From 8d415853f73c72263d24be3ea92b497d41f242f0 Mon Sep 17 00:00:00 2001 From: Steven Date: Thu, 9 Oct 2025 16:00:53 -0400 Subject: [PATCH 01/11] [Breaking] feat(next/image)!: add support for `images.dangerouslyAllowLocalIP` and `images.maximumRedirects` (#84676) This PR adds a two new options and sets a strict default value for each. - `images.dangerouslyAllowLocalIP` - `images.maximumRedirects` ### dangerouslyAllowLocalIP In rare cases when self-hosting Next.js on a private network, you may want to allow optimizing images from local IP addresses on the same network. However, this is not recommended for most users so the default is `false`. > [!NOTE] > BREAKING CHANGE: This change is breaking for those who self-hosting Next.js on a private network and want to allow optimizing images from local IP addresses on the same network. In those cases, you can still enable the config. ### maximumRedirects Since are also testing redirects for local IPs, we can also reduce the maximum number of redirects to 3 by default. Unlike normal websites which might redirect for features like auth, its unusual to have more than 3 redirects for an image. In some rare cases, developers may need to increase this value or set to `0` to disable redirects. > [!NOTE] > BREAKING CHANGE: This change is breaking for those who need image optimization to follow more than 3 redirects. --- crates/next-build-test/nextConfig.json | 2 + .../03-api-reference/02-components/image.mdx | 48 ++++++- packages/next/package.json | 1 + .../src/compiled/is-local-address/index.js | 1 + .../compiled/is-local-address/package.json | 1 + packages/next/src/server/config-schema.ts | 2 + packages/next/src/server/image-optimizer.ts | 54 +++++++- packages/next/src/server/next-server.ts | 6 +- packages/next/src/shared/lib/image-config.ts | 8 ++ packages/next/taskfile.js | 9 ++ packages/next/types/$$compiled.internal.d.ts | 4 + pnpm-lock.yaml | 122 ++---------------- .../test/maximum-redirects-0.test.ts | 23 ++++ .../test/maximum-redirects-1.test.ts | 23 ++++ .../test/minimum-cache-ttl.test.ts | 1 + test/integration/image-optimizer/test/util.ts | 71 ++++++++-- .../app-dir-localpatterns/test/index.test.ts | 2 + .../app-dir-qualities/test/index.test.ts | 2 + .../next-image-new/app-dir/test/index.test.ts | 2 + .../next-image-new/unicode/test/index.test.ts | 2 + .../unoptimized/test/index.test.ts | 2 + .../next-server-nft/next-server-nft.test.ts | 1 + 22 files changed, 266 insertions(+), 121 deletions(-) create mode 100644 packages/next/src/compiled/is-local-address/index.js create mode 100644 packages/next/src/compiled/is-local-address/package.json create mode 100644 test/integration/image-optimizer/test/maximum-redirects-0.test.ts create mode 100644 test/integration/image-optimizer/test/maximum-redirects-1.test.ts diff --git a/crates/next-build-test/nextConfig.json b/crates/next-build-test/nextConfig.json index e58b86c9465aca..aca5e269ecbc78 100644 --- a/crates/next-build-test/nextConfig.json +++ b/crates/next-build-test/nextConfig.json @@ -30,6 +30,8 @@ "disableStaticImages": false, "minimumCacheTTL": 60, "formats": ["image/avif", "image/webp"], + "maximumRedirects": 3, + "dangerouslyAllowLocalIP": false, "dangerouslyAllowSVG": false, "contentSecurityPolicy": "script-src 'none'; frame-src 'none'; sandbox;", "contentDispositionType": "inline", diff --git a/docs/01-app/03-api-reference/02-components/image.mdx b/docs/01-app/03-api-reference/02-components/image.mdx index 3c4088671d45ec..e25a531a76a330 100644 --- a/docs/01-app/03-api-reference/02-components/image.mdx +++ b/docs/01-app/03-api-reference/02-components/image.mdx @@ -804,6 +804,52 @@ module.exports = { } ``` +#### `maximumRedirects` + +The default image optimization loader will follow HTTP redirects when fetching remote images up to 3 times. + +```js filename="next.config.js" +module.exports = { + images: { + maximumRedirects: 3, + }, +} +``` + +You can configure the number of redirects to follow when fetching remote images. Setting the value to `0` will disable following redirects. + +```js filename="next.config.js" +module.exports = { + images: { + maximumRedirects: 0, + }, +} +``` + +#### `dangerouslyAllowLocalIP` + +In rare cases when self-hosting Next.js on a private network, you may want to allow optimizing images from local IP addresses on the same network. This is not recommended for most users because it could allow malicious users to access content on your internal network. + +By default, the value is false. + +```js filename="next.config.js" +module.exports = { + images: { + dangerouslyAllowLocalIP: false, + }, +} +``` + +If you need to optimize remote images hosted elsewhere in your local network, you can set the value to true. + +```js filename="next.config.js" +module.exports = { + images: { + dangerouslyAllowLocalIP: true, + }, +} +``` + #### `dangerouslyAllowSVG` `dangerouslyAllowSVG` allows you to serve SVG images. @@ -1284,7 +1330,7 @@ export default function Home() { | Version | Changes | | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `v16.0.0` | `qualities` default configuration changed to `[75]`, `preload` prop added, `priority` prop deprecated. | +| `v16.0.0` | `qualities` default configuration changed to `[75]`, `preload` prop added, `priority` prop deprecated, `dangerouslyAllowLocalIP` config added, `maximumRedirects` config added. | | `v15.3.0` | `remotePatterns` added support for array of `URL` objects. | | `v15.0.0` | `contentDispositionType` configuration default changed to `attachment`. | | `v14.2.23` | `qualities` configuration added. | diff --git a/packages/next/package.json b/packages/next/package.json index 9e040eaac59189..4275b6cdac1309 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -270,6 +270,7 @@ "ignore-loader": "0.1.2", "image-size": "1.2.1", "is-docker": "2.0.0", + "is-local-address": "2.2.2", "is-wsl": "2.2.0", "jest-worker": "27.5.1", "json5": "2.2.3", diff --git a/packages/next/src/compiled/is-local-address/index.js b/packages/next/src/compiled/is-local-address/index.js new file mode 100644 index 00000000000000..034f63037d60e4 --- /dev/null +++ b/packages/next/src/compiled/is-local-address/index.js @@ -0,0 +1 @@ +(()=>{"use strict";var e={555:(e,f,a)=>{e.exports=e=>a(276)(e)||a(628)(e)},276:e=>{const f=["0(?:\\.\\d{1,3}){3}","10(?:\\.\\d{1,3}){3}","127(?:\\.\\d{1,3}){3}","169\\.254\\.(?:[1-9]|1?\\d\\d|2[0-4]\\d|25[0-4])\\.\\d{1,3}","172\\.(?:1[6-9]|2\\d|3[01])(?:\\.\\d{1,3}){2}","192\\.(?:0\\.0(?:\\.\\d{1,3})|0\\.2(?:\\.\\d{1,3})|168(?:\\.\\d{1,3}){2})","100\\.(?:6[4-9]|[7-9]\\d|1[01]\\d|12[0-7])(?:\\.\\d{1,3}){2}","198\\.(?:1[89](?:\\.\\d{1,3}){2}|51\\.100(?:\\.\\d{1,3}))","203\\.0\\.113(?:\\.\\d{1,3})","22[4-9](?:\\.\\d{1,3}){3}|23[0-9](?:\\.\\d{1,3}){3}","24[0-9](?:\\.\\d{1,3}){3}|25[0-5](?:\\.\\d{1,3}){3}","localhost"];const a=new RegExp(`^(${f.join("|")})$`);e.exports=a.test.bind(a);e.exports.regex=a},628:e=>{const f=[/^::f{4}:0?([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$/,/^64:ff9b::([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$/,/^100:(:[0-9a-fA-F]{0,4}){0,6}$/,/^2001:(:[0-9a-fA-F]{0,4}){0,6}$/,/^2001:1[0-9a-fA-F]:([0-9a-fA-F]{0,4}:){0,7}[0-9a-fA-F]{0,4}$/,/^2001:2[0-9a-fA-F]?:([0-9a-fA-F]{0,4}:){0,7}[0-9a-fA-F]{0,4}$/,/^2001:db8:([0-9a-fA-F]{0,4}:){0,7}[0-9a-fA-F]{0,4}$/,/^3fff:([0-9a-fA-F]{0,4}:){0,7}[0-9a-fA-F]{0,4}$/,/^f[b-d][0-9a-fA-F]{2}:([0-9a-fA-F]{0,4}:){0,7}[0-9a-fA-F]{0,4}$/i,/^fe[8-9a-bA-B][0-9a-fA-F]:/i,/^ff([0-9a-fA-F]{2,2}):/i,/^ff00:([0-9a-fA-F]{0,4}:){0,7}[0-9a-fA-F]{0,4}$/,/^::1?$/,/^fec0:([0-9a-fA-F]{0,4}:){0,7}[0-9a-fA-F]{0,4}$/i,/^2002:([0-9a-fA-F]{0,4}:){0,7}[0-9a-fA-F]{0,4}$/];const a=new RegExp(`^(${f.map((e=>e.source)).join("|")})$`);e.exports=e=>{if(e.startsWith("[")&&e.endsWith("]")){e=e.slice(1,-1)}return a.test(e)};e.exports.regex=a}};var f={};function __nccwpck_require__(a){var r=f[a];if(r!==undefined){return r.exports}var d=f[a]={exports:{}};var t=true;try{e[a](d,d.exports,__nccwpck_require__);t=false}finally{if(t)delete f[a]}return d.exports}if(typeof __nccwpck_require__!=="undefined")__nccwpck_require__.ab=__dirname+"/";var a=__nccwpck_require__(555);module.exports=a})(); \ No newline at end of file diff --git a/packages/next/src/compiled/is-local-address/package.json b/packages/next/src/compiled/is-local-address/package.json new file mode 100644 index 00000000000000..61bbef852181e6 --- /dev/null +++ b/packages/next/src/compiled/is-local-address/package.json @@ -0,0 +1 @@ +{"name":"is-local-address","main":"index.js","author":{"email":"josefrancisco.verdu@gmail.com","name":"Kiko Beats","url":"https://kikobeats.com"},"license":"MIT"} diff --git a/packages/next/src/server/config-schema.ts b/packages/next/src/server/config-schema.ts index a1bd2b7cee8640..dc8cd30d0784af 100644 --- a/packages/next/src/server/config-schema.ts +++ b/packages/next/src/server/config-schema.ts @@ -551,6 +551,7 @@ export const configSchema: zod.ZodType = z.lazy(() => contentSecurityPolicy: z.string().optional(), contentDispositionType: z.enum(['inline', 'attachment']).optional(), dangerouslyAllowSVG: z.boolean().optional(), + dangerouslyAllowLocalIP: z.boolean().optional(), deviceSizes: z .array(z.number().int().gte(1).lte(10000)) .max(25) @@ -568,6 +569,7 @@ export const configSchema: zod.ZodType = z.lazy(() => .optional(), loader: z.enum(VALID_LOADERS).optional(), loaderFile: z.string().optional(), + maximumRedirects: z.number().int().min(0).max(20).optional(), minimumCacheTTL: z.number().int().gte(0).optional(), path: z.string().optional(), qualities: z diff --git a/packages/next/src/server/image-optimizer.ts b/packages/next/src/server/image-optimizer.ts index 6d4c098ec67ec3..c99b4ddf98eadb 100644 --- a/packages/next/src/server/image-optimizer.ts +++ b/packages/next/src/server/image-optimizer.ts @@ -6,6 +6,7 @@ import contentDisposition from 'next/dist/compiled/content-disposition' import imageSizeOf from 'next/dist/compiled/image-size' import { detector } from 'next/dist/compiled/image-detector/detector.js' import isAnimated from 'next/dist/compiled/is-animated' +import isLocalAddress from 'next/dist/compiled/is-local-address' import { join } from 'path' import nodeUrl, { type UrlWithParsedQuery } from 'url' @@ -30,6 +31,9 @@ import isError from '../lib/is-error' import { parseUrl } from '../lib/url' import type { CacheControl } from './lib/cache-control' import { InvariantError } from '../shared/lib/invariant-error' +import { lookup } from 'dns/promises' +import { isIP } from 'net' +import { ALL } from 'dns' type XCacheHeader = 'MISS' | 'HIT' | 'STALE' @@ -700,9 +704,40 @@ export async function optimizeImage({ return optimizedBuffer } -export async function fetchExternalImage(href: string): Promise { +function isRedirect(statusCode: number) { + return [301, 302, 303, 307, 308].includes(statusCode) +} + +export async function fetchExternalImage( + href: string, + dangerouslyAllowLocalIP: boolean, + count = 3 +): Promise { + if (!dangerouslyAllowLocalIP) { + const { hostname } = new URL(href) + let ips = [hostname] + if (!isIP(hostname)) { + const records = await lookup(hostname, { + family: 0, + all: true, + hints: ALL, + }).catch((_) => [{ address: hostname }]) + ips = records.map((record) => record.address) + } + const privateIps = ips.filter((ip) => isLocalAddress(ip)) + if (privateIps.length > 0) { + Log.error( + 'upstream image', + href, + 'resolved to private ip', + JSON.stringify(privateIps) + ) + throw new ImageError(400, '"url" parameter is not allowed') + } + } const res = await fetch(href, { signal: AbortSignal.timeout(7_000), + redirect: 'manual', }).catch((err) => err as Error) if (res instanceof Error) { @@ -717,6 +752,23 @@ export async function fetchExternalImage(href: string): Promise { throw err } + const locationHeader = res.headers.get('Location') + if ( + isRedirect(res.status) && + locationHeader && + URL.canParse(locationHeader, href) + ) { + if (count === 0) { + Log.error('upstream image response had too many redirects', href) + throw new ImageError( + 508, + '"url" parameter is valid but upstream response is invalid' + ) + } + const redirect = new URL(locationHeader, href).href + return fetchExternalImage(redirect, dangerouslyAllowLocalIP, count - 1) + } + if (!res.ok) { Log.error('upstream image response failed for', href, res.status) throw new ImageError( diff --git a/packages/next/src/server/next-server.ts b/packages/next/src/server/next-server.ts index 0d5b64e19f1ac4..879c81d7ac6134 100644 --- a/packages/next/src/server/next-server.ts +++ b/packages/next/src/server/next-server.ts @@ -780,7 +780,11 @@ export default class NextNodeServer extends BaseServer< const { isAbsolute, href } = paramsResult const imageUpstream = isAbsolute - ? await fetchExternalImage(href) + ? await fetchExternalImage( + href, + this.nextConfig.images.dangerouslyAllowLocalIP, + this.nextConfig.images.maximumRedirects + ) : await fetchInternalImage( href, req.originalRequest, diff --git a/packages/next/src/shared/lib/image-config.ts b/packages/next/src/shared/lib/image-config.ts index abd1b4939da9ba..72d91d24328c1d 100644 --- a/packages/next/src/shared/lib/image-config.ts +++ b/packages/next/src/shared/lib/image-config.ts @@ -103,6 +103,12 @@ export type ImageConfigComplete = { /** @see [Acceptable formats](https://nextjs.org/docs/api-reference/next/image#acceptable-formats) */ formats: ImageFormat[] + /** @see [Maximum Redirects](https://nextjs.org/docs/api-reference/next/image#maximumredirects) */ + maximumRedirects: number + + /** @see [Dangerously Allow Local IP](https://nextjs.org/docs/api-reference/next/image#dangerously-allow-local-ip) */ + dangerouslyAllowLocalIP: boolean + /** @see [Dangerously Allow SVG](https://nextjs.org/docs/api-reference/next/image#dangerously-allow-svg) */ dangerouslyAllowSVG: boolean @@ -140,6 +146,8 @@ export const imageConfigDefault: ImageConfigComplete = { disableStaticImages: false, minimumCacheTTL: 14400, // 4 hours formats: ['image/webp'], + maximumRedirects: 3, + dangerouslyAllowLocalIP: false, dangerouslyAllowSVG: false, contentSecurityPolicy: `script-src 'none'; frame-src 'none'; sandbox;`, contentDispositionType: 'attachment', diff --git a/packages/next/taskfile.js b/packages/next/taskfile.js index 1d717ae5e86125..4fc2f0edf4ff09 100644 --- a/packages/next/taskfile.js +++ b/packages/next/taskfile.js @@ -1275,6 +1275,14 @@ export async function ncc_is_animated(task, opts) { .target('src/compiled/is-animated') } // eslint-disable-next-line camelcase +externals['is-local-address'] = 'next/dist/compiled/is-local-address' +export async function ncc_is_local_address(task, opts) { + await task + .source(relative(__dirname, require.resolve('is-local-address'))) + .ncc({ packageName: 'is-local-address', externals }) + .target('src/compiled/is-local-address') +} +// eslint-disable-next-line camelcase externals['is-docker'] = 'next/dist/compiled/is-docker' export async function ncc_is_docker(task, opts) { await task @@ -2349,6 +2357,7 @@ export async function ncc(task, opts) { 'ncc_http_proxy', 'ncc_ignore_loader', 'ncc_is_animated', + 'ncc_is_local_address', 'ncc_is_docker', 'ncc_is_wsl', 'ncc_json5', diff --git a/packages/next/types/$$compiled.internal.d.ts b/packages/next/types/$$compiled.internal.d.ts index b9c97707374227..e7f479b865b2d5 100644 --- a/packages/next/types/$$compiled.internal.d.ts +++ b/packages/next/types/$$compiled.internal.d.ts @@ -856,6 +856,10 @@ declare module 'next/dist/compiled/is-animated' { export default function isAnimated(buffer: Buffer): boolean } +declare module 'next/dist/compiled/is-local-address' { + export default function isLocalAddress(ip: string): boolean +} + declare module 'next/dist/compiled/@opentelemetry/api' { import * as m from '@opentelemetry/api' export = m diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6d2f8fbd7caa6c..4bcc1eb08b460b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -287,7 +287,7 @@ importers: version: 5.2.1(eslint@9.12.0(jiti@2.5.1)) eslint-plugin-import: specifier: 2.31.0 - version: 2.31.0(@typescript-eslint/parser@8.36.0(eslint@9.12.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-typescript@3.6.3)(eslint@9.12.0(jiti@2.5.1)) + version: 2.31.0(@typescript-eslint/parser@8.36.0(eslint@9.12.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.12.0(jiti@2.5.1)) eslint-plugin-jest: specifier: 27.6.3 version: 27.6.3(@typescript-eslint/eslint-plugin@8.36.0(@typescript-eslint/parser@8.36.0(eslint@9.12.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.12.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.12.0(jiti@2.5.1))(jest@29.7.0(@types/node@20.17.6(patch_hash=rvl3vkomen3tospgr67bzubfyu))(babel-plugin-macros@3.1.0))(typescript@5.9.2) @@ -657,7 +657,7 @@ importers: version: 9.12.0(jiti@2.5.1) eslint-config-next: specifier: canary - version: 15.6.0-canary.36(eslint-plugin-import-x@4.3.1(eslint@9.12.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.12.0(jiti@2.5.1))(typescript@5.9.2) + version: link:../../packages/eslint-config-next tailwindcss: specifier: 4.1.13 version: 4.1.13 @@ -1420,6 +1420,9 @@ importers: is-docker: specifier: 2.0.0 version: 2.0.0 + is-local-address: + specifier: 2.2.2 + version: 2.2.2 is-wsl: specifier: 2.2.0 version: 2.2.0 @@ -4363,9 +4366,6 @@ packages: '@next/env@15.5.3': resolution: {integrity: sha512-RSEDTRqyihYXygx/OJXwvVupfr9m04+0vH8vyy0HfZ7keRto6VX9BbEk0J2PUk0VGy6YhklJUSrgForov5F9pw==} - '@next/eslint-plugin-next@15.6.0-canary.36': - resolution: {integrity: sha512-RznXP7eSDugL3fVYdwIyCXi4I5A2EQ4YBg0tANoPGfo1Eyn1KOTGfzBB8MLogN7TjBbUusw+gzpQ/mBvFc4RhA==} - '@next/swc-darwin-arm64@15.5.3': resolution: {integrity: sha512-nzbHQo69+au9wJkGKTU9lP7PXv0d1J5ljFpvb+LnEomLtSbJkbZyEs6sbF3plQmiOB2l9OBtN2tNSvCH1nQ9Jg==} engines: {node: '>= 10'} @@ -9174,15 +9174,6 @@ packages: engines: {node: '>=6.0'} hasBin: true - eslint-config-next@15.6.0-canary.36: - resolution: {integrity: sha512-v7uPmVVRtO3RFjaFsXoXdI60TWzPIZXAWO3HifrEC/y4Pr4y16LeWH45uxSc9H78V6D3iTOisSZeBdVr87MwoQ==} - peerDependencies: - eslint: ^7.23.0 || ^8.0.0 || ^9.0.0 - typescript: '>=3.3.1' - peerDependenciesMeta: - typescript: - optional: true - eslint-formatter-codeframe@7.32.1: resolution: {integrity: sha512-DK/3Q3+zVKq/7PdSYiCxPrsDF8H/TRMK5n8Hziwr4IMkMy+XiKSwbpj25AdajS63I/B61Snetq4uVvX9fOLyAg==} engines: {node: ^10.12.0 || >=12.0.0} @@ -11073,6 +11064,10 @@ packages: is-lambda@1.0.1: resolution: {integrity: sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==} + is-local-address@2.2.2: + resolution: {integrity: sha512-0f589LeYFladIRZrCx1uVjkAlRF5CPGKDuJgbwVGceRN2Q691MFxH+epioG7krwIfnqcE+kw5MohzXYx2VX2ew==} + engines: {node: '>= 10'} + is-map@2.0.3: resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} engines: {node: '>= 0.4'} @@ -14773,6 +14768,7 @@ packages: engines: {node: '>=0.6.0', teleport: '>=0.2.0'} deprecated: |- You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other. + (For a CapTP with native promises, see @endo/eventual-send and @endo/captp) qs@6.11.0: @@ -21093,10 +21089,6 @@ snapshots: '@next/env@15.5.3': {} - '@next/eslint-plugin-next@15.6.0-canary.36': - dependencies: - fast-glob: 3.3.1 - '@next/swc-darwin-arm64@15.5.3': optional: true @@ -26754,26 +26746,6 @@ snapshots: optionalDependencies: source-map: 0.6.1 - eslint-config-next@15.6.0-canary.36(eslint-plugin-import-x@4.3.1(eslint@9.12.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.12.0(jiti@2.5.1))(typescript@5.9.2): - dependencies: - '@next/eslint-plugin-next': 15.6.0-canary.36 - '@rushstack/eslint-patch': 1.10.4 - '@typescript-eslint/eslint-plugin': 8.36.0(@typescript-eslint/parser@8.36.0(eslint@9.12.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.12.0(jiti@2.5.1))(typescript@5.9.2) - '@typescript-eslint/parser': 8.36.0(eslint@9.12.0(jiti@2.5.1))(typescript@5.9.2) - eslint: 9.12.0(jiti@2.5.1) - eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@8.36.0(eslint@9.12.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import-x@4.3.1(eslint@9.12.0(jiti@2.5.1))(typescript@5.9.2))(eslint-plugin-import@2.31.0)(eslint@9.12.0(jiti@2.5.1)) - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.36.0(eslint@9.12.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.12.0(jiti@2.5.1)) - eslint-plugin-jsx-a11y: 6.10.0(eslint@9.12.0(jiti@2.5.1)) - eslint-plugin-react: 7.37.1(eslint@9.12.0(jiti@2.5.1)) - eslint-plugin-react-hooks: 5.0.0(eslint@9.12.0(jiti@2.5.1)) - optionalDependencies: - typescript: 5.9.2 - transitivePeerDependencies: - - eslint-import-resolver-webpack - - eslint-plugin-import-x - - supports-color - eslint-formatter-codeframe@7.32.1: dependencies: '@babel/code-frame': 7.12.11 @@ -26807,26 +26779,6 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.36.0(eslint@9.12.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import-x@4.3.1(eslint@9.12.0(jiti@2.5.1))(typescript@5.9.2))(eslint-plugin-import@2.31.0)(eslint@9.12.0(jiti@2.5.1)): - dependencies: - '@nolyfill/is-core-module': 1.0.39 - debug: 4.3.7 - enhanced-resolve: 5.17.1 - eslint: 9.12.0(jiti@2.5.1) - eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.36.0(eslint@9.12.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.36.0(eslint@9.12.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import-x@4.3.1(eslint@9.12.0(jiti@2.5.1))(typescript@5.9.2))(eslint-plugin-import@2.31.0)(eslint@9.12.0(jiti@2.5.1)))(eslint@9.12.0(jiti@2.5.1)) - fast-glob: 3.3.2 - get-tsconfig: 4.8.1 - is-bun-module: 1.2.1 - is-glob: 4.0.3 - optionalDependencies: - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.36.0(eslint@9.12.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.12.0(jiti@2.5.1)) - eslint-plugin-import-x: 4.3.1(eslint@9.12.0(jiti@2.5.1))(typescript@5.9.2) - transitivePeerDependencies: - - '@typescript-eslint/parser' - - eslint-import-resolver-node - - eslint-import-resolver-webpack - - supports-color - eslint-mdx@3.1.5(eslint@9.12.0(jiti@2.5.1)): dependencies: acorn: 8.14.0 @@ -26859,14 +26811,13 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.0(@typescript-eslint/parser@8.36.0(eslint@9.12.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.36.0(eslint@9.12.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import-x@4.3.1(eslint@9.12.0(jiti@2.5.1))(typescript@5.9.2))(eslint-plugin-import@2.31.0)(eslint@9.12.0(jiti@2.5.1)))(eslint@9.12.0(jiti@2.5.1)): + eslint-module-utils@2.12.0(@typescript-eslint/parser@8.36.0(eslint@9.12.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint@9.12.0(jiti@2.5.1)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.36.0(eslint@9.12.0(jiti@2.5.1))(typescript@5.9.2) eslint: 9.12.0(jiti@2.5.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@8.36.0(eslint@9.12.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import-x@4.3.1(eslint@9.12.0(jiti@2.5.1))(typescript@5.9.2))(eslint-plugin-import@2.31.0)(eslint@9.12.0(jiti@2.5.1)) transitivePeerDependencies: - supports-color @@ -26894,24 +26845,6 @@ snapshots: - typescript optional: true - eslint-plugin-import-x@4.3.1(eslint@9.12.0(jiti@2.5.1))(typescript@5.9.2): - dependencies: - '@typescript-eslint/utils': 8.36.0(eslint@9.12.0(jiti@2.5.1))(typescript@5.9.2) - debug: 4.4.0 - doctrine: 3.0.0 - eslint: 9.12.0(jiti@2.5.1) - eslint-import-resolver-node: 0.3.9 - get-tsconfig: 4.10.0 - is-glob: 4.0.3 - minimatch: 9.0.5 - semver: 7.6.3 - stable-hash: 0.0.4 - tslib: 2.8.1 - transitivePeerDependencies: - - supports-color - - typescript - optional: true - eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.16.0(eslint@9.12.0(jiti@2.5.1))(typescript@5.6.3))(eslint-import-resolver-typescript@3.6.3)(eslint@9.12.0(jiti@2.5.1)): dependencies: '@rtsao/scc': 1.1.0 @@ -26941,35 +26874,6 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.36.0(eslint@9.12.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-typescript@3.6.3)(eslint@9.12.0(jiti@2.5.1)): - dependencies: - '@rtsao/scc': 1.1.0 - array-includes: 3.1.8 - array.prototype.findlastindex: 1.2.5 - array.prototype.flat: 1.3.2 - array.prototype.flatmap: 1.3.2 - debug: 3.2.7 - doctrine: 2.1.0 - eslint: 9.12.0(jiti@2.5.1) - eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.36.0(eslint@9.12.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.36.0(eslint@9.12.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import-x@4.3.1(eslint@9.12.0(jiti@2.5.1))(typescript@5.9.2))(eslint-plugin-import@2.31.0)(eslint@9.12.0(jiti@2.5.1)))(eslint@9.12.0(jiti@2.5.1)) - hasown: 2.0.2 - is-core-module: 2.15.1 - is-glob: 4.0.3 - minimatch: 3.1.2 - object.fromentries: 2.0.8 - object.groupby: 1.0.3 - object.values: 1.2.0 - semver: 6.3.1 - string.prototype.trimend: 1.0.8 - tsconfig-paths: 3.15.0 - optionalDependencies: - '@typescript-eslint/parser': 8.36.0(eslint@9.12.0(jiti@2.5.1))(typescript@5.9.2) - transitivePeerDependencies: - - eslint-import-resolver-typescript - - eslint-import-resolver-webpack - - supports-color - eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.36.0(eslint@9.12.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.12.0(jiti@2.5.1)): dependencies: '@rtsao/scc': 1.1.0 @@ -26981,7 +26885,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.12.0(jiti@2.5.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.36.0(eslint@9.12.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.36.0(eslint@9.12.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import-x@4.3.1(eslint@9.12.0(jiti@2.5.1))(typescript@5.9.2))(eslint-plugin-import@2.31.0)(eslint@9.12.0(jiti@2.5.1)))(eslint@9.12.0(jiti@2.5.1)) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.36.0(eslint@9.12.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint@9.12.0(jiti@2.5.1)) hasown: 2.0.2 is-core-module: 2.15.1 is-glob: 4.0.3 @@ -29475,6 +29379,8 @@ snapshots: is-lambda@1.0.1: {} + is-local-address@2.2.2: {} + is-map@2.0.3: {} is-module@1.0.0: {} diff --git a/test/integration/image-optimizer/test/maximum-redirects-0.test.ts b/test/integration/image-optimizer/test/maximum-redirects-0.test.ts new file mode 100644 index 00000000000000..e78fa6c67202d1 --- /dev/null +++ b/test/integration/image-optimizer/test/maximum-redirects-0.test.ts @@ -0,0 +1,23 @@ +import { join } from 'path' +import { setupTests } from './util' + +const appDir = join(__dirname, '../app') + +describe('with maximumRedirects 0', () => { + setupTests({ + nextConfigImages: { + dangerouslyAllowLocalIP: true, + // Configure external domains so we can try out external redirects + domains: [ + 'localhost', + '127.0.0.1', + 'example.com', + 'assets.vercel.com', + 'image-optimization-test.vercel.app', + ], + // Prevent redirects + maximumRedirects: 0, + }, + appDir, + }) +}) diff --git a/test/integration/image-optimizer/test/maximum-redirects-1.test.ts b/test/integration/image-optimizer/test/maximum-redirects-1.test.ts new file mode 100644 index 00000000000000..2a774169d592d9 --- /dev/null +++ b/test/integration/image-optimizer/test/maximum-redirects-1.test.ts @@ -0,0 +1,23 @@ +import { join } from 'path' +import { setupTests } from './util' + +const appDir = join(__dirname, '../app') + +describe('with maximumRedirects 1', () => { + setupTests({ + nextConfigImages: { + dangerouslyAllowLocalIP: true, + // Configure external domains so we can try out external redirects + domains: [ + 'localhost', + '127.0.0.1', + 'example.com', + 'assets.vercel.com', + 'image-optimization-test.vercel.app', + ], + // Only one redirect + maximumRedirects: 1, + }, + appDir, + }) +}) diff --git a/test/integration/image-optimizer/test/minimum-cache-ttl.test.ts b/test/integration/image-optimizer/test/minimum-cache-ttl.test.ts index 73c2c810621e12..ebe1ef4fc7ab5a 100644 --- a/test/integration/image-optimizer/test/minimum-cache-ttl.test.ts +++ b/test/integration/image-optimizer/test/minimum-cache-ttl.test.ts @@ -6,6 +6,7 @@ const appDir = join(__dirname, '../app') describe('with minimumCacheTTL of 5 sec', () => { setupTests({ nextConfigImages: { + dangerouslyAllowLocalIP: true, // Configure external domains so we can try out // variations of the upstream Cache-Control header. domains: [ diff --git a/test/integration/image-optimizer/test/util.ts b/test/integration/image-optimizer/test/util.ts index dbc96e1116e91b..3682ea1d04689e 100644 --- a/test/integration/image-optimizer/test/util.ts +++ b/test/integration/image-optimizer/test/util.ts @@ -36,6 +36,7 @@ type RunTestsCtx = SetupTestsCtx & { nextOutput?: string } +let infiniteRedirect = 0 const largeSize = 1080 // defaults defined in server/config.ts const animatedWarnText = 'is an animated image so it will not be optimized. Consider adding the "unoptimized" property to the .' @@ -44,15 +45,32 @@ export async function serveSlowImage() { const port = await findPort() const server = http.createServer(async (req, res) => { const parsedUrl = new URL(req.url, 'http://localhost') - const delay = Number(parsedUrl.searchParams.get('delay')) || 500 + const delay = Number(parsedUrl.searchParams.get('delay')) || 0 const status = Number(parsedUrl.searchParams.get('status')) || 200 + const location = parsedUrl.searchParams.get('location') console.log('delaying image for', delay) await waitFor(delay) res.statusCode = status - if (status === 308) { + if (infiniteRedirect > 0 && infiniteRedirect < 1000) { + infiniteRedirect++ + res.statusCode = 308 + console.log('infinite redirect', location) + res.setHeader('location', '/') + res.end() + return + } + + if (status === 301 && location) { + console.log('redirecting to location', location) + res.setHeader('location', location) + res.end() + return + } + + if (status === 399) { res.end('invalid status') return } @@ -163,6 +181,8 @@ export function runTests(ctx: RunTestsCtx) { domains = [], formats = [], minimumCacheTTL = 14400, + maximumRedirects = 3, + dangerouslyAllowLocalIP, } = nextConfigImages || {} const avifEnabled = formats[0] === 'image/avif' let slowImageServer: Awaited> @@ -173,9 +193,9 @@ export function runTests(ctx: RunTestsCtx) { slowImageServer.stop() }) - if (domains.length > 0) { + if (domains.length > 0 && dangerouslyAllowLocalIP) { it('should normalize invalid status codes', async () => { - const url = `http://localhost:${slowImageServer.port}/slow.png?delay=${1}&status=308` + const url = `http://localhost:${slowImageServer.port}/slow.png?status=399` const query = { url, w: ctx.w, q: ctx.q } const opts: RequestInit = { headers: { accept: 'image/webp' }, @@ -194,6 +214,35 @@ export function runTests(ctx: RunTestsCtx) { }) } + if (domains.length > 0) { + it('should follow redirect from http to https when maximumRedirects > 0', async () => { + const url = `http://image-optimization-test.vercel.app/frog.png` + const query = { url, w: ctx.w, q: ctx.q } + const res = await fetchViaHTTP(ctx.appPort, '/_next/image', query, {}) + expect(res.status).toBe(maximumRedirects > 0 ? 200 : 508) + }) + + it('should follow redirect when dangerouslyAllowLocalIP enabled', async () => { + const url = `http://localhost:${slowImageServer.port}?status=301&location=%2Fslow.png` + const query = { url, w: ctx.w, q: ctx.q } + const res = await fetchViaHTTP(ctx.appPort, '/_next/image', query, {}) + let expectedStatus = dangerouslyAllowLocalIP ? 200 : 400 + if (maximumRedirects === 0) { + expectedStatus = 508 + } + expect(res.status).toBe(expectedStatus) + }) + + it('should return 508 after redirecting too many times', async () => { + infiniteRedirect = 1 + const url = `http://localhost:${slowImageServer.port}` + const query = { url, w: ctx.w, q: ctx.q } + const res = await fetchViaHTTP(ctx.appPort, '/_next/image', query, {}) + expect(res.status).toBe(508) + infiniteRedirect = 0 + }) + } + it('should return home page', async () => { const res = await fetchViaHTTP(ctx.appPort, '/', null, {}) expect(await res.text()).toMatch(/Image Optimizer Home/m) @@ -887,7 +936,7 @@ export function runTests(ctx: RunTestsCtx) { }) } - if (domains.length > 0) { + if (domains.length > 0 && dangerouslyAllowLocalIP) { it('should resize absolute url from localhost', async () => { const url = `http://localhost:${ctx.appPort}/test.png` const query = { url, w: ctx.w, q: ctx.q } @@ -1043,7 +1092,7 @@ export function runTests(ctx: RunTestsCtx) { } it('should fail when url has file protocol', async () => { - const url = `file://localhost:${ctx.appPort}/test.png` + const url = `file://example.vercel.sh:${ctx.appPort}/test.png` const query = { url, w: ctx.w, q: ctx.q } const opts = { headers: { accept: 'image/webp' } } const res = await fetchViaHTTP(ctx.appPort, '/_next/image', query, opts) @@ -1052,7 +1101,7 @@ export function runTests(ctx: RunTestsCtx) { }) it('should fail when url has ftp protocol', async () => { - const url = `ftp://localhost:${ctx.appPort}/test.png` + const url = `ftp://example.vercel.sh:${ctx.appPort}/test.png` const query = { url, w: ctx.w, q: ctx.q } const opts = { headers: { accept: 'image/webp' } } const res = await fetchViaHTTP(ctx.appPort, '/_next/image', query, opts) @@ -1068,7 +1117,7 @@ export function runTests(ctx: RunTestsCtx) { }) it('should fail when url is protocol relative', async () => { - const query = { url: `//example.com`, w: ctx.w, q: ctx.q } + const query = { url: `//example.vercel.sh`, w: ctx.w, q: ctx.q } const res = await fetchViaHTTP(ctx.appPort, '/_next/image', query, {}) expect(res.status).toBe(400) expect(await res.text()).toBe( @@ -1139,7 +1188,7 @@ export function runTests(ctx: RunTestsCtx) { ) }) - if (domains.length > 0) { + if (domains.length > 0 && dangerouslyAllowLocalIP) { it('should fail when url fails to load an image', async () => { const url = `http://localhost:${ctx.appPort}/not-an-image` const query = { w: ctx.w, url, q: ctx.q } @@ -1489,7 +1538,7 @@ export function runTests(ctx: RunTestsCtx) { expect(await res.text()).toBe("The requested resource isn't a valid image.") }) - if (domains.length > 0) { + if (domains.length > 0 && dangerouslyAllowLocalIP) { it('should handle concurrent requests', async () => { await cleanImagesDir(ctx.imagesDir) const delay = 500 @@ -1614,6 +1663,7 @@ export const setupTests = (ctx: SetupTestsCtx) => { q: 100, isDev, nextConfigImages: { + dangerouslyAllowLocalIP: true, domains: [ 'localhost', '127.0.0.1', @@ -1710,6 +1760,7 @@ export const setupTests = (ctx: SetupTestsCtx) => { q: 100, isDev, nextConfigImages: { + dangerouslyAllowLocalIP: true, domains: [ 'localhost', '127.0.0.1', diff --git a/test/integration/next-image-new/app-dir-localpatterns/test/index.test.ts b/test/integration/next-image-new/app-dir-localpatterns/test/index.test.ts index 30e4907ec04b9c..8cfc8a713b5527 100644 --- a/test/integration/next-image-new/app-dir-localpatterns/test/index.test.ts +++ b/test/integration/next-image-new/app-dir-localpatterns/test/index.test.ts @@ -75,6 +75,7 @@ function runTests(mode: 'dev' | 'server') { contentDispositionType: 'attachment', contentSecurityPolicy: "script-src 'none'; frame-src 'none'; sandbox;", + dangerouslyAllowLocalIP: false, dangerouslyAllowSVG: false, deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840], disableStaticImages: false, @@ -96,6 +97,7 @@ function runTests(mode: 'dev' | 'server') { search: '', }, ], + maximumRedirects: 3, minimumCacheTTL: 14400, path: '/_next/image', qualities: [75], diff --git a/test/integration/next-image-new/app-dir-qualities/test/index.test.ts b/test/integration/next-image-new/app-dir-qualities/test/index.test.ts index 67efd5f01a0113..8589d740bd9299 100644 --- a/test/integration/next-image-new/app-dir-qualities/test/index.test.ts +++ b/test/integration/next-image-new/app-dir-qualities/test/index.test.ts @@ -92,6 +92,7 @@ function runTests(mode: 'dev' | 'server') { contentDispositionType: 'attachment', contentSecurityPolicy: "script-src 'none'; frame-src 'none'; sandbox;", + dangerouslyAllowLocalIP: false, dangerouslyAllowSVG: false, deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840], disableStaticImages: false, @@ -108,6 +109,7 @@ function runTests(mode: 'dev' | 'server') { search: '', }, ], + maximumRedirects: 3, minimumCacheTTL: 14400, path: '/_next/image', qualities: [42, 69, 88], diff --git a/test/integration/next-image-new/app-dir/test/index.test.ts b/test/integration/next-image-new/app-dir/test/index.test.ts index c316d738c5b984..821982b6bb870c 100644 --- a/test/integration/next-image-new/app-dir/test/index.test.ts +++ b/test/integration/next-image-new/app-dir/test/index.test.ts @@ -1778,6 +1778,7 @@ function runTests(mode: 'dev' | 'server') { contentDispositionType: 'attachment', contentSecurityPolicy: "script-src 'none'; frame-src 'none'; sandbox;", + dangerouslyAllowLocalIP: false, dangerouslyAllowSVG: false, deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840], disableStaticImages: false, @@ -1794,6 +1795,7 @@ function runTests(mode: 'dev' | 'server') { search: '', }, ], + maximumRedirects: 3, minimumCacheTTL: 14400, path: '/_next/image', qualities: [75], diff --git a/test/integration/next-image-new/unicode/test/index.test.ts b/test/integration/next-image-new/unicode/test/index.test.ts index 34c6996e435035..7d2beb43aa7240 100644 --- a/test/integration/next-image-new/unicode/test/index.test.ts +++ b/test/integration/next-image-new/unicode/test/index.test.ts @@ -75,6 +75,7 @@ function runTests(mode: 'server' | 'dev') { contentDispositionType: 'attachment', contentSecurityPolicy: "script-src 'none'; frame-src 'none'; sandbox;", + dangerouslyAllowLocalIP: false, dangerouslyAllowSVG: false, deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840], disableStaticImages: false, @@ -101,6 +102,7 @@ function runTests(mode: 'server' | 'dev') { search: '', }, ], + maximumRedirects: 3, minimumCacheTTL: 14400, path: '/_next/image', qualities: [75], diff --git a/test/integration/next-image-new/unoptimized/test/index.test.ts b/test/integration/next-image-new/unoptimized/test/index.test.ts index 2304d3e784cd43..4c78061c99668a 100644 --- a/test/integration/next-image-new/unoptimized/test/index.test.ts +++ b/test/integration/next-image-new/unoptimized/test/index.test.ts @@ -100,6 +100,7 @@ function runTests(url: string, mode: 'dev' | 'server') { contentDispositionType: 'attachment', contentSecurityPolicy: "script-src 'none'; frame-src 'none'; sandbox;", + dangerouslyAllowLocalIP: false, dangerouslyAllowSVG: false, deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840], disableStaticImages: false, @@ -116,6 +117,7 @@ function runTests(url: string, mode: 'dev' | 'server') { search: '', }, ], + maximumRedirects: 3, minimumCacheTTL: 14400, path: '/_next/image', qualities: [75], diff --git a/test/production/next-server-nft/next-server-nft.test.ts b/test/production/next-server-nft/next-server-nft.test.ts index 1c98f5e5f76b16..ea49dc5a597322 100644 --- a/test/production/next-server-nft/next-server-nft.test.ts +++ b/test/production/next-server-nft/next-server-nft.test.ts @@ -138,6 +138,7 @@ const isReact18 = parseInt(process.env.NEXT_TEST_REACT_VERSION) === 18 "/node_modules/next/dist/compiled/image-detector/detector.js", "/node_modules/next/dist/compiled/image-size/index.js", "/node_modules/next/dist/compiled/is-animated/index.js", + "/node_modules/next/dist/compiled/is-local-address/index.js", "/node_modules/next/dist/compiled/jsonwebtoken/index.js", "/node_modules/next/dist/compiled/nanoid/index.cjs", "/node_modules/next/dist/compiled/next-server/app-page-turbo-experimental.runtime.prod.js", From e598a4f9763f3ccdaf23f4caf6aea205b84c5dcd Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Thu, 9 Oct 2025 14:03:01 -0700 Subject: [PATCH 02/11] Add new updateTag API and new signature for revalidateTag (#83822) --- .../src/transforms/react_server_components.rs | 2 - .../09-caching-and-revalidating.mdx | 70 +++++++++- .../04-functions/revalidateTag.mdx | 21 ++- .../04-functions/updateTag.mdx | 127 ++++++++++++++++++ errors/revalidate-tag-single-arg.mdx | 53 ++++++++ packages/next/cache.d.ts | 3 +- packages/next/cache.js | 11 +- packages/next/errors.json | 4 +- .../plugins/next-types-plugin/index.ts | 3 +- .../src/server/app-render/action-handler.ts | 2 +- .../app-render/work-async-storage.external.ts | 5 +- packages/next/src/server/base-server.ts | 26 +--- packages/next/src/server/config-shared.ts | 2 +- .../lib/cache-handlers/default.external.ts | 60 +++++++-- .../src/server/lib/cache-handlers/types.ts | 50 +------ packages/next/src/server/lib/implicit-tags.ts | 2 +- .../incremental-cache/file-system-cache.ts | 41 ++++-- .../src/server/lib/incremental-cache/index.ts | 35 +++-- .../tags-manifest.external.ts | 31 ++++- .../next/src/server/response-cache/types.ts | 4 + .../next/src/server/revalidation-utils.ts | 126 +++++++++++++++-- .../next/src/server/use-cache/handlers.ts | 24 ++-- .../src/server/use-cache/use-cache-wrapper.ts | 4 +- .../server/web/spec-extension/revalidate.ts | 104 ++++++++------ test/deploy-tests-manifest.json | 6 +- .../unstable_expirepath/page.js | 8 -- .../unstable_expiretag/page.js | 8 -- .../acceptance-app/rsc-build-errors.test.ts | 2 - ...component-compiler-errors-in-pages.test.ts | 4 +- .../app/cached/page.tsx | 4 +- .../app/test/page.tsx | 4 +- test/e2e/app-dir/actions/app-action.test.ts | 6 +- .../actions/app/action-discarding/actions.js | 4 +- .../actions/app/delayed-action/actions.ts | 4 +- .../app-dir/actions/app/redirect/actions.ts | 4 +- .../app-dir/actions/app/revalidate-2/page.js | 4 +- .../actions/app/revalidate-multiple/page.js | 6 +- .../app-dir/actions/app/revalidate/page.js | 14 +- test/e2e/app-dir/actions/app/shared/action.js | 4 +- .../e2e/app-dir/app-static/app-static.test.ts | 101 +++++++++++++- .../app/api/revalidate-path-edge/route.ts | 4 +- .../app/api/revalidate-path-node/route.ts | 4 +- .../app/api/revalidate-tag-edge/route.ts | 4 +- .../app/api/revalidate-tag-node/route.ts | 4 +- .../app/api/update-tag-error/route.ts | 23 ++++ .../app-static/app/no-store/static/page.tsx | 4 +- .../unstable-cache/dynamic-undefined/page.tsx | 4 +- .../app/unstable-cache/dynamic/page.tsx | 4 +- .../app-static/app/update-tag-test/actions.ts | 24 ++++ .../app/update-tag-test/buttons.tsx | 30 +++++ .../app-static/app/update-tag-test/page.tsx | 27 ++++ test/e2e/app-dir/app-static/next.config.js | 9 ++ .../app/[locale]/actions.ts | 4 +- .../app-dir/logging/app/default-cache/page.js | 4 +- .../app/popstate-revalidate/foo/action.ts | 4 +- .../app/timestamp/revalidate.js | 4 +- .../app/timestamp/trigger-revalidate/route.js | 2 +- .../next-after-app-deploy/index.test.ts | 2 +- .../next-after-app-deploy/middleware.js | 2 +- .../app/@dialog/revalidate-modal/page.tsx | 4 +- .../app/actions.ts | 4 +- .../nested-revalidate/@modal/modal/action.ts | 4 +- .../ppr-full/app/api/revalidate/route.js | 4 +- .../app/revalidate-tag/route.js | 4 +- .../app-dir/ppr-unstable-cache/next.config.js | 7 + .../app/api/revalidate-path/route.js | 4 +- .../app/api/revalidate-tag/route.js | 4 +- .../revalidate-dynamic/next.config.mjs | 12 ++ .../revalidate-dynamic.test.ts | 2 +- .../app/actions/revalidate.ts | 4 +- .../app/revalidate_via_page/page.tsx | 4 +- .../revalidatetag-rsc.test.ts | 10 +- .../segment-cache/revalidation/app/page.tsx | 4 +- .../app/legacy/page.tsx | 46 ------- .../use-cache-custom-handler/app/page.tsx | 6 +- .../use-cache-custom-handler/handler.js | 10 +- .../legacy-handler.js | 37 ----- .../use-cache-custom-handler/next.config.js | 1 - .../use-cache-custom-handler.test.ts | 74 +--------- .../revalidate-and-redirect/redirect/page.tsx | 4 +- .../app/(dynamic)/revalidate-and-use/page.tsx | 4 +- .../api/revalidate-redirect/route.ts | 2 +- .../(partially-static)/cache-tag/buttons.tsx | 14 +- .../app/(partially-static)/form/page.tsx | 4 +- test/e2e/app-dir/use-cache/next.config.js | 5 + test/e2e/app-dir/use-cache/use-cache.test.ts | 6 +- .../isr/app/app/self-revalidate/action.js | 4 +- .../app/revalidate-tag/route.ts | 2 +- .../global-default-cache-handler.test.ts | 8 +- .../next.config.js | 7 + .../resume-data-cache/app/revalidate/route.ts | 2 +- .../app-dir/resume-data-cache/next.config.js | 7 + .../ssg-single-pass/app/revalidate/route.ts | 4 +- .../production/custom-server/cache-handler.js | 10 +- test/rspack-build-tests-manifest.json | 26 ++-- test/rspack-dev-tests-manifest.json | 30 ++--- test/turbopack-build-tests-manifest.json | 26 ++-- test/turbopack-dev-tests-manifest.json | 30 ++--- 98 files changed, 1032 insertions(+), 564 deletions(-) create mode 100644 docs/01-app/03-api-reference/04-functions/updateTag.mdx create mode 100644 errors/revalidate-tag-single-arg.mdx delete mode 100644 test/development/acceptance-app/fixtures/rsc-build-errors/app/server-with-errors/next-cache-in-client/unstable_expirepath/page.js delete mode 100644 test/development/acceptance-app/fixtures/rsc-build-errors/app/server-with-errors/next-cache-in-client/unstable_expiretag/page.js create mode 100644 test/e2e/app-dir/app-static/app/api/update-tag-error/route.ts create mode 100644 test/e2e/app-dir/app-static/app/update-tag-test/actions.ts create mode 100644 test/e2e/app-dir/app-static/app/update-tag-test/buttons.tsx create mode 100644 test/e2e/app-dir/app-static/app/update-tag-test/page.tsx create mode 100644 test/e2e/app-dir/revalidate-dynamic/next.config.mjs delete mode 100644 test/e2e/app-dir/use-cache-custom-handler/app/legacy/page.tsx delete mode 100644 test/e2e/app-dir/use-cache-custom-handler/legacy-handler.js diff --git a/crates/next-custom-transforms/src/transforms/react_server_components.rs b/crates/next-custom-transforms/src/transforms/react_server_components.rs index 8c03305cfdd348..6f841be1156ff2 100644 --- a/crates/next-custom-transforms/src/transforms/react_server_components.rs +++ b/crates/next-custom-transforms/src/transforms/react_server_components.rs @@ -654,8 +654,6 @@ impl ReactServerComponentValidator { // "unstable_cache", // useless in client, but doesn't technically error "unstable_cacheLife", "unstable_cacheTag", - "unstable_expirePath", - "unstable_expireTag", // "unstable_noStore" // no-op in client, but allowed for legacy reasons ], ), diff --git a/docs/01-app/01-getting-started/09-caching-and-revalidating.mdx b/docs/01-app/01-getting-started/09-caching-and-revalidating.mdx index 3b4f41398abf6a..c4ce6b157443c8 100644 --- a/docs/01-app/01-getting-started/09-caching-and-revalidating.mdx +++ b/docs/01-app/01-getting-started/09-caching-and-revalidating.mdx @@ -9,6 +9,7 @@ related: - app/api-reference/functions/unstable_cache - app/api-reference/functions/revalidatePath - app/api-reference/functions/revalidateTag + - app/api-reference/functions/updateTag --- Caching is a technique for storing the result of data fetching and other computations so that future requests for the same data can be served faster, without doing the work again. While revalidation allows you to update cache entries without having to rebuild your entire application. @@ -19,6 +20,7 @@ Next.js provides a few APIs to handle caching and revalidation. This guide will - [`unstable_cache`](#unstable_cache) - [`revalidatePath`](#revalidatepath) - [`revalidateTag`](#revalidatetag) +- [`updateTag`](#updatetag) ## `fetch` @@ -154,7 +156,12 @@ See the [`unstable_cache` API reference](/docs/app/api-reference/functions/unsta ## `revalidateTag` -`revalidateTag` is used to revalidate cache entries based on a tag and following an event. To use it with `fetch`, start by tagging the function with the `next.tags` option: +`revalidateTag` is used to revalidate cache entries based on a tag and following an event. The function now supports two behaviors: + +- **With `profile="max"`**: Uses stale-while-revalidate semantics, serving stale content while fetching fresh content in the background +- **Without the second argument**: Legacy behavior that immediately expires the cache (deprecated) + +To use it with `fetch`, start by tagging the function with the `next.tags` option: ```tsx filename="app/lib/data.ts" highlight={3-5} switcher export async function getUserById(id: string) { @@ -204,21 +211,21 @@ export const getUserById = unstable_cache( Then, call `revalidateTag` in a [Route Handler](/docs/app/api-reference/file-conventions/route) or Server Action: -```tsx filename="app/lib/actions.ts" highlight={1} switcher +```tsx filename="app/lib/actions.ts" highlight={1,5} switcher import { revalidateTag } from 'next/cache' export async function updateUser(id: string) { // Mutate data - revalidateTag('user') + revalidateTag('user', 'max') // Recommended: Uses stale-while-revalidate } ``` -```jsx filename="app/lib/actions.js" highlight={1} switcher +```jsx filename="app/lib/actions.js" highlight={1,5} switcher import { revalidateTag } from 'next/cache' export async function updateUser(id) { // Mutate data - revalidateTag('user') + revalidateTag('user', 'max') // Recommended: Uses stale-while-revalidate } ``` @@ -247,3 +254,56 @@ export async function updateUser(id) { ``` See the [`revalidatePath` API reference](/docs/app/api-reference/functions/revalidatePath) to learn more. + +## `updateTag` + +`updateTag` is specifically designed for Server Actions to immediately expire cached data for read-your-own-writes scenarios. Unlike `revalidateTag`, it can only be used within Server Actions and immediately expires the cache entry. + +```tsx filename="app/lib/actions.ts" highlight={1,6} switcher +import { updateTag } from 'next/cache' +import { redirect } from 'next/navigation' + +export async function createPost(formData: FormData) { + // Create post in database + const post = await db.post.create({ + data: { + title: formData.get('title'), + content: formData.get('content'), + }, + }) + + // Immediately expire cache so the new post is visible + updateTag('posts') + updateTag(`post-${post.id}`) + + redirect(`/posts/${post.id}`) +} +``` + +```jsx filename="app/lib/actions.js" highlight={1,6} switcher +import { updateTag } from 'next/cache' +import { redirect } from 'next/navigation' + +export async function createPost(formData) { + // Create post in database + const post = await db.post.create({ + data: { + title: formData.get('title'), + content: formData.get('content'), + }, + }) + + // Immediately expire cache so the new post is visible + updateTag('posts') + updateTag(`post-${post.id}`) + + redirect(`/posts/${post.id}`) +} +``` + +The key differences between `revalidateTag` and `updateTag`: + +- **`updateTag`**: Only in Server Actions, immediately expires cache, for read-your-own-writes +- **`revalidateTag`**: In Server Actions and Route Handlers, supports stale-while-revalidate with `profile="max"` + +See the [`updateTag` API reference](/docs/app/api-reference/functions/updateTag) to learn more. diff --git a/docs/01-app/03-api-reference/04-functions/revalidateTag.mdx b/docs/01-app/03-api-reference/04-functions/revalidateTag.mdx index af12f332b48b77..ab2f553df84ce5 100644 --- a/docs/01-app/03-api-reference/04-functions/revalidateTag.mdx +++ b/docs/01-app/03-api-reference/04-functions/revalidateTag.mdx @@ -11,15 +11,24 @@ description: API Reference for the revalidateTag function. `revalidateTag` cannot be called in Client Components or Middleware, as it only works in server environments. -> **Good to know**: `revalidateTag` marks tagged data as stale, but fresh data is only fetched when pages using that tag are next visited. This means calling `revalidateTag` will not immediately trigger many revalidations at once. The invalidation only happens when any page using that tag is next visited. +### Revalidation Behavior + +The revalidation behavior depends on whether you provide the second argument: + +- **With `profile="max"` (recommended)**: The tag entry is marked as stale, and the next time a resource with that tag is visited, it will use stale-while-revalidate semantics. This means the stale content is served while fresh content is fetched in the background. +- **With a custom cache life profile**: For advanced usage, you can specify any cache life profile that your application has defined, allowing for custom revalidation behaviors tailored to your specific caching requirements. +- **Without the second argument (deprecated)**: The tag entry is expired immediately, and the next request to that resource will be a blocking revalidate/cache miss. This behavior is now deprecated, and you should either use `profile="max"` or migrate to `updateTag`. + +> **Good to know**: When using `profile="max"`, `revalidateTag` marks tagged data as stale, but fresh data is only fetched when pages using that tag are next visited. This means calling `revalidateTag` will not immediately trigger many revalidations at once. The invalidation only happens when any page using that tag is next visited. ## Parameters ```tsx -revalidateTag(tag: string): void; +revalidateTag(tag: string, profile?: string): void; ``` - `tag`: A string representing the cache tag associated with the data you want to revalidate. Must not exceed 256 characters. This value is case-sensitive. +- `profile`: A string that specifies the revalidation behavior. The recommended value is `"max"` which provides stale-while-revalidate semantics. For advanced usage, this can be configured to any cache life profile that your application defines. You can add tags to `fetch` as follows: @@ -48,7 +57,7 @@ import { revalidateTag } from 'next/cache' export default async function submit() { await addPost() - revalidateTag('posts') + revalidateTag('posts', 'max') } ``` @@ -59,7 +68,7 @@ import { revalidateTag } from 'next/cache' export default async function submit() { await addPost() - revalidateTag('posts') + revalidateTag('posts', 'max') } ``` @@ -73,7 +82,7 @@ export async function GET(request: NextRequest) { const tag = request.nextUrl.searchParams.get('tag') if (tag) { - revalidateTag(tag) + revalidateTag(tag, 'max') return Response.json({ revalidated: true, now: Date.now() }) } @@ -92,7 +101,7 @@ export async function GET(request) { const tag = request.nextUrl.searchParams.get('tag') if (tag) { - revalidateTag(tag) + revalidateTag(tag, 'max') return Response.json({ revalidated: true, now: Date.now() }) } diff --git a/docs/01-app/03-api-reference/04-functions/updateTag.mdx b/docs/01-app/03-api-reference/04-functions/updateTag.mdx new file mode 100644 index 00000000000000..36ea5963d60283 --- /dev/null +++ b/docs/01-app/03-api-reference/04-functions/updateTag.mdx @@ -0,0 +1,127 @@ +--- +title: updateTag +description: API Reference for the updateTag function. +--- + +`updateTag` allows you to update [cached data](/docs/app/guides/caching) on-demand for a specific cache tag from within Server Actions. This function is designed specifically for read-your-own-writes scenarios. + +## Usage + +`updateTag` can **only** be called from within Server Actions. It cannot be used in Route Handlers, Client Components, or any other context. + +If you need to invalidate cache tags in Route Handlers or other contexts, use [`revalidateTag`](/docs/app/api-reference/functions/revalidateTag) instead. + +> **Good to know**: `updateTag` immediately expires the cached data for the specified tag, causing the next request to that resource to be a blocking revalidate/cache miss. This ensures that Server Actions can immediately see their own writes. + +## Parameters + +```tsx +updateTag(tag: string): void; +``` + +- `tag`: A string representing the cache tag associated with the data you want to update. Must not exceed 256 characters. This value is case-sensitive. + +## Returns + +`updateTag` does not return a value. + +## Differences from revalidateTag + +While both `updateTag` and `revalidateTag` invalidate cached data, they serve different purposes: + +- **`updateTag`**: + - Can only be used in Server Actions + - Immediately expires the cache entry (blocking revalidate on next visit) + - Designed for read-your-own-writes scenarios + +- **`revalidateTag`**: + - Can be used in Server Actions and Route Handlers + - With `profile="max"` (recommended): Uses stale-while-revalidate semantics + - With custom profile: Can be configured to any cache life profile for advanced usage + - Without profile: legacy behavior which is equivalent to `updateTag` + +## Examples + +### Server Action with Read-Your-Own-Writes + +```ts filename="app/actions.ts" switcher +'use server' + +import { updateTag } from 'next/cache' +import { redirect } from 'next/navigation' + +export async function createPost(formData: FormData) { + const title = formData.get('title') + const content = formData.get('content') + + // Create the post in your database + const post = await db.post.create({ + data: { title, content }, + }) + + // Update the cache so the new post is immediately visible + updateTag('posts') + updateTag(`post-${post.id}`) + + // Redirect to the new post + redirect(`/posts/${post.id}`) +} +``` + +```js filename="app/actions.js" switcher +'use server' + +import { updateTag } from 'next/cache' +import { redirect } from 'next/navigation' + +export async function createPost(formData) { + const title = formData.get('title') + const content = formData.get('content') + + // Create the post in your database + const post = await db.post.create({ + data: { title, content }, + }) + + // Update the cache so the new post is immediately visible + updateTag('posts') + updateTag(`post-${post.id}`) + + // Redirect to the new post + redirect(`/posts/${post.id}`) +} +``` + +### Error when used outside Server Actions + +```ts filename="app/api/posts/route.ts" switcher +import { updateTag } from 'next/cache' + +export async function POST() { + // ❌ This will throw an error + updateTag('posts') + // Error: updateTag can only be called from within a Server Action + + // ✅ Use revalidateTag instead in Route Handlers + revalidateTag('posts', 'max') +} +``` + +## When to use updateTag + +Use `updateTag` when: + +- You're in a Server Action +- You need immediate cache invalidation for read-your-own-writes +- You want to ensure the next request sees updated data + +Use `revalidateTag` instead when: + +- You're in a Route Handler or other non-action context +- You want stale-while-revalidate semantics +- You're building a webhook or API endpoint for cache invalidation + +## Related + +- [`revalidateTag`](/docs/app/api-reference/functions/revalidateTag) - For invalidating tags in Route Handlers +- [`revalidatePath`](/docs/app/api-reference/functions/revalidatePath) - For invalidating specific paths diff --git a/errors/revalidate-tag-single-arg.mdx b/errors/revalidate-tag-single-arg.mdx new file mode 100644 index 00000000000000..0241140ac80458 --- /dev/null +++ b/errors/revalidate-tag-single-arg.mdx @@ -0,0 +1,53 @@ +--- +title: revalidateTag Single Argument Deprecated +--- + +## Why This Error Occurred + +You are using `revalidateTag` without providing the second argument, which is now deprecated. The function now requires a second argument to specify the revalidation behavior. + +## Possible Ways to Fix It + +### Option 1: Add the second argument + +Update your `revalidateTag` calls to include the second argument `"max"`: + +```js +// Before (deprecated) +revalidateTag('posts') + +// After +revalidateTag('posts', 'max') +``` + +### Option 2: Use updateTag in Server Actions + +If you're calling this from a Server Action and need immediate expiration for read-your-own-writes, use `updateTag` instead: + +```js +// In a Server Action +import { updateTag } from 'next/cache' + +export async function createPost() { + // ... create post ... + + // Immediately expire the cache + updateTag('posts') +} +``` + +## Revalidation Behavior + +- **`revalidateTag(tag, "max")` (recommended)**: The tag entry is marked as stale, and the next time a resource with that tag is visited, it will use stale-while-revalidate semantics. This means the stale content is served while fresh content is fetched in the background. + +- **`revalidateTag(tag, profile)`**: For advanced usage, you can specify any cache life profile that your application has defined in your `next.config` instead of `"max"`, allowing for custom revalidation behaviors. + +- **`updateTag(tag)`**: Only available in Server Actions. The tag entry is expired immediately, and the next request to that resource will be a blocking revalidate/cache miss. This ensures read-your-own-writes consistency. + +- **`revalidateTag(tag)` without second argument (deprecated)**: Same behavior as `updateTag` but shows this deprecation warning. + +## Useful Links + +- [revalidateTag Documentation](/docs/app/api-reference/functions/revalidateTag) +- [updateTag Documentation](/docs/app/api-reference/functions/updateTag) +- [Caching in Next.js](/docs/app/getting-started/caching-and-revalidating) diff --git a/packages/next/cache.d.ts b/packages/next/cache.d.ts index ca839bec10ada2..3e94fb3d997de7 100644 --- a/packages/next/cache.d.ts +++ b/packages/next/cache.d.ts @@ -3,8 +3,7 @@ export { unstable_cache } from 'next/dist/server/web/spec-extension/unstable-cac export { revalidatePath, revalidateTag, - unstable_expirePath, - unstable_expireTag, + updateTag, refresh, } from 'next/dist/server/web/spec-extension/revalidate' diff --git a/packages/next/cache.js b/packages/next/cache.js index 8e7a1e60277eda..961eb0a4db77cf 100644 --- a/packages/next/cache.js +++ b/packages/next/cache.js @@ -2,16 +2,14 @@ const cacheExports = { unstable_cache: require('next/dist/server/web/spec-extension/unstable-cache') .unstable_cache, + updateTag: require('next/dist/server/web/spec-extension/revalidate') + .updateTag, + revalidateTag: require('next/dist/server/web/spec-extension/revalidate') .revalidateTag, revalidatePath: require('next/dist/server/web/spec-extension/revalidate') .revalidatePath, - unstable_expireTag: require('next/dist/server/web/spec-extension/revalidate') - .unstable_expireTag, - unstable_expirePath: require('next/dist/server/web/spec-extension/revalidate') - .unstable_expirePath, - refresh: require('next/dist/server/web/spec-extension/revalidate').refresh, unstable_noStore: @@ -30,8 +28,7 @@ module.exports = cacheExports exports.unstable_cache = cacheExports.unstable_cache exports.revalidatePath = cacheExports.revalidatePath exports.revalidateTag = cacheExports.revalidateTag -exports.unstable_expireTag = cacheExports.unstable_expireTag -exports.unstable_expirePath = cacheExports.unstable_expirePath +exports.updateTag = cacheExports.updateTag exports.unstable_noStore = cacheExports.unstable_noStore exports.unstable_cacheLife = cacheExports.unstable_cacheLife exports.unstable_cacheTag = cacheExports.unstable_cacheTag diff --git a/packages/next/errors.json b/packages/next/errors.json index a24f4959aa1943..77ff24134fa56f 100644 --- a/packages/next/errors.json +++ b/packages/next/errors.json @@ -869,5 +869,7 @@ "868": "No reference found for param: %s in reference: %s", "869": "No reference found for segment: %s with reference: %s", "870": "refresh can only be called from within a Server Action. See more info here: https://nextjs.org/docs/app/api-reference/functions/refresh", - "871": "Image with src \"%s\" is using a query string which is not configured in images.localPatterns.\\nRead more: https://nextjs.org/docs/messages/next-image-unconfigured-localpatterns" + "871": "Image with src \"%s\" is using a query string which is not configured in images.localPatterns.\\nRead more: https://nextjs.org/docs/messages/next-image-unconfigured-localpatterns", + "872": "updateTag can only be called from within a Server Action. To invalidate cache tags in Route Handlers or other contexts, use revalidateTag instead. See more info here: https://nextjs.org/docs/app/api-reference/functions/updateTag", + "873": "Invalid profile provided \"%s\" must be configured under cacheLife in next.config or be \"max\"" } diff --git a/packages/next/src/build/webpack/plugins/next-types-plugin/index.ts b/packages/next/src/build/webpack/plugins/next-types-plugin/index.ts index 394e7b8ebe1788..75ec9bb807839d 100644 --- a/packages/next/src/build/webpack/plugins/next-types-plugin/index.ts +++ b/packages/next/src/build/webpack/plugins/next-types-plugin/index.ts @@ -530,10 +530,9 @@ function createCustomCacheLifeDefinitions(cacheLife: { declare module 'next/cache' { export { unstable_cache } from 'next/dist/server/web/spec-extension/unstable-cache' export { + updateTag, revalidateTag, revalidatePath, - unstable_expireTag, - unstable_expirePath, refresh, } from 'next/dist/server/web/spec-extension/revalidate' export { unstable_noStore } from 'next/dist/server/web/spec-extension/unstable-no-store' diff --git a/packages/next/src/server/app-render/action-handler.ts b/packages/next/src/server/app-render/action-handler.ts index ad1a80d6178a20..3bf2fb445f9a1a 100644 --- a/packages/next/src/server/app-render/action-handler.ts +++ b/packages/next/src/server/app-render/action-handler.ts @@ -346,7 +346,7 @@ async function createRedirectRenderResult( if (workStore.pendingRevalidatedTags) { forwardedHeaders.set( NEXT_CACHE_REVALIDATED_TAGS_HEADER, - workStore.pendingRevalidatedTags.join(',') + workStore.pendingRevalidatedTags.map((item) => item.tag).join(',') ) forwardedHeaders.set( NEXT_CACHE_REVALIDATE_TAG_TOKEN_HEADER, diff --git a/packages/next/src/server/app-render/work-async-storage.external.ts b/packages/next/src/server/app-render/work-async-storage.external.ts index 27e310a1d4c2e2..8c03c769a0803a 100644 --- a/packages/next/src/server/app-render/work-async-storage.external.ts +++ b/packages/next/src/server/app-render/work-async-storage.external.ts @@ -66,7 +66,10 @@ export interface WorkStore { * Tags that were revalidated during the current request. They need to be sent * to cache handlers to propagate their revalidation. */ - pendingRevalidatedTags?: string[] + pendingRevalidatedTags?: Array<{ + tag: string + profile?: string | { stale?: number; revalidate?: number; expire?: number } + }> /** * Tags that were previously revalidated (e.g. by a redirecting server action) diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts index 27770b9d3f944f..cbea9734c035fe 100644 --- a/packages/next/src/server/base-server.ts +++ b/packages/next/src/server/base-server.ts @@ -64,7 +64,7 @@ import RenderResult from './render-result' import { removeTrailingSlash } from '../shared/lib/router/utils/remove-trailing-slash' import { denormalizePagePath } from '../shared/lib/page-path/denormalize-page-path' import * as Log from '../build/output/log' -import { getPreviouslyRevalidatedTags, getServerUtils } from './server-utils' +import { getServerUtils } from './server-utils' import isError, { getProperError } from '../lib/is-error' import { addRequestMeta, @@ -141,7 +141,6 @@ import { SegmentPrefixRSCPathnameNormalizer } from './normalizers/request/segmen import { shouldServeStreamingMetadata } from './lib/streaming-metadata' import { decodeQueryPathParameter } from './lib/decode-query-path-parameter' import { NoFallbackError } from '../shared/lib/no-fallback-error.external' -import { getCacheHandlers } from './use-cache/handlers' import { fixMojibake } from './lib/fix-mojibake' import { computeCacheBustingSearchParam } from '../shared/lib/router/utils/cache-busting-search-param' import { setCacheBustingSearchParamWithHash } from '../client/components/router-reducer/set-cache-busting-search-param' @@ -1440,29 +1439,6 @@ export default abstract class Server< ;(globalThis as any).__incrementalCache = incrementalCache } - const cacheHandlers = getCacheHandlers() - - if (cacheHandlers) { - await Promise.all( - [...cacheHandlers].map(async (cacheHandler) => { - if ('refreshTags' in cacheHandler) { - // Note: cacheHandler.refreshTags() is called lazily before the - // first cache entry is retrieved. It allows us to skip the - // refresh request if no caches are read at all. - } else { - const previouslyRevalidatedTags = getPreviouslyRevalidatedTags( - req.headers, - this.getPrerenderManifest().preview.previewModeId - ) - - await cacheHandler.receiveExpiredTags( - ...previouslyRevalidatedTags - ) - } - }) - ) - } - // set server components HMR cache to request meta so it can be passed // down for edge functions if (!getRequestMeta(req, 'serverComponentsHmrCache')) { diff --git a/packages/next/src/server/config-shared.ts b/packages/next/src/server/config-shared.ts index be8699e10486fd..8bb38a8c1d7ec4 100644 --- a/packages/next/src/server/config-shared.ts +++ b/packages/next/src/server/config-shared.ts @@ -1406,7 +1406,7 @@ export const defaultConfig = Object.freeze({ max: { stale: 60 * 5, // 5 minutes revalidate: 60 * 60 * 24 * 30, // 1 month - expire: INFINITE_CACHE, // Unbounded. + expire: 60 * 60 * 24 * 365, // 1 year }, }, cacheHandlers: { diff --git a/packages/next/src/server/lib/cache-handlers/default.external.ts b/packages/next/src/server/lib/cache-handlers/default.external.ts index 5e6044207071e0..c81b7c6cad48e0 100644 --- a/packages/next/src/server/lib/cache-handlers/default.external.ts +++ b/packages/next/src/server/lib/cache-handlers/default.external.ts @@ -8,10 +8,12 @@ */ import { LRUCache } from '../lru-cache' -import type { CacheEntry, CacheHandlerV2 } from './types' +import type { CacheEntry, CacheHandler } from './types' import { - isStale, + areTagsExpired, + areTagsStale, tagsManifest, + type TagManifestEntry, } from '../incremental-cache/tags-manifest.external' type PrivateCacheEntry = { @@ -45,7 +47,7 @@ const debug = process.env.NEXT_PRIVATE_DEBUG_CACHE ? console.debug.bind(console, 'DefaultCacheHandler:') : undefined -const DefaultCacheHandler: CacheHandlerV2 = { +const DefaultCacheHandler: CacheHandler = { async get(cacheKey) { const pendingPromise = pendingSets.get(cacheKey) @@ -74,23 +76,31 @@ const DefaultCacheHandler: CacheHandlerV2 = { return undefined } - if (isStale(entry.tags, entry.timestamp)) { - debug?.('get', cacheKey, 'had stale tag') + let revalidate = entry.revalidate + if (areTagsExpired(entry.tags, entry.timestamp)) { + debug?.('get', cacheKey, 'had expired tag') return undefined } + + if (areTagsStale(entry.tags, entry.timestamp)) { + debug?.('get', cacheKey, 'had stale tag') + revalidate = -1 + } + const [returnStream, newSaved] = entry.value.tee() entry.value = newSaved debug?.('get', cacheKey, 'found', { tags: entry.tags, timestamp: entry.timestamp, - revalidate: entry.revalidate, expire: entry.expire, + revalidate, }) return { ...entry, + revalidate, value: returnStream, } }, @@ -138,23 +148,45 @@ const DefaultCacheHandler: CacheHandlerV2 = { // Nothing to do for an in-memory cache handler. }, - async getExpiration(...tags) { - const expiration = Math.max( - ...tags.map((tag) => tagsManifest.get(tag) ?? 0) - ) + async getExpiration(tags) { + const expirations = tags.map((tag) => { + const entry = tagsManifest.get(tag) + if (!entry) return 0 + // Return the most recent timestamp (either expired or stale) + return entry.expired || 0 + }) + + const expiration = Math.max(...expirations, 0) debug?.('getExpiration', { tags, expiration }) return expiration }, - async expireTags(...tags) { - const timestamp = Math.round(performance.timeOrigin + performance.now()) - debug?.('expireTags', { tags, timestamp }) + async updateTags(tags, durations) { + const now = Math.round(performance.timeOrigin + performance.now()) + debug?.('updateTags', { tags, timestamp: now }) for (const tag of tags) { // TODO: update file-system-cache? - tagsManifest.set(tag, timestamp) + const existingEntry = tagsManifest.get(tag) || {} + + if (durations) { + // Use provided durations directly + const updates: TagManifestEntry = { ...existingEntry } + + // mark as stale immediately + updates.stale = now + + if (durations.expire !== undefined) { + updates.expired = now + durations.expire * 1000 // Convert seconds to ms + } + + tagsManifest.set(tag, updates) + } else { + // Update expired field for immediate expiration (default behavior when no durations provided) + tagsManifest.set(tag, { ...existingEntry, expired: now }) + } } }, } diff --git a/packages/next/src/server/lib/cache-handlers/types.ts b/packages/next/src/server/lib/cache-handlers/types.ts index 8cbb91257224db..eee58946be7c6e 100644 --- a/packages/next/src/server/lib/cache-handlers/types.ts +++ b/packages/next/src/server/lib/cache-handlers/types.ts @@ -39,43 +39,7 @@ export interface CacheEntry { revalidate: number } -/** - * @deprecated Use {@link CacheHandlerV2} instead. - */ export interface CacheHandler { - /** - * Retrieve a cache entry for the given cache key, if available. The softTags - * should be used to check for staleness. - */ - get(cacheKey: string, softTags: string[]): Promise - - /** - * Store a cache entry for the given cache key. When this is called, the entry - * may still be pending, i.e. its value stream may still be written to. So it - * needs to be awaited first. If a `get` for the same cache key is called - * before the pending entry is complete, the cache handler must wait for the - * `set` operation to finish, before returning the entry, instead of returning - * undefined. - */ - set(cacheKey: string, entry: Promise): Promise - - /** - * Next.js will call this method when `revalidateTag` or `revalidatePath()` is - * called. It should update the tags manifest accordingly. - */ - expireTags(...tags: string[]): Promise - - /** - * The `receiveExpiredTags` method is called when an action request sends the - * 'x-next-revalidated-tags' header to indicate which tags have been expired - * by the action. The local tags manifest should be updated accordingly. As - * opposed to `expireTags`, the tags don't need to be propagated to a tags - * service, as this was already done by the server action. - */ - receiveExpiredTags(...tags: string[]): Promise -} - -export interface CacheHandlerV2 { /** * Retrieve a cache entry for the given cache key, if available. Will return * undefined if there's no valid entry, or if the given soft tags are stale. @@ -106,21 +70,11 @@ export interface CacheHandlerV2 { * Returns `Infinity` if the soft tags are supposed to be passed into the * `get` method instead to be checked for expiration. */ - getExpiration(...tags: string[]): Promise + getExpiration(tags: string[]): Promise /** * This function is called when tags are revalidated/expired. If applicable, * it should update the tags manifest accordingly. */ - expireTags(...tags: string[]): Promise + updateTags(tags: string[], durations?: { expire?: number }): Promise } - -/** - * This is a compatibility type to ease migration between cache handler - * versions. Until the old `CacheHandler` type is removed, this type should be - * used for all internal Next.js functions that deal with cache handlers to - * ensure that we are compatible with both cache handler versions. An exception - * is the built-in default cache handler, which implements the - * {@link CacheHandlerV2} interface. - */ -export type CacheHandlerCompat = CacheHandler | CacheHandlerV2 diff --git a/packages/next/src/server/lib/implicit-tags.ts b/packages/next/src/server/lib/implicit-tags.ts index a8ec951f1576cb..becffcea007b7f 100644 --- a/packages/next/src/server/lib/implicit-tags.ts +++ b/packages/next/src/server/lib/implicit-tags.ts @@ -62,7 +62,7 @@ function createTagsExpirationsByCacheKind( if ('getExpiration' in cacheHandler) { expirationsByCacheKind.set( kind, - createLazyResult(async () => cacheHandler.getExpiration(...tags)) + createLazyResult(async () => cacheHandler.getExpiration(tags)) ) } } diff --git a/packages/next/src/server/lib/incremental-cache/file-system-cache.ts b/packages/next/src/server/lib/incremental-cache/file-system-cache.ts index 4f944feb861c34..7582532d346f5e 100644 --- a/packages/next/src/server/lib/incremental-cache/file-system-cache.ts +++ b/packages/next/src/server/lib/incremental-cache/file-system-cache.ts @@ -1,6 +1,7 @@ import type { RouteMetadata } from '../../../export/routes/types' import type { CacheHandler, CacheHandlerContext, CacheHandlerValue } from '.' import type { CacheFs } from '../../../shared/lib/utils' +import type { TagManifestEntry } from './tags-manifest.external' import { CachedRouteKind, IncrementalCacheKind, @@ -21,7 +22,7 @@ import { RSC_SEGMENTS_DIR_SUFFIX, RSC_SUFFIX, } from '../../../lib/constants' -import { isStale, tagsManifest } from './tags-manifest.external' +import { areTagsExpired, tagsManifest } from './tags-manifest.external' import { MultiFileWriter } from '../../../lib/multi-file-writer' import { getMemoryCache } from './memory-cache.external' @@ -65,22 +66,39 @@ export default class FileSystemCache implements CacheHandler { public resetRequestCache(): void {} public async revalidateTag( - ...args: Parameters + tags: string | string[], + durations?: { expire?: number } ) { - let [tags] = args tags = typeof tags === 'string' ? [tags] : tags if (FileSystemCache.debug) { - console.log('FileSystemCache: revalidateTag', tags) + console.log('FileSystemCache: revalidateTag', tags, durations) } if (tags.length === 0) { return } + const now = Date.now() + for (const tag of tags) { - if (!tagsManifest.has(tag)) { - tagsManifest.set(tag, Date.now()) + const existingEntry = tagsManifest.get(tag) || {} + + if (durations) { + // Use provided durations directly + const updates: TagManifestEntry = { ...existingEntry } + + // mark as stale immediately + updates.stale = now + + if (durations.expire !== undefined) { + updates.expired = now + durations.expire * 1000 // Convert seconds to ms + } + + tagsManifest.set(tag, updates) + } else { + // Update expired field for immediate expiration (default behavior when no durations provided) + tagsManifest.set(tag, { ...existingEntry, expired: now }) } } } @@ -297,9 +315,12 @@ export default class FileSystemCache implements CacheHandler { // we trigger a blocking validation if an ISR page // had a tag revalidated, if we want to be a background // revalidation instead we return data.lastModified = -1 - if (cacheTags.length > 0 && isStale(cacheTags, data.lastModified)) { + if ( + cacheTags.length > 0 && + areTagsExpired(cacheTags, data.lastModified) + ) { if (FileSystemCache.debug) { - console.log('FileSystemCache: stale tags', cacheTags) + console.log('FileSystemCache: expired tags', cacheTags) } return null @@ -321,9 +342,9 @@ export default class FileSystemCache implements CacheHandler { return null } - if (isStale(combinedTags, data.lastModified)) { + if (areTagsExpired(combinedTags, data.lastModified)) { if (FileSystemCache.debug) { - console.log('FileSystemCache: stale tags', combinedTags) + console.log('FileSystemCache: expired tags', combinedTags) } return null diff --git a/packages/next/src/server/lib/incremental-cache/index.ts b/packages/next/src/server/lib/incremental-cache/index.ts index 06172d493ff623..8840a2d4bf3798 100644 --- a/packages/next/src/server/lib/incremental-cache/index.ts +++ b/packages/next/src/server/lib/incremental-cache/index.ts @@ -35,7 +35,7 @@ import type { Revalidate } from '../cache-control' import { getPreviouslyRevalidatedTags } from '../../server-utils' import { workAsyncStorage } from '../../app-render/work-async-storage.external' import { DetachedPromise } from '../../../lib/detached-promise' -import { isStale as isTagsStale } from './tags-manifest.external' +import { areTagsExpired, areTagsStale } from './tags-manifest.external' export interface CacheHandlerContext { fs?: CacheFs @@ -74,7 +74,8 @@ export class CacheHandler { ): Promise {} public async revalidateTag( - ..._args: Parameters + _tags: string | string[], + _durations?: { expire?: number } ): Promise {} public resetRequestCache(): void {} @@ -278,8 +279,11 @@ export class IncrementalCache implements IncrementalCacheType { } } - async revalidateTag(tags: string | string[]): Promise { - return this.cacheHandler?.revalidateTag(tags) + async revalidateTag( + tags: string | string[], + durations?: { expire?: number } + ): Promise { + return this.cacheHandler?.revalidateTag(tags, durations) } // x-ref: https://github.com/facebook/react/blob/2655c9354d8e1c54ba888444220f63e836925caa/packages/react/src/ReactFetch.js#L23 @@ -485,11 +489,11 @@ export class IncrementalCache implements IncrementalCacheType { combinedTags.some( (tag) => this.revalidatedTags?.includes(tag) || - workStore?.pendingRevalidatedTags?.includes(tag) + workStore?.pendingRevalidatedTags?.some((item) => item.tag === tag) ) ) { if (IncrementalCache.debug) { - console.log('IncrementalCache: stale tag', cacheKey) + console.log('IncrementalCache: expired tag', cacheKey) } return null @@ -523,9 +527,15 @@ export class IncrementalCache implements IncrementalCacheType { (cacheData.lastModified || 0)) / 1000 - const isStale = age > revalidate + let isStale = age > revalidate const data = cacheData.value.data + if (areTagsExpired(combinedTags, cacheData.lastModified)) { + return null + } else if (areTagsStale(combinedTags, cacheData.lastModified)) { + isStale = true + } + return { isStale, value: { kind: CachedRouteKind.FETCH, data, revalidate }, @@ -560,7 +570,7 @@ export class IncrementalCache implements IncrementalCacheType { revalidateAfter !== false && revalidateAfter < now ? true : undefined // If the stale time couldn't be determined based on the revalidation - // time, we check if the tags are stale. + // time, we check if the tags are expired or stale. if ( isStale === undefined && (cacheData?.value?.kind === CachedRouteKind.APP_PAGE || @@ -570,8 +580,13 @@ export class IncrementalCache implements IncrementalCacheType { if (typeof tagsHeader === 'string') { const cacheTags = tagsHeader.split(',') - if (cacheTags.length > 0 && isTagsStale(cacheTags, lastModified)) { - isStale = -1 + + if (cacheTags.length > 0) { + if (areTagsExpired(cacheTags, lastModified)) { + isStale = -1 + } else if (areTagsStale(cacheTags, lastModified)) { + isStale = true + } } } } diff --git a/packages/next/src/server/lib/incremental-cache/tags-manifest.external.ts b/packages/next/src/server/lib/incremental-cache/tags-manifest.external.ts index adb92884c8fb28..664f6e1c6b1867 100644 --- a/packages/next/src/server/lib/incremental-cache/tags-manifest.external.ts +++ b/packages/next/src/server/lib/incremental-cache/tags-manifest.external.ts @@ -1,15 +1,36 @@ import type { Timestamp } from '../cache-handlers/types' +export interface TagManifestEntry { + stale?: number + expired?: number +} + // We share the tags manifest between the "use cache" handlers and the previous // file-system cache. -export const tagsManifest = new Map() +export const tagsManifest = new Map() + +export const areTagsExpired = (tags: string[], timestamp: Timestamp) => { + for (const tag of tags) { + const entry = tagsManifest.get(tag) + + if (entry) { + if (entry.expired && entry.expired >= timestamp) { + return true + } + } + } + + return false +} -export const isStale = (tags: string[], timestamp: Timestamp) => { +export const areTagsStale = (tags: string[], timestamp: Timestamp) => { for (const tag of tags) { - const revalidatedAt = tagsManifest.get(tag) + const entry = tagsManifest.get(tag) - if (typeof revalidatedAt === 'number' && revalidatedAt >= timestamp) { - return true + if (entry) { + if (entry.stale && entry.stale >= timestamp) { + return true + } } } diff --git a/packages/next/src/server/response-cache/types.ts b/packages/next/src/server/response-cache/types.ts index 3a64e7d98e5bb7..2db060fb53d902 100644 --- a/packages/next/src/server/response-cache/types.ts +++ b/packages/next/src/server/response-cache/types.ts @@ -289,4 +289,8 @@ export interface IncrementalCache extends IncrementalResponseCache { data: Exclude | null, ctx: SetIncrementalResponseCacheContext ): Promise + revalidateTag( + tags: string | string[], + durations?: { expire?: number } + ): Promise } diff --git a/packages/next/src/server/revalidation-utils.ts b/packages/next/src/server/revalidation-utils.ts index acd305ce17b816..cedacbe5c94a74 100644 --- a/packages/next/src/server/revalidation-utils.ts +++ b/packages/next/src/server/revalidation-utils.ts @@ -48,12 +48,24 @@ function diffRevalidationState( prev: RevalidationState, curr: RevalidationState ): RevalidationState { - const prevTags = new Set(prev.pendingRevalidatedTags) + const prevTagsWithProfile = new Set( + prev.pendingRevalidatedTags.map((item) => { + const profileKey = + typeof item.profile === 'object' + ? JSON.stringify(item.profile) + : item.profile || '' + return `${item.tag}:${profileKey}` + }) + ) const prevRevalidateWrites = new Set(prev.pendingRevalidateWrites) return { - pendingRevalidatedTags: curr.pendingRevalidatedTags.filter( - (tag) => !prevTags.has(tag) - ), + pendingRevalidatedTags: curr.pendingRevalidatedTags.filter((item) => { + const profileKey = + typeof item.profile === 'object' + ? JSON.stringify(item.profile) + : item.profile || '' + return !prevTagsWithProfile.has(`${item.tag}:${profileKey}`) + }), pendingRevalidates: Object.fromEntries( Object.entries(curr.pendingRevalidates).filter( ([key]) => !(key in prev.pendingRevalidates) @@ -66,23 +78,105 @@ function diffRevalidationState( } async function revalidateTags( - tags: string[], - incrementalCache: IncrementalCache | undefined + tagsWithProfile: Array<{ + tag: string + profile?: string | { expire?: number } + }>, + incrementalCache: IncrementalCache | undefined, + workStore?: WorkStore ): Promise { - if (tags.length === 0) { + if (tagsWithProfile.length === 0) { return } + const handlers = getCacheHandlers() const promises: Promise[] = [] - if (incrementalCache) { - promises.push(incrementalCache.revalidateTag(tags)) + // Group tags by profile for batch processing + const tagsByProfile = new Map< + | string + | { stale?: number; revalidate?: number; expire?: number } + | undefined, + string[] + >() + + for (const item of tagsWithProfile) { + const profile = item.profile + // Find existing profile by comparing values + let existingKey = undefined + for (const [key] of tagsByProfile) { + if ( + typeof key === 'string' && + typeof profile === 'string' && + key === profile + ) { + existingKey = key + break + } + if ( + typeof key === 'object' && + typeof profile === 'object' && + JSON.stringify(key) === JSON.stringify(profile) + ) { + existingKey = key + break + } + if (key === profile) { + existingKey = key + break + } + } + + const profileKey = existingKey || profile + if (!tagsByProfile.has(profileKey)) { + tagsByProfile.set(profileKey, []) + } + tagsByProfile.get(profileKey)!.push(item.tag) } - const handlers = getCacheHandlers() - if (handlers) { - for (const handler of handlers) { - promises.push(handler.expireTags(...tags)) + // Process each profile group + for (const [profile, tagsForProfile] of tagsByProfile) { + // Look up the cache profile from workStore if available + let durations: { expire?: number } | undefined + + if (profile) { + let cacheLife: + | { stale?: number; revalidate?: number; expire?: number } + | undefined + + if (typeof profile === 'object') { + // Profile is already a cacheLife configuration object + cacheLife = profile + } else if (typeof profile === 'string') { + // Profile is a string key, look it up in workStore + cacheLife = workStore?.cacheLifeProfiles?.[profile] + + if (!cacheLife) { + throw new Error( + `Invalid profile provided "${profile}" must be configured under cacheLife in next.config or be "max"` + ) + } + } + + if (cacheLife) { + durations = { + expire: cacheLife.expire, + } + } + } + // If profile is not found and not 'max', durations will be undefined + // which will trigger immediate expiration in the cache handler + + for (const handler of handlers || []) { + if (profile) { + promises.push(handler.updateTags?.(tagsForProfile, durations)) + } else { + promises.push(handler.updateTags?.(tagsForProfile)) + } + } + + if (incrementalCache) { + promises.push(incrementalCache.revalidateTag(tagsForProfile, durations)) } } @@ -103,7 +197,11 @@ export async function executeRevalidates( state?.pendingRevalidateWrites ?? workStore.pendingRevalidateWrites ?? [] return Promise.all([ - revalidateTags(pendingRevalidatedTags, workStore.incrementalCache), + revalidateTags( + pendingRevalidatedTags, + workStore.incrementalCache, + workStore + ), ...Object.values(pendingRevalidates), ...pendingRevalidateWrites, ]) diff --git a/packages/next/src/server/use-cache/handlers.ts b/packages/next/src/server/use-cache/handlers.ts index de98066909d425..0e9df849f6b161 100644 --- a/packages/next/src/server/use-cache/handlers.ts +++ b/packages/next/src/server/use-cache/handlers.ts @@ -1,5 +1,5 @@ import DefaultCacheHandler from '../lib/cache-handlers/default.external' -import type { CacheHandlerCompat } from '../lib/cache-handlers/types' +import type { CacheHandler } from '../lib/cache-handlers/types' const debug = process.env.NEXT_PRIVATE_DEBUG_CACHE ? (message: string, ...args: any[]) => { @@ -18,11 +18,11 @@ const handlersSetSymbol = Symbol.for('@next/cache-handlers-set') */ const reference: typeof globalThis & { [handlersSymbol]?: { - RemoteCache?: CacheHandlerCompat - DefaultCache?: CacheHandlerCompat + RemoteCache?: CacheHandler + DefaultCache?: CacheHandler } - [handlersMapSymbol]?: Map - [handlersSetSymbol]?: Set + [handlersMapSymbol]?: Map + [handlersSetSymbol]?: Set } = globalThis /** @@ -37,11 +37,11 @@ export function initializeCacheHandlers(): boolean { } debug?.('initializing cache handlers') - reference[handlersMapSymbol] = new Map() + reference[handlersMapSymbol] = new Map() // Initialize the cache from the symbol contents first. if (reference[handlersSymbol]) { - let fallback: CacheHandlerCompat + let fallback: CacheHandler if (reference[handlersSymbol].DefaultCache) { debug?.('setting "default" cache handler from symbol') fallback = reference[handlersSymbol].DefaultCache @@ -81,7 +81,7 @@ export function initializeCacheHandlers(): boolean { * @returns The cache handler, or `undefined` if it does not exist. * @throws If the cache handlers are not initialized. */ -export function getCacheHandler(kind: string): CacheHandlerCompat | undefined { +export function getCacheHandler(kind: string): CacheHandler | undefined { // This should never be called before initializeCacheHandlers. if (!reference[handlersMapSymbol]) { throw new Error('Cache handlers not initialized') @@ -95,9 +95,7 @@ export function getCacheHandler(kind: string): CacheHandlerCompat | undefined { * @returns An iterator over the cache handlers, or `undefined` if they are not * initialized. */ -export function getCacheHandlers(): - | SetIterator - | undefined { +export function getCacheHandlers(): SetIterator | undefined { if (!reference[handlersSetSymbol]) { return undefined } @@ -112,7 +110,7 @@ export function getCacheHandlers(): * @throws If the cache handlers are not initialized. */ export function getCacheHandlerEntries(): - | MapIterator<[string, CacheHandlerCompat]> + | MapIterator<[string, CacheHandler]> | undefined { if (!reference[handlersMapSymbol]) { return undefined @@ -128,7 +126,7 @@ export function getCacheHandlerEntries(): */ export function setCacheHandler( kind: string, - cacheHandler: CacheHandlerCompat + cacheHandler: CacheHandler ): void { // This should never be called before initializeCacheHandlers. if (!reference[handlersMapSymbol] || !reference[handlersSetSymbol]) { diff --git a/packages/next/src/server/use-cache/use-cache-wrapper.ts b/packages/next/src/server/use-cache/use-cache-wrapper.ts index ff79577ea9ea62..b287fd70d1346b 100644 --- a/packages/next/src/server/use-cache/use-cache-wrapper.ts +++ b/packages/next/src/server/use-cache/use-cache-wrapper.ts @@ -1394,7 +1394,7 @@ export function cache( implicitTagsExpiration ) ) { - debug?.('discarding stale entry', serializedCacheKey) + debug?.('discarding expired entry', serializedCacheKey) entry = undefined } } @@ -1755,7 +1755,7 @@ function isRecentlyRevalidatedTag(tag: string, workStore: WorkStore): boolean { // In this case the revalidation might not have been fully propagated by a // remote cache handler yet, so we read it from the pending tags in the work // store. - if (pendingRevalidatedTags?.includes(tag)) { + if (pendingRevalidatedTags?.some((item) => item.tag === tag)) { debug?.('tag', tag, 'was just revalidated') return true diff --git a/packages/next/src/server/web/spec-extension/revalidate.ts b/packages/next/src/server/web/spec-extension/revalidate.ts index 91845dc54a2eaf..da9c2b4bf7f98f 100644 --- a/packages/next/src/server/web/spec-extension/revalidate.ts +++ b/packages/next/src/server/web/spec-extension/revalidate.ts @@ -12,47 +12,44 @@ import { workUnitAsyncStorage } from '../../app-render/work-unit-async-storage.e import { DynamicServerError } from '../../../client/components/hooks-server-context' import { InvariantError } from '../../../shared/lib/invariant-error' +type CacheLifeConfig = { + expire?: number +} + /** * This function allows you to purge [cached data](https://nextjs.org/docs/app/building-your-application/caching) on-demand for a specific cache tag. * * Read more: [Next.js Docs: `revalidateTag`](https://nextjs.org/docs/app/api-reference/functions/revalidateTag) */ -export function revalidateTag(tag: string) { - return revalidate([tag], `revalidateTag ${tag}`) +export function revalidateTag(tag: string, profile: string | CacheLifeConfig) { + if (!profile) { + console.warn( + '"revalidateTag" without the second argument is now deprecated, add second argument of "max" or use "updateTag". See more info here: https://nextjs.org/docs/messages/revalidate-tag-single-arg' + ) + } + return revalidate([tag], `revalidateTag ${tag}`, profile) } /** - * This function allows you to purge [cached data](https://nextjs.org/docs/app/building-your-application/caching) on-demand for a specific path. + * This function allows you to update [cached data](https://nextjs.org/docs/app/building-your-application/caching) on-demand for a specific cache tag. + * This can only be called from within a Server Action to enable read-your-own-writes semantics. * - * Read more: [Next.js Docs: `unstable_expirePath`](https://nextjs.org/docs/app/api-reference/functions/unstable_expirePath) + * Read more: [Next.js Docs: `updateTag`](https://nextjs.org/docs/app/api-reference/functions/updateTag) */ -export function unstable_expirePath( - originalPath: string, - type?: 'layout' | 'page' -) { - if (originalPath.length > NEXT_CACHE_SOFT_TAG_MAX_LENGTH) { - console.warn( - `Warning: expirePath received "${originalPath}" which exceeded max length of ${NEXT_CACHE_SOFT_TAG_MAX_LENGTH}. See more info here https://nextjs.org/docs/app/api-reference/functions/unstable_expirePath` - ) - return - } - - let normalizedPath = `${NEXT_CACHE_IMPLICIT_TAG_ID}${originalPath || '/'}` +export function updateTag(tag: string) { + const workStore = workAsyncStorage.getStore() - if (type) { - normalizedPath += `${normalizedPath.endsWith('/') ? '' : '/'}${type}` - } else if (isDynamicRoute(originalPath)) { - console.warn( - `Warning: a dynamic page path "${originalPath}" was passed to "expirePath", but the "type" parameter is missing. This has no effect by default, see more info here https://nextjs.org/docs/app/api-reference/functions/unstable_expirePath` + // TODO: change this after investigating why phase: 'action' is + // set for route handlers + if (!workStore || workStore.page.endsWith('/route')) { + throw new Error( + 'updateTag can only be called from within a Server Action. ' + + 'To invalidate cache tags in Route Handlers or other contexts, use revalidateTag instead. ' + + 'See more info here: https://nextjs.org/docs/app/api-reference/functions/updateTag' ) } - const tags = [normalizedPath] - if (normalizedPath === `${NEXT_CACHE_IMPLICIT_TAG_ID}/`) { - tags.push(`${NEXT_CACHE_IMPLICIT_TAG_ID}/index`) - } else if (normalizedPath === `${NEXT_CACHE_IMPLICIT_TAG_ID}/index`) { - tags.push(`${NEXT_CACHE_IMPLICIT_TAG_ID}/`) - } - return revalidate(tags, `unstable_expirePath ${originalPath}`) + // updateTag uses immediate expiration (no profile) without deprecation warning + return revalidate([tag], `updateTag ${tag}`, undefined) } /** @@ -81,15 +78,6 @@ export function refresh() { } } -/** - * This function allows you to purge [cached data](https://nextjs.org/docs/app/building-your-application/caching) on-demand for a specific cache tag. - * - * Read more: [Next.js Docs: `unstable_expireTag`](https://nextjs.org/docs/app/api-reference/functions/unstable_expireTag) - */ -export function unstable_expireTag(...tags: string[]) { - return revalidate(tags, `unstable_expireTag ${tags.join(', ')}`) -} - /** * This function allows you to purge [cached data](https://nextjs.org/docs/app/building-your-application/caching) on-demand for a specific path. * @@ -123,7 +111,11 @@ export function revalidatePath(originalPath: string, type?: 'layout' | 'page') { return revalidate(tags, `revalidatePath ${originalPath}`) } -function revalidate(tags: string[], expression: string) { +function revalidate( + tags: string[], + expression: string, + profile?: string | CacheLifeConfig +) { const store = workAsyncStorage.getStore() if (!store || !store.incrementalCache) { throw new Error( @@ -199,11 +191,39 @@ function revalidate(tags: string[], expression: string) { } for (const tag of tags) { - if (!store.pendingRevalidatedTags.includes(tag)) { - store.pendingRevalidatedTags.push(tag) + const existingIndex = store.pendingRevalidatedTags.findIndex((item) => { + if (item.tag !== tag) return false + // Compare profiles: both strings, both objects, or both undefined + if (typeof item.profile === 'string' && typeof profile === 'string') { + return item.profile === profile + } + if (typeof item.profile === 'object' && typeof profile === 'object') { + return JSON.stringify(item.profile) === JSON.stringify(profile) + } + return item.profile === profile + }) + if (existingIndex === -1) { + store.pendingRevalidatedTags.push({ + tag, + profile, + }) } } - // TODO: only revalidate if the path matches - store.pathWasRevalidated = true + // if profile is provided and this is a stale-while-revalidate + // update we do not mark the path as revalidated so that server + // actions don't pull their own writes + const cacheLife = + profile && typeof profile === 'object' + ? profile + : profile && + typeof profile === 'string' && + store?.cacheLifeProfiles?.[profile] + ? store.cacheLifeProfiles[profile] + : undefined + + if (!profile || cacheLife?.expire === 0) { + // TODO: only revalidate if the path matches + store.pathWasRevalidated = true + } } diff --git a/test/deploy-tests-manifest.json b/test/deploy-tests-manifest.json index 62c1bd105ffc0d..e5b5aa957435ca 100644 --- a/test/deploy-tests-manifest.json +++ b/test/deploy-tests-manifest.json @@ -5,20 +5,20 @@ "failed": [ "app-dir action handling fetch actions should invalidate client cache when path is revalidated", "app-dir action handling fetch actions should invalidate client cache when tag is revalidated", - "app-dir action handling fetch actions should handle unstable_expireTag" + "app-dir action handling fetch actions should handle revalidateTag" ] }, "test/e2e/app-dir/actions/app-action-node-middleware.test.ts": { "failed": [ "app-dir action handling fetch actions should invalidate client cache when path is revalidated", "app-dir action handling fetch actions should invalidate client cache when tag is revalidated", - "app-dir action handling fetch actions should handle unstable_expireTag" + "app-dir action handling fetch actions should handle revalidateTag" ] }, "test/e2e/app-dir/app-static/app-static.test.ts": { "failed": [ "app-dir static/dynamic handling new tags have been specified on subsequent fetch should not fetch from memory cache", - "app-dir static/dynamic handling new tags have been specified on subsequent fetch should not fetch from memory cache after unstable_expireTag is used" + "app-dir static/dynamic handling new tags have been specified on subsequent fetch should not fetch from memory cache after revalidateTag is used" ] }, "test/e2e/app-dir/metadata/metadata.test.ts": { diff --git a/test/development/acceptance-app/fixtures/rsc-build-errors/app/server-with-errors/next-cache-in-client/unstable_expirepath/page.js b/test/development/acceptance-app/fixtures/rsc-build-errors/app/server-with-errors/next-cache-in-client/unstable_expirepath/page.js deleted file mode 100644 index 5aa667268aa156..00000000000000 --- a/test/development/acceptance-app/fixtures/rsc-build-errors/app/server-with-errors/next-cache-in-client/unstable_expirepath/page.js +++ /dev/null @@ -1,8 +0,0 @@ -'use client' -import { unstable_expirePath } from 'next/cache' - -console.log({ unstable_expirePath }) - -export default function Page() { - return null -} diff --git a/test/development/acceptance-app/fixtures/rsc-build-errors/app/server-with-errors/next-cache-in-client/unstable_expiretag/page.js b/test/development/acceptance-app/fixtures/rsc-build-errors/app/server-with-errors/next-cache-in-client/unstable_expiretag/page.js deleted file mode 100644 index dbe846ba5fc49a..00000000000000 --- a/test/development/acceptance-app/fixtures/rsc-build-errors/app/server-with-errors/next-cache-in-client/unstable_expiretag/page.js +++ /dev/null @@ -1,8 +0,0 @@ -'use client' -import { unstable_expireTag } from 'next/cache' - -console.log({ unstable_expireTag }) - -export default function Page() { - return null -} diff --git a/test/development/acceptance-app/rsc-build-errors.test.ts b/test/development/acceptance-app/rsc-build-errors.test.ts index 5104550c4cb466..925d2e48a1aa1b 100644 --- a/test/development/acceptance-app/rsc-build-errors.test.ts +++ b/test/development/acceptance-app/rsc-build-errors.test.ts @@ -284,8 +284,6 @@ describe('Error overlay - RSC build errors', () => { 'revalidateTag', 'unstable_cacheLife', 'unstable_cacheTag', - 'unstable_expirePath', - 'unstable_expireTag', ])('%s is not allowed', async (api) => { await using sandbox = await createSandbox( next, diff --git a/test/development/acceptance/server-component-compiler-errors-in-pages.test.ts b/test/development/acceptance/server-component-compiler-errors-in-pages.test.ts index f25fc50345fafc..5b80930484c8a1 100644 --- a/test/development/acceptance/server-component-compiler-errors-in-pages.test.ts +++ b/test/development/acceptance/server-component-compiler-errors-in-pages.test.ts @@ -386,8 +386,8 @@ describe('Error Overlay for server components compiler errors in pages', () => { 'revalidateTag', 'unstable_cacheLife', 'unstable_cacheTag', - 'unstable_expirePath', - 'unstable_expireTag', + 'revalidatePath', + 'revalidateTag', ])('%s is not allowed', async (api) => { await using sandbox = await createSandbox(next, initialFiles) const { session } = sandbox diff --git a/test/development/app-dir/cache-components-dev-cache-scope/app/cached/page.tsx b/test/development/app-dir/cache-components-dev-cache-scope/app/cached/page.tsx index cbe0f376b6be68..0bab623bee7780 100644 --- a/test/development/app-dir/cache-components-dev-cache-scope/app/cached/page.tsx +++ b/test/development/app-dir/cache-components-dev-cache-scope/app/cached/page.tsx @@ -1,5 +1,5 @@ import { - unstable_expireTag, + revalidateTag, unstable_cacheLife as cacheLife, unstable_cacheTag, } from 'next/cache' @@ -13,7 +13,7 @@ function InnerComponent({ children }) { async function refresh() { 'use server' - unstable_expireTag('hello') + revalidateTag('hello') } async function reload() { diff --git a/test/e2e/app-dir/actions-revalidate-remount/app/test/page.tsx b/test/e2e/app-dir/actions-revalidate-remount/app/test/page.tsx index e4fb27b7b03a27..beabff09c1be21 100644 --- a/test/e2e/app-dir/actions-revalidate-remount/app/test/page.tsx +++ b/test/e2e/app-dir/actions-revalidate-remount/app/test/page.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { unstable_expirePath } from 'next/cache' +import { revalidatePath } from 'next/cache' export default async function HomePage() { await new Promise((resolve) => setTimeout(resolve, 200)) @@ -9,7 +9,7 @@ export default async function HomePage() {
{ 'use server' - unstable_expirePath('/test') + revalidatePath('/test') }} > diff --git a/test/e2e/app-dir/actions/app-action.test.ts b/test/e2e/app-dir/actions/app-action.test.ts index 6047cf948ed9d0..caddec0e627702 100644 --- a/test/e2e/app-dir/actions/app-action.test.ts +++ b/test/e2e/app-dir/actions/app-action.test.ts @@ -1301,7 +1301,7 @@ describe('app-dir action handling', () => { }, 5000) }) - it('should handle unstable_expirePath', async () => { + it('should handle revalidatePath', async () => { const browser = await next.browser('/revalidate') const randomNumber = await browser.elementByCss('#random-number').text() const justPutIt = await browser.elementByCss('#justputit').text() @@ -1324,7 +1324,7 @@ describe('app-dir action handling', () => { }) }) - it('should handle unstable_expireTag', async () => { + it('should handle revalidateTag', async () => { const browser = await next.browser('/revalidate') const randomNumber = await browser.elementByCss('#random-number').text() const justPutIt = await browser.elementByCss('#justputit').text() @@ -1348,7 +1348,7 @@ describe('app-dir action handling', () => { }) // TODO: investigate flakey behavior with revalidate - it.skip('should handle unstable_expireTag + redirect', async () => { + it.skip('should handle revalidateTag + redirect', async () => { const browser = await next.browser('/revalidate') const randomNumber = await browser.elementByCss('#random-number').text() const justPutIt = await browser.elementByCss('#justputit').text() diff --git a/test/e2e/app-dir/actions/app/action-discarding/actions.js b/test/e2e/app-dir/actions/app/action-discarding/actions.js index e1f906a5f564d1..b84fd819aca04e 100644 --- a/test/e2e/app-dir/actions/app/action-discarding/actions.js +++ b/test/e2e/app-dir/actions/app/action-discarding/actions.js @@ -1,6 +1,6 @@ 'use server' -import { revalidateTag } from 'next/cache' +import { updateTag } from 'next/cache' export async function slowAction() { await new Promise((resolve) => setTimeout(resolve, 2000)) @@ -9,6 +9,6 @@ export async function slowAction() { export async function slowActionWithRevalidation() { await new Promise((resolve) => setTimeout(resolve, 2000)) - revalidateTag('cached-random') + updateTag('cached-random') return 'slow action with revalidation completed' } diff --git a/test/e2e/app-dir/actions/app/delayed-action/actions.ts b/test/e2e/app-dir/actions/app/delayed-action/actions.ts index e80ff6f3ad7a39..94b37998dbee43 100644 --- a/test/e2e/app-dir/actions/app/delayed-action/actions.ts +++ b/test/e2e/app-dir/actions/app/delayed-action/actions.ts @@ -1,10 +1,10 @@ 'use server' -import { unstable_expirePath } from 'next/cache' +import { revalidatePath } from 'next/cache' import { redirect } from 'next/navigation' export const action = async () => { console.log('revalidating') - unstable_expirePath('/delayed-action', 'page') + revalidatePath('/delayed-action', 'page') return Math.random() } diff --git a/test/e2e/app-dir/actions/app/redirect/actions.ts b/test/e2e/app-dir/actions/app/redirect/actions.ts index 21de7a0a787b96..e067e07fcb4f20 100644 --- a/test/e2e/app-dir/actions/app/redirect/actions.ts +++ b/test/e2e/app-dir/actions/app/redirect/actions.ts @@ -1,7 +1,7 @@ 'use server' import { redirect } from 'next/navigation' -import { unstable_expirePath } from 'next/cache' +import { revalidatePath } from 'next/cache' type State = { errors: Record @@ -16,7 +16,7 @@ export async function action(previousState: State, formData: FormData) { } if (revalidate === 'on') { - unstable_expirePath('/redirect') + revalidatePath('/redirect') } redirect('/redirect/other') diff --git a/test/e2e/app-dir/actions/app/revalidate-2/page.js b/test/e2e/app-dir/actions/app/revalidate-2/page.js index 3609ab78a6896a..30ced161d72b09 100644 --- a/test/e2e/app-dir/actions/app/revalidate-2/page.js +++ b/test/e2e/app-dir/actions/app/revalidate-2/page.js @@ -1,4 +1,4 @@ -import { unstable_expireTag } from 'next/cache' +import { updateTag } from 'next/cache' import { cookies } from 'next/headers' import Link from 'next/link' @@ -26,7 +26,7 @@ export default async function Page() { id="revalidate-tag" formAction={async () => { 'use server' - unstable_expireTag('thankyounext') + updateTag('thankyounext') }} > revalidate thankyounext diff --git a/test/e2e/app-dir/actions/app/revalidate-multiple/page.js b/test/e2e/app-dir/actions/app/revalidate-multiple/page.js index 3b75c88146cb34..ebbee4cc00b600 100644 --- a/test/e2e/app-dir/actions/app/revalidate-multiple/page.js +++ b/test/e2e/app-dir/actions/app/revalidate-multiple/page.js @@ -1,4 +1,4 @@ -import { unstable_expireTag } from 'next/cache' +import { updateTag } from 'next/cache' export default async function Page() { const data1 = await fetch( @@ -29,8 +29,8 @@ export default async function Page() { id="revalidate" formAction={async () => { 'use server' - unstable_expireTag('thankyounext') - unstable_expireTag('justputit') + updateTag('thankyounext') + updateTag('justputit') }} > revalidate thankyounext diff --git a/test/e2e/app-dir/actions/app/revalidate/page.js b/test/e2e/app-dir/actions/app/revalidate/page.js index ef27713d7e241a..f7127bd0bffe74 100644 --- a/test/e2e/app-dir/actions/app/revalidate/page.js +++ b/test/e2e/app-dir/actions/app/revalidate/page.js @@ -1,4 +1,4 @@ -import { unstable_expirePath, unstable_expireTag } from 'next/cache' +import { revalidatePath, updateTag } from 'next/cache' import { redirect } from 'next/navigation' import Link from 'next/link' @@ -59,7 +59,7 @@ export default async function Page() { id="revalidate-thankyounext" formAction={async () => { 'use server' - unstable_expireTag('thankyounext') + updateTag('thankyounext') }} > revalidate thankyounext @@ -70,7 +70,7 @@ export default async function Page() { id="revalidate-justputit" formAction={async () => { 'use server' - unstable_expireTag('justputit') + updateTag('justputit') }} > revalidate justputit @@ -81,7 +81,7 @@ export default async function Page() { id="revalidate-path" formAction={async () => { 'use server' - unstable_expirePath('/revalidate') + revalidatePath('/revalidate') }} > revalidate path @@ -92,7 +92,7 @@ export default async function Page() { id="revalidate-path-redirect" formAction={async () => { 'use server' - unstable_expireTag('justputit') + updateTag('justputit') redirect('/revalidate') }} > @@ -115,7 +115,7 @@ export default async function Page() { id="redirect-revalidate" formAction={async () => { 'use server' - unstable_expireTag('justputit') + updateTag('justputit') redirect('/revalidate?foo=bar') }} > @@ -125,7 +125,7 @@ export default async function Page() { { 'use server' - unstable_expireTag('justputit') + updateTag('justputit') }} /> diff --git a/test/e2e/app-dir/actions/app/shared/action.js b/test/e2e/app-dir/actions/app/shared/action.js index 348836444dd626..f3dc0d5e73fc33 100644 --- a/test/e2e/app-dir/actions/app/shared/action.js +++ b/test/e2e/app-dir/actions/app/shared/action.js @@ -1,12 +1,12 @@ 'use server' -import { unstable_expirePath } from 'next/cache' +import { revalidatePath } from 'next/cache' let x = 0 export async function inc() { ++x - unstable_expirePath('/shared') + revalidatePath('/shared') } export async function get() { diff --git a/test/e2e/app-dir/app-static/app-static.test.ts b/test/e2e/app-dir/app-static/app-static.test.ts index 5bda44db2499a4..8b287804b92376 100644 --- a/test/e2e/app-dir/app-static/app-static.test.ts +++ b/test/e2e/app-dir/app-static/app-static.test.ts @@ -169,7 +169,7 @@ describe('app-dir static/dynamic handling', () => { expect(data1).not.toBe(data2) }) - it('should not fetch from memory cache after unstable_expireTag is used', async () => { + it('should not fetch from memory cache after revalidateTag is used', async () => { const res1 = await next.fetch('/specify-new-tags/one-tag') expect(res1.status).toBe(200) @@ -1162,6 +1162,12 @@ describe('app-dir static/dynamic handling', () => { "unstable-cache/fetch/no-store.segments/unstable-cache/fetch.segment.rsc", "unstable-cache/fetch/no-store.segments/unstable-cache/fetch/no-store.segment.rsc", "unstable-cache/fetch/no-store.segments/unstable-cache/fetch/no-store/__PAGE__.segment.rsc", + "update-tag-test.html", + "update-tag-test.rsc", + "update-tag-test.segments/_index.segment.rsc", + "update-tag-test.segments/_tree.segment.rsc", + "update-tag-test.segments/update-tag-test.segment.rsc", + "update-tag-test.segments/update-tag-test/__PAGE__.segment.rsc", "variable-config-revalidate/revalidate-3.html", "variable-config-revalidate/revalidate-3.rsc", "variable-config-revalidate/revalidate-3.segments/_index.segment.rsc", @@ -2557,6 +2563,31 @@ describe('app-dir static/dynamic handling', () => { "prefetchDataRoute": null, "srcRoute": "/unstable-cache/fetch/no-store", }, + "/update-tag-test": { + "allowHeader": [ + "host", + "x-matched-path", + "x-prerender-revalidate", + "x-prerender-revalidate-if-generated", + "x-next-revalidated-tags", + "x-next-revalidate-tag-token", + ], + "dataRoute": "/update-tag-test.rsc", + "experimentalBypassFor": [ + { + "key": "next-action", + "type": "header", + }, + { + "key": "content-type", + "type": "header", + "value": "multipart/form-data;.*", + }, + ], + "initialRevalidateSeconds": false, + "prefetchDataRoute": null, + "srcRoute": "/update-tag-test", + }, "/variable-config-revalidate/revalidate-3": { "allowHeader": [ "host", @@ -4884,4 +4915,72 @@ describe('app-dir static/dynamic handling', () => { const browser = await next.browser('/dynamic-param-edge/hello') expect(await browser.elementByCss('#slug').text()).toBe('hello') }) + + describe('updateTag', () => { + it('should throw error when updateTag is called in route handler', async () => { + const res = await next.fetch('/api/update-tag-error') + const data = await res.json() + + expect(data.error).toContain( + 'updateTag can only be called from within a Server Action' + ) + }) + + it('should successfully update tag when called from server action', async () => { + // First fetch to get initial data + const browser = await next.browser('/update-tag-test') + const initialData = JSON.parse(await browser.elementByCss('#data').text()) + + await retry(async () => { + // Click update button to trigger server action with updateTag + await browser.elementByCss('#update-button').click() + + // Refresh the page to see if cache was invalidated + await browser.refresh() + const newData = JSON.parse(await browser.elementByCss('#data').text()) + + // Data should be different after updateTag (immediate expiration) + expect(newData).not.toEqual(initialData) + }) + }) + + it('should use revalidateTag with max profile in server actions', async () => { + // First fetch to get initial data + const browser = await next.browser('/update-tag-test') + const initialData = JSON.parse(await browser.elementByCss('#data').text()) + + // Click revalidate button to trigger server action with revalidateTag(..., 'max') + await browser.elementByCss('#revalidate-button').click() + + // The behavior with 'max' profile would be stale-while-revalidate + // Initial request after revalidation might still show stale data + await retry(async () => { + await browser.refresh() + const dataAfterRevalidate = JSON.parse( + await browser.elementByCss('#data').text() + ) + + expect(dataAfterRevalidate).toBeDefined() + expect(dataAfterRevalidate).not.toBe(initialData) + }) + }) + + // Runtime logs aren't queryable in deploy mode + if (!isNextDeploy) { + it('should show deprecation warning for revalidateTag without second argument', async () => { + const cliOutputStart = next.cliOutput.length + + const browser = await next.browser('/update-tag-test') + + await retry(async () => { + // Click deprecated button to trigger server action with revalidateTag (no second arg) + await browser.elementByCss('#deprecated-button').click() + const output = next.cliOutput.substring(cliOutputStart) + expect(output).toContain( + '"revalidateTag" without the second argument is now deprecated' + ) + }) + }) + } + }) }) diff --git a/test/e2e/app-dir/app-static/app/api/revalidate-path-edge/route.ts b/test/e2e/app-dir/app-static/app/api/revalidate-path-edge/route.ts index a252d436c46262..ef0efdcbf8f503 100644 --- a/test/e2e/app-dir/app-static/app/api/revalidate-path-edge/route.ts +++ b/test/e2e/app-dir/app-static/app/api/revalidate-path-edge/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server' -import { unstable_expirePath } from 'next/cache' +import { revalidatePath } from 'next/cache' export const runtime = 'edge' @@ -7,7 +7,7 @@ export async function GET(req: NextRequest) { const path = req.nextUrl.searchParams.get('path') || '/' try { console.log('revalidating path', path) - unstable_expirePath(path) + revalidatePath(path) return NextResponse.json({ revalidated: true, now: Date.now() }) } catch (err) { console.error('Failed to revalidate', path, err) diff --git a/test/e2e/app-dir/app-static/app/api/revalidate-path-node/route.ts b/test/e2e/app-dir/app-static/app/api/revalidate-path-node/route.ts index daa0a3066bb199..a43a5cecb40823 100644 --- a/test/e2e/app-dir/app-static/app/api/revalidate-path-node/route.ts +++ b/test/e2e/app-dir/app-static/app/api/revalidate-path-node/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server' -import { unstable_expirePath } from 'next/cache' +import { revalidatePath } from 'next/cache' export const revalidate = 1 @@ -7,7 +7,7 @@ export async function GET(req: NextRequest) { const path = req.nextUrl.searchParams.get('path') || '/' try { console.log('revalidating path', path) - unstable_expirePath(path) + revalidatePath(path) return NextResponse.json({ revalidated: true, now: Date.now() }) } catch (err) { console.error('Failed to revalidate', path, err) diff --git a/test/e2e/app-dir/app-static/app/api/revalidate-tag-edge/route.ts b/test/e2e/app-dir/app-static/app/api/revalidate-tag-edge/route.ts index ca99fe330c5512..2f5fd4df3b6d50 100644 --- a/test/e2e/app-dir/app-static/app/api/revalidate-tag-edge/route.ts +++ b/test/e2e/app-dir/app-static/app/api/revalidate-tag-edge/route.ts @@ -1,11 +1,11 @@ import { NextResponse } from 'next/server' -import { unstable_expireTag } from 'next/cache' +import { revalidateTag } from 'next/cache' export const revalidate = 0 export const runtime = 'edge' export async function GET(req) { const tag = req.nextUrl.searchParams.get('tag') - unstable_expireTag(tag) + revalidateTag(tag, { expire: 0 }) return NextResponse.json({ revalidated: true, now: Date.now() }) } diff --git a/test/e2e/app-dir/app-static/app/api/revalidate-tag-node/route.ts b/test/e2e/app-dir/app-static/app/api/revalidate-tag-node/route.ts index 11e9494a44cb17..76c7ad20fb176f 100644 --- a/test/e2e/app-dir/app-static/app/api/revalidate-tag-node/route.ts +++ b/test/e2e/app-dir/app-static/app/api/revalidate-tag-node/route.ts @@ -1,10 +1,10 @@ import { NextResponse } from 'next/server' -import { unstable_expireTag } from 'next/cache' +import { revalidateTag } from 'next/cache' export const revalidate = 0 export async function GET(req) { const tag = req.nextUrl.searchParams.get('tag') - unstable_expireTag(tag) + revalidateTag(tag, 'expireNow') return NextResponse.json({ revalidated: true, now: Date.now() }) } diff --git a/test/e2e/app-dir/app-static/app/api/update-tag-error/route.ts b/test/e2e/app-dir/app-static/app/api/update-tag-error/route.ts new file mode 100644 index 00000000000000..ffbebc2c199f9a --- /dev/null +++ b/test/e2e/app-dir/app-static/app/api/update-tag-error/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from 'next/server' +import { updateTag } from 'next/cache' + +export async function GET() { + try { + // This should throw an error - updateTag cannot be used in route handlers + updateTag('test-tag') + return NextResponse.json({ error: 'Should not reach here' }) + } catch (error: unknown) { + return NextResponse.json( + { + error: + (error && + typeof error === 'object' && + 'message' in error && + error.message) || + 'unknown error', + expectedError: true, + }, + { status: 500 } + ) + } +} diff --git a/test/e2e/app-dir/app-static/app/no-store/static/page.tsx b/test/e2e/app-dir/app-static/app/no-store/static/page.tsx index c0908b70dc090a..087d5f8a951508 100644 --- a/test/e2e/app-dir/app-static/app/no-store/static/page.tsx +++ b/test/e2e/app-dir/app-static/app/no-store/static/page.tsx @@ -1,11 +1,11 @@ -import { unstable_expireTag, unstable_cache } from 'next/cache' +import { updateTag, unstable_cache } from 'next/cache' import { getUncachedRandomData } from '../no-store-fn' import { RevalidateButton } from '../revalidate-button' export default async function Page() { async function revalidate() { 'use server' - await unstable_expireTag('no-store-fn') + await updateTag('no-store-fn') } const cachedData = await unstable_cache( diff --git a/test/e2e/app-dir/app-static/app/unstable-cache/dynamic-undefined/page.tsx b/test/e2e/app-dir/app-static/app/unstable-cache/dynamic-undefined/page.tsx index a59e2d53dd2ed1..eed0b94eea6cd9 100644 --- a/test/e2e/app-dir/app-static/app/unstable-cache/dynamic-undefined/page.tsx +++ b/test/e2e/app-dir/app-static/app/unstable-cache/dynamic-undefined/page.tsx @@ -1,4 +1,4 @@ -import { unstable_expireTag, unstable_cache } from 'next/cache' +import { updateTag, unstable_cache } from 'next/cache' import { RevalidateButton } from '../revalidate-button' export const dynamic = 'force-dynamic' @@ -6,7 +6,7 @@ export const dynamic = 'force-dynamic' export default async function Page() { async function revalidate() { 'use server' - await unstable_expireTag('undefined-value-data') + await updateTag('undefined-value-data') } const cachedData = await unstable_cache( diff --git a/test/e2e/app-dir/app-static/app/unstable-cache/dynamic/page.tsx b/test/e2e/app-dir/app-static/app/unstable-cache/dynamic/page.tsx index 50453a91b64bc9..f5bb362eb686f5 100644 --- a/test/e2e/app-dir/app-static/app/unstable-cache/dynamic/page.tsx +++ b/test/e2e/app-dir/app-static/app/unstable-cache/dynamic/page.tsx @@ -1,5 +1,5 @@ import { draftMode } from 'next/headers' -import { unstable_expireTag, unstable_cache } from 'next/cache' +import { updateTag, unstable_cache } from 'next/cache' import { RevalidateButton } from '../revalidate-button' export const dynamic = 'force-dynamic' @@ -7,7 +7,7 @@ export const dynamic = 'force-dynamic' export default async function Page() { async function revalidate() { 'use server' - await unstable_expireTag('random-value-data') + await updateTag('random-value-data') } const cachedData = await unstable_cache( diff --git a/test/e2e/app-dir/app-static/app/update-tag-test/actions.ts b/test/e2e/app-dir/app-static/app/update-tag-test/actions.ts new file mode 100644 index 00000000000000..dfec8df828b362 --- /dev/null +++ b/test/e2e/app-dir/app-static/app/update-tag-test/actions.ts @@ -0,0 +1,24 @@ +'use server' + +import { revalidateTag, updateTag } from 'next/cache' + +export async function updateAction() { + // This should work - updateTag in server action + updateTag('test-update-tag') + + return { updated: true, timestamp: Date.now() } +} + +export async function revalidateAction() { + // This should work with second argument + revalidateTag('test-update-tag', 'max') + + return { revalidated: true, timestamp: Date.now() } +} + +export async function deprecatedRevalidateAction() { + // @ts-expect-error This should show deprecation warning + revalidateTag('test-update-tag') + + return { revalidated: true, timestamp: Date.now() } +} diff --git a/test/e2e/app-dir/app-static/app/update-tag-test/buttons.tsx b/test/e2e/app-dir/app-static/app/update-tag-test/buttons.tsx new file mode 100644 index 00000000000000..ae42fbb1755afe --- /dev/null +++ b/test/e2e/app-dir/app-static/app/update-tag-test/buttons.tsx @@ -0,0 +1,30 @@ +'use client' +import { + updateAction, + revalidateAction, + deprecatedRevalidateAction, +} from './actions' + +export function Buttons() { + return ( + <> + + + + + ) +} diff --git a/test/e2e/app-dir/app-static/app/update-tag-test/page.tsx b/test/e2e/app-dir/app-static/app/update-tag-test/page.tsx new file mode 100644 index 00000000000000..76347aa93ff103 --- /dev/null +++ b/test/e2e/app-dir/app-static/app/update-tag-test/page.tsx @@ -0,0 +1,27 @@ +import { unstable_cache } from 'next/cache' +import { Buttons } from './buttons' + +const getTimestamp = unstable_cache( + async () => { + return { + timestamp: Date.now(), + random: Math.random(), + } + }, + ['timestamp'], + { + tags: ['test-update-tag'], + } +) + +export default async function UpdateTagTest() { + const data = await getTimestamp() + + return ( +
+

Update Tag Test

+
{JSON.stringify(data)}
+ +
+ ) +} diff --git a/test/e2e/app-dir/app-static/next.config.js b/test/e2e/app-dir/app-static/next.config.js index 7110edcf433697..e09c13d9fd947f 100644 --- a/test/e2e/app-dir/app-static/next.config.js +++ b/test/e2e/app-dir/app-static/next.config.js @@ -3,6 +3,15 @@ module.exports = { logging: { fetches: {}, }, + experimental: { + cacheLife: { + expireNow: { + stale: 0, + expire: 0, + revalidate: 0, + }, + }, + }, cacheHandler: process.env.CUSTOM_CACHE_HANDLER ? require.resolve('./cache-handler.js') : undefined, diff --git a/test/e2e/app-dir/dynamic-interception-route-revalidate/app/[locale]/actions.ts b/test/e2e/app-dir/dynamic-interception-route-revalidate/app/[locale]/actions.ts index d4bdaffa25f090..902a6d21f7a3ab 100644 --- a/test/e2e/app-dir/dynamic-interception-route-revalidate/app/[locale]/actions.ts +++ b/test/e2e/app-dir/dynamic-interception-route-revalidate/app/[locale]/actions.ts @@ -1,9 +1,9 @@ 'use server' -import { unstable_expirePath } from 'next/cache' +import { revalidatePath } from 'next/cache' export async function doAction() { - unstable_expirePath('/en/photos/1/view') + revalidatePath('/en/photos/1/view') // sleep 1s await new Promise((resolve) => setTimeout(resolve, 1000)) return Math.random() diff --git a/test/e2e/app-dir/logging/app/default-cache/page.js b/test/e2e/app-dir/logging/app/default-cache/page.js index b58db456d943d7..e77c0361771e82 100644 --- a/test/e2e/app-dir/logging/app/default-cache/page.js +++ b/test/e2e/app-dir/logging/app/default-cache/page.js @@ -1,4 +1,4 @@ -import { revalidateTag } from 'next/cache' +import { updateTag } from 'next/cache' export const fetchCache = 'default-cache' @@ -80,7 +80,7 @@ function RevalidateForm() { { 'use server' - revalidateTag('test-tag') + updateTag('test-tag') }} > diff --git a/test/e2e/app-dir/navigation/app/popstate-revalidate/foo/action.ts b/test/e2e/app-dir/navigation/app/popstate-revalidate/foo/action.ts index a8da6c20addfad..15bd5fe08973e7 100644 --- a/test/e2e/app-dir/navigation/app/popstate-revalidate/foo/action.ts +++ b/test/e2e/app-dir/navigation/app/popstate-revalidate/foo/action.ts @@ -1,8 +1,8 @@ 'use server' -import { unstable_expirePath } from 'next/cache' +import { revalidatePath } from 'next/cache' export async function action() { - unstable_expirePath('/', 'layout') + revalidatePath('/', 'layout') return true } diff --git a/test/e2e/app-dir/next-after-app-deploy/app/timestamp/revalidate.js b/test/e2e/app-dir/next-after-app-deploy/app/timestamp/revalidate.js index 8c08eddeef5eae..e83c98bf560c43 100644 --- a/test/e2e/app-dir/next-after-app-deploy/app/timestamp/revalidate.js +++ b/test/e2e/app-dir/next-after-app-deploy/app/timestamp/revalidate.js @@ -1,4 +1,4 @@ -import { unstable_expirePath } from 'next/cache' +import { revalidatePath } from 'next/cache' export async function revalidateTimestampPage(/** @type {string} */ key) { const path = `/timestamp/key/${encodeURIComponent(key)}` @@ -10,7 +10,7 @@ export async function revalidateTimestampPage(/** @type {string} */ key) { } console.log('revalidateTimestampPage :: revalidating', path) - unstable_expirePath(path) + revalidatePath(path) } const WAIT_BEFORE_REVALIDATING_DEFAULT = 1000 diff --git a/test/e2e/app-dir/next-after-app-deploy/app/timestamp/trigger-revalidate/route.js b/test/e2e/app-dir/next-after-app-deploy/app/timestamp/trigger-revalidate/route.js index b2ca2d473adae1..18eb6a554db42a 100644 --- a/test/e2e/app-dir/next-after-app-deploy/app/timestamp/trigger-revalidate/route.js +++ b/test/e2e/app-dir/next-after-app-deploy/app/timestamp/trigger-revalidate/route.js @@ -1,7 +1,7 @@ import { revalidateTimestampPage } from '../revalidate' export async function POST(/** @type {Request} */ request) { - // we can't call unstable_expirePath from middleware, so we need to do it from here instead + // we can't call revalidatePath from middleware, so we need to do it from here instead const path = new URL(request.url).searchParams.get('path') if (!path) { return Response.json( diff --git a/test/e2e/app-dir/next-after-app-deploy/index.test.ts b/test/e2e/app-dir/next-after-app-deploy/index.test.ts index b88cf2f33e2df5..4e6617e21e1ff1 100644 --- a/test/e2e/app-dir/next-after-app-deploy/index.test.ts +++ b/test/e2e/app-dir/next-after-app-deploy/index.test.ts @@ -9,7 +9,7 @@ const WAIT_BEFORE_REVALIDATING = 1000 // If we want to verify that `after()` ran its callback, // we need it to perform some kind of side effect (because it can't affect the response). // In other tests, we often use logs for this, but we don't have access to those in deploy tests. -// So instead this test relies on calling `unstable_expirePath` inside `after` +// So instead this test relies on calling `revalidatePath` inside `after` // to revalidate an ISR page '/timestamp/key/[key]', and then checking if the timestamp changed -- // if it did, we successfully ran the callback (and performed a side effect). diff --git a/test/e2e/app-dir/next-after-app-deploy/middleware.js b/test/e2e/app-dir/next-after-app-deploy/middleware.js index d5115ff8dbe0ca..520e8780903de6 100644 --- a/test/e2e/app-dir/next-after-app-deploy/middleware.js +++ b/test/e2e/app-dir/next-after-app-deploy/middleware.js @@ -9,7 +9,7 @@ export function middleware( if (match) { const pathPrefix = match.groups.prefix after(async () => { - // we can't call unstable_expirePath from middleware, so we need to do it via an endpoint instead + // we can't call revalidatePath from middleware, so we need to do it via an endpoint instead const pathToRevalidate = pathPrefix + `/middleware` const postUrl = new URL('/timestamp/trigger-revalidate', url.href) diff --git a/test/e2e/app-dir/parallel-routes-revalidation/app/@dialog/revalidate-modal/page.tsx b/test/e2e/app-dir/parallel-routes-revalidation/app/@dialog/revalidate-modal/page.tsx index 6cb45f0cb8429f..82768d0dc084b7 100644 --- a/test/e2e/app-dir/parallel-routes-revalidation/app/@dialog/revalidate-modal/page.tsx +++ b/test/e2e/app-dir/parallel-routes-revalidation/app/@dialog/revalidate-modal/page.tsx @@ -1,6 +1,6 @@ import Link from 'next/link' -import { unstable_expirePath } from 'next/cache' +import { revalidatePath } from 'next/cache' import { addData } from '../../actions' export default function Page() { @@ -9,7 +9,7 @@ export default function Page() { await addData(new Date().toISOString()) - unstable_expirePath('/', 'layout') + revalidatePath('/', 'layout') } return ( diff --git a/test/e2e/app-dir/parallel-routes-revalidation/app/actions.ts b/test/e2e/app-dir/parallel-routes-revalidation/app/actions.ts index 1d612f3c5bb6ef..dc194b76a78eff 100644 --- a/test/e2e/app-dir/parallel-routes-revalidation/app/actions.ts +++ b/test/e2e/app-dir/parallel-routes-revalidation/app/actions.ts @@ -1,5 +1,5 @@ 'use server' -import { unstable_expirePath } from 'next/cache' +import { revalidatePath } from 'next/cache' import { redirect } from 'next/navigation' let data = [] @@ -25,5 +25,5 @@ export async function redirectAction() { export async function clearData() { data = [] - unstable_expirePath('/') + revalidatePath('/') } diff --git a/test/e2e/app-dir/parallel-routes-revalidation/app/nested-revalidate/@modal/modal/action.ts b/test/e2e/app-dir/parallel-routes-revalidation/app/nested-revalidate/@modal/modal/action.ts index 3aa8fecf8e69f1..3a45e68c6e3e9c 100644 --- a/test/e2e/app-dir/parallel-routes-revalidation/app/nested-revalidate/@modal/modal/action.ts +++ b/test/e2e/app-dir/parallel-routes-revalidation/app/nested-revalidate/@modal/modal/action.ts @@ -1,10 +1,10 @@ 'use server' -import { unstable_expirePath } from 'next/cache' +import { revalidatePath } from 'next/cache' export async function revalidateAction() { console.log('revalidate action') - unstable_expirePath('/') + revalidatePath('/') return { success: true, } diff --git a/test/e2e/app-dir/ppr-full/app/api/revalidate/route.js b/test/e2e/app-dir/ppr-full/app/api/revalidate/route.js index cdb5a911021e7f..d7dd340d86cf31 100644 --- a/test/e2e/app-dir/ppr-full/app/api/revalidate/route.js +++ b/test/e2e/app-dir/ppr-full/app/api/revalidate/route.js @@ -1,4 +1,4 @@ -import { unstable_expirePath } from 'next/cache' +import { revalidatePath } from 'next/cache' export async function GET(request) { const url = new URL(request.url) @@ -12,7 +12,7 @@ export async function GET(request) { type = 'page' } - unstable_expirePath(pathname, type) + revalidatePath(pathname, type) } return new Response( diff --git a/test/e2e/app-dir/ppr-unstable-cache/app/revalidate-tag/route.js b/test/e2e/app-dir/ppr-unstable-cache/app/revalidate-tag/route.js index fd76e4e68909d0..382ac84c84bbc4 100644 --- a/test/e2e/app-dir/ppr-unstable-cache/app/revalidate-tag/route.js +++ b/test/e2e/app-dir/ppr-unstable-cache/app/revalidate-tag/route.js @@ -1,6 +1,6 @@ -import { unstable_expireTag } from 'next/cache' +import { revalidateTag } from 'next/cache' export const POST = async () => { - unstable_expireTag('unstable-cache-fetch') + revalidateTag('unstable-cache-fetch', 'expireNow') return new Response('OK', { status: 200 }) } diff --git a/test/e2e/app-dir/ppr-unstable-cache/next.config.js b/test/e2e/app-dir/ppr-unstable-cache/next.config.js index 965373b19588f6..e0e7fecdc7757c 100644 --- a/test/e2e/app-dir/ppr-unstable-cache/next.config.js +++ b/test/e2e/app-dir/ppr-unstable-cache/next.config.js @@ -1,5 +1,12 @@ module.exports = { experimental: { + cacheLife: { + expireNow: { + stale: 0, + expire: 0, + revalidate: 0, + }, + }, cacheComponents: true, }, } diff --git a/test/e2e/app-dir/revalidate-dynamic/app/api/revalidate-path/route.js b/test/e2e/app-dir/revalidate-dynamic/app/api/revalidate-path/route.js index 569645441c959a..602822742a4424 100644 --- a/test/e2e/app-dir/revalidate-dynamic/app/api/revalidate-path/route.js +++ b/test/e2e/app-dir/revalidate-dynamic/app/api/revalidate-path/route.js @@ -1,7 +1,7 @@ import { NextResponse } from 'next/server' -import { unstable_expirePath } from 'next/cache' +import { revalidatePath } from 'next/cache' export async function GET(req) { - unstable_expirePath('/') + revalidatePath('/') return NextResponse.json({ revalidated: true, now: Date.now() }) } diff --git a/test/e2e/app-dir/revalidate-dynamic/app/api/revalidate-tag/route.js b/test/e2e/app-dir/revalidate-dynamic/app/api/revalidate-tag/route.js index 4b5bd2e2bfd139..c668f87f2b5e37 100644 --- a/test/e2e/app-dir/revalidate-dynamic/app/api/revalidate-tag/route.js +++ b/test/e2e/app-dir/revalidate-dynamic/app/api/revalidate-tag/route.js @@ -1,7 +1,7 @@ import { NextResponse } from 'next/server' -import { unstable_expireTag } from 'next/cache' +import { revalidateTag } from 'next/cache' export async function GET(req) { - unstable_expireTag('thankyounext') + revalidateTag('thankyounext', 'expireNow') return NextResponse.json({ revalidated: true, now: Date.now() }) } diff --git a/test/e2e/app-dir/revalidate-dynamic/next.config.mjs b/test/e2e/app-dir/revalidate-dynamic/next.config.mjs new file mode 100644 index 00000000000000..3a2e37c1c3b328 --- /dev/null +++ b/test/e2e/app-dir/revalidate-dynamic/next.config.mjs @@ -0,0 +1,12 @@ +const nextConfig = { + experimental: { + cacheLife: { + expireNow: { + stale: 0, + expire: 0, + revalidate: 0, + }, + }, + }, +} +export default nextConfig diff --git a/test/e2e/app-dir/revalidate-dynamic/revalidate-dynamic.test.ts b/test/e2e/app-dir/revalidate-dynamic/revalidate-dynamic.test.ts index cf1fce5f684c8f..ccaad43fc3202a 100644 --- a/test/e2e/app-dir/revalidate-dynamic/revalidate-dynamic.test.ts +++ b/test/e2e/app-dir/revalidate-dynamic/revalidate-dynamic.test.ts @@ -6,7 +6,7 @@ describe('app-dir revalidate-dynamic', () => { }) if (isNextStart) { - it('should correctly mark a route handler that uses unstable_expireTag as dynamic', async () => { + it('should correctly mark a route handler that uses revalidateTag as dynamic', async () => { expect(next.cliOutput).toContain('ƒ /api/revalidate-path') expect(next.cliOutput).toContain('ƒ /api/revalidate-tag') }) diff --git a/test/e2e/app-dir/revalidatetag-rsc/app/actions/revalidate.ts b/test/e2e/app-dir/revalidatetag-rsc/app/actions/revalidate.ts index d8b29d452776e8..226b0e84f361c4 100644 --- a/test/e2e/app-dir/revalidatetag-rsc/app/actions/revalidate.ts +++ b/test/e2e/app-dir/revalidatetag-rsc/app/actions/revalidate.ts @@ -1,11 +1,11 @@ 'use server' -import { unstable_expireTag } from 'next/cache' +import { updateTag } from 'next/cache' export const revalidate = async ( tag: string ): Promise<{ revalidated: boolean }> => { - unstable_expireTag(tag) + updateTag(tag) return { revalidated: true } } diff --git a/test/e2e/app-dir/revalidatetag-rsc/app/revalidate_via_page/page.tsx b/test/e2e/app-dir/revalidatetag-rsc/app/revalidate_via_page/page.tsx index e69c73172687bb..8a56bfe512b4fc 100644 --- a/test/e2e/app-dir/revalidatetag-rsc/app/revalidate_via_page/page.tsx +++ b/test/e2e/app-dir/revalidatetag-rsc/app/revalidate_via_page/page.tsx @@ -1,7 +1,7 @@ 'use server' import Link from 'next/link' -import { unstable_expireTag } from 'next/cache' +import { revalidateTag } from 'next/cache' const RevalidateViaPage = async ({ searchParams, @@ -9,7 +9,7 @@ const RevalidateViaPage = async ({ searchParams: Promise<{ tag: string }> }) => { const { tag } = await searchParams - unstable_expireTag(tag) + revalidateTag(tag, 'max') return (
diff --git a/test/e2e/app-dir/revalidatetag-rsc/revalidatetag-rsc.test.ts b/test/e2e/app-dir/revalidatetag-rsc/revalidatetag-rsc.test.ts index d321f40b65cf6a..0b305f6b84b98b 100644 --- a/test/e2e/app-dir/revalidatetag-rsc/revalidatetag-rsc.test.ts +++ b/test/e2e/app-dir/revalidatetag-rsc/revalidatetag-rsc.test.ts @@ -1,12 +1,12 @@ import { nextTestSetup } from 'e2e-utils' import { assertHasRedbox, getRedboxHeader, retry } from 'next-test-utils' -describe('unstable_expireTag-rsc', () => { +describe('revalidateTag-rsc', () => { const { next, isNextDev, isNextDeploy } = nextTestSetup({ files: __dirname, }) - it('should revalidate fetch cache if unstable_expireTag invoked via server action', async () => { + it('should revalidate fetch cache if revalidateTag invoked via server action', async () => { const browser = await next.browser('/') const randomNumber = await browser.elementById('data').text() await browser.refresh() @@ -23,14 +23,14 @@ describe('unstable_expireTag-rsc', () => { if (!isNextDeploy) { // skipped in deploy because it uses `next.cliOutput` - it('should error if unstable_expireTag is called during render', async () => { + it('should error if revalidateTag is called during render', async () => { const browser = await next.browser('/') await browser.elementByCss('#revalidate-via-page').click() if (isNextDev) { await assertHasRedbox(browser) await expect(getRedboxHeader(browser)).resolves.toContain( - 'Route /revalidate_via_page used "unstable_expireTag data"' + 'Route /revalidate_via_page used "revalidateTag data"' ) } else { await retry(async () => { @@ -41,7 +41,7 @@ describe('unstable_expireTag-rsc', () => { } expect(next.cliOutput).toContain( - 'Route /revalidate_via_page used "unstable_expireTag data"' + 'Route /revalidate_via_page used "revalidateTag data"' ) }) } diff --git a/test/e2e/app-dir/segment-cache/revalidation/app/page.tsx b/test/e2e/app-dir/segment-cache/revalidation/app/page.tsx index d4a410c6f1061a..5521a212d9a731 100644 --- a/test/e2e/app-dir/segment-cache/revalidation/app/page.tsx +++ b/test/e2e/app-dir/segment-cache/revalidation/app/page.tsx @@ -1,4 +1,4 @@ -import { revalidatePath, revalidateTag } from 'next/cache' +import { revalidatePath, updateTag } from 'next/cache' import { LinkAccordion, FormAccordion, @@ -23,7 +23,7 @@ export default async function Page() { id="revalidate-by-tag" formAction={async function () { 'use server' - revalidateTag('random-greeting') + updateTag('random-greeting') }} > Revalidate by tag diff --git a/test/e2e/app-dir/use-cache-custom-handler/app/legacy/page.tsx b/test/e2e/app-dir/use-cache-custom-handler/app/legacy/page.tsx deleted file mode 100644 index 67c60b1382de12..00000000000000 --- a/test/e2e/app-dir/use-cache-custom-handler/app/legacy/page.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { Suspense } from 'react' -import { - unstable_cacheLife as cacheLife, - unstable_cacheTag as cacheTag, - revalidateTag, -} from 'next/cache' -import { redirect } from 'next/navigation' -import { connection } from 'next/server' -import React from 'react' - -async function getData() { - 'use cache: legacy' - - cacheLife({ revalidate: 3 }) - cacheTag('legacy') - - return new Date().toISOString() -} - -async function AsyncComp() { - let data = await getData() - - return

{data}

-} - -export default async function Legacy() { - await connection() - - return ( -
- Loading...

}> - -
- { - 'use server' - - revalidateTag('legacy') - redirect('/legacy') - }} - > - - -
- ) -} diff --git a/test/e2e/app-dir/use-cache-custom-handler/app/page.tsx b/test/e2e/app-dir/use-cache-custom-handler/app/page.tsx index ce1c9b713c6d49..5f0c7f860bceaf 100644 --- a/test/e2e/app-dir/use-cache-custom-handler/app/page.tsx +++ b/test/e2e/app-dir/use-cache-custom-handler/app/page.tsx @@ -3,7 +3,7 @@ import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag, revalidatePath, - revalidateTag, + updateTag, } from 'next/cache' import { redirect } from 'next/navigation' import { connection } from 'next/server' @@ -38,7 +38,7 @@ export default async function Home() { formAction={async () => { 'use server' - revalidateTag('modern') + updateTag('modern') }} > Revalidate Tag @@ -58,7 +58,7 @@ export default async function Home() { formAction={async () => { 'use server' - revalidateTag('modern') + updateTag('modern') redirect('/') }} > diff --git a/test/e2e/app-dir/use-cache-custom-handler/handler.js b/test/e2e/app-dir/use-cache-custom-handler/handler.js index c0198d7387fd0e..40100e166386b6 100644 --- a/test/e2e/app-dir/use-cache-custom-handler/handler.js +++ b/test/e2e/app-dir/use-cache-custom-handler/handler.js @@ -4,7 +4,7 @@ const defaultCacheHandler = require('next/dist/server/lib/cache-handlers/default.external').default /** - * @type {import('next/dist/server/lib/cache-handlers/types').CacheHandlerV2} + * @type {import('next/dist/server/lib/cache-handlers/types').CacheHandler} */ const cacheHandler = { async get(cacheKey, softTags) { @@ -22,16 +22,16 @@ const cacheHandler = { return defaultCacheHandler.refreshTags() }, - async getExpiration(...tags) { + async getExpiration(tags) { console.log('ModernCustomCacheHandler::getExpiration', JSON.stringify(tags)) // Expecting soft tags in `get` to be used by the cache handler for checking // the expiration of a cache entry, instead of letting Next.js handle it. return Infinity }, - async expireTags(...tags) { - console.log('ModernCustomCacheHandler::expireTags', JSON.stringify(tags)) - return defaultCacheHandler.expireTags(...tags) + async updateTags(tags) { + console.log('ModernCustomCacheHandler::updateTags', JSON.stringify(tags)) + return defaultCacheHandler.updateTags(tags) }, } diff --git a/test/e2e/app-dir/use-cache-custom-handler/legacy-handler.js b/test/e2e/app-dir/use-cache-custom-handler/legacy-handler.js deleted file mode 100644 index 6fe8f06b4b291f..00000000000000 --- a/test/e2e/app-dir/use-cache-custom-handler/legacy-handler.js +++ /dev/null @@ -1,37 +0,0 @@ -// @ts-check - -const defaultCacheHandler = - require('next/dist/server/lib/cache-handlers/default.external').default - -/** - * @type {import('next/dist/server/lib/cache-handlers/types').CacheHandler} - */ -const cacheHandler = { - async get(cacheKey, softTags) { - console.log( - 'LegacyCustomCacheHandler::get', - cacheKey, - JSON.stringify(softTags) - ) - return defaultCacheHandler.get(cacheKey, softTags) - }, - - async set(cacheKey, pendingEntry) { - console.log('LegacyCustomCacheHandler::set', cacheKey) - return defaultCacheHandler.set(cacheKey, pendingEntry) - }, - - async expireTags(...tags) { - console.log('LegacyCustomCacheHandler::expireTags', JSON.stringify(tags)) - return defaultCacheHandler.expireTags(...tags) - }, - - async receiveExpiredTags(...tags) { - console.log( - 'LegacyCustomCacheHandler::receiveExpiredTags', - JSON.stringify(tags) - ) - }, -} - -module.exports = cacheHandler diff --git a/test/e2e/app-dir/use-cache-custom-handler/next.config.js b/test/e2e/app-dir/use-cache-custom-handler/next.config.js index 373992347171eb..ebc719d50aeb49 100644 --- a/test/e2e/app-dir/use-cache-custom-handler/next.config.js +++ b/test/e2e/app-dir/use-cache-custom-handler/next.config.js @@ -6,7 +6,6 @@ const nextConfig = { cacheComponents: true, cacheHandlers: { default: require.resolve('./handler.js'), - legacy: require.resolve('./legacy-handler.js'), }, }, } diff --git a/test/e2e/app-dir/use-cache-custom-handler/use-cache-custom-handler.test.ts b/test/e2e/app-dir/use-cache-custom-handler/use-cache-custom-handler.test.ts index 72c5469ec99925..98375aaf411ae9 100644 --- a/test/e2e/app-dir/use-cache-custom-handler/use-cache-custom-handler.test.ts +++ b/test/e2e/app-dir/use-cache-custom-handler/use-cache-custom-handler.test.ts @@ -72,46 +72,6 @@ describe('use-cache-custom-handler', () => { expect(cliOutput).not.toContain('ModernCustomCacheHandler::refreshTags') expect(cliOutput).not.toContain(`ModernCustomCacheHandler::getExpiration`) - - // We don't optimize for legacy cache handlers though: - expect(cliOutput).toContain( - `LegacyCustomCacheHandler::receiveExpiredTags []` - ) - }) - - it('should use a legacy custom cache handler if provided', async () => { - const browser = await next.browser(`/legacy`) - const initialData = await browser.elementById('data').text() - expect(initialData).toMatch(isoDateRegExp) - - expect(next.cliOutput.slice(outputIndex)).toContain( - 'LegacyCustomCacheHandler::receiveExpiredTags []' - ) - - expect(next.cliOutput.slice(outputIndex)).toMatch( - /LegacyCustomCacheHandler::get \["(development|[A-Za-z0-9_-]{21})","([0-9a-f]{2})+",\[\]\] \["_N_T_\/layout","_N_T_\/legacy\/layout","_N_T_\/legacy\/page","_N_T_\/legacy"\]/ - ) - - expect(next.cliOutput.slice(outputIndex)).toMatch( - /LegacyCustomCacheHandler::set \["(development|[A-Za-z0-9_-]{21})","([0-9a-f]{2})+",\[\]\]/ - ) - - // The data should be cached initially. - - await browser.refresh() - let data = await browser.elementById('data').text() - expect(data).toMatch(isoDateRegExp) - expect(data).toEqual(initialData) - - // Because we use a low `revalidate` value for the "use cache" function, new - // data should be returned eventually. - - await retry(async () => { - await browser.refresh() - data = await browser.elementById('data').text() - expect(data).toMatch(isoDateRegExp) - expect(data).not.toEqual(initialData) - }, 5000) }) it('should revalidate after redirect using a modern custom cache handler', async () => { @@ -123,33 +83,7 @@ describe('use-cache-custom-handler', () => { await retry(async () => { expect(next.cliOutput.slice(outputIndex)).toContain( - 'ModernCustomCacheHandler::expireTags ["modern"]' - ) - - const data = await browser.elementById('data').text() - expect(data).toMatch(isoDateRegExp) - expect(data).not.toEqual(initialData) - }, 5000) - }) - - it('should revalidate after redirect using a legacy custom cache handler', async () => { - const browser = await next.browser(`/legacy`) - const initialData = await browser.elementById('data').text() - expect(initialData).toMatch(isoDateRegExp) - - expect(next.cliOutput.slice(outputIndex)).toContain( - 'LegacyCustomCacheHandler::receiveExpiredTags []' - ) - - await browser.elementById('revalidate').click() - - await retry(async () => { - expect(next.cliOutput.slice(outputIndex)).toContain( - 'LegacyCustomCacheHandler::expireTags ["legacy"]' - ) - - expect(next.cliOutput.slice(outputIndex)).toContain( - 'LegacyCustomCacheHandler::receiveExpiredTags ["legacy"]' + 'ModernCustomCacheHandler::updateTags ["modern"]' ) const data = await browser.elementById('data').text() @@ -158,13 +92,13 @@ describe('use-cache-custom-handler', () => { }, 5000) }) - it('should not call expireTags for a normal invocation', async () => { + it('should not call updateTags for a normal invocation', async () => { await next.fetch(`/`) await retry(async () => { const cliOutput = next.cliOutput.slice(outputIndex) expect(cliOutput).toInclude('ModernCustomCacheHandler::refreshTags') - expect(cliOutput).not.toInclude('ModernCustomCacheHandler::expireTags') + expect(cliOutput).not.toInclude('ModernCustomCacheHandler::updateTags') }) }) @@ -179,7 +113,7 @@ describe('use-cache-custom-handler', () => { const cliOutput = next.cliOutput.slice(outputIndex) expect(cliOutput).not.toInclude('ModernCustomCacheHandler::getExpiration') expect(cliOutput).toIncludeRepeated( - `ModernCustomCacheHandler::expireTags`, + `ModernCustomCacheHandler::updateTags`, 1 ) }) diff --git a/test/e2e/app-dir/use-cache/app/(dynamic)/revalidate-and-redirect/redirect/page.tsx b/test/e2e/app-dir/use-cache/app/(dynamic)/revalidate-and-redirect/redirect/page.tsx index f96828fa42b43c..158b4ee6afe291 100644 --- a/test/e2e/app-dir/use-cache/app/(dynamic)/revalidate-and-redirect/redirect/page.tsx +++ b/test/e2e/app-dir/use-cache/app/(dynamic)/revalidate-and-redirect/redirect/page.tsx @@ -1,4 +1,4 @@ -import { revalidatePath, revalidateTag } from 'next/cache' +import { revalidatePath, updateTag } from 'next/cache' import { redirect } from 'next/navigation' export default function Page() { @@ -9,7 +9,7 @@ export default function Page() { formAction={async () => { 'use server' - revalidateTag('revalidate-and-redirect') + updateTag('revalidate-and-redirect') redirect('/revalidate-and-redirect') }} > diff --git a/test/e2e/app-dir/use-cache/app/(dynamic)/revalidate-and-use/page.tsx b/test/e2e/app-dir/use-cache/app/(dynamic)/revalidate-and-use/page.tsx index 77b10c50c1d672..871f463d0d8700 100644 --- a/test/e2e/app-dir/use-cache/app/(dynamic)/revalidate-and-use/page.tsx +++ b/test/e2e/app-dir/use-cache/app/(dynamic)/revalidate-and-use/page.tsx @@ -1,4 +1,4 @@ -import { revalidatePath, revalidateTag, unstable_cacheTag } from 'next/cache' +import { revalidatePath, unstable_cacheTag, updateTag } from 'next/cache' import { Form } from './form' import { connection } from 'next/server' @@ -26,7 +26,7 @@ export default async function Page() { const initialCachedValue = await getCachedValue() if (type === 'tag') { - revalidateTag('revalidate-and-use') + updateTag('revalidate-and-use') } else { revalidatePath('/revalidate-and-use') } diff --git a/test/e2e/app-dir/use-cache/app/(partially-static)/api/revalidate-redirect/route.ts b/test/e2e/app-dir/use-cache/app/(partially-static)/api/revalidate-redirect/route.ts index 3ca4e89352798e..984f68a3dd40b8 100644 --- a/test/e2e/app-dir/use-cache/app/(partially-static)/api/revalidate-redirect/route.ts +++ b/test/e2e/app-dir/use-cache/app/(partially-static)/api/revalidate-redirect/route.ts @@ -2,6 +2,6 @@ import { revalidateTag } from 'next/cache' import { redirect } from 'next/navigation' export async function GET() { - revalidateTag('api') + revalidateTag('api', 'expireNow') redirect('/api') } diff --git a/test/e2e/app-dir/use-cache/app/(partially-static)/cache-tag/buttons.tsx b/test/e2e/app-dir/use-cache/app/(partially-static)/cache-tag/buttons.tsx index 2b9ef977ca84c9..4f6ddf7baff20b 100644 --- a/test/e2e/app-dir/use-cache/app/(partially-static)/cache-tag/buttons.tsx +++ b/test/e2e/app-dir/use-cache/app/(partially-static)/cache-tag/buttons.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { unstable_expirePath, unstable_expireTag } from 'next/cache' +import { revalidatePath, updateTag } from 'next/cache' export function RevalidateButtons() { return ( @@ -8,7 +8,7 @@ export function RevalidateButtons() { id="revalidate-a" formAction={async () => { 'use server' - unstable_expireTag('a') + updateTag('a') }} > revalidate a @@ -17,7 +17,7 @@ export function RevalidateButtons() { id="revalidate-b" formAction={async () => { 'use server' - unstable_expireTag('b') + updateTag('b') }} > revalidate b @@ -26,7 +26,7 @@ export function RevalidateButtons() { id="revalidate-c" formAction={async () => { 'use server' - unstable_expireTag('c') + updateTag('c') }} > revalidate c @@ -35,7 +35,7 @@ export function RevalidateButtons() { id="revalidate-f" formAction={async () => { 'use server' - unstable_expireTag('f') + updateTag('f') }} > revalidate f @@ -44,7 +44,7 @@ export function RevalidateButtons() { id="revalidate-r" formAction={async () => { 'use server' - unstable_expireTag('r') + updateTag('r') }} > revalidate r @@ -53,7 +53,7 @@ export function RevalidateButtons() { id="revalidate-path" formAction={async () => { 'use server' - unstable_expirePath('/cache-tag') + revalidatePath('/cache-tag') }} > revalidate path diff --git a/test/e2e/app-dir/use-cache/app/(partially-static)/form/page.tsx b/test/e2e/app-dir/use-cache/app/(partially-static)/form/page.tsx index 32ea86041ec45c..504805f15dd085 100644 --- a/test/e2e/app-dir/use-cache/app/(partially-static)/form/page.tsx +++ b/test/e2e/app-dir/use-cache/app/(partially-static)/form/page.tsx @@ -1,8 +1,8 @@ -import { unstable_expireTag, unstable_cacheTag as cacheTag } from 'next/cache' +import { updateTag, unstable_cacheTag as cacheTag } from 'next/cache' async function refresh() { 'use server' - unstable_expireTag('home') + updateTag('home') } export default async function Page() { diff --git a/test/e2e/app-dir/use-cache/next.config.js b/test/e2e/app-dir/use-cache/next.config.js index b1c00b3d8cd76b..406bcbd170979a 100644 --- a/test/e2e/app-dir/use-cache/next.config.js +++ b/test/e2e/app-dir/use-cache/next.config.js @@ -10,6 +10,11 @@ const nextConfig = { revalidate: 100, expire: 300, }, + expireNow: { + stale: 0, + expire: 0, + revalidate: 0, + }, }, cacheHandlers: { custom: require.resolve( diff --git a/test/e2e/app-dir/use-cache/use-cache.test.ts b/test/e2e/app-dir/use-cache/use-cache.test.ts index bf456aebceb977..84950005bcb365 100644 --- a/test/e2e/app-dir/use-cache/use-cache.test.ts +++ b/test/e2e/app-dir/use-cache/use-cache.test.ts @@ -220,13 +220,13 @@ describe('use-cache', () => { }) }) - it('should update after unstable_expireTag correctly', async () => { + it('should update after revalidateTag correctly', async () => { const browser = await next.browser('/cache-tag') const initial = await browser.elementByCss('#a').text() if (!isNextDev) { // Bust the ISR cache first, to populate the in-memory cache for the - // subsequent unstable_expireTag calls. + // subsequent revalidateTag calls. await browser.elementByCss('#revalidate-path').click() await retry(async () => { expect(await browser.elementByCss('#a').text()).not.toBe(initial) @@ -608,7 +608,7 @@ describe('use-cache', () => { }) }) - it('should be able to revalidate a page using unstable_expireTag', async () => { + it('should be able to revalidate a page using revalidateTag', async () => { const browser = await next.browser(`/form`) const time1 = await browser.waitForElementByCss('#t').text() diff --git a/test/e2e/on-request-error/isr/app/app/self-revalidate/action.js b/test/e2e/on-request-error/isr/app/app/self-revalidate/action.js index a588c46c1427cb..815a97b132a458 100644 --- a/test/e2e/on-request-error/isr/app/app/self-revalidate/action.js +++ b/test/e2e/on-request-error/isr/app/app/self-revalidate/action.js @@ -1,7 +1,7 @@ 'use server' -import { unstable_expirePath } from 'next/cache' +import { revalidatePath } from 'next/cache' export async function revalidateSelf() { - unstable_expirePath('/app/self-revalidate') + revalidatePath('/app/self-revalidate') } diff --git a/test/production/app-dir/global-default-cache-handler/app/revalidate-tag/route.ts b/test/production/app-dir/global-default-cache-handler/app/revalidate-tag/route.ts index 399578325a3d0f..595c0d456bb856 100644 --- a/test/production/app-dir/global-default-cache-handler/app/revalidate-tag/route.ts +++ b/test/production/app-dir/global-default-cache-handler/app/revalidate-tag/route.ts @@ -4,7 +4,7 @@ import { NextRequest, NextResponse } from 'next/server' export async function GET(req: NextRequest) { console.log(req.url.toString()) - revalidateTag(req.nextUrl.searchParams.get('tag') || '') + revalidateTag(req.nextUrl.searchParams.get('tag') || '', 'expireNow') return NextResponse.json({ success: true }) } diff --git a/test/production/app-dir/global-default-cache-handler/global-default-cache-handler.test.ts b/test/production/app-dir/global-default-cache-handler/global-default-cache-handler.test.ts index bf7f79e2414b3e..3cababab9493f6 100644 --- a/test/production/app-dir/global-default-cache-handler/global-default-cache-handler.test.ts +++ b/test/production/app-dir/global-default-cache-handler/global-default-cache-handler.test.ts @@ -43,8 +43,8 @@ describe('global-default-cache-handler', () => { console.log('symbol getExpiration', tags) }, - expireTags(...tags) { - console.log('symbol expireTags', tags) + updateTags(...tags) { + console.log('symbol updateTags', tags) } } } @@ -93,12 +93,12 @@ describe('global-default-cache-handler', () => { }) }) - it('should call expireTags on global default cache handler', async () => { + it('should call updateTags on global default cache handler', async () => { const res = await fetchViaHTTP(appPort, '/revalidate-tag', { tag: 'tag1' }) expect(res.status).toBe(200) await retry(() => { - expect(output).toContain('symbol expireTags') + expect(output).toContain('symbol updateTags') expect(output).toContain('tag1') }) }) diff --git a/test/production/app-dir/global-default-cache-handler/next.config.js b/test/production/app-dir/global-default-cache-handler/next.config.js index 5bf8b9f6b99f5e..3da611fa376bcf 100644 --- a/test/production/app-dir/global-default-cache-handler/next.config.js +++ b/test/production/app-dir/global-default-cache-handler/next.config.js @@ -5,6 +5,13 @@ const nextConfig = { output: 'standalone', experimental: { useCache: true, + cacheLife: { + expireNow: { + stale: 0, + expire: 0, + revalidate: 0, + }, + }, }, } diff --git a/test/production/app-dir/resume-data-cache/app/revalidate/route.ts b/test/production/app-dir/resume-data-cache/app/revalidate/route.ts index 4100bb53cdd069..e26460e8628b02 100644 --- a/test/production/app-dir/resume-data-cache/app/revalidate/route.ts +++ b/test/production/app-dir/resume-data-cache/app/revalidate/route.ts @@ -1,6 +1,6 @@ import { revalidateTag } from 'next/cache' export function POST() { - revalidateTag('test') + revalidateTag('test', 'expireNow') return new Response(null, { status: 200 }) } diff --git a/test/production/app-dir/resume-data-cache/next.config.js b/test/production/app-dir/resume-data-cache/next.config.js index cf8a60b4804fcd..d875a2fbebf6e7 100644 --- a/test/production/app-dir/resume-data-cache/next.config.js +++ b/test/production/app-dir/resume-data-cache/next.config.js @@ -6,6 +6,13 @@ const nextConfig = { cacheComponents: true, clientSegmentCache: true, clientParamParsing: true, + cacheLife: { + expireNow: { + stale: 0, + expire: 0, + revalidate: 0, + }, + }, }, } diff --git a/test/production/app-dir/ssg-single-pass/app/revalidate/route.ts b/test/production/app-dir/ssg-single-pass/app/revalidate/route.ts index 6394d3d69efffa..2d1d363f655c86 100644 --- a/test/production/app-dir/ssg-single-pass/app/revalidate/route.ts +++ b/test/production/app-dir/ssg-single-pass/app/revalidate/route.ts @@ -1,8 +1,8 @@ import { NextResponse } from 'next/server' -import { unstable_expirePath } from 'next/cache' +import { revalidatePath } from 'next/cache' export async function GET() { - unstable_expirePath('/') + revalidatePath('/') return NextResponse.json({ success: true }) } diff --git a/test/production/custom-server/cache-handler.js b/test/production/custom-server/cache-handler.js index 283beaa1f3ab4c..6ee7e200fccf29 100644 --- a/test/production/custom-server/cache-handler.js +++ b/test/production/custom-server/cache-handler.js @@ -6,7 +6,7 @@ const defaultCacheHandler = require('next/dist/server/lib/cache-handlers/default.external').default /** - * @type {import('next/dist/server/lib/cache-handlers/types').CacheHandlerV2} + * @type {import('next/dist/server/lib/cache-handlers/types').CacheHandler} */ const cacheHandler = { async get(cacheKey) { @@ -23,12 +23,12 @@ const cacheHandler = { return defaultCacheHandler.refreshTags() }, - async getExpiration(...tags) { - return defaultCacheHandler.getExpiration(...tags) + async getExpiration(tags) { + return defaultCacheHandler.getExpiration(tags) }, - async expireTags(...tags) { - return defaultCacheHandler.expireTags(...tags) + async updateTags(tags) { + return defaultCacheHandler.updateTags(tags) }, } diff --git a/test/rspack-build-tests-manifest.json b/test/rspack-build-tests-manifest.json index b9dea3b9b53d9a..f0615fd513ac8e 100644 --- a/test/rspack-build-tests-manifest.json +++ b/test/rspack-build-tests-manifest.json @@ -165,8 +165,8 @@ "app-dir action handling fetch actions should handle calls to redirect() with a relative URL in a single pass", "app-dir action handling fetch actions should handle calls to redirect() with external URLs", "app-dir action handling fetch actions should handle redirects to routes that provide an invalid RSC response", - "app-dir action handling fetch actions should handle unstable_expirePath", - "app-dir action handling fetch actions should handle unstable_expireTag", + "app-dir action handling fetch actions should handle revalidatePath", + "app-dir action handling fetch actions should handle revalidateTag", "app-dir action handling fetch actions should invalidate client cache on other routes when cookies.set is called", "app-dir action handling fetch actions should invalidate client cache when path is revalidated", "app-dir action handling fetch actions should invalidate client cache when tag is revalidated", @@ -228,7 +228,7 @@ ], "failed": [], "pending": [ - "app-dir action handling fetch actions should handle unstable_expireTag + redirect", + "app-dir action handling fetch actions should handle revalidateTag + redirect", "app-dir action handling server actions render client components client component imported action should support importing client components from actions" ], "flakey": [], @@ -299,8 +299,8 @@ "app-dir action handling fetch actions should handle calls to redirect() with a relative URL in a single pass", "app-dir action handling fetch actions should handle calls to redirect() with external URLs", "app-dir action handling fetch actions should handle redirects to routes that provide an invalid RSC response", - "app-dir action handling fetch actions should handle unstable_expirePath", - "app-dir action handling fetch actions should handle unstable_expireTag", + "app-dir action handling fetch actions should handle revalidatePath", + "app-dir action handling fetch actions should handle revalidateTag", "app-dir action handling fetch actions should invalidate client cache on other routes when cookies.set is called", "app-dir action handling fetch actions should invalidate client cache when path is revalidated", "app-dir action handling fetch actions should invalidate client cache when tag is revalidated", @@ -362,7 +362,7 @@ ], "failed": [], "pending": [ - "app-dir action handling fetch actions should handle unstable_expireTag + redirect", + "app-dir action handling fetch actions should handle revalidateTag + redirect", "app-dir action handling server actions render client components client component imported action should support importing client components from actions" ], "flakey": [], @@ -5729,7 +5729,7 @@ }, "test/e2e/app-dir/revalidate-dynamic/revalidate-dynamic.test.ts": { "passed": [ - "app-dir revalidate-dynamic should correctly mark a route handler that uses unstable_expireTag as dynamic", + "app-dir revalidate-dynamic should correctly mark a route handler that uses revalidateTag as dynamic", "app-dir revalidate-dynamic should revalidate the data with /api/revalidate-path", "app-dir revalidate-dynamic should revalidate the data with /api/revalidate-tag" ], @@ -5740,8 +5740,8 @@ }, "test/e2e/app-dir/revalidatetag-rsc/revalidatetag-rsc.test.ts": { "passed": [ - "unstable_expireTag-rsc should error if unstable_expireTag is called during render", - "unstable_expireTag-rsc should revalidate fetch cache if unstable_expireTag invoked via server action" + "revalidateTag-rsc should error if revalidateTag is called during render", + "revalidateTag-rsc should revalidate fetch cache if revalidateTag invoked via server action" ], "failed": [], "pending": [], @@ -7132,7 +7132,7 @@ "test/e2e/app-dir/use-cache-custom-handler/use-cache-custom-handler.test.ts": { "passed": [ "use-cache-custom-handler calls neither refreshTags nor getExpiration if \"use cache\" is not used", - "use-cache-custom-handler should not call expireTags for a normal invocation", + "use-cache-custom-handler should not call updateTags for a normal invocation", "use-cache-custom-handler should not call getExpiration after an action", "use-cache-custom-handler should revalidate after redirect using a legacy custom cache handler", "use-cache-custom-handler should revalidate after redirect using a modern custom cache handler", @@ -7260,7 +7260,7 @@ "use-cache can reference server actions in \"use cache\" functions", "use-cache renders the not-found page when `notFound()` is used", "use-cache shares caches between the page/layout and generateMetadata", - "use-cache should be able to revalidate a page using unstable_expireTag", + "use-cache should be able to revalidate a page using revalidateTag", "use-cache should cache complex args", "use-cache should cache fetch without no-store", "use-cache should cache results", @@ -7290,7 +7290,7 @@ "use-cache should revalidate caches nested in unstable_cache", "use-cache should send an SWR cache-control header based on the revalidate and expire values", "use-cache should store a fetch response without no-store in the incremental cache handler during build", - "use-cache should update after unstable_expireTag correctly", + "use-cache should update after revalidateTag correctly", "use-cache should use revalidate config in fetch", "use-cache usage in node_modules should cache results when using a directive with a handler", "use-cache usage in node_modules should cache results when using a directive without a handler", @@ -19999,7 +19999,7 @@ }, "test/production/app-dir/global-default-cache-handler/global-default-cache-handler.test.ts": { "passed": [ - "global-default-cache-handler should call expireTags on global default cache handler", + "global-default-cache-handler should call updateTags on global default cache handler", "global-default-cache-handler should call refreshTags on global default cache handler", "global-default-cache-handler should use global symbol for default cache handler" ], diff --git a/test/rspack-dev-tests-manifest.json b/test/rspack-dev-tests-manifest.json index 133d15bc3d24aa..d52767cb457fdc 100644 --- a/test/rspack-dev-tests-manifest.json +++ b/test/rspack-dev-tests-manifest.json @@ -221,8 +221,8 @@ "Error overlay - RSC build errors importing 'next/cache' APIs in a client component unstable_cache is allowed", "Error overlay - RSC build errors importing 'next/cache' APIs in a client component unstable_cacheLife is not allowed", "Error overlay - RSC build errors importing 'next/cache' APIs in a client component unstable_cacheTag is not allowed", - "Error overlay - RSC build errors importing 'next/cache' APIs in a client component unstable_expirePath is not allowed", - "Error overlay - RSC build errors importing 'next/cache' APIs in a client component unstable_expireTag is not allowed", + "Error overlay - RSC build errors importing 'next/cache' APIs in a client component revalidatePath is not allowed", + "Error overlay - RSC build errors importing 'next/cache' APIs in a client component revalidateTag is not allowed", "Error overlay - RSC build errors importing 'next/cache' APIs in a client component unstable_noStore is allowed", "Error overlay - RSC build errors next/root-params importing 'next/root-params' in a client component", "Error overlay - RSC build errors next/root-params importing 'next/root-params' in a client component in a way that bypasses import analysis", @@ -467,8 +467,8 @@ "Error Overlay for server components compiler errors in pages importing 'next/cache' APIs in pages unstable_cache is allowed", "Error Overlay for server components compiler errors in pages importing 'next/cache' APIs in pages unstable_cacheLife is not allowed", "Error Overlay for server components compiler errors in pages importing 'next/cache' APIs in pages unstable_cacheTag is not allowed", - "Error Overlay for server components compiler errors in pages importing 'next/cache' APIs in pages unstable_expirePath is not allowed", - "Error Overlay for server components compiler errors in pages importing 'next/cache' APIs in pages unstable_expireTag is not allowed", + "Error Overlay for server components compiler errors in pages importing 'next/cache' APIs in pages revalidatePath is not allowed", + "Error Overlay for server components compiler errors in pages importing 'next/cache' APIs in pages revalidateTag is not allowed", "Error Overlay for server components compiler errors in pages importing 'next/cache' APIs in pages unstable_noStore is allowed", "Error Overlay for server components compiler errors in pages importing 'next/headers' in pages", "Error Overlay for server components compiler errors in pages importing 'next/root-params' in pages", @@ -2613,8 +2613,8 @@ "app-dir action handling fetch actions should handle calls to redirect() with a relative URL in a single pass", "app-dir action handling fetch actions should handle calls to redirect() with external URLs", "app-dir action handling fetch actions should handle redirects to routes that provide an invalid RSC response", - "app-dir action handling fetch actions should handle unstable_expirePath", - "app-dir action handling fetch actions should handle unstable_expireTag", + "app-dir action handling fetch actions should handle revalidatePath", + "app-dir action handling fetch actions should handle revalidateTag", "app-dir action handling fetch actions should invalidate client cache on other routes when cookies.set is called", "app-dir action handling fetch actions should invalidate client cache when path is revalidated", "app-dir action handling fetch actions should invalidate client cache when tag is revalidated", @@ -2675,7 +2675,7 @@ ], "failed": [], "pending": [ - "app-dir action handling fetch actions should handle unstable_expireTag + redirect", + "app-dir action handling fetch actions should handle revalidateTag + redirect", "app-dir action handling server actions render client components client component imported action should support importing client components from actions" ], "flakey": [], @@ -2742,8 +2742,8 @@ "app-dir action handling fetch actions should handle calls to redirect() with a relative URL in a single pass", "app-dir action handling fetch actions should handle calls to redirect() with external URLs", "app-dir action handling fetch actions should handle redirects to routes that provide an invalid RSC response", - "app-dir action handling fetch actions should handle unstable_expirePath", - "app-dir action handling fetch actions should handle unstable_expireTag", + "app-dir action handling fetch actions should handle revalidatePath", + "app-dir action handling fetch actions should handle revalidateTag", "app-dir action handling fetch actions should invalidate client cache on other routes when cookies.set is called", "app-dir action handling fetch actions should invalidate client cache when path is revalidated", "app-dir action handling fetch actions should invalidate client cache when tag is revalidated", @@ -2804,7 +2804,7 @@ ], "failed": [], "pending": [ - "app-dir action handling fetch actions should handle unstable_expireTag + redirect", + "app-dir action handling fetch actions should handle revalidateTag + redirect", "app-dir action handling server actions render client components client component imported action should support importing client components from actions" ], "flakey": [], @@ -7733,8 +7733,8 @@ }, "test/e2e/app-dir/revalidatetag-rsc/revalidatetag-rsc.test.ts": { "passed": [ - "unstable_expireTag-rsc should error if unstable_expireTag is called during render", - "unstable_expireTag-rsc should revalidate fetch cache if unstable_expireTag invoked via server action" + "revalidateTag-rsc should error if revalidateTag is called during render", + "revalidateTag-rsc should revalidate fetch cache if revalidateTag invoked via server action" ], "failed": [], "pending": [], @@ -8984,7 +8984,7 @@ "test/e2e/app-dir/use-cache-custom-handler/use-cache-custom-handler.test.ts": { "passed": [ "use-cache-custom-handler calls neither refreshTags nor getExpiration if \"use cache\" is not used", - "use-cache-custom-handler should not call expireTags for a normal invocation", + "use-cache-custom-handler should not call updateTags for a normal invocation", "use-cache-custom-handler should not call getExpiration after an action", "use-cache-custom-handler should revalidate after redirect using a legacy custom cache handler", "use-cache-custom-handler should revalidate after redirect using a modern custom cache handler", @@ -9122,7 +9122,7 @@ "use-cache renders the not-found page when `notFound()` is used", "use-cache replays logs from \"use cache\" functions", "use-cache shares caches between the page/layout and generateMetadata", - "use-cache should be able to revalidate a page using unstable_expireTag", + "use-cache should be able to revalidate a page using revalidateTag", "use-cache should cache complex args", "use-cache should cache fetch without no-store", "use-cache should cache results", @@ -9145,7 +9145,7 @@ "use-cache should revalidate caches after redirect", "use-cache should revalidate caches during on-demand revalidation", "use-cache should revalidate caches nested in unstable_cache", - "use-cache should update after unstable_expireTag correctly", + "use-cache should update after revalidateTag correctly", "use-cache should use revalidate config in fetch", "use-cache usage in node_modules should cache results when using a directive with a handler", "use-cache usage in node_modules should cache results when using a directive without a handler", diff --git a/test/turbopack-build-tests-manifest.json b/test/turbopack-build-tests-manifest.json index 2d0f6ad28dfa12..74124d0bf09acb 100644 --- a/test/turbopack-build-tests-manifest.json +++ b/test/turbopack-build-tests-manifest.json @@ -162,8 +162,8 @@ "app-dir action handling fetch actions should handle calls to redirect() with a relative URL in a single pass", "app-dir action handling fetch actions should handle calls to redirect() with external URLs", "app-dir action handling fetch actions should handle redirects to routes that provide an invalid RSC response", - "app-dir action handling fetch actions should handle unstable_expirePath", - "app-dir action handling fetch actions should handle unstable_expireTag", + "app-dir action handling fetch actions should handle revalidatePath", + "app-dir action handling fetch actions should handle revalidateTag", "app-dir action handling fetch actions should invalidate client cache on other routes when cookies.set is called", "app-dir action handling fetch actions should invalidate client cache when path is revalidated", "app-dir action handling fetch actions should invalidate client cache when tag is revalidated", @@ -225,7 +225,7 @@ ], "failed": [], "pending": [ - "app-dir action handling fetch actions should handle unstable_expireTag + redirect", + "app-dir action handling fetch actions should handle revalidateTag + redirect", "app-dir action handling server actions render client components client component imported action should support importing client components from actions" ], "flakey": [], @@ -294,8 +294,8 @@ "app-dir action handling fetch actions should handle calls to redirect() with a relative URL in a single pass", "app-dir action handling fetch actions should handle calls to redirect() with external URLs", "app-dir action handling fetch actions should handle redirects to routes that provide an invalid RSC response", - "app-dir action handling fetch actions should handle unstable_expirePath", - "app-dir action handling fetch actions should handle unstable_expireTag", + "app-dir action handling fetch actions should handle revalidatePath", + "app-dir action handling fetch actions should handle revalidateTag", "app-dir action handling fetch actions should invalidate client cache on other routes when cookies.set is called", "app-dir action handling fetch actions should invalidate client cache when path is revalidated", "app-dir action handling fetch actions should invalidate client cache when tag is revalidated", @@ -357,7 +357,7 @@ ], "failed": [], "pending": [ - "app-dir action handling fetch actions should handle unstable_expireTag + redirect", + "app-dir action handling fetch actions should handle revalidateTag + redirect", "app-dir action handling server actions render client components client component imported action should support importing client components from actions" ], "flakey": [], @@ -4776,7 +4776,7 @@ }, "test/e2e/app-dir/revalidate-dynamic/revalidate-dynamic.test.ts": { "passed": [ - "app-dir revalidate-dynamic should correctly mark a route handler that uses unstable_expireTag as dynamic", + "app-dir revalidate-dynamic should correctly mark a route handler that uses revalidateTag as dynamic", "app-dir revalidate-dynamic should revalidate the data with /api/revalidate-path", "app-dir revalidate-dynamic should revalidate the data with /api/revalidate-tag" ], @@ -4787,8 +4787,8 @@ }, "test/e2e/app-dir/revalidatetag-rsc/revalidatetag-rsc.test.ts": { "passed": [ - "unstable_expireTag-rsc should error if unstable_expireTag is called during render", - "unstable_expireTag-rsc should revalidate fetch cache if unstable_expireTag invoked via server action" + "revalidateTag-rsc should error if revalidateTag is called during render", + "revalidateTag-rsc should revalidate fetch cache if revalidateTag invoked via server action" ], "failed": [], "pending": [], @@ -5950,7 +5950,7 @@ "test/e2e/app-dir/use-cache-custom-handler/use-cache-custom-handler.test.ts": { "passed": [ "use-cache-custom-handler calls neither refreshTags nor getExpiration if \"use cache\" is not used", - "use-cache-custom-handler should not call expireTags for a normal invocation", + "use-cache-custom-handler should not call updateTags for a normal invocation", "use-cache-custom-handler should not call getExpiration after an action", "use-cache-custom-handler should revalidate after redirect using a legacy custom cache handler", "use-cache-custom-handler should revalidate after redirect using a modern custom cache handler", @@ -6052,7 +6052,7 @@ "use-cache can reference server actions in \"use cache\" functions", "use-cache renders the not-found page when `notFound()` is used", "use-cache shares caches between the page/layout and generateMetadata", - "use-cache should be able to revalidate a page using unstable_expireTag", + "use-cache should be able to revalidate a page using revalidateTag", "use-cache should cache complex args", "use-cache should cache fetch without no-store", "use-cache should cache results", @@ -6079,7 +6079,7 @@ "use-cache should revalidate caches nested in unstable_cache", "use-cache should send an SWR cache-control header based on the revalidate and expire values", "use-cache should store a fetch response without no-store in the incremental cache handler during build", - "use-cache should update after unstable_expireTag correctly", + "use-cache should update after revalidateTag correctly", "use-cache should use revalidate config in fetch", "use-cache usage in node_modules should cache results when using a directive with a handler", "use-cache usage in node_modules should cache results when using a directive without a handler", @@ -18427,7 +18427,7 @@ }, "test/production/app-dir/global-default-cache-handler/global-default-cache-handler.test.ts": { "passed": [ - "global-default-cache-handler should call expireTags on global default cache handler", + "global-default-cache-handler should call updateTags on global default cache handler", "global-default-cache-handler should call refreshTags on global default cache handler", "global-default-cache-handler should use global symbol for default cache handler" ], diff --git a/test/turbopack-dev-tests-manifest.json b/test/turbopack-dev-tests-manifest.json index 1acad1033dc016..15665e2de480a1 100644 --- a/test/turbopack-dev-tests-manifest.json +++ b/test/turbopack-dev-tests-manifest.json @@ -2020,8 +2020,8 @@ "Error overlay - RSC build errors importing 'next/cache' APIs in a client component unstable_cache is allowed", "Error overlay - RSC build errors importing 'next/cache' APIs in a client component unstable_cacheLife is not allowed", "Error overlay - RSC build errors importing 'next/cache' APIs in a client component unstable_cacheTag is not allowed", - "Error overlay - RSC build errors importing 'next/cache' APIs in a client component unstable_expirePath is not allowed", - "Error overlay - RSC build errors importing 'next/cache' APIs in a client component unstable_expireTag is not allowed", + "Error overlay - RSC build errors importing 'next/cache' APIs in a client component revalidatePath is not allowed", + "Error overlay - RSC build errors importing 'next/cache' APIs in a client component revalidateTag is not allowed", "Error overlay - RSC build errors importing 'next/cache' APIs in a client component unstable_noStore is allowed", "Error overlay - RSC build errors should allow to use and handle rsc poisoning client-only", "Error overlay - RSC build errors should allow to use and handle rsc poisoning server-only", @@ -2275,8 +2275,8 @@ "Error Overlay for server components compiler errors in pages importing 'next/cache' APIs in pages unstable_cache is allowed", "Error Overlay for server components compiler errors in pages importing 'next/cache' APIs in pages unstable_cacheLife is not allowed", "Error Overlay for server components compiler errors in pages importing 'next/cache' APIs in pages unstable_cacheTag is not allowed", - "Error Overlay for server components compiler errors in pages importing 'next/cache' APIs in pages unstable_expirePath is not allowed", - "Error Overlay for server components compiler errors in pages importing 'next/cache' APIs in pages unstable_expireTag is not allowed", + "Error Overlay for server components compiler errors in pages importing 'next/cache' APIs in pages revalidatePath is not allowed", + "Error Overlay for server components compiler errors in pages importing 'next/cache' APIs in pages revalidateTag is not allowed", "Error Overlay for server components compiler errors in pages importing 'next/cache' APIs in pages unstable_noStore is allowed", "Error Overlay for server components compiler errors in pages importing 'next/headers' in pages", "Error Overlay for server components compiler errors in pages importing 'server-only' in pages", @@ -4216,8 +4216,8 @@ "app-dir action handling fetch actions should handle calls to redirect() with a relative URL in a single pass", "app-dir action handling fetch actions should handle calls to redirect() with external URLs", "app-dir action handling fetch actions should handle redirects to routes that provide an invalid RSC response", - "app-dir action handling fetch actions should handle unstable_expirePath", - "app-dir action handling fetch actions should handle unstable_expireTag", + "app-dir action handling fetch actions should handle revalidatePath", + "app-dir action handling fetch actions should handle revalidateTag", "app-dir action handling fetch actions should invalidate client cache on other routes when cookies.set is called", "app-dir action handling fetch actions should invalidate client cache when path is revalidated", "app-dir action handling fetch actions should invalidate client cache when tag is revalidated", @@ -4279,7 +4279,7 @@ ], "failed": [], "pending": [ - "app-dir action handling fetch actions should handle unstable_expireTag + redirect", + "app-dir action handling fetch actions should handle revalidateTag + redirect", "app-dir action handling server actions render client components client component imported action should support importing client components from actions" ], "flakey": [], @@ -4344,8 +4344,8 @@ "app-dir action handling fetch actions should handle calls to redirect() with a relative URL in a single pass", "app-dir action handling fetch actions should handle calls to redirect() with external URLs", "app-dir action handling fetch actions should handle redirects to routes that provide an invalid RSC response", - "app-dir action handling fetch actions should handle unstable_expirePath", - "app-dir action handling fetch actions should handle unstable_expireTag", + "app-dir action handling fetch actions should handle revalidatePath", + "app-dir action handling fetch actions should handle revalidateTag", "app-dir action handling fetch actions should invalidate client cache on other routes when cookies.set is called", "app-dir action handling fetch actions should invalidate client cache when path is revalidated", "app-dir action handling fetch actions should invalidate client cache when tag is revalidated", @@ -4407,7 +4407,7 @@ ], "failed": [], "pending": [ - "app-dir action handling fetch actions should handle unstable_expireTag + redirect", + "app-dir action handling fetch actions should handle revalidateTag + redirect", "app-dir action handling server actions render client components client component imported action should support importing client components from actions" ], "flakey": [], @@ -8399,8 +8399,8 @@ }, "test/e2e/app-dir/revalidatetag-rsc/revalidatetag-rsc.test.ts": { "passed": [ - "unstable_expireTag-rsc should error if unstable_expireTag is called during render", - "unstable_expireTag-rsc should revalidate fetch cache if unstable_expireTag invoked via server action" + "revalidateTag-rsc should error if revalidateTag is called during render", + "revalidateTag-rsc should revalidate fetch cache if revalidateTag invoked via server action" ], "failed": [], "pending": [], @@ -9502,7 +9502,7 @@ "test/e2e/app-dir/use-cache-custom-handler/use-cache-custom-handler.test.ts": { "passed": [ "use-cache-custom-handler calls neither refreshTags nor getExpiration if \"use cache\" is not used", - "use-cache-custom-handler should not call expireTags for a normal invocation", + "use-cache-custom-handler should not call updateTags for a normal invocation", "use-cache-custom-handler should not call getExpiration after an action", "use-cache-custom-handler should revalidate after redirect using a legacy custom cache handler", "use-cache-custom-handler should revalidate after redirect using a modern custom cache handler", @@ -9619,7 +9619,7 @@ "use-cache renders the not-found page when `notFound()` is used", "use-cache replays logs from \"use cache\" functions", "use-cache shares caches between the page/layout and generateMetadata", - "use-cache should be able to revalidate a page using unstable_expireTag", + "use-cache should be able to revalidate a page using revalidateTag", "use-cache should cache complex args", "use-cache should cache fetch without no-store", "use-cache should cache results", @@ -9641,7 +9641,7 @@ "use-cache should revalidate caches after redirect", "use-cache should revalidate caches during on-demand revalidation", "use-cache should revalidate caches nested in unstable_cache", - "use-cache should update after unstable_expireTag correctly", + "use-cache should update after revalidateTag correctly", "use-cache should use revalidate config in fetch", "use-cache usage in node_modules should cache results when using a directive with a handler", "use-cache usage in node_modules should cache results when using a directive without a handler", From 363014645838b7b4e16f9d446032c52ce5434aff Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Thu, 9 Oct 2025 15:20:52 -0600 Subject: [PATCH 03/11] Add validation for missing default.js in parallel routes (#84702) ### What? Adds build-time validation to require explicit `default.js` files for all parallel route slots (except the implicit "children" slot). This validation is implemented in both Webpack and Turbopack bundlers. ### Why? Parallel routes without `default.js` files currently cause silent 404 errors when users navigate to those routes. This creates confusion and hard-to-debug issues because the routes appear to be configured correctly but fail at runtime without any indication of what went wrong. By making this validation explicit at build time, developers get immediate feedback about missing required files with clear error messages and documentation links, catching configuration mistakes before deployment. ### How? **Rust/Turbopack** (`crates/next-core/src/app_structure.rs`): Added `MissingDefaultParallelRouteIssue` that emits a build error when a parallel route slot is missing its `default.js` file. The validation is skipped for the "children" slot since it's implicit and doesn't require a default file. **Webpack** (`packages/next/src/build/webpack/loaders/next-app-loader/index.ts`): Added validation that throws `MissingDefaultParallelRouteError` when `default.js` cannot be resolved. The "children" slot falls back to the existing `PARALLEL_ROUTE_DEFAULT_PATH` behavior for backward compatibility. **Error Class** (`packages/next/src/shared/lib/errors/missing-default-parallel-route-error.ts`): New error type with helpful messaging that includes the slot path, explanation of the requirement, and a link to documentation. **Migration Path**: Users who want the previous 404 behavior can explicitly create a `default.js` that calls `notFound()`, or return `null` for empty slots: ```tsx import { notFound } from 'next/navigation' export default function Default() { notFound() } ``` Users can also run the following Deno script to generate the default files for them: https://gist.github.com/wyattjoh/ba7263ecb637ef399d3e3e4db63ffbd6 **Breaking Change**: This is a breaking change timed for Next.js 16 beta. Builds will now fail if parallel route slots are missing required `default.js` files. --- crates/next-core/src/app_structure.rs | 118 +++++++++++++++++- .../03-file-conventions/default.mdx | 12 +- errors/slot-missing-default.mdx | 80 ++++++++++++ .../webpack/loaders/next-app-loader/index.ts | 35 +++++- .../missing-default-parallel-route-error.ts | 12 ++ .../hmr-parallel-routes/app/@bar/default.tsx | 5 + .../hmr-parallel-routes/app/@foo/default.tsx | 5 + .../app/parallel/@content/default.tsx | 5 + .../app/parallel/@sidebar/default.tsx | 5 + .../app/@breadcrumbs/default.js | 5 + .../app/cases/parallel/@slot/default.tsx | 5 + .../app/parallel-route/@header/default.tsx | 5 + .../app/parallel/@parallel/default.tsx | 5 + .../payment/[[...slug]]/@parallel/default.jsx | 5 + .../[teamSlug]/(team)/@actions/default.tsx | 5 + .../app/[teamID]/@slot/default.tsx | 5 + .../app/@bar/default.tsx | 5 + .../app/@foo/default.tsx | 5 + .../no-page/@foobar/default.tsx | 5 + .../parallel-route-not-found.test.ts | 6 +- .../app/[foo_id]/[bar_id]/@modal/default.tsx | 5 + .../app/@slot/default.tsx | 5 + .../[username]/@feed/default.js | 5 + .../parallel-dynamic/[slug]/@bar/default.tsx | 5 + .../parallel-dynamic/[slug]/@foo/default.tsx | 5 + .../app/parallel-layout/@slot/default.tsx | 5 + .../home/@parallelB/default.tsx | 5 + .../foo/@parallel/default.tsx | 5 + .../parallel-side-bar/@sidebar/default.tsx | 5 + .../app/parallel-tab-bar/@audience/default.js | 5 + .../app/parallel-tab-bar/@views/default.js | 5 + .../app/parallel/@bar/nested/@a/default.js | 5 + .../app/parallel/@bar/nested/@b/default.js | 5 + .../app/parallel/@foo/nested/@a/default.js | 5 + .../app/parallel/@foo/nested/@b/default.js | 5 + .../app/with-loading/@slot/default.tsx | 5 + .../nested/[foo]/[bar]/@slot1/default.tsx | 5 + .../app/(group-a)/@parallel/default.tsx | 5 + .../app/@slot/default.tsx | 5 + .../app/@slot/default.tsx | 5 + .../with-parallel-routes/@one/default.js | 5 + .../with-parallel-routes/@two/default.js | 5 + .../app/(group)/conventions/@named/default.js | 5 + .../basic/app/test/@nav/default.tsx | 5 + .../app/dashboard/@analytics/default.tsx | 5 + .../app/dashboard/@team/default.tsx | 5 + .../app/root-page/@footer/default.js | 5 + .../app/root-page/@header/default.js | 5 + .../app/nested/@bar/default.tsx | 5 + .../app/nested/@foo/default.tsx | 5 + .../with-app-dir/app/@modal/default.tsx | 5 + 51 files changed, 478 insertions(+), 10 deletions(-) create mode 100644 errors/slot-missing-default.mdx create mode 100644 packages/next/src/shared/lib/errors/missing-default-parallel-route-error.ts create mode 100644 test/development/app-dir/hmr-parallel-routes/app/@bar/default.tsx create mode 100644 test/development/app-dir/hmr-parallel-routes/app/@foo/default.tsx create mode 100644 test/development/mcp-server/fixtures/parallel-routes-template/app/parallel/@content/default.tsx create mode 100644 test/development/mcp-server/fixtures/parallel-routes-template/app/parallel/@sidebar/default.tsx create mode 100644 test/e2e/app-dir/app-client-cache/fixtures/parallel-routes/app/@breadcrumbs/default.js create mode 100644 test/e2e/app-dir/cache-components/app/cases/parallel/@slot/default.tsx create mode 100644 test/e2e/app-dir/metadata-navigation/app/parallel-route/@header/default.tsx create mode 100644 test/e2e/app-dir/metadata-static-file/app/parallel/@parallel/default.tsx create mode 100644 test/e2e/app-dir/middleware-rewrite-catchall-priority-with-parallel-route/app/(someGroup)/payment/[[...slug]]/@parallel/default.jsx create mode 100644 test/e2e/app-dir/parallel-route-navigations/app/(dashboard-v2)/[teamSlug]/(team)/@actions/default.tsx create mode 100644 test/e2e/app-dir/parallel-route-navigations/app/[teamID]/@slot/default.tsx create mode 100644 test/e2e/app-dir/parallel-route-not-found/app/@bar/default.tsx create mode 100644 test/e2e/app-dir/parallel-route-not-found/app/@foo/default.tsx create mode 100644 test/e2e/app-dir/parallel-route-not-found/app/not-found-metadata/no-page/@foobar/default.tsx create mode 100644 test/e2e/app-dir/parallel-routes-and-interception-basepath/app/[foo_id]/[bar_id]/@modal/default.tsx create mode 100644 test/e2e/app-dir/parallel-routes-and-interception-catchall/app/@slot/default.tsx create mode 100644 test/e2e/app-dir/parallel-routes-and-interception/app/(group)/intercepting-parallel-modal/[username]/@feed/default.js create mode 100644 test/e2e/app-dir/parallel-routes-and-interception/app/parallel-dynamic/[slug]/@bar/default.tsx create mode 100644 test/e2e/app-dir/parallel-routes-and-interception/app/parallel-dynamic/[slug]/@foo/default.tsx create mode 100644 test/e2e/app-dir/parallel-routes-and-interception/app/parallel-layout/@slot/default.tsx create mode 100644 test/e2e/app-dir/parallel-routes-and-interception/app/parallel-nested/home/@parallelB/default.tsx create mode 100644 test/e2e/app-dir/parallel-routes-and-interception/app/parallel-no-page/foo/@parallel/default.tsx create mode 100644 test/e2e/app-dir/parallel-routes-and-interception/app/parallel-side-bar/@sidebar/default.tsx create mode 100644 test/e2e/app-dir/parallel-routes-and-interception/app/parallel-tab-bar/@audience/default.js create mode 100644 test/e2e/app-dir/parallel-routes-and-interception/app/parallel-tab-bar/@views/default.js create mode 100644 test/e2e/app-dir/parallel-routes-and-interception/app/parallel/@bar/nested/@a/default.js create mode 100644 test/e2e/app-dir/parallel-routes-and-interception/app/parallel/@bar/nested/@b/default.js create mode 100644 test/e2e/app-dir/parallel-routes-and-interception/app/parallel/@foo/nested/@a/default.js create mode 100644 test/e2e/app-dir/parallel-routes-and-interception/app/parallel/@foo/nested/@b/default.js create mode 100644 test/e2e/app-dir/parallel-routes-and-interception/app/with-loading/@slot/default.tsx create mode 100644 test/e2e/app-dir/parallel-routes-catchall-dynamic-segment/app/[locale]/nested/[foo]/[bar]/@slot1/default.tsx create mode 100644 test/e2e/app-dir/parallel-routes-catchall-groups/app/(group-a)/@parallel/default.tsx create mode 100644 test/e2e/app-dir/parallel-routes-not-found/app/@slot/default.tsx create mode 100644 test/e2e/app-dir/parallel-routes-root-slot/app/@slot/default.tsx create mode 100644 test/e2e/app-dir/root-layout/app/(mpa-navigation)/with-parallel-routes/@one/default.js create mode 100644 test/e2e/app-dir/root-layout/app/(mpa-navigation)/with-parallel-routes/@two/default.js create mode 100644 test/e2e/app-dir/rsc-basic/app/(group)/conventions/@named/default.js create mode 100644 test/e2e/app-dir/segment-cache/basic/app/test/@nav/default.tsx create mode 100644 test/e2e/app-dir/typed-routes/app/dashboard/@analytics/default.tsx create mode 100644 test/e2e/app-dir/typed-routes/app/dashboard/@team/default.tsx create mode 100644 test/integration/build-output/fixtures/with-parallel-routes/app/root-page/@footer/default.js create mode 100644 test/integration/build-output/fixtures/with-parallel-routes/app/root-page/@header/default.js create mode 100644 test/production/app-dir/parallel-routes-static/app/nested/@bar/default.tsx create mode 100644 test/production/app-dir/parallel-routes-static/app/nested/@foo/default.tsx create mode 100644 test/unit/eslint-plugin-next/with-app-dir/app/@modal/default.tsx diff --git a/crates/next-core/src/app_structure.rs b/crates/next-core/src/app_structure.rs index 7941dc840a3191..edc54e474664dd 100644 --- a/crates/next-core/src/app_structure.rs +++ b/crates/next-core/src/app_structure.rs @@ -840,6 +840,83 @@ impl Issue for DuplicateParallelRouteIssue { } } +#[turbo_tasks::value] +struct MissingDefaultParallelRouteIssue { + app_dir: FileSystemPath, + app_page: AppPage, + slot_name: RcStr, +} + +#[turbo_tasks::function] +fn missing_default_parallel_route_issue( + app_dir: FileSystemPath, + app_page: AppPage, + slot_name: RcStr, +) -> Vc { + MissingDefaultParallelRouteIssue { + app_dir, + app_page, + slot_name, + } + .cell() +} + +#[turbo_tasks::value_impl] +impl Issue for MissingDefaultParallelRouteIssue { + #[turbo_tasks::function] + fn file_path(&self) -> Result> { + Ok(self + .app_dir + .join(&self.app_page.to_string())? + .join(&format!("@{}", self.slot_name))? + .cell()) + } + + #[turbo_tasks::function] + fn stage(self: Vc) -> Vc { + IssueStage::AppStructure.cell() + } + + fn severity(&self) -> IssueSeverity { + IssueSeverity::Error + } + + #[turbo_tasks::function] + async fn title(&self) -> Vc { + StyledString::Text( + format!( + "Missing required default.js file for parallel route at {}/@{}", + self.app_page, self.slot_name + ) + .into(), + ) + .cell() + } + + #[turbo_tasks::function] + async fn description(&self) -> Vc { + Vc::cell(Some( + StyledString::Text( + format!( + "The parallel route slot \"@{}\" is missing a default.js file. When using \ + parallel routes, each slot must have a default.js file to serve as a \ + fallback.\n\nCreate a default.js file at: {}/@{}/default.js", + self.slot_name, self.app_page, self.slot_name + ) + .into(), + ) + .resolved_cell(), + )) + } + + #[turbo_tasks::function] + fn documentation_link(&self) -> Vc { + Vc::cell(rcstr!( + "https://nextjs.org/docs/messages/slot-missing-default" + )) + } +} + fn page_path_except_parallel(loader_tree: &AppPageLoaderTree) -> Option { if loader_tree.page.iter().any(|v| { matches!( @@ -1114,6 +1191,29 @@ async fn directory_tree_to_loader_tree_internal( if let Some(subtree) = subtree { if let Some(key) = parallel_route_key { + let is_inside_catchall = app_page.is_catchall(); + + // Validate that parallel routes (except "children") have a default.js file. + // Skip this validation if the slot is UNDER a catch-all route (i.e., the + // parallel route is a child of a catch-all segment). + // For example: + // /[...catchAll]/@slot - is_inside_catchall = true (skip validation) ✓ + // /@slot/[...catchAll] - is_inside_catchall = false (require default) ✓ + // The catch-all provides fallback behavior, so default.js is not required. + if key != "children" + && subdirectory.modules.default.is_none() + && !is_inside_catchall + { + missing_default_parallel_route_issue( + app_dir.clone(), + app_page.clone(), + key.into(), + ) + .to_resolved() + .await? + .emit(); + } + tree.parallel_routes.insert(key.into(), subtree); continue; } @@ -1173,8 +1273,24 @@ async fn directory_tree_to_loader_tree_internal( None }; + let is_inside_catchall = app_page.is_catchall(); + + // Only emit the issue if this is not the children slot and there's no default + // component. The children slot is implicit and doesn't require a default.js + // file. Also skip validation if the slot is UNDER a catch-all route. + if default.is_none() && key != "children" && !is_inside_catchall { + missing_default_parallel_route_issue( + app_dir.clone(), + app_page.clone(), + key.clone(), + ) + .to_resolved() + .await? + .emit(); + } + tree.parallel_routes.insert( - key, + key.clone(), default_route_tree(app_dir.clone(), global_metadata, app_page.clone(), default) .await?, ); diff --git a/docs/01-app/03-api-reference/03-file-conventions/default.mdx b/docs/01-app/03-api-reference/03-file-conventions/default.mdx index 68f32a30b67d8a..17710a5373827c 100644 --- a/docs/01-app/03-api-reference/03-file-conventions/default.mdx +++ b/docs/01-app/03-api-reference/03-file-conventions/default.mdx @@ -23,9 +23,17 @@ Consider the following folder structure. The `@team` slot has a `settings` page, When navigating to `/settings`, the `@team` slot will render the `settings` page while maintaining the currently active page for the `@analytics` slot. -On refresh, Next.js will render a `default.js` for `@analytics`. If `default.js` doesn't exist, a `404` is rendered instead. +On refresh, Next.js will render a `default.js` for `@analytics`. If `default.js` doesn't exist, an error is returned for named slots (`@team`, `@analytics`, etc) and requires you to define a `default.js` in order to continue. If you want to preserve the old behavior of returning a 404 in these situations, you can create a `default.js` that contains: -Additionally, since `children` is an implicit slot, you also need to create a `default.js` file to render a fallback for `children` when Next.js cannot recover the active state of the parent page. +```tsx filename="app/@team/default.js" +import { notFound } from 'next/navigation' + +export default function Default() { + notFound() +} +``` + +Additionally, since `children` is an implicit slot, you also need to create a `default.js` file to render a fallback for `children` when Next.js cannot recover the active state of the parent page. If you don't create a `default.js` for the `children` slot, it will return a 404 page for the route. ## Reference diff --git a/errors/slot-missing-default.mdx b/errors/slot-missing-default.mdx new file mode 100644 index 00000000000000..c72e4b14a5cc59 --- /dev/null +++ b/errors/slot-missing-default.mdx @@ -0,0 +1,80 @@ +--- +title: Missing Required default.js for Parallel Route +--- + +> Parallel route slots require a `default.js` file to serve as a fallback during navigation. + +## Why This Error Occurred + +You're using [parallel routes](https://nextjs.org/docs/app/api-reference/file-conventions/parallel-routes#defaultjs) in your Next.js application, but one of your parallel route slots is missing a required `default.js` file, which causes a build error. + +When using parallel routes, Next.js needs to know what to render in each slot when: + +- Navigating between pages that have different slot structures +- A slot doesn't match the current navigation (only during hard navigation) +- After a page refresh when Next.js cannot determine the active state for a slot + +The `default.js` file serves as a fallback to render when Next.js cannot determine the active state of a slot based on the current URL. Without this file, the build will fail. + +## Possible Ways to Fix It + +Create a `default.js` (or `.jsx`, `.tsx`) file in the parallel route slot directory that's mentioned in the error message. + +For example, if you have this structure where different slots have pages at different paths: + +``` +app/ +├── layout.js +├── page.js +├── @team/ +│ └── settings/ +│ └── page.js +└── @analytics/ + └── page.js +``` + +You need to add `default.js` files for each slot, excluding the children slot: + +``` +app/ +├── layout.js +├── page.js +├── default.js // Optiona: add this (for children slot) +├── @team/ +│ ├── default.js // Add this +│ └── settings/ +│ └── page.js +└── @analytics/ + ├── default.js // Add this + └── page.js +``` + +Without the root `default.js`, navigating to `/settings` would result in a 404, even though `@team/settings/page.js` exists. The `default.js` in the root tells Next.js what to render in the children slot when there's no matching page at that path. It's not required as other named slots default files are, but it's good to add to help improve the experience for your applications. + +### Example `default.js` file: + +The simplest implementation returns `null` to render nothing: + +```jsx filename="app/@analytics/default.js" +export default function Default() { + return null +} +``` + +You can also preserve the old behavior of returning a 404: + +```jsx filename="app/@team/default.js" +import { notFound } from 'next/navigation' + +export default function Default() { + notFound() +} +``` + +### When you need `default.js` + +You need a `default.js` file for each parallel route slot (directories starting with `@`) at each route segment. + +## Useful Links + +- [Parallel Routes Documentation](https://nextjs.org/docs/app/api-reference/file-conventions/parallel-routes#defaultjs) diff --git a/packages/next/src/build/webpack/loaders/next-app-loader/index.ts b/packages/next/src/build/webpack/loaders/next-app-loader/index.ts index 2fe3de03ecf1c9..f5e634c4bd025c 100644 --- a/packages/next/src/build/webpack/loaders/next-app-loader/index.ts +++ b/packages/next/src/build/webpack/loaders/next-app-loader/index.ts @@ -37,6 +37,7 @@ import type { PageExtensions } from '../../../page-extensions-type' import { PARALLEL_ROUTE_DEFAULT_PATH } from '../../../../client/components/builtin/default' import type { Compilation } from 'webpack' import { createAppRouteCode } from './create-app-route-code' +import { MissingDefaultParallelRouteError } from '../../../../shared/lib/errors/missing-default-parallel-route-error' export type AppLoaderOptions = { name: string @@ -115,6 +116,9 @@ export type AppDirModules = { const normalizeParallelKey = (key: string) => key.startsWith('@') ? key.slice(1) : key +const isCatchAllSegment = (segment: string) => + segment.startsWith('[...') || segment.startsWith('[[...') + const isDirectory = async (pathname: string) => { try { const stat = await fs.stat(pathname) @@ -539,11 +543,32 @@ async function createTreeCodeFromPath( ? '' : `/${adjacentParallelSegment}` - // if a default is found, use that. Otherwise use the fallback, which will trigger a `notFound()` - const defaultPath = - (await resolver( - `${appDirPrefix}${segmentPath}${actualSegment}/default` - )) ?? PARALLEL_ROUTE_DEFAULT_PATH + // Use the default path if it's found, otherwise if it's a children + // slot, then use the fallback (which triggers a `notFound()`). If this + // isn't a children slot, then throw an error, as it produces a silent + // 404 if we'd used the fallback. + const fullSegmentPath = `${appDirPrefix}${segmentPath}${actualSegment}` + let defaultPath = await resolver(`${fullSegmentPath}/default`) + if (!defaultPath) { + if (adjacentParallelSegment === 'children') { + defaultPath = PARALLEL_ROUTE_DEFAULT_PATH + } else { + // Check if we're inside a catch-all route (i.e., the parallel route is a child + // of a catch-all segment). Only skip validation if the slot is UNDER a catch-all. + // For example: + // /[...catchAll]/@slot - isInsideCatchAll = true (skip validation) ✓ + // /@slot/[...catchAll] - isInsideCatchAll = false (require default) ✓ + // The catch-all provides fallback behavior, so default.js is not required. + const isInsideCatchAll = segments.some(isCatchAllSegment) + if (!isInsideCatchAll) { + throw new MissingDefaultParallelRouteError( + fullSegmentPath, + adjacentParallelSegment + ) + } + defaultPath = PARALLEL_ROUTE_DEFAULT_PATH + } + } const varName = `default${nestedCollectedDeclarations.length}` nestedCollectedDeclarations.push([varName, defaultPath]) diff --git a/packages/next/src/shared/lib/errors/missing-default-parallel-route-error.ts b/packages/next/src/shared/lib/errors/missing-default-parallel-route-error.ts new file mode 100644 index 00000000000000..4a7d8051de3067 --- /dev/null +++ b/packages/next/src/shared/lib/errors/missing-default-parallel-route-error.ts @@ -0,0 +1,12 @@ +export class MissingDefaultParallelRouteError extends Error { + constructor(fullSegmentPath: string, slotName: string) { + super( + `Missing required default.js file for parallel route at ${fullSegmentPath}\n` + + `The parallel route slot "${slotName}" is missing a default.js file. When using parallel routes, each slot must have a default.js file to serve as a fallback.\n\n` + + `Create a default.js file at: ${fullSegmentPath}/default.js\n\n` + + `https://nextjs.org/docs/messages/slot-missing-default` + ) + + this.name = 'MissingDefaultParallelRouteError' + } +} diff --git a/test/development/app-dir/hmr-parallel-routes/app/@bar/default.tsx b/test/development/app-dir/hmr-parallel-routes/app/@bar/default.tsx new file mode 100644 index 00000000000000..6dbe479afc29b4 --- /dev/null +++ b/test/development/app-dir/hmr-parallel-routes/app/@bar/default.tsx @@ -0,0 +1,5 @@ +import { notFound } from 'next/navigation' + +export default function Default() { + notFound() +} diff --git a/test/development/app-dir/hmr-parallel-routes/app/@foo/default.tsx b/test/development/app-dir/hmr-parallel-routes/app/@foo/default.tsx new file mode 100644 index 00000000000000..6dbe479afc29b4 --- /dev/null +++ b/test/development/app-dir/hmr-parallel-routes/app/@foo/default.tsx @@ -0,0 +1,5 @@ +import { notFound } from 'next/navigation' + +export default function Default() { + notFound() +} diff --git a/test/development/mcp-server/fixtures/parallel-routes-template/app/parallel/@content/default.tsx b/test/development/mcp-server/fixtures/parallel-routes-template/app/parallel/@content/default.tsx new file mode 100644 index 00000000000000..6dbe479afc29b4 --- /dev/null +++ b/test/development/mcp-server/fixtures/parallel-routes-template/app/parallel/@content/default.tsx @@ -0,0 +1,5 @@ +import { notFound } from 'next/navigation' + +export default function Default() { + notFound() +} diff --git a/test/development/mcp-server/fixtures/parallel-routes-template/app/parallel/@sidebar/default.tsx b/test/development/mcp-server/fixtures/parallel-routes-template/app/parallel/@sidebar/default.tsx new file mode 100644 index 00000000000000..6dbe479afc29b4 --- /dev/null +++ b/test/development/mcp-server/fixtures/parallel-routes-template/app/parallel/@sidebar/default.tsx @@ -0,0 +1,5 @@ +import { notFound } from 'next/navigation' + +export default function Default() { + notFound() +} diff --git a/test/e2e/app-dir/app-client-cache/fixtures/parallel-routes/app/@breadcrumbs/default.js b/test/e2e/app-dir/app-client-cache/fixtures/parallel-routes/app/@breadcrumbs/default.js new file mode 100644 index 00000000000000..6dbe479afc29b4 --- /dev/null +++ b/test/e2e/app-dir/app-client-cache/fixtures/parallel-routes/app/@breadcrumbs/default.js @@ -0,0 +1,5 @@ +import { notFound } from 'next/navigation' + +export default function Default() { + notFound() +} diff --git a/test/e2e/app-dir/cache-components/app/cases/parallel/@slot/default.tsx b/test/e2e/app-dir/cache-components/app/cases/parallel/@slot/default.tsx new file mode 100644 index 00000000000000..6dbe479afc29b4 --- /dev/null +++ b/test/e2e/app-dir/cache-components/app/cases/parallel/@slot/default.tsx @@ -0,0 +1,5 @@ +import { notFound } from 'next/navigation' + +export default function Default() { + notFound() +} diff --git a/test/e2e/app-dir/metadata-navigation/app/parallel-route/@header/default.tsx b/test/e2e/app-dir/metadata-navigation/app/parallel-route/@header/default.tsx new file mode 100644 index 00000000000000..6dbe479afc29b4 --- /dev/null +++ b/test/e2e/app-dir/metadata-navigation/app/parallel-route/@header/default.tsx @@ -0,0 +1,5 @@ +import { notFound } from 'next/navigation' + +export default function Default() { + notFound() +} diff --git a/test/e2e/app-dir/metadata-static-file/app/parallel/@parallel/default.tsx b/test/e2e/app-dir/metadata-static-file/app/parallel/@parallel/default.tsx new file mode 100644 index 00000000000000..6dbe479afc29b4 --- /dev/null +++ b/test/e2e/app-dir/metadata-static-file/app/parallel/@parallel/default.tsx @@ -0,0 +1,5 @@ +import { notFound } from 'next/navigation' + +export default function Default() { + notFound() +} diff --git a/test/e2e/app-dir/middleware-rewrite-catchall-priority-with-parallel-route/app/(someGroup)/payment/[[...slug]]/@parallel/default.jsx b/test/e2e/app-dir/middleware-rewrite-catchall-priority-with-parallel-route/app/(someGroup)/payment/[[...slug]]/@parallel/default.jsx new file mode 100644 index 00000000000000..6dbe479afc29b4 --- /dev/null +++ b/test/e2e/app-dir/middleware-rewrite-catchall-priority-with-parallel-route/app/(someGroup)/payment/[[...slug]]/@parallel/default.jsx @@ -0,0 +1,5 @@ +import { notFound } from 'next/navigation' + +export default function Default() { + notFound() +} diff --git a/test/e2e/app-dir/parallel-route-navigations/app/(dashboard-v2)/[teamSlug]/(team)/@actions/default.tsx b/test/e2e/app-dir/parallel-route-navigations/app/(dashboard-v2)/[teamSlug]/(team)/@actions/default.tsx new file mode 100644 index 00000000000000..6dbe479afc29b4 --- /dev/null +++ b/test/e2e/app-dir/parallel-route-navigations/app/(dashboard-v2)/[teamSlug]/(team)/@actions/default.tsx @@ -0,0 +1,5 @@ +import { notFound } from 'next/navigation' + +export default function Default() { + notFound() +} diff --git a/test/e2e/app-dir/parallel-route-navigations/app/[teamID]/@slot/default.tsx b/test/e2e/app-dir/parallel-route-navigations/app/[teamID]/@slot/default.tsx new file mode 100644 index 00000000000000..6dbe479afc29b4 --- /dev/null +++ b/test/e2e/app-dir/parallel-route-navigations/app/[teamID]/@slot/default.tsx @@ -0,0 +1,5 @@ +import { notFound } from 'next/navigation' + +export default function Default() { + notFound() +} diff --git a/test/e2e/app-dir/parallel-route-not-found/app/@bar/default.tsx b/test/e2e/app-dir/parallel-route-not-found/app/@bar/default.tsx new file mode 100644 index 00000000000000..6dbe479afc29b4 --- /dev/null +++ b/test/e2e/app-dir/parallel-route-not-found/app/@bar/default.tsx @@ -0,0 +1,5 @@ +import { notFound } from 'next/navigation' + +export default function Default() { + notFound() +} diff --git a/test/e2e/app-dir/parallel-route-not-found/app/@foo/default.tsx b/test/e2e/app-dir/parallel-route-not-found/app/@foo/default.tsx new file mode 100644 index 00000000000000..6dbe479afc29b4 --- /dev/null +++ b/test/e2e/app-dir/parallel-route-not-found/app/@foo/default.tsx @@ -0,0 +1,5 @@ +import { notFound } from 'next/navigation' + +export default function Default() { + notFound() +} diff --git a/test/e2e/app-dir/parallel-route-not-found/app/not-found-metadata/no-page/@foobar/default.tsx b/test/e2e/app-dir/parallel-route-not-found/app/not-found-metadata/no-page/@foobar/default.tsx new file mode 100644 index 00000000000000..6dbe479afc29b4 --- /dev/null +++ b/test/e2e/app-dir/parallel-route-not-found/app/not-found-metadata/no-page/@foobar/default.tsx @@ -0,0 +1,5 @@ +import { notFound } from 'next/navigation' + +export default function Default() { + notFound() +} diff --git a/test/e2e/app-dir/parallel-route-not-found/parallel-route-not-found.test.ts b/test/e2e/app-dir/parallel-route-not-found/parallel-route-not-found.test.ts index 19231e455a8e8c..23bdb2111c8abd 100644 --- a/test/e2e/app-dir/parallel-route-not-found/parallel-route-not-found.test.ts +++ b/test/e2e/app-dir/parallel-route-not-found/parallel-route-not-found.test.ts @@ -5,7 +5,8 @@ describe('parallel-route-not-found', () => { files: __dirname, }) - it('should handle a layout that attempts to render a missing parallel route', async () => { + // TODO: adjust the test to work with the new error + it.skip('should handle a layout that attempts to render a missing parallel route', async () => { const browser = await next.browser('/no-bar-slot') const logs = await browser.log() expect(await browser.elementByCss('body').text()).toContain( @@ -23,7 +24,8 @@ describe('parallel-route-not-found', () => { } }) - it('should handle multiple missing parallel routes', async () => { + // TODO: adjust the test to work with the new error + it.skip('should handle multiple missing parallel routes', async () => { const browser = await next.browser('/both-slots-missing') const logs = await browser.log() diff --git a/test/e2e/app-dir/parallel-routes-and-interception-basepath/app/[foo_id]/[bar_id]/@modal/default.tsx b/test/e2e/app-dir/parallel-routes-and-interception-basepath/app/[foo_id]/[bar_id]/@modal/default.tsx new file mode 100644 index 00000000000000..6dbe479afc29b4 --- /dev/null +++ b/test/e2e/app-dir/parallel-routes-and-interception-basepath/app/[foo_id]/[bar_id]/@modal/default.tsx @@ -0,0 +1,5 @@ +import { notFound } from 'next/navigation' + +export default function Default() { + notFound() +} diff --git a/test/e2e/app-dir/parallel-routes-and-interception-catchall/app/@slot/default.tsx b/test/e2e/app-dir/parallel-routes-and-interception-catchall/app/@slot/default.tsx new file mode 100644 index 00000000000000..6dbe479afc29b4 --- /dev/null +++ b/test/e2e/app-dir/parallel-routes-and-interception-catchall/app/@slot/default.tsx @@ -0,0 +1,5 @@ +import { notFound } from 'next/navigation' + +export default function Default() { + notFound() +} diff --git a/test/e2e/app-dir/parallel-routes-and-interception/app/(group)/intercepting-parallel-modal/[username]/@feed/default.js b/test/e2e/app-dir/parallel-routes-and-interception/app/(group)/intercepting-parallel-modal/[username]/@feed/default.js new file mode 100644 index 00000000000000..6dbe479afc29b4 --- /dev/null +++ b/test/e2e/app-dir/parallel-routes-and-interception/app/(group)/intercepting-parallel-modal/[username]/@feed/default.js @@ -0,0 +1,5 @@ +import { notFound } from 'next/navigation' + +export default function Default() { + notFound() +} diff --git a/test/e2e/app-dir/parallel-routes-and-interception/app/parallel-dynamic/[slug]/@bar/default.tsx b/test/e2e/app-dir/parallel-routes-and-interception/app/parallel-dynamic/[slug]/@bar/default.tsx new file mode 100644 index 00000000000000..6dbe479afc29b4 --- /dev/null +++ b/test/e2e/app-dir/parallel-routes-and-interception/app/parallel-dynamic/[slug]/@bar/default.tsx @@ -0,0 +1,5 @@ +import { notFound } from 'next/navigation' + +export default function Default() { + notFound() +} diff --git a/test/e2e/app-dir/parallel-routes-and-interception/app/parallel-dynamic/[slug]/@foo/default.tsx b/test/e2e/app-dir/parallel-routes-and-interception/app/parallel-dynamic/[slug]/@foo/default.tsx new file mode 100644 index 00000000000000..6dbe479afc29b4 --- /dev/null +++ b/test/e2e/app-dir/parallel-routes-and-interception/app/parallel-dynamic/[slug]/@foo/default.tsx @@ -0,0 +1,5 @@ +import { notFound } from 'next/navigation' + +export default function Default() { + notFound() +} diff --git a/test/e2e/app-dir/parallel-routes-and-interception/app/parallel-layout/@slot/default.tsx b/test/e2e/app-dir/parallel-routes-and-interception/app/parallel-layout/@slot/default.tsx new file mode 100644 index 00000000000000..6dbe479afc29b4 --- /dev/null +++ b/test/e2e/app-dir/parallel-routes-and-interception/app/parallel-layout/@slot/default.tsx @@ -0,0 +1,5 @@ +import { notFound } from 'next/navigation' + +export default function Default() { + notFound() +} diff --git a/test/e2e/app-dir/parallel-routes-and-interception/app/parallel-nested/home/@parallelB/default.tsx b/test/e2e/app-dir/parallel-routes-and-interception/app/parallel-nested/home/@parallelB/default.tsx new file mode 100644 index 00000000000000..6dbe479afc29b4 --- /dev/null +++ b/test/e2e/app-dir/parallel-routes-and-interception/app/parallel-nested/home/@parallelB/default.tsx @@ -0,0 +1,5 @@ +import { notFound } from 'next/navigation' + +export default function Default() { + notFound() +} diff --git a/test/e2e/app-dir/parallel-routes-and-interception/app/parallel-no-page/foo/@parallel/default.tsx b/test/e2e/app-dir/parallel-routes-and-interception/app/parallel-no-page/foo/@parallel/default.tsx new file mode 100644 index 00000000000000..6dbe479afc29b4 --- /dev/null +++ b/test/e2e/app-dir/parallel-routes-and-interception/app/parallel-no-page/foo/@parallel/default.tsx @@ -0,0 +1,5 @@ +import { notFound } from 'next/navigation' + +export default function Default() { + notFound() +} diff --git a/test/e2e/app-dir/parallel-routes-and-interception/app/parallel-side-bar/@sidebar/default.tsx b/test/e2e/app-dir/parallel-routes-and-interception/app/parallel-side-bar/@sidebar/default.tsx new file mode 100644 index 00000000000000..6dbe479afc29b4 --- /dev/null +++ b/test/e2e/app-dir/parallel-routes-and-interception/app/parallel-side-bar/@sidebar/default.tsx @@ -0,0 +1,5 @@ +import { notFound } from 'next/navigation' + +export default function Default() { + notFound() +} diff --git a/test/e2e/app-dir/parallel-routes-and-interception/app/parallel-tab-bar/@audience/default.js b/test/e2e/app-dir/parallel-routes-and-interception/app/parallel-tab-bar/@audience/default.js new file mode 100644 index 00000000000000..6dbe479afc29b4 --- /dev/null +++ b/test/e2e/app-dir/parallel-routes-and-interception/app/parallel-tab-bar/@audience/default.js @@ -0,0 +1,5 @@ +import { notFound } from 'next/navigation' + +export default function Default() { + notFound() +} diff --git a/test/e2e/app-dir/parallel-routes-and-interception/app/parallel-tab-bar/@views/default.js b/test/e2e/app-dir/parallel-routes-and-interception/app/parallel-tab-bar/@views/default.js new file mode 100644 index 00000000000000..6dbe479afc29b4 --- /dev/null +++ b/test/e2e/app-dir/parallel-routes-and-interception/app/parallel-tab-bar/@views/default.js @@ -0,0 +1,5 @@ +import { notFound } from 'next/navigation' + +export default function Default() { + notFound() +} diff --git a/test/e2e/app-dir/parallel-routes-and-interception/app/parallel/@bar/nested/@a/default.js b/test/e2e/app-dir/parallel-routes-and-interception/app/parallel/@bar/nested/@a/default.js new file mode 100644 index 00000000000000..6dbe479afc29b4 --- /dev/null +++ b/test/e2e/app-dir/parallel-routes-and-interception/app/parallel/@bar/nested/@a/default.js @@ -0,0 +1,5 @@ +import { notFound } from 'next/navigation' + +export default function Default() { + notFound() +} diff --git a/test/e2e/app-dir/parallel-routes-and-interception/app/parallel/@bar/nested/@b/default.js b/test/e2e/app-dir/parallel-routes-and-interception/app/parallel/@bar/nested/@b/default.js new file mode 100644 index 00000000000000..6dbe479afc29b4 --- /dev/null +++ b/test/e2e/app-dir/parallel-routes-and-interception/app/parallel/@bar/nested/@b/default.js @@ -0,0 +1,5 @@ +import { notFound } from 'next/navigation' + +export default function Default() { + notFound() +} diff --git a/test/e2e/app-dir/parallel-routes-and-interception/app/parallel/@foo/nested/@a/default.js b/test/e2e/app-dir/parallel-routes-and-interception/app/parallel/@foo/nested/@a/default.js new file mode 100644 index 00000000000000..6dbe479afc29b4 --- /dev/null +++ b/test/e2e/app-dir/parallel-routes-and-interception/app/parallel/@foo/nested/@a/default.js @@ -0,0 +1,5 @@ +import { notFound } from 'next/navigation' + +export default function Default() { + notFound() +} diff --git a/test/e2e/app-dir/parallel-routes-and-interception/app/parallel/@foo/nested/@b/default.js b/test/e2e/app-dir/parallel-routes-and-interception/app/parallel/@foo/nested/@b/default.js new file mode 100644 index 00000000000000..6dbe479afc29b4 --- /dev/null +++ b/test/e2e/app-dir/parallel-routes-and-interception/app/parallel/@foo/nested/@b/default.js @@ -0,0 +1,5 @@ +import { notFound } from 'next/navigation' + +export default function Default() { + notFound() +} diff --git a/test/e2e/app-dir/parallel-routes-and-interception/app/with-loading/@slot/default.tsx b/test/e2e/app-dir/parallel-routes-and-interception/app/with-loading/@slot/default.tsx new file mode 100644 index 00000000000000..6dbe479afc29b4 --- /dev/null +++ b/test/e2e/app-dir/parallel-routes-and-interception/app/with-loading/@slot/default.tsx @@ -0,0 +1,5 @@ +import { notFound } from 'next/navigation' + +export default function Default() { + notFound() +} diff --git a/test/e2e/app-dir/parallel-routes-catchall-dynamic-segment/app/[locale]/nested/[foo]/[bar]/@slot1/default.tsx b/test/e2e/app-dir/parallel-routes-catchall-dynamic-segment/app/[locale]/nested/[foo]/[bar]/@slot1/default.tsx new file mode 100644 index 00000000000000..6dbe479afc29b4 --- /dev/null +++ b/test/e2e/app-dir/parallel-routes-catchall-dynamic-segment/app/[locale]/nested/[foo]/[bar]/@slot1/default.tsx @@ -0,0 +1,5 @@ +import { notFound } from 'next/navigation' + +export default function Default() { + notFound() +} diff --git a/test/e2e/app-dir/parallel-routes-catchall-groups/app/(group-a)/@parallel/default.tsx b/test/e2e/app-dir/parallel-routes-catchall-groups/app/(group-a)/@parallel/default.tsx new file mode 100644 index 00000000000000..6dbe479afc29b4 --- /dev/null +++ b/test/e2e/app-dir/parallel-routes-catchall-groups/app/(group-a)/@parallel/default.tsx @@ -0,0 +1,5 @@ +import { notFound } from 'next/navigation' + +export default function Default() { + notFound() +} diff --git a/test/e2e/app-dir/parallel-routes-not-found/app/@slot/default.tsx b/test/e2e/app-dir/parallel-routes-not-found/app/@slot/default.tsx new file mode 100644 index 00000000000000..6dbe479afc29b4 --- /dev/null +++ b/test/e2e/app-dir/parallel-routes-not-found/app/@slot/default.tsx @@ -0,0 +1,5 @@ +import { notFound } from 'next/navigation' + +export default function Default() { + notFound() +} diff --git a/test/e2e/app-dir/parallel-routes-root-slot/app/@slot/default.tsx b/test/e2e/app-dir/parallel-routes-root-slot/app/@slot/default.tsx new file mode 100644 index 00000000000000..6dbe479afc29b4 --- /dev/null +++ b/test/e2e/app-dir/parallel-routes-root-slot/app/@slot/default.tsx @@ -0,0 +1,5 @@ +import { notFound } from 'next/navigation' + +export default function Default() { + notFound() +} diff --git a/test/e2e/app-dir/root-layout/app/(mpa-navigation)/with-parallel-routes/@one/default.js b/test/e2e/app-dir/root-layout/app/(mpa-navigation)/with-parallel-routes/@one/default.js new file mode 100644 index 00000000000000..6dbe479afc29b4 --- /dev/null +++ b/test/e2e/app-dir/root-layout/app/(mpa-navigation)/with-parallel-routes/@one/default.js @@ -0,0 +1,5 @@ +import { notFound } from 'next/navigation' + +export default function Default() { + notFound() +} diff --git a/test/e2e/app-dir/root-layout/app/(mpa-navigation)/with-parallel-routes/@two/default.js b/test/e2e/app-dir/root-layout/app/(mpa-navigation)/with-parallel-routes/@two/default.js new file mode 100644 index 00000000000000..6dbe479afc29b4 --- /dev/null +++ b/test/e2e/app-dir/root-layout/app/(mpa-navigation)/with-parallel-routes/@two/default.js @@ -0,0 +1,5 @@ +import { notFound } from 'next/navigation' + +export default function Default() { + notFound() +} diff --git a/test/e2e/app-dir/rsc-basic/app/(group)/conventions/@named/default.js b/test/e2e/app-dir/rsc-basic/app/(group)/conventions/@named/default.js new file mode 100644 index 00000000000000..6dbe479afc29b4 --- /dev/null +++ b/test/e2e/app-dir/rsc-basic/app/(group)/conventions/@named/default.js @@ -0,0 +1,5 @@ +import { notFound } from 'next/navigation' + +export default function Default() { + notFound() +} diff --git a/test/e2e/app-dir/segment-cache/basic/app/test/@nav/default.tsx b/test/e2e/app-dir/segment-cache/basic/app/test/@nav/default.tsx new file mode 100644 index 00000000000000..6dbe479afc29b4 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/basic/app/test/@nav/default.tsx @@ -0,0 +1,5 @@ +import { notFound } from 'next/navigation' + +export default function Default() { + notFound() +} diff --git a/test/e2e/app-dir/typed-routes/app/dashboard/@analytics/default.tsx b/test/e2e/app-dir/typed-routes/app/dashboard/@analytics/default.tsx new file mode 100644 index 00000000000000..6dbe479afc29b4 --- /dev/null +++ b/test/e2e/app-dir/typed-routes/app/dashboard/@analytics/default.tsx @@ -0,0 +1,5 @@ +import { notFound } from 'next/navigation' + +export default function Default() { + notFound() +} diff --git a/test/e2e/app-dir/typed-routes/app/dashboard/@team/default.tsx b/test/e2e/app-dir/typed-routes/app/dashboard/@team/default.tsx new file mode 100644 index 00000000000000..6dbe479afc29b4 --- /dev/null +++ b/test/e2e/app-dir/typed-routes/app/dashboard/@team/default.tsx @@ -0,0 +1,5 @@ +import { notFound } from 'next/navigation' + +export default function Default() { + notFound() +} diff --git a/test/integration/build-output/fixtures/with-parallel-routes/app/root-page/@footer/default.js b/test/integration/build-output/fixtures/with-parallel-routes/app/root-page/@footer/default.js new file mode 100644 index 00000000000000..6dbe479afc29b4 --- /dev/null +++ b/test/integration/build-output/fixtures/with-parallel-routes/app/root-page/@footer/default.js @@ -0,0 +1,5 @@ +import { notFound } from 'next/navigation' + +export default function Default() { + notFound() +} diff --git a/test/integration/build-output/fixtures/with-parallel-routes/app/root-page/@header/default.js b/test/integration/build-output/fixtures/with-parallel-routes/app/root-page/@header/default.js new file mode 100644 index 00000000000000..6dbe479afc29b4 --- /dev/null +++ b/test/integration/build-output/fixtures/with-parallel-routes/app/root-page/@header/default.js @@ -0,0 +1,5 @@ +import { notFound } from 'next/navigation' + +export default function Default() { + notFound() +} diff --git a/test/production/app-dir/parallel-routes-static/app/nested/@bar/default.tsx b/test/production/app-dir/parallel-routes-static/app/nested/@bar/default.tsx new file mode 100644 index 00000000000000..6dbe479afc29b4 --- /dev/null +++ b/test/production/app-dir/parallel-routes-static/app/nested/@bar/default.tsx @@ -0,0 +1,5 @@ +import { notFound } from 'next/navigation' + +export default function Default() { + notFound() +} diff --git a/test/production/app-dir/parallel-routes-static/app/nested/@foo/default.tsx b/test/production/app-dir/parallel-routes-static/app/nested/@foo/default.tsx new file mode 100644 index 00000000000000..6dbe479afc29b4 --- /dev/null +++ b/test/production/app-dir/parallel-routes-static/app/nested/@foo/default.tsx @@ -0,0 +1,5 @@ +import { notFound } from 'next/navigation' + +export default function Default() { + notFound() +} diff --git a/test/unit/eslint-plugin-next/with-app-dir/app/@modal/default.tsx b/test/unit/eslint-plugin-next/with-app-dir/app/@modal/default.tsx new file mode 100644 index 00000000000000..6dbe479afc29b4 --- /dev/null +++ b/test/unit/eslint-plugin-next/with-app-dir/app/@modal/default.tsx @@ -0,0 +1,5 @@ +import { notFound } from 'next/navigation' + +export default function Default() { + notFound() +} From 8d475c5468b18cb3aad0de0a50632953d5243db1 Mon Sep 17 00:00:00 2001 From: nextjs-bot Date: Thu, 9 Oct 2025 22:09:18 +0000 Subject: [PATCH 04/11] v15.6.0-canary.55 --- lerna.json | 2 +- packages/create-next-app/package.json | 2 +- packages/eslint-config-next/package.json | 4 +-- packages/eslint-plugin-internal/package.json | 2 +- packages/eslint-plugin-next/package.json | 2 +- packages/font/package.json | 2 +- packages/next-bundle-analyzer/package.json | 2 +- packages/next-codemod/package.json | 2 +- packages/next-env/package.json | 2 +- packages/next-mdx/package.json | 2 +- packages/next-plugin-storybook/package.json | 2 +- packages/next-polyfill-module/package.json | 2 +- packages/next-polyfill-nomodule/package.json | 2 +- packages/next-rspack/package.json | 2 +- packages/next-swc/package.json | 2 +- packages/next/package.json | 14 +++++------ packages/react-refresh-utils/package.json | 2 +- packages/third-parties/package.json | 4 +-- pnpm-lock.yaml | 26 ++++++-------------- 19 files changed, 34 insertions(+), 44 deletions(-) diff --git a/lerna.json b/lerna.json index 44671514549036..d7b559003edbc7 100644 --- a/lerna.json +++ b/lerna.json @@ -16,5 +16,5 @@ "registry": "https://registry.npmjs.org/" } }, - "version": "15.6.0-canary.54" + "version": "15.6.0-canary.55" } diff --git a/packages/create-next-app/package.json b/packages/create-next-app/package.json index 208b1e9c4bc10e..f22893a9d6a0b5 100644 --- a/packages/create-next-app/package.json +++ b/packages/create-next-app/package.json @@ -1,6 +1,6 @@ { "name": "create-next-app", - "version": "15.6.0-canary.54", + "version": "15.6.0-canary.55", "keywords": [ "react", "next", diff --git a/packages/eslint-config-next/package.json b/packages/eslint-config-next/package.json index 4abc1816ff517f..8784a81d76851b 100644 --- a/packages/eslint-config-next/package.json +++ b/packages/eslint-config-next/package.json @@ -1,6 +1,6 @@ { "name": "eslint-config-next", - "version": "15.6.0-canary.54", + "version": "15.6.0-canary.55", "description": "ESLint configuration used by Next.js.", "main": "index.js", "license": "MIT", @@ -10,7 +10,7 @@ }, "homepage": "https://nextjs.org/docs/app/api-reference/config/eslint", "dependencies": { - "@next/eslint-plugin-next": "15.6.0-canary.54", + "@next/eslint-plugin-next": "15.6.0-canary.55", "@rushstack/eslint-patch": "^1.10.3", "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", diff --git a/packages/eslint-plugin-internal/package.json b/packages/eslint-plugin-internal/package.json index 114ba9419ad237..c0c80257f7e7fa 100644 --- a/packages/eslint-plugin-internal/package.json +++ b/packages/eslint-plugin-internal/package.json @@ -1,7 +1,7 @@ { "name": "@next/eslint-plugin-internal", "private": true, - "version": "15.6.0-canary.54", + "version": "15.6.0-canary.55", "description": "ESLint plugin for working on Next.js.", "exports": { ".": "./src/eslint-plugin-internal.js" diff --git a/packages/eslint-plugin-next/package.json b/packages/eslint-plugin-next/package.json index 0448af7ad182ba..3ae3471ea1e3e0 100644 --- a/packages/eslint-plugin-next/package.json +++ b/packages/eslint-plugin-next/package.json @@ -1,6 +1,6 @@ { "name": "@next/eslint-plugin-next", - "version": "15.6.0-canary.54", + "version": "15.6.0-canary.55", "description": "ESLint plugin for Next.js.", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/font/package.json b/packages/font/package.json index a60164af5a4cfa..a3d8975c31be06 100644 --- a/packages/font/package.json +++ b/packages/font/package.json @@ -1,7 +1,7 @@ { "name": "@next/font", "private": true, - "version": "15.6.0-canary.54", + "version": "15.6.0-canary.55", "repository": { "url": "vercel/next.js", "directory": "packages/font" diff --git a/packages/next-bundle-analyzer/package.json b/packages/next-bundle-analyzer/package.json index 5d0f14f95e70e1..803f555eb9464b 100644 --- a/packages/next-bundle-analyzer/package.json +++ b/packages/next-bundle-analyzer/package.json @@ -1,6 +1,6 @@ { "name": "@next/bundle-analyzer", - "version": "15.6.0-canary.54", + "version": "15.6.0-canary.55", "main": "index.js", "types": "index.d.ts", "license": "MIT", diff --git a/packages/next-codemod/package.json b/packages/next-codemod/package.json index 8008c1272ff33c..137fb38558f935 100644 --- a/packages/next-codemod/package.json +++ b/packages/next-codemod/package.json @@ -1,6 +1,6 @@ { "name": "@next/codemod", - "version": "15.6.0-canary.54", + "version": "15.6.0-canary.55", "license": "MIT", "repository": { "type": "git", diff --git a/packages/next-env/package.json b/packages/next-env/package.json index e449c812b27ecd..7359c8c74b2846 100644 --- a/packages/next-env/package.json +++ b/packages/next-env/package.json @@ -1,6 +1,6 @@ { "name": "@next/env", - "version": "15.6.0-canary.54", + "version": "15.6.0-canary.55", "keywords": [ "react", "next", diff --git a/packages/next-mdx/package.json b/packages/next-mdx/package.json index 627668fdbd27ea..c95aae7c9aee88 100644 --- a/packages/next-mdx/package.json +++ b/packages/next-mdx/package.json @@ -1,6 +1,6 @@ { "name": "@next/mdx", - "version": "15.6.0-canary.54", + "version": "15.6.0-canary.55", "main": "index.js", "license": "MIT", "repository": { diff --git a/packages/next-plugin-storybook/package.json b/packages/next-plugin-storybook/package.json index 7c415230e26e30..f3c8aec9603425 100644 --- a/packages/next-plugin-storybook/package.json +++ b/packages/next-plugin-storybook/package.json @@ -1,6 +1,6 @@ { "name": "@next/plugin-storybook", - "version": "15.6.0-canary.54", + "version": "15.6.0-canary.55", "repository": { "url": "vercel/next.js", "directory": "packages/next-plugin-storybook" diff --git a/packages/next-polyfill-module/package.json b/packages/next-polyfill-module/package.json index dc8fc0486eab09..c32ec16391147d 100644 --- a/packages/next-polyfill-module/package.json +++ b/packages/next-polyfill-module/package.json @@ -1,6 +1,6 @@ { "name": "@next/polyfill-module", - "version": "15.6.0-canary.54", + "version": "15.6.0-canary.55", "description": "A standard library polyfill for ES Modules supporting browsers (Edge 16+, Firefox 60+, Chrome 61+, Safari 10.1+)", "main": "dist/polyfill-module.js", "license": "MIT", diff --git a/packages/next-polyfill-nomodule/package.json b/packages/next-polyfill-nomodule/package.json index 7afd3faf6d187c..4d3bae2dfab4e5 100644 --- a/packages/next-polyfill-nomodule/package.json +++ b/packages/next-polyfill-nomodule/package.json @@ -1,6 +1,6 @@ { "name": "@next/polyfill-nomodule", - "version": "15.6.0-canary.54", + "version": "15.6.0-canary.55", "description": "A polyfill for non-dead, nomodule browsers.", "main": "dist/polyfill-nomodule.js", "license": "MIT", diff --git a/packages/next-rspack/package.json b/packages/next-rspack/package.json index c891219f86a86f..fb6fc51d40cea0 100644 --- a/packages/next-rspack/package.json +++ b/packages/next-rspack/package.json @@ -1,6 +1,6 @@ { "name": "next-rspack", - "version": "15.6.0-canary.54", + "version": "15.6.0-canary.55", "repository": { "url": "vercel/next.js", "directory": "packages/next-rspack" diff --git a/packages/next-swc/package.json b/packages/next-swc/package.json index 6ad044b6946824..33236b920a542f 100644 --- a/packages/next-swc/package.json +++ b/packages/next-swc/package.json @@ -1,6 +1,6 @@ { "name": "@next/swc", - "version": "15.6.0-canary.54", + "version": "15.6.0-canary.55", "private": true, "files": [ "native/" diff --git a/packages/next/package.json b/packages/next/package.json index 4275b6cdac1309..eab151946e7c0c 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -1,6 +1,6 @@ { "name": "next", - "version": "15.6.0-canary.54", + "version": "15.6.0-canary.55", "description": "The React Framework", "main": "./dist/server/next.js", "license": "MIT", @@ -98,7 +98,7 @@ ] }, "dependencies": { - "@next/env": "15.6.0-canary.54", + "@next/env": "15.6.0-canary.55", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", @@ -162,11 +162,11 @@ "@modelcontextprotocol/sdk": "1.18.1", "@mswjs/interceptors": "0.23.0", "@napi-rs/triples": "1.2.0", - "@next/font": "15.6.0-canary.54", - "@next/polyfill-module": "15.6.0-canary.54", - "@next/polyfill-nomodule": "15.6.0-canary.54", - "@next/react-refresh-utils": "15.6.0-canary.54", - "@next/swc": "15.6.0-canary.54", + "@next/font": "15.6.0-canary.55", + "@next/polyfill-module": "15.6.0-canary.55", + "@next/polyfill-nomodule": "15.6.0-canary.55", + "@next/react-refresh-utils": "15.6.0-canary.55", + "@next/swc": "15.6.0-canary.55", "@opentelemetry/api": "1.6.0", "@playwright/test": "1.51.1", "@rspack/core": "1.5.0", diff --git a/packages/react-refresh-utils/package.json b/packages/react-refresh-utils/package.json index d6a90de946d255..98732a1aef1d67 100644 --- a/packages/react-refresh-utils/package.json +++ b/packages/react-refresh-utils/package.json @@ -1,6 +1,6 @@ { "name": "@next/react-refresh-utils", - "version": "15.6.0-canary.54", + "version": "15.6.0-canary.55", "description": "An experimental package providing utilities for React Refresh.", "repository": { "url": "vercel/next.js", diff --git a/packages/third-parties/package.json b/packages/third-parties/package.json index 01457102021628..0bfcd47cc0458b 100644 --- a/packages/third-parties/package.json +++ b/packages/third-parties/package.json @@ -1,6 +1,6 @@ { "name": "@next/third-parties", - "version": "15.6.0-canary.54", + "version": "15.6.0-canary.55", "repository": { "url": "vercel/next.js", "directory": "packages/third-parties" @@ -26,7 +26,7 @@ "third-party-capital": "1.0.20" }, "devDependencies": { - "next": "15.6.0-canary.54", + "next": "15.6.0-canary.55", "outdent": "0.8.0", "prettier": "2.5.1", "typescript": "5.9.2" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4bcc1eb08b460b..50f719c874a2af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -899,7 +899,7 @@ importers: packages/eslint-config-next: dependencies: '@next/eslint-plugin-next': - specifier: 15.6.0-canary.54 + specifier: 15.6.0-canary.55 version: link:../eslint-plugin-next '@rushstack/eslint-patch': specifier: ^1.10.3 @@ -972,7 +972,7 @@ importers: packages/next: dependencies: '@next/env': - specifier: 15.6.0-canary.54 + specifier: 15.6.0-canary.55 version: link:../next-env '@swc/helpers': specifier: 0.5.15 @@ -1097,19 +1097,19 @@ importers: specifier: 1.2.0 version: 1.2.0 '@next/font': - specifier: 15.6.0-canary.54 + specifier: 15.6.0-canary.55 version: link:../font '@next/polyfill-module': - specifier: 15.6.0-canary.54 + specifier: 15.6.0-canary.55 version: link:../next-polyfill-module '@next/polyfill-nomodule': - specifier: 15.6.0-canary.54 + specifier: 15.6.0-canary.55 version: link:../next-polyfill-nomodule '@next/react-refresh-utils': - specifier: 15.6.0-canary.54 + specifier: 15.6.0-canary.55 version: link:../react-refresh-utils '@next/swc': - specifier: 15.6.0-canary.54 + specifier: 15.6.0-canary.55 version: link:../next-swc '@opentelemetry/api': specifier: 1.6.0 @@ -1803,7 +1803,7 @@ importers: version: 1.0.20 devDependencies: next: - specifier: 15.6.0-canary.54 + specifier: 15.6.0-canary.55 version: link:../next outdent: specifier: 0.8.0 @@ -9287,12 +9287,6 @@ packages: peerDependencies: eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 - eslint-plugin-react-hooks@5.0.0: - resolution: {integrity: sha512-hIOwI+5hYGpJEc4uPRmz2ulCjAGD/N13Lukkh8cLV0i2IRk/bdZDYjgLVHj+U9Z704kLIdIO6iueGvxNur0sgw==} - engines: {node: '>=10'} - peerDependencies: - eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 - eslint-plugin-react-hooks@7.0.0: resolution: {integrity: sha512-fNXaOwvKwq2+pXiRpXc825Vd63+KM4DLL40Rtlycb8m7fYpp6efrTp1sa6ZbP/Ap58K2bEKFXRmhURE+CJAQWw==} engines: {node: '>=18'} @@ -26982,10 +26976,6 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-react-hooks@5.0.0(eslint@9.12.0(jiti@2.5.1)): - dependencies: - eslint: 9.12.0(jiti@2.5.1) - eslint-plugin-react-hooks@7.0.0(eslint@9.12.0(jiti@2.5.1)): dependencies: '@babel/core': 7.26.10 From 5c8fdbe104b7e13bc653cb8b319782052d059bc1 Mon Sep 17 00:00:00 2001 From: Jimmy Lai Date: Thu, 9 Oct 2025 16:28:52 -0700 Subject: [PATCH 05/11] misc: allow beta to be triggered (#84713) ![a-picture-of-a-bear-in-a-spaceship-with-the-words-i-have-no-idea-what-i-m-doing](https://github.com/user-attachments/assets/a53076b7-6d4c-4e98-b540-b4f6950d4901) - update codemod script - upgrade both release scripts, not sure which one is correct --------- Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com> Co-authored-by: JJ Kasper --- .github/workflows/trigger_release.yml | 3 ++- .github/workflows/trigger_release_new.yml | 1 + packages/next-codemod/bin/next-codemod.ts | 6 ++++-- packages/next-codemod/bin/upgrade.ts | 2 +- scripts/release/version-packages.ts | 7 +++++++ scripts/start-release.js | 12 +++++++++--- 6 files changed, 24 insertions(+), 7 deletions(-) diff --git a/.github/workflows/trigger_release.yml b/.github/workflows/trigger_release.yml index 23d2d3421cd8c4..c1ba7bd15a1c57 100644 --- a/.github/workflows/trigger_release.yml +++ b/.github/workflows/trigger_release.yml @@ -6,13 +6,14 @@ on: workflow_dispatch: inputs: releaseType: - description: stable, canary, or release candidate? + description: stable, canary, beta, or release candidate? required: true type: choice options: - canary - stable - release-candidate + - beta semverType: description: semver type? diff --git a/.github/workflows/trigger_release_new.yml b/.github/workflows/trigger_release_new.yml index 9e31cb4ec1bea6..dedf47a092e19c 100644 --- a/.github/workflows/trigger_release_new.yml +++ b/.github/workflows/trigger_release_new.yml @@ -19,6 +19,7 @@ on: - canary - stable - release-candidate + - beta force: description: Forced Release diff --git a/packages/next-codemod/bin/next-codemod.ts b/packages/next-codemod/bin/next-codemod.ts index 1ef515684a69a1..c9890d201e0b3d 100644 --- a/packages/next-codemod/bin/next-codemod.ts +++ b/packages/next-codemod/bin/next-codemod.ts @@ -60,12 +60,14 @@ program ) .argument( '[revision]', - 'Specify the target Next.js version using an NPM dist tag (e.g. "latest", "canary", "rc") or an exact version number (e.g. "15.0.0").', + 'Specify the target Next.js version using an NPM dist tag (e.g. "latest", "canary", "rc", "beta") or an exact version number (e.g. "15.0.0").', packageJson.version.includes('-canary.') ? 'canary' : packageJson.version.includes('-rc.') ? 'rc' - : 'latest' + : packageJson.version.includes('-beta.') + ? 'beta' + : 'latest' ) .usage('[revision] [options]') .option('--verbose', 'Verbose output', false) diff --git a/packages/next-codemod/bin/upgrade.ts b/packages/next-codemod/bin/upgrade.ts index df08e9434a32f1..13520c48b7d973 100644 --- a/packages/next-codemod/bin/upgrade.ts +++ b/packages/next-codemod/bin/upgrade.ts @@ -106,7 +106,7 @@ export async function runUpgrade( 'peerDependencies' in targetNextPackageJson if (!validRevision) { throw new BadInput( - `Invalid revision provided: "${revision}". Please provide a valid Next.js version or dist-tag (e.g. "latest", "canary", "rc", or "15.0.0").\nCheck available versions at https://www.npmjs.com/package/next?activeTab=versions.` + `Invalid revision provided: "${revision}". Please provide a valid Next.js version or dist-tag (e.g. "latest", "canary", "beta", "rc", or "15.0.0").\nCheck available versions at https://www.npmjs.com/package/next?activeTab=versions.` ) } diff --git a/scripts/release/version-packages.ts b/scripts/release/version-packages.ts index cc038af03f1e37..d281cb20afd8cf 100644 --- a/scripts/release/version-packages.ts +++ b/scripts/release/version-packages.ts @@ -96,6 +96,13 @@ async function versionPackages() { }) break } + case 'beta': { + // Enter pre mode as "beta" tag. + await execa('pnpm', ['changeset', 'pre', 'enter', 'beta'], { + stdio: 'inherit', + }) + break + } case 'stable': { // No additional steps needed for 'stable' releases since we've already // exited any pre-release mode. Only need to run `changeset version` after. diff --git a/scripts/start-release.js b/scripts/start-release.js index 7af267a3edd215..ea0190ac4346a0 100644 --- a/scripts/start-release.js +++ b/scripts/start-release.js @@ -11,13 +11,17 @@ async function main() { const semverType = args[args.indexOf('--semver-type') + 1] const isCanary = releaseType === 'canary' const isReleaseCandidate = releaseType === 'release-candidate' + const isBeta = releaseType === 'beta' if ( releaseType !== 'stable' && releaseType !== 'canary' && - releaseType !== 'release-candidate' + releaseType !== 'release-candidate' && + releaseType !== 'beta' ) { - console.log(`Invalid release type ${releaseType}, must be stable or canary`) + console.log( + `Invalid release type ${releaseType}, must be stable, canary, release-candidate, or beta` + ) return } if (!isCanary && !SEMVER_TYPES.includes(semverType)) { @@ -71,7 +75,9 @@ async function main() { ? `pnpm lerna version ${preleaseType} --preid canary --force-publish -y && pnpm release --pre --skip-questions --show-url` : isReleaseCandidate ? `pnpm lerna version ${preleaseType} --preid rc --force-publish -y && pnpm release --pre --skip-questions --show-url` - : `pnpm lerna version ${semverType} --force-publish -y`, + : isBeta + ? `pnpm lerna version ${preleaseType} --preid beta --force-publish -y && pnpm release --pre --skip-questions --show-url` + : `pnpm lerna version ${semverType} --force-publish -y`, { stdio: 'pipe', shell: true, From e2713a423002420b3d12f6fa946205581d1151e3 Mon Sep 17 00:00:00 2001 From: nextjs-bot Date: Thu, 9 Oct 2025 23:37:45 +0000 Subject: [PATCH 06/11] v15.6.0-canary.56 --- lerna.json | 2 +- packages/create-next-app/package.json | 2 +- packages/eslint-config-next/package.json | 4 ++-- packages/eslint-plugin-internal/package.json | 2 +- packages/eslint-plugin-next/package.json | 2 +- packages/font/package.json | 2 +- packages/next-bundle-analyzer/package.json | 2 +- packages/next-codemod/package.json | 2 +- packages/next-env/package.json | 2 +- packages/next-mdx/package.json | 2 +- packages/next-plugin-storybook/package.json | 2 +- packages/next-polyfill-module/package.json | 2 +- packages/next-polyfill-nomodule/package.json | 2 +- packages/next-rspack/package.json | 2 +- packages/next-swc/package.json | 2 +- packages/next/package.json | 14 +++++++------- packages/react-refresh-utils/package.json | 2 +- packages/third-parties/package.json | 4 ++-- pnpm-lock.yaml | 16 ++++++++-------- 19 files changed, 34 insertions(+), 34 deletions(-) diff --git a/lerna.json b/lerna.json index d7b559003edbc7..1c06a02a47bf11 100644 --- a/lerna.json +++ b/lerna.json @@ -16,5 +16,5 @@ "registry": "https://registry.npmjs.org/" } }, - "version": "15.6.0-canary.55" + "version": "15.6.0-canary.56" } diff --git a/packages/create-next-app/package.json b/packages/create-next-app/package.json index f22893a9d6a0b5..4e83178111643f 100644 --- a/packages/create-next-app/package.json +++ b/packages/create-next-app/package.json @@ -1,6 +1,6 @@ { "name": "create-next-app", - "version": "15.6.0-canary.55", + "version": "15.6.0-canary.56", "keywords": [ "react", "next", diff --git a/packages/eslint-config-next/package.json b/packages/eslint-config-next/package.json index 8784a81d76851b..29adc1d655be65 100644 --- a/packages/eslint-config-next/package.json +++ b/packages/eslint-config-next/package.json @@ -1,6 +1,6 @@ { "name": "eslint-config-next", - "version": "15.6.0-canary.55", + "version": "15.6.0-canary.56", "description": "ESLint configuration used by Next.js.", "main": "index.js", "license": "MIT", @@ -10,7 +10,7 @@ }, "homepage": "https://nextjs.org/docs/app/api-reference/config/eslint", "dependencies": { - "@next/eslint-plugin-next": "15.6.0-canary.55", + "@next/eslint-plugin-next": "15.6.0-canary.56", "@rushstack/eslint-patch": "^1.10.3", "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", diff --git a/packages/eslint-plugin-internal/package.json b/packages/eslint-plugin-internal/package.json index c0c80257f7e7fa..bf66f00c4e4c29 100644 --- a/packages/eslint-plugin-internal/package.json +++ b/packages/eslint-plugin-internal/package.json @@ -1,7 +1,7 @@ { "name": "@next/eslint-plugin-internal", "private": true, - "version": "15.6.0-canary.55", + "version": "15.6.0-canary.56", "description": "ESLint plugin for working on Next.js.", "exports": { ".": "./src/eslint-plugin-internal.js" diff --git a/packages/eslint-plugin-next/package.json b/packages/eslint-plugin-next/package.json index 3ae3471ea1e3e0..780abd6e14481d 100644 --- a/packages/eslint-plugin-next/package.json +++ b/packages/eslint-plugin-next/package.json @@ -1,6 +1,6 @@ { "name": "@next/eslint-plugin-next", - "version": "15.6.0-canary.55", + "version": "15.6.0-canary.56", "description": "ESLint plugin for Next.js.", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/font/package.json b/packages/font/package.json index a3d8975c31be06..46a1556a970708 100644 --- a/packages/font/package.json +++ b/packages/font/package.json @@ -1,7 +1,7 @@ { "name": "@next/font", "private": true, - "version": "15.6.0-canary.55", + "version": "15.6.0-canary.56", "repository": { "url": "vercel/next.js", "directory": "packages/font" diff --git a/packages/next-bundle-analyzer/package.json b/packages/next-bundle-analyzer/package.json index 803f555eb9464b..02fef69c77755f 100644 --- a/packages/next-bundle-analyzer/package.json +++ b/packages/next-bundle-analyzer/package.json @@ -1,6 +1,6 @@ { "name": "@next/bundle-analyzer", - "version": "15.6.0-canary.55", + "version": "15.6.0-canary.56", "main": "index.js", "types": "index.d.ts", "license": "MIT", diff --git a/packages/next-codemod/package.json b/packages/next-codemod/package.json index 137fb38558f935..240b3a76001235 100644 --- a/packages/next-codemod/package.json +++ b/packages/next-codemod/package.json @@ -1,6 +1,6 @@ { "name": "@next/codemod", - "version": "15.6.0-canary.55", + "version": "15.6.0-canary.56", "license": "MIT", "repository": { "type": "git", diff --git a/packages/next-env/package.json b/packages/next-env/package.json index 7359c8c74b2846..dcabff6ea1262a 100644 --- a/packages/next-env/package.json +++ b/packages/next-env/package.json @@ -1,6 +1,6 @@ { "name": "@next/env", - "version": "15.6.0-canary.55", + "version": "15.6.0-canary.56", "keywords": [ "react", "next", diff --git a/packages/next-mdx/package.json b/packages/next-mdx/package.json index c95aae7c9aee88..63180c8a28e930 100644 --- a/packages/next-mdx/package.json +++ b/packages/next-mdx/package.json @@ -1,6 +1,6 @@ { "name": "@next/mdx", - "version": "15.6.0-canary.55", + "version": "15.6.0-canary.56", "main": "index.js", "license": "MIT", "repository": { diff --git a/packages/next-plugin-storybook/package.json b/packages/next-plugin-storybook/package.json index f3c8aec9603425..1f6151f0ea0a4d 100644 --- a/packages/next-plugin-storybook/package.json +++ b/packages/next-plugin-storybook/package.json @@ -1,6 +1,6 @@ { "name": "@next/plugin-storybook", - "version": "15.6.0-canary.55", + "version": "15.6.0-canary.56", "repository": { "url": "vercel/next.js", "directory": "packages/next-plugin-storybook" diff --git a/packages/next-polyfill-module/package.json b/packages/next-polyfill-module/package.json index c32ec16391147d..7f6e30776a80bc 100644 --- a/packages/next-polyfill-module/package.json +++ b/packages/next-polyfill-module/package.json @@ -1,6 +1,6 @@ { "name": "@next/polyfill-module", - "version": "15.6.0-canary.55", + "version": "15.6.0-canary.56", "description": "A standard library polyfill for ES Modules supporting browsers (Edge 16+, Firefox 60+, Chrome 61+, Safari 10.1+)", "main": "dist/polyfill-module.js", "license": "MIT", diff --git a/packages/next-polyfill-nomodule/package.json b/packages/next-polyfill-nomodule/package.json index 4d3bae2dfab4e5..e38a7b1f0aa826 100644 --- a/packages/next-polyfill-nomodule/package.json +++ b/packages/next-polyfill-nomodule/package.json @@ -1,6 +1,6 @@ { "name": "@next/polyfill-nomodule", - "version": "15.6.0-canary.55", + "version": "15.6.0-canary.56", "description": "A polyfill for non-dead, nomodule browsers.", "main": "dist/polyfill-nomodule.js", "license": "MIT", diff --git a/packages/next-rspack/package.json b/packages/next-rspack/package.json index fb6fc51d40cea0..2270e934856941 100644 --- a/packages/next-rspack/package.json +++ b/packages/next-rspack/package.json @@ -1,6 +1,6 @@ { "name": "next-rspack", - "version": "15.6.0-canary.55", + "version": "15.6.0-canary.56", "repository": { "url": "vercel/next.js", "directory": "packages/next-rspack" diff --git a/packages/next-swc/package.json b/packages/next-swc/package.json index 33236b920a542f..061f4ecc1ee2e0 100644 --- a/packages/next-swc/package.json +++ b/packages/next-swc/package.json @@ -1,6 +1,6 @@ { "name": "@next/swc", - "version": "15.6.0-canary.55", + "version": "15.6.0-canary.56", "private": true, "files": [ "native/" diff --git a/packages/next/package.json b/packages/next/package.json index eab151946e7c0c..ef134c0ccba4a9 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -1,6 +1,6 @@ { "name": "next", - "version": "15.6.0-canary.55", + "version": "15.6.0-canary.56", "description": "The React Framework", "main": "./dist/server/next.js", "license": "MIT", @@ -98,7 +98,7 @@ ] }, "dependencies": { - "@next/env": "15.6.0-canary.55", + "@next/env": "15.6.0-canary.56", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", @@ -162,11 +162,11 @@ "@modelcontextprotocol/sdk": "1.18.1", "@mswjs/interceptors": "0.23.0", "@napi-rs/triples": "1.2.0", - "@next/font": "15.6.0-canary.55", - "@next/polyfill-module": "15.6.0-canary.55", - "@next/polyfill-nomodule": "15.6.0-canary.55", - "@next/react-refresh-utils": "15.6.0-canary.55", - "@next/swc": "15.6.0-canary.55", + "@next/font": "15.6.0-canary.56", + "@next/polyfill-module": "15.6.0-canary.56", + "@next/polyfill-nomodule": "15.6.0-canary.56", + "@next/react-refresh-utils": "15.6.0-canary.56", + "@next/swc": "15.6.0-canary.56", "@opentelemetry/api": "1.6.0", "@playwright/test": "1.51.1", "@rspack/core": "1.5.0", diff --git a/packages/react-refresh-utils/package.json b/packages/react-refresh-utils/package.json index 98732a1aef1d67..22b8c6cef42d77 100644 --- a/packages/react-refresh-utils/package.json +++ b/packages/react-refresh-utils/package.json @@ -1,6 +1,6 @@ { "name": "@next/react-refresh-utils", - "version": "15.6.0-canary.55", + "version": "15.6.0-canary.56", "description": "An experimental package providing utilities for React Refresh.", "repository": { "url": "vercel/next.js", diff --git a/packages/third-parties/package.json b/packages/third-parties/package.json index 0bfcd47cc0458b..e9d93b8267f6df 100644 --- a/packages/third-parties/package.json +++ b/packages/third-parties/package.json @@ -1,6 +1,6 @@ { "name": "@next/third-parties", - "version": "15.6.0-canary.55", + "version": "15.6.0-canary.56", "repository": { "url": "vercel/next.js", "directory": "packages/third-parties" @@ -26,7 +26,7 @@ "third-party-capital": "1.0.20" }, "devDependencies": { - "next": "15.6.0-canary.55", + "next": "15.6.0-canary.56", "outdent": "0.8.0", "prettier": "2.5.1", "typescript": "5.9.2" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 50f719c874a2af..736f49e2b9c959 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -899,7 +899,7 @@ importers: packages/eslint-config-next: dependencies: '@next/eslint-plugin-next': - specifier: 15.6.0-canary.55 + specifier: 15.6.0-canary.56 version: link:../eslint-plugin-next '@rushstack/eslint-patch': specifier: ^1.10.3 @@ -972,7 +972,7 @@ importers: packages/next: dependencies: '@next/env': - specifier: 15.6.0-canary.55 + specifier: 15.6.0-canary.56 version: link:../next-env '@swc/helpers': specifier: 0.5.15 @@ -1097,19 +1097,19 @@ importers: specifier: 1.2.0 version: 1.2.0 '@next/font': - specifier: 15.6.0-canary.55 + specifier: 15.6.0-canary.56 version: link:../font '@next/polyfill-module': - specifier: 15.6.0-canary.55 + specifier: 15.6.0-canary.56 version: link:../next-polyfill-module '@next/polyfill-nomodule': - specifier: 15.6.0-canary.55 + specifier: 15.6.0-canary.56 version: link:../next-polyfill-nomodule '@next/react-refresh-utils': - specifier: 15.6.0-canary.55 + specifier: 15.6.0-canary.56 version: link:../react-refresh-utils '@next/swc': - specifier: 15.6.0-canary.55 + specifier: 15.6.0-canary.56 version: link:../next-swc '@opentelemetry/api': specifier: 1.6.0 @@ -1803,7 +1803,7 @@ importers: version: 1.0.20 devDependencies: next: - specifier: 15.6.0-canary.55 + specifier: 15.6.0-canary.56 version: link:../next outdent: specifier: 0.8.0 From 36357eba00b8dcc42c5eca8e6f2c39fbcab29f7d Mon Sep 17 00:00:00 2001 From: Sam Selikoff Date: Thu, 9 Oct 2025 16:43:37 -0700 Subject: [PATCH 07/11] Fix typo on welcome page (#84715) --- apps/docs/app/page.tsx | 2 +- packages/create-next-app/templates/app-tw/js/app/page.js | 2 +- packages/create-next-app/templates/app-tw/ts/app/page.tsx | 2 +- packages/create-next-app/templates/default-tw/js/pages/index.js | 2 +- .../create-next-app/templates/default-tw/ts/pages/index.tsx | 2 +- test/e2e/app-dir/middleware-rewrite-dynamic/app/page.tsx | 2 +- test/e2e/middleware-static-files/app/app/page.tsx | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/docs/app/page.tsx b/apps/docs/app/page.tsx index dacee02bd6e8a6..2348f6dca76b5b 100644 --- a/apps/docs/app/page.tsx +++ b/apps/docs/app/page.tsx @@ -22,7 +22,7 @@ export default function Home() { href="https://vercel.com/templates?framework=next.js" className="font-medium text-zinc-950 dark:text-zinc-50" > - Template + Templates {' '} or the{' '} - Template + Templates {" "} or the{" "} - Template + Templates {" "} or the{" "} - Template + Templates {" "} or the{" "} - Template + Templates {" "} or the{" "} - Template + Templates {' '} or the{' '} - Template + Templates {' '} or the{' '} Date: Thu, 9 Oct 2025 17:03:39 -0700 Subject: [PATCH 08/11] Fix tags check for expired/stale (#84717) Corrects the expired/stale checks in the tags manifest and updates test case to capture this. --- .../tags-manifest.external.ts | 17 +++++++---- .../e2e/app-dir/app-static/app-static.test.ts | 29 +++++++++++++++++-- .../app/api/revalidate-tag-node/route.ts | 3 +- 3 files changed, 39 insertions(+), 10 deletions(-) diff --git a/packages/next/src/server/lib/incremental-cache/tags-manifest.external.ts b/packages/next/src/server/lib/incremental-cache/tags-manifest.external.ts index 664f6e1c6b1867..f6ef586c00572a 100644 --- a/packages/next/src/server/lib/incremental-cache/tags-manifest.external.ts +++ b/packages/next/src/server/lib/incremental-cache/tags-manifest.external.ts @@ -12,9 +12,15 @@ export const tagsManifest = new Map() export const areTagsExpired = (tags: string[], timestamp: Timestamp) => { for (const tag of tags) { const entry = tagsManifest.get(tag) + const expiredAt = entry?.expired - if (entry) { - if (entry.expired && entry.expired >= timestamp) { + if (typeof expiredAt === 'number') { + const now = Date.now() + // For immediate expiration (expiredAt <= now) and tag was invalidated after entry was created + // OR for future expiration that has now passed (expiredAt > timestamp && expiredAt <= now) + const isImmediatelyExpired = expiredAt <= now && expiredAt > timestamp + + if (isImmediatelyExpired) { return true } } @@ -26,11 +32,10 @@ export const areTagsExpired = (tags: string[], timestamp: Timestamp) => { export const areTagsStale = (tags: string[], timestamp: Timestamp) => { for (const tag of tags) { const entry = tagsManifest.get(tag) + const staleAt = entry?.stale ?? 0 - if (entry) { - if (entry.stale && entry.stale >= timestamp) { - return true - } + if (typeof staleAt === 'number' && staleAt > timestamp) { + return true } } diff --git a/test/e2e/app-dir/app-static/app-static.test.ts b/test/e2e/app-dir/app-static/app-static.test.ts index 8b287804b92376..332ec665fb553e 100644 --- a/test/e2e/app-dir/app-static/app-static.test.ts +++ b/test/e2e/app-dir/app-static/app-static.test.ts @@ -4916,7 +4916,7 @@ describe('app-dir static/dynamic handling', () => { expect(await browser.elementByCss('#slug').text()).toBe('hello') }) - describe('updateTag', () => { + describe('updateTag/revalidateTag', () => { it('should throw error when updateTag is called in route handler', async () => { const res = await next.fetch('/api/update-tag-error') const data = await res.json() @@ -4944,7 +4944,7 @@ describe('app-dir static/dynamic handling', () => { }) }) - it('should use revalidateTag with max profile in server actions', async () => { + it('revalidateTag work with max profile in server actions', async () => { // First fetch to get initial data const browser = await next.browser('/update-tag-test') const initialData = JSON.parse(await browser.elementByCss('#data').text()) @@ -4954,15 +4954,38 @@ describe('app-dir static/dynamic handling', () => { // The behavior with 'max' profile would be stale-while-revalidate // Initial request after revalidation might still show stale data + let dataAfterRevalidate await retry(async () => { await browser.refresh() - const dataAfterRevalidate = JSON.parse( + dataAfterRevalidate = JSON.parse( await browser.elementByCss('#data').text() ) expect(dataAfterRevalidate).toBeDefined() expect(dataAfterRevalidate).not.toBe(initialData) }) + + if (isNextStart) { + // give second so tag isn't still stale state + await waitFor(1000) + + const res1 = await next.fetch('/update-tag-test') + const body1 = await res1.text() + const cacheHeader1 = res1.headers.get('x-nextjs-cache') + + expect(res1.status).toBe(200) + expect(cacheHeader1).toBeDefined() + expect(cacheHeader1).not.toBe('MISS') + + const res2 = await next.fetch('/update-tag-test') + const body2 = await res2.text() + const cacheHeader2 = res2.headers.get('x-nextjs-cache') + + expect(res2.status).toBe(200) + expect(cacheHeader2).toBeDefined() + expect(cacheHeader2).not.toBe('MISS') + expect(body1).toBe(body2) + } }) // Runtime logs aren't queryable in deploy mode diff --git a/test/e2e/app-dir/app-static/app/api/revalidate-tag-node/route.ts b/test/e2e/app-dir/app-static/app/api/revalidate-tag-node/route.ts index 76c7ad20fb176f..81bd39fe1960de 100644 --- a/test/e2e/app-dir/app-static/app/api/revalidate-tag-node/route.ts +++ b/test/e2e/app-dir/app-static/app/api/revalidate-tag-node/route.ts @@ -5,6 +5,7 @@ export const revalidate = 0 export async function GET(req) { const tag = req.nextUrl.searchParams.get('tag') - revalidateTag(tag, 'expireNow') + const profile = req.nextUrl.searchParams.get('profile') + revalidateTag(tag, profile || 'expireNow') return NextResponse.json({ revalidated: true, now: Date.now() }) } From b2ce9dd9c34d04c2c8d7f872bd0bb0e0f3a8d31a Mon Sep 17 00:00:00 2001 From: Zack Tanner <1939140+ztanner@users.noreply.github.com> Date: Thu, 9 Oct 2025 17:10:41 -0700 Subject: [PATCH 09/11] tweak middlewareClientMaxBodySize handling (#84712) This ensures we captured `middlewareClientMaxBodySize` in the config schema and rather than triggering a hard error, it will buffer up to the limit. --- .../middlewareClientMaxBodySize.mdx | 118 ++++++++++++++++++ .../middlewareClientMaxBodySize.mdx | 7 ++ packages/next/src/server/body-streams.ts | 9 +- packages/next/src/server/config-schema.ts | 1 + packages/next/src/server/config-shared.ts | 1 + .../app/api/echo/route.ts | 12 +- test/e2e/client-max-body-size/index.test.ts | 77 ++++++++---- 7 files changed, 198 insertions(+), 27 deletions(-) create mode 100644 docs/01-app/03-api-reference/05-config/01-next-config-js/middlewareClientMaxBodySize.mdx create mode 100644 docs/02-pages/04-api-reference/04-config/01-next-config-js/middlewareClientMaxBodySize.mdx diff --git a/docs/01-app/03-api-reference/05-config/01-next-config-js/middlewareClientMaxBodySize.mdx b/docs/01-app/03-api-reference/05-config/01-next-config-js/middlewareClientMaxBodySize.mdx new file mode 100644 index 00000000000000..f17717172244e4 --- /dev/null +++ b/docs/01-app/03-api-reference/05-config/01-next-config-js/middlewareClientMaxBodySize.mdx @@ -0,0 +1,118 @@ +--- +title: experimental.middlewareClientMaxBodySize +description: Configure the maximum request body size when using middleware. +version: experimental +--- + +When middleware is used, Next.js automatically clones the request body and buffers it in memory to enable multiple reads - both in middleware and the underlying route handler. To prevent excessive memory usage, this configuration option sets a size limit on the buffered body. + +By default, the maximum body size is **10MB**. If a request body exceeds this limit, the body will only be buffered up to the limit, and a warning will be logged indicating which route exceeded the limit. + +## Options + +### String format (recommended) + +Specify the size using a human-readable string format: + +```ts filename="next.config.ts" switcher +import type { NextConfig } from 'next' + +const nextConfig: NextConfig = { + experimental: { + middlewareClientMaxBodySize: '1mb', + }, +} + +export default nextConfig +``` + +```js filename="next.config.js" switcher +/** @type {import('next').NextConfig} */ +const nextConfig = { + experimental: { + middlewareClientMaxBodySize: '1mb', + }, +} + +module.exports = nextConfig +``` + +Supported units: `b`, `kb`, `mb`, `gb` + +### Number format + +Alternatively, specify the size in bytes as a number: + +```ts filename="next.config.ts" switcher +import type { NextConfig } from 'next' + +const nextConfig: NextConfig = { + experimental: { + middlewareClientMaxBodySize: 1048576, // 1MB in bytes + }, +} + +export default nextConfig +``` + +```js filename="next.config.js" switcher +/** @type {import('next').NextConfig} */ +const nextConfig = { + experimental: { + middlewareClientMaxBodySize: 1048576, // 1MB in bytes + }, +} + +module.exports = nextConfig +``` + +## Behavior + +When a request body exceeds the configured limit: + +1. Next.js will buffer only the first N bytes (up to the limit) +2. A warning will be logged to the console indicating the route that exceeded the limit +3. The request will continue processing normally, but only the partial body will be available +4. The request will **not** fail or return an error to the client + +If your application needs to process the full request body, you should either: + +- Increase the `middlewareClientMaxBodySize` limit +- Handle the partial body gracefully in your application logic + +## Example + +```ts filename="middleware.ts" +import { NextRequest, NextResponse } from 'next/server' + +export async function middleware(request: NextRequest) { + // Next.js automatically buffers the body with the configured size limit + // You can read the body in middleware... + const body = await request.text() + + // If the body exceeded the limit, only partial data will be available + console.log('Body size:', body.length) + + return NextResponse.next() +} +``` + +```ts filename="app/api/upload/route.ts" +import { NextRequest, NextResponse } from 'next/server' + +export async function POST(request: NextRequest) { + // ...and the body is still available in your route handler + const body = await request.text() + + console.log('Body in route handler:', body.length) + + return NextResponse.json({ received: body.length }) +} +``` + +## Good to know + +- This setting only applies when middleware is used in your application +- The default limit of 10MB is designed to balance memory usage and typical use cases +- The limit applies per-request, not globally across all concurrent requests +- For applications handling large file uploads, consider increasing the limit accordingly diff --git a/docs/02-pages/04-api-reference/04-config/01-next-config-js/middlewareClientMaxBodySize.mdx b/docs/02-pages/04-api-reference/04-config/01-next-config-js/middlewareClientMaxBodySize.mdx new file mode 100644 index 00000000000000..830e4ab56320c7 --- /dev/null +++ b/docs/02-pages/04-api-reference/04-config/01-next-config-js/middlewareClientMaxBodySize.mdx @@ -0,0 +1,7 @@ +--- +title: experimental.middlewareClientMaxBodySize +description: Configure the maximum request body size when using middleware. +source: app/api-reference/config/next-config-js/middlewareClientMaxBodySize +--- + +{/* DO NOT EDIT. The content of this doc is generated from the source above. To edit the content of this page, navigate to the source page in your editor. You can use the `Content` component to add content that is specific to the Pages Router. Any shared content should not be wrapped in a component. */} diff --git a/packages/next/src/server/body-streams.ts b/packages/next/src/server/body-streams.ts index d005106ec43446..e6b4804aaf6a70 100644 --- a/packages/next/src/server/body-streams.ts +++ b/packages/next/src/server/body-streams.ts @@ -92,11 +92,12 @@ export function getCloneableBody( if (bytesRead > bodySizeLimit) { limitExceeded = true - const error = new Error( - `Request body exceeded ${bytes.format(bodySizeLimit)}` + const urlInfo = readable.url ? ` for ${readable.url}` : '' + console.warn( + `Request body exceeded ${bytes.format(bodySizeLimit)}${urlInfo}. Only the first ${bytes.format(bodySizeLimit)} will be available unless configured. See https://nextjs.org/docs/app/api-reference/config/next-config-js/middlewareClientMaxBodySize for more details.` ) - p1.destroy(error) - p2.destroy(error) + p1.push(null) + p2.push(null) return } diff --git a/packages/next/src/server/config-schema.ts b/packages/next/src/server/config-schema.ts index dc8cd30d0784af..abfd10aec619fb 100644 --- a/packages/next/src/server/config-schema.ts +++ b/packages/next/src/server/config-schema.ts @@ -230,6 +230,7 @@ export const experimentalSchema = { linkNoTouchStart: z.boolean().optional(), manualClientBasePath: z.boolean().optional(), middlewarePrefetch: z.enum(['strict', 'flexible']).optional(), + middlewareClientMaxBodySize: zSizeLimit.optional(), multiZoneDraftMode: z.boolean().optional(), cssChunking: z.union([z.boolean(), z.literal('strict')]).optional(), nextScriptWorkers: z.boolean().optional(), diff --git a/packages/next/src/server/config-shared.ts b/packages/next/src/server/config-shared.ts index 8bb38a8c1d7ec4..97149bfeb36c2b 100644 --- a/packages/next/src/server/config-shared.ts +++ b/packages/next/src/server/config-shared.ts @@ -1498,6 +1498,7 @@ export const defaultConfig = Object.freeze({ browserDebugInfoInTerminal: false, lockDistDir: true, isolatedDevBuild: true, + middlewareClientMaxBodySize: 10_485_760, // 10MB }, htmlLimitedBots: undefined, bundlePagesRouterDependencies: false, diff --git a/test/e2e/client-max-body-size/app/api/echo/route.ts b/test/e2e/client-max-body-size/app/api/echo/route.ts index 7752aa7d12c13f..ac87608bb83b8d 100644 --- a/test/e2e/client-max-body-size/app/api/echo/route.ts +++ b/test/e2e/client-max-body-size/app/api/echo/route.ts @@ -1,5 +1,15 @@ import { NextRequest, NextResponse } from 'next/server' export async function POST(request: NextRequest) { - return new NextResponse('Hello World', { status: 200 }) + const body = await request.text() + return new NextResponse( + JSON.stringify({ + message: 'Hello World', + bodySize: body.length, + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ) } diff --git a/test/e2e/client-max-body-size/index.test.ts b/test/e2e/client-max-body-size/index.test.ts index dd4326ecf8fb3e..ecf28eff212826 100644 --- a/test/e2e/client-max-body-size/index.test.ts +++ b/test/e2e/client-max-body-size/index.test.ts @@ -11,7 +11,7 @@ describe('client-max-body-size', () => { if (skipped) return - it('should reject request body over 10MB by default', async () => { + it('should accept request body over 10MB but only buffer up to limit', async () => { const bodySize = 11 * 1024 * 1024 // 11MB const body = 'x'.repeat(bodySize) @@ -25,8 +25,15 @@ describe('client-max-body-size', () => { } ) - expect(res.status).toBe(400) - expect(next.cliOutput).toContain('Request body exceeded 10MB') + expect(res.status).toBe(200) + const responseBody = await res.json() + expect(responseBody.message).toBe('Hello World') + // Should only buffer up to 10MB, not the full 11MB + expect(responseBody.bodySize).toBeLessThanOrEqual(10 * 1024 * 1024) + expect(responseBody.bodySize).toBeLessThan(bodySize) + expect(next.cliOutput).toContain( + 'Request body exceeded 10MB for /api/echo' + ) }) it('should accept request body at exactly 10MB', async () => { @@ -44,8 +51,9 @@ describe('client-max-body-size', () => { ) expect(res.status).toBe(200) - const responseBody = await res.text() - expect(responseBody).toBe('Hello World') + const responseBody = await res.json() + expect(responseBody.message).toBe('Hello World') + expect(responseBody.bodySize).toBe(bodySize) }) it('should accept request body under 10MB', async () => { @@ -63,8 +71,9 @@ describe('client-max-body-size', () => { ) expect(res.status).toBe(200) - const responseBody = await res.text() - expect(responseBody).toBe('Hello World') + const responseBody = await res.json() + expect(responseBody.message).toBe('Hello World') + expect(responseBody.bodySize).toBe(bodySize) }) }) @@ -81,7 +90,7 @@ describe('client-max-body-size', () => { if (skipped) return - it('should reject request body over custom 5MB limit', async () => { + it('should accept request body over custom 5MB limit but only buffer up to limit', async () => { const bodySize = 6 * 1024 * 1024 // 6MB const body = 'a'.repeat(bodySize) @@ -95,8 +104,15 @@ describe('client-max-body-size', () => { } ) - expect(res.status).toBe(400) - expect(next.cliOutput).toContain('Request body exceeded 5MB') + expect(res.status).toBe(200) + const responseBody = await res.json() + expect(responseBody.message).toBe('Hello World') + // Should only buffer up to 5MB, not the full 6MB + expect(responseBody.bodySize).toBeLessThanOrEqual(5 * 1024 * 1024) + expect(responseBody.bodySize).toBeLessThan(bodySize) + expect(next.cliOutput).toContain( + 'Request body exceeded 5MB for /api/echo' + ) }) it('should accept request body under custom 5MB limit', async () => { @@ -114,8 +130,9 @@ describe('client-max-body-size', () => { ) expect(res.status).toBe(200) - const responseBody = await res.text() - expect(responseBody).toBe('Hello World') + const responseBody = await res.json() + expect(responseBody.message).toBe('Hello World') + expect(responseBody.bodySize).toBe(bodySize) }) }) @@ -132,7 +149,7 @@ describe('client-max-body-size', () => { if (skipped) return - it('should reject request body over custom 2MB limit', async () => { + it('should accept request body over custom 2MB limit but only buffer up to limit', async () => { const bodySize = 3 * 1024 * 1024 // 3MB const body = 'c'.repeat(bodySize) @@ -146,8 +163,15 @@ describe('client-max-body-size', () => { } ) - expect(res.status).toBe(400) - expect(next.cliOutput).toContain('Request body exceeded 2MB') + expect(res.status).toBe(200) + const responseBody = await res.json() + expect(responseBody.message).toBe('Hello World') + // Should only buffer up to 2MB, not the full 3MB + expect(responseBody.bodySize).toBeLessThanOrEqual(2 * 1024 * 1024) + expect(responseBody.bodySize).toBeLessThan(bodySize) + expect(next.cliOutput).toContain( + 'Request body exceeded 2MB for /api/echo' + ) }) it('should accept request body under custom 2MB limit', async () => { @@ -165,8 +189,9 @@ describe('client-max-body-size', () => { ) expect(res.status).toBe(200) - const responseBody = await res.text() - expect(responseBody).toBe('Hello World') + const responseBody = await res.json() + expect(responseBody.message).toBe('Hello World') + expect(responseBody.bodySize).toBe(bodySize) }) }) @@ -198,11 +223,12 @@ describe('client-max-body-size', () => { ) expect(res.status).toBe(200) - const responseBody = await res.text() - expect(responseBody).toBe('Hello World') + const responseBody = await res.json() + expect(responseBody.message).toBe('Hello World') + expect(responseBody.bodySize).toBe(bodySize) }) - it('should reject request body over custom 50MB limit', async () => { + it('should accept request body over custom 50MB limit but only buffer up to limit', async () => { const bodySize = 51 * 1024 * 1024 // 51MB const body = 'f'.repeat(bodySize) @@ -216,8 +242,15 @@ describe('client-max-body-size', () => { } ) - expect(res.status).toBe(400) - expect(next.cliOutput).toContain('Request body exceeded 50MB') + expect(res.status).toBe(200) + const responseBody = await res.json() + expect(responseBody.message).toBe('Hello World') + // Should only buffer up to 50MB, not the full 51MB + expect(responseBody.bodySize).toBeLessThanOrEqual(50 * 1024 * 1024) + expect(responseBody.bodySize).toBeLessThan(bodySize) + expect(next.cliOutput).toContain( + 'Request body exceeded 50MB for /api/echo' + ) }) }) }) From 73a4d0829a8e6a53af5be9c52bcf466e4941344c Mon Sep 17 00:00:00 2001 From: nextjs-bot Date: Fri, 10 Oct 2025 00:16:46 +0000 Subject: [PATCH 10/11] v15.6.0-canary.57 --- lerna.json | 2 +- packages/create-next-app/package.json | 2 +- packages/eslint-config-next/package.json | 4 ++-- packages/eslint-plugin-internal/package.json | 2 +- packages/eslint-plugin-next/package.json | 2 +- packages/font/package.json | 2 +- packages/next-bundle-analyzer/package.json | 2 +- packages/next-codemod/package.json | 2 +- packages/next-env/package.json | 2 +- packages/next-mdx/package.json | 2 +- packages/next-plugin-storybook/package.json | 2 +- packages/next-polyfill-module/package.json | 2 +- packages/next-polyfill-nomodule/package.json | 2 +- packages/next-rspack/package.json | 2 +- packages/next-swc/package.json | 2 +- packages/next/package.json | 14 +++++++------- packages/react-refresh-utils/package.json | 2 +- packages/third-parties/package.json | 4 ++-- pnpm-lock.yaml | 16 ++++++++-------- 19 files changed, 34 insertions(+), 34 deletions(-) diff --git a/lerna.json b/lerna.json index 1c06a02a47bf11..f4e58a25879e56 100644 --- a/lerna.json +++ b/lerna.json @@ -16,5 +16,5 @@ "registry": "https://registry.npmjs.org/" } }, - "version": "15.6.0-canary.56" + "version": "15.6.0-canary.57" } diff --git a/packages/create-next-app/package.json b/packages/create-next-app/package.json index 4e83178111643f..a60e4d5054d026 100644 --- a/packages/create-next-app/package.json +++ b/packages/create-next-app/package.json @@ -1,6 +1,6 @@ { "name": "create-next-app", - "version": "15.6.0-canary.56", + "version": "15.6.0-canary.57", "keywords": [ "react", "next", diff --git a/packages/eslint-config-next/package.json b/packages/eslint-config-next/package.json index 29adc1d655be65..1adc1eaf0ebe31 100644 --- a/packages/eslint-config-next/package.json +++ b/packages/eslint-config-next/package.json @@ -1,6 +1,6 @@ { "name": "eslint-config-next", - "version": "15.6.0-canary.56", + "version": "15.6.0-canary.57", "description": "ESLint configuration used by Next.js.", "main": "index.js", "license": "MIT", @@ -10,7 +10,7 @@ }, "homepage": "https://nextjs.org/docs/app/api-reference/config/eslint", "dependencies": { - "@next/eslint-plugin-next": "15.6.0-canary.56", + "@next/eslint-plugin-next": "15.6.0-canary.57", "@rushstack/eslint-patch": "^1.10.3", "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", diff --git a/packages/eslint-plugin-internal/package.json b/packages/eslint-plugin-internal/package.json index bf66f00c4e4c29..58de2830770c27 100644 --- a/packages/eslint-plugin-internal/package.json +++ b/packages/eslint-plugin-internal/package.json @@ -1,7 +1,7 @@ { "name": "@next/eslint-plugin-internal", "private": true, - "version": "15.6.0-canary.56", + "version": "15.6.0-canary.57", "description": "ESLint plugin for working on Next.js.", "exports": { ".": "./src/eslint-plugin-internal.js" diff --git a/packages/eslint-plugin-next/package.json b/packages/eslint-plugin-next/package.json index 780abd6e14481d..8729448c06f334 100644 --- a/packages/eslint-plugin-next/package.json +++ b/packages/eslint-plugin-next/package.json @@ -1,6 +1,6 @@ { "name": "@next/eslint-plugin-next", - "version": "15.6.0-canary.56", + "version": "15.6.0-canary.57", "description": "ESLint plugin for Next.js.", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/font/package.json b/packages/font/package.json index 46a1556a970708..a32781b5c747e9 100644 --- a/packages/font/package.json +++ b/packages/font/package.json @@ -1,7 +1,7 @@ { "name": "@next/font", "private": true, - "version": "15.6.0-canary.56", + "version": "15.6.0-canary.57", "repository": { "url": "vercel/next.js", "directory": "packages/font" diff --git a/packages/next-bundle-analyzer/package.json b/packages/next-bundle-analyzer/package.json index 02fef69c77755f..5b871e3480b161 100644 --- a/packages/next-bundle-analyzer/package.json +++ b/packages/next-bundle-analyzer/package.json @@ -1,6 +1,6 @@ { "name": "@next/bundle-analyzer", - "version": "15.6.0-canary.56", + "version": "15.6.0-canary.57", "main": "index.js", "types": "index.d.ts", "license": "MIT", diff --git a/packages/next-codemod/package.json b/packages/next-codemod/package.json index 240b3a76001235..3eb8be3caec454 100644 --- a/packages/next-codemod/package.json +++ b/packages/next-codemod/package.json @@ -1,6 +1,6 @@ { "name": "@next/codemod", - "version": "15.6.0-canary.56", + "version": "15.6.0-canary.57", "license": "MIT", "repository": { "type": "git", diff --git a/packages/next-env/package.json b/packages/next-env/package.json index dcabff6ea1262a..948e55bf048a75 100644 --- a/packages/next-env/package.json +++ b/packages/next-env/package.json @@ -1,6 +1,6 @@ { "name": "@next/env", - "version": "15.6.0-canary.56", + "version": "15.6.0-canary.57", "keywords": [ "react", "next", diff --git a/packages/next-mdx/package.json b/packages/next-mdx/package.json index 63180c8a28e930..13d9597eaa1837 100644 --- a/packages/next-mdx/package.json +++ b/packages/next-mdx/package.json @@ -1,6 +1,6 @@ { "name": "@next/mdx", - "version": "15.6.0-canary.56", + "version": "15.6.0-canary.57", "main": "index.js", "license": "MIT", "repository": { diff --git a/packages/next-plugin-storybook/package.json b/packages/next-plugin-storybook/package.json index 1f6151f0ea0a4d..6ff1266d7bf66c 100644 --- a/packages/next-plugin-storybook/package.json +++ b/packages/next-plugin-storybook/package.json @@ -1,6 +1,6 @@ { "name": "@next/plugin-storybook", - "version": "15.6.0-canary.56", + "version": "15.6.0-canary.57", "repository": { "url": "vercel/next.js", "directory": "packages/next-plugin-storybook" diff --git a/packages/next-polyfill-module/package.json b/packages/next-polyfill-module/package.json index 7f6e30776a80bc..adede03af5ee50 100644 --- a/packages/next-polyfill-module/package.json +++ b/packages/next-polyfill-module/package.json @@ -1,6 +1,6 @@ { "name": "@next/polyfill-module", - "version": "15.6.0-canary.56", + "version": "15.6.0-canary.57", "description": "A standard library polyfill for ES Modules supporting browsers (Edge 16+, Firefox 60+, Chrome 61+, Safari 10.1+)", "main": "dist/polyfill-module.js", "license": "MIT", diff --git a/packages/next-polyfill-nomodule/package.json b/packages/next-polyfill-nomodule/package.json index e38a7b1f0aa826..9cc0bf551062cb 100644 --- a/packages/next-polyfill-nomodule/package.json +++ b/packages/next-polyfill-nomodule/package.json @@ -1,6 +1,6 @@ { "name": "@next/polyfill-nomodule", - "version": "15.6.0-canary.56", + "version": "15.6.0-canary.57", "description": "A polyfill for non-dead, nomodule browsers.", "main": "dist/polyfill-nomodule.js", "license": "MIT", diff --git a/packages/next-rspack/package.json b/packages/next-rspack/package.json index 2270e934856941..9e0995ca8b1cdb 100644 --- a/packages/next-rspack/package.json +++ b/packages/next-rspack/package.json @@ -1,6 +1,6 @@ { "name": "next-rspack", - "version": "15.6.0-canary.56", + "version": "15.6.0-canary.57", "repository": { "url": "vercel/next.js", "directory": "packages/next-rspack" diff --git a/packages/next-swc/package.json b/packages/next-swc/package.json index 061f4ecc1ee2e0..349d05da493b1a 100644 --- a/packages/next-swc/package.json +++ b/packages/next-swc/package.json @@ -1,6 +1,6 @@ { "name": "@next/swc", - "version": "15.6.0-canary.56", + "version": "15.6.0-canary.57", "private": true, "files": [ "native/" diff --git a/packages/next/package.json b/packages/next/package.json index ef134c0ccba4a9..6ecab7ff9b1e32 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -1,6 +1,6 @@ { "name": "next", - "version": "15.6.0-canary.56", + "version": "15.6.0-canary.57", "description": "The React Framework", "main": "./dist/server/next.js", "license": "MIT", @@ -98,7 +98,7 @@ ] }, "dependencies": { - "@next/env": "15.6.0-canary.56", + "@next/env": "15.6.0-canary.57", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", @@ -162,11 +162,11 @@ "@modelcontextprotocol/sdk": "1.18.1", "@mswjs/interceptors": "0.23.0", "@napi-rs/triples": "1.2.0", - "@next/font": "15.6.0-canary.56", - "@next/polyfill-module": "15.6.0-canary.56", - "@next/polyfill-nomodule": "15.6.0-canary.56", - "@next/react-refresh-utils": "15.6.0-canary.56", - "@next/swc": "15.6.0-canary.56", + "@next/font": "15.6.0-canary.57", + "@next/polyfill-module": "15.6.0-canary.57", + "@next/polyfill-nomodule": "15.6.0-canary.57", + "@next/react-refresh-utils": "15.6.0-canary.57", + "@next/swc": "15.6.0-canary.57", "@opentelemetry/api": "1.6.0", "@playwright/test": "1.51.1", "@rspack/core": "1.5.0", diff --git a/packages/react-refresh-utils/package.json b/packages/react-refresh-utils/package.json index 22b8c6cef42d77..374dfd6f885118 100644 --- a/packages/react-refresh-utils/package.json +++ b/packages/react-refresh-utils/package.json @@ -1,6 +1,6 @@ { "name": "@next/react-refresh-utils", - "version": "15.6.0-canary.56", + "version": "15.6.0-canary.57", "description": "An experimental package providing utilities for React Refresh.", "repository": { "url": "vercel/next.js", diff --git a/packages/third-parties/package.json b/packages/third-parties/package.json index e9d93b8267f6df..35f9f4d948a827 100644 --- a/packages/third-parties/package.json +++ b/packages/third-parties/package.json @@ -1,6 +1,6 @@ { "name": "@next/third-parties", - "version": "15.6.0-canary.56", + "version": "15.6.0-canary.57", "repository": { "url": "vercel/next.js", "directory": "packages/third-parties" @@ -26,7 +26,7 @@ "third-party-capital": "1.0.20" }, "devDependencies": { - "next": "15.6.0-canary.56", + "next": "15.6.0-canary.57", "outdent": "0.8.0", "prettier": "2.5.1", "typescript": "5.9.2" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 736f49e2b9c959..b107494c9a5288 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -899,7 +899,7 @@ importers: packages/eslint-config-next: dependencies: '@next/eslint-plugin-next': - specifier: 15.6.0-canary.56 + specifier: 15.6.0-canary.57 version: link:../eslint-plugin-next '@rushstack/eslint-patch': specifier: ^1.10.3 @@ -972,7 +972,7 @@ importers: packages/next: dependencies: '@next/env': - specifier: 15.6.0-canary.56 + specifier: 15.6.0-canary.57 version: link:../next-env '@swc/helpers': specifier: 0.5.15 @@ -1097,19 +1097,19 @@ importers: specifier: 1.2.0 version: 1.2.0 '@next/font': - specifier: 15.6.0-canary.56 + specifier: 15.6.0-canary.57 version: link:../font '@next/polyfill-module': - specifier: 15.6.0-canary.56 + specifier: 15.6.0-canary.57 version: link:../next-polyfill-module '@next/polyfill-nomodule': - specifier: 15.6.0-canary.56 + specifier: 15.6.0-canary.57 version: link:../next-polyfill-nomodule '@next/react-refresh-utils': - specifier: 15.6.0-canary.56 + specifier: 15.6.0-canary.57 version: link:../react-refresh-utils '@next/swc': - specifier: 15.6.0-canary.56 + specifier: 15.6.0-canary.57 version: link:../next-swc '@opentelemetry/api': specifier: 1.6.0 @@ -1803,7 +1803,7 @@ importers: version: 1.0.20 devDependencies: next: - specifier: 15.6.0-canary.56 + specifier: 15.6.0-canary.57 version: link:../next outdent: specifier: 0.8.0 From 61a8037835a715f36dd52532ba049f81d4f532d4 Mon Sep 17 00:00:00 2001 From: Benjamin Woodruff Date: Thu, 9 Oct 2025 17:28:04 -0700 Subject: [PATCH 11/11] Rspack: Fix lockfile test on rspack (#84707) Noticed this on https://github.com/vercel/next.js/pull/84673 We don't run rspack test on every PR, so this wasn't caught before. --- test/development/lockfile/lockfile.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/test/development/lockfile/lockfile.test.ts b/test/development/lockfile/lockfile.test.ts index fcf2b3c6f7bf1a..6852de600e11ed 100644 --- a/test/development/lockfile/lockfile.test.ts +++ b/test/development/lockfile/lockfile.test.ts @@ -3,7 +3,7 @@ import execa from 'execa' import stripAnsi from 'strip-ansi' describe('lockfile', () => { - const { next, isTurbopack } = nextTestSetup({ + const { next, isTurbopack, isRspack } = nextTestSetup({ files: __dirname, }) @@ -13,7 +13,11 @@ describe('lockfile', () => { const { stdout, stderr, exitCode } = await execa( 'pnpm', - ['next', 'dev', isTurbopack ? '--turbopack' : '--webpack'], + [ + 'next', + 'dev', + ...(isRspack ? [] : [isTurbopack ? '--turbopack' : '--webpack']), + ], { cwd: next.testDir, env: next.env as NodeJS.ProcessEnv,