Skip to content

[backport] Fix dev mode hydration failure when page is served from HTTP cache#93492

Merged
unstubbable merged 2 commits into
next-16-2from
hl/backport-http-cache-bugfix
May 19, 2026
Merged

[backport] Fix dev mode hydration failure when page is served from HTTP cache#93492
unstubbable merged 2 commits into
next-16-2from
hl/backport-http-cache-bugfix

Conversation

@unstubbable

Copy link
Copy Markdown
Contributor

@github-actions

github-actions Bot commented May 5, 2026

Copy link
Copy Markdown
Contributor

Tests Passed

Commit: ba5ac8d

@unstubbable unstubbable marked this pull request as ready for review May 5, 2026 18:25
…92892)

PR #88182 introduced an experimental option to use `Cache-Control:
no-cache` instead of `no-store` in dev mode, and PR #91503 made it the
default. With `no-cache`, browsers may serve the page from HTTP cache on
back-forward navigation or tab duplication. The HTML, including the
inline RSC payload, is restored from cache and all scripts re-execute.

In dev mode, React's Flight client uses a debug channel (a
WebSocket-backed stream delivering component debug info) that adds
dependencies to model chunk initialization. On a fresh page load, the
WebSocket delivers this data and the dependencies resolve normally. On
HTTP cache restore, however, the bootstrap script re-executes and
creates a new debug channel stream, but the WebSocket doesn't re-send
debug data for the cached payload's request ID. The debug dependencies
are never fulfilled, blocking the entire model tree from initializing,
so `hydrateRoot` is never called and the page loses all interactivity.

This is dev-only — production builds have no debug channel, so there are
no stuck dependencies and no issue.

The fix buffers debug channel chunks in memory as they flow through the
`TransformStream` in `getOrCreateDebugChannelReadableWriterPair`. Once
all chunks have been received, the buffer is eagerly persisted to
`sessionStorage`. When the page detects it was served from HTTP cache
(via `PerformanceNavigationTiming.transferSize === 0`),
`createDebugChannel` restores the debug data from `sessionStorage` and
replays it as a synthetic `ReadableStream` instead of expecting it from
the WebSocket. If the restore fails (e.g., quota exceeded during the
earlier write, or the entry was overwritten by another page), the page
falls back to `location.reload()` to get a fresh page from the server.

A regression test is included that navigates to an external page and
verifies interactivity is preserved after clicking the browser back
button. Tab duplication (which triggers the same HTTP cache restore)
cannot be simulated in Playwright and was verified manually.

fixes #92238
fixes #91982
fixes #92687
#93486)

Follow-up to #92892, addressing the regression reported in
#93136 (comment).

That PR persisted the dev-mode debug channel chunks under a single
shared `sessionStorage` key (`__next_debug_channel`) so the data could
be replayed when a page is served from HTTP cache. The single-key design
holds when the only navigations away from a Next.js page go to other
origins, but it breaks the moment two Next.js documents share the same
tab.

When a user does an MPA navigation from page A to page B (a regular `<a
href>` link to another Next.js route), B's persisted entry overwrites
A's under the same key. Clicking back restores A from HTTP cache; A's
bootstrap re-executes with its original `self.__next_r`, looks up
`sessionStorage`, finds B's `requestId`, returns `undefined` from
`restoreDebugChannelFromSessionStorage`, and falls back to
`location.reload()`. Before the reload completes, the main RSC stream
closes; the Flight client surfaces references to debug-channel chunks
that block other model chunks — references the debug channel was
supposed to deliver but never did — as a `Connection closed.` error,
briefly flashing the error UI.

The fix changes the storage key from `__next_debug_channel` to a
per-document prefix `__next_debug_channel:${requestId}`. Each initial
document persists into its own entry, so A's data survives B's load and
the back-restore finds it. `requestId` is dropped from the stored value
since it's now part of the key. On `setItem` failure (likely a quota
issue), other entries with the prefix are removed and `setItem` is
retried once; if it still fails, persistence is silently skipped and
`location.reload()` remains the safety net on the read side.

The fallback path also gets a small change: when restore legitimately
fails (e.g., session storage cleared mid-session), `createDebugChannel`
now returns `{ readable: new ReadableStream() }` rather than `{
readable: undefined }`. The empty stream never enqueues and never
closes, so from the Flight client's perspective the debug channel is
still active when the main stream closes — the unresolved chunk
references no longer surface as a synchronous error before
`location.reload()` tears the document down.

The `bfcache-regression` test was updated to reproduce the bug. It now
clicks an internal MPA link to `/target-page` instead of an external
one, and asserts no console errors after the round trip via
`assertNoConsoleErrors`. The fixture pre-warms `/target-page`
compilation in parallel with the browser load in webpack mode, to avoid
an unrelated Fast Refresh full-reload warning when the route is compiled
on demand.
@unstubbable unstubbable force-pushed the hl/backport-http-cache-bugfix branch from 8addd31 to ba5ac8d Compare May 6, 2026 12:08
@unstubbable unstubbable requested a review from eps1lon May 6, 2026 12:20
@unstubbable unstubbable removed the request for review from eps1lon May 18, 2026 16:46
@unstubbable unstubbable enabled auto-merge (squash) May 18, 2026 17:43
@unstubbable unstubbable merged commit 3279f01 into next-16-2 May 19, 2026
251 of 256 checks passed
@unstubbable unstubbable deleted the hl/backport-http-cache-bugfix branch May 19, 2026 07:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants