diff --git a/packages/next/src/build/index.ts b/packages/next/src/build/index.ts index b75748f84c1c..75f24953960f 100644 --- a/packages/next/src/build/index.ts +++ b/packages/next/src/build/index.ts @@ -1472,6 +1472,11 @@ export default async function build( edgeInfo, pageType, hasServerComponents: !!appDir, + incrementalCacheHandlerPath: + config.experimental.incrementalCacheHandlerPath, + isrFlushToDisk: config.experimental.isrFlushToDisk, + maxMemoryCacheSize: + config.experimental.isrMemoryCacheSize, }) } ) diff --git a/packages/next/src/build/utils.ts b/packages/next/src/build/utils.ts index 7a391398ffd0..4d6113078e0d 100644 --- a/packages/next/src/build/utils.ts +++ b/packages/next/src/build/utils.ts @@ -55,6 +55,10 @@ import { overrideBuiltInReactPackages, } from './webpack/require-hook' import { isClientReference } from './is-client-reference' +import { StaticGenerationAsyncStorageWrapper } from '../server/async-storage/static-generation-async-storage-wrapper' +import { IncrementalCache } from '../server/lib/incremental-cache' +import { patchFetch } from '../server/lib/patch-fetch' +import { nodeFs } from '../server/lib/node-fs-methods' loadRequireHook() if (process.env.NEXT_PREBUNDLED_REACT) { @@ -998,6 +1002,15 @@ export async function buildStaticPaths({ (repeat && !Array.isArray(paramValue)) || (!repeat && typeof paramValue !== 'string') ) { + // If from appDir and not all params were provided from + // generateStaticParams we can just filter this entry out + // as it's meant to be generated at runtime + if (appDir) { + builtPage = '' + encodedBuiltPage = '' + return + } + throw new Error( `A required parameter (${validParamKey}) was not provided as ${ repeat ? 'an array' : 'a string' @@ -1031,6 +1044,10 @@ export async function buildStaticPaths({ .replace(/(?!^)\/$/, '') }) + if (!builtPage && !encodedBuiltPage) { + return + } + if (entry.locale && !locales?.includes(entry.locale)) { throw new Error( `Invalid locale returned from getStaticPaths for ${page}, the locale ${entry.locale} is not specified in ${configFileName}` @@ -1143,89 +1160,155 @@ export const collectGenerateParams = async ( export async function buildAppStaticPaths({ page, + distDir, configFileName, generateParams, + isrFlushToDisk, + incrementalCacheHandlerPath, + requestHeaders, + maxMemoryCacheSize, + fetchCacheKeyPrefix, + staticGenerationAsyncStorage, + serverHooks, }: { page: string configFileName: string generateParams: GenerateParams + incrementalCacheHandlerPath?: string + distDir: string + isrFlushToDisk?: boolean + fetchCacheKeyPrefix?: string + maxMemoryCacheSize?: number + requestHeaders: IncrementalCache['requestHeaders'] + staticGenerationAsyncStorage: Parameters< + typeof patchFetch + >[0]['staticGenerationAsyncStorage'] + serverHooks: Parameters[0]['serverHooks'] }) { - const pageEntry = generateParams[generateParams.length - 1] - - // if the page has legacy getStaticPaths we call it like normal - if (typeof pageEntry?.getStaticPaths === 'function') { - return buildStaticPaths({ - page, - configFileName, - getStaticPaths: pageEntry.getStaticPaths, - }) - } else { - // if generateStaticParams is being used we iterate over them - // collecting them from each level - type Params = Array> - let hadGenerateParams = false - - const buildParams = async ( - paramsItems: Params = [{}], - idx = 0 - ): Promise => { - const curGenerate = generateParams[idx] - - if (idx === generateParams.length) { - return paramsItems - } - if ( - typeof curGenerate.generateStaticParams !== 'function' && - idx < generateParams.length - ) { - return buildParams(paramsItems, idx + 1) - } - hadGenerateParams = true + patchFetch({ + staticGenerationAsyncStorage, + serverHooks, + }) + let CacheHandler: any + + if (incrementalCacheHandlerPath) { + CacheHandler = require(incrementalCacheHandlerPath) + CacheHandler = CacheHandler.default || CacheHandler + } + + const incrementalCache = new IncrementalCache({ + fs: nodeFs, + dev: true, + appDir: true, + flushToDisk: isrFlushToDisk, + serverDistDir: path.join(distDir, 'server'), + fetchCacheKeyPrefix, + maxMemoryCacheSize, + getPrerenderManifest: () => ({ + version: -1 as any, // letting us know this doesn't conform to spec + routes: {}, + dynamicRoutes: {}, + notFoundRoutes: [], + preview: null as any, // `preview` is special case read in next-dev-server + }), + CurCacheHandler: CacheHandler, + requestHeaders, + }) + + const wrapper = new StaticGenerationAsyncStorageWrapper() + + return wrapper.wrap( + staticGenerationAsyncStorage, + { + pathname: page, + renderOpts: { + incrementalCache, + supportsDynamicHTML: true, + isRevalidate: false, + isBot: false, + }, + }, + async () => { + const pageEntry = generateParams[generateParams.length - 1] + + // if the page has legacy getStaticPaths we call it like normal + if (typeof pageEntry?.getStaticPaths === 'function') { + return buildStaticPaths({ + page, + configFileName, + getStaticPaths: pageEntry.getStaticPaths, + }) + } else { + // if generateStaticParams is being used we iterate over them + // collecting them from each level + type Params = Array> + let hadGenerateParams = false + + const buildParams = async ( + paramsItems: Params = [{}], + idx = 0 + ): Promise => { + const curGenerate = generateParams[idx] + + if (idx === generateParams.length) { + return paramsItems + } + if ( + typeof curGenerate.generateStaticParams !== 'function' && + idx < generateParams.length + ) { + return buildParams(paramsItems, idx + 1) + } + hadGenerateParams = true + + const newParams = [] - const newParams = [] + for (const params of paramsItems) { + const result = await curGenerate.generateStaticParams({ params }) + // TODO: validate the result is valid here or wait for + // buildStaticPaths to validate? + for (const item of result) { + newParams.push({ ...params, ...item }) + } + } - for (const params of paramsItems) { - const result = await curGenerate.generateStaticParams({ params }) - // TODO: validate the result is valid here or wait for - // buildStaticPaths to validate? - for (const item of result) { - newParams.push({ ...params, ...item }) + if (idx < generateParams.length) { + return buildParams(newParams, idx + 1) + } + return newParams } - } + const builtParams = await buildParams() + const fallback = !generateParams.some( + // TODO: dynamic params should be allowed + // to be granular per segment but we need + // additional information stored/leveraged in + // the prerender-manifest to allow this behavior + (generate) => generate.config?.dynamicParams === false + ) - if (idx < generateParams.length) { - return buildParams(newParams, idx + 1) - } - return newParams - } - const builtParams = await buildParams() - const fallback = !generateParams.some( - // TODO: check complementary configs that can impact - // dynamicParams behavior - (generate) => generate.config?.dynamicParams === false - ) + if (!hadGenerateParams) { + return { + paths: undefined, + fallback: + process.env.NODE_ENV === 'production' && isDynamicRoute(page) + ? true + : undefined, + encodedPaths: undefined, + } + } - if (!hadGenerateParams) { - return { - paths: undefined, - fallback: - process.env.NODE_ENV === 'production' && isDynamicRoute(page) - ? true - : undefined, - encodedPaths: undefined, + return buildStaticPaths({ + staticPathsResult: { + fallback, + paths: builtParams.map((params) => ({ params })), + }, + page, + configFileName, + appDir: true, + }) } } - - return buildStaticPaths({ - staticPathsResult: { - fallback, - paths: builtParams.map((params) => ({ params })), - }, - page, - configFileName, - appDir: true, - }) - } + ) } export async function isPageStatic({ @@ -1243,6 +1326,9 @@ export async function isPageStatic({ pageType, hasServerComponents, originalAppPath, + isrFlushToDisk, + maxMemoryCacheSize, + incrementalCacheHandlerPath, }: { page: string distDir: string @@ -1258,6 +1344,9 @@ export async function isPageStatic({ pageRuntime?: ServerRuntime hasServerComponents?: boolean originalAppPath?: string + isrFlushToDisk?: boolean + maxMemoryCacheSize?: number + incrementalCacheHandlerPath?: string }): Promise<{ isStatic?: boolean isAmpOnly?: boolean @@ -1335,6 +1424,9 @@ export async function isPageStatic({ isClientComponent = isClientReference(componentsResult.ComponentMod) const tree = componentsResult.ComponentMod.tree const handlers = componentsResult.ComponentMod.handlers + const staticGenerationAsyncStorage = + componentsResult.ComponentMod.staticGenerationAsyncStorage + const serverHooks = componentsResult.ComponentMod.serverHooks const generateParams: GenerateParams = handlers ? [ @@ -1399,8 +1491,15 @@ export async function isPageStatic({ encodedPaths: encodedPrerenderRoutes, } = await buildAppStaticPaths({ page, + serverHooks, + staticGenerationAsyncStorage, configFileName, generateParams, + distDir, + requestHeaders: {}, + isrFlushToDisk, + maxMemoryCacheSize, + incrementalCacheHandlerPath, })) } } else { diff --git a/packages/next/src/lib/worker.ts b/packages/next/src/lib/worker.ts index 41f9f25cf96a..2581fbbad80b 100644 --- a/packages/next/src/lib/worker.ts +++ b/packages/next/src/lib/worker.ts @@ -49,7 +49,8 @@ export class Worker { _child: ChildProcess }[]) { worker._child.on('exit', (code, signal) => { - if (code || signal) { + // log unexpected exit if .end() wasn't called + if ((code || signal) && this._worker) { console.error( `Static worker unexpectedly exited with code: ${code} and signal: ${signal}` ) diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts index ad46cee77762..d88f404e4f18 100644 --- a/packages/next/src/server/base-server.ts +++ b/packages/next/src/server/base-server.ts @@ -1096,6 +1096,7 @@ export default abstract class Server { pathname, }: { pathname: string + requestHeaders: import('./lib/incremental-cache').IncrementalCache['requestHeaders'] originalAppPath?: string }): Promise<{ staticPaths?: string[] @@ -1162,6 +1163,7 @@ export default abstract class Server { const pathsResult = await this.getStaticPaths({ pathname, originalAppPath: components.pathname, + requestHeaders: req.headers, }) staticPaths = pathsResult.staticPaths @@ -1607,7 +1609,10 @@ export default abstract class Server { if (!staticPaths) { ;({ staticPaths, fallbackMode } = hasStaticPaths - ? await this.getStaticPaths({ pathname }) + ? await this.getStaticPaths({ + pathname, + requestHeaders: req.headers, + }) : { staticPaths: undefined, fallbackMode: false }) } diff --git a/packages/next/src/server/dev/next-dev-server.ts b/packages/next/src/server/dev/next-dev-server.ts index 0439fd8fff78..15075034973a 100644 --- a/packages/next/src/server/dev/next-dev-server.ts +++ b/packages/next/src/server/dev/next-dev-server.ts @@ -97,6 +97,7 @@ import { DefaultFileReader } from '../future/route-matcher-providers/dev/helpers import { NextBuildContext } from '../../build/build-context' import { logAppDirError } from './log-app-dir-error' import { createClientRouterFilter } from '../../lib/create-client-router-filter' +import { IncrementalCache } from '../lib/incremental-cache' // Load ReactDevOverlay only when needed let ReactDevOverlayImpl: FunctionComponent @@ -1511,9 +1512,11 @@ export default class DevServer extends Server { protected async getStaticPaths({ pathname, originalAppPath, + requestHeaders, }: { pathname: string originalAppPath?: string + requestHeaders: IncrementalCache['requestHeaders'] }): Promise<{ staticPaths?: string[] fallbackMode?: false | 'static' | 'blocking' @@ -1545,6 +1548,12 @@ export default class DevServer extends Server { defaultLocale, originalAppPath, isAppPath: !!originalAppPath, + requestHeaders, + incrementalCacheHandlerPath: + this.nextConfig.experimental.incrementalCacheHandlerPath, + fetchCacheKeyPrefix: this.nextConfig.experimental.fetchCacheKeyPrefix, + isrFlushToDisk: this.nextConfig.experimental.isrFlushToDisk, + maxMemoryCacheSize: this.nextConfig.experimental.isrMemoryCacheSize, }) return pathsResult } diff --git a/packages/next/src/server/dev/static-paths-worker.ts b/packages/next/src/server/dev/static-paths-worker.ts index 2515c3409767..f98fee43a00a 100644 --- a/packages/next/src/server/dev/static-paths-worker.ts +++ b/packages/next/src/server/dev/static-paths-worker.ts @@ -13,6 +13,9 @@ import { loadRequireHook, overrideBuiltInReactPackages, } from '../../build/webpack/require-hook' +import { IncrementalCache } from '../lib/incremental-cache' +import * as serverHooks from '../../client/components/hooks-server-context' +import { staticGenerationAsyncStorage } from '../../client/components/static-generation-async-storage' type RuntimeConfig = any @@ -40,6 +43,11 @@ export async function loadStaticPaths({ defaultLocale, isAppPath, originalAppPath, + isrFlushToDisk, + fetchCacheKeyPrefix, + maxMemoryCacheSize, + requestHeaders, + incrementalCacheHandlerPath, }: { distDir: string pathname: string @@ -50,6 +58,11 @@ export async function loadStaticPaths({ defaultLocale?: string isAppPath?: boolean originalAppPath?: string + isrFlushToDisk?: boolean + fetchCacheKeyPrefix?: string + maxMemoryCacheSize?: number + requestHeaders: IncrementalCache['requestHeaders'] + incrementalCacheHandlerPath?: string }): Promise<{ paths?: string[] encodedPaths?: string[] @@ -104,6 +117,14 @@ export async function loadStaticPaths({ page: pathname, generateParams, configFileName: config.configFileName, + distDir, + requestHeaders, + incrementalCacheHandlerPath, + serverHooks, + staticGenerationAsyncStorage, + isrFlushToDisk, + fetchCacheKeyPrefix, + maxMemoryCacheSize, }) } diff --git a/packages/next/src/server/lib/node-fs-methods.ts b/packages/next/src/server/lib/node-fs-methods.ts new file mode 100644 index 000000000000..4f3b9c5a031c --- /dev/null +++ b/packages/next/src/server/lib/node-fs-methods.ts @@ -0,0 +1,10 @@ +import _fs from 'fs' +import { CacheFs } from '../../shared/lib/utils' + +export const nodeFs: CacheFs = { + readFile: (f) => _fs.promises.readFile(f, 'utf8'), + readFileSync: (f) => _fs.readFileSync(f, 'utf8'), + writeFile: (f, d) => _fs.promises.writeFile(f, d, 'utf8'), + mkdir: (dir) => _fs.promises.mkdir(dir, { recursive: true }), + stat: (f) => _fs.promises.stat(f), +} diff --git a/packages/next/src/server/next-server.ts b/packages/next/src/server/next-server.ts index 8913c3fe9f9f..9d233c9733c4 100644 --- a/packages/next/src/server/next-server.ts +++ b/packages/next/src/server/next-server.ts @@ -99,6 +99,7 @@ import { MatchOptions } from './future/route-matcher-managers/route-matcher-mana import { INSTRUMENTATION_HOOK_FILENAME } from '../lib/constants' import { getTracer } from './lib/trace/tracer' import { NextNodeServerSpan } from './lib/trace/constants' +import { nodeFs } from './lib/node-fs-methods' export * from './base-server' @@ -1336,13 +1337,7 @@ export default class NextNodeServer extends BaseServer { } protected getCacheFilesystem(): CacheFs { - return { - readFile: (f) => fs.promises.readFile(f, 'utf8'), - readFileSync: (f) => fs.readFileSync(f, 'utf8'), - writeFile: (f, d) => fs.promises.writeFile(f, d, 'utf8'), - mkdir: (dir) => fs.promises.mkdir(dir, { recursive: true }), - stat: (f) => fs.promises.stat(f), - } + return nodeFs } private normalizeReq( 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 0b896eda3809..f8dbecd7c0e8 100644 --- a/test/e2e/app-dir/app-static/app-static.test.ts +++ b/test/e2e/app-dir/app-static/app-static.test.ts @@ -22,13 +22,30 @@ createNextDescribe( }, }, ({ next, isNextDev: isDev, isNextStart, isNextDeploy }) => { + let prerenderManifest + + beforeAll(async () => { + if (isNextStart) { + prerenderManifest = JSON.parse( + await next.readFile('.next/prerender-manifest.json') + ) + } + }) + if (isNextStart) { it('should output HTML/RSC files for static paths', async () => { const files = ( await glob('**/*', { cwd: join(next.testDir, '.next/server/app'), }) - ).filter((file) => file.match(/.*\.(js|html|rsc)$/)) + ) + .filter((file) => file.match(/.*\.(js|html|rsc)$/)) + .map((file) => { + return file.replace( + /partial-gen-params-no-additional-([\w]{1,})\/([\w]{1,})\/([\d]{1,})/, + 'partial-gen-params-no-additional-$1/$2/RAND' + ) + }) expect(files).toEqual([ '(new)/custom/page.js', @@ -74,6 +91,33 @@ createNextDescribe( 'hooks/use-search-params/with-suspense.html', 'hooks/use-search-params/with-suspense.rsc', 'hooks/use-search-params/with-suspense/page.js', + 'partial-gen-params-no-additional-lang/[lang]/[slug]/page.js', + 'partial-gen-params-no-additional-lang/en/RAND.html', + 'partial-gen-params-no-additional-lang/en/RAND.rsc', + 'partial-gen-params-no-additional-lang/en/first.html', + 'partial-gen-params-no-additional-lang/en/first.rsc', + 'partial-gen-params-no-additional-lang/en/second.html', + 'partial-gen-params-no-additional-lang/en/second.rsc', + 'partial-gen-params-no-additional-lang/fr/RAND.html', + 'partial-gen-params-no-additional-lang/fr/RAND.rsc', + 'partial-gen-params-no-additional-lang/fr/first.html', + 'partial-gen-params-no-additional-lang/fr/first.rsc', + 'partial-gen-params-no-additional-lang/fr/second.html', + 'partial-gen-params-no-additional-lang/fr/second.rsc', + 'partial-gen-params-no-additional-slug/[lang]/[slug]/page.js', + 'partial-gen-params-no-additional-slug/en/RAND.html', + 'partial-gen-params-no-additional-slug/en/RAND.rsc', + 'partial-gen-params-no-additional-slug/en/first.html', + 'partial-gen-params-no-additional-slug/en/first.rsc', + 'partial-gen-params-no-additional-slug/en/second.html', + 'partial-gen-params-no-additional-slug/en/second.rsc', + 'partial-gen-params-no-additional-slug/fr/RAND.html', + 'partial-gen-params-no-additional-slug/fr/RAND.rsc', + 'partial-gen-params-no-additional-slug/fr/first.html', + 'partial-gen-params-no-additional-slug/fr/first.rsc', + 'partial-gen-params-no-additional-slug/fr/second.html', + 'partial-gen-params-no-additional-slug/fr/second.rsc', + 'partial-gen-params/[lang]/[slug]/page.js', 'ssg-preview.html', 'ssg-preview.rsc', 'ssg-preview/[[...route]]/page.js', @@ -112,12 +156,10 @@ createNextDescribe( }) it('should have correct prerender-manifest entries', async () => { - const manifest = JSON.parse( - await next.readFile('.next/prerender-manifest.json') - ) + const curManifest = JSON.parse(JSON.stringify(prerenderManifest)) - Object.keys(manifest.dynamicRoutes).forEach((key) => { - const item = manifest.dynamicRoutes[key] + for (const key of Object.keys(curManifest.dynamicRoutes)) { + const item = curManifest.dynamicRoutes[key] if (item.dataRouteRegex) { item.dataRouteRegex = normalizeRegEx(item.dataRouteRegex) @@ -125,10 +167,25 @@ createNextDescribe( if (item.routeRegex) { item.routeRegex = normalizeRegEx(item.routeRegex) } - }) + } + + for (const key of Object.keys(curManifest.routes)) { + const newKey = key.replace( + /partial-gen-params-no-additional-([\w]{1,})\/([\w]{1,})\/([\d]{1,})/, + 'partial-gen-params-no-additional-$1/$2/RAND' + ) + if (newKey !== key) { + const route = curManifest.routes[key] + delete curManifest.routes[key] + curManifest.routes[newKey] = { + ...route, + dataRoute: `${newKey}.rsc`, + } + } + } - expect(manifest.version).toBe(4) - expect(manifest.routes).toEqual({ + expect(curManifest.version).toBe(4) + expect(curManifest.routes).toEqual({ '/blog/tim': { initialRevalidateSeconds: 10, srcRoute: '/blog/[author]', @@ -184,6 +241,66 @@ createNextDescribe( initialRevalidateSeconds: false, srcRoute: '/hooks/use-search-params/with-suspense', }, + '/partial-gen-params-no-additional-lang/en/RAND': { + dataRoute: '/partial-gen-params-no-additional-lang/en/RAND.rsc', + initialRevalidateSeconds: false, + srcRoute: '/partial-gen-params-no-additional-lang/[lang]/[slug]', + }, + '/partial-gen-params-no-additional-lang/en/first': { + dataRoute: '/partial-gen-params-no-additional-lang/en/first.rsc', + initialRevalidateSeconds: false, + srcRoute: '/partial-gen-params-no-additional-lang/[lang]/[slug]', + }, + '/partial-gen-params-no-additional-lang/en/second': { + dataRoute: '/partial-gen-params-no-additional-lang/en/second.rsc', + initialRevalidateSeconds: false, + srcRoute: '/partial-gen-params-no-additional-lang/[lang]/[slug]', + }, + '/partial-gen-params-no-additional-lang/fr/RAND': { + dataRoute: '/partial-gen-params-no-additional-lang/fr/RAND.rsc', + initialRevalidateSeconds: false, + srcRoute: '/partial-gen-params-no-additional-lang/[lang]/[slug]', + }, + '/partial-gen-params-no-additional-lang/fr/first': { + dataRoute: '/partial-gen-params-no-additional-lang/fr/first.rsc', + initialRevalidateSeconds: false, + srcRoute: '/partial-gen-params-no-additional-lang/[lang]/[slug]', + }, + '/partial-gen-params-no-additional-lang/fr/second': { + dataRoute: '/partial-gen-params-no-additional-lang/fr/second.rsc', + initialRevalidateSeconds: false, + srcRoute: '/partial-gen-params-no-additional-lang/[lang]/[slug]', + }, + '/partial-gen-params-no-additional-slug/en/RAND': { + dataRoute: '/partial-gen-params-no-additional-slug/en/RAND.rsc', + initialRevalidateSeconds: false, + srcRoute: '/partial-gen-params-no-additional-slug/[lang]/[slug]', + }, + '/partial-gen-params-no-additional-slug/en/first': { + dataRoute: '/partial-gen-params-no-additional-slug/en/first.rsc', + initialRevalidateSeconds: false, + srcRoute: '/partial-gen-params-no-additional-slug/[lang]/[slug]', + }, + '/partial-gen-params-no-additional-slug/en/second': { + dataRoute: '/partial-gen-params-no-additional-slug/en/second.rsc', + initialRevalidateSeconds: false, + srcRoute: '/partial-gen-params-no-additional-slug/[lang]/[slug]', + }, + '/partial-gen-params-no-additional-slug/fr/RAND': { + dataRoute: '/partial-gen-params-no-additional-slug/fr/RAND.rsc', + initialRevalidateSeconds: false, + srcRoute: '/partial-gen-params-no-additional-slug/[lang]/[slug]', + }, + '/partial-gen-params-no-additional-slug/fr/first': { + dataRoute: '/partial-gen-params-no-additional-slug/fr/first.rsc', + initialRevalidateSeconds: false, + srcRoute: '/partial-gen-params-no-additional-slug/[lang]/[slug]', + }, + '/partial-gen-params-no-additional-slug/fr/second': { + dataRoute: '/partial-gen-params-no-additional-slug/fr/second.rsc', + initialRevalidateSeconds: false, + srcRoute: '/partial-gen-params-no-additional-slug/[lang]/[slug]', + }, '/force-static/first': { dataRoute: '/force-static/first.rsc', initialRevalidateSeconds: false, @@ -240,7 +357,7 @@ createNextDescribe( srcRoute: '/variable-revalidate/revalidate-3', }, }) - expect(manifest.dynamicRoutes).toEqual({ + expect(curManifest.dynamicRoutes).toEqual({ '/blog/[author]/[slug]': { routeRegex: normalizeRegEx('^/blog/([^/]+?)/([^/]+?)(?:/)?$'), dataRoute: '/blog/[author]/[slug].rsc', @@ -255,9 +372,13 @@ createNextDescribe( }, '/dynamic-error/[id]': { dataRoute: '/dynamic-error/[id].rsc', - dataRouteRegex: '^\\/dynamic\\-error\\/([^\\/]+?)\\.rsc$', + dataRouteRegex: normalizeRegEx( + '^\\/dynamic\\-error\\/([^\\/]+?)\\.rsc$' + ), fallback: null, - routeRegex: '^\\/dynamic\\-error\\/([^\\/]+?)(?:\\/)?$', + routeRegex: normalizeRegEx( + '^\\/dynamic\\-error\\/([^\\/]+?)(?:\\/)?$' + ), }, '/gen-params-dynamic-revalidate/[slug]': { dataRoute: '/gen-params-dynamic-revalidate/[slug].rsc', @@ -279,6 +400,38 @@ createNextDescribe( '^\\/hooks\\/use\\-pathname\\/([^\\/]+?)(?:\\/)?$' ), }, + '/partial-gen-params-no-additional-lang/[lang]/[slug]': { + dataRoute: + '/partial-gen-params-no-additional-lang/[lang]/[slug].rsc', + dataRouteRegex: normalizeRegEx( + '^\\/partial\\-gen\\-params\\-no\\-additional\\-lang\\/([^\\/]+?)\\/([^\\/]+?)\\.rsc$' + ), + fallback: false, + routeRegex: normalizeRegEx( + '^\\/partial\\-gen\\-params\\-no\\-additional\\-lang\\/([^\\/]+?)\\/([^\\/]+?)(?:\\/)?$' + ), + }, + '/partial-gen-params-no-additional-slug/[lang]/[slug]': { + dataRoute: + '/partial-gen-params-no-additional-slug/[lang]/[slug].rsc', + dataRouteRegex: normalizeRegEx( + '^\\/partial\\-gen\\-params\\-no\\-additional\\-slug\\/([^\\/]+?)\\/([^\\/]+?)\\.rsc$' + ), + fallback: false, + routeRegex: normalizeRegEx( + '^\\/partial\\-gen\\-params\\-no\\-additional\\-slug\\/([^\\/]+?)\\/([^\\/]+?)(?:\\/)?$' + ), + }, + '/partial-gen-params/[lang]/[slug]': { + dataRoute: '/partial-gen-params/[lang]/[slug].rsc', + dataRouteRegex: normalizeRegEx( + '^\\/partial\\-gen\\-params\\/([^\\/]+?)\\/([^\\/]+?)\\.rsc$' + ), + fallback: null, + routeRegex: normalizeRegEx( + '^\\/partial\\-gen\\-params\\/([^\\/]+?)\\/([^\\/]+?)(?:\\/)?$' + ), + }, '/force-static/[slug]': { dataRoute: '/force-static/[slug].rsc', dataRouteRegex: normalizeRegEx( @@ -388,6 +541,166 @@ createNextDescribe( }) } + it('should handle partial-gen-params with default dynamicParams correctly', async () => { + const res = await next.fetch('/partial-gen-params/en/first') + expect(res.status).toBe(200) + + const html = await res.text() + const $ = cheerio.load(html) + const params = JSON.parse($('#params').text()) + + expect(params).toEqual({ lang: 'en', slug: 'first' }) + }) + + it('should handle partial-gen-params with layout dynamicParams = false correctly', async () => { + for (const { path, status, params } of [ + // these checks don't work with custom memory only + // cache handler + ...(process.env.CUSTOM_CACHE_HANDLER + ? [] + : [ + { + path: '/partial-gen-params-no-additional-lang/en/first', + status: 200, + params: { lang: 'en', slug: 'first' }, + }, + ]), + { + path: '/partial-gen-params-no-additional-lang/de/first', + status: 404, + params: {}, + }, + { + path: '/partial-gen-params-no-additional-lang/en/non-existent', + status: 404, + params: {}, + }, + ]) { + const res = await next.fetch(path) + expect(res.status).toBe(status) + + const html = await res.text() + const $ = cheerio.load(html) + const curParams = JSON.parse($('#params').text() || '{}') + + expect(curParams).toEqual(params) + } + }) + + it('should handle partial-gen-params with page dynamicParams = false correctly', async () => { + for (const { path, status, params } of [ + // these checks don't work with custom memory only + // cache handler + ...(process.env.CUSTOM_CACHE_HANDLER + ? [] + : [ + { + path: '/partial-gen-params-no-additional-slug/en/first', + status: 200, + params: { lang: 'en', slug: 'first' }, + }, + ]), + { + path: '/partial-gen-params-no-additional-slug/de/first', + status: 404, + params: {}, + }, + { + path: '/partial-gen-params-no-additional-slug/en/non-existent', + status: 404, + params: {}, + }, + ]) { + const res = await next.fetch(path) + expect(res.status).toBe(status) + + const html = await res.text() + const $ = cheerio.load(html) + const curParams = JSON.parse($('#params').text() || '{}') + + expect(curParams).toEqual(params) + } + }) + + // fetch cache in generateStaticParams needs fs for persistence + // so doesn't behave as expected with custom in memory only + // cache handler + if (!process.env.CUSTOM_CACHE_HANDLER) { + it('should honor fetch cache in generateStaticParams', async () => { + const initialRes = await next.fetch( + `/partial-gen-params-no-additional-lang/en/first` + ) + + expect(initialRes.status).toBe(200) + + // we can't read prerender-manifest from deployment + if (isNextDeploy) return + + let langFetchSlug + let slugFetchSlug + + if (isDev) { + await check(() => { + const matches = stripAnsi(next.cliOutput).match( + /partial-gen-params fetch ([\d]{1,})/ + ) + + if (matches[1]) { + langFetchSlug = matches[1] + slugFetchSlug = langFetchSlug + } + return langFetchSlug ? 'success' : next.cliOutput + }, 'success') + } else { + // the fetch cache can potentially be a miss since + // the generateStaticParams are executed parallel + // in separate workers so parse value from + // prerender-manifest + const routes = Object.keys(prerenderManifest.routes) + + for (const route of routes) { + const langSlug = route.match( + /partial-gen-params-no-additional-lang\/en\/([\d]{1,})/ + )?.[1] + + if (langSlug) { + langFetchSlug = langSlug + } + + const slugSlug = route.match( + /partial-gen-params-no-additional-slug\/en\/([\d]{1,})/ + )?.[1] + + if (slugSlug) { + slugFetchSlug = slugSlug + } + } + } + require('console').log({ langFetchSlug, slugFetchSlug }) + + for (const { pathname, slug } of [ + { + pathname: '/partial-gen-params-no-additional-lang/en', + slug: langFetchSlug, + }, + { + pathname: '/partial-gen-params-no-additional-slug/en', + slug: slugFetchSlug, + }, + ]) { + const res = await next.fetch(`${pathname}/${slug}`) + expect(res.status).toBe(200) + expect( + JSON.parse( + cheerio + .load(await res.text())('#params') + .text() + ) + ).toEqual({ lang: 'en', slug }) + } + }) + } + it('should honor fetch cache correctly', async () => { await check(async () => { const res = await fetchViaHTTP( diff --git a/test/e2e/app-dir/app-static/app/partial-gen-params-no-additional-lang/[lang]/[slug]/page.js b/test/e2e/app-dir/app-static/app/partial-gen-params-no-additional-lang/[lang]/[slug]/page.js new file mode 100644 index 000000000000..2767974e967c --- /dev/null +++ b/test/e2e/app-dir/app-static/app/partial-gen-params-no-additional-lang/[lang]/[slug]/page.js @@ -0,0 +1,32 @@ +export const dynamicParams = true + +export async function generateStaticParams() { + const res = await fetch( + 'https://next-data-api-endpoint.vercel.app/api/random?staticGen' + ) + + const data = await res.text() + const fetchSlug = Math.round(Number(data) * 100) + console.log('partial-gen-params fetch', fetchSlug) + + return [ + { + slug: 'first', + }, + { + slug: 'second', + }, + { + slug: fetchSlug + '', + }, + ] +} + +export default function Page({ params }) { + return ( + <> +

/partial-gen-params/[lang]/[slug]

+

{JSON.stringify(params)}

+ + ) +} diff --git a/test/e2e/app-dir/app-static/app/partial-gen-params-no-additional-lang/[lang]/layout.js b/test/e2e/app-dir/app-static/app/partial-gen-params-no-additional-lang/[lang]/layout.js new file mode 100644 index 000000000000..9f12f4da4794 --- /dev/null +++ b/test/e2e/app-dir/app-static/app/partial-gen-params-no-additional-lang/[lang]/layout.js @@ -0,0 +1,9 @@ +export const dynamicParams = false + +export function generateStaticParams() { + return [{ lang: 'en' }, { lang: 'fr' }] +} + +export default function Layout({ children }) { + return <>{children} +} diff --git a/test/e2e/app-dir/app-static/app/partial-gen-params-no-additional-slug/[lang]/[slug]/page.js b/test/e2e/app-dir/app-static/app/partial-gen-params-no-additional-slug/[lang]/[slug]/page.js new file mode 100644 index 000000000000..5fd4a8c2e790 --- /dev/null +++ b/test/e2e/app-dir/app-static/app/partial-gen-params-no-additional-slug/[lang]/[slug]/page.js @@ -0,0 +1,32 @@ +export const dynamicParams = false + +export async function generateStaticParams() { + const res = await fetch( + 'https://next-data-api-endpoint.vercel.app/api/random?staticGen' + ) + + const data = await res.text() + const fetchSlug = Math.round(Number(data) * 100) + console.log('partial-gen-params fetch', fetchSlug) + + return [ + { + slug: 'first', + }, + { + slug: 'second', + }, + { + slug: fetchSlug + '', + }, + ] +} + +export default function Page({ params }) { + return ( + <> +

/partial-gen-params/[lang]/[slug]

+

{JSON.stringify(params)}

+ + ) +} diff --git a/test/e2e/app-dir/app-static/app/partial-gen-params-no-additional-slug/[lang]/layout.js b/test/e2e/app-dir/app-static/app/partial-gen-params-no-additional-slug/[lang]/layout.js new file mode 100644 index 000000000000..9c1b007e107d --- /dev/null +++ b/test/e2e/app-dir/app-static/app/partial-gen-params-no-additional-slug/[lang]/layout.js @@ -0,0 +1,9 @@ +export const dynamicParams = true + +export function generateStaticParams() { + return [{ lang: 'en' }, { lang: 'fr' }] +} + +export default function Layout({ children }) { + return <>{children} +} diff --git a/test/e2e/app-dir/app-static/app/partial-gen-params/[lang]/[slug]/page.js b/test/e2e/app-dir/app-static/app/partial-gen-params/[lang]/[slug]/page.js new file mode 100644 index 000000000000..47c212176f01 --- /dev/null +++ b/test/e2e/app-dir/app-static/app/partial-gen-params/[lang]/[slug]/page.js @@ -0,0 +1,8 @@ +export default function Page({ params }) { + return ( + <> +

/partial-gen-params/[lang]/[slug]

+

{JSON.stringify(params)}

+ + ) +} diff --git a/test/e2e/app-dir/app-static/app/partial-gen-params/[lang]/layout.js b/test/e2e/app-dir/app-static/app/partial-gen-params/[lang]/layout.js new file mode 100644 index 000000000000..a57210039972 --- /dev/null +++ b/test/e2e/app-dir/app-static/app/partial-gen-params/[lang]/layout.js @@ -0,0 +1,7 @@ +export function generateStaticParams() { + return [{ lang: 'en' }, { lang: 'fr' }] +} + +export default function Layout({ children }) { + return <>{children} +}