Skip to content

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

Closed

Description

@ivogt

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).

  1. Request /ppr-push/deferred as a document until the response carries x-rango-shell: HIT.
  2. 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

  1. changed the title [-]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[/+] on Oct 4, 2026
  2. ivogt commented on Oct 4, 2026

    @ivogt
    CollaboratorAuthor

    Confirmed in the browser, dev and production (tests/cloudflare-basic, fixture route /ppr-push/deferred from #1032, now on main). 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-note rows 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.

    [production] MISS  errors: []
    [production] MISS  rows: ["settled-note@g1","deferred-note@g1"]  (parsed HTML, then hydrated)
    

    Shell HIT: hydration error, both modes.

    The shell's HTML has the row for the deferred push with no text:

    <ul><li data-testid="push-note">settled-note@g1</li><li data-testid="push-note"></li></ul>

    The same response then sends the handle data with that slot as a promise and its value:

    4:{"f6564a7d#PprPushNotes":{"M0L0L0L0R15":["settled-note@g1","$@5"]}}
    5:"deferred-note@g1"
    

    So the client renders deferred-note@g1 in an element the server rendered empty:

    [production] HIT  errors: ["pageerror: Minified React error #418; ... args[]=text&args[]="]
    [production] HIT  rows:   ["settled-note@g1",""] -> ["settled-note@g1","deferred-note@g1"] -> hydrated
    
    [dev] HIT  errors: ["pageerror: Hydration failed because the server rendered HTML didn't match the client. ..."]
    [dev] HIT  rows:   ["settled-note@g1",""] -> ["settled-note@g1"] -> hydrated -> ["settled-note@g1","deferred-note@g1"]
    

    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:

    <li data-testid="push-note"><!--$--><!--/$--></li>

    and the HIT still fails hydration (#418 ... args[]=HTML in 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 carries runs, 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 capture renders and records the value the deferred push settled to, and the HIT's first handle data carries it (a deferred push is then frozen with the shell like the loader's other pushes);
    • or the capture's render does not see the slot at all, and the HIT delivers it after hydration.

    The test is saved outside the repo; it becomes the regression test (dev and production, both apps) when this is fixed.

  3. ivogt commented on Oct 4, 2026

    @ivogt
    CollaboratorAuthor

    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.handles is consumed before hydrateRoot and metadata.handlesLate is 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.

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