Skip to content

Commit 3301077

Browse files
piehorinokai
andauthoredMar 6, 2025
fix: apply caching headers to pages router 404 with getStaticProps (#2764)
* fix: apply caching headers to pages router 404 with getStaticProps * chore: don't store revalidate on request context for app router pages to limit potentially unwanted impact of the change * chore: don't apply new 404 caching header code path, if existing method would have set cacheable header * test: add notFound cases for 404 with getStaticProps without revalidate * test: add fixture for 404 with getStaticProps with revalidate * tmp: outline 404 integration test cases for page router * test: update 404 caching tests * fix: relax the conditions for caching 404s * test: remove only * feat: hide 404 caching behind env var * test: remove only and console logs * test: update static 404 test * feat: reduce 404 caching scope to just the 404 page itself * test: remove only modifier * test: fix tests for new custom 404 pate --------- Co-authored-by: Rob Stanford <me@robstanford.com>
1 parent f8004d7 commit 3301077

File tree

14 files changed

+341
-22
lines changed

14 files changed

+341
-22
lines changed
 

‎src/build/content/prerendered.ts

+4
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ const routeToFilePath = (path: string) => {
5757

5858
const buildPagesCacheValue = async (
5959
path: string,
60+
initialRevalidateSeconds: number | false | undefined,
6061
shouldUseEnumKind: boolean,
6162
shouldSkipJson = false,
6263
): Promise<NetlifyCachedPageValue> => ({
@@ -65,6 +66,7 @@ const buildPagesCacheValue = async (
6566
pageData: shouldSkipJson ? {} : JSON.parse(await readFile(`${path}.json`, 'utf-8')),
6667
headers: undefined,
6768
status: undefined,
69+
revalidate: initialRevalidateSeconds,
6870
})
6971

7072
const buildAppCacheValue = async (
@@ -178,6 +180,7 @@ export const copyPrerenderedContent = async (ctx: PluginContext): Promise<void>
178180
}
179181
value = await buildPagesCacheValue(
180182
join(ctx.publishDir, 'server/pages', key),
183+
meta.initialRevalidateSeconds,
181184
shouldUseEnumKind,
182185
)
183186
break
@@ -210,6 +213,7 @@ export const copyPrerenderedContent = async (ctx: PluginContext): Promise<void>
210213
const key = routeToFilePath(route)
211214
const value = await buildPagesCacheValue(
212215
join(ctx.publishDir, 'server/pages', key),
216+
undefined,
213217
shouldUseEnumKind,
214218
true, // there is no corresponding json file for fallback, so we are skipping it for this entry
215219
)

‎src/run/handlers/cache.cts

+5
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,11 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions {
308308
case 'PAGES': {
309309
const { revalidate, ...restOfPageValue } = blob.value
310310

311+
const requestContext = getRequestContext()
312+
if (requestContext) {
313+
requestContext.pageHandlerRevalidate = revalidate
314+
}
315+
311316
span.addEvent(blob.value?.kind, { lastModified: blob.lastModified, revalidate, ttl })
312317

313318
await this.injectEntryToPrerenderManifest(key, revalidate)

‎src/run/handlers/request-context.cts

+1
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export type RequestContext = {
2525
didPagesRouterOnDemandRevalidate?: boolean
2626
serverTiming?: string
2727
routeHandlerRevalidate?: NetlifyCachedRouteValue['revalidate']
28+
pageHandlerRevalidate?: NetlifyCachedRouteValue['revalidate']
2829
/**
2930
* Track promise running in the background and need to be waited for.
3031
* Uses `context.waitUntil` if available, otherwise stores promises to

‎src/run/headers.ts

+34-13
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { Span } from '@opentelemetry/api'
22
import type { NextConfigComplete } from 'next/dist/server/config-shared.js'
33

44
import { encodeBlobKey } from '../shared/blobkey.js'
5+
import type { NetlifyCachedRouteValue } from '../shared/cache-types.cjs'
56

67
import { getLogger, RequestContext } from './handlers/request-context.cjs'
78
import type { RuntimeTracer } from './handlers/tracer.cjs'
@@ -197,6 +198,19 @@ export const adjustDateHeader = async ({
197198
headers.set('date', lastModifiedDate.toUTCString())
198199
}
199200

201+
function setCacheControlFromRequestContext(
202+
headers: Headers,
203+
revalidate: NetlifyCachedRouteValue['revalidate'],
204+
) {
205+
const cdnCacheControl =
206+
// if we are serving already stale response, instruct edge to not attempt to cache that response
207+
headers.get('x-nextjs-cache') === 'STALE'
208+
? 'public, max-age=0, must-revalidate, durable'
209+
: `s-maxage=${revalidate || 31536000}, stale-while-revalidate=31536000, durable`
210+
211+
headers.set('netlify-cdn-cache-control', cdnCacheControl)
212+
}
213+
200214
/**
201215
* Ensure stale-while-revalidate and s-maxage don't leak to the client, but
202216
* assume the user knows what they are doing if CDN cache controls are set
@@ -214,13 +228,7 @@ export const setCacheControlHeaders = (
214228
!headers.has('netlify-cdn-cache-control')
215229
) {
216230
// handle CDN Cache Control on Route Handler responses
217-
const cdnCacheControl =
218-
// if we are serving already stale response, instruct edge to not attempt to cache that response
219-
headers.get('x-nextjs-cache') === 'STALE'
220-
? 'public, max-age=0, must-revalidate, durable'
221-
: `s-maxage=${requestContext.routeHandlerRevalidate === false ? 31536000 : requestContext.routeHandlerRevalidate}, stale-while-revalidate=31536000, durable`
222-
223-
headers.set('netlify-cdn-cache-control', cdnCacheControl)
231+
setCacheControlFromRequestContext(headers, requestContext.routeHandlerRevalidate)
224232
return
225233
}
226234

@@ -231,14 +239,27 @@ export const setCacheControlHeaders = (
231239
.log('NetlifyHeadersHandler.trailingSlashRedirect')
232240
}
233241

234-
if (status === 404 && request.url.endsWith('.php')) {
235-
// temporary CDN Cache Control handling for bot probes on PHP files
236-
// https://linear.app/netlify/issue/FRB-1344/prevent-excessive-ssr-invocations-due-to-404-routes
237-
headers.set('cache-control', 'public, max-age=0, must-revalidate')
238-
headers.set('netlify-cdn-cache-control', `max-age=31536000, durable`)
242+
const cacheControl = headers.get('cache-control')
243+
if (status === 404) {
244+
if (request.url.endsWith('.php')) {
245+
// temporary CDN Cache Control handling for bot probes on PHP files
246+
// https://linear.app/netlify/issue/FRB-1344/prevent-excessive-ssr-invocations-due-to-404-routes
247+
headers.set('cache-control', 'public, max-age=0, must-revalidate')
248+
headers.set('netlify-cdn-cache-control', `max-age=31536000, durable`)
249+
return
250+
}
251+
252+
if (
253+
process.env.CACHE_404_PAGE &&
254+
request.url.endsWith('/404') &&
255+
['GET', 'HEAD'].includes(request.method)
256+
) {
257+
// handle CDN Cache Control on 404 Page responses
258+
setCacheControlFromRequestContext(headers, requestContext.pageHandlerRevalidate)
259+
return
260+
}
239261
}
240262

241-
const cacheControl = headers.get('cache-control')
242263
if (
243264
cacheControl !== null &&
244265
['GET', 'HEAD'].includes(request.method) &&

‎tests/e2e/page-router.test.ts

+5-5
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { expect } from '@playwright/test'
2-
import { test } from '../utils/playwright-helpers.js'
32
import { nextVersionSatisfies } from '../utils/next-version-helpers.mjs'
3+
import { test } from '../utils/playwright-helpers.js'
44

55
export function waitFor(millis: number) {
66
return new Promise((resolve) => setTimeout(resolve, millis))
@@ -462,7 +462,7 @@ test.describe('Simple Page Router (no basePath, no i18n)', () => {
462462
const headers = response?.headers() || {}
463463
expect(response?.status()).toBe(404)
464464

465-
expect(await page.textContent('h1')).toBe('404')
465+
expect(await page.textContent('p')).toBe('Custom 404 page')
466466

467467
// https://github.com/vercel/next.js/pull/69802 made changes to returned cache-control header,
468468
// after that (14.2.10 and canary.147) 404 pages would have `private` directive, before that
@@ -486,7 +486,7 @@ test.describe('Simple Page Router (no basePath, no i18n)', () => {
486486
const headers = response?.headers() || {}
487487
expect(response?.status()).toBe(404)
488488

489-
expect(await page.textContent('h1')).toBe('404')
489+
expect(await page.textContent('p')).toBe('Custom 404 page')
490490

491491
expect(headers['netlify-cdn-cache-control']).toBe(
492492
nextVersionSatisfies('>=15.0.0-canary.187')
@@ -1326,7 +1326,7 @@ test.describe('Page Router with basePath and i18n', () => {
13261326
const headers = response?.headers() || {}
13271327
expect(response?.status()).toBe(404)
13281328

1329-
expect(await page.textContent('h1')).toBe('404')
1329+
expect(await page.textContent('p')).toBe('Custom 404 page')
13301330

13311331
// https://github.com/vercel/next.js/pull/69802 made changes to returned cache-control header,
13321332
// after that 404 pages would have `private` directive, before that it would not
@@ -1349,7 +1349,7 @@ test.describe('Page Router with basePath and i18n', () => {
13491349
const headers = response?.headers() || {}
13501350
expect(response?.status()).toBe(404)
13511351

1352-
expect(await page.textContent('h1')).toBe('404')
1352+
expect(await page.textContent('p')).toBe('Custom 404 page')
13531353

13541354
expect(headers['netlify-cdn-cache-control']).toBe(
13551355
nextVersionSatisfies('>=15.0.0-canary.187')
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/** @type {import('next').NextConfig} */
2+
const nextConfig = {
3+
output: 'standalone',
4+
eslint: {
5+
ignoreDuringBuilds: true,
6+
},
7+
generateBuildId: () => 'build-id',
8+
}
9+
10+
module.exports = nextConfig
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"name": "page-router-404-get-static-props-with-revalidate",
3+
"version": "0.1.0",
4+
"private": true,
5+
"scripts": {
6+
"postinstall": "next build",
7+
"dev": "next dev",
8+
"build": "next build"
9+
},
10+
"dependencies": {
11+
"next": "latest",
12+
"react": "18.2.0",
13+
"react-dom": "18.2.0"
14+
}
15+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
export default function NotFound({ timestamp }) {
2+
return (
3+
<p>
4+
Custom 404 page with revalidate: <pre data-testid="timestamp">{timestamp}</pre>
5+
</p>
6+
)
7+
}
8+
9+
/** @type {import('next').GetStaticProps} */
10+
export const getStaticProps = ({ locale }) => {
11+
return {
12+
props: {
13+
timestamp: Date.now(),
14+
},
15+
revalidate: 300,
16+
}
17+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
const Product = ({ time, slug }) => (
2+
<div>
3+
<h1>Product {slug}</h1>
4+
<p>
5+
This page uses getStaticProps() and getStaticPaths() to pre-fetch a Product
6+
<span data-testid="date-now">{time}</span>
7+
</p>
8+
</div>
9+
)
10+
11+
/** @type {import('next').GetStaticProps} */
12+
export async function getStaticProps({ params }) {
13+
if (params.slug === 'not-found-no-revalidate') {
14+
return {
15+
notFound: true,
16+
}
17+
} else if (params.slug === 'not-found-with-revalidate') {
18+
return {
19+
notFound: true,
20+
revalidate: 600,
21+
}
22+
}
23+
24+
return {
25+
props: {
26+
time: new Date().toISOString(),
27+
slug: params.slug,
28+
},
29+
}
30+
}
31+
32+
/** @type {import('next').GetStaticPaths} */
33+
export const getStaticPaths = () => {
34+
return {
35+
paths: [],
36+
fallback: 'blocking', // false or "blocking"
37+
}
38+
}
39+
40+
export default Product

‎tests/fixtures/page-router-base-path-i18n/pages/products/[slug].js

+12
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,19 @@ const Product = ({ time, slug }) => (
88
</div>
99
)
1010

11+
/** @type {import('next').GetStaticProps} */
1112
export async function getStaticProps({ params }) {
13+
if (params.slug === 'not-found-no-revalidate') {
14+
return {
15+
notFound: true,
16+
}
17+
} else if (params.slug === 'not-found-with-revalidate') {
18+
return {
19+
notFound: true,
20+
revalidate: 600,
21+
}
22+
}
23+
1224
return {
1325
props: {
1426
time: new Date().toISOString(),
+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default function NotFound() {
2+
return <p>Custom 404 page</p>
3+
}

‎tests/fixtures/page-router/pages/products/[slug].js

+7
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@ const Product = ({ time, slug }) => (
99
)
1010

1111
export async function getStaticProps({ params }) {
12+
if (params.slug === 'not-found-with-revalidate') {
13+
return {
14+
notFound: true,
15+
revalidate: 600,
16+
}
17+
}
18+
1219
return {
1320
props: {
1421
time: new Date().toISOString(),

‎tests/integration/page-router.test.ts

+187-3
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { HttpResponse, http, passthrough } from 'msw'
44
import { setupServer } from 'msw/node'
55
import { platform } from 'node:process'
66
import { v4 } from 'uuid'
7-
import { afterAll, afterEach, beforeAll, beforeEach, expect, test, vi } from 'vitest'
7+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'
88
import { type FixtureTestContext } from '../utils/contexts.js'
99
import { createFixture, invokeFunction, runPlugin } from '../utils/fixture.js'
1010
import { encodeBlobKey, generateRandomObjectID, startMockBlobStore } from '../utils/helpers.js'
@@ -19,7 +19,6 @@ beforeAll(() => {
1919
// and passthrough everything else
2020
server = setupServer(
2121
http.post('https://api.netlify.com/api/v1/purge', () => {
22-
console.log('intercepted purge api call')
2322
return HttpResponse.json({})
2423
}),
2524
http.all(/.*/, () => passthrough()),
@@ -91,7 +90,6 @@ test<FixtureTestContext>('Should revalidate path with On-demand Revalidation', a
9190
expect(staticPageRevalidated.headers?.['cache-status']).toMatch(/"Next.js"; hit/)
9291
const dateCacheRevalidated = load(staticPageRevalidated.body)('[data-testid="date-now"]').text()
9392

94-
console.log({ dateCacheInitial, dateCacheRevalidated })
9593
expect(dateCacheInitial).not.toBe(dateCacheRevalidated)
9694
})
9795

@@ -153,3 +151,189 @@ test<FixtureTestContext>('Should serve correct locale-aware custom 404 pages', a
153151
'Served 404 page content should use non-default locale if non-default locale is explicitly used in pathname (after basePath)',
154152
).toBe('fr')
155153
})
154+
155+
// These tests describe how the 404 caching should work, but unfortunately it doesn't work like
156+
// this in v5 and a fix would represent a breaking change so we are skipping them for now, but
157+
// leaving them here for future reference when considering the next major version
158+
describe.skip('404 caching', () => {
159+
describe('404 without getStaticProps', () => {
160+
test<FixtureTestContext>('not matching dynamic paths should be cached permanently', async (ctx) => {
161+
await createFixture('page-router', ctx)
162+
await runPlugin(ctx)
163+
164+
const notExistingPage = await invokeFunction(ctx, {
165+
url: '/not-existing-page',
166+
})
167+
168+
expect(notExistingPage.statusCode).toBe(404)
169+
170+
expect(
171+
notExistingPage.headers['netlify-cdn-cache-control'],
172+
'should be cached permanently',
173+
).toBe('s-maxage=31536000, stale-while-revalidate=31536000, durable')
174+
})
175+
test<FixtureTestContext>('matching dynamic path with revalidate should be cached for revalidate time', async (ctx) => {
176+
await createFixture('page-router', ctx)
177+
await runPlugin(ctx)
178+
179+
const notExistingPage = await invokeFunction(ctx, {
180+
url: '/products/not-found-with-revalidate',
181+
})
182+
183+
expect(notExistingPage.statusCode).toBe(404)
184+
185+
expect(
186+
notExistingPage.headers['netlify-cdn-cache-control'],
187+
'should be cached for revalidate time',
188+
).toBe('s-maxage=600, stale-while-revalidate=31536000, durable')
189+
})
190+
})
191+
192+
describe('404 with getStaticProps without revalidate', () => {
193+
test<FixtureTestContext>('not matching dynamic paths should be cached permanently', async (ctx) => {
194+
await createFixture('page-router-base-path-i18n', ctx)
195+
await runPlugin(ctx)
196+
197+
const notExistingPage = await invokeFunction(ctx, {
198+
url: '/base/path/not-existing-page',
199+
})
200+
201+
expect(notExistingPage.statusCode).toBe(404)
202+
203+
expect(
204+
notExistingPage.headers['netlify-cdn-cache-control'],
205+
'should be cached permanently',
206+
).toBe('s-maxage=31536000, stale-while-revalidate=31536000, durable')
207+
})
208+
test<FixtureTestContext>('matching dynamic path with revalidate should be cached for revalidate time', async (ctx) => {
209+
await createFixture('page-router-base-path-i18n', ctx)
210+
await runPlugin(ctx)
211+
212+
const notExistingPage = await invokeFunction(ctx, {
213+
url: '/base/path/products/not-found-with-revalidate',
214+
})
215+
216+
expect(notExistingPage.statusCode).toBe(404)
217+
218+
expect(
219+
notExistingPage.headers['netlify-cdn-cache-control'],
220+
'should be cached for revalidate time',
221+
).toBe('s-maxage=600, stale-while-revalidate=31536000, durable')
222+
})
223+
})
224+
225+
describe('404 with getStaticProps with revalidate', () => {
226+
test<FixtureTestContext>('not matching dynamic paths should be cached for 404 page revalidate', async (ctx) => {
227+
await createFixture('page-router-404-get-static-props-with-revalidate', ctx)
228+
await runPlugin(ctx)
229+
230+
// ignoring initial stale case
231+
await invokeFunction(ctx, {
232+
url: 'not-existing-page',
233+
})
234+
235+
await new Promise((res) => setTimeout(res, 100))
236+
237+
const notExistingPage = await invokeFunction(ctx, {
238+
url: 'not-existing-page',
239+
})
240+
241+
expect(notExistingPage.statusCode).toBe(404)
242+
243+
expect(
244+
notExistingPage.headers['netlify-cdn-cache-control'],
245+
'should be cached for 404 page revalidate',
246+
).toBe('s-maxage=300, stale-while-revalidate=31536000, durable')
247+
})
248+
249+
test<FixtureTestContext>('matching dynamic path with revalidate should be cached for revalidate time', async (ctx) => {
250+
await createFixture('page-router-404-get-static-props-with-revalidate', ctx)
251+
await runPlugin(ctx)
252+
253+
// ignoring initial stale case
254+
await invokeFunction(ctx, {
255+
url: 'products/not-found-with-revalidate',
256+
})
257+
await new Promise((res) => setTimeout(res, 100))
258+
259+
const notExistingPage = await invokeFunction(ctx, {
260+
url: 'products/not-found-with-revalidate',
261+
})
262+
263+
expect(notExistingPage.statusCode).toBe(404)
264+
265+
expect(
266+
notExistingPage.headers['netlify-cdn-cache-control'],
267+
'should be cached for revalidate time',
268+
).toBe('s-maxage=600, stale-while-revalidate=31536000, durable')
269+
})
270+
})
271+
})
272+
273+
// This is a temporary fix to ensure that the 404 page itself is cached correctly when requested
274+
// directly. This is a workaround for a specific customer and should be removed once the 404 caching
275+
// is fixed in the next major version.
276+
describe('404 page caching', () => {
277+
beforeAll(() => {
278+
process.env.CACHE_404_PAGE = 'true'
279+
})
280+
281+
afterAll(() => {
282+
delete process.env.CACHE_404_PAGE
283+
})
284+
285+
test<FixtureTestContext>('404 without getStaticProps', async (ctx) => {
286+
await createFixture('page-router', ctx)
287+
await runPlugin(ctx)
288+
289+
const notExistingPage = await invokeFunction(ctx, {
290+
url: '/404',
291+
})
292+
293+
expect(notExistingPage.statusCode).toBe(404)
294+
295+
expect(
296+
notExistingPage.headers['netlify-cdn-cache-control'],
297+
'should be cached permanently',
298+
).toBe('s-maxage=31536000, stale-while-revalidate=31536000, durable')
299+
})
300+
301+
test<FixtureTestContext>('404 with getStaticProps without revalidate', async (ctx) => {
302+
await createFixture('page-router-base-path-i18n', ctx)
303+
await runPlugin(ctx)
304+
305+
const notExistingPage = await invokeFunction(ctx, {
306+
url: '/base/404',
307+
})
308+
309+
expect(notExistingPage.statusCode).toBe(404)
310+
311+
expect(
312+
notExistingPage.headers['netlify-cdn-cache-control'],
313+
'should be cached permanently',
314+
).toBe('s-maxage=31536000, stale-while-revalidate=31536000, durable')
315+
})
316+
317+
test<FixtureTestContext>('404 with getStaticProps with revalidate', async (ctx) => {
318+
await createFixture('page-router-404-get-static-props-with-revalidate', ctx)
319+
await runPlugin(ctx)
320+
321+
// ignoring initial stale case
322+
await invokeFunction(ctx, {
323+
url: '/404',
324+
})
325+
326+
await new Promise((res) => setTimeout(res, 100))
327+
328+
const notExistingPage = await invokeFunction(ctx, {
329+
url: '/404',
330+
})
331+
332+
expect(notExistingPage.statusCode).toBe(404)
333+
334+
expect(
335+
notExistingPage.headers['netlify-cdn-cache-control'],
336+
'should be cached for 404 page revalidate',
337+
).toBe('s-maxage=300, stale-while-revalidate=31536000, durable')
338+
})
339+
})

‎tests/integration/static.test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ test<FixtureTestContext>('requesting a non existing page route that needs to be
5454
// test that it should request the 404.html file
5555
const call1 = await invokeFunction(ctx, { url: 'static/revalidate-not-existing' })
5656
expect(call1.statusCode).toBe(404)
57-
expect(load(call1.body)('h1').text()).toBe('404')
57+
expect(load(call1.body)('p').text()).toBe('Custom 404 page')
5858

5959
// https://github.com/vercel/next.js/pull/69802 made changes to returned cache-control header,
6060
// after that (14.2.10 and canary.147) 404 pages would have `private` directive, before that it

0 commit comments

Comments
 (0)
Please sign in to comment.