Skip to content

fix(router): a document served from a PPR shell hydrates with the handle data its shell was rendered from (#1035) - #1054

Merged
ivogt merged 12 commits into
mainfrom
fix/1035-shell-hit-handle-snapshot
Oct 4, 2026
Merged

ivogt merged 12 commits into
mainfrom
fix/1035-shell-hit-handle-snapshot

Conversation

@ivogt

@ivogt ivogt commented Oct 4, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #1035.

Problem

On a ppr route, a handle value that an ssr: false loader pushes as a promise broke hydration on every document served from the shell (x-rango-shell: HIT): React error #418 in production, "Hydration failed because the server rendered HTML didn't match the client" in dev. The first load of the page, before a shell existed, was clean.

The shell's HTML had an element for the push with nothing in it (<li>settled-note@g1</li><li></li>), and the HIT's data had the value in it or one row fewer, depending on timing.

Two more mismatches of the same kind were found on the way and are fixed here: a settled push by a loader without ssr: false, read in the static part of a shell page; and, on any document, a useHandle reader inside a loader's loading() boundary when that loader pushes after an await.

The rule

A promise pushed from a loader is live and is never in a shell. To have a value in the shell's HTML, push it settled (await it) from the ssr: false loader, or push it from the handler.

  1. A HIT hydrates with the record: metadata.handles is the shell record's handle data as restored, standing copies and placeholders alike.
  2. Everything this request's loaders do to the store afterwards (a live push, a placeholder replaced or dropped, a deferred push, a cache entry's replay) rides metadata.handlesLate, which the client applies after the root hydrates.
  3. The capture renders the prelude from what its record keeps.
  4. A useHandle reader that React is hydrating reads the handle data its HTML was rendered from, whenever its boundary hydrates, and moves on to the live data in its mount effect. This one holds on every document.

Root cause

It is not that the HIT awaits the loader. The loader's data is its pin, resolved at once, and the deferred push comes from a background executeLoaderData nobody awaits (loader-cache.ts:555-560 on ede1367f), which here hits the loader's own cache() entry and replays its pushes. Whether that replay is in the pre-hydration data was a race with the setTimeout(0) the document handle stream starts with (handle-store.ts:937): production on workerd and both modes on Node won it (text mismatch), dev on workerd lost it (one row fewer). Both fail, because the mismatch was made at capture:

  • The capture replays the loader's cache() entry too. The deferred push comes back as the thenable Flight decoded, whose then() returns nothing, so the push funnel's value.then(mask) stored undefined in the slot (shell-capture.ts:2051).
  • The record leaves that slot out (it is tagged), but the capture's own render read the whole store (full-payload.ts:56): an element for a push no HIT's record supplies.
  • When the body runs at capture instead (no cache(), or a cold entry) the promise is a real one and the element held the resolved value: a promise-shaped value baked into a shell every visitor shares.

Instrumented run, cloudflare-basic, production (dev is the same up to the last two lines, in the other order):

capture funnel: THENABLE loader push :: pushed=<thenable ctor=Promise status=fulfilled> value.then(mask) returned=undefined(undefined)
[ssr:captureShellHTML] SSR render reads handle data :: [string("settled-note@g1"), undefined(undefined)]
resolveLoaderData[replay]: PIN served, value resolved immediately :: holes=false runs=true -> executeLoaderData kicked in the BACKGROUND (not awaited)
HIT tail: router.match() returned -> buildFullPayload
store#3 stream(settled) first pulled :: [string("settled-note@g1")]
loader cache() entry HIT: body does NOT run, the entry's recorded pushes are replayed
store#3 push STORED v3 :: <thenable> kind=cache-entry-replay handlerBarrierResolved=true
store#3 streamLate YIELD v3
store#3 stream(settled) setTimeout(0) elapsed, first read :: v3 [string("settled-note@g1"), <thenable>]

Before and after

const Notes = createHandle<string>();

const Product = createLoader(async (ctx) => {
  const product = await db.product(ctx.params.id);
  ctx.use(Notes)(product.name);
  ctx.use(Notes)(db.stockNote(product.id)); // a promise: a deferred push
  return { product };
});

