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
App Route
-} diff --git a/test/development/dev-cache-control-no-cache/app/layout.js b/test/development/dev-cache-control-no-cache/app/layout.js deleted file mode 100644 index 4ee00a218505..000000000000 --- a/test/development/dev-cache-control-no-cache/app/layout.js +++ /dev/null @@ -1,7 +0,0 @@ -export default function RootLayout({ children }) { - return ( - - {children} - - ) -} diff --git a/test/development/dev-cache-control-no-cache/dev-cache-control-no-cache.test.ts b/test/development/dev-cache-control-no-cache/dev-cache-control-no-cache.test.ts deleted file mode 100644 index 2a38178d4344..000000000000 --- a/test/development/dev-cache-control-no-cache/dev-cache-control-no-cache.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { nextTestSetup } from 'e2e-utils' - -describe('dev Cache-Control header', () => { - const { next } = nextTestSetup({ - files: __dirname, - }) - - it('should use no-cache for pages router', async () => { - const res = await next.fetch('/pages-route') - expect(res.headers.get('Cache-Control')).toBe('no-cache, must-revalidate') - }) - - it('should use no-cache for app router', async () => { - const res = await next.fetch('/app-route') - expect(res.headers.get('Cache-Control')).toBe('no-cache, must-revalidate') - }) -}) diff --git a/test/development/dev-cache-control-no-cache/next.config.js b/test/development/dev-cache-control-no-cache/next.config.js deleted file mode 100644 index 5a877d2dbfab..000000000000 --- a/test/development/dev-cache-control-no-cache/next.config.js +++ /dev/null @@ -1,2 +0,0 @@ -/** @type {import('next').NextConfig} */ -module.exports = {} diff --git a/test/development/dev-cache-control/app/about/page.tsx b/test/development/dev-cache-control/app/about/page.tsx new file mode 100644 index 000000000000..0f3c73b1ac07 --- /dev/null +++ b/test/development/dev-cache-control/app/about/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return

About

