tests: added tests for PPR (#10808)

This adds some tests to the PPR implementation for Next.js. This also
fixes a bug where the static pages were incorrectly generating a header
that falsly indicated that it postponed.
This commit is contained in:
Wyatt Johnson
2023-11-08 09:21:51 -07:00
committed by GitHub
parent 2bd9216403
commit fd29b966d3
17 changed files with 324 additions and 2 deletions

View File

@@ -0,0 +1,5 @@
---
'@vercel/next': patch
---
Fixed headers for static routes when PPR is enabled

View File

@@ -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' }
: {}),
},

View File

@@ -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 (
<Suspense fallback={<Dynamic pathname={`/dynamic/force-dynamic/${slug}`} fallback />}>
<Dynamic pathname={`/dynamic/force-dynamic/${slug}`} />
</Suspense>
)
}

View File

@@ -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 (
<Suspense fallback={<Dynamic pathname={`/dynamic/force-static/${slug}`} fallback />}>
<Dynamic pathname={`/dynamic/force-static/${slug}`} />
</Suspense>
)
}

View File

@@ -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 (
<html>
<body>
<h1>Partial Prerendering</h1>
<p>
Below are links that are associated with different pages that all will
partially prerender
</p>
<aside>
<ul>
{links.map(({ href, tag }) => (
<li key={href}>
<Link href={href}>{href}</Link> <span>{tag}</span>
</li>
))}
</ul>
</aside>
<main>{children}</main>
</body>
</html>
)
}

View File

@@ -0,0 +1,16 @@
import React, { Suspense } from 'react'
import { Dynamic } from '../../../components/dynamic'
export const revalidate = 60
export default ({ params: { slug } }) => {
return (
<Suspense fallback={<Dynamic pathname={`/nested/${slug}`} fallback />}>
<Dynamic pathname={`/nested/${slug}`} />
</Suspense>
)
}
export const generateStaticParams = async () => {
return [{ slug: 'a' }]
}

View File

@@ -0,0 +1,10 @@
import React from 'react'
import { Dynamic } from '../../../../components/dynamic'
export default ({ params: { slug } }) => {
return <Dynamic pathname={`/no-suspense/nested/${slug}`} />
}
export const generateStaticParams = async () => {
return [{ slug: 'a' }]
}

View File

@@ -0,0 +1,6 @@
import React from 'react'
import { Dynamic } from '../../components/dynamic'
export default () => {
return <Dynamic pathname="/no-suspense" />
}

View File

@@ -0,0 +1,10 @@
import React, { Suspense } from 'react'
import { Dynamic } from '../../../components/dynamic'
export default ({ params: { slug } }) => {
return (
<Suspense fallback={<Dynamic pathname={`/on-demand/${slug}`} fallback />}>
<Dynamic pathname={`/on-demand/${slug}`} />
</Suspense>
)
}

View File

@@ -0,0 +1,10 @@
import React, { Suspense } from 'react'
import { Dynamic } from '../components/dynamic'
export default () => {
return (
<Suspense fallback={<Dynamic pathname="/" fallback />}>
<Dynamic pathname="/" />
</Suspense>
)
}

View File

@@ -0,0 +1,6 @@
import React from 'react'
import { Dynamic } from '../../components/dynamic'
export default () => {
return <Dynamic pathname="/static" fallback />
}

View File

@@ -0,0 +1,37 @@
import React from 'react'
import { headers } from 'next/headers'
export const Dynamic = ({ pathname, fallback }) => {
if (fallback) {
return <div>Loading...</div>
}
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 (
<div>
<dl>
{pathname && (
<>
<dt>Pathname</dt>
<dd>{pathname}</dd>
</>
)}
{messages.map(({ name, value }) => (
<React.Fragment key={name}>
<dt>
Header: <code>{name}</code>
</dt>
<dd>{value ?? 'null'}</dd>
</React.Fragment>
))}
</dl>
</div>
)
}

View File

@@ -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('</html>');
}
);
});
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);
}
});
});
});

View File

@@ -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;

View File

@@ -0,0 +1,8 @@
{
"dependencies": {
"next": "canary",
"react": "experimental",
"react-dom": "experimental"
},
"ignoreNextjsUpdates": true
}

View File

@@ -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"
}
}
]
}

View File

@@ -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];