path("/product/:id", ProductPage, { name: "product", ppr: true }, () => [
  loader(Product, { ssr: false }, () => [cache({ ttl: 300 })]),
]);
Before After
HIT's HTML <li>Widget</li><li></li> <li>Widget</li>
HIT's pre-hydration handle data ["Widget", "3 left"] or ["Widget"], by timing ["Widget"]
After hydration ["Widget", "3 left"], after a hydration error ["Widget", "3 left"], no error
Document MISS, a route without ppr <li>Widget</li><li>3 left</li>, clean unchanged

For a value that has to be in the shell's HTML, one line changes:

ctx.use(Notes)(db.stockNote(id)); // a promise: live, after hydration on a HIT
ctx.use(Notes)(await db.stockNote(id)); // settled: baked into the shell

Path table

What the HTML was rendered from, what the client hydrates with, what arrives after. "record" is the shell record's handle data.

Path origin/main This branch
Document MISS HTML and hydration: every push before the handler barrier, a deferred one resolved. After: later pushes same
HIT, settled pushes only HTML, hydration: record. After: nothing same
HIT, deferred push, the loader's cache() entry warm at capture (#1035) HTML: record plus an empty element. Hydration: record, plus the value if the replay won the race. Fails either way HTML, hydration: record. After: record plus the value
HIT, deferred push, the body ran at capture (no cache(), or a cold entry) HTML: record plus the capture's value, baked. Hydration: record plus this run's value (@g2 next to @g1), or not HTML, hydration: record. After: record plus this run's value
HIT, a push that holds a promise HTML: the capture's value, promise masked. Hydration: this run's value, or not HTML, hydration: record (no element). After: this run's value
HIT, entry without loader pins (ppr.maxSnapshotBytes), the run pushes HTML: the capture's push. Hydration: the run's push. Handle rows mismatch HTML, hydration: the capture's push. After: the run's push in its place
The same, the run makes no push HTML: the capture's push. Hydration: none HTML, hydration: the capture's push. After: none
HIT, live-lane loader push, reader in the static part HTML: no element. Hydration: the push when made before the loader's first await (#418), else after HTML, hydration: record. After: the push
HIT, live-lane loader push, reader inside that loader's boundary (a hole) Push before the first await: in the hole's HTML and in the hydration data, clean. After an await: fails in that boundary Both after hydration, clean (rule 4)
Document MISS, the same reader, push after an await fails in that boundary clean (rule 4)
Client navigation that replays the shell one stream, the final state same
Document of a route without ppr as a MISS same

A HIT of a Prerender route with ppr freezes at the same point (read from the code, not probed).

The pinless row: that page still reports a hydration error, and it is the loader DATA, the documented drift of an entry without pins. Dev names the element: <p data-testid="push-value">, + pinned@g2 / - pinned@g1. The handle rows are not part of it. Recorded over time in both modes: ["pinned-note@g1"] in the HTML and when the root hydrated, ["pinned-note@g2"] after. expectPinlessHitKeepsRunPushWithRunData now asserts that.

Code change

Non-test source, against origin/main (6c93f7b0): 317 lines added, 37 removed, in 15 files. 210 / 28 of that is runtime, 107 / 9 is the two testing primitives. 143 of the 317 added lines are code, the rest comments (server/handle-store.ts: 66 added, 30 of them code).

Piece Files Added / removed
(i) the capture handles/deferred-resolution.ts, rsc/full-payload.ts, rsc/shell-capture.ts 49 / 8
(ii) the HIT server/handle-store.ts, rsc/rsc-rendering.ts 77 / 3
(iii) useHandle browser/event-controller.ts, browser/react/use-handle.ts, browser/react/subscribe-to-nothing.ts, browser/react/location-state.ts, browser/react/Link.tsx, browser/rsc-router.tsx, ssr/ssr-root.tsx 84 / 17
testing primitives testing/serve-shell-request.ts, testing/flight.entry.ts, testing/render-route.tsx 107 / 9

(i) The capture renders from what its record keeps (7037260d):

