Skip to content

Commit

Permalink
fix: create cache entries for fallback pages to support next@canary (#…
Browse files Browse the repository at this point in the history
…2649)

* test: add test case for fallback: true

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

* fix: don't permamently cache fallback html

* Update src/build/content/prerendered.ts

Co-authored-by: Rob Stanford <me@robstanford.com>

* chore: don't use extra fallback manifest and instead store html and boolean wether that is fallback html in single blob

* test: add some unit tests about static html blobs and fallbacks

---------

Co-authored-by: Rob Stanford <me@robstanford.com>
Co-authored-by: Philippe Serhal <philippe.serhal@netlify.com>
  • Loading branch information
3 people authored Oct 16, 2024
1 parent c77ece7 commit 8ab259a
Show file tree
Hide file tree
Showing 13 changed files with 529 additions and 91 deletions.
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

0 comments on commit 8ab259a

Please sign in to comment.