+} 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 ( -
-

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 1b8300d33c67..b1da3e15eb76 100644 --- a/test/e2e/app-dir/bfcache-regression/app/layout.tsx +++ b/test/e2e/app-dir/bfcache-regression/app/layout.tsx @@ -1,5 +1,5 @@ -import Link from 'next/link' import { ReactNode } from 'react' + export default function Root({ children }: { children: ReactNode }) { return ( @@ -7,9 +7,6 @@ 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 deleted file mode 100644 index 438be18ab7fe..000000000000 --- a/test/e2e/app-dir/bfcache-regression/app/purge/[slug]/page.tsx +++ /dev/null @@ -1,27 +0,0 @@ -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/app/streaming/page.tsx b/test/e2e/app-dir/bfcache-regression/app/streaming/page.tsx deleted file mode 100644 index 46b0b3bb39c2..000000000000 --- a/test/e2e/app-dir/bfcache-regression/app/streaming/page.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { Suspense } from 'react' -import { connection } from 'next/server' - -async function DynamicContent() { - await connection() - // Delay so that the streamed body has not arrived by the time the - // bootstrap script reads PerformanceNavigationTiming.transferSize. - await new Promise((resolve) => setTimeout(resolve, 500)) - return

Dynamic content

-} - -export default function Page() { - return ( -
-

Streaming page

- Loading...

}> - -
-
- ) -} 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 5ab4b3dba6d8..f8de9570b0d8 100644 --- a/test/e2e/app-dir/bfcache-regression/bfcache-regression.test.ts +++ b/test/e2e/app-dir/bfcache-regression/bfcache-regression.test.ts @@ -2,10 +2,15 @@ import { nextTestSetup } from 'e2e-utils' import { assertNoConsoleErrors, retry } from 'next-test-utils' describe('bfcache-regression', () => { - const { next, isTurbopack, isNextDev } = nextTestSetup({ + const { next, isTurbopack } = nextTestSetup({ files: __dirname, }) + // Development documents are served with `no-store`, so a back navigation + // re-fetches the page instead of restoring it from the browser's HTTP cache. + // A restored document would re-execute the page scripts while React's debug + // channel has no data for its request id, which blocks hydration and leaves + // the page without interactivity. it('should preserve interactivity after navigating back from another page via MPA navigation', async () => { // In webpack dev, compiling a new route on demand while another page is // open triggers an HMR cycle that has no Fast Refresh boundary, surfacing @@ -46,227 +51,4 @@ describe('bfcache-regression', () => { await assertNoConsoleErrors(browser) }) - - // Regression test for an infinite refresh loop on the initial load of a - // 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', { - pushErrorAsConsoleLog: true, - beforePageLoad: async (page) => { - // Increments on every load event for /streaming (including any - // location.reload() triggered by the bug), so loadCount > 1 means a - // reload happened. URL-filtered to skip the about:blank load Firefox - // emits when Playwright creates the page. - page.on('load', () => { - if (page.url().endsWith('/streaming')) { - loadCount++ - } - }) - }, - }) - - await retry(async () => { - expect(await browser.elementById('dynamic-content').text()).toBe( - 'Dynamic content' - ) - }) - - expect(loadCount).toBe(1) - - 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) - }) - } - - if (isNextDev) { - // Persistence only exists in dev. - // 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 - // 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 f0088acc7daac3222d8d43ea593cf6dc6c7bc746 Mon Sep 17 00:00:00 2001 From: David Alexandru Ilie Date: Tue, 18 Aug 2026 18:39:04 +0200 Subject: [PATCH 5/6] docs: warn when catching permanentRedirect (#97496) ## Summary Document that `permanentRedirect()` throws and should be called outside a broad `try/catch` block. The `redirect()` and `notFound()` references already explain this control-flow requirement. The `permanentRedirect()` reference omits it, so application error handling can accidentally suppress the redirect. Align the page with the `redirect()` reference by adding a dedicated Behavior section for the try/catch guidance and temporary-redirect cross-link. ## Verification - Prettier, ESLint, and `git diff --check` --- .../03-api-reference/04-functions/permanentRedirect.mdx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/01-app/03-api-reference/04-functions/permanentRedirect.mdx b/docs/01-app/03-api-reference/04-functions/permanentRedirect.mdx index 10d31e04b02f..f9b8d3752cc9 100644 --- a/docs/01-app/03-api-reference/04-functions/permanentRedirect.mdx +++ b/docs/01-app/03-api-reference/04-functions/permanentRedirect.mdx @@ -12,8 +12,6 @@ When used in a streaming context, this will insert a meta tag to emit the redire If a resource doesn't exist, you can use the [`notFound` function](/docs/app/api-reference/functions/not-found) instead. -> **Good to know**: If you prefer to return a 307 (Temporary) HTTP redirect instead of 308 (Permanent), you can use the [`redirect` function](/docs/app/api-reference/functions/redirect) instead. - ## Parameters The `permanentRedirect` function accepts two arguments: @@ -45,6 +43,11 @@ The `type` parameter has no effect when used in Server Components. `permanentRedirect` does not return a value. +## Behavior + +- In Server Actions and Route Handlers, `permanentRedirect` should be called **outside** the `try` block when using `try/catch` statements because it throws an error. +- If you prefer to return a 307 (Temporary) HTTP redirect instead of 308 (Permanent), you can use the [`redirect` function](/docs/app/api-reference/functions/redirect) instead. + ## Example Invoking the `permanentRedirect()` function throws a `NEXT_REDIRECT` error and terminates rendering of the route segment in which it was thrown. From 9dfbc93848ba8f69c4bd6c340e7d8026c6c86272 Mon Sep 17 00:00:00 2001 From: Niklas Mischkulnig <4586894+mischnic@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:38:41 +0200 Subject: [PATCH 6/6] Turbopack: support character class ranges in regex (#97502) Closes https://github.com/vercel/next.js/issues/97467 --------- Co-authored-by: vercel-fleet[bot] <308483924+vercel-fleet[bot]@users.noreply.github.com> --- turbopack/crates/turbo-tasks-fs/src/glob.rs | 8 ++++++++ turbopack/crates/turbo-tasks-fs/src/globset.rs | 3 ++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/turbopack/crates/turbo-tasks-fs/src/glob.rs b/turbopack/crates/turbo-tasks-fs/src/glob.rs index a1273227a819..aa84ea7e7568 100644 --- a/turbopack/crates/turbo-tasks-fs/src/glob.rs +++ b/turbopack/crates/turbo-tasks-fs/src/glob.rs @@ -237,6 +237,7 @@ mod tests { #[case::alternatives_empty1("react{,-dom}", "react")] #[case::alternatives_empty2("react{,-dom}", "react-dom")] #[case::alternatives_chars("[abc]", "b")] + #[case::character_range("[a-z].js", "b.js")] fn glob_match(#[case] glob: &str, #[case] path: &str) { let glob = Glob::parse(RcStr::from(glob), GlobOptions::default()).unwrap(); @@ -245,6 +246,13 @@ mod tests { assert!(glob.matches(path)); } + #[test] + fn glob_rejects_invalid_character_range() { + let error = Glob::parse(rcstr!("[z-a]"), GlobOptions::default()).unwrap_err(); + + assert!(format!("{error:#}").contains("invalid character range")); + } + #[rstest] #[case::early_end("*.raw", "hello.raw.js")] #[case::early_end( diff --git a/turbopack/crates/turbo-tasks-fs/src/globset.rs b/turbopack/crates/turbo-tasks-fs/src/globset.rs index 8bec64ce1169..1670a06fe728 100644 --- a/turbopack/crates/turbo-tasks-fs/src/globset.rs +++ b/turbopack/crates/turbo-tasks-fs/src/globset.rs @@ -585,7 +585,7 @@ impl<'a> Parser<'a> { if in_range { // invariant: in_range is only set when there is // already at least one character seen. - if let Some(kind) = add_to_last_range(ranges.last_mut().unwrap(), '-') { + if let Some(kind) = add_to_last_range(ranges.last_mut().unwrap(), c) { return Err(self.error(kind)); } } else { @@ -653,6 +653,7 @@ mod tests { "(?:a|b|c(?:/)?)(?:/h(?:/.*)?)?" )] #[case::classes("[abc]/d/**", "[abc]/d/.*", "[abc](?:/d(?:/.*)?)?")] + #[case::ranges("[a-z]/d/**", "[a-z]/d/.*", "[a-z](?:/d(?:/.*)?)?")] fn glob_regex_mapping( #[case] glob: &str, #[case] glob_regex: &str,