// handles/deferred-resolution.ts
export async function* resolvedHandleStream(handleStore, recordedOnly?) {
  let snapshot = {};
  for await (const data of handleStore.stream("settled")) snapshot = data;
  if (recordedOnly) snapshot = recordedHandleData(handleStore, snapshot);
  yield await resolveDeferredHandleValues(snapshot);
}
// rsc/full-payload.ts: both capture producers build their payload here
handles: resolvedHandleStream(handleStore, reqCtx._shellCaptureRun === true),
// rsc/shell-capture.ts: the funnel keeps a deferred push a promise of its value
masked = Promise.resolve(value).then(mask);

recordedHandleData filters with getDataForSegment(id, true), the filter captureHandles writes the record with, so the prelude and the record cannot disagree.

(ii) The HIT hydrates from the record (8f0890e5, 4a863836):

// server/handle-store.ts
freezeDocumentSnapshot() {
  if (documentSnapshot) return;
  // development: reported through onError when stream("settled") was already read
  documentSnapshot = { version, data: cloneHandleData(data) };
}
// stream("settled"), before the batching setTimeout(0):
if (documentSnapshot) {
  if (Object.keys(documentSnapshot.data).length > 0) yield documentSnapshot.data;
  return;
}
// streamLate(): let seen = documentSnapshot?.version ?? version;

// rsc/rsc-rendering.ts, serveShellHit's tail context
tailCtx._resolveRenderBarrier = (segments) => {
  handleStore.freezeDocumentSnapshot();
  resolveRenderBarrier(segments);
};

The tail's render barrier resolves once the record is replayed and before a loader starts, on both hit paths, so match code is untouched. Nothing in this piece knows what a deferred push is. The frozen state is served without the timer tick the live lane batches pushes with (SSR and the pre-hydration drain block on this stream) and without a second copy: the lane's one consumer, resolvedHandleStream, takes the last yield and builds a new object from it (resolveDeferredHandleValues, or recordedHandleData on a capture) and never writes to it.

(iii) A hydrating reader reads the document's handle data (4fd269b8, 4a863836):

// browser/event-controller.ts
freezeHydrationHandleState: () => { hydrationHandleState = getHandleState(); },
getHydrationHandleState: () =>
  hydrationHandleState?.data === handleData ? undefined : hydrationHandleState,
// setHandleData, partial branch: never in place
handleData = cloneHandleData(handleData);

// browser/react/use-handle.ts
const noHydrationState = (): undefined => undefined;
const hydrationState = useSyncExternalStore(
  subscribeToNothing,
  noHydrationState,
  () => ctx?.eventController.getHydrationHandleState(),
);
// in the useState initializer:
const state = hydrationState ?? ctx.eventController.getHandleState();

initBrowserApp freezes before hydrateRoot, for a payload without handles too. useSyncExternalStore is used for its server snapshot only, as useLocationState since #992. The server snapshot is the frozen state itself, undefined while the live state is still it; the client snapshot is a constant undefined. React re-renders a reader after it hydrated only when the two differ, which is exactly when something arrived late, and the store is constant as far as React can tell: no per-render store effect, no transition render redone synchronously. A partial update merges into a copy of the containers, always, so no object the controller handed out is written to: not the frozen state, and not a history entry's data, which a back/forward restore installs by reference. Both controller methods are required; the two implementers are the browser controller and the SSR one (renderRoute uses the browser controller). The three no-op subscribe functions of useHandle, useLocationState and Link are one (subscribe-to-nothing.ts).

No stored format changes.

Routes. One fixture route was added to each app for the useHandle e2e (/ppr-push/live in cloudflare-basic, /shell-push/live in the router test-app): cloudflare-basic had no reader inside a live loader's boundary. router.named-routes.gen.ts is committed for both; no other *.gen.ts changed.

Testing primitives. serveShellRequest's result gains readHandles(): { hydration, late }, decoded with the Flight client the way browser/rsc-router.tsx reads the two channels (a6f388ff, 4a863836). renderRoute gains lateHandles, handle values applied once the root has hydrated, where production releases the late channel; it also freezes like initBrowserApp (4fd269b8). Without lateHandles no test could put an update between the root's hydration and a boundary's, so the userland test for (iii) could not exist.

End-to-end example

import { renderRoute } from "@rangojs/router/testing/dom";

function NotesPage() {
  return (
    <Suspense fallback={null}>
      <NoteList /> {/* <li> per useHandle(Notes) value */}
    </Suspense>
  );
}

