diff --git a/packages/next/errors.json b/packages/next/errors.json index 63f9e57be9490e..3819021a442e0f 100644 --- a/packages/next/errors.json +++ b/packages/next/errors.json @@ -618,5 +618,7 @@ "617": "A required parameter (%s) was not provided as a string received %s in generateStaticParams for %s", "618": "A required parameter (%s) was not provided as an array received %s in generateStaticParams for %s", "619": "Page not found", - "620": "A required parameter (%s) was not provided as %s received %s in getStaticPaths for %s" + "620": "A required parameter (%s) was not provided as %s received %s in getStaticPaths for %s", + "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." } diff --git a/packages/next/src/build/static-paths/app.ts b/packages/next/src/build/static-paths/app.ts index 1ace85d7991327..1667ccedf76605 100644 --- a/packages/next/src/build/static-paths/app.ts +++ b/packages/next/src/build/static-paths/app.ts @@ -51,12 +51,12 @@ function areParamValuesEqual(a: ParamValue, b: ParamValue) { /** * Filters out duplicate parameters from a list of parameters. * - * @param paramKeys - The keys of the parameters. + * @param routeParamKeys - The keys of the parameters. * @param routeParams - The list of parameters to filter. * @returns The list of unique parameters. */ function filterUniqueParams( - paramKeys: readonly string[], + routeParamKeys: readonly string[], routeParams: readonly Params[] ): Params[] { const unique: Params[] = [] @@ -66,8 +66,8 @@ function filterUniqueParams( for (; i < unique.length; i++) { const item = unique[i] let j = 0 - for (; j < paramKeys.length; j++) { - const key = paramKeys[j] + for (; j < routeParamKeys.length; j++) { + const key = routeParamKeys[j] // If the param is not the same, then we need to break out of the loop. if (!areParamValuesEqual(item[key], params[key])) { @@ -77,7 +77,7 @@ function filterUniqueParams( // If we got to the end of the paramKeys array, then it means that we // found a duplicate. Skip it. - if (j === paramKeys.length) { + if (j === routeParamKeys.length) { break } } @@ -159,7 +159,8 @@ function filterRootParamsCombinations( * @param page - The page to validate. * @param regex - The route regex. * @param isRoutePPREnabled - Whether the route has partial prerendering enabled. - * @param paramKeys - The keys of the parameters. + * @param routeParamKeys - The keys of the parameters. + * @param rootParamKeys - The keys of the root params. * @param routeParams - The list of parameters to validate. * @returns The list of validated parameters. */ @@ -167,15 +168,37 @@ function validateParams( page: string, regex: RouteRegex, isRoutePPREnabled: boolean, - paramKeys: readonly string[], + routeParamKeys: readonly string[], + rootParamKeys: readonly string[], routeParams: readonly Params[] ): Params[] { const valid: Params[] = [] + // Validate that if there are any root params, that the user has provided at + // least one value for them only if we're using partial prerendering. + if (isRoutePPREnabled && rootParamKeys.length > 0) { + if ( + routeParams.length === 0 || + rootParamKeys.some((key) => + routeParams.some((params) => !(key in params)) + ) + ) { + if (rootParamKeys.length === 1) { + throw new Error( + `A required root parameter (${rootParamKeys[0]}) was not provided in generateStaticParams for ${page}, please provide at least one value.` + ) + } + + throw new Error( + `Required root params (${rootParamKeys.join(', ')}) were not provided in generateStaticParams for ${page}, please provide at least one value for each.` + ) + } + } + for (const params of routeParams) { const item: Params = {} - for (const key of paramKeys) { + for (const key of routeParamKeys) { const { repeat, optional } = regex.groups[key] let paramValue = params[key] @@ -309,7 +332,7 @@ export async function buildAppStaticPaths({ }) const regex = getRouteRegex(page) - const paramKeys = Object.keys(getRouteMatcher(regex)(page) || {}) + const routeParamKeys = Object.keys(getRouteMatcher(regex)(page) || {}) const afterRunner = new AfterRunner() @@ -425,10 +448,10 @@ export async function buildAppStaticPaths({ // Determine if all the segments have had their parameters provided. const hadAllParamsGenerated = - paramKeys.length === 0 || + routeParamKeys.length === 0 || (routeParams.length > 0 && routeParams.every((params) => { - for (const key of paramKeys) { + for (const key of routeParamKeys) { if (key in params) continue return false } @@ -463,25 +486,44 @@ export async function buildAppStaticPaths({ if (hadAllParamsGenerated || isRoutePPREnabled) { if (isRoutePPREnabled) { // Discover all unique combinations of the rootParams so we can generate - // shells for each of them. + // shells for each of them if they're available. routeParams.unshift( - // We're inserting an empty object at the beginning of the array so that - // we can generate a shell for when all params are unknown. - {}, ...filterRootParamsCombinations(rootParamKeys, routeParams) ) + + result.prerenderedRoutes ??= [] + result.prerenderedRoutes.push({ + pathname: page, + encodedPathname: page, + fallbackRouteParams: routeParamKeys, + fallbackMode: dynamicParams + ? // If the fallback params includes any root params, then we need to + // perform a blocking static render. + rootParamKeys.length > 0 + ? FallbackMode.BLOCKING_STATIC_RENDER + : fallbackMode + : FallbackMode.NOT_FOUND, + fallbackRootParams: rootParamKeys, + }) } filterUniqueParams( - paramKeys, - validateParams(page, regex, isRoutePPREnabled, paramKeys, routeParams) + routeParamKeys, + validateParams( + page, + regex, + isRoutePPREnabled, + routeParamKeys, + rootParamKeys, + routeParams + ) ).forEach((params) => { let pathname: string = page let encodedPathname: string = page const fallbackRouteParams: string[] = [] - for (const key of paramKeys) { + for (const key of routeParamKeys) { if (fallbackRouteParams.length > 0) { // This is a partial route, so we should add the value to the // fallbackRouteParams. diff --git a/test/e2e/app-dir/app-root-params/fixtures/multiple-roots/app/(dashboard)/[id]/layout.tsx b/test/e2e/app-dir/app-root-params/fixtures/multiple-roots/app/(dashboard)/[id]/layout.tsx index cde4bf8fe4b43e..f7b45d80141e47 100644 --- a/test/e2e/app-dir/app-root-params/fixtures/multiple-roots/app/(dashboard)/[id]/layout.tsx +++ b/test/e2e/app-dir/app-root-params/fixtures/multiple-roots/app/(dashboard)/[id]/layout.tsx @@ -7,3 +7,8 @@ export default function Root({ children }: { children: ReactNode }) { ) } + +export const revalidate = 0 +export async function generateStaticParams() { + return [{ id: '1' }] +} diff --git a/test/e2e/app-dir/app-root-params/fixtures/simple/app/[lang]/[locale]/layout.tsx b/test/e2e/app-dir/app-root-params/fixtures/simple/app/[lang]/[locale]/layout.tsx index 716a8db36f52c5..614e0bf24928dc 100644 --- a/test/e2e/app-dir/app-root-params/fixtures/simple/app/[lang]/[locale]/layout.tsx +++ b/test/e2e/app-dir/app-root-params/fixtures/simple/app/[lang]/[locale]/layout.tsx @@ -7,3 +7,8 @@ export default function Root({ children }: { children: ReactNode }) { ) } + +export const revalidate = 0 +export async function generateStaticParams() { + return [{ lang: 'en', locale: 'us' }] +} diff --git a/test/e2e/app-dir/dynamic-interception-route-revalidate/app/[locale]/layout.tsx b/test/e2e/app-dir/dynamic-interception-route-revalidate/app/[locale]/layout.tsx index 575f5e28dc4c97..85d1a64551877d 100644 --- a/test/e2e/app-dir/dynamic-interception-route-revalidate/app/[locale]/layout.tsx +++ b/test/e2e/app-dir/dynamic-interception-route-revalidate/app/[locale]/layout.tsx @@ -16,3 +16,8 @@ export default function Root({ ) } + +export const revalidate = 0 +export async function generateStaticParams() { + return [{ locale: 'en' }] +} diff --git a/test/e2e/app-dir/global-error/catch-all/app/[lang]/layout.js b/test/e2e/app-dir/global-error/catch-all/app/[lang]/layout.js index 4328f6b8b8aa2e..73096ec8a97068 100644 --- a/test/e2e/app-dir/global-error/catch-all/app/[lang]/layout.js +++ b/test/e2e/app-dir/global-error/catch-all/app/[lang]/layout.js @@ -7,3 +7,6 @@ export default async function RootLayout({ children }) { } export const dynamic = 'force-dynamic' +export async function generateStaticParams() { + return [{ lang: 'en' }] +} diff --git a/test/e2e/app-dir/parallel-route-not-found-params/app/[locale]/layout.tsx b/test/e2e/app-dir/parallel-route-not-found-params/app/[locale]/layout.tsx index 79700b1a883c96..42d288818bdaf6 100644 --- a/test/e2e/app-dir/parallel-route-not-found-params/app/[locale]/layout.tsx +++ b/test/e2e/app-dir/parallel-route-not-found-params/app/[locale]/layout.tsx @@ -13,3 +13,8 @@ export default async function Layout(props: { ) } + +export const revalidate = 0 +export async function generateStaticParams() { + return [{ locale: 'en' }] +} diff --git a/test/e2e/app-dir/parallel-routes-and-interception-from-root/app/[locale]/layout.tsx b/test/e2e/app-dir/parallel-routes-and-interception-from-root/app/[locale]/layout.tsx index ebe570cdf0a28c..dd1241ca87eb92 100644 --- a/test/e2e/app-dir/parallel-routes-and-interception-from-root/app/[locale]/layout.tsx +++ b/test/e2e/app-dir/parallel-routes-and-interception-from-root/app/[locale]/layout.tsx @@ -19,3 +19,8 @@ export default async function RootLayout({ ) } + +export const revalidate = 0 +export const generateStaticParams = async () => { + return [{ locale: 'en' }] +} diff --git a/test/e2e/app-dir/ppr-missing-root-params/fixtures/multiple/app/[lang]/[region]/layout.jsx b/test/e2e/app-dir/ppr-missing-root-params/fixtures/multiple/app/[lang]/[region]/layout.jsx new file mode 100644 index 00000000000000..a3a86a5ca1e12c --- /dev/null +++ b/test/e2e/app-dir/ppr-missing-root-params/fixtures/multiple/app/[lang]/[region]/layout.jsx @@ -0,0 +1,7 @@ +export default function Root({ children }) { + return ( + + {children} + + ) +} diff --git a/test/e2e/app-dir/ppr-missing-root-params/fixtures/multiple/app/[lang]/[region]/page.jsx b/test/e2e/app-dir/ppr-missing-root-params/fixtures/multiple/app/[lang]/[region]/page.jsx new file mode 100644 index 00000000000000..ff7159d9149fee --- /dev/null +++ b/test/e2e/app-dir/ppr-missing-root-params/fixtures/multiple/app/[lang]/[region]/page.jsx @@ -0,0 +1,3 @@ +export default function Page() { + return

hello world

+} diff --git a/test/e2e/app-dir/ppr-missing-root-params/fixtures/multiple/next.config.js b/test/e2e/app-dir/ppr-missing-root-params/fixtures/multiple/next.config.js new file mode 100644 index 00000000000000..16e8384b655b41 --- /dev/null +++ b/test/e2e/app-dir/ppr-missing-root-params/fixtures/multiple/next.config.js @@ -0,0 +1,11 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = { + experimental: { + ppr: true, + dynamicIO: true, + }, +} + +module.exports = nextConfig diff --git a/test/e2e/app-dir/ppr-missing-root-params/fixtures/nested/app/[lang]/blog/[slug]/page.jsx b/test/e2e/app-dir/ppr-missing-root-params/fixtures/nested/app/[lang]/blog/[slug]/page.jsx new file mode 100644 index 00000000000000..bcc5c2b2cfd72a --- /dev/null +++ b/test/e2e/app-dir/ppr-missing-root-params/fixtures/nested/app/[lang]/blog/[slug]/page.jsx @@ -0,0 +1,7 @@ +export default function Page() { + return

hello world

+} + +export async function generateStaticParams() { + return [{ slug: 'hello-world' }] +} diff --git a/test/e2e/app-dir/ppr-missing-root-params/fixtures/nested/app/[lang]/layout.jsx b/test/e2e/app-dir/ppr-missing-root-params/fixtures/nested/app/[lang]/layout.jsx new file mode 100644 index 00000000000000..a3a86a5ca1e12c --- /dev/null +++ b/test/e2e/app-dir/ppr-missing-root-params/fixtures/nested/app/[lang]/layout.jsx @@ -0,0 +1,7 @@ +export default function Root({ children }) { + return ( + + {children} + + ) +} diff --git a/test/e2e/app-dir/ppr-missing-root-params/fixtures/nested/next.config.js b/test/e2e/app-dir/ppr-missing-root-params/fixtures/nested/next.config.js new file mode 100644 index 00000000000000..16e8384b655b41 --- /dev/null +++ b/test/e2e/app-dir/ppr-missing-root-params/fixtures/nested/next.config.js @@ -0,0 +1,11 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = { + experimental: { + ppr: true, + dynamicIO: true, + }, +} + +module.exports = nextConfig diff --git a/test/e2e/app-dir/ppr-missing-root-params/fixtures/single/app/[lang]/layout.jsx b/test/e2e/app-dir/ppr-missing-root-params/fixtures/single/app/[lang]/layout.jsx new file mode 100644 index 00000000000000..a3a86a5ca1e12c --- /dev/null +++ b/test/e2e/app-dir/ppr-missing-root-params/fixtures/single/app/[lang]/layout.jsx @@ -0,0 +1,7 @@ +export default function Root({ children }) { + return ( + + {children} + + ) +} diff --git a/test/e2e/app-dir/ppr-missing-root-params/fixtures/single/app/[lang]/page.jsx b/test/e2e/app-dir/ppr-missing-root-params/fixtures/single/app/[lang]/page.jsx new file mode 100644 index 00000000000000..ff7159d9149fee --- /dev/null +++ b/test/e2e/app-dir/ppr-missing-root-params/fixtures/single/app/[lang]/page.jsx @@ -0,0 +1,3 @@ +export default function Page() { + return

hello world

+} diff --git a/test/e2e/app-dir/ppr-missing-root-params/fixtures/single/next.config.js b/test/e2e/app-dir/ppr-missing-root-params/fixtures/single/next.config.js new file mode 100644 index 00000000000000..16e8384b655b41 --- /dev/null +++ b/test/e2e/app-dir/ppr-missing-root-params/fixtures/single/next.config.js @@ -0,0 +1,11 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = { + experimental: { + ppr: true, + dynamicIO: true, + }, +} + +module.exports = nextConfig diff --git a/test/e2e/app-dir/ppr-missing-root-params/ppr-missing-root-params.test.ts b/test/e2e/app-dir/ppr-missing-root-params/ppr-missing-root-params.test.ts new file mode 100644 index 00000000000000..59786d94ed30f6 --- /dev/null +++ b/test/e2e/app-dir/ppr-missing-root-params/ppr-missing-root-params.test.ts @@ -0,0 +1,74 @@ +import { nextTestSetup } from 'e2e-utils' +import path from 'path' + +describe('ppr-missing-root-params (single)', () => { + const { next, isNextDev } = nextTestSetup({ + files: path.join(__dirname, 'fixtures/single'), + skipStart: true, + skipDeployment: true, + }) + + beforeAll(async () => { + try { + await next.start() + } catch {} + }) + + it('should result in a build error', async () => { + if (isNextDev) { + await next.fetch('/en') + } + + expect(next.cliOutput).toContain( + `Error: A required root parameter (lang) was not provided in generateStaticParams for /[lang], please provide at least one value.` + ) + }) +}) + +describe('ppr-missing-root-params (multiple)', () => { + const { next, isNextDev } = nextTestSetup({ + files: path.join(__dirname, 'fixtures/multiple'), + skipStart: true, + skipDeployment: true, + }) + + beforeAll(async () => { + try { + await next.start() + } catch {} + }) + + it('should result in a build error', async () => { + if (isNextDev) { + await next.fetch('/en/us') + } + + expect(next.cliOutput).toContain( + `Error: Required root params (lang, region) were not provided in generateStaticParams for /[lang]/[region], please provide at least one value for each.` + ) + }) +}) + +describe('ppr-missing-root-params (nested)', () => { + const { next, isNextDev } = nextTestSetup({ + files: path.join(__dirname, 'fixtures/nested'), + skipStart: true, + skipDeployment: true, + }) + + beforeAll(async () => { + try { + await next.start() + } catch {} + }) + + it('should result in a build error', async () => { + if (isNextDev) { + await next.fetch('/en/blog/hello') + } + + expect(next.cliOutput).toContain( + `Error: A required root parameter (lang) was not provided in generateStaticParams for /[lang]/blog/[slug], please provide at least one value.` + ) + }) +}) diff --git a/test/e2e/app-dir/root-layout/app/(mpa-navigation)/dynamic-catchall/[...slug]/layout.js b/test/e2e/app-dir/root-layout/app/(mpa-navigation)/dynamic-catchall/[...slug]/layout.js index 05b841b280b3fc..504c66add6583b 100644 --- a/test/e2e/app-dir/root-layout/app/(mpa-navigation)/dynamic-catchall/[...slug]/layout.js +++ b/test/e2e/app-dir/root-layout/app/(mpa-navigation)/dynamic-catchall/[...slug]/layout.js @@ -8,3 +8,8 @@ export default function Root({ children }) { ) } + +export const revalidate = 0 +export async function generateStaticParams() { + return [{ slug: ['slug'] }] +} diff --git a/test/e2e/opentelemetry/instrumentation/app/app/[param]/layout.tsx b/test/e2e/opentelemetry/instrumentation/app/app/[param]/layout.tsx index fe772898131271..e7f20d0fca71c3 100644 --- a/test/e2e/opentelemetry/instrumentation/app/app/[param]/layout.tsx +++ b/test/e2e/opentelemetry/instrumentation/app/app/[param]/layout.tsx @@ -13,3 +13,8 @@ export default async function Layout({ export async function generateMetadata() { return {} } + +export const revalidate = 0 +export async function generateStaticParams() { + return [{ param: 'test' }] +}