From 252791753249f381aa684c3fbada79c5878c5a86 Mon Sep 17 00:00:00 2001 From: James Anderson Date: Sun, 28 Jul 2024 17:53:15 +0100 Subject: [PATCH] Fix prerendered dynamic ISR functions change in Vercel CLI (#834) --- .changeset/warm-rivers-reply.md | 5 + .../processVercelFunctions/configs.ts | 1 + .../invalidFunctions.ts | 47 +++++++ .../prerenderFunctions.ts | 1 + packages/next-on-pages/src/utils/fs.ts | 10 ++ .../next-on-pages/tests/_helpers/index.ts | 10 +- .../invalidFunctions.test.ts | 121 ++++++++++++++++++ .../prerenderFunctions.test.ts | 4 +- 8 files changed, 194 insertions(+), 5 deletions(-) create mode 100644 .changeset/warm-rivers-reply.md diff --git a/.changeset/warm-rivers-reply.md b/.changeset/warm-rivers-reply.md new file mode 100644 index 000000000..0e3904778 --- /dev/null +++ b/.changeset/warm-rivers-reply.md @@ -0,0 +1,5 @@ +--- +'@cloudflare/next-on-pages': patch +--- + +Account for the Vercel CLI no longer generating prerender configs for dynamic ISR functions. diff --git a/packages/next-on-pages/src/buildApplication/processVercelFunctions/configs.ts b/packages/next-on-pages/src/buildApplication/processVercelFunctions/configs.ts index a7aa5bf65..dea0e5490 100644 --- a/packages/next-on-pages/src/buildApplication/processVercelFunctions/configs.ts +++ b/packages/next-on-pages/src/buildApplication/processVercelFunctions/configs.ts @@ -88,6 +88,7 @@ export type CollectedFunctions = { export type FunctionInfo = { relativePath: string; config: VercelFunctionConfig; + sourcePath?: string; outputPath?: string; outputByteSize?: number; route?: { diff --git a/packages/next-on-pages/src/buildApplication/processVercelFunctions/invalidFunctions.ts b/packages/next-on-pages/src/buildApplication/processVercelFunctions/invalidFunctions.ts index 7d109668d..d6d92d729 100644 --- a/packages/next-on-pages/src/buildApplication/processVercelFunctions/invalidFunctions.ts +++ b/packages/next-on-pages/src/buildApplication/processVercelFunctions/invalidFunctions.ts @@ -44,6 +44,7 @@ export async function checkInvalidFunctions( await tryToFixI18nFunctions(collectedFunctions, opts); await tryToFixInvalidFuncsWithValidIndexAlternative(collectedFunctions); + await tryToFixInvalidDynamicISRFuncs(collectedFunctions); if (collectedFunctions.invalidFunctions.size > 0) { await printInvalidFunctionsErrorMessage( @@ -309,3 +310,49 @@ async function tryToFixInvalidFuncsWithValidIndexAlternative({ } } } + +/** + * Tries to fix invalid dynamic ISR functions that have valid prerendered children. + * + * The Vercel CLI might not generated a prerender config for a dynamic ISR function, depending + * on the Vercel CLI version. Therefore, we also check if valid prerendered routes were created + * for the dynamic route to determine if the function can be ignored. + * + * @param collectedFunctions Collected functions from the Vercel build output. + */ +async function tryToFixInvalidDynamicISRFuncs({ + prerenderedFunctions, + invalidFunctions, + ignoredFunctions, +}: CollectedFunctions) { + if (invalidFunctions.size === 0) { + return; + } + + const prerenderedFunctionEntries = [...prerenderedFunctions.values()]; + + for (const [fullPath, fnInfo] of invalidFunctions.entries()) { + const fnPathWithoutRscOrFuncExt = fnInfo.relativePath.replace( + /(\.rsc)?\.func$/, + '', + ); + + const isDynamicISRFunc = + fnInfo.config.operationType === 'ISR' && + /\/\[[\w-]+\]$/.test(fnPathWithoutRscOrFuncExt); + + if (isDynamicISRFunc) { + const matchingPrerenderedChildFunc = prerenderedFunctionEntries.find( + fnInfo => fnInfo.sourcePath === fnPathWithoutRscOrFuncExt, + ); + + if (matchingPrerenderedChildFunc) { + ignoredFunctions.set(fullPath, { + reason: 'invalid dynamic isr route with valid prerendered children', + ...fnInfo, + }); + invalidFunctions.delete(fullPath); + } + } + } +} diff --git a/packages/next-on-pages/src/buildApplication/processVercelFunctions/prerenderFunctions.ts b/packages/next-on-pages/src/buildApplication/processVercelFunctions/prerenderFunctions.ts index 1f03e34a7..12472db65 100644 --- a/packages/next-on-pages/src/buildApplication/processVercelFunctions/prerenderFunctions.ts +++ b/packages/next-on-pages/src/buildApplication/processVercelFunctions/prerenderFunctions.ts @@ -46,6 +46,7 @@ export async function processPrerenderFunctions( headers: config.initialHeaders, overrides: getRouteOverrides(destRoute), }; + fnInfo.sourcePath = config.sourcePath; } else { invalidFunctions.set(path, fnInfo); prerenderedFunctions.delete(path); diff --git a/packages/next-on-pages/src/utils/fs.ts b/packages/next-on-pages/src/utils/fs.ts index e0bf18df6..057d0842a 100644 --- a/packages/next-on-pages/src/utils/fs.ts +++ b/packages/next-on-pages/src/utils/fs.ts @@ -178,3 +178,13 @@ export function getFileHash(path: string): Buffer | undefined { return undefined; } } + +/** + * Add a trailing slash to a path name if it doesn't already have one. + * + * @param path Path name to add a trailing slash to. + * @returns Path name with a trailing slash added. + */ +export function addTrailingSlash(path: string) { + return path.endsWith('/') ? path : `${path}/`; +} diff --git a/packages/next-on-pages/tests/_helpers/index.ts b/packages/next-on-pages/tests/_helpers/index.ts index cb392aff9..ed9b99cf2 100644 --- a/packages/next-on-pages/tests/_helpers/index.ts +++ b/packages/next-on-pages/tests/_helpers/index.ts @@ -304,11 +304,14 @@ export function createInvalidFuncDir( * Create a fake prerender config file for testing. * * @param path Path name for the file in the build output. - * @param ext File extension for the fallback file in the build output. + * @param opts File extension for the fallback in the build output and prerender config options. * @returns The stringified prerender config file contents. */ -export function mockPrerenderConfigFile(path: string, ext?: string): string { - const extension = ext || (path.endsWith('.rsc') ? 'rsc' : 'html'); +export function mockPrerenderConfigFile( + path: string, + opts: { ext?: string; sourcePath?: string } = {}, +): string { + const extension = opts.ext || (path.endsWith('.rsc') ? 'rsc' : 'html'); const fsPath = `${path}.prerender-fallback.${extension}`; const config: VercelPrerenderConfig = { @@ -318,6 +321,7 @@ export function mockPrerenderConfigFile(path: string, ext?: string): string { mode: 0, fsPath, }, + sourcePath: opts.sourcePath, initialHeaders: { ...((path.endsWith('.rsc') || path.endsWith('.json')) && { 'content-type': 'text/x-component', diff --git a/packages/next-on-pages/tests/src/buildApplication/processVercelFunctions/invalidFunctions.test.ts b/packages/next-on-pages/tests/src/buildApplication/processVercelFunctions/invalidFunctions.test.ts index e7664c1ab..8cb1bb226 100644 --- a/packages/next-on-pages/tests/src/buildApplication/processVercelFunctions/invalidFunctions.test.ts +++ b/packages/next-on-pages/tests/src/buildApplication/processVercelFunctions/invalidFunctions.test.ts @@ -220,4 +220,125 @@ describe('checkInvalidFunctions', () => { ignoredFunctions.has(resolve(functionsDir, 'index.action.func')), ).toEqual(true); }); + + test('should ignore dynamic isr routes with prerendered children', async () => { + const mockedConsoleWarn = mockConsole('warn'); + + const { collectedFunctions, restoreFsMock } = await collectFunctionsFrom({ + functions: { + '[dynamic-1].func': prerenderFuncDir, + '[dynamic-1].rsc.func': prerenderFuncDir, + 'dynamic-1-child.func': prerenderFuncDir, + 'dynamic-1-child.prerender-config.json': mockPrerenderConfigFile( + 'dynamic-1-child', + { sourcePath: '/[dynamic-1]' }, + ), + 'dynamic-1-child.prerender-fallback.html': '', + nested: { + '[dynamic-2].func': prerenderFuncDir, + 'dynamic-2-child.func': prerenderFuncDir, + 'dynamic-2-child.prerender-config.json': mockPrerenderConfigFile( + 'dynamic-2-child', + { sourcePath: '/nested/[dynamic-2]' }, + ), + 'dynamic-2-child.prerender-fallback.html': '', + }, + }, + }); + + const opts = { + functionsDir, + outputDir: resolve('.vercel/output/static'), + vercelConfig: { version: 3 as const }, + }; + + await processEdgeFunctions(collectedFunctions); + await processPrerenderFunctions(collectedFunctions, opts); + await checkInvalidFunctions(collectedFunctions, opts); + restoreFsMock(); + + const { prerenderedFunctions, invalidFunctions, ignoredFunctions } = + collectedFunctions; + + expect(prerenderedFunctions.size).toEqual(2); + expect(invalidFunctions.size).toEqual(0); + expect(ignoredFunctions.size).toEqual(3); + + expect(getRouteInfo(prerenderedFunctions, 'dynamic-1-child.func')).toEqual({ + path: '/dynamic-1-child.html', + overrides: ['/dynamic-1-child'], + headers: { vary: 'RSC, Next-Router-State-Tree, Next-Router-Prefetch' }, + }); + expect( + getRouteInfo(prerenderedFunctions, 'nested/dynamic-2-child.func'), + ).toEqual({ + path: '/nested/dynamic-2-child.html', + overrides: ['/nested/dynamic-2-child'], + headers: { vary: 'RSC, Next-Router-State-Tree, Next-Router-Prefetch' }, + }); + + expect([...ignoredFunctions.keys()]).toEqual([ + resolve(functionsDir, '[dynamic-1].func'), + resolve(functionsDir, '[dynamic-1].rsc.func'), + resolve(functionsDir, 'nested/[dynamic-2].func'), + ]); + + mockedConsoleWarn.restore(); + }); + + test('should not ignore dynamic isr routes when there are no prerendered children', async () => { + const processExitMock = vi + .spyOn(process, 'exit') + .mockImplementation(async () => undefined as never); + const mockedConsoleWarn = mockConsole('warn'); + const mockedConsoleError = mockConsole('error'); + + const { collectedFunctions, restoreFsMock } = await collectFunctionsFrom({ + functions: { + '[dynamic-1].func': prerenderFuncDir, + 'edge-route.func': edgeFuncDir, + }, + }); + + const opts = { + functionsDir, + outputDir: resolve('.vercel/output/static'), + vercelConfig: { version: 3 as const }, + }; + + await processEdgeFunctions(collectedFunctions); + await processPrerenderFunctions(collectedFunctions, opts); + await checkInvalidFunctions(collectedFunctions, opts); + restoreFsMock(); + + const { + edgeFunctions, + prerenderedFunctions, + invalidFunctions, + ignoredFunctions, + } = collectedFunctions; + + expect(edgeFunctions.size).toEqual(1); + expect(prerenderedFunctions.size).toEqual(0); + expect(invalidFunctions.size).toEqual(1); + expect(ignoredFunctions.size).toEqual(0); + + expect(getRouteInfo(edgeFunctions, 'edge-route.func')).toEqual({ + path: '/edge-route', + overrides: [], + }); + + expect([...invalidFunctions.keys()]).toEqual([ + resolve(functionsDir, '[dynamic-1].func'), + ]); + + expect(processExitMock).toHaveBeenCalledWith(1); + mockedConsoleError.expectCalls([ + /The following routes were not configured to run with the Edge Runtime(?:.|\n)+- \/\[dynamic-1\]/, + ]); + + processExitMock.mockRestore(); + mockedConsoleError.restore(); + mockedConsoleWarn.restore(); + }); }); diff --git a/packages/next-on-pages/tests/src/buildApplication/processVercelFunctions/prerenderFunctions.test.ts b/packages/next-on-pages/tests/src/buildApplication/processVercelFunctions/prerenderFunctions.test.ts index e4f4d3756..bf283d586 100644 --- a/packages/next-on-pages/tests/src/buildApplication/processVercelFunctions/prerenderFunctions.test.ts +++ b/packages/next-on-pages/tests/src/buildApplication/processVercelFunctions/prerenderFunctions.test.ts @@ -77,7 +77,7 @@ describe('processPrerenderFunctions', () => { 'favicon.ico.func': prerenderFuncDir, 'favicon.ico.prerender-config.json': mockPrerenderConfigFile( 'favicon.ico', - 'body', + { ext: 'body' }, ), 'favicon.ico.prerender-fallback.body': 'favicon.ico', }, @@ -123,7 +123,7 @@ describe('processPrerenderFunctions', () => { 'data.json.func': prerenderFuncDir, 'data.json.prerender-config.json': mockPrerenderConfigFile( 'data.json', - 'json', + { ext: 'json' }, ), 'data.json.prerender-fallback.json': 'data.json', },