Skip to content

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

Closed

Description

@ivogt

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

const Notes = createHandle<string>();

const Context = createLoader(async (ctx) => {
  ctx.use(Notes)("settled note");          // settled, not a promise
  return { value: "..." };
});

path(
  "/page",
  Prerender(async () => <Page />),
  { name: "page", ppr: true },
  () => [loader(Context, { ssr: false })],
);
// <Page> renders useHandle(Notes)

How it would happen

  1. 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.
  2. 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.
  3. 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.
  4. 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.

Activity

  1. 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
  2. ivogt commented on Oct 4, 2026

    @ivogt
    CollaboratorAuthor

    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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions