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/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/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. 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' 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/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/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..de4a40bb9420 --- /dev/null +++ b/test/development/dev-cache-control/dev-cache-control.test.ts @@ -0,0 +1,81 @@ +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') + + // 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/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 7f49d00790b5..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,222 +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. - it('should reload to recover when a debug channel entry was pruned by newer page loads', async () => { - // The debug channel for the initial document is buffered and persisted to - // IndexedDB so it can be restored when the browser serves the page from - // the HTTP cache (back-forward navigation). Persistence is bounded to a - // maximum number of entries, pruning the oldest on each write. This - // verifies that an entry pushed out by newer page loads is no longer - // restorable, so going back to it recovers via a full reload instead. - - // One past the persistence cap (MAX_ENTRIES = 10): loading the whole - // chain writes 11 entries, pruning exactly the first page's entry and - // leaving /purge/2..11 cached. - const PAGES = 11 - - // Snapshot the server output so we can count requests made during this - // test. Recovery is observed through server requests rather than client - // load events: a still-cached page is restored client-side from the HTTP - // cache with no server request, while the pruned page misses and recovers - // with a full reload, which is a fresh server request. Load-event counts - // would be browser-dependent here, since some browsers fire the reload - // before the back-navigation's own load event and some after. - const outputIndex = next.cliOutput.length - const browser = await next.browser('/purge/1') - - // Wait until the just-loaded page's debug channel has been durably - // committed to IndexedDB before navigating away. Persistence is deferred - // to an idle callback and its IndexedDB write is async; navigating before - // it commits would abort the transaction and drop the entry. The page - // sets a flag once the commit completes (test mode only), which resets - // naturally on each navigation since every document gets a fresh window. - const waitForPersisted = () => - retry(async () => { - expect( - await browser.eval( - () => (self as any).__NEXT_DEBUG_CHANNEL_PERSISTED - ) - ).toBe(true) - }) - - // Hard-navigate through the chain. Each load persists its own entry, so - // after more than MAX_ENTRIES loads the earliest pages are pruned. - for (let n = 1; n <= PAGES; n++) { - await retry(async () => { - expect(await browser.elementById(`purge-${n}`).text()).toBe( - `Purge ${n}` - ) - }) - await waitForPersisted() - if (n < PAGES) { - await browser.elementById('next').click() - } - } - - // Back-navigate the whole way to the first page. Each step restores the - // page's HTML from the HTTP cache and re-runs the debug channel restore. - for (let n = PAGES; n > 1; n--) { - await browser.back() - await retry(async () => { - expect(await browser.elementById(`purge-${n - 1}`).text()).toBe( - `Purge ${n - 1}` - ) - }) - } - - // Per-page server request counts after the forward + back traversal. - // /purge/1 reaches 2 requests in every browser but via different paths: - // - // Chrome and Firefox restore each back-navigation from the HTTP cache - // (the HMR WebSocket disqualifies bfcache, so the browser falls back to - // HTTP cache restore with no server request). /purge/2..10 stay at one - // request because their IDB entries are still around and the restore - // replays them silently. /purge/1's IDB entry was pruned by the time we - // get back to it (MAX_ENTRIES=10), so its restore misses and recovers - // via a single location.reload() — that's the second server request. - // - // Playwright's WebKit is encoded as a separate expectation because it - // doesn't match real Safari behavior. Real Safari keeps recent pages in - // bfcache and falls back to HTTP cache restore for evicted ones, so it - // would behave like Chrome/Firefox here. Playwright's WebKit instead - // re-fetches every back-navigation target from the server, which adds - // one extra server request per back-step (including /purge/1 — the same - // re-fetch behavior already accounts for its second request, so the - // pruned IDB entry never triggers a reload there). The fresh re-fetch - // is correctly classified as a non-cache-restore by debug-channel.ts - // (the deferred-pageshow branch routes it to the live WebSocket-backed - // channel), so no spurious reload follows. - const isSafari = global.browserName === 'safari' - await retry(async () => { - const getCounts: Record = {} - const output = next.cliOutput.slice(outputIndex) - for (const [, path] of output.matchAll(/GET (\/purge\/\d+) /g)) { - getCounts[path] = (getCounts[path] ?? 0) + 1 - } - expect(getCounts).toEqual({ - '/purge/1': 2, - // Chrome/Firefox: 1 forward only (HTTP cache restore on back). - // Safari (Playwright/WebKit): 1 forward + 1 back re-fetch = 2. - '/purge/2': isSafari ? 2 : 1, - '/purge/3': isSafari ? 2 : 1, - '/purge/4': isSafari ? 2 : 1, - '/purge/5': isSafari ? 2 : 1, - '/purge/6': isSafari ? 2 : 1, - '/purge/7': isSafari ? 2 : 1, - '/purge/8': isSafari ? 2 : 1, - '/purge/9': isSafari ? 2 : 1, - '/purge/10': isSafari ? 2 : 1, - '/purge/11': 1, - }) - }) - }) - } }) 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) 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/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, 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;