diff --git a/src/build/content/prerendered.ts b/src/build/content/prerendered.ts index 6c45a7447..3510aefe1 100644 --- a/src/build/content/prerendered.ts +++ b/src/build/content/prerendered.ts @@ -41,17 +41,28 @@ const writeCacheEntry = async ( } /** - * Normalize routes by stripping leading slashes and ensuring root path is index + * Normalize routes by ensuring leading slashes and ensuring root path is /index */ -const routeToFilePath = (path: string) => (path === '/' ? '/index' : path) +const routeToFilePath = (path: string) => { + if (path === '/') { + return '/index' + } + + if (path.startsWith('/')) { + return path + } + + return `/${path}` +} const buildPagesCacheValue = async ( path: string, shouldUseEnumKind: boolean, + shouldSkipJson = false, ): Promise => ({ kind: shouldUseEnumKind ? 'PAGES' : 'PAGE', html: await readFile(`${path}.html`, 'utf-8'), - pageData: JSON.parse(await readFile(`${path}.json`, 'utf-8')), + pageData: shouldSkipJson ? {} : JSON.parse(await readFile(`${path}.json`, 'utf-8')), headers: undefined, status: undefined, }) @@ -146,8 +157,8 @@ export const copyPrerenderedContent = async (ctx: PluginContext): Promise }) : false - await Promise.all( - Object.entries(manifest.routes).map( + await Promise.all([ + ...Object.entries(manifest.routes).map( ([route, meta]): Promise => limitConcurrentPrerenderContentHandling(async () => { const lastModified = meta.initialRevalidateSeconds @@ -195,7 +206,17 @@ export const copyPrerenderedContent = async (ctx: PluginContext): Promise await writeCacheEntry(key, value, lastModified, ctx) }), ), - ) + ...ctx.getFallbacks(manifest).map(async (route) => { + const key = routeToFilePath(route) + const value = await buildPagesCacheValue( + join(ctx.publishDir, 'server/pages', key), + shouldUseEnumKind, + true, // there is no corresponding json file for fallback, so we are skipping it for this entry + ) + + await writeCacheEntry(key, value, Date.now(), ctx) + }), + ]) // app router 404 pages are not in the prerender manifest // so we need to check for them manually diff --git a/src/build/content/static.test.ts b/src/build/content/static.test.ts index d26610e11..ed1927b29 100644 --- a/src/build/content/static.test.ts +++ b/src/build/content/static.test.ts @@ -1,12 +1,13 @@ -import { Buffer } from 'node:buffer' +import { readFile } from 'node:fs/promises' import { join } from 'node:path' import { inspect } from 'node:util' import type { NetlifyPluginOptions } from '@netlify/build' import glob from 'fast-glob' +import type { PrerenderManifest } from 'next/dist/build/index.js' import { beforeEach, describe, expect, Mock, test, vi } from 'vitest' -import { mockFileSystem } from '../../../tests/index.js' +import { decodeBlobKey, encodeBlobKey, mockFileSystem } from '../../../tests/index.js' import { type FixtureTestContext } from '../../../tests/utils/contexts.js' import { createFsFixture } from '../../../tests/utils/fixture.js' import { PluginContext, RequiredServerFilesManifest } from '../plugin-context.js' @@ -21,7 +22,19 @@ type Context = FixtureTestContext & { const createFsFixtureWithBasePath = ( fixture: Record, ctx: Omit, - basePath = '', + + { + basePath = '', + // eslint-disable-next-line unicorn/no-useless-undefined + i18n = undefined, + dynamicRoutes = {}, + }: { + basePath?: string + i18n?: Pick, 'locales'> + dynamicRoutes?: { + [route: string]: Pick + } + } = {}, ) => { return createFsFixture( { @@ -32,8 +45,10 @@ const createFsFixtureWithBasePath = ( appDir: ctx.relativeAppDir, config: { distDir: ctx.publishDir, + i18n, }, } as Pick), + [join(ctx.publishDir, 'prerender-manifest.json')]: JSON.stringify({ dynamicRoutes }), }, ctx, ) @@ -121,7 +136,7 @@ describe('Regular Repository layout', () => { '.next/static/sub-dir/test2.js': '', }, ctx, - '/base/path', + { basePath: '/base/path' }, ) await copyStaticAssets(pluginContext) @@ -168,7 +183,7 @@ describe('Regular Repository layout', () => { 'public/another-asset.json': '', }, ctx, - '/base/path', + { basePath: '/base/path' }, ) await copyStaticAssets(pluginContext) @@ -182,26 +197,100 @@ describe('Regular Repository layout', () => { ) }) - test('should copy the static pages to the publish directory if there are no corresponding JSON files', async ({ - pluginContext, - ...ctx - }) => { - await createFsFixtureWithBasePath( - { - '.next/server/pages/test.html': '', - '.next/server/pages/test2.html': '', - '.next/server/pages/test3.json': '', - }, - ctx, - ) + describe('should copy the static pages to the publish directory if there are no corresponding JSON files and mark wether html file is a fallback', () => { + test('no i18n', async ({ pluginContext, ...ctx }) => { + await createFsFixtureWithBasePath( + { + '.next/server/pages/test.html': '', + '.next/server/pages/test2.html': '', + '.next/server/pages/test3.json': '', + '.next/server/pages/blog/[slug].html': '', + }, + ctx, + { + dynamicRoutes: { + '/blog/[slug]': { + fallback: '/blog/[slug].html', + }, + }, + }, + ) - await copyStaticContent(pluginContext) - const files = await glob('**/*', { cwd: pluginContext.blobDir, dot: true }) + await copyStaticContent(pluginContext) + const files = await glob('**/*', { cwd: pluginContext.blobDir, dot: true }) + + const expectedStaticPages = ['blog/[slug].html', 'test.html', 'test2.html'] + const expectedFallbacks = new Set(['blog/[slug].html']) + + expect(files.map((path) => decodeBlobKey(path)).sort()).toEqual(expectedStaticPages) + + for (const page of expectedStaticPages) { + const expectedIsFallback = expectedFallbacks.has(page) + + const blob = JSON.parse( + await readFile(join(pluginContext.blobDir, await encodeBlobKey(page)), 'utf-8'), + ) - expect(files.map((path) => Buffer.from(path, 'base64').toString('utf-8')).sort()).toEqual([ - 'test.html', - 'test2.html', - ]) + expect(blob, `${page} should ${expectedIsFallback ? '' : 'not '}be a fallback`).toEqual({ + html: '', + isFallback: expectedIsFallback, + }) + } + }) + + test('with i18n', async ({ pluginContext, ...ctx }) => { + await createFsFixtureWithBasePath( + { + '.next/server/pages/de/test.html': '', + '.next/server/pages/de/test2.html': '', + '.next/server/pages/de/test3.json': '', + '.next/server/pages/de/blog/[slug].html': '', + '.next/server/pages/en/test.html': '', + '.next/server/pages/en/test2.html': '', + '.next/server/pages/en/test3.json': '', + '.next/server/pages/en/blog/[slug].html': '', + }, + ctx, + { + dynamicRoutes: { + '/blog/[slug]': { + fallback: '/blog/[slug].html', + }, + }, + i18n: { + locales: ['en', 'de'], + }, + }, + ) + + await copyStaticContent(pluginContext) + const files = await glob('**/*', { cwd: pluginContext.blobDir, dot: true }) + + const expectedStaticPages = [ + 'de/blog/[slug].html', + 'de/test.html', + 'de/test2.html', + 'en/blog/[slug].html', + 'en/test.html', + 'en/test2.html', + ] + const expectedFallbacks = new Set(['en/blog/[slug].html', 'de/blog/[slug].html']) + + expect(files.map((path) => decodeBlobKey(path)).sort()).toEqual(expectedStaticPages) + + for (const page of expectedStaticPages) { + const expectedIsFallback = expectedFallbacks.has(page) + + const blob = JSON.parse( + await readFile(join(pluginContext.blobDir, await encodeBlobKey(page)), 'utf-8'), + ) + + expect(blob, `${page} should ${expectedIsFallback ? '' : 'not '}be a fallback`).toEqual({ + html: '', + isFallback: expectedIsFallback, + }) + } + }) }) test('should not copy the static pages to the publish directory if there are corresponding JSON files', async ({ @@ -269,7 +358,7 @@ describe('Mono Repository', () => { 'apps/app-1/.next/static/sub-dir/test2.js': '', }, ctx, - '/base/path', + { basePath: '/base/path' }, ) await copyStaticAssets(pluginContext) @@ -316,7 +405,7 @@ describe('Mono Repository', () => { 'apps/app-1/public/another-asset.json': '', }, ctx, - '/base/path', + { basePath: '/base/path' }, ) await copyStaticAssets(pluginContext) @@ -330,26 +419,100 @@ describe('Mono Repository', () => { ) }) - test('should copy the static pages to the publish directory if there are no corresponding JSON files', async ({ - pluginContext, - ...ctx - }) => { - await createFsFixtureWithBasePath( - { - 'apps/app-1/.next/server/pages/test.html': '', - 'apps/app-1/.next/server/pages/test2.html': '', - 'apps/app-1/.next/server/pages/test3.json': '', - }, - ctx, - ) + describe('should copy the static pages to the publish directory if there are no corresponding JSON files and mark wether html file is a fallback', () => { + test('no i18n', async ({ pluginContext, ...ctx }) => { + await createFsFixtureWithBasePath( + { + 'apps/app-1/.next/server/pages/test.html': '', + 'apps/app-1/.next/server/pages/test2.html': '', + 'apps/app-1/.next/server/pages/test3.json': '', + 'apps/app-1/.next/server/pages/blog/[slug].html': '', + }, + ctx, + { + dynamicRoutes: { + '/blog/[slug]': { + fallback: '/blog/[slug].html', + }, + }, + }, + ) - await copyStaticContent(pluginContext) - const files = await glob('**/*', { cwd: pluginContext.blobDir, dot: true }) + await copyStaticContent(pluginContext) + const files = await glob('**/*', { cwd: pluginContext.blobDir, dot: true }) + + const expectedStaticPages = ['blog/[slug].html', 'test.html', 'test2.html'] + const expectedFallbacks = new Set(['blog/[slug].html']) + + expect(files.map((path) => decodeBlobKey(path)).sort()).toEqual(expectedStaticPages) + + for (const page of expectedStaticPages) { + const expectedIsFallback = expectedFallbacks.has(page) + + const blob = JSON.parse( + await readFile(join(pluginContext.blobDir, await encodeBlobKey(page)), 'utf-8'), + ) - expect(files.map((path) => Buffer.from(path, 'base64').toString('utf-8')).sort()).toEqual([ - 'test.html', - 'test2.html', - ]) + expect(blob, `${page} should ${expectedIsFallback ? '' : 'not '}be a fallback`).toEqual({ + html: '', + isFallback: expectedIsFallback, + }) + } + }) + + test('with i18n', async ({ pluginContext, ...ctx }) => { + await createFsFixtureWithBasePath( + { + 'apps/app-1/.next/server/pages/de/test.html': '', + 'apps/app-1/.next/server/pages/de/test2.html': '', + 'apps/app-1/.next/server/pages/de/test3.json': '', + 'apps/app-1/.next/server/pages/de/blog/[slug].html': '', + 'apps/app-1/.next/server/pages/en/test.html': '', + 'apps/app-1/.next/server/pages/en/test2.html': '', + 'apps/app-1/.next/server/pages/en/test3.json': '', + 'apps/app-1/.next/server/pages/en/blog/[slug].html': '', + }, + ctx, + { + dynamicRoutes: { + '/blog/[slug]': { + fallback: '/blog/[slug].html', + }, + }, + i18n: { + locales: ['en', 'de'], + }, + }, + ) + + await copyStaticContent(pluginContext) + const files = await glob('**/*', { cwd: pluginContext.blobDir, dot: true }) + + const expectedStaticPages = [ + 'de/blog/[slug].html', + 'de/test.html', + 'de/test2.html', + 'en/blog/[slug].html', + 'en/test.html', + 'en/test2.html', + ] + const expectedFallbacks = new Set(['en/blog/[slug].html', 'de/blog/[slug].html']) + + expect(files.map((path) => decodeBlobKey(path)).sort()).toEqual(expectedStaticPages) + + for (const page of expectedStaticPages) { + const expectedIsFallback = expectedFallbacks.has(page) + + const blob = JSON.parse( + await readFile(join(pluginContext.blobDir, await encodeBlobKey(page)), 'utf-8'), + ) + + expect(blob, `${page} should ${expectedIsFallback ? '' : 'not '}be a fallback`).toEqual({ + html: '', + isFallback: expectedIsFallback, + }) + } + }) }) test('should not copy the static pages to the publish directory if there are corresponding JSON files', async ({ diff --git a/src/build/content/static.ts b/src/build/content/static.ts index 4079695bd..8231ec1fd 100644 --- a/src/build/content/static.ts +++ b/src/build/content/static.ts @@ -6,6 +6,7 @@ import { trace } from '@opentelemetry/api' import { wrapTracer } from '@opentelemetry/api/experimental' import glob from 'fast-glob' +import type { HtmlBlob } from '../../run/next.cjs' import { encodeBlobKey } from '../../shared/blobkey.js' import { PluginContext } from '../plugin-context.js' import { verifyNetlifyForms } from '../verification.js' @@ -25,6 +26,8 @@ export const copyStaticContent = async (ctx: PluginContext): Promise => { extglob: true, }) + const fallbacks = ctx.getFallbacks(await ctx.getPrerenderManifest()) + try { await mkdir(destDir, { recursive: true }) await Promise.all( @@ -33,7 +36,14 @@ export const copyStaticContent = async (ctx: PluginContext): Promise => { .map(async (path): Promise => { const html = await readFile(join(srcDir, path), 'utf-8') verifyNetlifyForms(ctx, html) - await writeFile(join(destDir, await encodeBlobKey(path)), html, 'utf-8') + + const isFallback = fallbacks.includes(path.slice(0, -5)) + + await writeFile( + join(destDir, await encodeBlobKey(path)), + JSON.stringify({ html, isFallback } satisfies HtmlBlob), + 'utf-8', + ) }), ) } catch (error) { diff --git a/src/build/plugin-context.ts b/src/build/plugin-context.ts index e678cd936..74c0ffc32 100644 --- a/src/build/plugin-context.ts +++ b/src/build/plugin-context.ts @@ -334,6 +334,42 @@ export class PluginContext { return this.#nextVersion } + #fallbacks: string[] | null = null + /** + * Get an array of localized fallback routes + * + * Example return value for non-i18n site: `['blog/[slug]']` + * + * Example return value for i18n site: `['en/blog/[slug]', 'fr/blog/[slug]']` + */ + getFallbacks(prerenderManifest: PrerenderManifest): string[] { + if (!this.#fallbacks) { + // dynamic routes don't have entries for each locale so we have to generate them + // ourselves. If i18n is not used we use empty string as "locale" to be able to use + // same handling wether i18n is used or not + const locales = this.buildConfig.i18n?.locales ?? [''] + + this.#fallbacks = Object.entries(prerenderManifest.dynamicRoutes).reduce( + (fallbacks, [route, meta]) => { + // fallback can be `string | false | null` + // - `string` - when user use pages router with `fallback: true`, and then it's html file path + // - `null` - when user use pages router with `fallback: 'block'` or app router with `export const dynamicParams = true` + // - `false` - when user use pages router with `fallback: false` or app router with `export const dynamicParams = false` + if (typeof meta.fallback === 'string') { + for (const locale of locales) { + const localizedRoute = posixJoin(locale, route.replace(/^\/+/g, '')) + fallbacks.push(localizedRoute) + } + } + return fallbacks + }, + [] as string[], + ) + } + + return this.#fallbacks + } + /** Fails a build with a message and an optional error */ failBuild(message: string, error?: unknown): never { return this.utils.build.failBuild(message, error instanceof Error ? { error } : undefined) diff --git a/src/run/handlers/request-context.cts b/src/run/handlers/request-context.cts index c54427f74..b71969327 100644 --- a/src/run/handlers/request-context.cts +++ b/src/run/handlers/request-context.cts @@ -11,7 +11,7 @@ export type RequestContext = { responseCacheGetLastModified?: number responseCacheKey?: string responseCacheTags?: string[] - usedFsRead?: boolean + usedFsReadForNonFallback?: boolean didPagesRouterOnDemandRevalidate?: boolean serverTiming?: string routeHandlerRevalidate?: NetlifyCachedRouteValue['revalidate'] diff --git a/src/run/headers.test.ts b/src/run/headers.test.ts index 16f99d50d..4ca423e40 100644 --- a/src/run/headers.test.ts +++ b/src/run/headers.test.ts @@ -321,7 +321,7 @@ describe('headers', () => { }) }) - test('should not set any headers if "cache-control" is not set and "requestContext.usedFsRead" is not truthy', () => { + test('should not set any headers if "cache-control" is not set and "requestContext.usedFsReadForNonFallback" is not truthy', () => { const request = new Request(defaultUrl) const response = new Response() vi.spyOn(response.headers, 'set') @@ -331,13 +331,13 @@ describe('headers', () => { expect(response.headers.set).toHaveBeenCalledTimes(0) }) - test('should set permanent, durable "netlify-cdn-cache-control" if "cache-control" is not set and "requestContext.usedFsRead" is truthy', () => { + test('should set permanent, durable "netlify-cdn-cache-control" if "cache-control" is not set and "requestContext.usedFsReadForNonFallback" is truthy', () => { const request = new Request(defaultUrl) const response = new Response() vi.spyOn(response.headers, 'set') const requestContext = createRequestContext() - requestContext.usedFsRead = true + requestContext.usedFsReadForNonFallback = true setCacheControlHeaders(response, request, requestContext) @@ -359,7 +359,7 @@ describe('headers', () => { vi.spyOn(response.headers, 'set') const requestContext = createRequestContext() - requestContext.usedFsRead = true + requestContext.usedFsReadForNonFallback = true setCacheControlHeaders(response, request, requestContext) diff --git a/src/run/headers.ts b/src/run/headers.ts index 1d99b8016..12c56871a 100644 --- a/src/run/headers.ts +++ b/src/run/headers.ts @@ -273,7 +273,7 @@ export const setCacheControlHeaders = ( cacheControl === null && !headers.has('cdn-cache-control') && !headers.has('netlify-cdn-cache-control') && - requestContext.usedFsRead + requestContext.usedFsReadForNonFallback ) { // handle CDN Cache Control on static files headers.set('cache-control', 'public, max-age=0, must-revalidate') @@ -282,7 +282,10 @@ export const setCacheControlHeaders = ( } export const setCacheTagsHeaders = (headers: Headers, requestContext: RequestContext) => { - if (requestContext.responseCacheTags) { + if ( + requestContext.responseCacheTags && + (headers.has('cache-control') || headers.has('netlify-cdn-cache-control')) + ) { headers.set('netlify-cache-tag', requestContext.responseCacheTags.join(',')) } } diff --git a/src/run/next.cts b/src/run/next.cts index b99515e39..84f378ddb 100644 --- a/src/run/next.cts +++ b/src/run/next.cts @@ -80,6 +80,11 @@ console.timeEnd('import next server') type FS = typeof import('fs') +export type HtmlBlob = { + html: string + isFallback: boolean +} + export async function getMockedRequestHandlers(...args: Parameters) { const tracer = getTracer() return tracer.withActiveSpan('mocked request handler', async () => { @@ -98,14 +103,18 @@ export async function getMockedRequestHandlers(...args: Parameters { test.describe('On-demand revalidate works correctly', () => { - for (const { label, prerendered, pagePath, revalidateApiBasePath, expectedH1Content } of [ + for (const { + label, + useFallback, + prerendered, + pagePath, + revalidateApiBasePath, + expectedH1Content, + } of [ { - label: 'prerendered page with static path and awaited res.revalidate()', + label: + 'prerendered page with static path with fallback: blocking and awaited res.revalidate()', prerendered: true, pagePath: '/static/revalidate-manual', revalidateApiBasePath: '/api/revalidate', expectedH1Content: 'Show #71', }, { - label: 'prerendered page with dynamic path and awaited res.revalidate()', + label: + 'prerendered page with dynamic path with fallback: blocking and awaited res.revalidate()', prerendered: true, pagePath: '/products/prerendered', revalidateApiBasePath: '/api/revalidate', expectedH1Content: 'Product prerendered', }, { - label: 'not prerendered page with dynamic path and awaited res.revalidate()', + label: + 'not prerendered page with dynamic path with fallback: blocking and awaited res.revalidate()', prerendered: false, pagePath: '/products/not-prerendered', revalidateApiBasePath: '/api/revalidate', expectedH1Content: 'Product not-prerendered', }, { - label: 'not prerendered page with dynamic path and not awaited res.revalidate()', + label: + 'not prerendered page with dynamic path with fallback: blocking and not awaited res.revalidate()', prerendered: false, pagePath: '/products/not-prerendered-and-not-awaited-revalidation', revalidateApiBasePath: '/api/revalidate-no-await', @@ -82,7 +93,7 @@ test.describe('Simple Page Router (no basePath, no i18n)', () => { }, { label: - 'prerendered page with dynamic path and awaited res.revalidate() - non-ASCII variant', + 'prerendered page with dynamic path with fallback: blocking and awaited res.revalidate() - non-ASCII variant', prerendered: true, pagePath: '/products/事前レンダリング,test', revalidateApiBasePath: '/api/revalidate', @@ -90,12 +101,29 @@ test.describe('Simple Page Router (no basePath, no i18n)', () => { }, { label: - 'not prerendered page with dynamic path and awaited res.revalidate() - non-ASCII variant', + 'not prerendered page with dynamic path with fallback: blocking and awaited res.revalidate() - non-ASCII variant', prerendered: false, pagePath: '/products/事前レンダリングされていない,test', revalidateApiBasePath: '/api/revalidate', expectedH1Content: 'Product 事前レンダリングされていない,test', }, + { + label: + 'prerendered page with dynamic path with fallback: true and awaited res.revalidate()', + prerendered: true, + pagePath: '/fallback-true/prerendered', + revalidateApiBasePath: '/api/revalidate', + expectedH1Content: 'Product prerendered', + }, + { + label: + 'not prerendered page with dynamic path with fallback: true and awaited res.revalidate()', + prerendered: false, + useFallback: true, + pagePath: '/fallback-true/not-prerendered', + revalidateApiBasePath: '/api/revalidate', + expectedH1Content: 'Product not-prerendered', + }, ]) { test(label, async ({ page, pollUntilHeadersMatch, pageRouter }) => { // in case there is retry or some other test did hit that path before @@ -126,13 +154,25 @@ test.describe('Simple Page Router (no basePath, no i18n)', () => { const headers1 = response1?.headers() || {} expect(response1?.status()).toBe(200) expect(headers1['x-nextjs-cache']).toBeUndefined() - expect(headers1['netlify-cache-tag']).toBe(`_n_t_${encodeURI(pagePath).toLowerCase()}`) + + const fallbackWasServed = + useFallback && headers1['cache-status'].includes('"Next.js"; fwd=miss') + expect(headers1['netlify-cache-tag']).toBe( + fallbackWasServed ? undefined : `_n_t_${encodeURI(pagePath).toLowerCase()}`, + ) expect(headers1['netlify-cdn-cache-control']).toBe( - nextVersionSatisfies('>=15.0.0-canary.187') - ? 's-maxage=31536000, durable' - : 's-maxage=31536000, stale-while-revalidate=31536000, durable', + fallbackWasServed + ? undefined + : nextVersionSatisfies('>=15.0.0-canary.187') + ? 's-maxage=31536000, durable' + : 's-maxage=31536000, stale-while-revalidate=31536000, durable', ) + if (fallbackWasServed) { + const loading = await page.textContent('[data-testid="loading"]') + expect(loading, 'Fallback should be shown').toBe('Loading...') + } + const date1 = await page.textContent('[data-testid="date-now"]') const h1 = await page.textContent('h1') expect(h1).toBe(expectedH1Content) @@ -458,7 +498,14 @@ test.describe('Simple Page Router (no basePath, no i18n)', () => { test.describe('Page Router with basePath and i18n', () => { test.describe('Static revalidate works correctly', () => { - for (const { label, prerendered, pagePath, revalidateApiBasePath, expectedH1Content } of [ + for (const { + label, + useFallback, + prerendered, + pagePath, + revalidateApiBasePath, + expectedH1Content, + } of [ { label: 'prerendered page with static path and awaited res.revalidate()', prerendered: true, @@ -467,21 +514,24 @@ test.describe('Page Router with basePath and i18n', () => { expectedH1Content: 'Show #71', }, { - label: 'prerendered page with dynamic path and awaited res.revalidate()', + label: + 'prerendered page with dynamic path with fallback: blocking and awaited res.revalidate()', prerendered: true, pagePath: '/products/prerendered', revalidateApiBasePath: '/api/revalidate', expectedH1Content: 'Product prerendered', }, { - label: 'not prerendered page with dynamic path and awaited res.revalidate()', + label: + 'not prerendered page with dynamic path with fallback: blocking and awaited res.revalidate()', prerendered: false, pagePath: '/products/not-prerendered', revalidateApiBasePath: '/api/revalidate', expectedH1Content: 'Product not-prerendered', }, { - label: 'not prerendered page with dynamic path and not awaited res.revalidate()', + label: + 'not prerendered page with dynamic path with fallback: blocking and not awaited res.revalidate()', prerendered: false, pagePath: '/products/not-prerendered-and-not-awaited-revalidation', revalidateApiBasePath: '/api/revalidate-no-await', @@ -489,7 +539,7 @@ test.describe('Page Router with basePath and i18n', () => { }, { label: - 'prerendered page with dynamic path and awaited res.revalidate() - non-ASCII variant', + 'prerendered page with dynamic path with fallback: blocking and awaited res.revalidate() - non-ASCII variant', prerendered: true, pagePath: '/products/事前レンダリング,test', revalidateApiBasePath: '/api/revalidate', @@ -497,12 +547,29 @@ test.describe('Page Router with basePath and i18n', () => { }, { label: - 'not prerendered page with dynamic path and awaited res.revalidate() - non-ASCII variant', + 'not prerendered page with dynamic path with fallback: blocking and awaited res.revalidate() - non-ASCII variant', prerendered: false, pagePath: '/products/事前レンダリングされていない,test', revalidateApiBasePath: '/api/revalidate', expectedH1Content: 'Product 事前レンダリングされていない,test', }, + { + label: + 'prerendered page with dynamic path with fallback: true and awaited res.revalidate()', + prerendered: true, + pagePath: '/fallback-true/prerendered', + revalidateApiBasePath: '/api/revalidate', + expectedH1Content: 'Product prerendered', + }, + { + label: + 'not prerendered page with dynamic path with fallback: true and awaited res.revalidate()', + prerendered: false, + useFallback: true, + pagePath: '/fallback-true/not-prerendered', + revalidateApiBasePath: '/api/revalidate', + expectedH1Content: 'Product not-prerendered', + }, ]) { test.describe(label, () => { test(`default locale`, async ({ page, pollUntilHeadersMatch, pageRouterBasePathI18n }) => { @@ -540,15 +607,28 @@ test.describe('Page Router with basePath and i18n', () => { const headers1ImplicitLocale = response1ImplicitLocale?.headers() || {} expect(response1ImplicitLocale?.status()).toBe(200) expect(headers1ImplicitLocale['x-nextjs-cache']).toBeUndefined() + + const fallbackWasServedImplicitLocale = + useFallback && headers1ImplicitLocale['cache-status'].includes('"Next.js"; fwd=miss') + expect(headers1ImplicitLocale['netlify-cache-tag']).toBe( - `_n_t_/en${encodeURI(pagePath).toLowerCase()}`, + fallbackWasServedImplicitLocale + ? undefined + : `_n_t_/en${encodeURI(pagePath).toLowerCase()}`, ) expect(headers1ImplicitLocale['netlify-cdn-cache-control']).toBe( - nextVersionSatisfies('>=15.0.0-canary.187') - ? 's-maxage=31536000, durable' - : 's-maxage=31536000, stale-while-revalidate=31536000, durable', + fallbackWasServedImplicitLocale + ? undefined + : nextVersionSatisfies('>=15.0.0-canary.187') + ? 's-maxage=31536000, durable' + : 's-maxage=31536000, stale-while-revalidate=31536000, durable', ) + if (fallbackWasServedImplicitLocale) { + const loading = await page.textContent('[data-testid="loading"]') + expect(loading, 'Fallback should be shown').toBe('Loading...') + } + const date1ImplicitLocale = await page.textContent('[data-testid="date-now"]') const h1ImplicitLocale = await page.textContent('h1') expect(h1ImplicitLocale).toBe(expectedH1Content) @@ -570,15 +650,27 @@ test.describe('Page Router with basePath and i18n', () => { const headers1ExplicitLocale = response1ExplicitLocale?.headers() || {} expect(response1ExplicitLocale?.status()).toBe(200) expect(headers1ExplicitLocale['x-nextjs-cache']).toBeUndefined() + + const fallbackWasServedExplicitLocale = + useFallback && headers1ExplicitLocale['cache-status'].includes('"Next.js"; fwd=miss') expect(headers1ExplicitLocale['netlify-cache-tag']).toBe( - `_n_t_/en${encodeURI(pagePath).toLowerCase()}`, + fallbackWasServedExplicitLocale + ? undefined + : `_n_t_/en${encodeURI(pagePath).toLowerCase()}`, ) expect(headers1ExplicitLocale['netlify-cdn-cache-control']).toBe( - nextVersionSatisfies('>=15.0.0-canary.187') - ? 's-maxage=31536000, durable' - : 's-maxage=31536000, stale-while-revalidate=31536000, durable', + fallbackWasServedExplicitLocale + ? undefined + : nextVersionSatisfies('>=15.0.0-canary.187') + ? 's-maxage=31536000, durable' + : 's-maxage=31536000, stale-while-revalidate=31536000, durable', ) + if (fallbackWasServedExplicitLocale) { + const loading = await page.textContent('[data-testid="loading"]') + expect(loading, 'Fallback should be shown').toBe('Loading...') + } + const date1ExplicitLocale = await page.textContent('[data-testid="date-now"]') const h1ExplicitLocale = await page.textContent('h1') expect(h1ExplicitLocale).toBe(expectedH1Content) @@ -938,13 +1030,25 @@ test.describe('Page Router with basePath and i18n', () => { const headers1 = response1?.headers() || {} expect(response1?.status()).toBe(200) expect(headers1['x-nextjs-cache']).toBeUndefined() - expect(headers1['netlify-cache-tag']).toBe(`_n_t_/de${encodeURI(pagePath).toLowerCase()}`) + + const fallbackWasServed = + useFallback && headers1['cache-status'].includes('"Next.js"; fwd=miss') + expect(headers1['netlify-cache-tag']).toBe( + fallbackWasServed ? undefined : `_n_t_/de${encodeURI(pagePath).toLowerCase()}`, + ) expect(headers1['netlify-cdn-cache-control']).toBe( - nextVersionSatisfies('>=15.0.0-canary.187') - ? 's-maxage=31536000, durable' - : 's-maxage=31536000, stale-while-revalidate=31536000, durable', + fallbackWasServed + ? undefined + : nextVersionSatisfies('>=15.0.0-canary.187') + ? 's-maxage=31536000, durable' + : 's-maxage=31536000, stale-while-revalidate=31536000, durable', ) + if (fallbackWasServed) { + const loading = await page.textContent('[data-testid="loading"]') + expect(loading, 'Fallback should be shown').toBe('Loading...') + } + const date1 = await page.textContent('[data-testid="date-now"]') const h1 = await page.textContent('h1') expect(h1).toBe(expectedH1Content) diff --git a/tests/fixtures/page-router-base-path-i18n/pages/fallback-true/[slug].js b/tests/fixtures/page-router-base-path-i18n/pages/fallback-true/[slug].js new file mode 100644 index 000000000..5e85c5765 --- /dev/null +++ b/tests/fixtures/page-router-base-path-i18n/pages/fallback-true/[slug].js @@ -0,0 +1,43 @@ +import { useRouter } from 'next/router' + +const Product = ({ time, slug }) => { + const router = useRouter() + + if (router.isFallback) { + return Loading... + } + + return ( +
+

Product {slug}

+

+ This page uses getStaticProps() and getStaticPaths() to pre-fetch a Product + {time} +

+
+ ) +} + +export async function getStaticProps({ params }) { + return { + props: { + time: new Date().toISOString(), + slug: params.slug, + }, + } +} + +export const getStaticPaths = () => { + return { + paths: [ + { + params: { + slug: 'prerendered', + }, + }, + ], + fallback: true, + } +} + +export default Product diff --git a/tests/fixtures/page-router/pages/fallback-true/[slug].js b/tests/fixtures/page-router/pages/fallback-true/[slug].js new file mode 100644 index 000000000..5e85c5765 --- /dev/null +++ b/tests/fixtures/page-router/pages/fallback-true/[slug].js @@ -0,0 +1,43 @@ +import { useRouter } from 'next/router' + +const Product = ({ time, slug }) => { + const router = useRouter() + + if (router.isFallback) { + return Loading... + } + + return ( +
+

Product {slug}

+

+ This page uses getStaticProps() and getStaticPaths() to pre-fetch a Product + {time} +

+
+ ) +} + +export async function getStaticProps({ params }) { + return { + props: { + time: new Date().toISOString(), + slug: params.slug, + }, + } +} + +export const getStaticPaths = () => { + return { + paths: [ + { + params: { + slug: 'prerendered', + }, + }, + ], + fallback: true, + } +} + +export default Product diff --git a/tests/integration/cache-handler.test.ts b/tests/integration/cache-handler.test.ts index 6610ae73b..22ba4397b 100644 --- a/tests/integration/cache-handler.test.ts +++ b/tests/integration/cache-handler.test.ts @@ -44,6 +44,8 @@ describe('page router', () => { // check if the blob entries where successful set on the build plugin const blobEntries = await getBlobEntries(ctx) expect(blobEntries.map(({ key }) => decodeBlobKey(key.substring(0, 50))).sort()).toEqual([ + '/fallback-true/[slug]', + '/fallback-true/prerendered', // the real key is much longer and ends in a hash, but we only assert on the first 50 chars to make it easier '/products/an-incredibly-long-product-', '/products/prerendered', @@ -54,6 +56,7 @@ describe('page router', () => { '/static/revalidate-slow-data', '404.html', '500.html', + 'fallback-true/[slug].html', 'static/fully-static.html', ]) diff --git a/tests/integration/static.test.ts b/tests/integration/static.test.ts index c5457eac0..c0fb42fd7 100644 --- a/tests/integration/static.test.ts +++ b/tests/integration/static.test.ts @@ -35,6 +35,8 @@ test('requesting a non existing page route that needs to be const entries = await getBlobEntries(ctx) expect(entries.map(({ key }) => decodeBlobKey(key.substring(0, 50))).sort()).toEqual([ + '/fallback-true/[slug]', + '/fallback-true/prerendered', '/products/an-incredibly-long-product-', '/products/prerendered', '/products/事前レンダリング,te', @@ -44,6 +46,7 @@ test('requesting a non existing page route that needs to be '/static/revalidate-slow-data', '404.html', '500.html', + 'fallback-true/[slug].html', 'static/fully-static.html', // the real key is much longer and ends in a hash, but we only assert on the first 50 chars to make it easier ])