From 867227352b490e448f41f2c2807817575d1fc35a Mon Sep 17 00:00:00 2001
From: Joseph
Date: Tue, 2 Jun 2026 10:07:28 +0200
Subject: [PATCH 1/6] chore: lint-language output only warnings and errors
(#94261)
The lint-language script is too verbose, it prints "no issues found" for
all files it touches.
---
package.json | 2 +-
scripts/check-examples.sh | 6 ++++--
2 files changed, 5 insertions(+), 3 deletions(-)
diff --git a/package.json b/package.json
index e8282b35b5dd..dad3bc3d9997 100644
--- a/package.json
+++ b/package.json
@@ -69,7 +69,7 @@
"check-unused-turbo-tasks": "node scripts/check-unused-turbo-tasks.mjs",
"lint": "run-p test-types lint-typescript prettier-check \"lint-eslint .\" lint-ast-grep lint-language check-unused-turbo-tasks",
"lint-fix": "pnpm prettier-fix && pnpm lint-eslint --fix .",
- "lint-language": "alex .",
+ "lint-language": "alex . --quiet",
"prettier-check": "prettier --check .",
"check-examples": "./scripts/check-examples.sh",
"get-test-timings": "node run-tests.js --timings --write-timings -g 1/1",
diff --git a/scripts/check-examples.sh b/scripts/check-examples.sh
index 4dd13451fd63..7a1587ed1538 100755
--- a/scripts/check-examples.sh
+++ b/scripts/check-examples.sh
@@ -2,10 +2,12 @@
for folder in examples/* ; do
if [ -f "$folder/package.json" ]; then
- cat $folder/package.json | jq '
+ tmp=$(mktemp)
+ jq '
.private = true |
del(.license, .version, .name, .author, .description)
- ' | sponge $folder/package.json
+ ' "$folder/package.json" > "$tmp" && cat "$tmp" > "$folder/package.json"
+ rm -f "$tmp"
fi
if [ -f "$folder/tsconfig.json" ]; then
if [ -d "$folder/app" ] || [ -d "$folder/src/app" ]; then
From 957520dbfb75a8aaecd4b2033ce15dde1086898d Mon Sep 17 00:00:00 2001
From: Tobias Koppers
Date: Tue, 2 Jun 2026 10:33:44 +0200
Subject: [PATCH 2/6] DiskFileSystem: run write effects on a spawned task
(#94140)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
### What?
Make `DiskFileSystem` write and symlink effects run on their own spawned
tasks, so multiple pending writes can execute in parallel rather than
serially on the caller's future.
### Why?
Effects emitted by `DiskFileSystem::write` and `write_link` were
previously applied inline within the caller's future. That meant the
work for each effect ran sequentially in the awaiting task — writes did
not overlap even though each one is largely I/O bound (path validation,
lock acquisition, file write, fsync).
### How?
- `WriteEffect` and `WriteLinkEffect` now derive `Clone` and own their
data so they can be moved into a spawned task. `full_path` becomes
`Arc` to keep cloning cheap.
- `apply_inner` takes `self` by value instead of `&self`.
- `apply()` now calls `spawn(self.clone().apply_inner())` so the effect
body runs on a dedicated turbo-tasks task. Multiple effects awaited
concurrently will execute in parallel.
Closes NEXT-
Fixes #
---
turbopack/crates/turbo-tasks-fs/src/lib.rs | 26 +++++++++++++---------
1 file changed, 15 insertions(+), 11 deletions(-)
diff --git a/turbopack/crates/turbo-tasks-fs/src/lib.rs b/turbopack/crates/turbo-tasks-fs/src/lib.rs
index fd6cad52cc47..f0aad83952aa 100644
--- a/turbopack/crates/turbo-tasks-fs/src/lib.rs
+++ b/turbopack/crates/turbo-tasks-fs/src/lib.rs
@@ -64,7 +64,7 @@ use turbo_rcstr::{RcStr, rcstr};
use turbo_tasks::{
Completion, Effect, EffectStateStorage, InvalidationReason, NonLocalValue, ReadRef, ResolvedVc,
TaskInput, TurboTasksApi, ValueToString, ValueToStringRef, Vc, debug::ValueDebugFormat,
- emit_effect, parallel, trace::TraceRawVcs, turbo_tasks_weak, turbobail, turbofmt,
+ emit_effect, parallel, spawn, trace::TraceRawVcs, turbo_tasks_weak, turbobail, turbofmt,
};
use turbo_tasks_hash::{
DeterministicHash, DeterministicHasher, HashAlgorithm, deterministic_hash, hash_xxh3_hash64,
@@ -936,9 +936,9 @@ impl FileSystem for DiskFileSystem {
let inner = self.inner.clone();
- #[derive(TraceRawVcs, NonLocalValue)]
+ #[derive(TraceRawVcs, NonLocalValue, Clone)]
struct WriteEffect {
- full_path: PathBuf,
+ full_path: Arc,
inner: Arc,
content: ReadRef,
content_hash: u128,
@@ -960,12 +960,14 @@ impl FileSystem for DiskFileSystem {
}
async fn apply(&self) -> Result<(), AnyhowWrapper> {
- self.apply_inner().await.map_err(AnyhowWrapper::from)
+ spawn(self.clone().apply_inner())
+ .await
+ .map_err(AnyhowWrapper::from)
}
}
impl WriteEffect {
- async fn apply_inner(&self) -> anyhow::Result<()> {
+ async fn apply_inner(self) -> anyhow::Result<()> {
let full_path = validate_path_length(&self.full_path)?;
let _lock = self.inner.lock_path(&full_path).await;
@@ -1086,7 +1088,7 @@ impl FileSystem for DiskFileSystem {
let content_hash = u128::from_le_bytes(hash_xxh3_hash128(&*content));
emit_effect(WriteEffect {
- full_path,
+ full_path: Arc::new(full_path),
inner,
content,
content_hash,
@@ -1111,9 +1113,9 @@ impl FileSystem for DiskFileSystem {
let full_path = self.to_sys_path(&fs_path);
let inner = self.inner.clone();
- #[derive(TraceRawVcs, NonLocalValue)]
+ #[derive(TraceRawVcs, NonLocalValue, Clone)]
struct WriteLinkEffect {
- full_path: PathBuf,
+ full_path: Arc,
inner: Arc,
content: ReadRef,
content_hash: u128,
@@ -1135,12 +1137,14 @@ impl FileSystem for DiskFileSystem {
}
async fn apply(&self) -> Result<(), AnyhowWrapper> {
- self.apply_inner().await.map_err(AnyhowWrapper::from)
+ spawn(self.clone().apply_inner())
+ .await
+ .map_err(AnyhowWrapper::from)
}
}
impl WriteLinkEffect {
- async fn apply_inner(&self) -> anyhow::Result<()> {
+ async fn apply_inner(self) -> anyhow::Result<()> {
let full_path = validate_path_length(&self.full_path)?;
let _lock = self.inner.lock_path(&full_path).await;
@@ -1367,7 +1371,7 @@ impl FileSystem for DiskFileSystem {
let content_hash = u128::from_le_bytes(hash_xxh3_hash128(&*content));
emit_effect(WriteLinkEffect {
- full_path,
+ full_path: Arc::new(full_path),
inner,
content,
content_hash,
From 4fff8f4238faaa9f50dac7bc383aa7ddfc0e8c9b Mon Sep 17 00:00:00 2001
From: Hendrik Liebau
Date: Tue, 2 Jun 2026 14:46:02 +0200
Subject: [PATCH 3/6] Disable `default-case` ESLint rule for type-checked
TypeScript files (#94316)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`@typescript-eslint/switch-exhaustiveness-check` (enabled in
eslint.cli.config.mjs) already guarantees complete switch coverage on
enums and discriminated unions, so requiring an additional default case
on those switches in TypeScript just forced dead branches.
We turn on its `requireDefaultForNonUnion` option so that switches on
plain types such as `string` or `number` still require a default,
preserving the coverage that `default-case` previously gave us. We scope
the `default-case: off` override to exactly the files where the
type-checked rule runs — the same `files` and `ignores` as the
switch-exhaustiveness check — and cross-reference the two config blocks
so the globs stay in sync. That way `default-case` stays enabled for
JavaScript files, `.mts` files, and the directories the type-checked
config skips, so no switch is left unchecked.
The two `// eslint-disable-next-line default-case` directives in
`postcss-loader` and `head.tsx` are no longer the right escape hatch:
with `requireDefaultForNonUnion` the exhaustiveness check now wants
those switches handled, and a directive for that rule would be reported
as unused under the editor config where it does not run. We give each
switch an explicit `default` case instead, which both rules accept and
which makes the previously implicit fall-through behavior explicit.
---
eslint.cli.config.mjs | 8 ++++++-
eslint.config.mjs | 24 +++++++++++++++++++
.../loaders/postcss-loader/src/index.ts | 4 +++-
packages/next/src/shared/lib/head.tsx | 3 ++-
4 files changed, 36 insertions(+), 3 deletions(-)
diff --git a/eslint.cli.config.mjs b/eslint.cli.config.mjs
index 85729ea54796..32b7d7529ec1 100644
--- a/eslint.cli.config.mjs
+++ b/eslint.cli.config.mjs
@@ -10,6 +10,9 @@ export default defineConfig([
// This override adds type-checked rules.
// Linting with type-checked rules is very slow and needs a lot of memory,
// so we exclude non-essential files.
+ // NOTE: eslint.config.mjs has a config block that mirrors these
+ // `files`/`ignores` to override non-type-checked rules for the same set of
+ // files. Keep both in sync if you change the globs.
ignores: [
'bench/**/*',
'examples/**/*',
@@ -27,7 +30,10 @@ export default defineConfig([
rules: {
// TODO: enable in follow-up PR
'@typescript-eslint/no-floating-promises': 'off',
- '@typescript-eslint/switch-exhaustiveness-check': 'error',
+ '@typescript-eslint/switch-exhaustiveness-check': [
+ 'error',
+ { requireDefaultForNonUnion: true },
+ ],
},
},
])
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 4a0f2ca7b9e4..3ff56f0429d2 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -394,6 +394,30 @@ export default defineConfig([
'@typescript-eslint/prefer-literal-enum-member': 'error',
},
},
+ {
+ // This block mirrors the `files`/`ignores` of the type-checked config in
+ // eslint.cli.config.mjs, so it targets exactly the files for which those
+ // type-aware rules run. Use it to override non-type-checked rules whose
+ // behavior overlaps with a type-checked rule. Keep the globs below in sync
+ // with eslint.cli.config.mjs.
+ files: ['**/*.ts', '**/*.tsx'],
+ ignores: [
+ 'bench/**/*',
+ 'examples/**/*',
+ 'test/**/*',
+ '**/*.d.ts',
+ 'turbopack/**/*',
+ ],
+ rules: {
+ // `@typescript-eslint/switch-exhaustiveness-check` already enforces
+ // complete switch coverage on these files: every member of a union or
+ // enum must be handled, and with `requireDefaultForNonUnion` a default
+ // is required for switches on plain types (string, number, …). Leaving
+ // `default-case` on would additionally force a redundant default on
+ // exhaustive union/enum switches.
+ 'default-case': 'off',
+ },
+ },
{
files: ['packages/**/*.ts', 'packages/**/*.tsx'],
plugins: {
diff --git a/packages/next/src/build/webpack/loaders/postcss-loader/src/index.ts b/packages/next/src/build/webpack/loaders/postcss-loader/src/index.ts
index 834efcbac12f..2fca8a06bd9c 100644
--- a/packages/next/src/build/webpack/loaders/postcss-loader/src/index.ts
+++ b/packages/next/src/build/webpack/loaders/postcss-loader/src/index.ts
@@ -83,7 +83,6 @@ export default async function loader(
}
for (const message of result.messages) {
- // eslint-disable-next-line default-case
switch (message.type) {
case 'dependency':
this.addDependency(message.file)
@@ -109,6 +108,9 @@ export default async function loader(
message.info
)
}
+ break
+ default:
+ break
}
}
diff --git a/packages/next/src/shared/lib/head.tsx b/packages/next/src/shared/lib/head.tsx
index 74e91b6fdfe0..f40fa3c6d215 100644
--- a/packages/next/src/shared/lib/head.tsx
+++ b/packages/next/src/shared/lib/head.tsx
@@ -73,7 +73,6 @@ function unique() {
}
}
- // eslint-disable-next-line default-case
switch (h.type) {
case 'title':
case 'base':
@@ -106,6 +105,8 @@ function unique() {
}
}
break
+ default:
+ break
}
return isUnique
From abbfa7e4c23a528c27aa76cd11303fc902278905 Mon Sep 17 00:00:00 2001
From: Hendrik Liebau
Date: Tue, 2 Jun 2026 14:46:02 +0200
Subject: [PATCH 4/6] Handle a purged browser cache on back navigation (#94317)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
When the browser's HTTP cache entry for a back-navigation target has
been evicted between forward visit and back-press — for example with a
long-lived tab, storage pressure, or a manual cache clear — the browser
re-fetches the document fresh from the server. Previously we relied on
`type === 'back_forward'` alone to decide that the document came from
cache, which meant we treated those re-fetches as cache restores too.
The persisted-chunk lookup would miss on the fresh response, and we'd
recover with an unnecessary `location.reload()`.
The same edge case is exposed by the Playwright/WebKit combination in
the upstack PR: bfcache isn't utilized there, so back-navigations always
come over the network, and the old single-signal check classified those
re-fetches as served-from-cache and triggered the same unnecessary
reload.
With this change we split the cache-restore detection into two phases.
At script-execution time we look at `deliveryType` (Chrome ≥109, Safari
≥17) and the navigation entry's `transferSize`/`encodedBodySize` to
decide cache-restore vs fresh-response when those fields are populated.
When the body bytes haven't been measured yet — the common case for
streaming Next.js dev RSC responses on every browser, and also WebKit
leaving both size fields at zero at exec time — we suspend the readable
until `pageshow` and re-check there. On a fresh re-fetch we route
through the live WebSocket-backed channel, which already has the debug
data for the new response, and skip the reload entirely.
The new `bfcache-regression` test exercises this edge case on Chromium
by clearing the browser cache via CDP between the forward and back
navigations, using a new `clearBrowserCache()` helper on the test
browser wrapper. The same exec-time code path is naturally hit by Safari
whenever its navigation entry's size fields are still zero at
script-execution time, but the harness can't force the eviction
deterministically there.
---
packages/next/src/client/dev/debug-channel.ts | 252 ++++++++++++++----
.../bfcache-regression.test.ts | 85 +++++-
test/lib/browsers/playwright.ts | 15 ++
3 files changed, 298 insertions(+), 54 deletions(-)
diff --git a/packages/next/src/client/dev/debug-channel.ts b/packages/next/src/client/dev/debug-channel.ts
index 837647f7993a..640ba95d7183 100644
--- a/packages/next/src/client/dev/debug-channel.ts
+++ b/packages/next/src/client/dev/debug-channel.ts
@@ -46,44 +46,141 @@ function persistDebugChannelToSessionStorage(requestId: string): void {
}
}
-function wasServedFromCache(): boolean {
- try {
- // There is exactly one PerformanceNavigationTiming entry per page load.
- const entry = performance.getEntriesByType('navigation')[0]
+const enum ExecTimeCacheDecision {
+ /**
+ * The HTML document was served from the browser's cache; replay the
+ * previously persisted chunks instead of waiting for the WebSocket-backed
+ * channel.
+ */
+ CacheRestore,
- if (!entry) {
- return false
- }
+ /**
+ * The HTML document came fresh from the server. The live WebSocket-backed
+ * channel will deliver the debug chunks.
+ */
+ FreshResponse,
- // HTTP cache restore detection isn't uniform across browsers, so we combine
- // two signals:
- //
- // 1. type === 'back_forward' — set on browser-history navigations
- // (back/forward) in all three browsers, and on tab duplication in
- // Chrome and Firefox. This only matters when scripts actually
- // re-execute; a bfcache restore preserves the entire JS context and
- // never reaches this code. The HMR WebSocket disqualifies bfcache in
- // Chrome and Firefox, so back/forward falls back to an HTTP cache
- // restore and we land here with this type set. Safari is more lenient
- // and often still uses bfcache for back/forward despite the WebSocket,
- // in which case this function isn't called and no recovery is needed.
- // 2. responseStart === 0 && responseEnd > 0 — Safari uses type='navigate'
- // on tab duplication. It sets responseStart to 0 when no
- // first-body-byte arrived over the network; fresh loads always have
- // responseStart > 0.
- //
- // Neither fires on Firefox's fresh streaming load, where transferSize is
- // transiently 0. That case has type='navigate' with a non-zero
- // responseStart, so it correctly returns false and avoids a
- // location.reload() loop that earlier (transferSize-only) versions of this
- // check triggered.
- return (
- entry.type === 'back_forward' ||
- (entry.responseStart === 0 && entry.responseEnd > 0)
- )
- } catch {
+ /**
+ * Can't tell from the navigation entry as it stands now. Caller should defer
+ * to `pageshow` and re-check there with `wasServedFromCacheAtPageshow`.
+ */
+ Undecided,
+}
+
+/**
+ * Decide at script-execution time whether the document was served from the
+ * browser's cache or freshly fetched from the server. `type === 'back_forward'`
+ * alone isn't enough: a back/forward navigation can also be a fresh server
+ * re-fetch when the HTTP cache entry was evicted (long-lived tab, storage
+ * pressure, manual cache clear), and treating that as a cache restore would
+ * trigger an unnecessary `location.reload()` when no persisted chunks are
+ * found.
+ */
+function wasServedFromCacheKnownAtExec(
+ entry: NavigationEntry | undefined
+): ExecTimeCacheDecision {
+ if (!entry) {
+ return ExecTimeCacheDecision.FreshResponse
+ }
+
+ // Safari tab-duplication cache restore: type='navigate' paired with
+ // responseStart=0 (no first-body-byte over the network) and a non-zero
+ // responseEnd. Fresh navigations always have responseStart > 0.
+ if (
+ entry.type === 'navigate' &&
+ entry.responseStart === 0 &&
+ entry.responseEnd > 0
+ ) {
+ return ExecTimeCacheDecision.CacheRestore
+ }
+
+ // Every remaining cache-restore signal requires a back/forward navigation.
+ // (bfcache restores don't re-execute scripts and never reach this code.)
+ if (entry.type !== 'back_forward') {
+ return ExecTimeCacheDecision.FreshResponse
+ }
+
+ // Chrome ≥109 and Safari ≥17 populate `deliveryType` at exec time even when
+ // the size fields aren't filled in yet. This is the only exec-time fast path
+ // for real Safari ≥17 cache restores (Safari leaves encodedBodySize at 0 at
+ // exec).
+ if (entry.deliveryType === 'cache') {
+ return ExecTimeCacheDecision.CacheRestore
+ }
+
+ // Chrome and Firefox publish an HTTP cache restore as transferSize=0 (no
+ // bytes over the wire) plus a non-zero cached body size at exec time.
+ if (entry.transferSize === 0 && entry.encodedBodySize > 0) {
+ return ExecTimeCacheDecision.CacheRestore
+ }
+
+ // No body bytes measured yet. Either the response is still streaming, or
+ // WebKit is reporting transferSize=0 and encodedBodySize=0 at exec time
+ // regardless of whether the document was cached or re-fetched. Defer to
+ // `pageshow` where the two cases become distinguishable.
+ if (entry.encodedBodySize === 0) {
+ return ExecTimeCacheDecision.Undecided
+ }
+
+ // Body bytes already measured at exec time with no other cache signal: a
+ // re-fetched back-nav whose response happened to complete before our script
+ // ran. The deferred branch above would have caught the same case if the
+ // response had still been streaming.
+ return ExecTimeCacheDecision.FreshResponse
+}
+
+/**
+ * Re-check the cache-restore decision at `pageshow`, when every browser has
+ * populated the navigation-entry size fields. Only called when
+ * `wasServedFromCacheKnownAtExec` returned `ExecTimeCacheDecision.Undecided`.
+ */
+function wasServedFromCacheAtPageshow(
+ entry: NavigationEntry | undefined
+): boolean {
+ if (!entry) {
return false
}
+
+ // Safari tab-duplication signature; see the matching branch in
+ // `wasServedFromCacheKnownAtExec`.
+ if (
+ entry.type === 'navigate' &&
+ entry.responseStart === 0 &&
+ entry.responseEnd > 0
+ ) {
+ return true
+ }
+
+ // A back/forward navigation where at least one of the size fields is zero
+ // means the body didn't come over the wire. Browsers signal a cache restore
+ // differently — Chrome/Firefox zero `transferSize` and keep a non-zero cached
+ // `encodedBodySize`; Safari does the inverse with a small `transferSize`
+ // (header overhead) and `encodedBodySize=0`; WebKit under Playwright zeros
+ // both. A fresh re-fetch populates both with the response size.
+ return (
+ entry.type === 'back_forward' &&
+ (entry.transferSize === 0 || entry.encodedBodySize === 0)
+ )
+}
+
+/**
+ * The DOM lib's `PerformanceNavigationTiming` doesn't include the
+ * `deliveryType` property yet, even though it's shipped in Chrome ≥109,
+ * Firefox ≥115, and Safari ≥17. See
+ * https://w3c.github.io/navigation-timing/#dom-performancenavigationtiming-deliverytype.
+ */
+type NavigationEntry = PerformanceNavigationTiming & {
+ readonly deliveryType?: string
+}
+
+function getNavigationEntry(): NavigationEntry | undefined {
+ try {
+ return performance.getEntriesByType('navigation')[0] as
+ | NavigationEntry
+ | undefined
+ } catch {
+ return undefined
+ }
}
function restoreDebugChannelFromSessionStorage(
@@ -183,22 +280,85 @@ export function createDebugChannel(
// Only attempt to restore the sessionStorage debug channel entry for the
// initial document load (no request headers). Client-side navigations pass
// request headers and should always use the WebSocket-backed debug channel.
- if (!requestHeaders && wasServedFromCache()) {
- const readable = restoreDebugChannelFromSessionStorage(requestId)
-
- if (readable) {
- return { readable }
+ if (!requestHeaders) {
+ switch (wasServedFromCacheKnownAtExec(getNavigationEntry())) {
+ case ExecTimeCacheDecision.CacheRestore:
+ return { readable: restoreDebugChannelOrReload(requestId) }
+ case ExecTimeCacheDecision.Undecided:
+ // Body bytes haven't been measured on the navigation entry yet. Suspend
+ // the stream until pageshow, re-check there, then source from the
+ // persisted chunks or the WebSocket-backed pair accordingly.
+ return { readable: createDeferredDebugChannelReadable(requestId) }
+ case ExecTimeCacheDecision.FreshResponse:
+ // Fall through to the shared WebSocket-backed channel below.
+ break
}
-
- // Debug channel can't be restored — debug deps would block hydration.
- // Force a fresh page load from the server. Return a never-closing stream
- // so the Flight client stays parked until the reload tears the document
- // down, instead of synchronously erroring with "Connection closed.".
- location.reload()
- return { readable: new ReadableStream() }
}
const { readable } = getOrCreateDebugChannelReadableWriterPair(requestId)
return { readable }
}
+
+/**
+ * Try to restore the debug channel from the persisted chunks. If none are
+ * found, force a fresh page load.
+ */
+function restoreDebugChannelOrReload(
+ requestId: string
+): ReadableStream {
+ const readable = restoreDebugChannelFromSessionStorage(requestId)
+
+ if (readable) {
+ return readable
+ }
+
+ // No persisted entry. Typically this happens when the HTTP cache held the
+ // HTML but the persisted entry was never written, or was overwritten by a
+ // newer document in this tab.
+ location.reload()
+
+ // Never-closing stream. Keeps the Flight client suspended until the reload
+ // tears the document down, instead of letting it synchronously error with
+ // "Connection closed.".
+ return new ReadableStream()
+}
+
+/**
+ * Used when `wasServedFromCacheKnownAtExec` returns
+ * `ExecTimeCacheDecision.Undecided`. Waits for `pageshow`, re-runs the check,
+ * and forwards data from either the persisted chunks or the WebSocket.
+ */
+function createDeferredDebugChannelReadable(
+ requestId: string
+): ReadableStream {
+ return new ReadableStream({
+ async start(controller) {
+ // By `pageshow` every browser has populated the navigation-entry size
+ // fields, so the re-check below is unambiguous.
+ await new Promise((resolve) => {
+ window.addEventListener('pageshow', () => resolve(), { once: true })
+ })
+
+ const source = wasServedFromCacheAtPageshow(getNavigationEntry())
+ ? restoreDebugChannelOrReload(requestId)
+ : getOrCreateDebugChannelReadableWriterPair(requestId).readable
+
+ const reader = source.getReader()
+ try {
+ while (true) {
+ const { done, value } = await reader.read()
+ if (done) {
+ controller.close()
+ return
+ }
+ controller.enqueue(value)
+ }
+ } catch (error) {
+ controller.error(error)
+ } finally {
+ reader.releaseLock()
+ }
+ },
+ })
+}
diff --git a/test/e2e/app-dir/bfcache-regression/bfcache-regression.test.ts b/test/e2e/app-dir/bfcache-regression/bfcache-regression.test.ts
index 86eb3835694d..8572a8f153a4 100644
--- a/test/e2e/app-dir/bfcache-regression/bfcache-regression.test.ts
+++ b/test/e2e/app-dir/bfcache-regression/bfcache-regression.test.ts
@@ -2,7 +2,7 @@ import { nextTestSetup } from 'e2e-utils'
import { assertNoConsoleErrors, retry } from 'next-test-utils'
describe('bfcache-regression', () => {
- const { next, isTurbopack } = nextTestSetup({
+ const { next, isTurbopack, isNextDev } = nextTestSetup({
files: __dirname,
})
@@ -48,13 +48,13 @@ describe('bfcache-regression', () => {
})
// Regression test for an infinite refresh loop on the initial load of a
- // streaming page. wasServedFromCache() in debug-channel.ts must not treat
- // a still-in-flight streaming response as an HTTP cache restore, or it
- // calls location.reload() and the next load hits the same condition. The
- // bug only manifests in browsers where PerformanceNavigationTiming
- // reports transferSize/encodedBodySize as 0 until the body finishes
- // arriving — Firefox in practice. Chrome and Safari report non-zero
- // values during streaming and aren't affected.
+ // streaming page. The cache-restore detection in debug-channel.ts must not
+ // treat a still-in-flight streaming response as an HTTP cache restore, or it
+ // triggers a location.reload() that lands in the same condition. Only
+ // manifests in browsers where PerformanceNavigationTiming reports
+ // transferSize/encodedBodySize as 0 until the body finishes arriving —
+ // Firefox in practice. Chrome and Safari populate those fields during
+ // streaming and aren't affected.
it('should not enter a refresh loop on initial load of a page with streaming dynamic content', async () => {
let loadCount = 0
const browser = await next.browser('/streaming', {
@@ -82,4 +82,73 @@ describe('bfcache-regression', () => {
await assertNoConsoleErrors(browser)
})
+
+ if (isNextDev && global.browserName === 'chrome') {
+ // Verifies the eviction edge case in the cache-restore detection. When the
+ // HTTP cache entry for the back-navigation target has been evicted between
+ // forward visit and back-press (long-lived tab, storage pressure, manual
+ // cache clear), the browser re-fetches the document fresh from the server.
+ // The debug-channel restore must NOT mistake that re-fetch for a cache
+ // restore and trigger a spurious location.reload() — the live
+ // WebSocket-backed channel already has the debug data for the fresh
+ // response.
+ //
+ // Chromium-only because clearing the browser cache via the test harness
+ // uses CDP, which Playwright only exposes for Chromium. The same exec-time
+ // code path is exercised by Safari whenever its navigation entry's size
+ // fields are still zero at script-execution time (the deferred-to-pageshow
+ // branch), but the harness can't deterministically force the eviction
+ // there.
+ it('should recover via the live debug channel when the back-navigation target was evicted from the HTTP cache', async () => {
+ const outputIndex = next.cliOutput.length
+ // Use /streaming as the back-nav target so the body is still streaming
+ // when our inline script reads PerformanceNavigationTiming — that forces
+ // the deferred branch (encodedBodySize === 0 at exec).
+ const browser = await next.browser('/streaming', {
+ pushErrorAsConsoleLog: true,
+ })
+
+ await retry(async () => {
+ expect(await browser.elementById('dynamic-content').text()).toBe(
+ 'Dynamic content'
+ )
+ })
+
+ // Navigate forward via the layout's MPA link (full page navigation, not a
+ // client-side transition).
+ await browser.elementByCss('a[href="/target-page"]').click()
+ expect(await (await browser.elementByCss('h2')).text()).toBe(
+ 'Target Page'
+ )
+
+ // Simulate cache eviction by clearing the browser HTTP cache via CDP.
+ // With the cached body gone, the browser back-navigation falls back to a
+ // fresh server fetch instead of an HTTP cache restore.
+ await browser.clearBrowserCache()
+
+ await browser.back()
+
+ // The page should render the dynamic content without a spurious reload.
+ await retry(async () => {
+ expect(await browser.elementById('dynamic-content').text()).toBe(
+ 'Dynamic content'
+ )
+ })
+
+ // '/streaming' should have been requested exactly twice: the initial
+ // forward load and the back-navigation re-fetch. A third request
+ // would indicate that the debug-channel restore mistook the re-fetch
+ // for a cache restore and triggered a spurious location.reload().
+ const output = next.cliOutput.slice(outputIndex)
+ const counts: Record = {}
+ for (const [, path] of output.matchAll(
+ /GET (\/(?:streaming|target-page)) /g
+ )) {
+ counts[path] = (counts[path] ?? 0) + 1
+ }
+ expect(counts).toEqual({ '/streaming': 2, '/target-page': 1 })
+
+ await assertNoConsoleErrors(browser)
+ })
+ }
})
diff --git a/test/lib/browsers/playwright.ts b/test/lib/browsers/playwright.ts
index dbd80182139d..0389781d23b0 100644
--- a/test/lib/browsers/playwright.ts
+++ b/test/lib/browsers/playwright.ts
@@ -389,6 +389,21 @@ export class Playwright {
await page.reload()
})
}
+ /**
+ * Evict the browser HTTP cache via CDP (`Network.clearBrowserCache`). This is
+ * only supported in Chromium; gate the calling test on `global.browserName
+ * === 'chrome'` (Playwright's Firefox and WebKit don't expose CDP).
+ */
+ clearBrowserCache() {
+ return this.startChain(async () => {
+ const session = await context!.newCDPSession(page)
+ try {
+ await session.send('Network.clearBrowserCache')
+ } finally {
+ await session.detach()
+ }
+ })
+ }
setDimensions({ width, height }: { height: number; width: number }) {
return this.startOrPreserveChain(() =>
page.setViewportSize({ width, height })
From 24ae16139c1eca3d392dd9685ab26d94a325a08f Mon Sep 17 00:00:00 2001
From: Hendrik Liebau
Date: Tue, 2 Jun 2026 14:46:03 +0200
Subject: [PATCH 5/6] Persist debug channel via `IndexedDB` without blocking
hydration (#94243)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
In development the debug channel that streams React's Server Component
debug information to the browser is buffered for the initial document
and persisted, so that it can be replayed when the browser later serves
the page from its HTTP cache — for example on back/forward navigation or
tab duplication — instead of forcing a full reload. This changes where
that buffer is persisted, moving it from `sessionStorage` to
`IndexedDB`.
The largest win comes from no longer having to serialize the data.
`sessionStorage` can only hold strings, so the binary debug chunks had
to be encoded into a string before being written and parsed back out
again on restore, which becomes expensive once the payload grows to
several megabytes. `IndexedDB` stores the chunks directly as the
`Uint8Array`s they already are, so there is no encode and parse round
trip on either side. Its asynchronous API helps a little on top of that,
since the write itself no longer has to happen synchronously.
The other improvement is that persistence no longer competes with
hydration. The initial document's debug stream closes while hydration is
still running, and the previous synchronous `sessionStorage` write
happened at exactly that moment, taking main thread time away from
hydration. The write is now deferred with `requestIdleCallback`, so it
only runs once the main thread is genuinely idle, which is after
hydration has drained, and it is skipped entirely if the page navigates
away before that happens, in which case a later restore simply falls
back to a reload. This is visible in the profiles below: with
`sessionStorage` there is still hydration work running after the
persistence task, whereas with the idle-scheduled `IndexedDB` write the
persistence only runs once hydration is done.
In a profile of a test page that deliberately transfers a large amount
of debug information, the persistence work on the main thread dropped
from more than 700 ms to roughly 25 to 45 ms. A real application with
far less debug data will see a smaller difference, so the test page
amplifies the effect, but the direction is the same.
The number of persisted entries stays bounded to 10, with the oldest
pruned on each write, and an end-to-end test covers the case where an
entry is pushed out by newer page loads so that navigating back to it
recovers through a page reload.
**Before with `sessionStorage`**:
**After with idle-scheduled `IndexedDB`:**
---
packages/next/src/client/dev/debug-channel.ts | 261 ++++++++++++------
.../app/large-debug-data/client.tsx | 9 +
.../app/large-debug-data/page.tsx | 28 ++
.../app-dir/bfcache-regression/app/layout.tsx | 4 +
.../app/purge/[slug]/page.tsx | 27 ++
.../bfcache-regression.test.ts | 120 ++++++++
6 files changed, 372 insertions(+), 77 deletions(-)
create mode 100644 test/e2e/app-dir/bfcache-regression/app/large-debug-data/client.tsx
create mode 100644 test/e2e/app-dir/bfcache-regression/app/large-debug-data/page.tsx
create mode 100644 test/e2e/app-dir/bfcache-regression/app/purge/[slug]/page.tsx
diff --git a/packages/next/src/client/dev/debug-channel.ts b/packages/next/src/client/dev/debug-channel.ts
index 640ba95d7183..b45613e6777d 100644
--- a/packages/next/src/client/dev/debug-channel.ts
+++ b/packages/next/src/client/dev/debug-channel.ts
@@ -8,43 +8,164 @@ export interface DebugChannelReadableWriterPair {
const pairs = new Map()
-const DEBUG_CHANNEL_STORAGE_KEY_PREFIX = '__next_debug_channel:'
-
-// Buffer for the initial document's debug channel data. Written to
-// sessionStorage once complete so it can be restored when the browser serves
-// the page from HTTP cache (back-forward navigation, tab duplication, etc.).
-let initialDocumentDebugChunks: Uint8Array[] = []
-
-function persistDebugChannelToSessionStorage(requestId: string): void {
- const key = DEBUG_CHANNEL_STORAGE_KEY_PREFIX + requestId
- const value = JSON.stringify(
- initialDocumentDebugChunks.map((chunk) => {
- let binary = ''
- for (let i = 0; i < chunk.byteLength; i++) {
- binary += String.fromCharCode(chunk[i])
- }
- return btoa(binary)
- })
- )
+const DB_NAME = '__next_debug_channel'
+const STORE_NAME = 'channels'
+const CREATED_AT_INDEX = 'createdAt'
+const MAX_ENTRIES = 10
+
+interface DebugChannelEntry {
+ readonly requestId: string
+ readonly createdAt: number
+ readonly chunks: Uint8Array[]
+}
+
+function openDebugChannelDB(): Promise {
+ return new Promise((resolve, reject) => {
+ const openRequest = indexedDB.open(DB_NAME, 1)
+ openRequest.onupgradeneeded = () => {
+ const store = openRequest.result.createObjectStore(STORE_NAME, {
+ keyPath: 'requestId',
+ })
+ store.createIndex(CREATED_AT_INDEX, 'createdAt')
+ }
+ openRequest.onsuccess = () => resolve(openRequest.result)
+ openRequest.onerror = () => reject(openRequest.error)
+ openRequest.onblocked = () => reject(openRequest.error)
+ })
+}
+
+/**
+ * Resolves on the next idle period via `requestIdleCallback`, falling back to a
+ * `setTimeout` where `requestIdleCallback` is unavailable.
+ */
+function whenIdle(): Promise {
+ return new Promise((resolve) => {
+ if (typeof requestIdleCallback === 'function') {
+ requestIdleCallback(() => resolve())
+ } else {
+ setTimeout(resolve, 0)
+ }
+ })
+}
+async function persistDebugChannelToIndexedDB(
+ requestId: string,
+ chunks: Uint8Array[]
+): Promise {
+ let db: IDBDatabase
try {
- sessionStorage.setItem(key, value)
- } catch {
- // Likely a quota error. Drop entries from previous documents in this tab
- // (we only need to restore the current one's entry on cache restore) and
- // retry once. If it still fails, skip silently — the location.reload()
- // fallback in createDebugChannel handles this case.
- for (let i = sessionStorage.length - 1; i >= 0; i--) {
- const k = sessionStorage.key(i)
- if (k?.startsWith(DEBUG_CHANNEL_STORAGE_KEY_PREFIX) && k !== key) {
- sessionStorage.removeItem(k)
+ db = await openDebugChannelDB()
+ } catch (error) {
+ console.debug('Failed to open debug channel IndexedDB for write', error)
+ return
+ }
+
+ try {
+ await new Promise((resolve, reject) => {
+ const transaction = db.transaction(STORE_NAME, 'readwrite')
+ const store = transaction.objectStore(STORE_NAME)
+
+ store.put({
+ requestId,
+ createdAt: Date.now(),
+ chunks,
+ } satisfies DebugChannelEntry)
+
+ // Prune oldest entries beyond the cap to bound storage growth across tabs
+ // and/or page loads. The createdAt index gives ordered traversal without
+ // scanning, and the cursor deletes commit atomically with the put above.
+ const countReq = store.count()
+ countReq.onsuccess = () => {
+ let entriesToDelete = countReq.result - MAX_ENTRIES
+ if (entriesToDelete <= 0) {
+ return
+ }
+ const cursorReq = store.index(CREATED_AT_INDEX).openCursor()
+ cursorReq.onsuccess = () => {
+ const cursor = cursorReq.result
+ if (!cursor || entriesToDelete === 0) {
+ return
+ }
+ cursor.delete()
+ entriesToDelete--
+ cursor.continue()
+ }
}
- }
- try {
- sessionStorage.setItem(key, value)
- } catch {}
+
+ transaction.oncomplete = () => {
+ if (process.env.__NEXT_TEST_MODE) {
+ // Test-only flag, set once this document's debug channel entry is
+ // durably committed. Persistence is deferred to an idle callback and
+ // the IndexedDB write is async, so this flag lets e2e tests await
+ // persistence deterministically — coupling only to "an entry was
+ // persisted" and not to how or where it is stored. It resets
+ // naturally on each navigation since every document gets a fresh
+ // window. The local cast keeps the augmentation out of the shipped
+ // declaration files.
+ ;(
+ self as { __NEXT_DEBUG_CHANNEL_PERSISTED?: boolean }
+ ).__NEXT_DEBUG_CHANNEL_PERSISTED = true
+ }
+ resolve()
+ }
+ transaction.onerror = () => reject(transaction.error)
+ transaction.onabort = () => reject(transaction.error)
+ })
+ } catch (error) {
+ // Best-effort: if persistence fails (quota, transaction abort, etc.), an
+ // HTTP cache restore will fall back to location.reload() since no entry
+ // will be found.
+ console.debug('Failed to write debug channel entry to IndexedDB', error)
+ } finally {
+ db.close()
}
}
+function restoreDebugChannelFromIndexedDB(
+ requestId: string
+): ReadableStream {
+ return new ReadableStream({
+ async start(controller) {
+ let entry: DebugChannelEntry | undefined
+
+ try {
+ const db = await openDebugChannelDB()
+ try {
+ entry = await new Promise((resolve, reject) => {
+ const tx = db.transaction(STORE_NAME, 'readonly')
+ const store = tx.objectStore(STORE_NAME)
+ const getReq: IDBRequest =
+ store.get(requestId)
+ getReq.onsuccess = () => resolve(getReq.result)
+ getReq.onerror = () => reject(getReq.error)
+ })
+ } finally {
+ db.close()
+ }
+ } catch (error) {
+ // Treat any IDB failure as "no entry" and fall through to reload.
+ console.debug(
+ 'Failed to read debug channel entry from IndexedDB',
+ error
+ )
+ }
+
+ if (!entry) {
+ // Debug channel can't be restored — missing debug chunks would block
+ // hydration. Force a fresh page load from the server. Leave the stream
+ // parked (no enqueue, no close) so the Flight client stays put until
+ // the reload tears the document down, instead of synchronously erroring
+ // with "Connection closed.".
+ location.reload()
+ return
+ }
+
+ for (const chunk of entry.chunks) {
+ controller.enqueue(chunk)
+ }
+ controller.close()
+ },
+ })
+}
const enum ExecTimeCacheDecision {
/**
@@ -183,54 +304,22 @@ function getNavigationEntry(): NavigationEntry | undefined {
}
}
-function restoreDebugChannelFromSessionStorage(
- requestId: string
-): ReadableStream | undefined {
- try {
- const serializedData = sessionStorage.getItem(
- DEBUG_CHANNEL_STORAGE_KEY_PREFIX + requestId
- )
-
- if (!serializedData) {
- return undefined
- }
-
- const chunks = (JSON.parse(serializedData) as string[]).map((base64) => {
- const binary = atob(base64)
- const bytes = new Uint8Array(binary.length)
- for (let i = 0; i < binary.length; i++) {
- bytes[i] = binary.charCodeAt(i)
- }
- return bytes
- })
-
- return new ReadableStream({
- start(controller) {
- for (const chunk of chunks) {
- controller.enqueue(chunk)
- }
- controller.close()
- },
- })
- } catch {
- return undefined
- }
-}
-
export function getOrCreateDebugChannelReadableWriterPair(
requestId: string
): DebugChannelReadableWriterPair {
let pair = pairs.get(requestId)
if (!pair) {
- // Only buffer chunks for the initial document's debug channel, not for
- // client-side navigation requests.
- const shouldBuffer = requestId === self.__next_r
+ // Buffer chunks only for the initial document's debug channel, not for
+ // client-side navigation requests. Persisted to IndexedDB once complete so
+ // it can be restored when the browser serves the page from HTTP cache
+ // (back-forward navigation, tab duplication, etc.).
+ const chunks: Uint8Array[] | null = requestId === self.__next_r ? [] : null
const { readable, writable } = new TransformStream({
transform(chunk, controller) {
- if (shouldBuffer) {
- initialDocumentDebugChunks.push(chunk.slice())
+ if (chunks) {
+ chunks.push(chunk.slice())
}
controller.enqueue(chunk)
},
@@ -240,12 +329,30 @@ export function getOrCreateDebugChannelReadableWriterPair(
pairs.set(requestId, pair)
pair.writer.closed
- .then(() => {
- if (shouldBuffer) {
- persistDebugChannelToSessionStorage(requestId)
+ .then(async () => {
+ if (!chunks) {
+ return
+ }
+ // The initial document's debug stream closes while hydration is still
+ // running, so persisting here would steal main-thread time from it.
+ // Wait for genuine idle (no timeout): persistence is best-effort, so if
+ // the page never idles before navigation we skip it and a later restore
+ // falls back to a reload, rather than forcing a blocking write.
+ await whenIdle()
+ await persistDebugChannelToIndexedDB(requestId, chunks)
+ })
+ .catch((error) => {
+ // writer.closed rejected (e.g., stream aborted) — nothing to persist.
+ console.debug('Debug channel writer closed with error', error)
+ })
+ .finally(() => {
+ pairs.delete(requestId)
+ // Release the buffered chunk bytes once the channel is done, whether or
+ // not we were able to persist them.
+ if (chunks) {
+ chunks.length = 0
}
})
- .finally(() => pairs.delete(requestId))
}
return pair
@@ -277,7 +384,7 @@ export function createDebugChannel(
}
}
- // Only attempt to restore the sessionStorage debug channel entry for the
+ // Only attempt to restore the IndexedDB debug channel entry for the
// initial document load (no request headers). Client-side navigations pass
// request headers and should always use the WebSocket-backed debug channel.
if (!requestHeaders) {
@@ -307,7 +414,7 @@ export function createDebugChannel(
function restoreDebugChannelOrReload(
requestId: string
): ReadableStream {
- const readable = restoreDebugChannelFromSessionStorage(requestId)
+ const readable = restoreDebugChannelFromIndexedDB(requestId)
if (readable) {
return readable
diff --git a/test/e2e/app-dir/bfcache-regression/app/large-debug-data/client.tsx b/test/e2e/app-dir/bfcache-regression/app/large-debug-data/client.tsx
new file mode 100644
index 000000000000..9047d5ebad94
--- /dev/null
+++ b/test/e2e/app-dir/bfcache-regression/app/large-debug-data/client.tsx
@@ -0,0 +1,9 @@
+'use client'
+
+import { useState } from 'react'
+
+export function ClientComponent() {
+ const [count, setCount] = useState(0)
+
+ return
+}
diff --git a/test/e2e/app-dir/bfcache-regression/app/large-debug-data/page.tsx b/test/e2e/app-dir/bfcache-regression/app/large-debug-data/page.tsx
new file mode 100644
index 000000000000..f97094b0e466
--- /dev/null
+++ b/test/e2e/app-dir/bfcache-regression/app/large-debug-data/page.tsx
@@ -0,0 +1,28 @@
+import { Suspense } from 'react'
+import { ClientComponent } from './client'
+
+// This page is only for manual performance profiling of the debug channel
+// persistence (it streams a large amount of debug data). It is not used by any
+// end-to-end test.
+async function Home() {
+ for (let i = 0; i < 50; i++) {
+ await new Promise((resolve) =>
+ setTimeout(() => resolve('a'.repeat(1_000_000)))
+ )
+ }
+
+ return (
+