import path from 'node:path' import type { NextAdapter } from 'next' import { nextTestSetup } from 'e2e-utils' import { getDevCliValidationOutput } from 'e2e-utils/instant-validation' import { retry } from 'next-test-utils' import { PrefetchHint, type PrefetchHints, } from 'next/dist/shared/lib/app-router-types' type DynamicRoute = { fallback: false | null | string fallbackSourceRoute?: string throwOnEmptyStaticShell?: boolean remainingPrerenderableParams?: Array<{ paramName: string paramType: string }> } describe('parameter matching', () => { const { next, isNextStart } = nextTestSetup({ files: __dirname, env: { NEXT_PRIVATE_DEBUG_PARAM_MATCHING: '1', }, }) it('inherits closed parameters and lets a page override open policies', async () => { const inherited = await next.fetch('/en/catalog/t1/items/b2') expect(inherited.status).toBe(200) expect(await inherited.text()).toContain('en/t1/b2') const overridden = await next.fetch('/en/preview/t2/items/b2') expect(overridden.status).toBe(200) expect(await overridden.text()).toContain('en/t2/b2') expect((await next.fetch('/fr/catalog/t1/items/b1')).status).toBe(404) expect((await next.fetch('/fr/preview/t1/items/b1')).status).toBe(404) }) it('supports uniform policies and permanently dynamic suffixes', async () => { for (const [pathname, text] of [ ['/fallback-only/t2/items/b2', 't2/b2'], ['/blocking-only/t2/items/b2', 't2/b2'], ['/dynamic-suffix/t2/items/b2', 't2/b2'], ['/no-example-fallback/t2/items/b2', 't2/b2'], ['/no-example-blocking/t2/items/b2', 't2/b2'], ['/no-example-blocking-fallback/t2/items/b2', 't2/b2'], ]) { const response = await next.fetch(pathname) expect(response.status).toBe(200) expect(await response.text()).toContain(text) } expect((await next.fetch('/not-found-only/t1/items/b1')).status).toBe(200) expect((await next.fetch('/not-found-only/t1/items/b2')).status).toBe(404) expect((await next.fetch('/not-found-only/t2/items/b2')).status).toBe(404) }) it('preserves inferred fallback and blocking behavior without configuration', async () => { const full = await next.fetch('/inferred-full/t2/items/b2') expect(full.status).toBe(200) expect(await full.text()).toContain('t2/b2') const empty = await next.fetch('/inferred-empty/t2/items/b2') expect(empty.status).toBe(200) expect(await empty.text()).toContain('t2/b2') }) it('fills policy holes using the existing shell heuristic', async () => { const blocking = await next.fetch('/inferred-hole-blocking/t2/items/b2') expect(blocking.status).toBe(200) expect(await blocking.text()).toContain('t2/b2') const fallback = await next.fetch('/inferred-hole-fallback/t2/items/b2') expect(fallback.status).toBe(200) expect(await fallback.text()).toContain('t2/b2') }) it('requires descendants to override every affected explicit policy', async () => { const tightened = await next.fetch('/specificity-tighten/en/shoes') expect(tightened.status).toBe(200) expect(await tightened.text()).toContain('en/shoes') expect((await next.fetch('/specificity-tighten/en/hats')).status).toBe(404) expect((await next.fetch('/specificity-tighten/fr/shoes')).status).toBe(404) const loosened = await next.fetch('/specificity-loosen/fr/hats/details') expect(loosened.status).toBe(200) expect(await loosened.text()).toContain('fr/hats') }) if (isNextStart) { it.each([ '/no-example-blocking/[top]/items/[bottom]', '/no-example-blocking-fallback/[top]/items/[bottom]', ])( 'collects build-time hints without publishing a blocking fallback for %s', async (pathname) => { const hints = (await next.readJSON( '.next/server/prefetch-hints.json' )) as Record const manifest = (await next.readJSON( '.next/prerender-manifest.json' )) as { routes: Record dynamicRoutes: Record } // Neither route has generateStaticParams. The first permits an empty // shell with instant=false; the second validates its non-empty shell. // Both still need a build render to collect best-effort prefetch hints. expect(hints[pathname]).toBeDefined() const nodes = [hints[pathname]] for (const node of nodes) { expect(node.hints & PrefetchHint.PrefetchDisabled).toBe(0) if (node.slots) nodes.push(...Object.values(node.slots)) } expect(manifest.dynamicRoutes[pathname].fallback).toBeNull() expect(manifest.routes[pathname]).toBeUndefined() } ) it('only specializes unresolved prerenderable params', async () => { const manifest = JSON.parse( await next.readFile('.next/prerender-manifest.json') ) as { dynamicRoutes: Record } for (const [pathname, paramNames] of [ // With both params seeded, each shell can fill the remaining suffix. ['/inferred-full/[top]/items/[bottom]', ['top', 'bottom']], ['/inferred-full/t1/items/[bottom]', ['bottom']], // A permanently dynamic suffix must never be specialized. ['/dynamic-suffix/[top]/items/[bottom]', ['top']], ['/dynamic-suffix/t1/items/[bottom]', []], // Explicit policies allow specialization without build-time examples, // including matcher-only prefixes that produce no fallback artifact. ['/no-example-fallback/[top]/items/[bottom]', ['top']], ['/no-example-blocking/[top]/items/[bottom]', ['top']], [ '/no-example-blocking-fallback/[top]/items/[bottom]', ['top', 'bottom'], ], ] as const) { expect(manifest.dynamicRoutes[pathname]).toBeDefined() expect( manifest.dynamicRoutes[pathname].remainingPrerenderableParams ).toEqual( paramNames.length > 0 ? paramNames.map((paramName) => ({ paramName, paramType: 'dynamic', })) : undefined ) } }) it('emits one route matcher for each effective prefix behavior', async () => { const manifest = JSON.parse( await next.readFile('.next/prerender-manifest.json') ) as { routes: Record dynamicRoutes: Record } const sourceRoute = '/[lang]/catalog/[top]/items/[bottom]' const matchers = Object.fromEntries( Object.entries(manifest.dynamicRoutes).filter( ([pathname, route]) => pathname === sourceRoute || route.fallbackSourceRoute === sourceRoute ) ) expect(matchers[sourceRoute].fallback).toBe(false) expect(matchers['/en/catalog/[top]/items/[bottom]'].fallback).toBeNull() expect(typeof matchers['/en/catalog/t1/items/[bottom]'].fallback).toBe( 'string' ) expect(matchers['/es/catalog/[top]/items/[bottom]'].fallback).toBeNull() expect(typeof matchers['/es/catalog/t1/items/[bottom]'].fallback).toBe( 'string' ) // Matcher-only prefixes have no source shell to validate. The explicit // fallback below them is the required shell, even with a concrete child. expect(matchers[sourceRoute].throwOnEmptyStaticShell).toBe(false) expect( matchers['/en/catalog/[top]/items/[bottom]'].throwOnEmptyStaticShell ).toBe(false) expect( matchers['/en/catalog/t1/items/[bottom]'].throwOnEmptyStaticShell ).toBe(true) expect(manifest.routes['/en/catalog/t1/items/b1']?.srcRoute).toBe( sourceRoute ) expect(manifest.routes['/es/catalog/t1/items/b1']?.srcRoute).toBe( sourceRoute ) expect( manifest.dynamicRoutes['/inferred-hole-blocking/[top]/items/[bottom]'] .fallback ).toBeNull() expect( typeof manifest.dynamicRoutes[ '/inferred-hole-blocking/t1/items/[bottom]' ].fallback ).toBe('string') expect( typeof manifest.dynamicRoutes[ '/inferred-hole-fallback/[top]/items/[bottom]' ].fallback ).toBe('string') expect( typeof manifest.dynamicRoutes[ '/inferred-hole-fallback/t1/items/[bottom]' ].fallback ).toBe('string') expect( typeof manifest.dynamicRoutes[ '/no-example-fallback/[top]/items/[bottom]' ].fallback ).toBe('string') expect( manifest.dynamicRoutes[ '/no-example-blocking-fallback/[top]/items/[bottom]' ].fallback ).toBeNull() expect( manifest.dynamicRoutes['/no-example-blocking/[top]/items/[bottom]'] .fallback ).toBeNull() }) it('prints the effective matching and deployment-pattern digest', () => { expect(next.cliOutput).toContain('Experimental parameter matching') expect(next.cliOutput).toContain( 'not-found /[lang]/catalog/[top]/items/[bottom]' ) expect(next.cliOutput).toContain( 'blocking /en/catalog/[top]/items/[bottom]' ) expect(next.cliOutput).toContain( 'fallback /en/catalog/t1/items/[bottom]' ) expect(next.cliOutput).toContain('prerender /en/catalog/t1/items/b1') expect(next.cliOutput).toContain('/inferred-empty/[top]/items/[bottom]') expect(next.cliOutput).toContain( 'blocking /inferred-hole-blocking/[top]/items/[bottom]' ) expect(next.cliOutput).toContain( 'fallback /inferred-hole-blocking/t1/items/[bottom]' ) expect(next.cliOutput).toContain('Emitted dynamic route patterns') expect(next.cliOutput).toContain('/[lang]/catalog/[top]/items/[bottom] (') }) it('uses the existing fallback-false adapter routing contract', async () => { const { routing, outputs, distDir, repoRoot } = JSON.parse( await next.readFile('build-complete.json') ) as Parameters>[0] const prerenderManifest = JSON.parse( await next.readFile('.next/prerender-manifest.json') ) as { preview: { previewModeId: string } } const sourceRoute = '/[lang]/catalog/[top]/items/[bottom]' const blockingRoute = '/en/catalog/[top]/items/[bottom]' const fallbackRoute = '/en/catalog/t1/items/[bottom]' const routesBySource = new Map( routing.dynamicRoutes.map((route) => [route.source, route]) ) // Like dynamicParams=false and Pages Router fallback:false, closed // matchers require the build's Draft Mode bypass cookie. The legacy // preview-data cookie is not required; normal misses stay closed. const fallbackFalseHas = [ { type: 'cookie', key: '__prerender_bypass', value: prerenderManifest.preview.previewModeId, }, ] for (const [pattern, pathname] of [ [sourceRoute, '/fr/catalog/t2/items/b2'], [blockingRoute, '/en/catalog/t2/items/b2'], [fallbackRoute, '/en/catalog/t1/items/b2'], ]) { const emittedRoute = routesBySource.get(pattern)! expect(emittedRoute).toBeDefined() expect(emittedRoute.has).toEqual( pattern === sourceRoute ? fallbackFalseHas : undefined ) // Canary collapses document, RSC, and segment requests into one // matcher. The bypass-cookie gate must cover every request form. const regex = new RegExp(emittedRoute.sourceRegex) for (const suffix of ['', '.rsc', '.segments/_tree.segment.rsc']) { expect(pathname + suffix).toMatch(regex) } } const routeSources = routing.dynamicRoutes.map((route) => route.source) expect(routeSources.indexOf(fallbackRoute)).toBeLessThan( routeSources.indexOf(blockingRoute) ) expect(routeSources.indexOf(blockingRoute)).toBeLessThan( routeSources.indexOf(sourceRoute) ) // Specialized matchers use the original page module and manifest. They // do not need synthetic page entrypoints in the deployed function. const parentOutput = outputs.appPages.find( (output) => output.pathname === sourceRoute )! expect(parentOutput).toBeDefined() const manifestPath = path.join(distDir, 'app-path-routes-manifest.json') expect(parentOutput.assets[path.relative(repoRoot, manifestPath)]).toBe( manifestPath ) for (const pattern of [blockingRoute, fallbackRoute]) { const aliasPath = path.join(distDir, 'server/app', `${pattern}/page.js`) expect( parentOutput.assets[path.relative(repoRoot, aliasPath)] ).toBeUndefined() } // A closed prefix does not make the whole page a build-time allowlist. // Fully closed pages still retain that restriction. const seededOutput = outputs.prerenders.find( (output) => output.pathname === '/en/catalog/t1/items/b1' )! expect(seededOutput).toBeDefined() expect(seededOutput.parentOutputId).toBe(parentOutput.id) expect(seededOutput.parentFallbackMode).toBeUndefined() expect( outputs.prerenders.find( (output) => output.pathname === '/not-found-only/t1/items/b1' )?.parentFallbackMode ).toBe(false) }) } }) describe('parameter matching complex route shapes', () => { const { next, isNextStart } = nextTestSetup({ files: path.join(__dirname, 'fixtures', 'complex-routes'), }) it('applies root and catch-all parameter policies in order', async () => { for (const pathname of [ '/en/catch-all/known/deep', '/en/catch-all/novel/deep', ]) { const response = await next.fetch(pathname) expect(response.status).toBe(200) expect(await response.text()).toContain( `en/${pathname.split('/').slice(3).join('/')}` ) } expect((await next.fetch('/fr/catch-all/novel/deep')).status).toBe(404) }) it('closes both forms of an optional catch-all miss', async () => { for (const pathname of [ '/en/optional-catch-all', '/en/optional-catch-all/known/deep', ]) { expect((await next.fetch(pathname)).status).toBe(200) } expect((await next.fetch('/en/optional-catch-all/novel')).status).toBe(404) expect((await next.fetch('/fr/optional-catch-all')).status).toBe(404) }) it('merges an agreed policy for a param shared by parallel slots', async () => { for (const item of ['known', 'novel']) { const response = await next.fetch(`/en/parallel/${item}`) expect(response.status).toBe(200) const html = await response.text() expect(html).toContain(`children:en/${item}`) expect(html).toContain(`left:en/${item}`) expect(html).toContain(`right:en/${item}`) } expect((await next.fetch('/fr/parallel/novel')).status).toBe(404) }) if (isNextStart) { it('does not print parameter matching diagnostics by default', () => { expect(next.cliOutput).not.toContain('Experimental parameter matching') expect(next.cliOutput).not.toContain('Emitted dynamic route patterns') }) it('emits root-gated matchers for catch-all and parallel routes', async () => { const manifest = JSON.parse( await next.readFile('.next/prerender-manifest.json') ) as { dynamicRoutes: Record } expect( manifest.dynamicRoutes['/[lang]/catch-all/[...parts]'].fallback ).toBe(false) expect( typeof manifest.dynamicRoutes['/en/catch-all/[...parts]'].fallback ).toBe('string') expect(manifest.dynamicRoutes['/[lang]/parallel/[item]'].fallback).toBe( false ) expect( typeof manifest.dynamicRoutes['/en/parallel/[item]'].fallback ).toBe('string') }) } }) describe('parameter matching foreground policy', () => { const { next, isNextDev, skipped } = nextTestSetup({ files: path.join(__dirname, 'fixtures', 'foreground-policy'), }) if (skipped || isNextDev) { it.skip('only runs against a production route cache', () => {}) return } function readShellMarker(body: string): string { const match = body.match(/data-shell-marker="[^"]+">([^<]+)${pathname.split('/').at(-1)}

`) return { body, marker: readShellMarker(body) } } it('generates a distinct shell before returning a blocking miss', async () => { const first = await render('/blocking/novel-a') const other = await render('/blocking/novel-b') expect(other.marker).not.toBe(first.marker) expect(first.body).not.toContain('

waiting for params

') expect(other.body).not.toContain('

waiting for params

') const repeat = await render('/blocking/novel-a') expect(repeat.marker).toBe(first.marker) }) it('returns the shared build shell for a fallback miss', async () => { const first = await render('/fallback/novel-a') const other = await render('/fallback/novel-b') expect(other.marker).toBe(first.marker) expect(first.body).toContain('

waiting for params

') expect(other.body).toContain('

waiting for params

') }) }) describe('parameter matching ordering validation', () => { const { next, isNextDev, skipped } = nextTestSetup({ files: path.join(__dirname, 'fixtures', 'invalid-order'), skipStart: true, skipDeployment: true, }) if (skipped) { it.skip('skipped', () => {}) return } beforeAll(async () => { if (isNextDev) { await next.start() await next.fetch('/en/t1/b1') } else { await next.build().catch(() => {}) } }) it('rejects incoherent policies after layout and page merging', async () => { await retry(() => { expect(next.cliOutput).toContain( 'Expected parameters in this order: "not-found", "blocking", "fallback", then "dynamic"' ) }) }) }) describe('parameter matching dynamic validation', () => { const { next, isNextDev, skipped } = nextTestSetup({ files: path.join(__dirname, 'fixtures', 'dynamic-prerender'), skipStart: true, skipDeployment: true, }) if (skipped) { it.skip('skipped', () => {}) return } beforeAll(async () => { if (isNextDev) { await next.start() await next.fetch('/t1/b1') } else { await next.build().catch(() => {}) } }) it('rejects generateStaticParams output at or below a dynamic param', async () => { await retry(() => { expect(next.cliOutput).toContain( 'cannot prerender parameter "top" because parameter "top" is configured as "dynamic"' ) }) }) }) describe('parameter matching type validation', () => { const { next, isNextStart, skipped } = nextTestSetup({ files: path.join(__dirname, 'fixtures', 'type-invalid-param-below'), skipStart: true, skipDeployment: true, }) if (skipped || !isNextStart) { it.skip('only runs during production builds', () => {}) return } beforeAll(async () => { await next.build().catch(() => {}) }) it('constrains matching keys to params visible to the exporting module', () => { expect(next.cliOutput).toContain('Failed to type check.') expect(next.cliOutput).toMatch( /Type '"category"' does not satisfy the constraint 'never'/ ) }) }) describe('parameter matching without a config opt-in', () => { const { next, skipped } = nextTestSetup({ files: path.join(__dirname, 'fixtures', 'flag-disabled'), skipStart: true, skipDeployment: true, }) if (skipped) { it.skip('skipped', () => {}) return } beforeAll(async () => { await next.start() }) it('uses the unstable export without a next.config flag', async () => { expect((await next.fetch('/en')).status).toBe(200) expect((await next.fetch('/fr')).status).toBe(404) }) }) describe('parameter matching cache components requirement', () => { if (process.env.__NEXT_CACHE_COMPONENTS === 'true') { it.skip('not applicable when Cache Components is forced on', () => {}) return } const { next, isNextDev, skipped } = nextTestSetup({ files: path.join(__dirname, 'fixtures', 'cache-components-disabled'), skipStart: true, skipDeployment: true, }) if (skipped) { it.skip('skipped', () => {}) return } beforeAll(async () => { if (isNextDev) { await next.start() await next.fetch('/en') } else { await next.build().catch(() => {}) } }) it('requires Cache Components', async () => { await retry(() => { expect(next.cliOutput).toContain( 'cannot use parameter matching without enabling `cacheComponents`' ) }) }) }) describe('parameter matching static export validation', () => { const { next, isNextStart, skipped } = nextTestSetup({ files: path.join(__dirname, 'fixtures', 'export-open-param'), skipStart: true, skipDeployment: true, }) if (skipped || !isNextStart) { it.skip('only runs during production builds', () => {}) return } beforeAll(async () => { await next.build().catch(() => {}) }) it('requires every exported param to be explicitly closed for now', () => { expect(next.cliOutput).toContain( 'must configure parameter "lang" as "not-found"' ) }) }) describe('parameter matching fallback validation', () => { const { next, isNextStart, skipped } = nextTestSetup({ files: path.join(__dirname, 'fixtures', 'empty-fallback'), skipStart: true, skipDeployment: true, }) if (skipped || !isNextStart) { it.skip('only runs during production builds', () => {}) return } beforeAll(async () => { await next.build().catch(() => {}) }) it('does not downgrade an explicitly configured fallback to blocking', () => { expect(next.cliOutput).toContain( 'Error occurred prerendering page "/t1/[bottom]"' ) }) }) describe('parameter matching generic shell validation', () => { const { next, isNextDev, skipped } = nextTestSetup({ files: path.join(__dirname, 'fixtures', 'missing-fallback-seed'), skipStart: true, skipDeployment: true, }) if (skipped) { it.skip('skipped', () => {}) return } beforeAll(async () => { if (isNextDev) { await next.start() await next.fetch('/t1/b1') } else { await next.build().catch(() => {}) } }) it('validates the generic blocking shell when no example reaches fallback', async () => { await retry(() => { if (isNextDev) { expect(next.cliOutput).toContain( 'encountered runtime data during prerendering' ) expect(next.cliOutput).toContain('at TopLayout') } else { expect(next.cliOutput).toContain( 'Error occurred prerendering page "/[top]/[bottom]"' ) } }) }) }) describe('parameter matching development shell validation', () => { const { next, isNextDev, skipped } = nextTestSetup({ files: path.join(__dirname, 'fixtures', 'dev-shell-validation'), skipStart: true, skipDeployment: true, env: { NEXT_TEST_LOG_VALIDATION: '1', }, }) if (skipped || !isNextDev) { it.skip('only runs in development', () => {}) return } beforeAll(async () => { await next.start() }) async function renderAndWaitForValidation(pathname: string) { const outputIndex = next.cliOutput.length const response = await next.fetch(pathname) expect(response.status).toBe(200) return getDevCliValidationOutput(`http://n${pathname}`, () => next.cliOutput.slice(outputIndex) ) } it('uses the most-specific generated shape for an inferred generated value', async () => { const inferredKnown = await renderAndWaitForValidation('/inferred/t1/b2') expect(inferredKnown).not.toContain( 'Error: Route "/inferred/[top]/[bottom]"' ) }) it('uses the most-specific generated shape for an inferred novel param', async () => { const inferredNovel = await renderAndWaitForValidation('/inferred/t2/b2') expect(inferredNovel).not.toContain( 'Error: Route "/inferred/[top]/[bottom]"' ) }) it('uses the most-specific generated shape when no fallback is configured', async () => { const blockingOnlyNovel = await renderAndWaitForValidation( '/blocking-only/t2/b2' ) expect(blockingOnlyNovel).not.toContain( 'Error: Route "/blocking-only/[top]/[bottom]"' ) }) it('treats an explicitly blocking param as known', async () => { const blockingNovel = await renderAndWaitForValidation('/blocking/t2/b2') expect(blockingNovel).not.toContain( 'Error: Route "/blocking/[top]/[bottom]"' ) }) it.each(['/fallback/t1/b1', '/fallback/t1/b2', '/fallback/t2/b2'])( 'treats an explicitly fallback param as unknown for %s', async (pathname) => { const outputIndex = next.cliOutput.length await renderAndWaitForValidation(pathname) // Webpack's formatted stderr can arrive after the stdout validation // markers. Wait for the error from this request, not their pipe ordering. await retry(() => { expect(next.cliOutput.slice(outputIndex)).toContain( 'Error: Route "/fallback/[top]/[bottom]"' ) }) } ) })