Repository navigation
Description
On a document load served from a PPR shell, a handle value pushed as a promise by an ssr: false loader reaches the client after hydration, while the shell's HTML already contains it. A component that renders one element per pushed value hydrates with that element missing, React reports a hydration mismatch and regenerates the tree on the client.
Found while verifying #1032. It is not caused by #1032: the same error appears with main's versions of the ten source files that PR changes (eed288b1, cloudflare-basic, dev).
Reproduction
The fixture is in PR #1032 (tests/cloudflare-basic/src/loaders/ppr-push-ownership.ts, route /ppr-push/deferred; the router test-app has the same one under e2e/test-app/src/urls/shell-push-ownership*):
export const PprPushNotes = createHandle<string>();
export const PprPushDeferredLoader = createLoader(async (ctx) => {
ctx.use(PprPushNotes)("settled-note");
ctx.use(PprPushNotes)(Promise.resolve("deferred-note")); // a deferred push
return { value: "deferred" };
});
// urls: a ppr route, the loader on the bake lane with its own cache()
path("/ppr-push/deferred", Page, { name: "pprPushDeferred", ppr: { ttl: 300, swr: 120 } }, () => [
loader(PprPushDeferredLoader, { ssr: false }, () => [cache({ ttl: 300 })]),
]);The view renders <li data-testid="push-note"> per value of useHandle(PprPushNotes).
- Request
/ppr-push/deferredas a document until the response carriesx-rango-shell: HIT. - Load the page in a browser (dev server).
Observed
[vite] (client) [Unhandled error] Error: Hydration failed because the server rendered HTML didn't match the client.
...
<PprPushView loader={{...}}>
<section>
<p>
<ul>
- <li data-testid="push-note">
The page ends up correct (both notes are shown) after React regenerates the list. Production was not inspected; there React does the same client render without the log.
Cause, as far as it was traced
The capture waits for the deferred push to settle, so the prelude renders its row. The record cannot keep a promise, so on a HIT the loader's pin asks for a run (runs), the push is made again as a promise, and its value arrives on the late handle channel, after the document's handle snapshot and after hydration. Described in docs/design/handle-push-ownership.md ("One thing the browser tests showed that is older than this work", on the #1032 branch).
Expected
A document served from a shell hydrates clean when the only difference is the delivery time of a deferred push the shell already rendered. Two directions, neither evaluated:
- the HIT's first handle snapshot carries the settled value the capture saw (the capture has it), and the later run only replaces it;
- or the shell does not render the rows of a push it cannot record.
When this is fixed
expectReplayDeliversDeferredPush in tests/shared-e2e/src/index.ts (added by #1032) installs no hydration-error guard because of this; it should get guardHydrationErrors like the #1003 body next to it.
Activity
[-]PPR shell HIT: a deferred handle push arrives after hydration while the shell already rendered it (hydration error)[/-][+]PPR shell HIT: the shell bakes an empty element for a deferred handle push, so the page fails hydration on every hit[/+]ivogt commentedon Oct 4, 2026
Confirmed in the browser, dev and production (
tests/cloudflare-basic, fixture route/ppr-push/deferredfrom #1032, now onmain). This also corrects the description above: the shell does not hold the deferred value. It holds an empty element for it.What the run shows
The test loads the page once before a shell exists (MISS), waits for the shell, loads it again (HIT), and records page errors and the
push-noterows in the DOM each time they change.Document MISS (no shell yet): clean, both modes. Both rows are in the HTML with their text, no error.
Shell HIT: hydration error, both modes.
The shell's HTML has the row for the deferred push with no text:
The same response then sends the handle data with that slot as a promise and its value:
So the client renders
deferred-note@g1in an element the server rendered empty:In dev the client's first handle data holds only the settled push, so it is the row count that differs; in production the deferred value is already there and it is the text that differs. Either way the shell's HTML cannot match.
A Suspense boundary in the component does not avoid it
With the view changed to read each value through
use()inside<Suspense fallback={null}>, the shell stores a completed, empty boundary, not a pending one:and the HIT still fails hydration (
#418 ... args[]=HTMLin production, the same message as above in dev). So during the capture's render the slot exists and its value renders as nothing; it is not a promise the consumer could suspend on.So the cause is the capture, not the delivery time
The capture renders the prelude with a slot for the deferred push whose value is empty, and leaves the push out of the record (
src/rsc/shell-capture.ts, the comment at "A deferred (thenable) push": it stays tagged, the loader's pin carriesruns, and a HIT runs the body for it). The HIT then delivers the real value into that slot. A value that arrived after hydration into a slot the shell did not render would hydrate clean; a slot the shell rendered empty never does.The two directions in the description still stand, restated:
The test is saved outside the repo; it becomes the regression test (dev and production, both apps) when this is fixed.
ivogt commentedon Oct 4, 2026
Direction decided: on a shell HIT the client hydrates with the handle data the shell was rendered from, and live handle data is swapped in after hydration. That maps onto what the client already has:
metadata.handlesis consumed beforehydrateRootandmetadata.handlesLateis applied after hydration commits (src/browser/rsc-router.tsx). So the HIT's pre-hydration snapshot becomes the record's data only, this request's own pushes move to the late channel, and the capture renders no slot for a deferred push. Consequence: on a shell HIT a deferred push is not in the HTML and shows right after hydration; a promise-shaped value is a live value and is never baked into a shared shell.fix(router): a document served from a PPR shell hydrates with the han…
fix(router): shell capture does not wait for loader pushes its record…
docs(router): trim render-pass.ts comments to the invariants