mirror of
https://github.com/vercel/next.js.git
synced 2026-09-20 02:25:18 +08:00
e974969ddd
Caches that have `stale < MIN_SHELL_STALE` were using `makeStageHangingPromise` in prerenders, which (in theory) was meant to track a runtime data access during a static prerender, which in turn would make the page use runtime shells. This is semantically incorrect, because both static and runtime shells would exclude the cache, and both static and runtime prefetches would include it, so it does not actually signal runtimeness, only prefetchness. The only thing we should be doing for these caches is excluding the content, same as `unstable_prefetch()`. This PR replaces `makeStageHangingPromise` with `makePrefetchHangingPromise`, which is not tracked as runtime data at all. We also use it for `unstable_prefetch()` to signal the correspondence. Note that in practice, the usage of `makeStageHangingPromise` in `use-cache-wrapper` was *technically* fine, because its runtime data tracking was effectively dead code. We only returned it if `stagedRendering.finalStage < stage`, but for static prerenders `stage = staticLinkDataStage = PrefetchStatic` and `finalStage = Static`, so the condition was never actually true, and we never actually tracked it as a runtime data access. However semantically it should still not be tracked as runtime data. (The condition *can* be true in a runtime prerender that only prerenders the shell. We don't do any tracking in runtime prerenders, so it was also a no-op). (this was extracted from #98129, where we separate runtime data access tracking into session data and URL data. i couldn't figure which one `makeStageHangingPromise` should be tracked as, but then i realized it shouldn't be tracked at all)
1179 lines
39 KiB
TypeScript
1179 lines
39 KiB
TypeScript
import { nextTestSetup } from 'e2e-utils'
|
|
import { waitFor } from 'next-test-utils'
|
|
import type * as Playwright from 'playwright'
|
|
import { createRouterAct } from 'router-act'
|
|
import { UNEXPECTED_CACHE_MISS_MESSAGE } from 'next/src/server/use-cache/use-cache-errors'
|
|
|
|
describe('runtime prefetching', () => {
|
|
const { next, isNextDev, isNextDeploy } = nextTestSetup({
|
|
files: __dirname,
|
|
})
|
|
if (isNextDev) {
|
|
it('is skipped', () => {})
|
|
return
|
|
}
|
|
|
|
let currentCliOutputIndex = 0
|
|
beforeEach(() => {
|
|
resetCliOutput()
|
|
})
|
|
|
|
const getCliOutput = () => {
|
|
if (next.cliOutput.length < currentCliOutputIndex) {
|
|
// cliOutput shrank since we started the test, so something (like a `sandbox`) reset the logs
|
|
currentCliOutputIndex = 0
|
|
}
|
|
return next.cliOutput.slice(currentCliOutputIndex)
|
|
}
|
|
|
|
const resetCliOutput = () => {
|
|
currentCliOutputIndex = next.cliOutput.length
|
|
}
|
|
|
|
// We never expect to see this logged here.
|
|
afterEach(() => {
|
|
if (getCliOutput().includes(UNEXPECTED_CACHE_MISS_MESSAGE)) {
|
|
throw new Error(
|
|
`A test unexpectedly logged "${UNEXPECTED_CACHE_MISS_MESSAGE}"`
|
|
)
|
|
}
|
|
})
|
|
|
|
describe.each([
|
|
{
|
|
description: 'in a page',
|
|
prefix: 'in-page',
|
|
},
|
|
{
|
|
description: 'in a private cache',
|
|
prefix: 'in-private-cache',
|
|
},
|
|
{
|
|
description: 'passed to a public cache',
|
|
prefix: 'passed-to-public-cache',
|
|
},
|
|
])('$description', ({ prefix }) => {
|
|
it('includes dynamic params, but not dynamic content', async () => {
|
|
let page: Playwright.Page
|
|
const browser = await next.browser('/', {
|
|
beforePageLoad(p: Playwright.Page) {
|
|
page = p
|
|
},
|
|
})
|
|
const act = createRouterAct(page)
|
|
|
|
// Reveal the link to trigger a runtime prefetch for one value of the dynamic param
|
|
await act(async () => {
|
|
const linkToggle = await browser.elementByCss(
|
|
`input[data-link-accordion="/${prefix}/dynamic-params/123"]`
|
|
)
|
|
await linkToggle.click()
|
|
}, [
|
|
// Should allow reading dynamic params
|
|
{
|
|
includes: 'Param: 123',
|
|
},
|
|
// Should not prefetch the dynamic content
|
|
{
|
|
includes: 'Dynamic content',
|
|
block: 'reject',
|
|
},
|
|
])
|
|
|
|
// Reveal the link to trigger a runtime prefetch for a different value of the dynamic param
|
|
await act(async () => {
|
|
const linkToggle = await browser.elementByCss(
|
|
`input[data-link-accordion="/${prefix}/dynamic-params/456"]`
|
|
)
|
|
await linkToggle.click()
|
|
}, [
|
|
// Should allow reading dynamic params
|
|
{
|
|
includes: 'Param: 456',
|
|
},
|
|
// Should not prefetch the dynamic content
|
|
{
|
|
includes: 'Dynamic content',
|
|
block: 'reject',
|
|
},
|
|
])
|
|
|
|
// Navigate to the page
|
|
await act(async () => {
|
|
await act(
|
|
async () => {
|
|
await browser
|
|
.elementByCss(`a[href="/${prefix}/dynamic-params/123"]`)
|
|
.click()
|
|
},
|
|
{
|
|
// Temporarily block the navigation request.
|
|
// The runtime-prefetched parts of the tree should be visible before it finishes.
|
|
includes: 'Dynamic content',
|
|
block: true,
|
|
}
|
|
)
|
|
expect(await browser.elementById('param-value').text()).toEqual(
|
|
'Param: 123'
|
|
)
|
|
})
|
|
// After navigating, we should see both the parts that we prefetched and dynamic content.
|
|
expect(await browser.elementById('param-value').text()).toEqual(
|
|
'Param: 123'
|
|
)
|
|
expect(await browser.elementById('dynamic-content').text()).toEqual(
|
|
'Dynamic content'
|
|
)
|
|
|
|
await browser.back()
|
|
|
|
// Navigate to the other page
|
|
await act(async () => {
|
|
await act(
|
|
async () => {
|
|
await browser
|
|
.elementByCss(`a[href="/${prefix}/dynamic-params/456"]`)
|
|
.click()
|
|
},
|
|
{
|
|
// Temporarily block the navigation request.
|
|
// The runtime-prefetched parts of the tree should be visible before it finishes.
|
|
includes: 'Dynamic content',
|
|
block: true,
|
|
}
|
|
)
|
|
expect(await browser.elementById('param-value').text()).toEqual(
|
|
'Param: 456'
|
|
)
|
|
})
|
|
// After navigating, we should see both the parts that we prefetched and dynamic content.
|
|
expect(await browser.elementById('param-value').text()).toEqual(
|
|
'Param: 456'
|
|
)
|
|
expect(await browser.elementById('dynamic-content').text()).toEqual(
|
|
'Dynamic content'
|
|
)
|
|
})
|
|
|
|
it('includes root params, but not dynamic content', async () => {
|
|
let page: Playwright.Page
|
|
const browser = await next.browser('/with-root-param/en', {
|
|
beforePageLoad(p: Playwright.Page) {
|
|
page = p
|
|
},
|
|
})
|
|
const act = createRouterAct(page)
|
|
|
|
// Reveal the link to trigger a runtime prefetch for one value of the root param
|
|
await act(async () => {
|
|
const linkToggle = await browser.elementByCss(
|
|
`input[data-link-accordion="/with-root-param/en/${prefix}/root-params"]`
|
|
)
|
|
await linkToggle.click()
|
|
}, [
|
|
// Should allow reading root params
|
|
{
|
|
includes: 'Lang: en',
|
|
},
|
|
// Should not prefetch the dynamic content
|
|
{
|
|
includes: 'Dynamic content',
|
|
block: 'reject',
|
|
},
|
|
])
|
|
|
|
// TODO(runtime-ppr) - visiting root params that weren't in generateStaticParams errors when deployed
|
|
if (!isNextDeploy) {
|
|
// Reveal the link to trigger a runtime prefetch for a different value of the root param
|
|
await act(async () => {
|
|
const linkToggle = await browser.elementByCss(
|
|
`input[data-link-accordion="/with-root-param/de/${prefix}/root-params"]`
|
|
)
|
|
await linkToggle.click()
|
|
}, [
|
|
// Should allow reading root params
|
|
{
|
|
includes: 'Lang: de',
|
|
},
|
|
// Should not prefetch the dynamic content
|
|
{
|
|
includes: 'Dynamic content',
|
|
block: 'reject',
|
|
},
|
|
])
|
|
}
|
|
|
|
// Navigate to the first page
|
|
await act(async () => {
|
|
await act(
|
|
async () => {
|
|
await browser
|
|
.elementByCss(
|
|
`a[href="/with-root-param/en/${prefix}/root-params"]`
|
|
)
|
|
.click()
|
|
},
|
|
{
|
|
// Temporarily block the navigation request.
|
|
// The runtime-prefetched parts of the tree should be visible before it finishes.
|
|
includes: 'Dynamic content',
|
|
block: true,
|
|
}
|
|
)
|
|
expect(await browser.elementById('root-param-value').text()).toEqual(
|
|
'Lang: en'
|
|
)
|
|
})
|
|
// After navigating, we should see both the parts that we prefetched and dynamic content.
|
|
expect(await browser.elementById('root-param-value').text()).toEqual(
|
|
'Lang: en'
|
|
)
|
|
expect(await browser.elementById('dynamic-content').text()).toEqual(
|
|
'Dynamic content'
|
|
)
|
|
|
|
// TODO(runtime-ppr) - visiting root params that weren't in generateStaticParams errors when deployed
|
|
if (!isNextDeploy) {
|
|
await browser.back()
|
|
|
|
// Navigate to the other page
|
|
await act(async () => {
|
|
await act(
|
|
async () => {
|
|
await browser
|
|
.elementByCss(
|
|
`a[href="/with-root-param/de/${prefix}/root-params"]`
|
|
)
|
|
.click()
|
|
},
|
|
{
|
|
// Temporarily block the navigation request.
|
|
// The runtime-prefetched parts of the tree should be visible before it finishes.
|
|
includes: 'Dynamic content',
|
|
block: true,
|
|
}
|
|
)
|
|
expect(await browser.elementById('root-param-value').text()).toEqual(
|
|
'Lang: de'
|
|
)
|
|
})
|
|
// After navigating, we should see both the parts that we prefetched and dynamic content.
|
|
expect(await browser.elementById('root-param-value').text()).toEqual(
|
|
'Lang: de'
|
|
)
|
|
expect(await browser.elementById('dynamic-content').text()).toEqual(
|
|
'Dynamic content'
|
|
)
|
|
}
|
|
})
|
|
|
|
it('includes search params, but not dynamic content', async () => {
|
|
let page: Playwright.Page
|
|
const browser = await next.browser('/', {
|
|
beforePageLoad(p: Playwright.Page) {
|
|
page = p
|
|
},
|
|
})
|
|
const act = createRouterAct(page)
|
|
|
|
// Reveal the link to trigger a runtime prefetch for one value of the search param
|
|
await act(async () => {
|
|
const linkToggle = await browser.elementByCss(
|
|
`input[data-link-accordion="/${prefix}/search-params?searchParam=123"]`
|
|
)
|
|
await linkToggle.click()
|
|
}, [
|
|
// Should allow reading search params
|
|
{
|
|
includes: 'Search param: 123',
|
|
},
|
|
// Should not prefetch the dynamic content
|
|
{
|
|
includes: 'Dynamic content',
|
|
block: 'reject',
|
|
},
|
|
])
|
|
|
|
// Reveal the link to trigger a runtime prefetch for a different value of the search param
|
|
await act(async () => {
|
|
const linkToggle = await browser.elementByCss(
|
|
`input[data-link-accordion="/${prefix}/search-params?searchParam=456"]`
|
|
)
|
|
await linkToggle.click()
|
|
}, [
|
|
// Should allow reading search params
|
|
{
|
|
includes: 'Search param: 456',
|
|
},
|
|
// Should not prefetch the dynamic content
|
|
{
|
|
includes: 'Dynamic content',
|
|
block: 'reject',
|
|
},
|
|
])
|
|
|
|
// Navigate to the page
|
|
await act(async () => {
|
|
await act(
|
|
async () => {
|
|
await browser
|
|
.elementByCss(
|
|
`a[href="/${prefix}/search-params?searchParam=123"]`
|
|
)
|
|
.click()
|
|
},
|
|
{
|
|
// Temporarily block the navigation request.
|
|
// The runtime-prefetched parts of the tree should be visible before it finishes.
|
|
includes: 'Dynamic content',
|
|
block: true,
|
|
}
|
|
)
|
|
expect(await browser.elementById('search-param-value').text()).toEqual(
|
|
'Search param: 123'
|
|
)
|
|
})
|
|
// After navigating, we should see both the parts that we prefetched and dynamic content.
|
|
expect(await browser.elementById('search-param-value').text()).toEqual(
|
|
'Search param: 123'
|
|
)
|
|
expect(await browser.elementById('dynamic-content').text()).toEqual(
|
|
'Dynamic content'
|
|
)
|
|
|
|
await browser.back()
|
|
|
|
// Navigate to the other page
|
|
await act(
|
|
async () => {
|
|
await browser
|
|
.elementByCss(`a[href="/${prefix}/search-params?searchParam=456"]`)
|
|
.click()
|
|
},
|
|
{
|
|
// Now the dynamic content should be fetched
|
|
includes: 'Dynamic content',
|
|
}
|
|
)
|
|
expect(await browser.elementById('search-param-value').text()).toEqual(
|
|
'Search param: 456'
|
|
)
|
|
expect(await browser.elementById('dynamic-content').text()).toEqual(
|
|
'Dynamic content'
|
|
)
|
|
})
|
|
|
|
it('includes headers, but not dynamic content', async () => {
|
|
let page: Playwright.Page
|
|
const browser = await next.browser('/', {
|
|
beforePageLoad(p: Playwright.Page) {
|
|
page = p
|
|
},
|
|
})
|
|
const act = createRouterAct(page)
|
|
|
|
// Reveal the link to trigger a runtime prefetch for one value of the search param
|
|
await act(async () => {
|
|
const linkToggle = await browser.elementByCss(
|
|
`input[data-link-accordion="/${prefix}/headers"]`
|
|
)
|
|
await linkToggle.click()
|
|
}, [
|
|
// Should allow reading headers
|
|
{
|
|
includes: 'Header: present',
|
|
},
|
|
// Should not prefetch the dynamic content
|
|
{
|
|
includes: 'Dynamic content',
|
|
block: 'reject',
|
|
},
|
|
])
|
|
|
|
// Navigate to the page
|
|
await act(async () => {
|
|
await act(
|
|
async () => {
|
|
await browser.elementByCss(`a[href="/${prefix}/headers"]`).click()
|
|
},
|
|
{
|
|
// Temporarily block the navigation request.
|
|
// The runtime-prefetched parts of the tree should be visible before it finishes.
|
|
includes: 'Dynamic content',
|
|
block: true,
|
|
}
|
|
)
|
|
expect(await browser.elementById('header-value').text()).toEqual(
|
|
'Header: present'
|
|
)
|
|
})
|
|
// After navigating, we should see both the parts that we prefetched and dynamic content.
|
|
expect(await browser.elementById('header-value').text()).toEqual(
|
|
'Header: present'
|
|
)
|
|
expect(await browser.elementById('dynamic-content').text()).toEqual(
|
|
'Dynamic content'
|
|
)
|
|
})
|
|
|
|
it('includes cookies, but not dynamic content', async () => {
|
|
let page: Playwright.Page
|
|
const browser = await next.browser('/', {
|
|
beforePageLoad(p: Playwright.Page) {
|
|
page = p
|
|
},
|
|
})
|
|
// Clear cookies after the test. This currently doesn't happen automatically.
|
|
await using _ = defer(() => browser.deleteCookies())
|
|
|
|
const act = createRouterAct(page)
|
|
|
|
await browser.addCookie({ name: 'testCookie', value: 'initialValue' })
|
|
|
|
// Reveal the link to trigger a runtime prefetch for the initial cookie value
|
|
await act(async () => {
|
|
const linkToggle = await browser.elementByCss(
|
|
`input[data-link-accordion="/${prefix}/cookies"]`
|
|
)
|
|
await linkToggle.click()
|
|
}, [
|
|
// Should allow reading cookies
|
|
{
|
|
includes: 'Cookie: initialValue',
|
|
},
|
|
// Should not prefetch the dynamic content
|
|
{
|
|
includes: 'Dynamic content',
|
|
block: 'reject',
|
|
},
|
|
])
|
|
|
|
// Navigate to the page
|
|
await act(async () => {
|
|
await act(
|
|
async () => {
|
|
await browser.elementByCss(`a[href="/${prefix}/cookies"]`).click()
|
|
},
|
|
{
|
|
// Temporarily block the navigation request.
|
|
// The runtime-prefetched parts of the tree should be visible before it finishes.
|
|
includes: 'Dynamic content',
|
|
block: true,
|
|
}
|
|
)
|
|
expect(await browser.elementById('cookie-value').text()).toEqual(
|
|
'Cookie: initialValue'
|
|
)
|
|
})
|
|
// After navigating, we should see both the parts that we prefetched and dynamic content.
|
|
expect(await browser.elementById('cookie-value').text()).toEqual(
|
|
'Cookie: initialValue'
|
|
)
|
|
expect(await browser.elementById('dynamic-content').text()).toEqual(
|
|
'Dynamic content'
|
|
)
|
|
|
|
// Update the cookie via a server action.
|
|
// This should cause the client cache to be dropped,
|
|
// so the page should get prefetched again when the link becomes visible
|
|
await browser.elementByCss('input[name="cookie"]').type('updatedValue')
|
|
await browser.elementByCss('[type="submit"]').click()
|
|
|
|
// Go back to the previous page
|
|
await browser.back()
|
|
|
|
// wait a tick before navigating
|
|
// TODO: Why does this need to be so long when deployed? What other signal do we have that we can wait on?
|
|
await waitFor(2000)
|
|
|
|
// Navigate to the page
|
|
await act(async () => {
|
|
await act(
|
|
async () => {
|
|
await browser.elementByCss(`a[href="/${prefix}/cookies"]`).click()
|
|
},
|
|
{
|
|
includes: 'Dynamic content',
|
|
block: true,
|
|
}
|
|
)
|
|
expect(await browser.elementById('cookie-value').text()).toEqual(
|
|
'Cookie: updatedValue'
|
|
)
|
|
})
|
|
|
|
expect(await browser.elementById('cookie-value').text()).toEqual(
|
|
'Cookie: updatedValue'
|
|
)
|
|
expect(await browser.elementById('dynamic-content').text()).toEqual(
|
|
'Dynamic content'
|
|
)
|
|
})
|
|
|
|
it('can completely prefetch a page that uses cookies and no uncached IO', async () => {
|
|
let page: Playwright.Page
|
|
const browser = await next.browser('/', {
|
|
beforePageLoad(p: Playwright.Page) {
|
|
page = p
|
|
},
|
|
})
|
|
// Clear cookies after the test. This currently doesn't happen automatically.
|
|
await using _ = defer(() => browser.deleteCookies())
|
|
|
|
const act = createRouterAct(page, { includeAppShellRequests: true })
|
|
|
|
await browser.addCookie({ name: 'testCookie', value: 'initialValue' })
|
|
|
|
// Reveal the link.
|
|
// We won't actually perform a runtime prefetch, because the request is
|
|
// satisfied by the app shell.
|
|
await act(async () => {
|
|
const linkToggle = await browser.elementByCss(
|
|
`input[data-link-accordion="/${prefix}/cookies-only"]`
|
|
)
|
|
await linkToggle.click()
|
|
}, [
|
|
// Should allow reading cookies in the app shell
|
|
{
|
|
includes: 'Cookie: initialValue',
|
|
},
|
|
])
|
|
|
|
// Navigate to the page.
|
|
await act(
|
|
async () => {
|
|
await browser
|
|
.elementByCss(`a[href="/${prefix}/cookies-only"]`)
|
|
.click()
|
|
},
|
|
// The page doesn't use any other IO, so we prefetched it completely, and shouldn't issue any more requests.
|
|
'no-requests'
|
|
)
|
|
expect(await browser.elementById('cookie-value').text()).toEqual(
|
|
'Cookie: initialValue'
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('should not cache runtime prefetch responses in the browser cache or server-side', () => {
|
|
// This is a bit difficult to test, but we can request the same thing repeatedly and expect different results.
|
|
|
|
it.each([
|
|
{ description: 'in a page', prefix: 'in-page' },
|
|
{ description: 'in a private cache', prefix: 'in-private-cache' },
|
|
])(
|
|
'different cookies should return different prefetch results - $description',
|
|
async ({ prefix }) => {
|
|
let page: Playwright.Page
|
|
const browser = await next.browser('/', {
|
|
beforePageLoad(p: Playwright.Page) {
|
|
page = p
|
|
},
|
|
})
|
|
// Clear cookies after the test. This currently doesn't happen automatically.
|
|
await using _ = defer(() => browser.deleteCookies())
|
|
|
|
const act = createRouterAct(page, { includeAppShellRequests: true })
|
|
|
|
await browser.addCookie({ name: 'testCookie', value: 'initialValue' })
|
|
|
|
// Reveal the link.
|
|
// We won't actually perform a runtime prefetch, because the request is
|
|
// satisfied by the app shell.
|
|
|
|
await act(async () => {
|
|
const linkToggle = await browser.elementByCss(
|
|
`input[data-link-accordion="/${prefix}/cookies-only"]`
|
|
)
|
|
await linkToggle.click()
|
|
}, [
|
|
// Should allow reading cookies
|
|
{
|
|
includes: 'Cookie: initialValue',
|
|
},
|
|
])
|
|
|
|
// Reload the page with a new cookie value
|
|
await browser.addCookie({ name: 'testCookie', value: 'updatedValue' })
|
|
await browser.refresh()
|
|
|
|
// Reveal the link to trigger a runtime prefetch for the updated cookie value.
|
|
await act(async () => {
|
|
const linkToggle = await browser.elementByCss(
|
|
`input[data-link-accordion="/${prefix}/cookies-only"]`
|
|
)
|
|
await linkToggle.click()
|
|
}, [
|
|
// The response shouldn't be cached in the browser or on the server.
|
|
// If it was, we'd get a stale value here.
|
|
{
|
|
includes: 'Cookie: updatedValue',
|
|
},
|
|
])
|
|
}
|
|
)
|
|
|
|
it('private caches should return new results on each request', async () => {
|
|
let page: Playwright.Page
|
|
const browser = await next.browser('/', {
|
|
beforePageLoad(p: Playwright.Page) {
|
|
page = p
|
|
},
|
|
})
|
|
// Clear cookies after the test. This currently doesn't happen automatically.
|
|
await using _ = defer(() => browser.deleteCookies())
|
|
|
|
const act = createRouterAct(page)
|
|
|
|
// Reveal the link to trigger the first runtime prefetch
|
|
await act(async () => {
|
|
const linkToggle = await browser.elementByCss(
|
|
`input[data-link-accordion="/in-private-cache/date-now"]`
|
|
)
|
|
await linkToggle.click()
|
|
}, [
|
|
// The timestamp value is in a private cache, so it should be included
|
|
{
|
|
includes: 'Timestamp: ',
|
|
},
|
|
])
|
|
|
|
// Navigate to the page to reveal the runtime-prefetched content, and save the timestamp value it had
|
|
let firstTimestampValue: string
|
|
await act(async () => {
|
|
await act(
|
|
async () => {
|
|
await browser
|
|
.elementByCss(`a[href="/in-private-cache/date-now"]`)
|
|
.click()
|
|
},
|
|
// Temporarily block the navigation request.
|
|
// The prefetched parts of the tree should be visible before it finishes.
|
|
'block'
|
|
)
|
|
firstTimestampValue = await browser.elementById('timestamp').text()
|
|
})
|
|
|
|
// Go back to the initial page and reload it to clear the client router cache
|
|
await browser.back()
|
|
await browser.refresh()
|
|
|
|
// Reveal the link to trigger the second runtime prefetch
|
|
await act(async () => {
|
|
const linkToggle = await browser.elementByCss(
|
|
`input[data-link-accordion="/in-private-cache/date-now"]`
|
|
)
|
|
await linkToggle.click()
|
|
}, [
|
|
// The timestamp value is in a private cache, so it should be included
|
|
{
|
|
includes: 'Timestamp: ',
|
|
},
|
|
])
|
|
|
|
// Navigate to the page to reveal the runtime-prefetched content, and save the timestamp value it had
|
|
let secondTimestampValue: string
|
|
await act(async () => {
|
|
await act(
|
|
async () => {
|
|
await browser
|
|
.elementByCss(`a[href="/in-private-cache/date-now"]`)
|
|
.click()
|
|
},
|
|
// Temporarily block the navigation request.
|
|
// The prefetched parts of the tree should be visible before it finishes.
|
|
'block'
|
|
)
|
|
secondTimestampValue = await browser.elementById('timestamp').text()
|
|
})
|
|
|
|
// If the runtime prefetch response wasn't cached, the responses should be different
|
|
expect(firstTimestampValue).not.toEqual(secondTimestampValue)
|
|
})
|
|
})
|
|
|
|
it('can completely prefetch a page that is fully static', async () => {
|
|
let page: Playwright.Page
|
|
const browser = await next.browser('/', {
|
|
beforePageLoad(p: Playwright.Page) {
|
|
page = p
|
|
},
|
|
})
|
|
|
|
const act = createRouterAct(page)
|
|
|
|
// Reveal the link to trigger a runtime prefetch for the page
|
|
await act(async () => {
|
|
const linkToggle = await browser.elementByCss(
|
|
`input[data-link-accordion="/fully-static"]`
|
|
)
|
|
await linkToggle.click()
|
|
}, [
|
|
{
|
|
includes: 'Hello from a fully static page!',
|
|
},
|
|
])
|
|
|
|
// Navigate to the page.
|
|
await act(
|
|
async () => {
|
|
await browser.elementByCss(`a[href="/fully-static"]`).click()
|
|
},
|
|
// The page doesn't use any IO, so we prefetched it completely, and shouldn't issue any more requests.
|
|
'no-requests'
|
|
)
|
|
expect(await browser.elementByCss('p#intro').text()).toBe(
|
|
'Hello from a fully static page!'
|
|
)
|
|
})
|
|
|
|
describe('excluded caches', () => {
|
|
it.each([
|
|
{
|
|
// If a cache has an expiration time under 5min
|
|
// (MIN_PRERENDERABLE_EXPIRE), we omit it from static prerenders.
|
|
// However, it should still be included in a runtime prefetch if its
|
|
// stale time is >=30s. (MIN_PREFETCHABLE_STALE)
|
|
description:
|
|
'includes short-lived public caches with a prefetchable staleTime',
|
|
staticContent: 'This page uses a short-lived public cache',
|
|
path: '/caches/public-short-expire-prefetchable-stale',
|
|
},
|
|
{
|
|
// If a cache has an expiration time under 5min
|
|
// (MIN_PRERENDERABLE_EXPIRE), we omit it from static prerenders.
|
|
// However, it should still be included in a runtime prefetch if its
|
|
// stale time is >=30s. (MIN_PREFETCHABLE_STALE) `cacheLife("seconds")`
|
|
// is deliberately set to have a stale time of 30s to stay above this
|
|
// treshold.
|
|
description: 'includes public caches with cacheLife("seconds")',
|
|
staticContent: 'This page uses a short-lived public cache',
|
|
path: '/caches/public-seconds',
|
|
},
|
|
{
|
|
// A Private cache will always be omitted from static prerenders.
|
|
// However, it should still be included in a runtime prefetch if its
|
|
// stale time is >=30s. (MIN_PREFETCHABLE_STALE) `cacheLife("seconds")`
|
|
// is deliberately set to have a stale time of 30s to stay above this
|
|
// treshold.
|
|
description: 'includes private caches with cacheLife("seconds")',
|
|
staticContent: 'This page uses a short-lived private cache',
|
|
path: '/caches/private-seconds',
|
|
},
|
|
])('$description', async ({ path, staticContent }) => {
|
|
let page: Playwright.Page
|
|
const browser = await next.browser('/', {
|
|
beforePageLoad(p: Playwright.Page) {
|
|
page = p
|
|
},
|
|
})
|
|
const act = createRouterAct(page)
|
|
|
|
const DYNAMICALLY_PREFETCHABLE_CONTENT = 'Short-lived cached content'
|
|
|
|
// Reveal the link to trigger a runtime prefetch
|
|
await act(async () => {
|
|
const linkToggle = await browser.elementByCss(
|
|
`input[data-link-accordion="${path}"]`
|
|
)
|
|
await linkToggle.click()
|
|
}, [
|
|
{
|
|
includes: staticContent,
|
|
},
|
|
// Should include the short-lived cache
|
|
{
|
|
includes: DYNAMICALLY_PREFETCHABLE_CONTENT,
|
|
},
|
|
])
|
|
|
|
// Navigate to the page. We didn't include any uncached IO, so the page is fully prefetched,
|
|
// and this shouldn't issue any more requests
|
|
await act(async () => {
|
|
await browser.elementByCss(`a[href="${path}"]`).click()
|
|
}, 'no-requests')
|
|
|
|
expect(await browser.elementByCss('main').text()).toInclude(
|
|
DYNAMICALLY_PREFETCHABLE_CONTENT
|
|
)
|
|
})
|
|
|
|
it.each([
|
|
{
|
|
description:
|
|
'includes public caches whose staleTime was too short to include in a shell',
|
|
staticContent: 'This page uses a public cache',
|
|
path: '/caches/public-non-shell-stale',
|
|
},
|
|
{
|
|
description:
|
|
'includes private caches whose staleTime was too short to include in a shell',
|
|
staticContent: 'This page uses a private cache',
|
|
path: '/caches/private-non-shell-stale',
|
|
},
|
|
])('$description', async ({ path, staticContent }) => {
|
|
let page: Playwright.Page
|
|
const browser = await next.browser('/', {
|
|
beforePageLoad(p: Playwright.Page) {
|
|
page = p
|
|
},
|
|
})
|
|
const act = createRouterAct(page, { includeAppShellRequests: true })
|
|
// Reveal the link to trigger a runtime prefetch.
|
|
await act(async () => {
|
|
await browser
|
|
.elementByCss(`input[data-link-accordion="${path}"]`)
|
|
.click()
|
|
}, [
|
|
// Shell request
|
|
{
|
|
includes: staticContent,
|
|
kind: 'runtime',
|
|
},
|
|
// Prefetch request
|
|
// (the shell was not complete, because the cache was excluded from it)
|
|
{
|
|
includes: 'Non-shell cached content',
|
|
kind: 'runtime',
|
|
},
|
|
])
|
|
|
|
// The cache should be displayed while navigating and after.
|
|
await act(
|
|
async () => {
|
|
await browser.elementByCss(`a[href="${path}"]`).click()
|
|
expect(await browser.elementById('cached-value').text()).toBe(
|
|
'Non-shell cached content'
|
|
)
|
|
},
|
|
// The prefetch was complete.
|
|
'no-requests'
|
|
)
|
|
expect(await browser.elementById('cached-value').text()).toBe(
|
|
'Non-shell cached content'
|
|
)
|
|
})
|
|
|
|
it('omits short-lived public caches with a non-prefetchable staleTime', async () => {
|
|
// If a cache has a stale time below 30s (MIN_PREFETCHABLE_STALE), we
|
|
// should omit it from runtime prefetches.
|
|
|
|
let page: Playwright.Page
|
|
const browser = await next.browser('/', {
|
|
beforePageLoad(p: Playwright.Page) {
|
|
page = p
|
|
},
|
|
})
|
|
const act = createRouterAct(page)
|
|
|
|
const STATIC_CONTENT = 'This page uses a short-lived public cache'
|
|
const DYNAMIC_CONTENT = 'Short-lived cached content'
|
|
|
|
// Reveal the link to trigger a runtime prefetch.
|
|
await act(async () => {
|
|
const linkToggle = await browser.elementByCss(
|
|
`input[data-link-accordion="/caches/public-short-expire-non-prefetchable-stale"]`
|
|
)
|
|
await linkToggle.click()
|
|
}, [
|
|
// Should include the shell
|
|
{
|
|
includes: STATIC_CONTENT,
|
|
},
|
|
// Should not include the short-lived cache
|
|
// (We set the `stale` value to be under 30s, so it will be excluded from runtime prerenders)
|
|
{
|
|
includes: DYNAMIC_CONTENT,
|
|
block: 'reject',
|
|
},
|
|
])
|
|
|
|
// Navigate to the page
|
|
await act(async () => {
|
|
await act(
|
|
async () => {
|
|
await browser
|
|
.elementByCss(
|
|
`a[href="/caches/public-short-expire-non-prefetchable-stale"]`
|
|
)
|
|
.click()
|
|
},
|
|
{
|
|
// Temporarily block the navigation request.
|
|
// The prefetched parts of the tree should be visible before it finishes.
|
|
includes: DYNAMIC_CONTENT,
|
|
block: true,
|
|
}
|
|
)
|
|
expect(await browser.elementById('intro').text()).toInclude(
|
|
STATIC_CONTENT
|
|
)
|
|
})
|
|
|
|
// After navigating, we should see both the parts that we prefetched and the short lived cache.
|
|
expect(await browser.elementById('intro').text()).toInclude(
|
|
STATIC_CONTENT
|
|
)
|
|
expect(await browser.elementById('cached-value').text()).toMatch(/\d+/)
|
|
})
|
|
|
|
it('omits private caches with a non-prefetchable staleTime', async () => {
|
|
// If a cache has a stale time below 30s (MIN_PREFETCHABLE_STALE), we
|
|
// should omit it from runtime prefetches.
|
|
|
|
let page: Playwright.Page
|
|
const browser = await next.browser('/', {
|
|
beforePageLoad(p: Playwright.Page) {
|
|
page = p
|
|
},
|
|
})
|
|
const act = createRouterAct(page)
|
|
|
|
const STATIC_CONTENT = 'This page uses a short-lived private cache'
|
|
const DYNAMIC_CONTENT = 'Short-lived cached content'
|
|
|
|
// Reveal the link to trigger a runtime prefetch
|
|
await act(async () => {
|
|
const linkToggle = await browser.elementByCss(
|
|
`input[data-link-accordion="/caches/private-non-prefetchable-stale"]`
|
|
)
|
|
await linkToggle.click()
|
|
}, [
|
|
// Should include the shell
|
|
{
|
|
includes: STATIC_CONTENT,
|
|
},
|
|
// Should not prefetch the short-lived cache
|
|
// (We set the `stale` value to be under 30s, so it will be excluded from runtime prefetches)
|
|
{
|
|
includes: DYNAMIC_CONTENT,
|
|
block: 'reject',
|
|
},
|
|
])
|
|
|
|
// Navigate to the page
|
|
await act(async () => {
|
|
await act(
|
|
async () => {
|
|
await browser
|
|
.elementByCss(`a[href="/caches/private-non-prefetchable-stale"]`)
|
|
.click()
|
|
},
|
|
{
|
|
// Temporarily block the navigation request.
|
|
// The runtime-prefetched parts of the tree should be visible before it finishes.
|
|
includes: DYNAMIC_CONTENT,
|
|
block: true,
|
|
}
|
|
)
|
|
expect(await browser.elementById('intro').text()).toInclude(
|
|
STATIC_CONTENT
|
|
)
|
|
})
|
|
|
|
// After navigating, we should see both the parts that we prefetched and dynamic content.
|
|
expect(await browser.elementById('intro').text()).toInclude(
|
|
STATIC_CONTENT
|
|
)
|
|
const cachedValue1 = await browser.elementById('cached-value').text()
|
|
expect(cachedValue1).toMatch(/\d+/)
|
|
|
|
// Try navigating again. The cache is private, so we should see a different timestamp
|
|
await browser.back()
|
|
|
|
// Hover the link again. The prefetch should be cached, so we shouldn't see any requests
|
|
await act(async () => {
|
|
const linkToggle = await browser.elementByCss(
|
|
`input[data-link-accordion="/caches/private-non-prefetchable-stale"]`
|
|
)
|
|
await linkToggle.hover()
|
|
}, 'no-requests')
|
|
|
|
// Navigate to the page again
|
|
await act(async () => {
|
|
await act(
|
|
async () => {
|
|
await browser
|
|
.elementByCss(`a[href="/caches/private-non-prefetchable-stale"]`)
|
|
.click()
|
|
},
|
|
{
|
|
// Temporarily block the navigation request.
|
|
// The runtime-prefetched parts of the tree should be visible before it finishes.
|
|
includes: 'Short-lived cached content',
|
|
block: true,
|
|
}
|
|
)
|
|
expect(await browser.elementById('intro').text()).toInclude(
|
|
STATIC_CONTENT
|
|
)
|
|
})
|
|
|
|
// After navigating, we should see both the parts that we prefetched and dynamic content.
|
|
// The private cache was omitted from the runtime prefetch, so we didn't cache it in the router,
|
|
// and it was not cached server-side either, so we should get a different value than the previous request.
|
|
const cachedValue2 = await browser.elementById('cached-value').text()
|
|
expect(cachedValue2).toMatch(/\d+/)
|
|
|
|
expect(cachedValue1).not.toEqual(cachedValue2)
|
|
})
|
|
})
|
|
|
|
describe('errors', () => {
|
|
it.each([
|
|
{
|
|
description: 'when sync IO is used after awaiting cookies()',
|
|
path: '/errors/sync-io-after-runtime-api/cookies',
|
|
},
|
|
{
|
|
description: 'when sync IO is used after awaiting headers()',
|
|
path: '/errors/sync-io-after-runtime-api/headers',
|
|
},
|
|
{
|
|
description: 'when sync IO is used after awaiting dynamic params',
|
|
path: '/errors/sync-io-after-runtime-api/dynamic-params/123',
|
|
},
|
|
{
|
|
description: 'when sync IO is used after awaiting searchParams',
|
|
path: '/errors/sync-io-after-runtime-api/search-params?foo=bar',
|
|
},
|
|
{
|
|
description: 'when sync IO is used after awaiting a private cache',
|
|
path: '/errors/sync-io-after-runtime-api/private-cache',
|
|
},
|
|
{
|
|
description:
|
|
'when sync IO is used after awaiting a quickly-expiring public cache',
|
|
path: '/errors/sync-io-after-runtime-api/quickly-expiring-public-cache',
|
|
},
|
|
])(
|
|
'aborts the prerender without logging an error $description',
|
|
async ({ path }) => {
|
|
// In a runtime prefetch, we might encounter sync IO usages that weren't caught during build,
|
|
// because they were hidden behind e.g. a cookies() call.
|
|
// We currently have no way to catch these statically.
|
|
// In that case, we should abort the prerender, but still return partial content.
|
|
|
|
// TODO: this doesn't work as well as it could, see comment before the navigation
|
|
|
|
let page: Playwright.Page
|
|
const browser = await next.browser('/errors', {
|
|
beforePageLoad(p: Playwright.Page) {
|
|
page = p
|
|
},
|
|
})
|
|
const act = createRouterAct(page)
|
|
|
|
const STATIC_CONTENT = 'This page performs sync IO after'
|
|
|
|
// Reveal the link to trigger a runtime prefetch
|
|
await act(async () => {
|
|
const linkToggle = await browser.elementByCss(
|
|
`input[data-link-accordion="${path}"]`
|
|
)
|
|
await linkToggle.click()
|
|
}, [
|
|
// Should include the shell
|
|
{
|
|
includes: STATIC_CONTENT,
|
|
},
|
|
// Should abort the render when sync IO is encountered,
|
|
// so this should never be included
|
|
{
|
|
includes: 'Timestamp',
|
|
block: 'reject',
|
|
},
|
|
])
|
|
|
|
if (!isNextDeploy) {
|
|
expect(getCliOutput()).not.toMatch(`Date.now()`)
|
|
}
|
|
|
|
// Navigate to the page
|
|
await act(async () => {
|
|
await act(
|
|
async () => {
|
|
await browser.elementByCss(`a[href="${path}"]`).click()
|
|
},
|
|
{
|
|
// Temporarily block the navigation request.
|
|
includes: 'Timestamp',
|
|
block: true,
|
|
}
|
|
)
|
|
// We aborted the render because of sync IO, so we won't display the timestamp,
|
|
// but due to the way we sequence tasks, we should've at least finished rendering the static parts.
|
|
expect(await browser.elementsByCss('#timestamp')).toHaveLength(0)
|
|
expect(await browser.elementById('intro').text()).toInclude(
|
|
STATIC_CONTENT
|
|
)
|
|
})
|
|
|
|
// After navigating, we should see the sync IO result that we omitted from the prefetch.
|
|
expect(await browser.elementById('intro').text()).toInclude(
|
|
STATIC_CONTENT
|
|
)
|
|
expect(await browser.elementById('timestamp').text()).toMatch(
|
|
/Timestamp: \d+/
|
|
)
|
|
}
|
|
)
|
|
|
|
it('should trigger error boundaries for errors that occurred in runtime-prefetched content', async () => {
|
|
// A thrown error in the prerender should not stop us from sending a prefetch response.
|
|
// This should work without any extra effort, but I'm adding a test for it as a sanity check.
|
|
|
|
let page: Playwright.Page
|
|
const browser = await next.browser('/errors', {
|
|
beforePageLoad(p: Playwright.Page) {
|
|
page = p
|
|
},
|
|
})
|
|
const act = createRouterAct(page, { includeAppShellRequests: true })
|
|
|
|
const STATIC_CONTENT = 'This page errors after a cookies call'
|
|
|
|
// Reveal the link.
|
|
// We won't actually perform a runtime prefetch, because the request is
|
|
// satisfied by the app shell.
|
|
await act(async () => {
|
|
const linkToggle = await browser.elementByCss(
|
|
`input[data-link-accordion="/errors/error-after-cookies"]`
|
|
)
|
|
await linkToggle.click()
|
|
}, [
|
|
// Should include the shell
|
|
{
|
|
includes: STATIC_CONTENT,
|
|
},
|
|
])
|
|
|
|
if (!isNextDeploy) {
|
|
expect(getCliOutput()).toContain('Error: Kaboom')
|
|
}
|
|
|
|
// Navigate to the page. We already have the page cached.
|
|
// Even though the render errored, we shouldn't fetch it again.
|
|
await act(async () => {
|
|
await browser
|
|
.elementByCss(`a[href="/errors/error-after-cookies"]`)
|
|
.click()
|
|
}, 'no-requests')
|
|
|
|
// After navigating, we should see the sync IO result that we omitted from the prefetch.
|
|
expect(await browser.elementById('intro').text()).toInclude(
|
|
STATIC_CONTENT
|
|
)
|
|
expect(await browser.elementById('error-boundary').text()).toInclude(
|
|
'Error boundary: Minified React error #441;'
|
|
)
|
|
})
|
|
})
|
|
})
|
|
|
|
function defer(callback: () => Promise<void>) {
|
|
return {
|
|
[Symbol.asyncDispose]: callback,
|
|
}
|
|
}
|