it("hydrates with the document's notes, then shows the late one", async () => {
  const { serverHtml, recoverableErrors, getAllByRole } = await renderRoute(
    [{ path: "/notes", Component: NotesPage }],
    {
      hydrate: true,
      handles: [[Notes, ["from-handler"]]],
      lateHandles: [[Notes, ["from-handler", "from-loader"]]],
    },
  );
  expect(serverHtml).not.toContain("from-loader");
  expect(recoverableErrors).toEqual([]);
  expect(getAllByRole("listitem")).toHaveLength(2);
});

And for a shell, through serveShellRequest:

const hit = await serveShellRequest(router, "/product/1", { cacheStore });
const handles = await hit.readHandles();
expect(values(handles.hydration)).toEqual(["Widget"]); // what the shell's HTML holds: hydrates clean
expect(values(handles.late.at(-1))).toEqual(["Widget", "3 left"]); // after hydration

Independent review

An independent consultation read this branch when it held pieces (i) and (ii) and recommended finishing this design complete, with the useHandle piece, in one PR. Two other designs were decided against:

  • Bake it (the capture awaits a deferred push and records the value, so it is in a HIT's HTML). It would make "a promise from a loader is live" depend on whether the promise settles within ppr.captureTimeout. It contradicts the liveness rule that already holds for loader data and for a promise nested in a loader's push (mask-nested.ts), baking only one shape. And it would freeze a value its author shaped as per-request into a shell every visitor shares.
  • A real hole (a pending promise in the slot at capture, read with use() under <Suspense>, filled by the HIT's resume). It changes what useHandle returns.

Both are recorded in docs/design/handle-push-ownership.md, "Options decided against".

Two independent reviews then read the complete branch (2898e606) and found no functional defect. Their findings are applied in 4a863836:

  • useHandle: the server snapshot is the frozen state itself and the client snapshot a constant undefined. The earlier client snapshot read the controller through an inline function (a store effect on every render of every reader) and flipped once per document (one in-flight transition render redone synchronously). The render sequences of all five unit cases are identical before and after, with and without StrictMode.
  • setHandleData never writes into the live object in place.
  • stream("settled") serves a frozen snapshot before the batching timer and without a second clone; a development-only report when a freeze comes after the document stream was first read.
  • readHandles() lost prelude (on a HIT hydration is what the prelude was rendered from).
  • One shared no-op subscribe; the hydration freeze is unconditional; the two hydrationCommitted comments give the reason that remains.

Left from those reviews, not done here:

  • Unifying the store's document/late split. stream("settled") and streamLate() each decide where the document ends, and the freeze is a third input to both. One owner for that boundary would be cleaner. It is not behavior-neutral (it changes which pushes a MISS delivers in the snapshot and which late), so it is its own change.
  • Rendering the prelude's handles from the decoded record instead of filtering the capture's store with the record's filter (recordedHandleData). Then the prelude would be rendered from the bytes a HIT restores, by construction. Larger: the capture would have to decode its own record before it renders.

Test plan

Red before, green now.

Pieces (i) and (ii), red on ede1367f:

  • serve-shell-request-push-ownership.rsc-test.tsx, "a shell HIT hydrates from its record": 17 red, 5 controls.
    • a deferred push is not in the shell (... is warm at capture): expected [ 'settled-note-1', undefined ] to deeply equal [ 'settled-note-1' ]
    • ... (misses at capture): expected [ 'settled-note-2', 'deferred-note-2' ] to deeply equal [ 'settled-note-2' ]
    • a deferred push by a loader that runs at capture is not in the shell: expected [ 'plain-settled@g1', 'plain-deferred@g1' ] to deeply equal [ 'plain-settled@g1' ]
    • a push that holds a promise is not in the shell: expected [ 'nested@g1' ] to deeply equal []
    • a deferred push arrives after hydration (2 variants): hydration [settled, deferred], nothing after
    • an entry without loader pins hydrates with the capture's push ...: expected { hydration: [ 'eb-after@g2' ], … } to deeply equal { hydration: [ 'eb-after@g1' ], … }
    • a live-lane loader's push arrives after hydration ...: hydration [ 'handler-note', 'live-note@g2' ]; expected [ 'handler-note' ]
    • <path>: the client hydrates with exactly what the shell was rendered from (6 routes)
  • rsc/__tests__/shell-capture-deferred-push.test.ts: expected undefined to be 'deferred'
  • handles/__tests__/deferred-resolution.test.ts (recordedOnly), server/__tests__/handle-store.test.ts (freezeDocumentSnapshot, 7 tests: TypeError: store.freezeDocumentSnapshot is not a function)
  • Browser, expectShellHitHydratesFromRecord, dev and (production), both apps: received ["settled-note@g1", ""], expected ["settled-note@g1"]
  • expectReplayDeliversDeferredPush with its new guardHydrationErrors: "Hydration failed ..." (dev), "Minified React error Fix prefetched parallel navigation streaming #418" (production)

Piece (iii), red with origin/main's versions of the runtime files:

  • Userland, testing/__tests__/render-route-hydrate.test.tsx, "PPR shell HIT: the shell bakes an empty element for a deferred handle push, so the page fails hydration on every hit #1035: a useHandle reader in a Suspense boundary that hydrates after a late handle update reads the values its HTML was rendered from, then the update" (both StrictMode settings): :216 expected [ Array(1) ] to deeply equal [] (recoverableErrors held "Hydration failed ...")
  • __tests__/use-handle-hydration.test.tsx: :122 and :132, the same assertion ("... rendered HTML didn't match", "... rendered text didn't match"). Three more cases pass before and after: nothing late renders once, a late update after hydration costs no extra render, a mounted reader reads the live state.
  • __tests__/event-controller.test.ts, "hydration handle state" (4 tests): TypeError: ctrl.freezeHydrationHandleState is not a function
  • Browser, expectLateBoundaryHandleReaderHydratesClean (a document MISS, then a shell HIT), dev and (production), both apps. Red in all four with two errors per run: "Hydration failed because the server rendered HTML didn't match the client" (dev), "Minified React error Fix prefetched parallel navigation streaming #418 ... args[]=HTML" (production).
  • Browser, expectPinlessHitKeepsRunPushWithRunData, the new rows-at-hydration assertion. Red in all four: - "pinned-note@g1" / + "pinned-note@g2".
  • e2e/shell-cache.test.ts:883 "live dep: a live loader a running ssr false loader awaits keeps its live push on a HIT": green again in dev and production, unchanged. (It was red on pieces (i) and (ii) alone.)

Added with the review round: handle-store.test.ts (the frozen stream needs no timer tick; the development report, and its absence when the freeze comes first), event-controller.test.ts ("a partial update never writes into the object the state was set from"), and the full render sequences in use-handle-hydration.test.tsx.

On the final tree (4a863836, origin/main at 6c93f7b0 merged):

  • pnpm run typecheck: 16 of 16 tasks
  • pnpm run test:unit:all: router 7566 passed, router rsc 506 passed | 8 expected fail, every consumer suite passed
  • pnpm run lint, pnpm run format, pnpm check:e2e-bucketing, pnpm check:e2e-parity --strict, pnpm run check:docs-api
  • pnpm check:bundle-guards: client router chunk 47373 B gzip (limit 48128 B)
  • Router test-app, files matching shell ppr handle semantic-matrix hook-render-stability, --no-deps: dev 272 passed, production 239 passed. Semantic matrix 25 and 28.
  • cloudflare-basic, files matching ppr handle hook-render-stability: dev 117 passed; production 134 passed in two full runs. A first production run had 133 passed and 1 failed: ppr-capture-timeout.test.ts:262 with Error: apiRequestContext.get: socket hang up on GET /ppr-short-meta?probe=ct715neg. It did not reproduce in those two full runs nor in three runs of that file alone. It was not run on origin/main.
  • Render-count pins unchanged: hook-render-stability 8 dev and 8 production in the router app, 4 and 4 in cloudflare-basic. No pin file was touched.
  • git status --porcelain empty, *.gen.ts included.

What an existing app can notice

In order of scope:

  • (a) What is in a shell's HTML changes only for promise pushes of ssr: false loaders and the loaders they await. Such a value was in a HIT's HTML when the loader's body ran at capture (frozen there for every visitor), or an empty element when its cache() entry replayed it. It is now in the HTML of a document MISS and of a route without ppr only. A crawler that reads a HIT does not see it. Live loaders never ran at capture, so nothing of theirs was ever in a shell.
  • (b) On a shell HIT every loader push made during the request arrives after hydration, live-lane loaders included. It used to race. A live-lane push made before the loader's first await was in a HIT's hydration data; now there is one frame without it, where it used to be a hydration error (read outside the loader's boundary) or already in the boundary's HTML (read inside it). A shell without loader pins shows the capture's handle values until hydration, then the run's.
  • (c) useHandle on every document: a hydrating reader reads the data its HTML was rendered from. This fixes the mismatch for a push made after an await and read inside the loader's boundary. Such a reader renders once more after it hydrates when data arrived in between; a reader that hydrates with nothing new renders exactly as often as before.
  • The one-line app change for a value that has to be in the HTML: ctx.use(Notes)(await value) instead of ctx.use(Notes)(value).

Notes

  • Follow-up, not done here: the capture still waits for a deferred loader push it no longer renders (settleCaptureRecord, src/rsc/shell-capture.ts), under ppr.captureTimeout. A push that does not settle in time refuses the shell (probed: "produced no usable shell ... did not settle within ppr.captureTimeout") for a value the shell does not contain. A deferred HANDLER push is baked and has to keep waiting, so this is its own change.

  • Follow-up, read from the code and not probed: on a route that combines Prerender with ppr, a SETTLED push from an ssr: false loader is kept in the prelude by recordedHandleData (the capture's store holds it untagged), while the HIT restores only the prerender entry's handles (yieldFromStore) and serves the loader from its pin without running it. The HTML would then hold an element the hydration data lacks, and nothing delivers it later. The same on main, where the capture rendered its whole store.

  • Existing tests whose reading changed: a HIT's Flight payload now holds two handle states, so "the last row with the value" is no longer what the client ends with. final() in the push-ownership suite (29 call sites) and 19 assertions in serve-shell-request.rsc-test.tsx read the end state through readHandles() instead. What each asserts is unchanged. One assertion flipped: "a deferred push by a runs: 1 loader reaches the HIT" asserted that the prelude contained the deferred value.

  • A router upgrade changes an app's cache versions (feat(router): per-app cache versions, a deploy keeps the cache of code it did not change #1034: a dependency changed), so no shell captured before the upgrade is served after it. An app that sets createRouter({ version }) itself keeps its shells: one captured before still has the extra element until it is recaptured.

  • origin/main moved four times while this was built (feat(router): per-app cache versions, a deploy keeps the cache of code it did not change #1034, fix(router): buildEnv "auto" reads the wrangler config of the Vite root #1045, test(router): make the HMR recovery test deterministic and its cleanup failure-safe #1048 and fix(router): on Back/Forward, usePathname and useSearchParams change with the page, not at popstate (#1031) #1049, then fix(router): resolve the plugin-rsc static.edge vendor entry through @rangojs/router under pnpm #1051 to chore(router): declare @vitejs/plugin-rsc as a dependency only, not also a peer #1053). All are merged; the gate and the e2e subsets above ran on the result.

  • Docs in this change: docs/design/handle-push-ownership.md (the rule, the trace, the path table, the three pieces, the options decided against, the follow-up), skills/ppr/SKILL.md ("Handles on a shell HIT"), skills/loader/SKILL.md, skills/hooks/handle-and-actions.md, skills/testing/serve-shell-request.md, skills/testing/client-components.md, skills/testing/handles.md, docs/testing.md, docs/internal/execution-model.md, docs/internal/feature-map.md, docs/design/caching.md, docs/design/shell-fast-path.md, and the changelog entry.

  • Second run by the reviewer on the final commit (4a863836): gate green (router 7566 unit, 506 RSC with 8 expected failures; every consumer package green). E2e, --no-deps: router app shell-push-ownership, shell-cache and hook-render-stability: 77 passed in dev and 77 in production; cloudflare-basic ppr-push-ownership and hook-render-stability: 9 and 9. With main's versions of the 14 modified runtime files, the cloudflare dev run of ppr-push-ownership fails at the deferred-push body's hydration guard (1 failed, 3 did not run in the serial describe).

ivogt added 12 commits October 4, 2026 08:06
…1035)

readHandles() decodes the payload with the Flight client and drains its two
handle channels as browser/rsc-router.tsx does: hydration (metadata.handles,
what the document hydrates with), late (each metadata.handlesLate state,
applied after hydration) and, on a HIT, prelude (the handle data the stored
shell was rendered from). Before, a test could only match handle values as
text in the Flight payload and could not tell the two channels apart.

The controls for #1035 use it: a HIT with settled pushes only, a document
MISS, a navigation replay and a document without ppr, as they are today.
… its record keeps (#1035)

A PPR shell capture stores a doc record with the handle pushes a HIT can
restore, and leaves out the ones it cannot keep: a deferred (thenable) loader
push and one holding a masked promise. Its own render still read the whole
handle store, so the prelude had an element for a push no HIT's record
supplies.

For a deferred push replayed from the loader's cache() entry that element was
empty: Flight decodes a promise to a thenable whose then() returns nothing,
so the funnel's value.then(mask) stored undefined in the slot. For one made
by a body that ran at capture the element held the resolved value, a
promise-shaped value baked into a shell every visitor shares.

- resolvedHandleStream(handleStore, recordedOnly): a capture's payload
  (buildFullPayload under _shellCaptureRun, both producers) carries the
  pushes the record keeps, by the filter the record is written with. Which
  pushes a record keeps stays the funnel's decision.
- The funnel keeps a deferred push as a promise of its value
  (Promise.resolve(value).then(mask)).

One existing assertion changed meaning: 'a deferred push by a runs: 1 loader
reaches the HIT' asserted the prelude contained the deferred value.
…quest's pushes after hydration (#1035)

A document served from a PPR shell hydrated with whatever its handle store
held when the document stream was first read: the record's pushes plus any a
loader of this request had made by then. The prelude was rendered from the
record, so every such push was an element the HTML did not have (or a value
it did not show), and whether it was there was a race. A deferred push by an
ssr: false loader failed hydration on every HIT; a settled push by a
live-lane loader made before its first await did too, and so did the run of
an entry without loader pins.

- HandleStore.freezeDocumentSnapshot(): the document lane (stream('settled'))
  yields the frozen state and nothing newer, and streamLate() starts from it,
  so a change made before the handler barrier is late too.
- serveShellHit freezes the store when the tail's render barrier resolves:
  the record is replayed and no loader has run. The same point for a runtime
  record and a prerender store entry, so match code is untouched.

What the request's loaders push, replace or drop rides metadata.handlesLate,
which the client already applies after hydration. No stored format and no
client change.

Tests: a HIT's Flight payload now carries two handle states (hydration and
late), so the two serve-shell-request suites read the state the client ends
with through readHandles() instead of matching the payload text. What each
asserts is unchanged. expectReplayDeliversDeferredPush gets the hydration
guard it could not have before.
The design section in docs/design/handle-push-ownership.md: the rule, the
trace of how #1035 broke, the path table (origin/main against now), the two
halves as built, the row left open (a useHandle reader that hydrates after
the root), and what recording a deferred push would take. The ppr and loader
skills, the execution model, the feature map and the testing docs
(readHandles) state the HIT rule; the changelog lists what an app can
notice.
…ndle-snapshot

# Conflicts:
#	packages/rangojs-router/docs/internal/feature-map.md
#	packages/rangojs-router/src/testing/serve-shell-request.ts
…1035)

shell-cache.test.ts 'live dep: a live loader a running ssr false loader
awaits keeps its live push on a HIT' reads useHandle rows inside a live
loader's loading() boundary. It passes on origin/main and fails with the HIT
rule, in dev and in production, because a reader that hydrates after the
root reads the controller's live state. The design section names the test
and what a useHandle patch measured.
…TML was rendered from (#1035)

The late handle channel is released when the root hydrates. A useHandle
reader inside a boundary that hydrates after the root (a live loader's
loading(), a streamed <Suspense>) initialized from the controller's live
state, which by then held values the boundary's HTML was rendered without:
'Hydration failed' in dev, React error #418 in production. On main that was
a loader push made after an await, on any document. With the shell HIT rule
(every loader push of the request is late) it was every push of a live
loader read inside its own boundary, which turned
shell-cache.test.ts 'live dep: ... keeps its live push on a HIT' red.

- EventController.freezeHydrationHandleState() keeps the state the document
  was rendered with; initBrowserApp calls it before hydrateRoot.
  getHydrationHandleState() returns it once the live state has moved on,
  undefined while the live state is still it. A partial update copies the
  containers first, so the frozen state is never written to.
- useHandle reads it in a hydrating render (useSyncExternalStore for its
  server snapshot, as useLocationState since #992); its mount effect moves
  the reader on to the live state. The snapshot is 'has the live state
  moved on', so a reader that hydrated with nothing late is not rendered
  again: the hook-render-stability pins are unchanged.
- renderRoute({ hydrate: true }) freezes like initBrowserApp, and gains
  lateHandles: handle values applied once the root has hydrated, where
  production releases the late channel. Without it no test could put a
  late update between the root's hydration and a boundary's.

Tests: use-handle-hydration.test.tsx and event-controller.test.ts (unit),
render-route-hydrate.test.tsx (userland, both StrictMode settings),
expectLateBoundaryHandleReaderHydratesClean in both apps, dev and
production, on a new fixture route (/ppr-push/live, /shell-push/live: a
live loader that pushes after an await, read inside its loading()).

expectPinlessHitKeepsRunPushWithRunData now records the handle rows over
time: on a HIT of a shell without loader pins React repairs the loader data
on hydration (the documented drift), and the handle rows are not part of
it. They hydrate as the shell has them and change after.
…decided against (#1035)

- docs/design/handle-push-ownership.md: rule 4 (a hydrating reader reads the
  document's handle data) as built, the one-sentence rule for app authors,
  option A (bake a deferred push at capture) and option C (a pending promise
  in the slot) recorded as decided against with their reasons, and the
  follow-up that is not done: the capture still waits for a deferred loader
  push it no longer renders.
- The ppr, loader and hooks skills and the changelog state the scope in
  order: what is in a shell's HTML, what a HIT delivers after hydration, and
  useHandle on every document, then the one-line app change.
- renderRoute's lateHandles in the testing skill and docs/testing.md; the
  execution model and the feature map name the new controller methods.
No behavior change for an app; edits to the three pieces already on the
branch.

- useHandle: the useSyncExternalStore server snapshot is the frozen handle
  state itself (undefined while the live state is still it) and the client
  snapshot is a constant undefined. The same readers re-render after
  hydrating as before (those whose server snapshot was not undefined). The
  previous client snapshot read the controller through an inline function:
  a store effect on every render of every reader, and a boolean that
  flipped once per document, which made React redo an in-flight transition
  render synchronously.
- EventController.setHandleData: a partial update merges into a copy of the
  containers, always (cloneHandleData), instead of copying only when the
  frozen state shared the object. The live object is never written in
  place: a back/forward restore installs the history cache's own object as
  the live state, and a later partial update wrote into that entry.
- HandleStore: stream('settled') serves a frozen snapshot before the
  batching setTimeout(0), and yields the frozen object without a second
  clone (its one consumer, resolvedHandleStream, builds a new object from a
  yield). In development a freeze that comes after the document stream was
  first read is reported through onError: that stream stays on the live
  state and the old race would be back.
- serveShellRequest: readHandles() returns { hydration, late }; prelude is
  gone (on a HIT hydration is what the prelude was rendered from) and with
  it decodePayloadHandles' late parameter. The evidence suite decodes
  hit.prelude itself.
- One subscribeToNothing (browser/react/subscribe-to-nothing.ts) for
  useHandle, useLocationState and Link.
- The hydration freeze is unconditional: after the handles block in
  initBrowserApp (a payload without handles is covered), and in renderRoute
  for a mounted tree too.
- rsc-router.tsx: the two comments on hydrationCommitted give the reason
  that remains for waiting for the root's commit.
@pkg-pr-new

pkg-pr-new Bot commented Oct 4, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@rangojs/router@1054

commit: 4a86383

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

PPR shell HIT: the shell bakes an empty element for a deferred handle push, so the page fails hydration on every hit

1 participant