From 808d2ba3e083930561fdd5071ffe0a78fa361da9 Mon Sep 17 00:00:00 2001 From: Zack Tanner <1939140+ztanner@users.noreply.github.com> Date: Sat, 18 Jan 2025 11:24:58 -0800 Subject: [PATCH] error when output: export is used with intercepting routes (#75058) Intercepting routes are built on top of [rewrites](https://nextjs.org/docs/app/api-reference/config/next-config-js/rewrites) which is one of the listed [unsupported features](https://nextjs.org/docs/app/building-your-application/deploying/static-exports#unsupported-features) of `output: "export"`. However, because the Next.js server injects the interception routes, it's not caught by existing validation logic in the export flow. This properly documents that intercepting routes are not currently supported by `output: "export"` and hard errors in next dev/build if detected. Previously route interception would just not have worked, instead serving the non-intercepted page when built, leading to a confusing experience. We eventually want to support this with improved SPA/export features, however that's out of scope for this PR: the goal here is to ensure we're providing more immediate feedback about the fact that this is unsupported. --- .../10-deploying/02-static-exports.mdx | 1 + packages/next/errors.json | 4 ++- packages/next/src/export/index.ts | 20 ++++++++++++- .../next/src/server/dev/next-dev-server.ts | 8 +++++ .../app-dir/actions/app-action-export.test.ts | 2 ++ .../app/@modal/(.)page/page.tsx | 3 ++ .../app/@modal/default.tsx | 1 + .../app/default.tsx | 3 ++ .../app/layout.tsx | 13 ++++++++ .../app/page.tsx | 9 ++++++ .../app/page/page.tsx | 3 ++ .../interception-routes-output-export.test.ts | 30 +++++++++++++++++++ .../next.config.js | 6 ++++ 13 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 test/e2e/app-dir/interception-routes-output-export/app/@modal/(.)page/page.tsx create mode 100644 test/e2e/app-dir/interception-routes-output-export/app/@modal/default.tsx create mode 100644 test/e2e/app-dir/interception-routes-output-export/app/default.tsx create mode 100644 test/e2e/app-dir/interception-routes-output-export/app/layout.tsx create mode 100644 test/e2e/app-dir/interception-routes-output-export/app/page.tsx create mode 100644 test/e2e/app-dir/interception-routes-output-export/app/page/page.tsx create mode 100644 test/e2e/app-dir/interception-routes-output-export/interception-routes-output-export.test.ts create mode 100644 test/e2e/app-dir/interception-routes-output-export/next.config.js diff --git a/docs/01-app/03-building-your-application/10-deploying/02-static-exports.mdx b/docs/01-app/03-building-your-application/10-deploying/02-static-exports.mdx index 2f2c375de57ce..e16c482900b06 100644 --- a/docs/01-app/03-building-your-application/10-deploying/02-static-exports.mdx +++ b/docs/01-app/03-building-your-application/10-deploying/02-static-exports.mdx @@ -288,6 +288,7 @@ Features that require a Node.js server, or dynamic logic that cannot be computed - [Image Optimization](/docs/app/building-your-application/optimizing/images) with the default `loader` - [Draft Mode](/docs/app/building-your-application/configuring/draft-mode) - [Server Actions](/docs/app/building-your-application/data-fetching/server-actions-and-mutations) +- [Intercepting Routes](/docs/app/building-your-application/routing/intercepting-routes) Attempting to use any of these features with `next dev` will result in an error, similar to setting the [`dynamic`](/docs/app/api-reference/file-conventions/route-segment-config#dynamic) option to `error` in the root layout. diff --git a/packages/next/errors.json b/packages/next/errors.json index f3a918ac233fe..fb9f2243e2468 100644 --- a/packages/next/errors.json +++ b/packages/next/errors.json @@ -622,5 +622,7 @@ "621": "Required root params (%s) were not provided in generateStaticParams for %s, please provide at least one value for each.", "622": "A required root parameter (%s) was not provided in generateStaticParams for %s, please provide at least one value.", "623": "Invalid quality prop (%s) on \\`next/image\\` does not match \\`images.qualities\\` configured in your \\`next.config.js\\`\\nSee more info: https://nextjs.org/docs/messages/next-image-unconfigured-qualities", - "624": "Internal Next.js Error: createMutableActionQueue was called more than once" + "624": "Internal Next.js Error: createMutableActionQueue was called more than once", + "625": "Server Actions are not supported with static export.\\nRead more: https://nextjs.org/docs/app/building-your-application/deploying/static-exports#unsupported-features", + "626": "Intercepting routes are not supported with static export.\\nRead more: https://nextjs.org/docs/app/building-your-application/deploying/static-exports#unsupported-features" } diff --git a/packages/next/src/export/index.ts b/packages/next/src/export/index.ts index e09daa276aa73..069691a42115e 100644 --- a/packages/next/src/export/index.ts +++ b/packages/next/src/export/index.ts @@ -32,6 +32,7 @@ import { SERVER_DIRECTORY, SERVER_REFERENCE_MANIFEST, APP_PATH_ROUTES_MANIFEST, + ROUTES_MANIFEST, } from '../shared/lib/constants' import loadConfig from '../server/config' import type { ExportPathMap } from '../server/config-shared' @@ -52,6 +53,7 @@ import { formatManifest } from '../build/manifests/formatter/format-manifest' import { TurborepoAccessTraceResult } from '../build/turborepo-access-trace' import { createProgress } from '../build/progress' import type { DeepReadonly } from '../shared/lib/deep-readonly' +import { isInterceptionRouteRewrite } from '../lib/generate-interception-routes-rewrites' export class ExportError extends Error { code = 'NEXT_EXPORT_ERROR' @@ -302,13 +304,29 @@ async function exportAppImpl( serverActionsManifest = require( join(distDir, SERVER_DIRECTORY, SERVER_REFERENCE_MANIFEST + '.json') ) + if (nextConfig.output === 'export') { + const routesManifest = require(join(distDir, ROUTES_MANIFEST)) + + // We already prevent rewrites earlier in the process, however Next.js will insert rewrites + // for interception routes so we need to check for that here. + if (routesManifest?.rewrites?.beforeFiles?.length > 0) { + const hasInterceptionRouteRewrite = + routesManifest.rewrites.beforeFiles.some(isInterceptionRouteRewrite) + + if (hasInterceptionRouteRewrite) { + throw new ExportError( + `Intercepting routes are not supported with static export.\nRead more: https://nextjs.org/docs/app/building-your-application/deploying/static-exports#unsupported-features` + ) + } + } + if ( Object.keys(serverActionsManifest.node).length > 0 || Object.keys(serverActionsManifest.edge).length > 0 ) { throw new ExportError( - `Server Actions are not supported with static export.` + `Server Actions are not supported with static export.\nRead more: https://nextjs.org/docs/app/building-your-application/deploying/static-exports#unsupported-features` ) } } diff --git a/packages/next/src/server/dev/next-dev-server.ts b/packages/next/src/server/dev/next-dev-server.ts index 9b0e543261517..6b7092c4252fe 100644 --- a/packages/next/src/server/dev/next-dev-server.ts +++ b/packages/next/src/server/dev/next-dev-server.ts @@ -604,6 +604,14 @@ export default class DevServer extends Server { this.nextConfig.basePath ).map((route) => new RegExp(buildCustomRoute('rewrite', route).regex)) + if (this.nextConfig.output === 'export' && rewrites.length > 0) { + Log.error( + 'Intercepting routes are not supported with static export.\nRead more: https://nextjs.org/docs/app/building-your-application/deploying/static-exports#unsupported-features' + ) + + process.exit(1) + } + return rewrites ?? [] } diff --git a/test/e2e/app-dir/actions/app-action-export.test.ts b/test/e2e/app-dir/actions/app-action-export.test.ts index 7223b0b5d6a60..66288756848cd 100644 --- a/test/e2e/app-dir/actions/app-action-export.test.ts +++ b/test/e2e/app-dir/actions/app-action-export.test.ts @@ -27,6 +27,8 @@ describe('app-dir action handling - next export', () => { } ` ) + // interception routes are also not supported with export + await next.remove('app/interception-routes') try { await next.start() } catch {} diff --git a/test/e2e/app-dir/interception-routes-output-export/app/@modal/(.)page/page.tsx b/test/e2e/app-dir/interception-routes-output-export/app/@modal/(.)page/page.tsx new file mode 100644 index 0000000000000..7c48f6584136b --- /dev/null +++ b/test/e2e/app-dir/interception-routes-output-export/app/@modal/(.)page/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return 'intercepted' +} diff --git a/test/e2e/app-dir/interception-routes-output-export/app/@modal/default.tsx b/test/e2e/app-dir/interception-routes-output-export/app/@modal/default.tsx new file mode 100644 index 0000000000000..ad4e17b5767f9 --- /dev/null +++ b/test/e2e/app-dir/interception-routes-output-export/app/@modal/default.tsx @@ -0,0 +1 @@ +export default () => null diff --git a/test/e2e/app-dir/interception-routes-output-export/app/default.tsx b/test/e2e/app-dir/interception-routes-output-export/app/default.tsx new file mode 100644 index 0000000000000..86b9e9a388129 --- /dev/null +++ b/test/e2e/app-dir/interception-routes-output-export/app/default.tsx @@ -0,0 +1,3 @@ +export default function Default() { + return null +} diff --git a/test/e2e/app-dir/interception-routes-output-export/app/layout.tsx b/test/e2e/app-dir/interception-routes-output-export/app/layout.tsx new file mode 100644 index 0000000000000..6a674f5ea764a --- /dev/null +++ b/test/e2e/app-dir/interception-routes-output-export/app/layout.tsx @@ -0,0 +1,13 @@ +export default function Layout(props: { + children: React.ReactNode + modal: React.ReactNode +}) { + return ( + + +
{props.children}
+ + + + ) +} diff --git a/test/e2e/app-dir/interception-routes-output-export/app/page.tsx b/test/e2e/app-dir/interception-routes-output-export/app/page.tsx new file mode 100644 index 0000000000000..dc7913ffcf588 --- /dev/null +++ b/test/e2e/app-dir/interception-routes-output-export/app/page.tsx @@ -0,0 +1,9 @@ +import Link from 'next/link' + +export default function Page() { + return ( +
+ To /page +
+ ) +} diff --git a/test/e2e/app-dir/interception-routes-output-export/app/page/page.tsx b/test/e2e/app-dir/interception-routes-output-export/app/page/page.tsx new file mode 100644 index 0000000000000..ff7159d9149fe --- /dev/null +++ b/test/e2e/app-dir/interception-routes-output-export/app/page/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return

