Repository navigation
v0.18.0 — a PPR shell HIT never runs a handler; nested cache() keys compose; identity reads guarded in theme and loader caches
Breaking: a nested cache() keys its records within the enclosing cache()'s key() partition (#983)
A cache() nested in a keyed cache() ignored the outer key(): the
innermost boundary alone decided the record key (#970). An inner cache()
without key stored under the default key, and an inner key() dropped the
outer one, so a visitor in one partition received what a visitor in another
partition had cached under the inner boundary. With PPR, the shell partition
follows the record key, so the shell was shared the same way.
A key() now partitions every record under its cache(). The record key is
a list of parts, outermost first, each URI-encoded and joined by |:
- An inner
cache()withoutkey: the enclosingkey()results, then its
own default key (its store'skeyGeneratorresult when the store has one,
else the default key). Its routes keep their own records, as before, and
gain the partition. - An inner
cache()with its ownkey: the enclosingkey()results, then
its own (tier%3Agold|v%3Aa). Two partitions never share an inner record,
and the inner key still splits within a partition. - Deeper nesting composes the same way.
- Unchanged: a single keyed
cache()keeps its rawkey()result, a
cache()with nokeyanywhere above it keeps its default key, and
cache(false)caches nothing. - A response route's entry is keyed the same way. A
pprroute's shell and
its client navigation replay are partitioned by thekey()results (the
shell key already carries the URL). Eachkey()still runs once per
request.
cache({ ttl: 300, key: (ctx) => `tier:${tierOf(ctx)}` }, () => [
layout(TierLayout, () => [
cache({ ttl: 60 }, () => [
path("/pricing", PricingPage),
path("/faq", FaqPage),
]),
]),
]);
// Before: the /pricing record was keyed "doc:<host>/pricing" for every tier;
// a silver visitor could HIT the record a gold visitor's request wrote.
// After: it is keyed "tier%3Agold|doc%3A<host>%2Fpricing" (or silver's);
// each tier renders its own, and /faq keeps its own record.The breaking parts:
- Records under a nested
cache()in a keyed one move to new keys, so they
miss once after deploy. - A prerendered
pprroute under acache()withoutkey, nested in a
keyed one, was unpartitioned and served its build-time shell. It is now
partitioned by the outerkey(), so it stops serving the build shell:
each partition captures its own at runtime, and the once-per-route
build-shell warning fires.
Migration: to share an inner cache across partitions on purpose, move it
outside the keyed cache(); to keep serving a route's build shell, move the
route out from under the keyed cache(). In tests, shellCacheKey(url, searchParams, partition) takes the nested key() results as an array, outermost first,
and composes them the way the router does. Composed keys never collide with
each other, but a single key() result is stored as returned: don't return
raw request input from key() (prefix, normalize or encode it), or a value
containing | can name another partition's record
(#975).
Breaking: reading the visitor's theme on a ppr or cache() route refuses the capture or throws, like cookies() (#979)
ctx.theme in a handler, ctx.theme in middleware and
getRequestContext().theme are the visitor's theme cookie, but they were
plain reads. A ppr shell, a route cache() entry or a "use cache" entry
stored the first visitor's theme and served it to every later visitor until
the entry expired (#971). The shell also stored the capturing visitor's theme
as its initialTheme, so on a HIT a visitor with no stored theme kept that
visitor's useTheme().theme for the life of the page.
The reads are now guarded like cookies(), with the same exemptions:
- On a
pprroute, a handler that reads the theme has its shell capture
refused (warned once per URL). Every request stays an
x-rango-shell: MISSrendered with that visitor's theme. - Inside a
cache()boundary or a"use cache"function the read throws. A
cache()route that reads it answers 500 where it used to answer 200. - The shell's
initialThemeis the no-cookie default (defaultTheme),
whoever captured it. A visitor with a stored theme gets it pre-paint from
the theme script and inuseTheme()after hydration. - The theme getters are read-only and non-enumerable.
{ ...ctx }and
Object.assign({}, ctx)no longer carrytheme, andctx.theme = xthrows
(it is typedreadonly). Readctx.themedirectly. - A fetchable loader's ctx is a spread of the request context, so an untyped
JS fetchable loader no longer seesctx.themeat runtime (LoaderContext
never typed it). ReadgetRequestContext().themeor the cookie instead. ctx.themeis read when it is accessed, so afterctx.setTheme()in the
same request it returns the new theme, as the middleware ctx already did.
Read the theme where it is per request: useTheme() in a client component,
or a live loader (no ssr: false).
// Before: the first visitor's theme was stored in the shell and served to all.
path("/settings", (ctx) => <SettingsPage theme={ctx.theme} />, { ppr: true });
// After: the page reads the theme on the client.
path("/settings", () => <SettingsPage />, { ppr: true });// settings-page.tsx
"use client";
import { useTheme } from "@rangojs/router/theme";
export function SettingsPage() {
const { theme } = useTheme();
return <p>{`theme: ${theme}`}</p>;
}A server-side read moves into a live loader, read under loading():
createLoader(async () => cookies().get("theme")?.value ?? "system") (the
cookie name is storageKey).
Breaking: a loader's own cache() fails the fill and stores nothing when its body reads cookies()/headers() without a key() (#982)
A loader bound with its own cache() (loader(Loader, () => [cache({...})]))
stores its value under loader, host, path and params. Nothing in that key
names the user, and an enclosing route cache() key does not partition it.
A body that read cookies() stored the first visitor's session in the entry
and served it to every later visitor for the TTL, with nothing thrown or
logged (#972).
On a miss, the loader now fails, and nothing is stored, when its execution
read cookies(), headers() or a non-cacheable variable
(createVar({ cache: false }), or a value written with
ctx.set(..., { cache: false })), unless the binding declares identity: a
key() on the loader's cache(), or a keyGenerator on its store. The check
tests only that one of them is there, so it must itself include the value. The
loader fails the same way whoever ran the body: the binding, or a reader that
started the loader first (a parent layout's handler calling ctx.use()). It
also fails when the body reads another loader with ctx.use() that read them,
including a keyed cached loader served from its own cache: that entry keeps
the read its miss made. A stale entry's background refresh fails the same way
(reported to onError; the stale entry keeps serving). A read that settles
after the value, in a promise inside it or in a handle push that settles
within the 5 s handle-encode timeout, can't fail what was already served: that
user gets their own value, nothing is stored, and onError gets the error. A
push still pending after the timeout drops the entry's handles; the value is
stored without them. A cookie write alone (cookies().set()) is not a read. A theme read
(getRequestContext().theme, or a handler or middleware ctx.theme read
inside a loader body) counts like a cookies() read. A loader without
its own cache() is unchanged, under a route cache() too.
const SessionLoader = createLoader(async () => ({
session: cookies().get("session")?.value,
}));
// Before: user B received user A's { session: "a" } until the TTL ran out.
// After: the miss fails —
// cookies() cannot be called inside loader "…#SessionLoader", whose own
// cache() has no key(). …
loader(SessionLoader, () => [cache({ ttl: 60 })]);Migration: add a key() that includes what the body reads, or drop the
loader's cache() if the value must stay per request.
loader(SessionLoader, () => [
cache({
ttl: 60,
key: () => `session:${cookies().get("session")?.value ?? "anon"}`,
}),
]);Entries stored before the upgrade are not checked: a HIT skips the body. In a
store that versions its keys per deploy (CFCacheStore's build version) they
are gone after the deploy. In one that doesn't (VercelCacheStore without
options.version, a pinned version, a shared external store), a leaked entry
keeps serving through its TTL and SWR window, because a failed refresh keeps
the stale entry. The same store also keeps keyed entries written before the
upgrade without the identity mark, so an unkeyed cached loader that reads one
on a HIT or stale hit fills without error, until that keyed entry is rewritten
(its refresh stores the mark), and serves that value through its own TTL and
SWR window. On such a store, when an unkeyed cached loader reads a keyed one,
bump the store's version or updateTag() the affected tags after the
upgrade.
Breaking: a route cache() entry is tagged by what its content recorded, so updateTag() evicts shells and documents built from it (#965)
On a ppr route with its own cache(), the shell capture replays the route's
cache() entry instead of rendering again, so no covered handler or server
component runs. The entry carried only its cache({ tags }), so the shell
was stored without the tags those components recorded with cacheTag() or a
"use cache" read. updateTag() of such a tag then left the page on
x-rango-shell: HIT until the shell's ttl+swr, even after the awaited call
resolved (#957). A document-cache entry stored while the cache() entry was a
HIT lost the same tags.
A route cache() entry now stores the tags its content recorded when it was
written: render-callable cacheTag() calls and "use cache" reads, in the
handlers, server components and handle values it covers, loading() subtrees
included (a HIT replays their output too). A loader's tags reach the entry only when a
handler consumes its value with ctx.use(), whoever started the loader; a
loader nobody reads on the server (a useLoader() under loading()) runs
per request and stays off. The entry records its tags again on every HIT, so
the shell or document built from the HIT carries them.
The breaking part is that those tags also invalidate the cache() entry.
Before, only cache({ tags }) did, and after updateTag() the entry kept
replaying the output rendered before the mutation until it expired.
function CampaignLayout() {
cacheTag("campaign"); // render-callable: no "use cache" around it
return <Banner />;
}
layout(CampaignLayout, () => [
cache({ ttl: 600 }, () => [path("/sale", SalePage, { ppr: true })]),
]);
await updateTag("campaign");
// Before: the /sale shell captured from the cache() entry stayed a HIT, and
// the entry kept the old banner, until ttl+swr.
// After: shell and entry are dropped; the next request renders fresh.Migration: none is needed for correctness. Suppose a tag inside a cached
subtree is meant for the document only, and it changes often enough that
re-rendering the entry costs too much. On a route without ppr, move the
cacheTag() to a layout above the cache(): that layout re-runs on every
HIT and stays out of the entry.
CFCacheStore.set() now resolves once its Cache API write lands. Before, it
resolved as soon as the write was scheduled. The PPR capture waits for the
page render's cache() write before reading it, so it replays the entry
instead of rendering the page a second time. In the production run of
tests/cloudflare-basic ppr-tag-eviction (10 repeats, 60 captures), captures
that re-rendered went from 30 to 0.
Breaking: a PPR shell HIT never runs a route handler; everything a handler produces is baked into the shell (#969)
A ppr route's shell HIT used to re-run the route's handlers in several
cases: a handler that awaited a loader (ctx.use(Loader)), a handle push with
a promise nested in its value, a transition({ when }) predicate the
pre-handler gate had not evaluated, a route cache()
whose own tier missed or used key(), a store keyGenerator, a build-time
shell, and a capture whose segment record did not settle in time. The re-run
cost the handlers' work on every HIT, and its output replaced the captured
output in the page payload, so the payload could disagree with the HTML the
shell had frozen. A handler promise passed to a component under <Suspense>,
or an async server component, was a hole only when it lost the capture's
timing race, so whether it streamed per request or froze depended on how fast
it was.
The capture now waits for everything a handler produces, within
ppr.captureTimeout, stores it as the shell's segment record, and renders the
shell from that record. Every HIT replays the record; only middleware and
loaders run (not a promise-free ssr: false loader, below). A value you want
fresh per request belongs in a live loader.
layout(async (ctx) => {
const reviews = fetchReviews(ctx.params.id); // not awaited
return (
<Suspense fallback={<Spinner />}>
<Reviews data={reviews} />
</Suspense>
);
});
// Before: when fetchReviews was slower than the capture's quiet window, the
// shell froze the spinner and every HIT fetched and streamed the reviews.
// After: the capture waits for fetchReviews; the reviews are in the shell and
// every HIT serves them until the shell expires or its tags are invalidated.Migration: everything a handler produces is shell material, as it is under
cache(): a promise it passes to a component under <Suspense>, an async
server component, a nested promise in a handle it pushes, and a loader it
awaits are all awaited at capture (bounded by ppr.captureTimeout) and served
frozen for the shell's lifetime. A HIT never runs a handler. To keep a value
fresh per request, load it in a loader without ssr: false and read it with
useLoader under loading() or an inline <Suspense>; inside an
ssr: false loader, return that part as a nested promise. Request-scoped
reads the capture now waits for refuse it: cookies(), headers(), a
{ cache: false } variable, and ctx.dynamic() inside a handler promise, an
async component, a handle push, or a loader a handler awaits. A normal
ctx.get() value is not guarded: shell material is shared per host, URL and
request partition, so a variable your middleware sets per visitor (a tenant,
a locale, a user) and a handler reads is captured once and served to every
visitor of that URL in that partition. Declare such a variable with
createVar({ cache: false }) to make the capture refuse instead, or
partition the route by it (below). cache(false), or a condition() that returns false, on a
ppr route now renders the request like a cache miss (no shell, no
x-rango-shell header, no capture); before, the document still served shell
HITs and only the handlers the HIT re-ran saw the opt-out.
Shells are partitioned by the same request keys that partition the route's
cache() record: the route's cache({ key }), or the store's
keyGenerator. Each partition captures, serves and replays its own shell;
a visitor is never served another partition's. A route with neither keeps
one shell per host, path and filtered search.
cache(
{ ttl: 300, key: (ctx) => `tier:${ctx.request.headers.get("x-tier")}` },
() => [path("/pricing", PricingPage, { ppr: true })],
);
// Before: one /pricing shell; a HIT replayed whichever tier captured it.
// After: /pricing has a shell per tier; gold and silver each capture their own.A route's key() runs once per request: the shell read, the record lookup
(document or client navigation) and the capture share its result, so a
key() that reads cookies() works on a ppr route (the capture used to
run it again and its guard refused the capture). A store keyGenerator runs
once per default key it is given; one that returns the default key unchanged
partitions nothing. A key() or keyGenerator that throws serves no shell
for that request, like the record path renders uncached. A build-time shell
is never served for a partitioned route: the build captured one partition.
Keep partition values to a small, known set (normalize a header or cookie to
the tiers or locales you serve before returning it): every distinct value
captures and stores a shell of its own. Query strings multiply shells the same
way, since the shell key includes the sorted search: a product page reached as
?color=red&size=m and ?color=blue&size=m has two shells, and an appended
tracking param mints another. createRouter({ cache: { searchParams } })
drops params from the key, shell included (for example
{ exclude: TRACKING_SEARCH_PARAMS }); exclude only a param the shell does
not render from.
A unit test pins both through serveShellRequest
(@rangojs/router/testing/flight): count a handler's runs across a MISS and
the HITs after it (they stay put while a loader under loading() runs on
every HIT), and serve each partition with its own request headers; the
result's key is the key production resolved, partition included.
Also changed:
@rangojs/router/testing:shellCacheKey(url, searchParams, partition)
takes the request partition (what the route'skey()or the store's
keyGeneratorreturns) for a partitioned route, and appends it
URI-encoded, as the serve path does.@rangojs/router/testing/flight:serveShellRequest'skey, and so
readEntry(), is the shell key the serve path resolved for the document,
request partition included.@rangojs/router/cache: theShellCacheEntryfieldshandlerLiveHoles
andtransitionWhenare removed. No HIT runs a handler, so no entry marks
itself as needing one; a custom store that set or read them can drop them.@rangojs/router/testing: thePprReplayBypassReasonvalues
"handler-live-holes"and"transition-when"are removed; a partial
navigation no longer reports them.- A HIT whose segment record cannot be read has already sent the shell and
cannot render the rest without running handlers. It ends the response with
a script that reloads the page once with a_rsc_shell=missquery marker;
a request carrying the marker renders like a cache miss (no shell, no
capture), so the reload cannot repeat. The router drops the marker from the
request before anything reads it, so middleware, handlers, loaders, cache
keys anduseSearchParamssee the URL the visitor asked for, and the
browser drops it from the address bar before the page hydrates. When the entry is broken (a record
that fails to decode, a snapshot that lacks it or does not parse) the HIT
also replaces the entry and schedules a recapture; when aCFCacheStore
snapshot read was only slow (over max(kvReadTimeoutMs, 1 s) on the
document read) the entry stays. Before, the HIT re-ran the handlers. - A shell never outlives the route
cache()entry it was captured from: it
stays fresh no longer than that entry does and is served no longer than
that entry can be (its ttl, and its ttl + swr, are capped to what the entry
has left, in whole seconds). An entry already in itsswrwindow gives a
shell that is stale from the start, served while it recaptures; each
isolate recaptures a stale shell at most once per second. A capture that
outlives the entry it read retries once with a fresh render. No shell is
stored when that retry's entry runs out too, when the capture budget
leaves no time to retry, or when the capture wrote the entry itself and it
ran out before the store; the URL then backs off, and a warning with the
entry'sttlandswris logged once per route. Before, a
document request could replay the capture's handler output for the whole
shell ttl after the route'scache()entry had expired or refreshed, while
a client navigation of the same URL read the newer entry. In dev, an
explicitppr.ttl/ppr.swrthat the entry reduces warns once per route and
states what the shell is stored with. A custom store'sputShellreceives
a capped window in whole seconds; its ttl is 0 for a shell that is stale
from the start. CFCacheStorewith KV writes every PPR shell to KV. A shell whose lifetime
is under KV's 60-secondexpirationTtlminimum is written with that
minimum, and its reads still expire it at its own deadline. Before, such a
shell (apprttl + swr under 60 s, or one capped by a short route
cache()) stayed in the Cache API of the colo that captured it.- A request whose route is partitioned (
cache({ key })or a store
keyGenerator) does not serve the route's build-time shell; when the route
has one, a once-per-route warning says so. AkeyGeneratorthat returns the
default key unchanged partitions nothing and keeps it. - A capture that runs out of
ppr.captureTimeoutis not retried in place: its
handlers may still be running. The no-shell warning names the cause. ppr.maxSnapshotBytesbounds only the loader data the snapshot pins; the
segment record is always kept. A capture whose whole entry, measured as the
store writes it (prelude, snapshot, and a head carrying the postponed state
and tags), exceeds the store's value limit is refused with a warning:
25 MiB by default (Cloudflare KV), three quarters ofmaxItemByteson
VercelCacheStore.- A capture that produced no segment record is refused instead of stored.
ppr.captureTimeoutalso bounds the capture's match, at runtime and at
build time: handlers, and the loaders they await, that have not returned by
the deadline store no shell (the warning names them). Before, the deadline
started after the match.- A shell's tags are its segment record's (what the handlers, the server
components they render and the loaders they consume recorded, #965), its
bake-lane loaders' andppr.tags, so a capture that renders the page and
one that replays the route'scache()record store the same set. A tag
recorded on the request outside what the shell renders no longer tags it. debugShellCaptureevents carryrecordSettleMs(record=) and
entryBytes(entry=);snapshotBytescounts the loader pins only.ShellCaptureDebugEvent["outcome"]gains"expired": an attempt whose
routecache()entry ran out before it could store the shell (retried
once in place), and"skip-stored": a document MISS skipped its capture
because another request's capture stored the shell while the MISS
rendered. Before, that MISS captured the page again, and the new store
dropped the isolate's memo of the shell stored a moment earlier, so the
next HIT read the store instead. Aswitchover the outcome that checks
exhaustiveness needs cases for both.
Measured with vite preview against the previous commit: test-app
/shell-cache captured in 288 ms instead of 1,182 ms and reached its first HIT
in 2.3 s instead of 3.6 s (its segment record used to miss the capture's 1 s
write window, so the entry stored none and every HIT re-ran the handlers);
/shell-cache/baked-only reached its first HIT in 0.5 s instead of 2.5 s. On
tests/cloudflare-basic the stored /ppr-large entry is the same 1,647,731
bytes and its HIT's first byte on the #941 edge-latency harness is unchanged
(8.3-8.5 ms untagged, 9.4-9.6 ms tagged).
Breaking: on a ppr route, a loader a handler awaits cannot read cookies() or headers() (#969)
A small, intended limit that follows from the change above. Handler output is
baked by design, and a loader a handler awaits (await ctx.use(Loader)) is
part of it: it runs at capture, and every HIT serves the captured value. It
used to be exempt from the capture guard because the handler re-ran on every
HIT; with no handler on a HIT, an identity read there would show the capturing
user to every visitor, so the capture now refuses it. Awaiting a loader from a
handler is not the common path (components read loaders with useLoader),
and cache() is unchanged: on a route without ppr, such a loader may still
read cookies() and headers().
Symptom: the route answers x-rango-shell: MISS on every request, and the
server logs, once per key:
[rango] Shell capture for "<key>" was refused: the loader "<loader id>" called cookies() during capture; request-scoped data must not bake into the shared shell. Read it in a loader without ssr: false and consume it with useLoader under loading() or an inline <Suspense> (a live hole). …
Fix: register the loader on the route with loading() (the live lane, run on
every request) and read it with useLoader in a client component instead of
awaiting it in the handler:
path("/account", AccountPage, { name: "account", ppr: true }, () => [
loader(UserLoader), // reads cookies(); live, fresh on every HIT
loading(<AccountSkeleton />),
]);When the value has a small, known set of values (a tier, a locale),
partitioning the route with cache({ key }) also works: key() may read
cookies() or headers() and selects which shell a visitor gets. The loader
itself still must not call them; hand it the value through a variable your
middleware sets.
Breaking: a promise-free ssr: false loader does not run on a PPR shell HIT (#969)
A HIT is rendered from the shell, so live values are meaningless there, the
same reason no handler runs. A bake-lane (ssr: false) loader whose return
held no promises used to run on every HIT anyway, in the background, and its
result was discarded. Now it does not run: the container pinned at capture is
served.
A client navigation that replays the shell (x-rango-ppr-replay: HIT) now
matches the document HIT. Before, it ran every loader and served its fresh
values. Now a promise-free ssr: false loader is served from its pin without
running, and one whose return holds promises runs with its baked parts pinned
over the fresh result, including an ssr: false loader on an entry that also
has loading().
path("/product/:id", ProductPage, { name: "product", ppr: true }, () => [
loader(ProductLoader, { ssr: false }), // returns { name, description }
]);
// Before: ProductLoader's body ran on every HIT; its result was discarded.
// After: it runs at capture only; every HIT serves the captured container.Side effects in such a loader's body (logging, counters, writes) now happen
once per capture instead of once per request; move per-request work into
middleware or a live loader. Its own cache() is read at capture only, so a
recapture reads through it. A loader whose return holds promises is unchanged:
it runs on every HIT, because only its body can create the live promises, and
its baked parts are overlaid on the fresh result.
The settled handle pushes of the loaders a bake-lane loader awaits with
ctx.use(), and those a loader's own cache() entry replays at capture, are
now recorded with the shell too, so they still appear, once, on a HIT that
runs neither. A push the capture cannot record (a deferred push, or one
holding a promise) keeps those loaders running on each HIT, in the
background, as before.
Breaking: a loader's own cache() entry is tagged by what its body recorded, so updateTag() drops it (#968)
A loader bound with its own cache() (loader(Loader, () => [cache({...})]))
stored only its cache({ tags }). On a HIT its body does not run, so the tags
the body recorded were lost for that request: cacheTag() calls, and the tags
of "use cache" reads inside it. updateTag() of such a tag left the loader
serving its old value until ttl+swr. A route cache() record, PPR shell or
document written over the HIT lacked the tag too, so the fix above (#965)
could not evict them either.
The entry now stores the tags its body recorded when it was written: its
cacheTag() calls, its "use cache" reads, render-time tags of server
components in its value or handle pushes, and the tags the loaders it reads
with ctx.use() record, including a loader the route or a layout also binds,
or the handler read first. A loader it reads that has its own cache() also
brings its cache({ tags }) and, on a HIT, its stored tags. Every HIT records
them again, so a record, shell or document built over the HIT carries them, and
so does a loader that reads this one. A stale HIT's background refresh stores
the refreshed body's tags, not the stale entry's, and runs the loaders it reads
again rather than reusing the page's stale copies.
The breaking part is that those tags now invalidate the loader's entry.
Before, only cache({ tags }) did.
export const ProductLoader = createLoader(async (ctx) => {
cacheTag(`product-${ctx.params.id}`);
return db.product(ctx.params.id);
});
loader(ProductLoader, () => [cache({ ttl: 600 })]);
await updateTag("product-42");
// Before: /product/42 kept the old product until the entry's ttl.
// After: the entry is dropped; the next request runs the loader.Migration: none is needed for correctness. If a tag the body records is meant
for the page only, and dropping the loader's entry with it costs too much, call
cacheTag() in the handler that reads the loader instead of in the loader
body. An entry written before this release carries only its cache({ tags })
until it expires or is rewritten. One rare case keeps a loader's
cache({ tags }) off a reader's entry: a cached loader reads another loader
before that loader's own cache() binding starts (a cached layout loader
reading a route's cached loader) and writes its entry first. It still stores
the tags that loader's body records; add cacheTag() for the config tags in
that loader's body if the reader must drop with them.
Added: runLoader() runs a loader through its own cache() (#968)
runLoader() and runLoaderResult() take a cache option: the options a
route binds the loader with (loader(Loader, () => [cache({...})])). The call
goes through the production loader-cache read-through against cache.store or
cacheStore, so a HIT returns the stored value without running the body, and
updateTag() of a tag the body recorded drops the entry. Use it in the
react-server project (real Flight); the write is a background task the call
does not await.
const load = () =>
runLoader(ProductLoader, {
params: { id: "42" },
cacheStore,
cache: { ttl: 600 },
});
await load(); // MISS: the body runs
await load(); // HIT, once the write landed: the body does not run
await runInRequestContext(() => updateTag("product-42"), { cacheStore });
await load(); // MISS againBreaking: a PPR hole reads the current "use cache" entry, not the shell's captured copy (#958)
When a ppr route's shell and one of its live holes (a loader under
loading() or an inline <Suspense>) read the same "use cache" entry,
a shell HIT used to hand the hole the copy frozen in the shell's capture
snapshot, so the hole matched the shell until the shell expired. The hole now
reads the entry from the cache store. It is still cached under its own
profile (ttl, swr, tags) and does not re-execute on every request; once the
entry expires or is invalidated and refreshes, the hole shows the refreshed
value while the shell keeps the value it was captured with. Nothing changes
for an entry a loader also read during the capture (an ssr: false loader,
or a loader a handler awaits): the hole keeps the captured value for it too.
async function getStock(sku: string) {
"use cache: short"; // ttl 60
return fetchStock(sku);
}
// The layout (shell) and the loader under loading() (hole) both read it.
// Before: after the entry refreshed, the hole still showed the captured stock.
// After: the hole shows the refreshed stock; the shell keeps the captured one.Migration: if the hole must show the shell's value, pass it down from the
shell (a prop, or a handle the hole reads) instead of reading the entry again
in the live loader.
A PPR shell entry stores only what a HIT reads (#958)
A shell entry's capture snapshot held every cache read the capture made. When
every HIT replays the handlers' output from the captured segment record, the
"use cache" items the handlers read to produce it are never read again; the
capture no longer stores them. A navigation-only entry (captured for partial
navigations) stores only its segment records. On tests/cloudflare-basic
/ppr-large the stored entry went from 3,272,863 to 1,647,811 bytes (snapshot
2,643,864 to 1,018,799), which a HIT reads and parses after its first byte. In
dev the same snapshot was 16,960,162 bytes, over the 8 MiB maxSnapshotBytes
cap, so it was not stored at all; it now is (1,374,662 bytes). Under
debugPerformance the shell tail line and ppr-tail row show what was
dropped next to what was kept (records=segment:1 pruned=item:5).
Added: a tag-marker memo for PPR shell reads, and a fresh-reads cookie after updateTag()/revalidateTag() (#960)
A tagged PPR shell HIT waited for its tag-invalidation markers before its
first byte: on CFCacheStore a KV read that started once the entry's head
named the tags, on VercelCacheStore a runtime-cache read after the entry
read. Both stores now remember which tags each shell carried (and take a
route's ppr.tags as known up front), start those marker reads alongside the
entry read, and keep marker values per isolate, stale-while-revalidate: a
value younger than memo.markerFreshMs (default 1000 on CFCacheStore, 300
on VercelCacheStore) is used as is, one younger than memo.markerMaxStaleMs
(default 10000 and 2000) is used while a background read refreshes it, and an
older one waits for the store read. { markerFreshMs: 0 } turns the value
memo off. With the #941 edge latencies injected, a tagged shell's first byte
on CFCacheStore is 0.1 ms instead of 10.5 ms on a shell memo hit and 8.3 ms
instead of 17.5 ms on a store read; on VercelCacheStore, with a 6 ms
runtime-cache read modeled, 0.1 ms instead of 7.0 ms and 10.8 ms instead of
18.5 ms. Only PPR shell reads use the value memo: cache(), "use cache"
and response entries, and the shell write gate, keep reading their markers.
A mutation still gets correct reads. The request that runs updateTag() or
revalidateTag() reads its own writes, the isolate that runs it writes the
new marker into its memo, and its response sets the fresh-reads cookie:
<state cookie prefix>-fresh (rango-state-fresh by default, one per prefix,
so every router on the host that shares the prefix honors it), HttpOnly,
SameSite=Lax, Path=/, Secure on https, with a Max-Age of the longest
time the stores' memos can be stale plus 1 s (11 s for CFCacheStore with
KV, 3 s for VercelCacheStore and for CFCacheStore without KV). The same
user's requests that carry it skip both memos on every isolate. That closes,
for the mutating user, the two cases the shell memo left open:
CFCacheStore without KV in purge mode, and VercelCacheStore in another
region. Other users can get a shell whose memoized marker predates an
invalidation for up to markerMaxStaleMs, on top of the platform's own
consistency (KV propagates across locations in up to about 60 s); set
{ shellMs: 0, markerFreshMs: 0 } where every user's next request must see an invalidation.
A store with both memos off (for example MemorySegmentCacheStore alone)
sets no cookie. The cookie is lost when updateTag()/revalidateTag() runs
after the response headers were sent (from a streaming loader or render; dev
warns). Any client can send the cookie; it only makes that client's own
requests read the store, the cost of memos off.
A response whose request called updateTag() or revalidateTag() now
carries that Set-Cookie when an invalidated store keeps memos, which keeps
it out of the document cache, a response route's cache(), and CDNs that
do not cache responses with cookies (Cloudflare's among them). Under debugPerformance
the ppr:shell-marker row adds memo=fresh|stale|read|bypass,
hint=<hinted>/<tags>, lead= (how long the hinted marker reads ran before
the entry named its tags), and fresh-reads when the cookie was present.
Added: a per-isolate PPR shell memo in CFCacheStore and VercelCacheStore (#959)
A warm isolate serving the same PPR shell repeatedly re-read and re-parsed the
stored entry on every HIT. Both stores now keep the last fresh read of each
shell for memo.shellMs (default 2000; { shellMs: 0 } turns it off) under a
memo.shellMaxBytes cap (default 16 MiB, least recently used evicted), and
serve the next HITs from memory. On a storefront-sized shell a CFCacheStore
memo hit's first byte is 0.2 ms instead of 8.3 ms (untagged) and 10.2 ms
instead of 17.3 ms (tagged) with Cloudflare's measured I/O latencies injected;
a VercelCacheStore memo hit is 0.1 ms instead of 10.8 ms and 6.9 ms instead
of 17.9 ms with a 6 ms runtime-cache read modeled. Under debugPerformance
both stores report the read as ppr:shell-read (hit memo) with a
ppr:shell-memo row (hit or miss, and the memo's size), and a
VercelCacheStore HIT now reports its store read and tag-marker read too.
VercelCacheReadOutcome, the outcome the VercelCacheStore debug option
receives, gains "memo-hit".
The tag-marker check still runs on every HIT, and the isolate that runs
updateTag()/revalidateTag() drops its own memoized copies, so neither that
request nor a later one on the same isolate gets the invalidated shell. With
KV bound, a CFCacheStore memo in any isolate rejects it once that isolate's
marker read sees the invalidation (see the tag-marker memo above). What
another isolate can still serve for up to one window: the previous
capture of a key another isolate just recaptured; for CFCacheStore without
KV in purge mode, a purged shell (the purge reaches the stored entry, not
other isolates' memos), the mutating user's next request included when it
lands on another isolate; for VercelCacheStore, a shell invalidated from
another region (the tag markers are a regional cache.set; without the memo,
expireTag removed the entry everywhere within about 300 ms) or by a platform
expireTag issued outside rango. The fresh-reads cookie (above) sends the
mutating user's next requests past the memo; an app where every user's next
request must see the invalidation sets { shellMs: 0, markerFreshMs: 0 } (with
KV, CFCacheStore's marker memo serves other users an invalidated shell for
up to markerMaxStaleMs even with the shell memo off).
VercelCacheStore keeps one memo per cache handle: create the
getCache() handle once per process, as the updated examples do, or the
memo never hits.
Added: a PPR shell HIT broken into debugPerformance rows (#955)
Under debugPerformance, a PPR shell HIT reported one ppr:shell-read row.
With CFCacheStore, that read is now broken into ppr:shell-match,
ppr:shell-head and ppr:shell-prelude (with byte counts),
ppr:shell-marker (the tag-marker read and how long the commit waited on it),
and, for a KV hit after a Cache API miss, a ppr:shell-l1-miss row first.
Every store then shows ppr:shell-open (the integrity check and decode) and
ppr:shell-commit (chunk count and prelude bytes), both labelled cpu. The
work after the commit (snapshot read and parse, seed, tail) prints as one
[RSC Perf] … shell tail: line with snapshot bytes, records per family, and
CPU durations, and rides the next request's Server-Timing as ppr-tail, now
in production too when the HIT collected metrics. In production, nothing is
collected when debugPerformance is off.
Added: serveShellRequest(), a real PPR shell capture and HIT in unit tests (#966)
No @rangojs/router/testing primitive ran a ppr route's shell capture or
served a shell HIT, so what a HIT shows, when a shell recaptures, and what
updateTag() evicts could only be tested in e2e (#956).
serveShellRequest(router, url, options?) from
@rangojs/router/testing/flight serves one GET through the router's
production request handler, with its nonce, version, cache config and
middleware, and settles the request's background tasks before it resolves. The
first request for a ppr route is a MISS whose capture has stored the shell;
the next one with the same store is a HIT from it. partial serves a client
navigation and reports its x-rango-ppr-replay decision:
const cacheStore = new MemorySegmentCacheStore();
const miss = await serveShellRequest(router, "/product/1", { cacheStore });
// miss.shellStatus === "MISS"; await miss.readEntry() is the stored shell
const hit = await serveShellRequest(router, "/product/1", { cacheStore });
// hit.shellStatus === "HIT"
// hit.prelude: the shell as captured; hit.flight: this request's tailOnly the HTML step is stubbed, because react-dom/server does not load under
the react-server condition: the prelude is the Flight text the capture
rendered, and a HIT body is that prelude followed by the tail's Flight. Real
prelude HTML, its <body> sanity gate, SSR render errors, the capture
deadline's partial prelude, and fizz resume of the holes stay e2e.
resetShellTestState(), from the same entry, clears the PPR state a worker
keeps across requests and a test file keeps across tests: the capture's
backoff and stampede guard, and CFCacheStore's isolate memos. Call it in
beforeEach.
Fixes
- The request that calls
revalidateTag()reads its own writes. A server
action that calledrevalidateTag("x")and then rendered got the
pre-invalidation value of"use cache"andcache()entries taggedx:
onCFCacheStorewith KV until the KV marker write landed, and for the
rest of the request once a read had memoized the absent marker; on
VercelCacheStoreuntilexpireTaglanded; onMemorySegmentCacheStore
when the read started in the same tick, as a"use cache"call right after
revalidateTag()does.revalidateTag()now calls each store's
invalidateTags()before it returns, and each built-in store marks the
tags as invalidated for the rest of that request before its first await;
only the durable write (KV marker, tag purge,expireTag) stays in the
background. Other requests see the invalidation when that write lands, as
before. The mark only turns that request's hits into misses: if the
durable write fails, the request pays extra misses, not a stale read. This
also changes the failed-write case ofawait updateTag()onCFCacheStore
andVercelCacheStore: the call still rejects, and the rest of the request
now misses on the tags instead of reading the entries.
VercelCacheStorestamps tagged entries with their write time, so an entry
the request writes after its invalidation still hits (one written in the
same millisecond misses); entries written before this release count as
older. A customSegmentCacheStoregets the same behavior by recording the
invalidation in request-scoped state before the firstawaitof its
invalidateTags(). More same-request cases are fixed, for both verbs. A
"use cache"call made earlier in the request, whose store write was
still pending, was reused by the same call after the invalidation
(await getStock("beer"); revalidateTag("stock"); await getStock("beer")
returned"beer #1"twice), and withawait updateTag()its write landed
after the invalidation and was read back. A stale entry's background
refresh that started before the call wrote its pre-invalidation value
after it, for the rest of the request and for later requests. Now an
execution or refresh that started before one of its own tags was
invalidated is neither joined nor written, and concurrent calls after the
invalidation share one fresh execution. An execution with other tags, or
one that starts after the call in the same millisecond, still fills the
store. The mask also reaches contexts derived from the request (a PPR HIT
tail after an invalidation in middleware). On aCFCacheStorein purge
mode without KV, an L1 entry without the store's entry Cache-Tags (written
before they existed) was served after the request's own invalidation; it
now misses. Not covered, as before this release
(#977): a"use cache"
execution in another request, or a loader's owncache()in any request,
that started before the invalidation can still write its value after it,
on every built-in store, and later reads can get that value
(#981). - In dev, a PPR route whose
ssr: falseloader returns JSX now captures its
shell and HITs. Before, Flight logged
Attempted to render <…> without development properties, the capture
failed withTypeError: Cannot read properties of undefined (reading 'stack'),
and every request stayedx-rango-shell: MISS. The same applies to JSX in a
handle value pushed during a capture. A promise in the props of a host
element,<Suspense>or a client component (<p>{fetchStock()}</p>) is a
live hole and the rest of that JSX stays baked on every HIT. A server
component with a promise in its props
(reviews: <Reviews data={fetchReviews()} />) is a live hole as a whole: on
a HIT it renders from the fresh loader run. Before, in production too, the
shell stored that server component's output rendered with a placeholder in
place of the promise, and every HIT served it
(#967). - A PPR shell capture no longer stalls when an
ssr: falseloader value
reaches the same promise-holding object twice
({ summary: shared, card: { shared } }). The snapshot encode waited
forever on the capture's never-settling stand-in for that promise, so the
shell was never stored
(#967). - A loader body that reads a loader bound with its own
cache()
(await ctx.use(ProductLoader)) gets the binding's value, as a handler read
does. On a loader-cache HIT the read used to run the bound loader's body
again, so the reader saw a different value from the one the page rendered,
and the body ran twice in one request
(#968). - A
CFCacheStorePPR shell read whose memoized shell went stale keeps the
route'sppr.tagsin its tag-marker prefetch. When the isolate's tag-name
hints had evicted the key, the store read started those marker reads only
after it parsed the entry's head (#963). - A PPR shell HIT whose capture snapshot is already in memory
(MemorySegmentCacheStore, build-time shells, the new shell memo) no longer
starts the resumed tail's work before the prelude is written: the tail waits
one macrotask after the commit when the snapshot had already arrived (one
still arriving on I/O yields on its own). With the shell memo on local
workerd, a storefront-sized HIT's first byte over a trivial response went
from 5.9 ms to 1.3 ms (#959). - A PPR shell capture no longer renders the page's server components twice.
It rendered them once for the stored segment record and again for the page
payload the shell's HTML was frozen from, so uncached nondeterministic
server output (an async server component reading a counter or the clock)
could differ between the shell's HTML and the payload every HIT replays,
and hydration repaired the mismatch on every HIT. The capture now renders
the shell from the stored record. - A PPR shell HIT whose captured segment record fails to decode now schedules
a recapture. The HIT reportscache-corruptand degrades as described under
"a PPR shell HIT never runs a route handler" above; before, nothing replaced
the entry, so every HIT re-rendered the handlers until the shell expired
(#958). - A PPR shell capture no longer gives up while a client component in the
shell is still loading its module. The capture aborted a fixed number of
task turns after the page payload arrived, and a client component outside
any Suspense boundary could still be loading then, so the attempt stored
nothing and retried 400 ms later: about 400 ms more perPrerender+ppr
URL that loaded a client module first (withDEBUG=rango:prerender,
vite buildlogsshell capture attempt 1/2 for <url> produced no shell). On
tests/cloudflare-basic,/ppr-shell/prerendered/alphawent from 495-501 ms
to 112-119 ms,/ppr-shell/passthrough/bakedfrom 447-452 ms to 38-39 ms, and
the shell phase from 1329-1366 ms to 552-584 ms. A runtime capture in a cold
isolate had the same race. Once its payload settles, the capture now waits,
withinppr.captureTimeout, for the client module loads in flight in the
isolate, including loads another render requested; live loaders
and promises passed down from the server still postpone as holes
(#954). - A
vite buildthat rewrote a router'snamed-routes.gen.ts(the first
build after adding, renaming or removing a route, or any build that found
the file out of date) could skip every build-time PPR shell. The build-time
server that runs route discovery and the shell phase watched the project,
so the rewrite could reload its modules (when the router file's importers
end at an entry nothing imports, as in a Cloudflareworker.rsc.tsx) and
drop the route trie discovery had installed; the shell phase then matched
eachPrerender+pprURL in declaration order. A wildcard such as
path("/*")declared before the route won, every candidate logged
SHELL SKIP <url> - no router matched "<route>" (matched: <wildcard route>, ...),
and the routes fell back to capturing the shell on the first request after
deploy. The next build, with the file up to date, captured them. That
server no longer watches files
(#951). - A PPR shell HIT decodes its stored prelude once instead of twice, with the
runtime's nativeUint8Array.fromBase64where it exists (workerd: 0.05 ms
instead of 0.75 ms per decode of a 614 KB prelude), and enqueues it in 32 KB
chunks instead of one write. A streaming compressor handed one write
compresses all of it before its first output byte; with 32 KB chunks, Node's
zlib and workerd'sCompressionStreamemit their first bytes after one
chunk (measured locally; Cloudflare's edge compressor was not measured).
Partial navigation replay no longer decodes the document prelude it never
serves. The no-shell warning names an async server component rendered
without awaiting its data as a cause, and in dev it prints the component
stacks still pending when the capture froze the shell
(#952). CFCacheStorestores a PPR shell prelude-first: a head, the raw prelude
(no base64), then the capture snapshot, in both the Cache API and KV. A
shell HIT reads the head and the prelude, runs the tag-marker read alongside
the prelude read, and sends its first byte while the snapshot is still being
read; only the resumed tail waits for it. On a storefront-sized shell (614 KB
prelude, 2.5 MB snapshot) the first byte moved from 13.2 ms to 3.6 ms over a
trivial response on local workerd, and from 21.0 ms to 8.3 ms (29.8 ms to
17.5 ms when tagged) with Cloudflare's measured I/O latencies injected. The
publicgetShell/putShellcontract is unchanged. Shells move to a new key
namespace; existing entries are already misses after a deploy (buildVersion)
and age out. One failure mode changes: a truncated, corrupt, or slow
snapshot used to make the read a MISS; on the document read (slow meaning
over max(kvReadTimeoutMs, 1 s)) it is now a HIT whose resumed tail cannot
replay the segment record, so the page reloads once into a cache-miss
render (see "a PPR shell HIT never runs a route handler" above), and a
truncated or corrupt entry is also evicted, replaced and recaptured.
getShell(partial navigations, the testing helpers) keeps
kvReadTimeoutMsand misses
(#953).