Files
next.js/test/e2e/app-dir/segment-cache/cached-navigations/cached-navigations-global-runtime.test.ts
Andrew ClarkandGitHub 3de2d1a213 Unify allow-runtime with Partial Prefetching (#96106)
Removes the "allow-runtime" prefetch config, and turns its behavior on
implicitly wherever Partial Prefetching is enabled.

The original motivation for "allow-runtime" was to give apps more
control over server costs triggered by prefetches. Until a route
explicitly opts in, prefetches would only be served from the CDN, not
from the server. The problem, though, was it was very confusing to know
when to add or remove this configuration. The incentive for many apps
was to add it everywhere, with no clear signal for when to remove it.

Our updated thinking is that Partial Prefetching itself already provides
sufficient protection against runaway prefetching costs: per-link
prefetches only happen on Link components that explicitly opt in with
the prefetch prop.

The optimizations landed earlier in this stack also make allow-runtime
less necessary: on pages where all the content is statically renderable,
prefetches are served from the static cache and no runtime request is
ever issued; only a page that accesses non-static data is prefetched at
runtime.

The upshot of this decision is that runtime versus static becomes an
internal optimization; the same content gets prefetched regardless of
whether or how Next.js is able to optimize it.
2026-07-28 11:51:29 -04:00

108 lines
3.7 KiB
TypeScript

import path from 'path'
import { nextTestSetup } from 'e2e-utils'
import type * as Playwright from 'playwright'
import { createRouterAct } from 'router-act'
// The `global-runtime` fixture sets `partialPrefetching: true`, which makes
// every route runtime-cache its navigations regardless of whether a segment
// opted in via a `prefetch` config.
describe('cached navigations - global partial prefetching', () => {
const { next, isNextDev } = nextTestSetup({
files: path.join(__dirname, 'global-runtime'),
})
if (isNextDev) {
it('is skipped', () => {})
return
}
it('runtime-caches a route that has no per-segment prefetch config', async () => {
let page: Playwright.Page
const browser = await next.browser('/', {
async beforePageLoad(p: Playwright.Page) {
page = p
await page.clock.install()
},
})
const act = createRouterAct(page)
// First navigation to /runtime-prefetchable — a route that reads request
// data (searchParams, cookies, headers) but has no per-segment `prefetch`
// config. The link uses prefetch={false}, so this is a plain navigation
// with no prefetch.
await act(
async () => {
await browser.elementByCss('a[href="/runtime-prefetchable"]').click()
},
{ includes: 'Dynamic content' }
)
// Everything is visible after the full dynamic response.
expect(await browser.elementById('cached-content').text()).toContain(
'Cached content'
)
expect(
await browser.elementById('search-params-boundary').text()
).toContain('Search params:')
expect(await browser.elementById('cookies-boundary').text()).toContain(
'Cookie:'
)
expect(await browser.elementById('headers-boundary').text()).toContain(
'Header:'
)
expect(await browser.elementById('connection-boundary').text()).toContain(
'Dynamic content'
)
// Navigate back to home
await browser.back()
expect(await browser.elementByCss('h1').text()).toBe('Home')
// Second navigation — under the global flag, the request-derived content
// (searchParams, cookies, headers) was runtime-cached from the first
// navigation's embedded runtime prefetch stream and shows instantly, even
// with the dynamic request blocked. Without the global flag this route
// would only get static caching, since it never opts in via a per-segment
// `prefetch` config. Only the truly dynamic connection() content needs a
// server request.
await act(async () => {
await act(
async () => {
await browser.elementByCss('a[href="/runtime-prefetchable"]').click()
},
{
includes: 'Dynamic content',
block: true,
}
)
// Static cached content is visible.
expect(await browser.elementById('cached-content').text()).toContain(
'Cached content'
)
// Request-derived content is runtime-cached from the first navigation,
// despite no per-segment opt-in.
expect(
await browser.elementById('search-params-boundary').text()
).toContain('Search params:')
expect(await browser.elementById('cookies-boundary').text()).toContain(
'Cookie:'
)
expect(await browser.elementById('headers-boundary').text()).toContain(
'Header:'
)
// Only connection() shows a Suspense fallback — it's truly dynamic.
expect(await browser.elementById('connection-boundary').text()).toBe(
'Loading connection...'
)
})
// After unblocking, the dynamic content resolves too.
expect(await browser.elementById('connection-boundary').text()).toContain(
'Dynamic content'
)
})
})