Skip to content

Commit 3301077

Browse files
piehorinokai
andauthored
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 <[email protected]>
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(),

0 commit comments

Comments
 (0)