diff --git a/.changeset/green-bananas-guess.md b/.changeset/green-bananas-guess.md new file mode 100644 index 000000000..314f032b0 --- /dev/null +++ b/.changeset/green-bananas-guess.md @@ -0,0 +1,5 @@ +--- +'@vercel/next': patch +--- + +Fixed headers for static routes when PPR is enabled diff --git a/packages/next/src/utils.ts b/packages/next/src/utils.ts index ca0c6daca..0604d50ff 100644 --- a/packages/next/src/utils.ts +++ b/packages/next/src/utils.ts @@ -2429,7 +2429,8 @@ export const onPrerenderRoute = initialHeaders: { 'content-type': rscContentTypeHeader, vary: rscVaryHeader, - ...(experimentalPPR && rscDidPostponeHeader + // If it contains a pre-render, then it was postponed. + ...(prerender && rscDidPostponeHeader ? { [rscDidPostponeHeader]: '1' } : {}), }, diff --git a/packages/next/test/fixtures/00-app-dir-ppr-full/app/dynamic/force-dynamic/page.jsx b/packages/next/test/fixtures/00-app-dir-ppr-full/app/dynamic/force-dynamic/page.jsx new file mode 100644 index 000000000..c5105deaa --- /dev/null +++ b/packages/next/test/fixtures/00-app-dir-ppr-full/app/dynamic/force-dynamic/page.jsx @@ -0,0 +1,13 @@ +import React, { Suspense } from 'react' +import { Dynamic } from '../../../components/dynamic' + +export const dynamic = 'force-dynamic' +export const revalidate = 60 + +export default ({ params: { slug } }) => { + return ( + }> + + + ) +} diff --git a/packages/next/test/fixtures/00-app-dir-ppr-full/app/dynamic/force-static/page.jsx b/packages/next/test/fixtures/00-app-dir-ppr-full/app/dynamic/force-static/page.jsx new file mode 100644 index 000000000..8ceabc2c1 --- /dev/null +++ b/packages/next/test/fixtures/00-app-dir-ppr-full/app/dynamic/force-static/page.jsx @@ -0,0 +1,13 @@ +import React, { Suspense } from 'react' +import { Dynamic } from '../../../components/dynamic' + +export const dynamic = 'force-static' +export const revalidate = 60 + +export default ({ params: { slug } }) => { + return ( + }> + + + ) +} diff --git a/packages/next/test/fixtures/00-app-dir-ppr-full/app/layout.jsx b/packages/next/test/fixtures/00-app-dir-ppr-full/app/layout.jsx new file mode 100644 index 000000000..a40f10ae1 --- /dev/null +++ b/packages/next/test/fixtures/00-app-dir-ppr-full/app/layout.jsx @@ -0,0 +1,43 @@ +import React from 'react' +import Link from 'next/link' + +const links = [ + { href: '/', tag: 'pre-generated' }, + { href: '/nested/a', tag: 'pre-generated' }, + { href: '/nested/b', tag: 'on-demand' }, + { href: '/nested/c', tag: 'on-demand' }, + { href: '/on-demand/a', tag: 'on-demand, no-gsp' }, + { href: '/on-demand/b', tag: 'on-demand, no-gsp' }, + { href: '/on-demand/c', tag: 'on-demand, no-gsp' }, + { href: '/static', tag: 'static' }, + { href: '/no-suspense', tag: 'no suspense' }, + { href: '/no-suspense/nested/a', tag: 'no suspense, pre-generated' }, + { href: '/no-suspense/nested/b', tag: 'no suspense, on-demand' }, + { href: '/no-suspense/nested/c', tag: 'no suspense, on-demand' }, + { href: '/dynamic/force-dynamic', tag: "dynamic = 'force-dynamic'" }, + { href: '/dynamic/force-static', tag: "dynamic = 'force-static'" }, +] + +export default ({ children }) => { + return ( + + +

Partial Prerendering

+

+ Below are links that are associated with different pages that all will + partially prerender +

+ +
{children}
+ + + ) +} diff --git a/packages/next/test/fixtures/00-app-dir-ppr-full/app/nested/[slug]/page.jsx b/packages/next/test/fixtures/00-app-dir-ppr-full/app/nested/[slug]/page.jsx new file mode 100644 index 000000000..fec0c0923 --- /dev/null +++ b/packages/next/test/fixtures/00-app-dir-ppr-full/app/nested/[slug]/page.jsx @@ -0,0 +1,16 @@ +import React, { Suspense } from 'react' +import { Dynamic } from '../../../components/dynamic' + +export const revalidate = 60 + +export default ({ params: { slug } }) => { + return ( + }> + + + ) +} + +export const generateStaticParams = async () => { + return [{ slug: 'a' }] +} diff --git a/packages/next/test/fixtures/00-app-dir-ppr-full/app/no-suspense/nested/[slug]/page.jsx b/packages/next/test/fixtures/00-app-dir-ppr-full/app/no-suspense/nested/[slug]/page.jsx new file mode 100644 index 000000000..d7f48f3a2 --- /dev/null +++ b/packages/next/test/fixtures/00-app-dir-ppr-full/app/no-suspense/nested/[slug]/page.jsx @@ -0,0 +1,10 @@ +import React from 'react' +import { Dynamic } from '../../../../components/dynamic' + +export default ({ params: { slug } }) => { + return +} + +export const generateStaticParams = async () => { + return [{ slug: 'a' }] +} diff --git a/packages/next/test/fixtures/00-app-dir-ppr-full/app/no-suspense/page.jsx b/packages/next/test/fixtures/00-app-dir-ppr-full/app/no-suspense/page.jsx new file mode 100644 index 000000000..ef2cf10a3 --- /dev/null +++ b/packages/next/test/fixtures/00-app-dir-ppr-full/app/no-suspense/page.jsx @@ -0,0 +1,6 @@ +import React from 'react' +import { Dynamic } from '../../components/dynamic' + +export default () => { + return +} diff --git a/packages/next/test/fixtures/00-app-dir-ppr-full/app/on-demand/[slug]/page.jsx b/packages/next/test/fixtures/00-app-dir-ppr-full/app/on-demand/[slug]/page.jsx new file mode 100644 index 000000000..289d8a9d6 --- /dev/null +++ b/packages/next/test/fixtures/00-app-dir-ppr-full/app/on-demand/[slug]/page.jsx @@ -0,0 +1,10 @@ +import React, { Suspense } from 'react' +import { Dynamic } from '../../../components/dynamic' + +export default ({ params: { slug } }) => { + return ( + }> + + + ) +} diff --git a/packages/next/test/fixtures/00-app-dir-ppr-full/app/page.jsx b/packages/next/test/fixtures/00-app-dir-ppr-full/app/page.jsx new file mode 100644 index 000000000..1d5918209 --- /dev/null +++ b/packages/next/test/fixtures/00-app-dir-ppr-full/app/page.jsx @@ -0,0 +1,10 @@ +import React, { Suspense } from 'react' +import { Dynamic } from '../components/dynamic' + +export default () => { + return ( + }> + + + ) +} diff --git a/packages/next/test/fixtures/00-app-dir-ppr-full/app/static/page.jsx b/packages/next/test/fixtures/00-app-dir-ppr-full/app/static/page.jsx new file mode 100644 index 000000000..f90cd84e7 --- /dev/null +++ b/packages/next/test/fixtures/00-app-dir-ppr-full/app/static/page.jsx @@ -0,0 +1,6 @@ +import React from 'react' +import { Dynamic } from '../../components/dynamic' + +export default () => { + return +} diff --git a/packages/next/test/fixtures/00-app-dir-ppr-full/components/dynamic.jsx b/packages/next/test/fixtures/00-app-dir-ppr-full/components/dynamic.jsx new file mode 100644 index 000000000..65d7f95f1 --- /dev/null +++ b/packages/next/test/fixtures/00-app-dir-ppr-full/components/dynamic.jsx @@ -0,0 +1,37 @@ +import React from 'react' +import { headers } from 'next/headers' + +export const Dynamic = ({ pathname, fallback }) => { + if (fallback) { + return
Loading...
+ } + + const messages = [] + const names = ['x-test-input', 'user-agent'] + const list = headers() + + for (const name of names) { + messages.push({ name, value: list.get(name) }) + } + + return ( +
+
+ {pathname && ( + <> +
Pathname
+
{pathname}
+ + )} + {messages.map(({ name, value }) => ( + +
+ Header: {name} +
+
{value ?? 'null'}
+
+ ))} +
+
+ ) +} diff --git a/packages/next/test/fixtures/00-app-dir-ppr-full/index.test.js b/packages/next/test/fixtures/00-app-dir-ppr-full/index.test.js new file mode 100644 index 000000000..8a22c3a7f --- /dev/null +++ b/packages/next/test/fixtures/00-app-dir-ppr-full/index.test.js @@ -0,0 +1,103 @@ +/* eslint-env jest */ +const path = require('path'); +const { deployAndTest } = require('../../utils'); +const fetch = require('../../../../../test/lib/deployment/fetch-retry'); + +const pages = [ + { pathname: '/', dynamic: true }, + { pathname: '/nested/a', dynamic: true }, + { pathname: '/nested/b', dynamic: true }, + { pathname: '/nested/c', dynamic: true }, + { pathname: '/on-demand/a', dynamic: true }, + { pathname: '/on-demand/b', dynamic: true }, + { pathname: '/on-demand/c', dynamic: true }, + { pathname: '/static', dynamic: false }, + { pathname: '/no-suspense', dynamic: true }, + { pathname: '/no-suspense/nested/a', dynamic: true }, + { pathname: '/no-suspense/nested/b', dynamic: true }, + { pathname: '/no-suspense/nested/c', dynamic: true }, + // TODO: uncomment when we've fixed the 404 case for force-dynamic pages + // { pathname: '/dynamic/force-dynamic', dynamic: 'force-dynamic' }, + { pathname: '/dynamic/force-static', dynamic: false }, +]; + +const ctx = {}; + +describe(`${__dirname.split(path.sep).pop()}`, () => { + beforeAll(async () => { + const info = await deployAndTest(__dirname); + Object.assign(ctx, info); + }); + + describe('dynamic pages should resume', () => { + it.each(pages.filter(p => p.dynamic))( + 'should resume $pathname', + async ({ pathname }) => { + const expected = `${Date.now()}:${Math.random()}`; + const res = await fetch(`${ctx.deploymentUrl}${pathname}`, { + headers: { 'X-Test-Input': expected }, + }); + expect(res.status).toEqual(200); + expect(res.headers.get('content-type')).toEqual( + 'text/html; charset=utf-8' + ); + const html = await res.text(); + expect(html).toContain(expected); + expect(html).toContain(''); + } + ); + }); + + describe('prefetch RSC payloads should return', () => { + it.each(pages)( + 'should prefetch $pathname', + async ({ pathname, dynamic }) => { + const unexpected = `${Date.now()}:${Math.random()}`; + const res = await fetch(`${ctx.deploymentUrl}${pathname}`, { + headers: { + RSC: '1', + 'Next-Router-Prefetch': '1', + 'X-Test-Input': unexpected, + }, + }); + expect(res.status).toEqual(200); + expect(res.headers.get('content-type')).toEqual('text/x-component'); + console.log( + 'X-NextJS-Postponed-Reason', + res.headers.get('X-NextJS-Postponed-Reason') + ); + if (dynamic) { + expect(res.headers.get('X-NextJS-Postponed')).toEqual('1'); + } else { + expect(res.headers.has('X-NextJS-Postponed')).toEqual(false); + } + + // Expect that static RSC prefetches do not contain the dynamic text. + const text = await res.text(); + expect(text).not.toContain(unexpected); + } + ); + }); + + describe('dynamic RSC payloads should return', () => { + it.each(pages)('should fetch $pathname', async ({ pathname, dynamic }) => { + const expected = `${Date.now()}:${Math.random()}`; + const res = await fetch(`${ctx.deploymentUrl}${pathname}`, { + headers: { RSC: '1', 'X-Test-Input': expected }, + }); + expect(res.status).toEqual(200); + expect(res.headers.get('content-type')).toEqual('text/x-component'); + expect(res.headers.has('X-NextJS-Postponed')).toEqual(false); + + const text = await res.text(); + if (dynamic) { + // Expect that dynamic RSC prefetches do contain the dynamic text. + expect(text).toContain(expected); + } else { + // Expect that dynamic RSC prefetches do not contain the dynamic text + // when we're forced static. + expect(text).not.toContain(expected); + } + }); + }); +}); diff --git a/packages/next/test/fixtures/00-app-dir-ppr-full/next.config.js b/packages/next/test/fixtures/00-app-dir-ppr-full/next.config.js new file mode 100644 index 000000000..f5206309a --- /dev/null +++ b/packages/next/test/fixtures/00-app-dir-ppr-full/next.config.js @@ -0,0 +1,14 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + experimental: { + ppr: true, + }, + eslint: { + // Warning: This allows production builds to successfully complete even if + // your project has ESLint errors. + ignoreDuringBuilds: true, + }, + productionBrowserSourceMaps: true, +}; + +module.exports = nextConfig; diff --git a/packages/next/test/fixtures/00-app-dir-ppr-full/package.json b/packages/next/test/fixtures/00-app-dir-ppr-full/package.json new file mode 100644 index 000000000..087749b0a --- /dev/null +++ b/packages/next/test/fixtures/00-app-dir-ppr-full/package.json @@ -0,0 +1,8 @@ +{ + "dependencies": { + "next": "canary", + "react": "experimental", + "react-dom": "experimental" + }, + "ignoreNextjsUpdates": true +} diff --git a/packages/next/test/fixtures/00-app-dir-ppr-full/vercel.json b/packages/next/test/fixtures/00-app-dir-ppr-full/vercel.json new file mode 100644 index 000000000..4252a3dd7 --- /dev/null +++ b/packages/next/test/fixtures/00-app-dir-ppr-full/vercel.json @@ -0,0 +1,25 @@ +{ + "builds": [ + { + "src": "package.json", + "use": "@vercel/next", + "config": { + "functions": { + "app/**/*": { + "maxDuration": 5, + "memory": 512 + } + } + } + } + ], + "probes": [ + { + "path": "/", + "status": 200, + "responseHeaders": { + "Content-Type": "text/html; charset=utf-8" + } + } + ] +} diff --git a/test/lib/deployment/test-deployment.js b/test/lib/deployment/test-deployment.js index a18b2cdab..42d9df5ec 100644 --- a/test/lib/deployment/test-deployment.js +++ b/test/lib/deployment/test-deployment.js @@ -283,7 +283,9 @@ async function runProbe(probe, deploymentId, deploymentUrl, ctx) { } }); hadTest = true; - } else if (probe.notResponseHeaders) { + } + + if (probe.notResponseHeaders) { Object.keys(probe.notResponseHeaders).forEach(header => { const headerValue = resp.headers.get(header); const expected = probe.notResponseHeaders[header];