Keep-alive
A route that exports keepAlive stays mounted after the visitor leaves it. Back and forward bring back the same page, not a new render of it: its scroll positions, form input, open <details>, focus and component state are where the visitor left them.
Keep a page
// doc-check: skip - a route module; the page component is the app's own.
// routes/orders/[id].tsx
export const keepAlive = true
export default function Order({ data }: { data: OrderData }) {
// ...
}Pass options instead of true to change how the page comes back:
import type { KeepAliveOptions } from "@nifrajs/web"
// Show the kept page as it was, without reloading its data; drop it after a minute hidden.
export const keepAlive: KeepAliveOptions = { revalidate: false, maxAge: 60_000 }keepAlive works with the React (19.2 or later, for <Activity>), Preact, Solid, Svelte and Vue adapters. A build with another adapter fails and names the routes that export it.
When a page is kept, and when it comes back
- A page is kept when the visitor leaves it by a link, a form or back and forward. A redirect or a
replacenavigation discards it, since its history entry is gone. - Only back or forward to the page's own history entry brings it back. A link to the same path renders a fresh page.
- A new path on the same route is a new page:
/orders/1and/orders/2are kept apart. A change of the query string alone keeps the page. - A tab keeps at most three pages, dropping the one shown longest ago, and none hidden longer than ten minutes, or the route's
maxAge. - Going back and then following a link drops the pages ahead of it, as the browser drops those history entries.
- A page left with a route open in a modal over it is kept with the modal. Back to that entry shows the page and opens the modal over it again.
What changes when a page stays mounted
- Code that runs when a page mounts runs once for that page, not on every visit. Back to a kept page does not mount it again; work that belongs to each visit goes in
onPageShow. React is the exception:<Activity>runs the page's effects again each time it is shown. - In Preact, Solid, Svelte and Vue a hidden page keeps running: its timers fire, its subscriptions deliver, its stores update. Pause what should not run unseen with
onPageHide. - State outlives the visit. Form input, a filter, an open panel are where the visitor left them; reset in
onPageShowwhat must start fresh. - Each kept page holds its DOM and its components' memory, at most three per tab. In development, the devtools overlay lists them with their path, how long each has been hidden and how many elements it renders.
Fresh data
A page comes back at once, as it was left, and reloads its data behind it. The reload is shared onto the data the page already holds: what did not change keeps its identity, so nothing below an unchanged value renders again, and form input in a list survives. Set revalidate: false to skip the reload; an action submitted while the page was hidden still reloads it.
A partially prerendered page that is kept stops its holes while it is hidden: a hole still loading is cancelled on the server, and a reload on its way is dropped. When the page comes back each hole reloads in place, its old content marked aria-busy="true" until the new arrives, with revalidate: false too. An interactive hole keeps its state through it, in React as well, where <Activity> cleans the page's effects up while it is hidden.
With live refresh, a hidden page is not watched. When it comes back, what changed while it was hidden reloads, whatever revalidate says, and a grant that expired in the meantime is loaded again.
While a page is hidden
A kept page is taken out of the document. It is absent from the accessibility tree, its media stops, and its ids never collide with the page on screen. It renders from its own route state, so it never sees another page's params.
In React, <Activity> cleans up the page's effects while it is hidden and runs them again when it is shown. usePageVisibility() reads which it is:
// doc-check: skip - a page component; Chart is the app's own.
import { usePageVisibility } from "@nifrajs/web-react/router"
export default function Dashboard() {
const visible = usePageVisibility()
return <Chart animate={visible} />
}A Preact, Solid, Svelte or Vue page keeps running while it is hidden. Pause timers, sockets and media with onPageHide and resume them with onPageShow, from each adapter's /router:
<script>
import { onMount } from "svelte"
import { onPageHide, onPageShow } from "@nifrajs/web-svelte/router"
let timer
const start = () => (timer = setInterval(refresh, 5000))
const stop = () => clearInterval(timer)
const offHide = onPageHide(stop)
const offShow = onPageShow(start)
// onMount runs in the browser only, so the server starts no timer.
onMount(() => {
start()
return () => {
stop()
offHide()
offShow()
}
})
</script><script setup lang="ts">
import { onPageHide, onPageShow } from "@nifrajs/web-vue/router"
import { onMounted, onUnmounted } from "vue"
let socket: Socket | undefined
onMounted(() => {
socket = connect()
})
onUnmounted(() => socket?.close())
onPageHide(() => socket?.pause())
onPageShow(() => socket?.resume())
</script>| Adapter | usePageVisibility() | onPageHide / onPageShow |
|---|---|---|
| React | a boolean | effects, which hiding cleans up |
| Preact | a boolean | hooks |
| Solid | an accessor | run in a component, removed with it |
| Svelte | a getter | run as the component initializes, return their removal |
| Vue | a ref | run in setup; onActivated / onDeactivated also run |
A useBlocker in a hidden page blocks nothing. Any code can listen for the nifra:pagehide and nifra:pageshow events, which bubble from the page. An island inside a kept page stays mounted with it; its enhancer pauses with onPageHide and onPageShow from @nifrajs/web/islands:
import { defineIsland, onPageHide, onPageShow } from "@nifrajs/web/islands"
export const clock = defineIsland((el) => {
const tick = () => {
el.textContent = new Date().toLocaleTimeString()
}
let timer = setInterval(tick, 1000)
const hide = onPageHide(el, () => clearInterval(timer))
const show = onPageShow(el, () => {
timer = setInterval(tick, 1000)
})
return () => {
hide()
show()
clearInterval(timer)
}
})The server components of a kept page stay mounted too. With bindComponentRouter installed, navigation leaves them alone while the page is hidden, and the reload of a page that comes back updates them.
CSS around a kept page
A kept page renders inside two <slot> elements, [data-nifra-entry] and [data-nifra-page]; so does a layout below which the routes' layouts differ. Browsers lay a <slot> out as display: contents, and the wrappers carry no inline style, so a strict style-src needs nothing for them. Vue, whose <KeepAlive> moves the page itself, renders neither. They take no box, but a selector that reached the page's root as a direct child, or by its position among siblings, stops matching:
/* Stops matching: the wrappers sit between main and the page's root */
main > section { padding: 2rem; }
/* Matches, kept or not */
main section { padding: 2rem; }
[data-nifra-page] > section { padding: 2rem; }In development, the first time a kept page is shown the client reads the page's same-origin stylesheets and reports each such selector once, as NIFRA_KEEPALIVE_DIRECT_CHILD_CSS.
Keep-alive and sign-in
A page kept for one visitor must never come back for the next. When a response signs a visitor in or out, the server also rotates a readable epoch cookie (__Host-nifra-epoch, or nifra-epoch on local http:) with the session cookie's lifetime. A tab that reads a new epoch drops every page it kept and tells the other tabs of the origin to do the same. A sign-out by a native form post, or a session that expires while the visitor is away, is covered the same way: back compares the epoch before it shows a kept page.
The session cookies of @nifrajs/auth and Auth.js count under their default names, and Better Auth's under any cookiePrefix. Name any other, such as one renamed through Better Auth's advanced.cookies, in identityCookies. nifra dev and nifra build read it from backend/framework.ts; a server you write passes the same object to createWebApp({ keepAlive }):
// backend/framework.ts
export const keepAlive = {
identityCookies: ["sid"], // your own session cookie
ignoreCookies: ["cart"], // HttpOnly, but says nothing about who is signed in
}In development, an HttpOnly cookie that changes and is in neither list is reported once, as NIFRA_KEEPALIVE_IDENTITY_COOKIE. A flow that changes who is signed in without changing a cookie rotates the epoch itself:
import { server } from "@nifrajs/core/server"
import { rotateIdentityEpoch } from "@nifrajs/web"
// A flow that changes who is signed in without changing a watched cookie.
export const api = server().post("/switch-account", (c) => {
rotateIdentityEpoch(c)
return { ok: true }
})When identity lives in client storage, as a bearer token, call identityChanged() at sign-in and sign-out:
import { identityChanged } from "@nifrajs/web/client"
// Identity in client storage: say so at sign-in and sign-out.
export function signOut(): void {
localStorage.removeItem("token")
identityChanged()
}A document the browser puts in its back/forward cache drops its kept pages first, so a cached document never carries them.
Limits
- A route with a
clientLoaderruns it again when the page comes back, even withrevalidate: false. - A hot update of a route module drops every kept page.