Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: create cache entries for fallback pages to support next@canary #2649

33 changes: 27 additions & 6 deletions src/build/content/prerendered.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<NetlifyCachedPageValue> => ({
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,
})
Expand Down Expand Up @@ -146,8 +157,8 @@ export const copyPrerenderedContent = async (ctx: PluginContext): Promise<void>
})
: false

await Promise.all(
Object.entries(manifest.routes).map(
await Promise.all([
...Object.entries(manifest.routes).map(
([route, meta]): Promise<void> =>
limitConcurrentPrerenderContentHandling(async () => {
const lastModified = meta.initialRevalidateSeconds
Expand Down Expand Up @@ -195,7 +206,17 @@ export const copyPrerenderedContent = async (ctx: PluginContext): Promise<void>
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
Expand Down
249 changes: 206 additions & 43 deletions src/build/content/static.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -21,7 +22,19 @@ type Context = FixtureTestContext & {
const createFsFixtureWithBasePath = (
fixture: Record<string, string>,
ctx: Omit<Context, 'pluginContext'>,
basePath = '',

{
basePath = '',
// eslint-disable-next-line unicorn/no-useless-undefined
i18n = undefined,
dynamicRoutes = {},
}: {
basePath?: string
i18n?: Pick<NonNullable<RequiredServerFilesManifest['config']['i18n']>, 'locales'>
dynamicRoutes?: {
[route: string]: Pick<PrerenderManifest['dynamicRoutes'][''], 'fallback'>
}
} = {},
) => {
return createFsFixture(
{
Expand All @@ -32,8 +45,10 @@ const createFsFixtureWithBasePath = (
appDir: ctx.relativeAppDir,
config: {
distDir: ctx.publishDir,
i18n,
},
} as Pick<RequiredServerFilesManifest, 'relativeAppDir' | 'appDir'>),
[join(ctx.publishDir, 'prerender-manifest.json')]: JSON.stringify({ dynamicRoutes }),
},
ctx,
)
Expand Down Expand Up @@ -121,7 +136,7 @@ describe('Regular Repository layout', () => {
'.next/static/sub-dir/test2.js': '',
},
ctx,
'/base/path',
{ basePath: '/base/path' },
)

await copyStaticAssets(pluginContext)
Expand Down Expand Up @@ -168,7 +183,7 @@ describe('Regular Repository layout', () => {
'public/another-asset.json': '',
},
ctx,
'/base/path',
{ basePath: '/base/path' },
)

await copyStaticAssets(pluginContext)
Expand All @@ -182,26 +197,100 @@ describe('Regular Repository layout', () => {
)
})

test<Context>('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<Context>('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<Context>('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<Context>('should not copy the static pages to the publish directory if there are corresponding JSON files', async ({
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -316,7 +405,7 @@ describe('Mono Repository', () => {
'apps/app-1/public/another-asset.json': '',
},
ctx,
'/base/path',
{ basePath: '/base/path' },
)

await copyStaticAssets(pluginContext)
Expand All @@ -330,26 +419,100 @@ describe('Mono Repository', () => {
)
})

test<Context>('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<Context>('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<Context>('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<Context>('should not copy the static pages to the publish directory if there are corresponding JSON files', async ({
Expand Down
Loading
Loading