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`**: sessionStorage **After with idle-scheduled `IndexedDB`:** 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 ( +
+

Large Debug Data

+ +
+ ) +} + +export default function Page() { + return ( + Loading...

}> + +
+ ) +} diff --git a/test/e2e/app-dir/bfcache-regression/app/layout.tsx b/test/e2e/app-dir/bfcache-regression/app/layout.tsx index b186cdc61084..1b8300d33c67 100644 --- a/test/e2e/app-dir/bfcache-regression/app/layout.tsx +++ b/test/e2e/app-dir/bfcache-regression/app/layout.tsx @@ -1,3 +1,4 @@ +import Link from 'next/link' import { ReactNode } from 'react' export default function Root({ children }: { children: ReactNode }) { return ( @@ -6,6 +7,9 @@ export default function Root({ children }: { children: ReactNode }) {

MPA Link

+

+ Large Debug Data +

{children}
diff --git a/test/e2e/app-dir/bfcache-regression/app/purge/[slug]/page.tsx b/test/e2e/app-dir/bfcache-regression/app/purge/[slug]/page.tsx new file mode 100644 index 000000000000..438be18ab7fe --- /dev/null +++ b/test/e2e/app-dir/bfcache-regression/app/purge/[slug]/page.tsx @@ -0,0 +1,27 @@ +const numberOfPages = 11 + +export function generateStaticParams() { + return Array.from({ length: numberOfPages }, (_, i) => ({ + slug: String(i + 1), + })) +} + +export default async function Page({ + params, +}: { + params: Promise<{ slug: string }> +}) { + const { slug } = await params + const n = Number(slug) + + return ( +
+

Purge {n}

+ {n < numberOfPages ? ( + + ) : null} +
+ ) +} 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 8572a8f153a4..6c10e86be66d 100644 --- a/test/e2e/app-dir/bfcache-regression/bfcache-regression.test.ts +++ b/test/e2e/app-dir/bfcache-regression/bfcache-regression.test.ts @@ -151,4 +151,124 @@ describe('bfcache-regression', () => { await assertNoConsoleErrors(browser) }) } + + if ( + // Persistence only exists in dev. + isNextDev && + // TODO: Re-enable for node streams once the React debug channel integration + // is fixed there. With `__NEXT_USE_NODE_STREAMS` the debug channel readable + // doesn't close as expected, so the client never persists the buffered + // entry and this test's wait-for-persisted step times out. + !process.env.__NEXT_USE_NODE_STREAMS + ) { + it('should reload to recover when a debug channel entry was pruned by newer page loads', async () => { + // The debug channel for the initial document is buffered and persisted to + // IndexedDB so it can be restored when the browser serves the page from + // the HTTP cache (back-forward navigation). Persistence is bounded to a + // maximum number of entries, pruning the oldest on each write. This + // verifies that an entry pushed out by newer page loads is no longer + // restorable, so going back to it recovers via a full reload instead. + + // One past the persistence cap (MAX_ENTRIES = 10): loading the whole + // chain writes 11 entries, pruning exactly the first page's entry and + // leaving /purge/2..11 cached. + const PAGES = 11 + + // Snapshot the server output so we can count requests made during this + // test. Recovery is observed through server requests rather than client + // load events: a still-cached page is restored client-side from the HTTP + // cache with no server request, while the pruned page misses and recovers + // with a full reload, which is a fresh server request. Load-event counts + // would be browser-dependent here, since some browsers fire the reload + // before the back-navigation's own load event and some after. + const outputIndex = next.cliOutput.length + const browser = await next.browser('/purge/1') + + // Wait until the just-loaded page's debug channel has been durably + // committed to IndexedDB before navigating away. Persistence is deferred + // to an idle callback and its IndexedDB write is async; navigating before + // it commits would abort the transaction and drop the entry. The page + // sets a flag once the commit completes (test mode only), which resets + // naturally on each navigation since every document gets a fresh window. + const waitForPersisted = () => + retry(async () => { + expect( + await browser.eval( + () => (self as any).__NEXT_DEBUG_CHANNEL_PERSISTED + ) + ).toBe(true) + }) + + // Hard-navigate through the chain. Each load persists its own entry, so + // after more than MAX_ENTRIES loads the earliest pages are pruned. + for (let n = 1; n <= PAGES; n++) { + await retry(async () => { + expect(await browser.elementById(`purge-${n}`).text()).toBe( + `Purge ${n}` + ) + }) + await waitForPersisted() + if (n < PAGES) { + await browser.elementById('next').click() + } + } + + // Back-navigate the whole way to the first page. Each step restores the + // page's HTML from the HTTP cache and re-runs the debug channel restore. + for (let n = PAGES; n > 1; n--) { + await browser.back() + await retry(async () => { + expect(await browser.elementById(`purge-${n - 1}`).text()).toBe( + `Purge ${n - 1}` + ) + }) + } + + // Per-page server request counts after the forward + back traversal. + // /purge/1 reaches 2 requests in every browser but via different paths: + // + // Chrome and Firefox restore each back-navigation from the HTTP cache + // (the HMR WebSocket disqualifies bfcache, so the browser falls back to + // HTTP cache restore with no server request). /purge/2..10 stay at one + // request because their IDB entries are still around and the restore + // replays them silently. /purge/1's IDB entry was pruned by the time we + // get back to it (MAX_ENTRIES=10), so its restore misses and recovers + // via a single location.reload() — that's the second server request. + // + // Playwright's WebKit is encoded as a separate expectation because it + // doesn't match real Safari behavior. Real Safari keeps recent pages in + // bfcache and falls back to HTTP cache restore for evicted ones, so it + // would behave like Chrome/Firefox here. Playwright's WebKit instead + // re-fetches every back-navigation target from the server, which adds + // one extra server request per back-step (including /purge/1 — the same + // re-fetch behavior already accounts for its second request, so the + // pruned IDB entry never triggers a reload there). The fresh re-fetch + // is correctly classified as a non-cache-restore by debug-channel.ts + // (the deferred-pageshow branch routes it to the live WebSocket-backed + // channel), so no spurious reload follows. + const isSafari = global.browserName === 'safari' + await retry(async () => { + const getCounts: Record = {} + const output = next.cliOutput.slice(outputIndex) + for (const [, path] of output.matchAll(/GET (\/purge\/\d+) /g)) { + getCounts[path] = (getCounts[path] ?? 0) + 1 + } + expect(getCounts).toEqual({ + '/purge/1': 2, + // Chrome/Firefox: 1 forward only (HTTP cache restore on back). + // Safari (Playwright/WebKit): 1 forward + 1 back re-fetch = 2. + '/purge/2': isSafari ? 2 : 1, + '/purge/3': isSafari ? 2 : 1, + '/purge/4': isSafari ? 2 : 1, + '/purge/5': isSafari ? 2 : 1, + '/purge/6': isSafari ? 2 : 1, + '/purge/7': isSafari ? 2 : 1, + '/purge/8': isSafari ? 2 : 1, + '/purge/9': isSafari ? 2 : 1, + '/purge/10': isSafari ? 2 : 1, + '/purge/11': 1, + }) + }) + }) + } }) From beaa1b3886eefbaf937e797913cfee2c849f7a18 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Tue, 2 Jun 2026 14:46:03 +0200 Subject: [PATCH 6/6] Fix debug channel not closing on the client with Node streams (#94268) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The debug channel's Node streams implementation never closed on the client. This leaked memory, because the resources held for each debug channel — the buffered chunks and its entry in the client-side registry, and the pending reader and stream pipeline on the server — are only released once the channel closes. It also meant the buffered debug entry was never persisted to `IndexedDB`, so the bfcache restore test timed out waiting for it. The server-side write target forwarded React's writes into the readable side with `passthrough.push()` and signalled completion with `passthrough.push(null)`. Because a `PassThrough` is a `Duplex`, pushing `null` ends only its readable half and leaves the writable half open, so `writableEnded` stays `false`. The readable is later consumed through `Readable.toWeb()`, both in `connectReactDebugChannel` and inside `teeStream`, and `Readable.toWeb()` never closes the resulting web stream while the writable half is still open. The close therefore never reached the client, nothing was cleaned up, and the test's wait-for-persisted step never resolved. Forwarding through `passthrough.write()` and `passthrough.end()` instead ends both halves, so the close propagates through the conversion and the channel closes on the client as expected. This also re-enables the previously skipped node streams case of the bfcache regression test, which now passes alongside the web streams case. --- .../app-render/debug-channel-server.node.ts | 30 ++++++++++++------- .../bfcache-regression.test.ts | 9 +----- 2 files changed, 21 insertions(+), 18 deletions(-) diff --git a/packages/next/src/server/app-render/debug-channel-server.node.ts b/packages/next/src/server/app-render/debug-channel-server.node.ts index 0c572ff66bc2..f4aaa4f084be 100644 --- a/packages/next/src/server/app-render/debug-channel-server.node.ts +++ b/packages/next/src/server/app-render/debug-channel-server.node.ts @@ -14,24 +14,34 @@ export { createWebDebugChannel } from './debug-channel-server.web' * which expects debugChannel to be a Node.js stream with a .write() method. */ export function createNodeDebugChannel(): DebugChannelPair { - const readable = new PassThrough() + // The readable side is a PassThrough that the client reads from. The + // server-side write target is a separate, write-only Writable that forwards + // into it rather than the PassThrough itself: React's renderToPipeableStream + // detects `.read()` on the debug channel and would enter bidirectional mode, + // reading its own output back as commands. + // + // The forwarding must use `passthrough.write()` / `passthrough.end()`, not + // `passthrough.push()` / `passthrough.push(null)`. A PassThrough is a Duplex; + // pushing `null` ends only its readable half and leaves the writable half + // open (`writableEnded` stays false). The readable is later consumed via + // `Readable.toWeb()` (in `connectReactDebugChannel`, and inside `teeStream`), + // and `Readable.toWeb()` never closes the resulting web stream while the + // PassThrough's writable half is still open — so the debug channel never + // closes on the client. Ending it via `passthrough.end()` closes both halves + // and the close propagates. + const passthrough = new PassThrough() - // Use a plain Writable instead of exposing the PassThrough directly. - // React's renderToPipeableStream detects .read() on the debugChannel and - // enters bidirectional mode, reading its own output back as commands. const writable = new Writable({ - write(chunk, _encoding, callback) { - readable.push(chunk) - callback() + write(chunk, encoding, callback) { + passthrough.write(chunk, encoding, callback) }, final(callback) { - readable.push(null) - callback() + passthrough.end(callback) }, }) return { serverSide: writable, - clientSide: { readable }, + clientSide: { readable: passthrough }, } } 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 6c10e86be66d..7f49d00790b5 100644 --- a/test/e2e/app-dir/bfcache-regression/bfcache-regression.test.ts +++ b/test/e2e/app-dir/bfcache-regression/bfcache-regression.test.ts @@ -152,15 +152,8 @@ describe('bfcache-regression', () => { }) } - if ( + if (isNextDev) { // Persistence only exists in dev. - isNextDev && - // TODO: Re-enable for node streams once the React debug channel integration - // is fixed there. With `__NEXT_USE_NODE_STREAMS` the debug channel readable - // doesn't close as expected, so the client never persists the buffered - // entry and this test's wait-for-persisted step times out. - !process.env.__NEXT_USE_NODE_STREAMS - ) { it('should reload to recover when a debug channel entry was pruned by newer page loads', async () => { // The debug channel for the initial document is buffered and persisted to // IndexedDB so it can be restored when the browser serves the page from