diff --git a/.github/workflows/trigger_release.yml b/.github/workflows/trigger_release.yml index 23d2d3421cd8c..c1ba7bd15a1c5 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 9e31cb4ec1bea..dedf47a092e19 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/apps/docs/app/page.tsx b/apps/docs/app/page.tsx index dacee02bd6e8a..2348f6dca76b5 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{' '} 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/crates/next-custom-transforms/src/transforms/react_server_components.rs b/crates/next-custom-transforms/src/transforms/react_server_components.rs index 8c03305cfdd34..6f841be1156ff 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 3b4f41398abf6..c4ce6b157443c 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/02-components/image.mdx b/docs/01-app/03-api-reference/02-components/image.mdx index 3c4088671d45e..e25a531a76a33 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/docs/01-app/03-api-reference/03-file-conventions/default.mdx b/docs/01-app/03-api-reference/03-file-conventions/default.mdx index 68f32a30b67d8..17710a5373827 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/docs/01-app/03-api-reference/04-functions/revalidateTag.mdx b/docs/01-app/03-api-reference/04-functions/revalidateTag.mdx index af12f332b48b7..ab2f553df84ce 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 0000000000000..36ea5963d6028 --- /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/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 0000000000000..f17717172244e --- /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 0000000000000..830e4ab56320c --- /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/errors/revalidate-tag-single-arg.mdx b/errors/revalidate-tag-single-arg.mdx new file mode 100644 index 0000000000000..0241140ac8045 --- /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/errors/slot-missing-default.mdx b/errors/slot-missing-default.mdx new file mode 100644 index 0000000000000..c72e4b14a5cc5 --- /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/lerna.json b/lerna.json index 4467151454903..f4e58a25879e5 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.57" } diff --git a/packages/create-next-app/package.json b/packages/create-next-app/package.json index 208b1e9c4bc10..a60e4d5054d02 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.57", "keywords": [ "react", "next", diff --git a/packages/create-next-app/templates/app-tw/js/app/page.js b/packages/create-next-app/templates/app-tw/js/app/page.js index d72c3359aa7c2..691817bc68b30 100644 --- a/packages/create-next-app/templates/app-tw/js/app/page.js +++ b/packages/create-next-app/templates/app-tw/js/app/page.js @@ -22,7 +22,7 @@ export default function Home() { href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" 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{" "} 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/build/webpack/plugins/next-types-plugin/index.ts b/packages/next/src/build/webpack/plugins/next-types-plugin/index.ts index 394e7b8ebe178..75ec9bb807839 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/compiled/is-local-address/index.js b/packages/next/src/compiled/is-local-address/index.js new file mode 100644 index 0000000000000..034f63037d60e --- /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 0000000000000..61bbef852181e --- /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/app-render/action-handler.ts b/packages/next/src/server/app-render/action-handler.ts index ad1a80d6178a2..3bf2fb445f9a1 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 27e310a1d4c2e..8c03c769a0803 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 27770b9d3f944..cbea9734c035f 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/body-streams.ts b/packages/next/src/server/body-streams.ts index d005106ec4344..e6b4804aaf6a7 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 a1bd2b7cee864..abfd10aec619f 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(), @@ -551,6 +552,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 +570,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/config-shared.ts b/packages/next/src/server/config-shared.ts index be8699e10486f..97149bfeb36c2 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: { @@ -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/packages/next/src/server/image-optimizer.ts b/packages/next/src/server/image-optimizer.ts index 6d4c098ec67ec..c99b4ddf98ead 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/lib/cache-handlers/default.external.ts b/packages/next/src/server/lib/cache-handlers/default.external.ts index 5e6044207071e..c81b7c6cad48e 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 8cbb91257224d..eee58946be7c6 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 a8ec951f1576c..becffcea007b7 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 4f944feb861c3..7582532d346f5 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 06172d493ff62..8840a2d4bf379 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 adb92884c8fb2..f6ef586c00572 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,14 +1,40 @@ 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) + const expiredAt = entry?.expired + + 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 + } + } + } + + 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) + const staleAt = entry?.stale ?? 0 - if (typeof revalidatedAt === 'number' && revalidatedAt >= timestamp) { + if (typeof staleAt === 'number' && staleAt > timestamp) { return true } } diff --git a/packages/next/src/server/next-server.ts b/packages/next/src/server/next-server.ts index 0d5b64e19f1ac..879c81d7ac613 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/server/response-cache/types.ts b/packages/next/src/server/response-cache/types.ts index 3a64e7d98e5bb..2db060fb53d90 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 acd305ce17b81..cedacbe5c94a7 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 de98066909d42..0e9df849f6b16 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 ff79577ea9ea6..b287fd70d1346 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 91845dc54a2ea..da9c2b4bf7f98 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/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 0000000000000..4a7d8051de306 --- /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/packages/next/src/shared/lib/image-config.ts b/packages/next/src/shared/lib/image-config.ts index abd1b4939da9b..72d91d24328c1 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 1d717ae5e8612..4fc2f0edf4ff0 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 b9c9770737422..e7f479b865b2d 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/packages/react-refresh-utils/package.json b/packages/react-refresh-utils/package.json index d6a90de946d25..374dfd6f88511 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.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 0145710202162..35f9f4d948a82 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.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.54", + "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 6d2f8fbd7caa6..b107494c9a528 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 @@ -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.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.54 + 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.54 + specifier: 15.6.0-canary.57 version: link:../font '@next/polyfill-module': - specifier: 15.6.0-canary.54 + specifier: 15.6.0-canary.57 version: link:../next-polyfill-module '@next/polyfill-nomodule': - specifier: 15.6.0-canary.54 + specifier: 15.6.0-canary.57 version: link:../next-polyfill-nomodule '@next/react-refresh-utils': - specifier: 15.6.0-canary.54 + specifier: 15.6.0-canary.57 version: link:../react-refresh-utils '@next/swc': - specifier: 15.6.0-canary.54 + specifier: 15.6.0-canary.57 version: link:../next-swc '@opentelemetry/api': specifier: 1.6.0 @@ -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 @@ -1800,7 +1803,7 @@ importers: version: 1.0.20 devDependencies: next: - specifier: 15.6.0-canary.54 + specifier: 15.6.0-canary.57 version: link:../next outdent: specifier: 0.8.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} @@ -9296,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'} @@ -11073,6 +11058,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 +14762,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 +21083,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 +26740,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 +26773,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 +26805,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 +26839,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 +26868,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 +26879,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 @@ -27078,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 @@ -29475,6 +29369,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/scripts/release/version-packages.ts b/scripts/release/version-packages.ts index cc038af03f1e3..d281cb20afd8c 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 7af267a3edd21..ea0190ac4346a 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, diff --git a/test/deploy-tests-manifest.json b/test/deploy-tests-manifest.json index 62c1bd105ffc0..e5b5aa957435c 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 5aa667268aa15..0000000000000 --- 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 dbe846ba5fc49..0000000000000 --- 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 5104550c4cb46..925d2e48a1aa1 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 f25fc50345faf..5b80930484c8a 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 cbe0f376b6be6..0bab623bee778 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/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 0000000000000..6dbe479afc29b --- /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 0000000000000..6dbe479afc29b --- /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/lockfile/lockfile.test.ts b/test/development/lockfile/lockfile.test.ts index fcf2b3c6f7bf1..6852de600e11e 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, 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 0000000000000..6dbe479afc29b --- /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 0000000000000..6dbe479afc29b --- /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/actions-revalidate-remount/app/test/page.tsx b/test/e2e/app-dir/actions-revalidate-remount/app/test/page.tsx index e4fb27b7b03a2..beabff09c1be2 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 6047cf948ed9d..caddec0e62770 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 e1f906a5f564d..b84fd819aca04 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 e80ff6f3ad7a3..94b37998dbee4 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 21de7a0a787b9..e067e07fcb4f2 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 3609ab78a6896..30ced161d72b0 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 3b75c88146cb3..ebbee4cc00b60 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 ef27713d7e241..f7127bd0bffe7 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 348836444dd62..f3dc0d5e73fc3 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-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 0000000000000..6dbe479afc29b --- /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/app-static/app-static.test.ts b/test/e2e/app-dir/app-static/app-static.test.ts index 5bda44db2499a..332ec665fb553 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,95 @@ 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/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() + + 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('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()) + + // 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 + let dataAfterRevalidate + await retry(async () => { + await browser.refresh() + 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 + 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 a252d436c4626..ef0efdcbf8f50 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 daa0a3066bb19..a43a5cecb4082 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 ca99fe330c551..2f5fd4df3b6d5 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 11e9494a44cb1..81bd39fe1960d 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,11 @@ 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) + const profile = req.nextUrl.searchParams.get('profile') + revalidateTag(tag, profile || '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 0000000000000..ffbebc2c199f9 --- /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 c0908b70dc090..087d5f8a95150 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 a59e2d53dd2ed..eed0b94eea6cd 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 50453a91b64bc..f5bb362eb686f 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 0000000000000..dfec8df828b36 --- /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 0000000000000..ae42fbb1755af --- /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 0000000000000..76347aa93ff10 --- /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 7110edcf43369..e09c13d9fd947 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/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 0000000000000..6dbe479afc29b --- /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/dynamic-interception-route-revalidate/app/[locale]/actions.ts b/test/e2e/app-dir/dynamic-interception-route-revalidate/app/[locale]/actions.ts index d4bdaffa25f09..902a6d21f7a3a 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 b58db456d943d..e77c0361771e8 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/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 0000000000000..6dbe479afc29b --- /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 0000000000000..6dbe479afc29b --- /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 0000000000000..6dbe479afc29b --- /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/middleware-rewrite-dynamic/app/page.tsx b/test/e2e/app-dir/middleware-rewrite-dynamic/app/page.tsx index dc6405889a044..6517e28e985bf 100644 --- a/test/e2e/app-dir/middleware-rewrite-dynamic/app/page.tsx +++ b/test/e2e/app-dir/middleware-rewrite-dynamic/app/page.tsx @@ -22,7 +22,7 @@ export default function Home() { href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" className="font-medium text-zinc-950 dark:text-zinc-50" > - Template + Templates
{' '} or the{' '} { - // 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-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 0000000000000..6dbe479afc29b --- /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 0000000000000..6dbe479afc29b --- /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 0000000000000..6dbe479afc29b --- /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 0000000000000..6dbe479afc29b --- /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 0000000000000..6dbe479afc29b --- /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 19231e455a8e8..23bdb2111c8ab 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 0000000000000..6dbe479afc29b --- /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 0000000000000..6dbe479afc29b --- /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 0000000000000..6dbe479afc29b --- /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 0000000000000..6dbe479afc29b --- /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 0000000000000..6dbe479afc29b --- /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 0000000000000..6dbe479afc29b --- /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 0000000000000..6dbe479afc29b --- /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 0000000000000..6dbe479afc29b --- /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 0000000000000..6dbe479afc29b --- /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 0000000000000..6dbe479afc29b --- /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 0000000000000..6dbe479afc29b --- /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 0000000000000..6dbe479afc29b --- /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 0000000000000..6dbe479afc29b --- /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 0000000000000..6dbe479afc29b --- /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 0000000000000..6dbe479afc29b --- /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 0000000000000..6dbe479afc29b --- /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 0000000000000..6dbe479afc29b --- /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 0000000000000..6dbe479afc29b --- /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 0000000000000..6dbe479afc29b --- /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-revalidation/app/@dialog/revalidate-modal/page.tsx b/test/e2e/app-dir/parallel-routes-revalidation/app/@dialog/revalidate-modal/page.tsx index 6cb45f0cb8429..82768d0dc084b 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 1d612f3c5bb6e..dc194b76a78ef 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 3aa8fecf8e69f..3a45e68c6e3e9 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/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 0000000000000..6dbe479afc29b --- /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/ppr-full/app/api/revalidate/route.js b/test/e2e/app-dir/ppr-full/app/api/revalidate/route.js index cdb5a911021e7..d7dd340d86cf3 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 fd76e4e68909d..382ac84c84bbc 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 965373b19588f..e0e7fecdc7757 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 569645441c959..602822742a442 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 4b5bd2e2bfd13..c668f87f2b5e3 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 0000000000000..3a2e37c1c3b32 --- /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 cf1fce5f684c8..ccaad43fc3202 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 d8b29d452776e..226b0e84f361c 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 e69c73172687b..8a56bfe512b4f 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 d321f40b65cf6..0b305f6b84b98 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/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 0000000000000..6dbe479afc29b --- /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 0000000000000..6dbe479afc29b --- /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 0000000000000..6dbe479afc29b --- /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 0000000000000..6dbe479afc29b --- /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/segment-cache/revalidation/app/page.tsx b/test/e2e/app-dir/segment-cache/revalidation/app/page.tsx index d4a410c6f1061..5521a212d9a73 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/typed-routes/app/dashboard/@analytics/default.tsx b/test/e2e/app-dir/typed-routes/app/dashboard/@analytics/default.tsx new file mode 100644 index 0000000000000..6dbe479afc29b --- /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 0000000000000..6dbe479afc29b --- /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/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 67c60b1382de1..0000000000000 --- 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 ce1c9b713c6d4..5f0c7f860bcea 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 c0198d7387fd0..40100e166386b 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 6fe8f06b4b291..0000000000000 --- 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 373992347171e..ebc719d50aeb4 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 72c5469ec9992..98375aaf411ae 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 f96828fa42b43..158b4ee6afe29 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 77b10c50c1d67..871f463d0d870 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 3ca4e89352798..984f68a3dd40b 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 2b9ef977ca84c..4f6ddf7baff20 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 32ea86041ec45..504805f15dd08 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 b1c00b3d8cd76..406bcbd170979 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 bf456aebceb97..84950005bcb36 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/client-max-body-size/app/api/echo/route.ts b/test/e2e/client-max-body-size/app/api/echo/route.ts index 7752aa7d12c13..ac87608bb83b8 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 dd4326ecf8fb3..ecf28eff21282 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' + ) }) }) }) diff --git a/test/e2e/middleware-static-files/app/app/page.tsx b/test/e2e/middleware-static-files/app/app/page.tsx index dc6405889a044..6517e28e985bf 100644 --- a/test/e2e/middleware-static-files/app/app/page.tsx +++ b/test/e2e/middleware-static-files/app/app/page.tsx @@ -22,7 +22,7 @@ export default function Home() { href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" className="font-medium text-zinc-950 dark:text-zinc-50" > - Template + Templates
{' '} or the{' '} { + 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 0000000000000..2a774169d592d --- /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 73c2c810621e1..ebe1ef4fc7ab5 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 dbc96e1116e91..3682ea1d04689 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 30e4907ec04b9..8cfc8a713b552 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 67efd5f01a011..8589d740bd929 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 c316d738c5b98..821982b6bb870 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 34c6996e43503..7d2beb43aa724 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 2304d3e784cd4..4c78061c99668 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/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 399578325a3d0..595c0d456bb85 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 bf7f79e2414b3..3cababab9493f 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 5bf8b9f6b99f5..3da611fa376bc 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/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 0000000000000..6dbe479afc29b --- /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 0000000000000..6dbe479afc29b --- /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/production/app-dir/resume-data-cache/app/revalidate/route.ts b/test/production/app-dir/resume-data-cache/app/revalidate/route.ts index 4100bb53cdd06..e26460e8628b0 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 cf8a60b4804fc..d875a2fbebf6e 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 6394d3d69efff..2d1d363f655c8 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 283beaa1f3ab4..6ee7e200fccf2 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/production/next-server-nft/next-server-nft.test.ts b/test/production/next-server-nft/next-server-nft.test.ts index 1c98f5e5f76b1..ea49dc5a59732 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", diff --git a/test/rspack-build-tests-manifest.json b/test/rspack-build-tests-manifest.json index b9dea3b9b53d9..f0615fd513ac8 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 133d15bc3d24a..d52767cb457fd 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 2d0f6ad28dfa1..74124d0bf09ac 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 1acad1033dc01..15665e2de480a 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", 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 0000000000000..6dbe479afc29b --- /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() +}