hello world

+} diff --git a/test/e2e/app-dir/interception-routes-output-export/interception-routes-output-export.test.ts b/test/e2e/app-dir/interception-routes-output-export/interception-routes-output-export.test.ts new file mode 100644 index 0000000000000..ca18ed020972b --- /dev/null +++ b/test/e2e/app-dir/interception-routes-output-export/interception-routes-output-export.test.ts @@ -0,0 +1,30 @@ +import { isNextDev, isNextStart } from 'e2e-utils' +import { findPort, killApp, launchApp, nextBuild, retry } from 'next-test-utils' + +describe('interception-routes-output-export', () => { + it('should error when using interception routes with static export', async () => { + if (isNextStart) { + const { code, stderr } = await nextBuild(__dirname, [], { stderr: true }) + expect(stderr).toContain( + 'Intercepting routes are not supported with static export.' + ) + expect(code).toBe(1) + } else if (isNextDev) { + let stderr = '' + const port = await findPort() + const app = await launchApp(__dirname, port, { + onStderr(msg) { + stderr += msg || '' + }, + }) + + await retry(async () => { + expect(stderr).toContain( + 'Intercepting routes are not supported with static export.' + ) + }) + + await killApp(app) + } + }) +}) diff --git a/test/e2e/app-dir/interception-routes-output-export/next.config.js b/test/e2e/app-dir/interception-routes-output-export/next.config.js new file mode 100644 index 0000000000000..db98d74fb2c31 --- /dev/null +++ b/test/e2e/app-dir/interception-routes-output-export/next.config.js @@ -0,0 +1,6 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = { output: 'export' } + +module.exports = nextConfig