From 36dd9e21d60b0495c0d7f6530c7a3cf83a64f474 Mon Sep 17 00:00:00 2001
From: Fabian Hiller
Date: Tue, 18 Aug 2026 09:44:20 -0400
Subject: [PATCH 1/6] docs: mention Valibot as validation library option in
forms guides (#97468)
The forms guides recommend validating with a library like Zod. This
broadens the wording to also mention Valibot, which covers the same use
case with a smaller bundle footprint. The authentication guide already
lists Zod or Yup, so Valibot is added to that list as well. The existing
Zod examples are unchanged. I'm the author of Valibot.
- Ran `prettier --check` on the touched files with the repo's pinned
version.
---
docs/01-app/02-guides/authentication.mdx | 2 +-
docs/01-app/02-guides/forms.mdx | 2 +-
docs/02-pages/02-guides/forms.mdx | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/docs/01-app/02-guides/authentication.mdx b/docs/01-app/02-guides/authentication.mdx
index 2715ed476443..4c808308a2f8 100644
--- a/docs/01-app/02-guides/authentication.mdx
+++ b/docs/01-app/02-guides/authentication.mdx
@@ -100,7 +100,7 @@ export async function signup(formData) {}
#### 2. Validate form fields on the server
-Use the Server Action to validate the form fields on the server. If your authentication provider doesn't provide form validation, you can use a schema validation library like [Zod](https://zod.dev/) or [Yup](https://github.com/jquense/yup).
+Use the Server Action to validate the form fields on the server. If your authentication provider doesn't provide form validation, you can use a schema validation library like [Zod](https://zod.dev/), [Valibot](https://valibot.dev/) or [Yup](https://github.com/jquense/yup).
Using Zod as an example, you can define a form schema with appropriate error messages:
diff --git a/docs/01-app/02-guides/forms.mdx b/docs/01-app/02-guides/forms.mdx
index 2a6a6cfacea9..3a2598cb85c2 100644
--- a/docs/01-app/02-guides/forms.mdx
+++ b/docs/01-app/02-guides/forms.mdx
@@ -131,7 +131,7 @@ export async function updateUser(userId, formData) {}
Forms can be validated on the client or server.
- For **client-side validation**, you can use the HTML attributes like `required` and `type="email"` for basic validation.
-- For **server-side validation**, you can use a library like [zod](https://zod.dev/) to validate the form fields. For example:
+- For **server-side validation**, you can use a schema validation library like [Zod](https://zod.dev/) or [Valibot](https://valibot.dev/) to validate the form fields. For example:
```tsx filename="app/actions.ts" switcher
'use server'
diff --git a/docs/02-pages/02-guides/forms.mdx b/docs/02-pages/02-guides/forms.mdx
index ec6b418143b6..f34247b9a047 100644
--- a/docs/02-pages/02-guides/forms.mdx
+++ b/docs/02-pages/02-guides/forms.mdx
@@ -96,7 +96,7 @@ export default function Page() {
We recommend using HTML validation like `required` and `type="email"` for basic client-side form validation.
-For more advanced server-side validation, you can use a schema validation library like [zod](https://zod.dev/) to validate the form fields before mutating the data:
+For more advanced server-side validation, you can use a schema validation library like [Zod](https://zod.dev/) or [Valibot](https://valibot.dev/) to validate the form fields before mutating the data:
```ts filename="pages/api/submit.ts" switcher
import type { NextApiRequest, NextApiResponse } from 'next'
From da50acde10a9af9bac7282b607fd4d2b215b28e1 Mon Sep 17 00:00:00 2001
From: Niklas Mischkulnig <4586894+mischnic@users.noreply.github.com>
Date: Tue, 18 Aug 2026 15:59:26 +0200
Subject: [PATCH 2/6] Turbopack: gracefully handle outputFileTracingIncludes
matching a symlink (#97507)
Closes https://github.com/vercel/next.js/pull/96999
Make sure we don't do `.read().hash()` which is incorrect with symlinks.
Instead, hash the symlink itself instead of its target. This is what we
copy into the function source anyway
---------
Co-authored-by: vercel-fleet[bot] <308483924+vercel-fleet[bot]@users.noreply.github.com>
Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com>
---
crates/next-api/src/next_server_nft.rs | 6 ++--
crates/next-api/src/nft_json.rs | 3 +-
.../app/include-me/link-to-dir | 1 +
.../build-trace-extra-entries-turbo.test.ts | 17 +++++++++++
.../crates/turbo-tasks-fs/src/content.rs | 15 ++++++++++
turbopack/crates/turbo-tasks-fs/src/path.rs | 29 +++++++++++++++++++
6 files changed, 65 insertions(+), 6 deletions(-)
create mode 120000 test/production/build-trace-extra-entries-turbo/app/include-me/link-to-dir
diff --git a/crates/next-api/src/next_server_nft.rs b/crates/next-api/src/next_server_nft.rs
index 77b3150415b4..7abd4b326a1f 100644
--- a/crates/next-api/src/next_server_nft.rs
+++ b/crates/next-api/src/next_server_nft.rs
@@ -237,8 +237,7 @@ impl Asset for ServerNftJsonAsset {
.get_relative_path_to(&module_path)
.context("failed to compute relative path for server NFT JSON")?,
module_path
- .read()
- .hash(hash_salt, HashAlgorithm::Xxh3Hash128Hex)
+ .hash_file(hash_salt, HashAlgorithm::Xxh3Hash128Hex)
.await?,
));
@@ -258,8 +257,7 @@ impl Asset for ServerNftJsonAsset {
base_dir
.get_relative_path_to(file)
.context("failed to compute relative path for server NFT JSON")?,
- file.read()
- .hash(hash_salt, HashAlgorithm::Xxh3Hash128Hex)
+ file.hash_file(hash_salt, HashAlgorithm::Xxh3Hash128Hex)
.await?,
))
}
diff --git a/crates/next-api/src/nft_json.rs b/crates/next-api/src/nft_json.rs
index 6d003c8adcf5..138c540c56aa 100644
--- a/crates/next-api/src/nft_json.rs
+++ b/crates/next-api/src/nft_json.rs
@@ -241,8 +241,7 @@ impl Asset for NftJsonAsset {
relative_path,
Either::Left(
file_path
- .read()
- .hash(hash_salt, HashAlgorithm::Xxh3Hash128Hex)
+ .hash_file(hash_salt, HashAlgorithm::Xxh3Hash128Hex)
.await?,
),
))
diff --git a/test/production/build-trace-extra-entries-turbo/app/include-me/link-to-dir b/test/production/build-trace-extra-entries-turbo/app/include-me/link-to-dir
new file mode 120000
index 000000000000..efcdaa6e77b7
--- /dev/null
+++ b/test/production/build-trace-extra-entries-turbo/app/include-me/link-to-dir
@@ -0,0 +1 @@
+../content
\ No newline at end of file
diff --git a/test/production/build-trace-extra-entries-turbo/build-trace-extra-entries-turbo.test.ts b/test/production/build-trace-extra-entries-turbo/build-trace-extra-entries-turbo.test.ts
index da5f326da58c..5f5207ca340f 100644
--- a/test/production/build-trace-extra-entries-turbo/build-trace-extra-entries-turbo.test.ts
+++ b/test/production/build-trace-extra-entries-turbo/build-trace-extra-entries-turbo.test.ts
@@ -72,6 +72,23 @@ describe('build trace with extra entries', () => {
(file: string) => file === '../../../include-me/second.txt'
)
).toBe(true)
+ if (isTurbopack) {
+ // A symlink matched by outputFileTracingIncludes is traced as the symlink itself, even
+ // when it points at a directory (this used to fail the build with
+ // `reading file "..." Is a directory (os error 21)`).
+ // The webpack tracer globs with `nodir: true`, which drops directory symlinks, so this
+ // only applies to Turbopack.
+ expect(
+ tracedFiles.some(
+ (file: string) => file === '../../../include-me/link-to-dir'
+ )
+ ).toBe(true)
+ expect(
+ appDirRoute1Trace.files.some(
+ (file: string) => file === '../../../../include-me/link-to-dir'
+ )
+ ).toBe(true)
+ }
expect(
indexTrace.files.some((file: string) => file.includes('exclude-me'))
).toBe(false)
diff --git a/turbopack/crates/turbo-tasks-fs/src/content.rs b/turbopack/crates/turbo-tasks-fs/src/content.rs
index dd6d4bf3db6e..8b90fb54f9ec 100644
--- a/turbopack/crates/turbo-tasks-fs/src/content.rs
+++ b/turbopack/crates/turbo-tasks-fs/src/content.rs
@@ -206,6 +206,21 @@ pub enum LinkContent {
NotFound,
}
+#[turbo_tasks::value_impl]
+impl LinkContent {
+ /// Hashes the link itself (its target and type), not the content of whatever the link points
+ /// at. This mirrors [`FileContent::hash`] and is the right content hash for consumers that
+ /// re-create a symlink as a symlink instead of copying the resolved file.
+ #[turbo_tasks::function]
+ pub async fn hash(&self, salt: Vc, algorithm: HashAlgorithm) -> Result> {
+ Ok(Vc::cell(RcStr::from(deterministic_hash(
+ &salt.await?,
+ self,
+ algorithm,
+ ))))
+ }
+}
+
#[turbo_tasks::value(shared)]
#[derive(Clone, DeterministicHash, PartialOrd, Ord)]
pub struct File {
diff --git a/turbopack/crates/turbo-tasks-fs/src/path.rs b/turbopack/crates/turbo-tasks-fs/src/path.rs
index 4863c2ec109f..cf9a88819072 100644
--- a/turbopack/crates/turbo-tasks-fs/src/path.rs
+++ b/turbopack/crates/turbo-tasks-fs/src/path.rs
@@ -11,6 +11,7 @@ use turbo_tasks::{
Completion, NonLocalValue, ResolvedVc, ValueToString, ValueToStringRef, Vc, trace::TraceRawVcs,
turbobail, turbofmt,
};
+use turbo_tasks_hash::HashAlgorithm;
use turbo_unix_path::{get_parent_path, get_relative_path_to, join_path, normalize_path};
use crate::{
@@ -398,6 +399,15 @@ impl FileSystemPath {
self.fs().read(self.clone()).parse_json5()
}
+ /// Hashes the file content (but not as a byte-exact content hash). This does NOT follow
+ /// symlinks, so use this when you only want the hash of the file itself, not whatever it
+ /// might point to.
+ ///
+ /// This is basically `isSymlink ? self.read_link().hash() : self.read().hash()`.
+ pub fn hash_file(&self, salt: Vc, algorithm: HashAlgorithm) -> Vc {
+ hash_file(self.clone(), salt, algorithm)
+ }
+
/// Reads content of a directory.
///
/// DETERMINISM: Result is in random order. Either sort result or do not
@@ -681,6 +691,25 @@ async fn realpath_with_links(path: FileSystemPath) -> Result>
.cell())
}
+#[turbo_tasks::function]
+async fn hash_file(
+ path: FileSystemPath,
+ salt: Vc,
+ algorithm: HashAlgorithm,
+) -> Result> {
+ match *path.get_type().await? {
+ FileSystemEntryType::File => Ok(path.read().hash(salt, algorithm)),
+ FileSystemEntryType::Symlink => Ok(path.read_link().hash(salt, algorithm)),
+ FileSystemEntryType::NotFound | FileSystemEntryType::Error => {
+ // Should this rather be `return None`?
+ turbobail!("Cannot hash content of missing path {path}")
+ }
+ FileSystemEntryType::Directory | FileSystemEntryType::Other => {
+ turbobail!("Cannot hash content of non-file path {path}")
+ }
+ }
+}
+
#[cfg(test)]
mod tests {
use turbo_rcstr::rcstr;
From 2839982a037412b99c05fbe8579a82b16ca8d2d9 Mon Sep 17 00:00:00 2001
From: Hendrik Liebau
Date: Tue, 18 Aug 2026 16:54:45 +0200
Subject: [PATCH 3/6] Stop the browser from restoring stale pages in
development (#97505)
Development responses used `no-store, must-revalidate` until #88182
tried `no-cache, must-revalidate` behind
`experimental.devCacheControlNoCache`, and #91503 removed that option
and hard-coded the `no-cache` value everywhere. That was right for
static assets and wrong for documents. A browser may reuse a stored
response for a history navigation without revalidating it, and
development documents are streamed without an `ETag`, so there is
nothing to revalidate against. Going back therefore restored the
document the browser had stored earlier and showed output from before
the latest edit, and it is also what forced the debug channel
persistence workarounds in #92892, #93486 and #94243.
Documents and RSC or data responses now use `no-store` again, set in
`app-page-runtime.ts` for app pages, in `pages-handler.ts` for pages,
and in the legacy render pipe in `base-server.ts` so that the three do
not drift apart. None of them ever serves a static asset, so assets keep
`no-cache, must-revalidate` from the `nextStaticFolder` branch in
`router-server.ts` and stay cacheable: they are revalidated against the
`ETag` that `serveStatic` adds and reused from a `304` instead of being
downloaded again on every page load. `must-revalidate` is left off the
document value, because it only governs reuse of an already stale stored
response and nothing is stored any more.
A back navigation is no longer instant, since the document is fetched
again instead of being restored locally.
`test/development/dev-cache-control` covers both sides of that
trade-off: an edit that is visible after a back navigation, and
unchanged assets that still come back as `304`. It replaces
`dev-cache-control-no-cache` and asserts the header for both routers as
well, so there is one suite instead of two with nearly the same name.
A development document is now never restored from the HTTP cache, so the
debug channel persistence has no remaining trigger and its `IndexedDB`
write on every page load is no longer needed. Removing it is a follow-up
on top of this change. The pruning and recovery test in
`bfcache-regression` is the one case whose premise disappears entirely,
and it is skipped here with a note to delete it along with the
persistence.
closes #96503
---
.../src/build/templates/app-page-runtime.ts | 22 ++---
packages/next/src/server/base-server.ts | 22 ++---
packages/next/src/server/lib/router-server.ts | 4 +
.../src/server/lib/router-utils/filesystem.ts | 3 +-
.../route-modules/pages/pages-handler.ts | 9 +-
.../app/app-route/page.js | 3 -
.../dev-cache-control-no-cache/app/layout.js | 7 --
.../dev-cache-control-no-cache.test.ts | 17 ----
.../dev-cache-control-no-cache/next.config.js | 2 -
.../dev-cache-control/app/about/page.tsx | 3 +
.../dev-cache-control/app/layout.tsx | 9 ++
.../dev-cache-control/app/page.tsx | 12 +++
.../dev-cache-control/app/value.ts | 1 +
.../dev-cache-control.test.ts | 90 +++++++++++++++++++
.../dev-cache-control/next.config.js | 9 ++
.../pages/pages-route.js | 0
.../bfcache-regression.test.ts | 7 +-
.../custom-cache-control.test.ts | 22 ++---
test/e2e/app-dir/ppr-full/ppr-full.test.ts | 2 +-
.../not-found-revalidate.test.ts | 14 ++-
20 files changed, 173 insertions(+), 85 deletions(-)
delete mode 100644 test/development/dev-cache-control-no-cache/app/app-route/page.js
delete mode 100644 test/development/dev-cache-control-no-cache/app/layout.js
delete mode 100644 test/development/dev-cache-control-no-cache/dev-cache-control-no-cache.test.ts
delete mode 100644 test/development/dev-cache-control-no-cache/next.config.js
create mode 100644 test/development/dev-cache-control/app/about/page.tsx
create mode 100644 test/development/dev-cache-control/app/layout.tsx
create mode 100644 test/development/dev-cache-control/app/page.tsx
create mode 100644 test/development/dev-cache-control/app/value.ts
create mode 100644 test/development/dev-cache-control/dev-cache-control.test.ts
create mode 100644 test/development/dev-cache-control/next.config.js
rename test/development/{dev-cache-control-no-cache => dev-cache-control}/pages/pages-route.js (100%)
diff --git a/packages/next/src/build/templates/app-page-runtime.ts b/packages/next/src/build/templates/app-page-runtime.ts
index 9a8844648542..9f55ec77ffb4 100644
--- a/packages/next/src/build/templates/app-page-runtime.ts
+++ b/packages/next/src/build/templates/app-page-runtime.ts
@@ -50,7 +50,6 @@ import {
NEXT_IS_PRERENDER_HEADER,
NEXT_DID_POSTPONE_HEADER,
RSC_CONTENT_TYPE_HEADER,
- NEXT_HMR_REFRESH_HEADER,
} from '../../client/components/app-router-headers' with { 'turbopack-transition': 'next-server-utility' }
import { getBotType } from '../../shared/lib/router/utils/is-bot' with { 'turbopack-transition': 'next-server-utility' }
import {
@@ -1623,21 +1622,14 @@ export function createAppPageEntrypoint({
)
}
- // Dev responses use `no-cache` so the browser can restore them from the
- // HTTP cache on back/forward instead of reloading. HMR refresh responses
- // opt out into `no-store` because a superseded refresh's fetch is aborted
- // mid-write: under `no-cache` the response is stored, so the abort leaves
- // the cache entry shared with the superseding refresh (same URL)
- // half-written; Chromium then discards it and reissues the superseding
- // refresh on a second connection as a duplicate request. `no-store` keeps
- // that entry from being created.
+ // Documents and RSC payloads must not be stored in development.
+ // Browsers reuse a stored response for a history navigation without
+ // revalidating it, so a back navigation would restore a page from
+ // before the latest edit. Static assets never reach this code. They
+ // keep a revalidatable `Cache-Control`, so the browser caches them
+ // between page loads.
if (routeModule.isDev) {
- res.setHeader(
- 'Cache-Control',
- req.headers[NEXT_HMR_REFRESH_HEADER] === '1'
- ? 'no-store'
- : 'no-cache, must-revalidate'
- )
+ res.setHeader('Cache-Control', 'no-store')
}
if (!cacheEntry) {
diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts
index 60bb9fb11054..9f333b6173f6 100644
--- a/packages/next/src/server/base-server.ts
+++ b/packages/next/src/server/base-server.ts
@@ -102,7 +102,6 @@ import {
NEXT_URL,
NEXT_ROUTER_STATE_TREE_HEADER,
NEXT_INSTANT_TEST_COOKIE,
- NEXT_HMR_REFRESH_HEADER,
} from '../client/components/app-router-headers'
import { nanoid } from 'next/dist/compiled/nanoid'
import { LocaleRouteNormalizer } from './normalizers/locale-route-normalizer'
@@ -2140,21 +2139,14 @@ export default abstract class Server<
if (!res.sent) {
const { generateEtags, poweredByHeader } = this.renderOpts
- // Dev responses use `no-cache` so the browser can restore them from the
- // HTTP cache on back/forward instead of reloading. HMR refresh responses
- // opt out into `no-store` because a superseded refresh's fetch is aborted
- // mid-write: under `no-cache` the response is stored, so the abort leaves
- // the cache entry shared with the superseding refresh (same URL)
- // half-written; Chromium then discards it and reissues the superseding
- // refresh on a second connection as a duplicate request. `no-store` keeps
- // that entry from being created.
+ // Documents and data responses must not be stored in development.
+ // Browsers reuse a stored response for a history navigation without
+ // revalidating it, so a back navigation would restore a page from before
+ // the latest edit. Static assets never reach this code. They keep a
+ // revalidatable `Cache-Control`, so the browser caches them between page
+ // loads.
if (this.dev) {
- res.setHeader(
- 'Cache-Control',
- req.headers[NEXT_HMR_REFRESH_HEADER] === '1'
- ? 'no-store'
- : 'no-cache, must-revalidate'
- )
+ res.setHeader('Cache-Control', 'no-store')
cacheControl = undefined
}
diff --git a/packages/next/src/server/lib/router-server.ts b/packages/next/src/server/lib/router-server.ts
index 45a6967134de..031f0ab32400 100644
--- a/packages/next/src/server/lib/router-server.ts
+++ b/packages/next/src/server/lib/router-server.ts
@@ -638,6 +638,10 @@ export async function initialize(opts: {
res.setHeader('Cache-Control', 'public, max-age=0, must-revalidate')
res.setHeader('Service-Worker-Allowed', config.basePath || '/')
} else if (opts.dev && !isNextFont(parsedUrl.pathname)) {
+ // Development assets stay revalidatable. `serveStatic` adds an
+ // `ETag`, so the browser sends a conditional request and reuses the
+ // stored body when the server answers `304`. This keeps the browser
+ // from downloading every chunk again on each page load.
res.setHeader('Cache-Control', 'no-cache, must-revalidate')
} else {
res.setHeader(
diff --git a/packages/next/src/server/lib/router-utils/filesystem.ts b/packages/next/src/server/lib/router-utils/filesystem.ts
index 874ebae1e915..986b85c34f47 100644
--- a/packages/next/src/server/lib/router-utils/filesystem.ts
+++ b/packages/next/src/server/lib/router-utils/filesystem.ts
@@ -743,7 +743,8 @@ export async function setupFsCheck(opts: {
const fsPath = staticMetadataFiles.get(itemPath)
if (fsPath) {
return {
- // "nextStaticFolder" sets Cache-Control "no-store" on dev.
+ // "nextStaticFolder" sets Cache-Control
+ // "no-cache, must-revalidate" on dev.
type: 'nextStaticFolder',
fsPath,
itemPath: fsPath,
diff --git a/packages/next/src/server/route-modules/pages/pages-handler.ts b/packages/next/src/server/route-modules/pages/pages-handler.ts
index 627c5747e672..2d939b16438c 100644
--- a/packages/next/src/server/route-modules/pages/pages-handler.ts
+++ b/packages/next/src/server/route-modules/pages/pages-handler.ts
@@ -687,9 +687,14 @@ export const getHandler = ({
)
}
- // In dev, we should not cache pages for any reason.
+ // Documents and data responses must not be stored in development.
+ // Browsers reuse a stored response for a history navigation without
+ // revalidating it, so a back navigation would restore a page from
+ // before the latest edit. Static assets never reach this code. They
+ // keep a revalidatable `Cache-Control`, so the browser caches them
+ // between page loads.
if (routeModule.isDev) {
- res.setHeader('Cache-Control', 'no-cache, must-revalidate')
+ res.setHeader('Cache-Control', 'no-store')
}
// Draft mode should never be cached
diff --git a/test/development/dev-cache-control-no-cache/app/app-route/page.js b/test/development/dev-cache-control-no-cache/app/app-route/page.js
deleted file mode 100644
index cabb5263b521..000000000000
--- a/test/development/dev-cache-control-no-cache/app/app-route/page.js
+++ /dev/null
@@ -1,3 +0,0 @@
-export default function AppRoute() {
- return
+}
diff --git a/test/development/dev-cache-control/app/layout.tsx b/test/development/dev-cache-control/app/layout.tsx
new file mode 100644
index 000000000000..7c3f422f0039
--- /dev/null
+++ b/test/development/dev-cache-control/app/layout.tsx
@@ -0,0 +1,9 @@
+import type { ReactNode } from 'react'
+
+export default function Root({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/test/development/dev-cache-control/app/page.tsx b/test/development/dev-cache-control/app/page.tsx
new file mode 100644
index 000000000000..37b1c82497b8
--- /dev/null
+++ b/test/development/dev-cache-control/app/page.tsx
@@ -0,0 +1,12 @@
+import { value } from './value'
+
+export default function Page() {
+ return (
+ <>
+
{value}
+
+ About
+
+ >
+ )
+}
diff --git a/test/development/dev-cache-control/app/value.ts b/test/development/dev-cache-control/app/value.ts
new file mode 100644
index 000000000000..af0f2f03fe2f
--- /dev/null
+++ b/test/development/dev-cache-control/app/value.ts
@@ -0,0 +1 @@
+export const value = 'Value A'
diff --git a/test/development/dev-cache-control/dev-cache-control.test.ts b/test/development/dev-cache-control/dev-cache-control.test.ts
new file mode 100644
index 000000000000..24deb8d2be70
--- /dev/null
+++ b/test/development/dev-cache-control/dev-cache-control.test.ts
@@ -0,0 +1,90 @@
+import type * as Playwright from 'playwright'
+import { nextTestSetup } from 'e2e-utils'
+import { retry } from 'next-test-utils'
+import { readFile, writeFile } from 'fs/promises'
+import { join } from 'path'
+
+describe('dev Cache-Control', () => {
+ const { next } = nextTestSetup({
+ files: __dirname,
+ })
+
+ it('sends no-store for an app router document', async () => {
+ const res = await next.fetch('/')
+ expect(res.headers.get('Cache-Control')).toBe('no-store')
+ })
+
+ it('sends no-store for a pages router document', async () => {
+ const res = await next.fetch('/pages-route')
+ expect(res.headers.get('Cache-Control')).toBe('no-store')
+ })
+
+ it('keeps serving static assets from the browser cache', async () => {
+ const browser = await next.browser('/')
+ const assetStatusCodes: number[] = []
+
+ browser.on('response', (response: Playwright.Response) => {
+ const url = new URL(response.url())
+
+ // The webpack dev bundler adds a `v` query to some of its own chunks to
+ // bust the browser cache on every page load. Those are never cache hits
+ // by design.
+ if (
+ url.pathname.startsWith('/_next/static/') &&
+ !url.searchParams.has('v')
+ ) {
+ assetStatusCodes.push(response.status())
+ }
+ })
+
+ // Only the responses of the second page load are of interest.
+ assetStatusCodes.length = 0
+ await browser.refresh()
+
+ await retry(async () => {
+ expect(assetStatusCodes.length).toBeGreaterThan(0)
+ })
+
+ // The dev server answers the revalidation of an unchanged asset with 304,
+ // so the browser reuses the body from its cache instead of downloading it
+ // again. `no-store` would force a full download on every page load.
+ expect([...new Set(assetStatusCodes)]).toEqual([304])
+ })
+
+ // Runs last because it edits a file that the other test cases rely on.
+ it('serves an edited page after a back navigation', async () => {
+ const browser = await next.browser('/')
+ expect(await browser.elementByCss('#value').text()).toBe('Value A')
+
+ // The debug channel is persisted once the main thread is idle. Before that
+ // has happened, a document that is restored from the HTTP cache falls back
+ // to `location.reload()`, which would hide a stale response.
+ await retry(async () => {
+ expect(
+ await browser.eval(() => (self as any).__NEXT_DEBUG_CHANNEL_PERSISTED)
+ ).toBe(true)
+ })
+
+ // A plain anchor triggers a document navigation, so the browser can keep
+ // the page it navigates away from in its HTTP cache.
+ await browser.elementByCss('#to-about').click()
+ await browser.waitForElementByCss('#about')
+
+ const valueFile = join(next.testDir, 'app/value.ts')
+ const value = await readFile(valueFile, 'utf8')
+ await writeFile(valueFile, value.replace('Value A', 'Value B'))
+
+ // The dev server must serve the edited value before going back, so that a
+ // stale page can only come from the browser.
+ await retry(async () => {
+ const $ = await next.render$('/')
+ expect($('#value').text()).toBe('Value B')
+ })
+
+ await browser.back({ waitUntil: 'commit' })
+
+ await retry(async () => {
+ expect(await browser.elementByCss('#value').text()).toBe('Value B')
+ })
+ })
+})
diff --git a/test/development/dev-cache-control/next.config.js b/test/development/dev-cache-control/next.config.js
new file mode 100644
index 000000000000..3e88313f550b
--- /dev/null
+++ b/test/development/dev-cache-control/next.config.js
@@ -0,0 +1,9 @@
+/**
+ * @type {import('next').NextConfig}
+ */
+const nextConfig = {
+ // Writing the agent rules files would trigger an unrelated Fast Refresh.
+ agentRules: false,
+}
+
+module.exports = nextConfig
diff --git a/test/development/dev-cache-control-no-cache/pages/pages-route.js b/test/development/dev-cache-control/pages/pages-route.js
similarity index 100%
rename from test/development/dev-cache-control-no-cache/pages/pages-route.js
rename to test/development/dev-cache-control/pages/pages-route.js
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 7f49d00790b5..5ab4b3dba6d8 100644
--- a/test/e2e/app-dir/bfcache-regression/bfcache-regression.test.ts
+++ b/test/e2e/app-dir/bfcache-regression/bfcache-regression.test.ts
@@ -154,7 +154,12 @@ describe('bfcache-regression', () => {
if (isNextDev) {
// Persistence only exists in dev.
- it('should reload to recover when a debug channel entry was pruned by newer page loads', async () => {
+ // TODO: Remove this test together with the debug channel persistence.
+ // Documents are served with `no-store`, so a back navigation always
+ // re-fetches the page and never restores it from the HTTP cache. The
+ // persisted entries that this test prunes are therefore never read, and
+ // the recovery reload it asserts no longer happens.
+ it.skip('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
diff --git a/test/e2e/app-dir/custom-cache-control/custom-cache-control.test.ts b/test/e2e/app-dir/custom-cache-control/custom-cache-control.test.ts
index 121ecc0207f0..73eb9366c713 100644
--- a/test/e2e/app-dir/custom-cache-control/custom-cache-control.test.ts
+++ b/test/e2e/app-dir/custom-cache-control/custom-cache-control.test.ts
@@ -15,14 +15,14 @@ describe('custom-cache-control', () => {
it('should have custom cache-control for app-ssg prerendered', async () => {
const res = await next.fetch('/app-ssg/first')
expect(res.headers.get('cache-control')).toBe(
- isNextDev ? 'no-cache, must-revalidate' : 's-maxage=30'
+ isNextDev ? 'no-store' : 's-maxage=30'
)
})
it('should have custom cache-control for app-ssg lazy', async () => {
const res = await next.fetch('/app-ssg/lazy')
expect(res.headers.get('cache-control')).toBe(
- isNextDev ? 'no-cache, must-revalidate' : 's-maxage=31'
+ isNextDev ? 'no-store' : 's-maxage=31'
)
})
;(process.env.__NEXT_CACHE_COMPONENTS ? it.skip : it)(
@@ -31,9 +31,7 @@ describe('custom-cache-control', () => {
const res = await next.fetch('/app-ssg/another')
// eslint-disable-next-line jest/no-standalone-expect
expect(res.headers.get('cache-control')).toBe(
- isNextDev
- ? 'no-cache, must-revalidate'
- : 's-maxage=120, stale-while-revalidate=31535880'
+ isNextDev ? 'no-store' : 's-maxage=120, stale-while-revalidate=31535880'
)
}
)
@@ -41,44 +39,42 @@ describe('custom-cache-control', () => {
it('should have custom cache-control for app-ssr', async () => {
const res = await next.fetch('/app-ssr')
expect(res.headers.get('cache-control')).toBe(
- isNextDev ? 'no-cache, must-revalidate' : 's-maxage=32'
+ isNextDev ? 'no-store' : 's-maxage=32'
)
})
it('should have custom cache-control for auto static page', async () => {
const res = await next.fetch('/pages-auto-static')
expect(res.headers.get('cache-control')).toBe(
- isNextDev ? 'no-cache, must-revalidate' : 's-maxage=33'
+ isNextDev ? 'no-store' : 's-maxage=33'
)
})
it('should have custom cache-control for pages-ssg prerendered', async () => {
const res = await next.fetch('/pages-ssg/first')
expect(res.headers.get('cache-control')).toBe(
- isNextDev ? 'no-cache, must-revalidate' : 's-maxage=34'
+ isNextDev ? 'no-store' : 's-maxage=34'
)
})
it('should have custom cache-control for pages-ssg lazy', async () => {
const res = await next.fetch('/pages-ssg/lazy')
expect(res.headers.get('cache-control')).toBe(
- isNextDev ? 'no-cache, must-revalidate' : 's-maxage=35'
+ isNextDev ? 'no-store' : 's-maxage=35'
)
})
it('should have default cache-control for pages-ssg another', async () => {
const res = await next.fetch('/pages-ssg/another')
expect(res.headers.get('cache-control')).toBe(
- isNextDev
- ? 'no-cache, must-revalidate'
- : 's-maxage=120, stale-while-revalidate=31535880'
+ isNextDev ? 'no-store' : 's-maxage=120, stale-while-revalidate=31535880'
)
})
it('should have default cache-control for pages-ssr', async () => {
const res = await next.fetch('/pages-ssr')
expect(res.headers.get('cache-control')).toBe(
- isNextDev ? 'no-cache, must-revalidate' : 's-maxage=36'
+ isNextDev ? 'no-store' : 's-maxage=36'
)
})
})
diff --git a/test/e2e/app-dir/ppr-full/ppr-full.test.ts b/test/e2e/app-dir/ppr-full/ppr-full.test.ts
index 096d8f3c87c6..c98e21c92a38 100644
--- a/test/e2e/app-dir/ppr-full/ppr-full.test.ts
+++ b/test/e2e/app-dir/ppr-full/ppr-full.test.ts
@@ -192,7 +192,7 @@ describe.skip('ppr-full', () => {
if (isNextDeploy) {
expect(cacheControl).toEqual('public, max-age=0, must-revalidate')
} else if (isNextDev) {
- expect(cacheControl).toEqual('no-cache, must-revalidate')
+ expect(cacheControl).toEqual('no-store')
} else if (dynamic === false || dynamic === 'force-static') {
expect(cacheControl).toEqual(
revalidate === undefined
diff --git a/test/e2e/not-found-revalidate/not-found-revalidate.test.ts b/test/e2e/not-found-revalidate/not-found-revalidate.test.ts
index ca6cb570d199..b74271b02007 100644
--- a/test/e2e/not-found-revalidate/not-found-revalidate.test.ts
+++ b/test/e2e/not-found-revalidate/not-found-revalidate.test.ts
@@ -94,9 +94,7 @@ describe('SSG notFound revalidate', () => {
let $ = await next.render$('/fallback-blocking/hello')
expect(res.headers.get('cache-control')).toBe(
- isNextDev
- ? 'no-cache, must-revalidate'
- : 's-maxage=1, stale-while-revalidate=31535999'
+ isNextDev ? 'no-store' : 's-maxage=1, stale-while-revalidate=31535999'
)
expect(res.status).toBe(404)
expect(JSON.parse($('#props').text()).notFound).toBe(true)
@@ -106,7 +104,7 @@ describe('SSG notFound revalidate', () => {
$ = await next.render$('/fallback-blocking/hello')
expect(res.headers.get('cache-control')).toBe(
isNextDev
- ? 'no-cache, must-revalidate'
+ ? 'no-store'
: 's-maxage=1, stale-while-revalidate=31535999'
)
expect(res.status).toBe(200)
@@ -124,7 +122,7 @@ describe('SSG notFound revalidate', () => {
const p = JSON.parse($r('#props').text())
expect(r.headers.get('cache-control')).toBe(
isNextDev
- ? 'no-cache, must-revalidate'
+ ? 'no-store'
: 's-maxage=1, stale-while-revalidate=31535999'
)
expect(r.status).toBe(200)
@@ -143,7 +141,7 @@ describe('SSG notFound revalidate', () => {
const res = await next.fetch('/fallback-true/world')
expect(res.headers.get('cache-control')).toBe(
isNextDev
- ? 'no-cache, must-revalidate'
+ ? 'no-store'
: 's-maxage=1, stale-while-revalidate=31535999'
)
expect(res.status).toBe(404)
@@ -157,7 +155,7 @@ describe('SSG notFound revalidate', () => {
const props = JSON.parse($('#props').text())
expect(res.headers.get('cache-control')).toBe(
isNextDev
- ? 'no-cache, must-revalidate'
+ ? 'no-store'
: 's-maxage=1, stale-while-revalidate=31535999'
)
expect(res.status).toBe(200)
@@ -175,7 +173,7 @@ describe('SSG notFound revalidate', () => {
const props3 = JSON.parse($r('#props').text())
expect(r.headers.get('cache-control')).toBe(
isNextDev
- ? 'no-cache, must-revalidate'
+ ? 'no-store'
: 's-maxage=1, stale-while-revalidate=31535999'
)
expect(r.status).toBe(200)
From b18acf67128591337eb8e50d07ba20cbbb239f34 Mon Sep 17 00:00:00 2001
From: Hendrik Liebau
Date: Tue, 18 Aug 2026 16:54:46 +0200
Subject: [PATCH 4/6] Remove the development debug channel persistence (#97510)
Documents are now served with `no-store` in development, so a browser
never restores one from its HTTP cache and the page scripts never
re-execute against a debug channel that has already delivered its data.
The persistence and restore machinery that existed for that case has no
remaining trigger, so this removes it: the `IndexedDB` write scheduled
on every page load, the cache-restore detection across
`PerformanceNavigationTiming` fields and `deliveryType`, the `pageshow`
deferral for browsers that populate those fields late, and the
`location.reload()` fallback for a missing entry. It was built up over
#92892, #93486, #94128, #94317 and #94243, and takes `debug-channel.ts`
from 535 lines to 121.
The per-consumer `tee()` and the LRU-bounded pair map stay. They were
added for an unrelated reason, namely that one response can be decoded
more than once, so this is not a revert to the state before the
persistence landed. The rejection handler on `writer.closed` also stays,
because an errored stream would otherwise surface as an unhandled
rejection now that nothing else observes it.
`bfcache-regression` keeps the original regression test, which loads a
page, navigates away, comes back and asserts that the counter is still
interactive. That case now fails if the development `Cache-Control`
value ever goes back to `no-cache`, because the restored document would
block hydration with no reload to recover, so it is worth keeping as is.
The other three tests lose their premise and are deleted along with the
routes only they used: the pruning case that was skipped when the header
changed, the recovery case that needs a restore path to recover into,
and the streaming case that guarded the detection against treating an
in-flight response as a restore. The `large-debug-data` route goes too.
It existed only to make the persistence write expensive enough to
profile by hand when it moved to `IndexedDB`.
---
packages/next/src/client/dev/debug-channel.ts | 426 +-----------------
.../dev-cache-control.test.ts | 9 -
.../app/large-debug-data/client.tsx | 9 -
.../app/large-debug-data/page.tsx | 28 --
.../app-dir/bfcache-regression/app/layout.tsx | 5 +-
.../app/purge/[slug]/page.tsx | 27 --
.../bfcache-regression/app/streaming/page.tsx | 21 -
.../bfcache-regression.test.ts | 230 +---------
8 files changed, 13 insertions(+), 742 deletions(-)
delete mode 100644 test/e2e/app-dir/bfcache-regression/app/large-debug-data/client.tsx
delete mode 100644 test/e2e/app-dir/bfcache-regression/app/large-debug-data/page.tsx
delete mode 100644 test/e2e/app-dir/bfcache-regression/app/purge/[slug]/page.tsx
delete mode 100644 test/e2e/app-dir/bfcache-regression/app/streaming/page.tsx
diff --git a/packages/next/src/client/dev/debug-channel.ts b/packages/next/src/client/dev/debug-channel.ts
index 21016eb21ac5..0791ac23e665 100644
--- a/packages/next/src/client/dev/debug-channel.ts
+++ b/packages/next/src/client/dev/debug-channel.ts
@@ -32,306 +32,6 @@ const pairs = new Map()
*/
const MAX_DEBUG_CHANNEL_PAIRS = 64
-const DB_NAME = '__next_debug_channel'
-const STORE_NAME = 'channels'
-const CREATED_AT_INDEX = 'createdAt'
-/**
- * Upper bound on persisted document debug channels in IndexedDB (one per
- * document, kept for HTTP-cache restore), evicted oldest-first.
- */
-const MAX_PERSISTED_DOCUMENT_CHANNELS = 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 {
- 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_PERSISTED_DOCUMENT_CHANNELS
- 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()
- }
- }
-
- 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 {
- /**
- * The HTML document was served from the browser's cache; replay the
- * previously persisted chunks instead of waiting for the WebSocket-backed
- * channel.
- */
- CacheRestore,
-
- /**
- * The HTML document came fresh from the server. The live WebSocket-backed
- * channel will deliver the debug chunks.
- */
- FreshResponse,
-
- /**
- * 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
- }
-}
-
/**
* Reclaim the least-recently-used debug-channel pairs once the map exceeds
* `MAX_DEBUG_CHANNEL_PAIRS`. The map is iterated in insertion order and we
@@ -365,20 +65,7 @@ export function getOrCreateDebugChannelReadableWriterPair(
return existingPair
}
- // 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 (chunks) {
- chunks.push(chunk.slice())
- }
- controller.enqueue(chunk)
- },
- })
+ const { readable, writable } = new TransformStream()
const pair: DebugChannelReadableWriterPair = {
readable,
@@ -389,31 +76,11 @@ export function getOrCreateDebugChannelReadableWriterPair(
// bound the map by reclaiming the least-recently-used.
evictExcessDebugChannelPairs()
- pair.writer.closed
- .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(() => {
- // Keep the now-closed pair in the map so late decodes of this request
- // still resolve against its buffered stream; it's reclaimed later by LRU
- // eviction. Release the IndexedDB staging buffer now that it's persisted.
- if (chunks) {
- chunks.length = 0
- }
- })
+ // An errored stream rejects `writer.closed`. Observe the rejection so that it
+ // does not surface as an unhandled rejection.
+ pair.writer.closed.catch((error) => {
+ console.debug('Debug channel writer closed with error', error)
+ })
return pair
}
@@ -444,24 +111,6 @@ export function createDebugChannel(
}
}
- // 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) {
- 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
- }
- }
-
const pair = getOrCreateDebugChannelReadableWriterPair(requestId)
// Hand out a fresh tee branch per consumer and keep the remainder for the
// next one (see the `readable` field doc above).
@@ -470,66 +119,3 @@ export function createDebugChannel(
return { readable: branch }
}
-
-/**
- * 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 = restoreDebugChannelFromIndexedDB(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/development/dev-cache-control/dev-cache-control.test.ts b/test/development/dev-cache-control/dev-cache-control.test.ts
index 24deb8d2be70..de4a40bb9420 100644
--- a/test/development/dev-cache-control/dev-cache-control.test.ts
+++ b/test/development/dev-cache-control/dev-cache-control.test.ts
@@ -56,15 +56,6 @@ describe('dev Cache-Control', () => {
const browser = await next.browser('/')
expect(await browser.elementByCss('#value').text()).toBe('Value A')
- // The debug channel is persisted once the main thread is idle. Before that
- // has happened, a document that is restored from the HTTP cache falls back
- // to `location.reload()`, which would hide a stale response.
- await retry(async () => {
- expect(
- await browser.eval(() => (self as any).__NEXT_DEBUG_CHANNEL_PERSISTED)
- ).toBe(true)
- })
-
// A plain anchor triggers a document navigation, so the browser can keep
// the page it navigates away from in its HTTP cache.
await browser.elementByCss('#to-about').click()
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
deleted file mode 100644
index 9047d5ebad94..000000000000
--- a/test/e2e/app-dir/bfcache-regression/app/large-debug-data/client.tsx
+++ /dev/null
@@ -1,9 +0,0 @@
-'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
deleted file mode 100644
index f97094b0e466..000000000000
--- a/test/e2e/app-dir/bfcache-regression/app/large-debug-data/page.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-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 (
-