Files
next.js/test/e2e/app-dir/segment-cache/max-prefetch-inlining/max-prefetch-inlining.test.ts
Andrew ClarkandGitHub 0e6ab3f548 [Prefetch Inlining] Generate size-based hints on server (#90891)
Part 1 of 2. This commit adds the server-side infrastructure for
size-based segment bundling but does not change any observable behavior.
The client-side changes that actually consume bundled responses are in
the next commit.

At build time, a measurement pass renders each segment's prefetch
response, measures its gzip size, and decides which segments should be
bundled together vs fetched separately. The decisions are persisted to a
manifest and embedded into the route tree prefetch response so the
client can act on them.

The decisions are computed once at build and remain fixed for the
lifetime of the deployment. They are not recomputed during
ISR/revalidation — if they could change, the client would need to
re-fetch the route tree after every revalidation, defeating the purpose
of caching it independently.

Refer to the next commit for a full description of the design and
motivation.

## Config

experimental.prefetchInlining accepts either a boolean or an object with
threshold overrides (maxSize, maxBundleSize). When true, the default
thresholds are used (2KB per-segment, 10KB total budget). The auto
behavior will eventually become the default. The config will remain
available for overriding thresholds.
2026-03-13 12:09:25 -04:00

147 lines
5.0 KiB
TypeScript

import { nextTestSetup } from 'e2e-utils'
import type * as Playwright from 'playwright'
import { createRouterAct } from 'router-act'
// Tests prefetch inlining with maxSize and maxBundleSize set to Infinity,
// which inlines all segments into a single response per route. This
// approximates the behavior of pre-Segment Cache (pre-Next 16) prefetching,
// where all prefetch data was bundled into one response. The tradeoff is that
// you lose the benefits of per-layout deduplication across routes.
//
// This is a special case of the general prefetch inlining feature tested in
// the sibling `prefetch-inlining` directory. We may consolidate these tests
// in the future.
describe('max prefetch inlining', () => {
const { next, isNextDev } = nextTestSetup({
files: __dirname,
})
if (isNextDev) {
it('disabled in development', () => {})
return
}
it('bundles all segment data into a single request per route', async () => {
// The test app has two routes that are 5 segments deep:
// /shared/a/b/c and /shared/a/d/e
// Without inlining, prefetching each route would issue one request per
// segment plus one for the head (6+ requests). With inlining enabled,
// all segment data is bundled into a single response, so revealing a
// link should produce at most 2 prefetch requests per route: one for
// /_tree and one for the inlined segment data.
let rscRequestCount = 0
let page: Playwright.Page
const browser = await next.browser('/', {
beforePageLoad(p: Playwright.Page) {
page = p
p.on('request', (request: Playwright.Request) => {
if (request.headers()['rsc']) {
rscRequestCount++
}
})
},
})
const act = createRouterAct(page!)
// Reveal the first link to trigger a prefetch of /shared/a/b/c
await act(
async () => {
await browser
.elementByCss('input[data-link-accordion="/shared/a/b/c"]')
.click()
},
{
includes: 'Page C',
}
)
// Snapshot the request count before revealing the second link
const countBeforeSecondPrefetch = rscRequestCount
// Reveal the second link to trigger a prefetch of /shared/a/d/e
await act(
async () => {
await browser
.elementByCss('input[data-link-accordion="/shared/a/d/e"]')
.click()
},
{
includes: 'Page E',
}
)
// The delta should be at most 2 requests (/_tree + /_inlined).
// Without inlining, there would be 6+ individual segment requests.
const delta = rscRequestCount - countBeforeSecondPrefetch
expect(delta).toBeLessThanOrEqual(2)
// Navigate to the second route. Because the data was fully prefetched,
// there should be no additional requests.
await act(async () => {
await browser.elementByCss('a[href="/shared/a/d/e"]').click()
}, 'no-requests')
// Verify the page rendered correctly
const text = await browser.elementByCss('#page-e').text()
expect(text).toBe('Page E')
})
it('works with dynamic routes', async () => {
// Regression test: the build previously failed with
// "Invariant: missing __PAGE__ segmentPath" when prefetchInlining was
// combined with dynamic routes.
const $ = await next.render$('/dynamic/hello')
expect($('#dynamic-page').text()).toBe('hello')
})
it('deduplicates inlined prefetch requests for the same route', async () => {
// Two links point to the same route (/shared/a/b/c). When the first
// link is revealed, its prefetch spawns an inlined request. While
// that request is still pending, revealing the second link should
// not spawn any additional requests because the segments are already
// Pending from the first task.
let page: Playwright.Page
const browser = await next.browser('/', {
beforePageLoad(p: Playwright.Page) {
page = p
},
})
const act = createRouterAct(page!)
await act(
async () => {
// Reveal the first link, blocking its response so the prefetch
// stays in-flight.
await act(async () => {
await browser
.elementByCss('input[data-link-accordion="/shared/a/b/c"]')
.click()
}, 'block')
// While the first prefetch is still pending, reveal a second
// link to the same route. This should not spawn any new
// requests because the segments are already Pending.
await act(async () => {
await browser
.elementByCss('input[data-link-accordion="duplicate-a"]')
.click()
}, 'no-requests')
},
{
includes: 'Page C',
}
)
// Navigate to the route. Because the data was fully prefetched,
// no additional requests are needed.
await act(async () => {
await browser.elementByCss('a[href="/shared/a/b/c"]').click()
}, 'no-requests')
const text = await browser.elementByCss('#page-c').text()
expect(text).toBe('Page C')
})
})