From 24647b9a3a6f0b914423c95a59cd6fcc81caa778 Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Mon, 9 Sep 2024 12:58:07 -0700 Subject: [PATCH] Add ability to customize Cache-Control (#69802) This continues https://github.com/vercel/next.js/pull/39707 bringing the changes up to date with canary and adds test cases to ensure it's working as expected. Closes: https://github.com/vercel/next.js/issues/22319 Closes: https://github.com/vercel/next.js/pull/39707 Closes: NDX-148 --- packages/next/src/server/base-server.ts | 17 +++- packages/next/src/server/lib/router-server.ts | 2 +- packages/next/src/server/send-payload.ts | 4 +- .../app/app-ssg/[slug]/page.tsx | 18 ++++ .../custom-cache-control/app/app-ssr/page.tsx | 9 ++ .../custom-cache-control/app/layout.tsx | 8 ++ .../custom-cache-control.test.ts | 84 +++++++++++++++++++ .../custom-cache-control/next.config.js | 74 ++++++++++++++++ .../pages/pages-auto-static.tsx | 7 ++ .../pages/pages-ssg/[slug].tsx | 29 +++++++ .../custom-cache-control/pages/pages-ssr.tsx | 15 ++++ test/integration/404-page/test/index.test.js | 4 +- .../pages-dir/production/test/index.test.ts | 2 +- 13 files changed, 266 insertions(+), 7 deletions(-) create mode 100644 test/e2e/app-dir/custom-cache-control/app/app-ssg/[slug]/page.tsx create mode 100644 test/e2e/app-dir/custom-cache-control/app/app-ssr/page.tsx create mode 100644 test/e2e/app-dir/custom-cache-control/app/layout.tsx create mode 100644 test/e2e/app-dir/custom-cache-control/custom-cache-control.test.ts create mode 100644 test/e2e/app-dir/custom-cache-control/next.config.js create mode 100644 test/e2e/app-dir/custom-cache-control/pages/pages-auto-static.tsx create mode 100644 test/e2e/app-dir/custom-cache-control/pages/pages-ssg/[slug].tsx create mode 100644 test/e2e/app-dir/custom-cache-control/pages/pages-ssr.tsx diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts index 42819826ea5a6..8aed8efebec90 100644 --- a/packages/next/src/server/base-server.ts +++ b/packages/next/src/server/base-server.ts @@ -2587,6 +2587,13 @@ export default abstract class Server { } ) + if (isPreviewMode) { + res.setHeader( + 'Cache-Control', + 'private, no-cache, no-store, max-age=0, must-revalidate' + ) + } + if (!cacheEntry) { if (ssgCacheKey && !(isOnDemandRevalidate && revalidateOnlyGenerated)) { // A cache entry might not be generated if a response is written @@ -2719,7 +2726,9 @@ export default abstract class Server { // for the revalidate value addRequestMeta(req, 'notFoundRevalidate', cacheEntry.revalidate) - if (cacheEntry.revalidate) { + // If cache control is already set on the response we don't + // override it to allow users to customize it via next.config + if (cacheEntry.revalidate && !res.getHeader('Cache-Control')) { res.setHeader( 'Cache-Control', formatRevalidate({ @@ -2740,7 +2749,9 @@ export default abstract class Server { await this.render404(req, res, { pathname, query }, false) return null } else if (cachedData.kind === 'REDIRECT') { - if (cacheEntry.revalidate) { + // If cache control is already set on the response we don't + // override it to allow users to customize it via next.config + if (cacheEntry.revalidate && !res.getHeader('Cache-Control')) { res.setHeader( 'Cache-Control', formatRevalidate({ @@ -3223,7 +3234,7 @@ export default abstract class Server { if (setHeaders) { res.setHeader( 'Cache-Control', - 'no-cache, no-store, max-age=0, must-revalidate' + 'private, no-cache, no-store, max-age=0, must-revalidate' ) } diff --git a/packages/next/src/server/lib/router-server.ts b/packages/next/src/server/lib/router-server.ts index 4757b7b0aba6a..37c87d3aa2f96 100644 --- a/packages/next/src/server/lib/router-server.ts +++ b/packages/next/src/server/lib/router-server.ts @@ -526,7 +526,7 @@ export async function initialize(opts: { // 404 case res.setHeader( 'Cache-Control', - 'no-cache, no-store, max-age=0, must-revalidate' + 'private, no-cache, no-store, max-age=0, must-revalidate' ) // Short-circuit favicon.ico serving so that the 404 page doesn't get built as favicon is requested by the browser when loading any route. diff --git a/packages/next/src/server/send-payload.ts b/packages/next/src/server/send-payload.ts index e79c33f8a0b01..de5a45ec1aadf 100644 --- a/packages/next/src/server/send-payload.ts +++ b/packages/next/src/server/send-payload.ts @@ -59,7 +59,9 @@ export async function sendRenderResult({ res.setHeader('X-Powered-By', 'Next.js') } - if (typeof revalidate !== 'undefined') { + // If cache control is already set on the response we don't + // override it to allow users to customize it via next.config + if (typeof revalidate !== 'undefined' && !res.getHeader('Cache-Control')) { res.setHeader( 'Cache-Control', formatRevalidate({ diff --git a/test/e2e/app-dir/custom-cache-control/app/app-ssg/[slug]/page.tsx b/test/e2e/app-dir/custom-cache-control/app/app-ssg/[slug]/page.tsx new file mode 100644 index 0000000000000..70eb489bb26b9 --- /dev/null +++ b/test/e2e/app-dir/custom-cache-control/app/app-ssg/[slug]/page.tsx @@ -0,0 +1,18 @@ +export const revalidate = 120 + +export function generateStaticParams() { + return [ + { + slug: 'first', + }, + ] +} + +export default function Page({ params }) { + return ( + <> +

/app-ssg/[slug]

+

{JSON.stringify(params)}

+ + ) +} diff --git a/test/e2e/app-dir/custom-cache-control/app/app-ssr/page.tsx b/test/e2e/app-dir/custom-cache-control/app/app-ssr/page.tsx new file mode 100644 index 0000000000000..69d3c4b3c18fe --- /dev/null +++ b/test/e2e/app-dir/custom-cache-control/app/app-ssr/page.tsx @@ -0,0 +1,9 @@ +export const dynamic = 'force-dynamic' + +export default function Page() { + return ( + <> +

/app-ssr

+ + ) +} diff --git a/test/e2e/app-dir/custom-cache-control/app/layout.tsx b/test/e2e/app-dir/custom-cache-control/app/layout.tsx new file mode 100644 index 0000000000000..888614deda3ba --- /dev/null +++ b/test/e2e/app-dir/custom-cache-control/app/layout.tsx @@ -0,0 +1,8 @@ +import { ReactNode } from 'react' +export default function Root({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} diff --git a/test/e2e/app-dir/custom-cache-control/custom-cache-control.test.ts b/test/e2e/app-dir/custom-cache-control/custom-cache-control.test.ts new file mode 100644 index 0000000000000..dce7b40eb0b80 --- /dev/null +++ b/test/e2e/app-dir/custom-cache-control/custom-cache-control.test.ts @@ -0,0 +1,84 @@ +import { nextTestSetup } from 'e2e-utils' + +describe('custom-cache-control', () => { + const { next, isNextDev, isNextDeploy } = nextTestSetup({ + files: __dirname, + }) + + if (isNextDeploy) { + // customizing these headers won't apply on environments + // where headers are applied outside of the Next.js server + it('should skip for deploy', () => {}) + return + } + + it('should have custom cache-control for app-ssg prerendered', async () => { + const res = await next.fetch('/app-ssg/first') + expect(res.headers.get('cache-control')).toBe( + isNextDev ? 'no-store, must-revalidate' : 's-maxage=30' + ) + }) + + it('should have custom cache-control for app-ssg lazy', async () => { + const res = await next.fetch('/app-ssg/lazy') + expect(res.headers.get('cache-control')).toBe( + isNextDev ? 'no-store, must-revalidate' : 's-maxage=31' + ) + }) + ;(process.env.__NEXT_EXPERIMENTAL_PPR ? it.skip : it)( + 'should have default cache-control for app-ssg another', + async () => { + const res = await next.fetch('/app-ssg/another') + // eslint-disable-next-line jest/no-standalone-expect + expect(res.headers.get('cache-control')).toBe( + isNextDev + ? 'no-store, must-revalidate' + : 's-maxage=120, stale-while-revalidate' + ) + } + ) + + it('should have custom cache-control for app-ssr', async () => { + const res = await next.fetch('/app-ssr') + expect(res.headers.get('cache-control')).toBe( + isNextDev ? 'no-store, must-revalidate' : 's-maxage=32' + ) + }) + + it('should have custom cache-control for auto static page', async () => { + const res = await next.fetch('/pages-auto-static') + expect(res.headers.get('cache-control')).toBe( + isNextDev ? 'no-store, must-revalidate' : 's-maxage=33' + ) + }) + + it('should have custom cache-control for pages-ssg prerendered', async () => { + const res = await next.fetch('/pages-ssg/first') + expect(res.headers.get('cache-control')).toBe( + isNextDev ? 'no-store, must-revalidate' : 's-maxage=34' + ) + }) + + it('should have custom cache-control for pages-ssg lazy', async () => { + const res = await next.fetch('/pages-ssg/lazy') + expect(res.headers.get('cache-control')).toBe( + isNextDev ? 'no-store, must-revalidate' : 's-maxage=35' + ) + }) + + it('should have default cache-control for pages-ssg another', async () => { + const res = await next.fetch('/pages-ssg/another') + expect(res.headers.get('cache-control')).toBe( + isNextDev + ? 'no-store, must-revalidate' + : 's-maxage=120, stale-while-revalidate' + ) + }) + + it('should have default cache-control for pages-ssr', async () => { + const res = await next.fetch('/pages-ssr') + expect(res.headers.get('cache-control')).toBe( + isNextDev ? 'no-store, must-revalidate' : 's-maxage=36' + ) + }) +}) diff --git a/test/e2e/app-dir/custom-cache-control/next.config.js b/test/e2e/app-dir/custom-cache-control/next.config.js new file mode 100644 index 0000000000000..7a61bbe7e6723 --- /dev/null +++ b/test/e2e/app-dir/custom-cache-control/next.config.js @@ -0,0 +1,74 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = { + headers() { + return [ + { + source: '/app-ssg/first', + headers: [ + { + key: 'Cache-Control', + value: 's-maxage=30', + }, + ], + }, + { + source: '/app-ssg/lazy', + headers: [ + { + key: 'Cache-Control', + value: 's-maxage=31', + }, + ], + }, + { + source: '/app-ssr', + headers: [ + { + key: 'Cache-Control', + value: 's-maxage=32', + }, + ], + }, + { + source: '/pages-auto-static', + headers: [ + { + key: 'Cache-Control', + value: 's-maxage=33', + }, + ], + }, + { + source: '/pages-ssg/first', + headers: [ + { + key: 'Cache-Control', + value: 's-maxage=34', + }, + ], + }, + { + source: '/pages-ssg/lazy', + headers: [ + { + key: 'Cache-Control', + value: 's-maxage=35', + }, + ], + }, + { + source: '/pages-ssr', + headers: [ + { + key: 'Cache-Control', + value: 's-maxage=36', + }, + ], + }, + ] + }, +} + +module.exports = nextConfig diff --git a/test/e2e/app-dir/custom-cache-control/pages/pages-auto-static.tsx b/test/e2e/app-dir/custom-cache-control/pages/pages-auto-static.tsx new file mode 100644 index 0000000000000..97f19eff92600 --- /dev/null +++ b/test/e2e/app-dir/custom-cache-control/pages/pages-auto-static.tsx @@ -0,0 +1,7 @@ +export default function Page() { + return ( + <> +

/pages-auto-static

+ + ) +} diff --git a/test/e2e/app-dir/custom-cache-control/pages/pages-ssg/[slug].tsx b/test/e2e/app-dir/custom-cache-control/pages/pages-ssg/[slug].tsx new file mode 100644 index 0000000000000..9b9c4d0182f75 --- /dev/null +++ b/test/e2e/app-dir/custom-cache-control/pages/pages-ssg/[slug].tsx @@ -0,0 +1,29 @@ +export function getStaticProps({ params }) { + return { + props: { + now: Date.now(), + params, + }, + revalidate: 120, + } +} + +export function getStaticPaths() { + return { + paths: [ + { + params: { slug: 'first' }, + }, + ], + fallback: 'blocking', + } +} + +export default function Page({ params }) { + return ( + <> +

/pages-ssg/[slug]

+

{JSON.stringify(params)}

+ + ) +} diff --git a/test/e2e/app-dir/custom-cache-control/pages/pages-ssr.tsx b/test/e2e/app-dir/custom-cache-control/pages/pages-ssr.tsx new file mode 100644 index 0000000000000..5792abd592c3e --- /dev/null +++ b/test/e2e/app-dir/custom-cache-control/pages/pages-ssr.tsx @@ -0,0 +1,15 @@ +export function getServerSideProps() { + return { + props: { + now: Date.now(), + }, + } +} + +export default function Page() { + return ( + <> +

/pages-ssr

+ + ) +} diff --git a/test/integration/404-page/test/index.test.js b/test/integration/404-page/test/index.test.js index ca76076108567..5a59b227ee1eb 100644 --- a/test/integration/404-page/test/index.test.js +++ b/test/integration/404-page/test/index.test.js @@ -262,7 +262,9 @@ describe('404 Page Support', () => { await killApp(app) expect(cache404).toBe(null) - expect(cacheNext).toBe('no-cache, no-store, max-age=0, must-revalidate') + expect(cacheNext).toBe( + 'private, no-cache, no-store, max-age=0, must-revalidate' + ) }) it('shows error with getInitialProps in pages/404 build', async () => { diff --git a/test/production/pages-dir/production/test/index.test.ts b/test/production/pages-dir/production/test/index.test.ts index 8b70938940196..95441526fdd12 100644 --- a/test/production/pages-dir/production/test/index.test.ts +++ b/test/production/pages-dir/production/test/index.test.ts @@ -618,7 +618,7 @@ createNextDescribe( expect(res.status).toBe(404) expect(res.headers.get('Cache-Control')).toBe( - 'no-cache, no-store, max-age=0, must-revalidate' + 'private, no-cache, no-store, max-age=0, must-revalidate' ) })