You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Prerender + ppr: a settled push from an ssr: false loader is in the shell HTML but never reaches the client (hydration error on every HIT) #1057
On a route that combines Prerender with ppr, a settled handle push from an ssr: false loader can end up in the shell's HTML but never in the handle data the browser hydrates with, and never arrive afterwards. A component that renders that handle would then hydrate with an element the client does not have.
Read from the code during the review of #1054 (the fix for #1035), on main at 2bff0fb0 (0.21.0). Not reproduced: no fixture in either test app has this shape. The same reading applies to 0.20.0.
Shape
constNotes=createHandle<string>();constContext=createLoader(async(ctx)=>{ctx.use(Notes)("settled note");// settled, not a promisereturn{value: "..."};});path("/page",Prerender(async()=><Page/>),{name: "page",ppr: true},()=>[loader(Context,{ssr: false})],);// <Page> renders useHandle(Notes)
How it would happen
Capture. For a Prerender route the record-first step returns kind: "prerender" (src/rsc/shell-capture.ts, CaptureRecordOutcome, around line 1691): the prerender store supplies the handler layer and no doc record is written.
The capture's push funnel credits the loader's settled push to that loader, untagged, so recordedHandleData (src/handles/deferred-resolution.ts, around line 128) keeps it, and the shell's prelude renders it.
HIT. The prerender-store hit path restores only the prerender entry's own handles (src/router/match-middleware/cache-lookup.ts, around lines 262-270, entry.handles). That entry was written at build time and does not hold the loader's push.
The ssr: false loader is served from its pin. Its pin's runs bit is 0 for a promise-free loader (src/router/segment-resolution/loader-cache.ts, around line 555), so the body does not run on the HIT and nothing pushes the value again.
Result: the HTML holds the push, the pre-hydration data does not, and the late channel never delivers it.
What to do first
Build the fixture (in both apps, dev and production) and confirm or refute it in a browser, as was done for #1035. If it reproduces, the direction follows the rule #1054 set: the shell's HTML and the HIT's pre-hydration data come from the same recorded data. Either the prerender-store path records and restores the loader-owned pushes the prelude rendered, or the capture leaves them out of the prelude for a prerender outcome.
changed the title [-]Prerender + ppr: a settled push from an ssr: false loader may be in the shell HTML but never reach the client (read from code)[/-][+]Prerender + ppr: a settled push from an ssr: false loader is in the shell HTML but never reaches the client (hydration error on every HIT)[/+]on Oct 4, 2026
Confirmed in a browser, dev and production, on cloudflare-basic at 2bff0fb0 (0.21.0), with a temporary fixture (since removed).
/ppr-push/pre-settled: Prerender(async () => <PprPushView />), ppr: { ttl: 300, swr: 120 }, loader(PprPushSettledLoader, { ssr: false }); the loader is promise-free and pushes "settled-only@g1".
Control /ppr-push/ppr-settled: the same loader and page with ppr and no Prerender.
Case
Rows in the HTML
Rows over time
After hydration
Hydration error
pre-settled MISS
settled-only@g1
parsed, then hydrated with it
present
none
pre-settled HIT
settled-only@g1
parsed with it, then removed
missing for the 2.5 s observed
dev: "Hydration failed" at the <li data-testid="push-note">; production: #418
ppr-settled MISS
settled-only@g1
hydrated with it
present
none
ppr-settled HIT
settled-only@g1
hydrated with it
present
none
The loader's data is correct on the pre-settled HIT (push-value reads the pinned value); only the handle push is lost. The ppr-settled HIT's HTML carries the handle data in a __FLIGHT_DATA row; the pre-settled HIT's HTML has no PprPushNotes row at all. Nothing arrives on the late channel.
So every step of the description holds: for a Prerender route the capture's record step returns { kind: "prerender" } and writes no doc record (src/rsc/shell-capture.ts ~1685-1692), so the handleOwners that keep a loader's pushes are stored nowhere; the prelude still renders the push (recordedHandleData keeps untagged pushes); the HIT restores only the build-time prerender entry's handles (cache-lookup.ts ~262-269); and the promise-free loader is served from its pin without running.
Fix options
A (recommended): record and restore the loader-owned pushes for a prerender outcome, as a doc record does, so the HIT restores them through pushRestored and the HTML and the pre-hydration data match. Estimated 40 to 70 lines in shell-capture.ts and cache-lookup.ts. Risk to check: the capture and storedDocKey currently treat "no doc record" as the prerender-served case, and the size-cap logic around the doc record.
B: leave those pushes out of the prelude and make the loader run on the HIT (tag them, set runs on the pin). Smaller (10 to 25 lines), but a settled title or meta push from the loader would then be missing from the HTML and arrive after hydration, and a promise-free loader would start running on every HIT.
Activity
[-]Prerender + ppr: a settled push from an ssr: false loader may be in the shell HTML but never reach the client (read from code)[/-][+]Prerender + ppr: a settled push from an ssr: false loader is in the shell HTML but never reaches the client (hydration error on every HIT)[/+]ivogt commentedon Oct 4, 2026
Confirmed in a browser, dev and production, on cloudflare-basic at
2bff0fb0(0.21.0), with a temporary fixture (since removed)./ppr-push/pre-settled:Prerender(async () => <PprPushView />),ppr: { ttl: 300, swr: 120 },loader(PprPushSettledLoader, { ssr: false }); the loader is promise-free and pushes"settled-only@g1"./ppr-push/ppr-settled: the same loader and page withpprand noPrerender.pre-settledMISSsettled-only@g1pre-settledHITsettled-only@g1<li data-testid="push-note">; production: #418ppr-settledMISSsettled-only@g1ppr-settledHITsettled-only@g1The loader's data is correct on the
pre-settledHIT (push-valuereads the pinned value); only the handle push is lost. Theppr-settledHIT's HTML carries the handle data in a__FLIGHT_DATArow; thepre-settledHIT's HTML has noPprPushNotesrow at all. Nothing arrives on the late channel.So every step of the description holds: for a
Prerenderroute the capture's record step returns{ kind: "prerender" }and writes no doc record (src/rsc/shell-capture.ts~1685-1692), so thehandleOwnersthat keep a loader's pushes are stored nowhere; the prelude still renders the push (recordedHandleDatakeeps untagged pushes); the HIT restores only the build-time prerender entry's handles (cache-lookup.ts~262-269); and the promise-free loader is served from its pin without running.Fix options
prerenderoutcome, as a doc record does, so the HIT restores them throughpushRestoredand the HTML and the pre-hydration data match. Estimated 40 to 70 lines inshell-capture.tsandcache-lookup.ts. Risk to check: the capture andstoredDocKeycurrently treat "no doc record" as the prerender-served case, and the size-cap logic around the doc record.runson the pin). Smaller (10 to 25 lines), but a settled title or meta push from the loader would then be missing from the HTML and arrive after hydration, and a promise-free loader would start running on every HIT.fix(router): a Prerender + ppr shell HIT hydrates with its loader pus…