import { nextTestSetup } from 'e2e-utils'
import { waitFor } from 'next-test-utils'
import type * as Playwright from 'playwright'
import { createRouterAct } from 'router-act'
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
}
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)
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-only"]`
)
await linkToggle.click()
}, [
// Should allow reading cookies
{
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)
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-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('cache stale time handling', () => {
it.each([
{
// If a cache has an expiration time under 5min (DYNAMIC_EXPIRE), we omit it from static prerenders.
// However, it should still be included in a runtime prefetch if its stale time is >=30s. (RUNTIME_PREFETCH_DYNAMIC_STALE)
description:
'includes short-lived public caches with a long enough staleTime',
staticContent: 'This page uses a short-lived public cache',
path: '/caches/public-short-expire-long-stale',
},
{
// If a cache has an expiration time under 5min (DYNAMIC_EXPIRE), we omit it from static prerenders.
// However, it should still be included in a runtime prefetch if its stale time is >=30s. (RUNTIME_PREFETCH_DYNAMIC_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. (RUNTIME_PREFETCH_DYNAMIC_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('omits short-lived public caches with a short enough staleTime', async () => {
// If a cache has a stale time below 30s (RUNTIME_PREFETCH_DYNAMIC_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-short-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-short-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 short enough staleTime', async () => {
// If a cache has a stale time below 30s (RUNTIME_PREFETCH_DYNAMIC_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-short-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-short-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-short-stale"]`
)
await linkToggle.hover()
}, 'no-requests')
// Navigate to the page again
await act(async () => {
await act(
async () => {
await browser
.elementByCss(`a[href="/caches/private-short-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)
const STATIC_CONTENT = 'This page errors after a cookies call'
// Reveal the link to trigger a runtime prefetch
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 paged 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,
}
}