diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts index aa586d8a9ab36..b1ddcca4f6425 100644 --- a/packages/next/src/server/base-server.ts +++ b/packages/next/src/server/base-server.ts @@ -1294,7 +1294,7 @@ export default abstract class Server { } res.statusCode = Number(req.headers['x-invoke-status']) - let err = null + let err: Error | null = null if (typeof req.headers['x-invoke-error'] === 'string') { const invokeError = JSON.parse( @@ -2601,7 +2601,18 @@ export default abstract class Server { return null } - if (isSSG && !this.minimalMode) { + const didPostpone = + cacheEntry.value?.kind === 'PAGE' && !!cacheEntry.value.postponed + + if ( + isSSG && + !this.minimalMode && + // We don't want to send a cache header for requests that contain dynamic + // data. If this is a Dynamic RSC request or wasn't a Prefetch RSC + // request, then we should set the cache header. + !isDynamicRSCRequest && + (!didPostpone || isPrefetchRSCRequest) + ) { // set x-nextjs-cache header to match the header // we set for the image-optimizer res.setHeader( @@ -2789,13 +2800,8 @@ export default abstract class Server { res.statusCode = cachedData.status } - // Mark that the request did postpone if this is a data request or we're - // testing. It's used to verify that we're actually serving a postponed - // request so we can trust the cache headers. - if ( - cachedData.postponed && - (isRSCRequest || process.env.__NEXT_TEST_MODE) - ) { + // Mark that the request did postpone if this is a data request. + if (cachedData.postponed && isRSCRequest) { res.setHeader(NEXT_DID_POSTPONE_HEADER, '1') } @@ -2813,7 +2819,12 @@ export default abstract class Server { return { type: 'rsc', body: cachedData.html, - revalidate: cacheEntry.revalidate, + // Dynamic RSC responses cannot be cached, even if they're + // configured with `force-static` because we have no way of + // distinguishing between `force-static` and pages that have no + // postponed state. + // TODO: distinguish `force-static` from pages with no postponed state (static) + revalidate: 0, } } @@ -2881,7 +2892,10 @@ export default abstract class Server { return { type: 'html', body, - revalidate: cacheEntry.revalidate, + // We don't want to cache the response if it has postponed data because + // the response being sent to the client it's dynamic parts are streamed + // to the client on the same request. + revalidate: 0, } } else if (isDataReq) { return { diff --git a/test/e2e/app-dir/ppr-full/app/dynamic/force-dynamic/nested/[slug]/page.jsx b/test/e2e/app-dir/ppr-full/app/dynamic/force-dynamic/nested/[slug]/page.jsx new file mode 100644 index 0000000000000..7295f69905892 --- /dev/null +++ b/test/e2e/app-dir/ppr-full/app/dynamic/force-dynamic/nested/[slug]/page.jsx @@ -0,0 +1,20 @@ +import React, { Suspense } from 'react' +import { Dynamic } from '../../../../../components/dynamic' + +export const dynamic = 'force-dynamic' + +export function generateStaticParams() { + return [] +} + +export default ({ params: { slug } }) => { + return ( + + } + > + + + ) +} diff --git a/test/e2e/app-dir/ppr-full/app/dynamic/force-dynamic/page.jsx b/test/e2e/app-dir/ppr-full/app/dynamic/force-dynamic/page.jsx index 97c8772e3bef9..3b7cfa92bb23a 100644 --- a/test/e2e/app-dir/ppr-full/app/dynamic/force-dynamic/page.jsx +++ b/test/e2e/app-dir/ppr-full/app/dynamic/force-dynamic/page.jsx @@ -3,14 +3,10 @@ import { Dynamic } from '../../../components/dynamic' export const dynamic = 'force-dynamic' -export default ({ params: { slug } }) => { +export default () => { return ( - - } - > - + }> + ) } diff --git a/test/e2e/app-dir/ppr-full/app/dynamic/force-static/page.jsx b/test/e2e/app-dir/ppr-full/app/dynamic/force-static/page.jsx index 195abf0da924c..5450852a95f4b 100644 --- a/test/e2e/app-dir/ppr-full/app/dynamic/force-static/page.jsx +++ b/test/e2e/app-dir/ppr-full/app/dynamic/force-static/page.jsx @@ -4,12 +4,10 @@ import { Dynamic } from '../../../components/dynamic' export const dynamic = 'force-static' export const revalidate = 60 -export default ({ params: { slug } }) => { +export default () => { return ( - } - > - + }> + ) } diff --git a/test/e2e/app-dir/ppr-full/app/layout.jsx b/test/e2e/app-dir/ppr-full/app/layout.jsx index a9a05c18834d6..d5f42b3100b0f 100644 --- a/test/e2e/app-dir/ppr-full/app/layout.jsx +++ b/test/e2e/app-dir/ppr-full/app/layout.jsx @@ -1,18 +1,10 @@ -import { Links } from '../components/links' +import { Layout } from '../components/layout' export default ({ children }) => { return ( -

Partial Prerendering

-

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

- -
{children}
+ {children} ) diff --git a/test/e2e/app-dir/ppr-full/app/static/page.jsx b/test/e2e/app-dir/ppr-full/app/static/page.jsx index f90cd84e78518..15786ea59639e 100644 --- a/test/e2e/app-dir/ppr-full/app/static/page.jsx +++ b/test/e2e/app-dir/ppr-full/app/static/page.jsx @@ -1,6 +1,8 @@ -import React from 'react' -import { Dynamic } from '../../components/dynamic' - export default () => { - return + return ( +
+
Pathname
+
/static
+
+ ) } diff --git a/test/e2e/app-dir/ppr-full/components/dynamic.jsx b/test/e2e/app-dir/ppr-full/components/dynamic.jsx index 0c931f8500f5e..0d58c0c8e3f9f 100644 --- a/test/e2e/app-dir/ppr-full/components/dynamic.jsx +++ b/test/e2e/app-dir/ppr-full/components/dynamic.jsx @@ -1,37 +1,38 @@ -import React from 'react' -import { headers } from 'next/headers' +import React, { use } from 'react' +import * as next from 'next/headers' export const Dynamic = ({ pathname, fallback }) => { if (fallback) { - return
Loading...
+ return
Dynamic Loading...
} + const headers = next.headers() const messages = [] - const names = ['x-test-input', 'user-agent'] - const list = headers() + for (const name of ['x-test-input', 'user-agent']) { + messages.push({ name, value: headers.get(name) }) + } - for (const name of names) { - messages.push({ name, value: list.get(name) }) + const delay = headers.get('x-delay') + if (delay) { + use(new Promise((resolve) => setTimeout(resolve, parseInt(delay, 10)))) } return ( -
-
- {pathname && ( - <> -
Pathname
-
{pathname}
- - )} - {messages.map(({ name, value }) => ( - -
- Header: {name} -
-
{value ?? 'null'}
-
- ))} -
-
+
+ {pathname && ( + <> +
Pathname
+
{pathname}
+ + )} + {messages.map(({ name, value }) => ( + +
+ Header: {name} +
+
{value ?? `MISSING:${name.toUpperCase()}`}
+
+ ))} +
) } diff --git a/test/e2e/app-dir/ppr-full/components/layout.jsx b/test/e2e/app-dir/ppr-full/components/layout.jsx new file mode 100644 index 0000000000000..2f3499f28dd0c --- /dev/null +++ b/test/e2e/app-dir/ppr-full/components/layout.jsx @@ -0,0 +1,18 @@ +import React from 'react' +import { Links } from './links' + +export const Layout = ({ children }) => { + return ( + <> +

Partial Prerendering

+

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

+ +
{children}
+ + ) +} diff --git a/test/e2e/app-dir/ppr-full/components/links.jsx b/test/e2e/app-dir/ppr-full/components/links.jsx index 645fcc8259542..431ad004bf608 100644 --- a/test/e2e/app-dir/ppr-full/components/links.jsx +++ b/test/e2e/app-dir/ppr-full/components/links.jsx @@ -18,6 +18,18 @@ const links = [ { 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-dynamic/nested/a', + tag: "dynamic = 'force-dynamic', on-demand, no-gsp", + }, + { + href: '/dynamic/force-dynamic/nested/b', + tag: "dynamic = 'force-dynamic', on-demand, no-gsp", + }, + { + href: '/dynamic/force-dynamic/nested/c', + tag: "dynamic = 'force-dynamic', on-demand, no-gsp", + }, { href: '/dynamic/force-static', tag: "dynamic = 'force-static'" }, { href: '/edge/suspense', tag: 'edge, pre-generated' }, { href: '/edge/suspense/a', tag: 'edge, pre-generated' }, @@ -27,6 +39,7 @@ const links = [ { href: '/edge/no-suspense/a', tag: 'edge, no suspense, pre-generated' }, { href: '/edge/no-suspense/b', tag: 'edge, no suspense, on-demand' }, { href: '/edge/no-suspense/c', tag: 'edge, no suspense, on-demand' }, + { href: '/pages', tag: 'pages' }, ] export const Links = () => { diff --git a/test/e2e/app-dir/ppr-full/pages/pages.jsx b/test/e2e/app-dir/ppr-full/pages/pages.jsx new file mode 100644 index 0000000000000..54e71dc1cf89d --- /dev/null +++ b/test/e2e/app-dir/ppr-full/pages/pages.jsx @@ -0,0 +1,13 @@ +import React from 'react' +import { Layout } from '../components/layout' + +export default () => { + return ( + +
+
Pathname
+
/pages
+
+
+ ) +} diff --git a/test/e2e/app-dir/ppr-full/ppr-full.test.ts b/test/e2e/app-dir/ppr-full/ppr-full.test.ts index b9086bb2499eb..7e02cd609dc79 100644 --- a/test/e2e/app-dir/ppr-full/ppr-full.test.ts +++ b/test/e2e/app-dir/ppr-full/ppr-full.test.ts @@ -1,6 +1,55 @@ import { createNextDescribe } from 'e2e-utils' -const pages = [ +async function measure(stream: NodeJS.ReadableStream) { + let streamFirstChunk = 0 + let streamEnd = 0 + + const data: { + chunk: string + emittedAt: number + }[] = [] + + await new Promise((resolve, reject) => { + stream.on('data', (chunk: Buffer | string) => { + if (!streamFirstChunk) { + streamFirstChunk = Date.now() + } + + if (typeof chunk !== 'string') { + chunk = chunk.toString() + } + + data.push({ chunk, emittedAt: Date.now() }) + }) + + stream.on('end', () => { + streamEnd = Date.now() + resolve() + }) + + stream.on('error', reject) + }) + + return { + streamFirstChunk, + streamEnd, + data, + } +} + +type Page = { + pathname: string + dynamic: boolean | 'force-dynamic' | 'force-static' + revalidate?: number + + /** + * If true, this indicates that the test case should not expect any content + * to be sent as the static part. + */ + emptyStaticPart?: boolean +} + +const pages: Page[] = [ { pathname: '/', dynamic: true }, { pathname: '/nested/a', dynamic: true, revalidate: 60 }, { pathname: '/nested/b', dynamic: true, revalidate: 60 }, @@ -12,12 +61,14 @@ const pages = [ { pathname: '/loading/b', dynamic: true, revalidate: 60 }, { pathname: '/loading/c', dynamic: true, revalidate: 60 }, { 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: '/no-suspense', dynamic: true, emptyStaticPart: true }, + { pathname: '/no-suspense/nested/a', dynamic: true, emptyStaticPart: true }, + { pathname: '/no-suspense/nested/b', dynamic: true, emptyStaticPart: true }, + { pathname: '/no-suspense/nested/c', dynamic: true, emptyStaticPart: true }, + { pathname: '/dynamic/force-dynamic', dynamic: 'force-dynamic' }, + { pathname: '/dynamic/force-dynamic/nested/a', dynamic: 'force-dynamic' }, + { pathname: '/dynamic/force-dynamic/nested/b', dynamic: 'force-dynamic' }, + { pathname: '/dynamic/force-dynamic/nested/c', dynamic: 'force-dynamic' }, { pathname: '/dynamic/force-static', dynamic: 'force-static', @@ -30,115 +81,270 @@ createNextDescribe( { files: __dirname, }, - ({ next, isNextDev, isNextStart, isNextDeploy }) => { - describe('dynamic pages should resume', () => { - it.each(pages.filter((p) => p.dynamic === true))( - 'should resume $pathname', - async ({ pathname }) => { - const expected = `${Date.now()}:${Math.random()}` - const res = await next.fetch(pathname, { - headers: { 'X-Test-Input': expected }, + ({ next, isNextDev, isNextDeploy }) => { + describe('HTML Response', () => { + describe.each(pages)( + 'for $pathname', + ({ pathname, dynamic, revalidate, emptyStaticPart }) => { + beforeEach(async () => { + // Hit the page once to populate the cache. + const res = await next.fetch(pathname) + + // Consume the response body to ensure the cache is populated. + await res.text() }) - 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('') - } - ) - }) - if (!isNextDev) { - describe('prefetch RSC payloads should return', () => { - it.each(pages)( - 'should prefetch $pathname', - async ({ pathname, dynamic, revalidate }) => { - const unexpected = `${Date.now()}:${Math.random()}` - const res = await next.fetch(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') + it('should allow navigations to and from a pages/ page', async () => { + const browser = await next.browser(pathname) - const cache = res.headers.get('cache-control') + try { + await browser.waitForElementByCss(`[data-pathname="${pathname}"]`) - // cache header handling is different when in minimal mode + // Add a window var so we can detect if there was a full navigation. + const now = Date.now() + await browser.eval(`window.beforeNav = ${now.toString()}`) + + // Navigate to the pages page and wait for the page to load. + await browser.elementByCss(`a[href="/pages"]`).click() + await browser.waitForElementByCss('[data-pathname="/pages"]') + + // Ensure we did a full page navigation, and not a client navigation. + let beforeNav = await browser.eval('window.beforeNav') + expect(beforeNav).not.toBe(now) + + await browser.eval(`window.beforeNav = ${now.toString()}`) + + // Navigate back and wait for the page to load. + await browser.elementByCss(`a[href="${pathname}"]`).click() + await browser.waitForElementByCss(`[data-pathname="${pathname}"]`) + + // Ensure we did a full page navigation, and not a client navigation. + beforeNav = await browser.eval('window.beforeNav') + expect(beforeNav).not.toBe(now) + } finally { + await browser.close() + } + }) + + it('should have correct headers', async () => { + const res = await next.fetch(pathname) + expect(res.status).toEqual(200) + expect(res.headers.get('content-type')).toEqual( + 'text/html; charset=utf-8' + ) + + const cacheControl = res.headers.get('cache-control') if (isNextDeploy) { - expect(cache).toContain('public') - expect(cache).toContain('must-revalidate') + expect(cacheControl).toEqual('public, max-age=0, must-revalidate') + } else if (isNextDev) { + expect(cacheControl).toEqual('no-store, must-revalidate') + } else if (dynamic === false || dynamic === 'force-static') { + expect(cacheControl).toEqual( + `s-maxage=${revalidate || '31536000'}, stale-while-revalidate` + ) } else { - expect(cache).toContain(`s-maxage=${revalidate || '31536000'}`) - expect(cache).toContain('stale-while-revalidate') + expect(cacheControl).toEqual( + 'private, no-cache, no-store, max-age=0, must-revalidate' + ) } - // Expect that static RSC prefetches do not contain the dynamic text. - const text = await res.text() - expect(text).not.toContain(unexpected) + // The cache header is not relevant in development and is not + // deterministic enough for this table test to verify. + if (isNextDev) return - if (dynamic === true) { - // The dynamic component will contain the text "needle" if it was - // rendered using dynamic content. - expect(text).not.toContain('needle') - expect(res.headers.get('X-NextJS-Postponed')).toEqual('1') + if ( + !isNextDeploy && + (dynamic === false || dynamic === 'force-static') + ) { + expect(res.headers.get('x-nextjs-cache')).toEqual('HIT') } else { - if (dynamic !== false) { - expect(text).toContain('needle') + expect(res.headers.get('x-nextjs-cache')).toEqual(null) + } + }) + + if (dynamic === true) { + it('should cache the static part', async () => { + const delay = 500 + + const dynamicValue = `${Date.now()}:${Math.random()}` + const start = Date.now() + const res = await next.fetch(pathname, { + headers: { + 'X-Delay': delay.toString(), + 'X-Test-Input': dynamicValue, + }, + }) + expect(res.status).toBe(200) + + const result = await measure(res.body) + if (emptyStaticPart) { + expect(result.streamFirstChunk - start).toBeGreaterThanOrEqual( + delay + ) + } else { + expect(result.streamFirstChunk - start).toBeLessThan(delay) } + expect(result.streamEnd - start).toBeGreaterThanOrEqual(delay) - expect(res.headers.has('X-NextJS-Postponed')).toEqual(false) - } + // Find all the chunks that arrived before the delay, and split + // it into the static and dynamic parts. + const chunks = result.data.reduce( + (acc, { chunk, emittedAt }) => { + if (emittedAt < start + delay) { + acc.static.push(chunk) + } else { + acc.dynamic.push(chunk) + } + return acc + }, + { static: [], dynamic: [] } + ) + + const parts = { + static: chunks.static.join(''), + dynamic: chunks.dynamic.join(''), + } + + // The static part should not contain the dynamic input. + expect(parts.dynamic).toContain(dynamicValue) + + // Ensure static part contains what we expect. + if (emptyStaticPart) { + expect(parts.static).toBe('') + } else { + expect(parts.static).toContain('Dynamic Loading...') + expect(parts.static).not.toContain(dynamicValue) + } + }) } - ) - }) - describe('dynamic RSC payloads should return', () => { - it.each(pages)( - 'should fetch $pathname', - async ({ pathname, dynamic }) => { - const expected = `${Date.now()}:${Math.random()}` + if (dynamic === true || dynamic === 'force-dynamic') { + it('should resume with dynamic content', async () => { + const expected = `${Date.now()}:${Math.random()}` + const res = await next.fetch(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).not.toContain('MISSING:USER-AGENT') + expect(html).toContain('') + }) + } else { + it('should not contain dynamic content', async () => { + const unexpected = `${Date.now()}:${Math.random()}` + const res = await next.fetch(pathname, { + headers: { 'X-Test-Input': unexpected }, + }) + expect(res.status).toEqual(200) + expect(res.headers.get('content-type')).toEqual( + 'text/html; charset=utf-8' + ) + const html = await res.text() + expect(html).not.toContain(unexpected) + if (dynamic !== false) { + expect(html).toContain('MISSING:USER-AGENT') + expect(html).toContain('MISSING:X-TEST-INPUT') + } + expect(html).toContain('') + }) + } + } + ) + }) + + if (!isNextDev) { + describe('Prefetch RSC Response', () => { + describe.each(pages)('for $pathname', ({ pathname, revalidate }) => { + it('should have correct headers', async () => { const res = await next.fetch(pathname, { - headers: { RSC: '1', 'X-Test-Input': expected }, + headers: { RSC: '1', 'Next-Router-Prefetch': '1' }, }) 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 cache = res.headers.get('cache-control') // cache header handling is different when in minimal mode + const cache = res.headers.get('cache-control') if (isNextDeploy) { - expect(cache).toContain('private') - expect(cache).toContain('no-store') - expect(cache).toContain('no-cache') - expect(cache).toContain('max-age=0') - expect(cache).toContain('must-revalidate') + expect(cache).toEqual('public, max-age=0, must-revalidate') + } else { + expect(cache).toEqual( + `s-maxage=${revalidate || '31536000'}, stale-while-revalidate` + ) + } + + if (!isNextDeploy) { + expect(res.headers.get('x-nextjs-cache')).toEqual('HIT') } else { - expect(cache).toContain('s-maxage=1') - expect(cache).toContain('stale-while-revalidate') + expect(res.headers.get('x-nextjs-cache')).toEqual(null) } + }) + it('should not contain dynamic content', async () => { + const unexpected = `${Date.now()}:${Math.random()}` + const res = await next.fetch(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') const text = await res.text() + expect(text).not.toContain(unexpected) + }) + }) + }) - if (dynamic !== false) { - expect(text).toContain('needle') - } + describe('Dynamic RSC Response', () => { + describe.each(pages)('for $pathname', ({ pathname, dynamic }) => { + it('should have correct headers', async () => { + const res = await next.fetch(pathname, { + headers: { RSC: '1' }, + }) + expect(res.status).toEqual(200) + expect(res.headers.get('content-type')).toEqual('text/x-component') + expect(res.headers.get('cache-control')).toEqual( + 'private, no-cache, no-store, max-age=0, must-revalidate' + ) + expect(res.headers.get('x-nextjs-cache')).toEqual(null) + }) - if (dynamic === true) { - // Expect that dynamic RSC prefetches do contain the dynamic text. + if (dynamic === true || dynamic === 'force-dynamic') { + it('should contain dynamic content', async () => { + const expected = `${Date.now()}:${Math.random()}` + const res = await next.fetch(pathname, { + headers: { RSC: '1', 'X-Test-Input': expected }, + }) + expect(res.status).toEqual(200) + expect(res.headers.get('content-type')).toEqual( + 'text/x-component' + ) + const text = await res.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) - } + }) + } else { + it('should not contain dynamic content', async () => { + const unexpected = `${Date.now()}:${Math.random()}` + const res = await next.fetch(pathname, { + headers: { + RSC: '1', + 'X-Test-Input': unexpected, + }, + }) + expect(res.status).toEqual(200) + expect(res.headers.get('content-type')).toEqual( + 'text/x-component' + ) + const text = await res.text() + expect(text).not.toContain(unexpected) + }) } - ) + }) }) } } diff --git a/test/e2e/app-dir/ppr/ppr.test.ts b/test/e2e/app-dir/ppr/ppr.test.ts index 51151ee03b6e5..15c40a107a34c 100644 --- a/test/e2e/app-dir/ppr/ppr.test.ts +++ b/test/e2e/app-dir/ppr/ppr.test.ts @@ -70,21 +70,6 @@ createNextDescribe( expect($('#container > #dynamic > #state').length).toBe(0) }) } - - if (!isNextDev) { - it('should cache the static part', async () => { - // First, render the page to populate the cache. - let res = await next.fetch(pathname) - expect(res.status).toBe(200) - expect(res.headers.get('x-nextjs-postponed')).toBe('1') - - // Then, render the page again. - res = await next.fetch(pathname) - expect(res.status).toBe(200) - expect(res.headers.get('x-nextjs-cache')).toBe('HIT') - expect(res.headers.get('x-nextjs-postponed')).toBe('1') - }) - } }) describe.each([ diff --git a/test/ppr-tests-manifest.json b/test/ppr-tests-manifest.json index 245e69a789749..5340d605fd327 100644 --- a/test/ppr-tests-manifest.json +++ b/test/ppr-tests-manifest.json @@ -79,6 +79,7 @@ "test/integration/app-dir-export/**/*", "test/e2e/app-dir/next-font/**/*", "test/e2e/app-dir/ppr/**/*", + "test/e2e/app-dir/ppr-*/**/*", "test/e2e/app-dir/app-prefetch*/**/*", "test/e2e/app-dir/interception-middleware-rewrite/interception-middleware-rewrite.test.ts", "test/e2e/app-dir/searchparams-static-bailout/searchparams-static-bailout.test.ts"