Files
next.js/test/e2e/app-dir/segment-cache/prefetch-inlining/prefetch-inlining.test.ts
Andrew ClarkandGitHub f765823edd prefetchInlining: fix build crash on notFound() pages (#92806)
When traversing the route tree in collectPrefetchHints, we use keys from
the FlightRouterState to look up corresponding entries in the seed data
object. Usually these two structures agree, but there are subtle cases
like notFound() where the router state contains keys that don't exist in
the seed data. TypeScript doesn't catch this because the `in` operator
narrows the key to `string`, which is assignable to the seed data
object's index signature — but the lookup returns `undefined` rather
than `null`.

Eventually the plan is to converge these into a single data structure so
this kind of mismatch can't happen structurally. For now, coalesce
undefined to null with `?? null`, matching the pattern already used in
ppr-navigations.ts and segment-cache/cache.ts on the client.

## Test plan

- Added regression test: page with `notFound()` + `generateStaticParams`
under `prefetchInlining`
- Verified the build crashes without the fix and passes with it
- All existing prefetch-inlining tests continue to pass

<!-- NEXT_JS_LLM_PR -->
2026-04-14 18:08:25 -07:00

820 lines
29 KiB
TypeScript

import type * as Playwright from 'playwright'
import { nextTestSetup } from 'e2e-utils'
import { retry } from 'next-test-utils'
import { createRouterAct } from 'router-act'
// Bit values from PrefetchHint enum (const enum, so we duplicate values here)
const HasRuntimePrefetch = 0b00001 // 1
const ParentInlinedIntoSelf = 0b100000 // 32
const InlinedIntoChild = 0b1000000 // 64
const HeadInlinedIntoSelf = 0b10000000 // 128
const HeadOutlined = 0b100000000 // 256
const PrefetchDisabled = 0b10000000000 // 1024
// Matches the shape of RootTreePrefetch / TreePrefetch from collect-segment-
// data.tsx. We only declare the fields we need.
type TreePrefetch = {
name: string
prefetchHints: number
slots: null | { [key: string]: TreePrefetch }
}
type RootTreePrefetch = {
tree: TreePrefetch
}
/**
* Renders the TreePrefetch as an ASCII tree showing inlining decisions.
* Segments marked with "⇣ inlined" have their data included in a descendant's
* response instead of being fetched separately. Validates that parent/child
* hints are consistent (every InlinedIntoChild parent must have a child with
* ParentInlinedIntoSelf, and vice versa).
*/
// "outlined ■" is the fixed-width tag (10 chars). Other tags are right-aligned
// to match.
const OUTLINED_TAG = 'outlined \u25A0'
const INLINED_TAG = '\u21E3'.padStart(OUTLINED_TAG.length)
const RUNTIME_TAG = 'runtime \u25FB'.padStart(OUTLINED_TAG.length)
const DYNAMIC_TAG = 'dynamic \u25FB'.padStart(OUTLINED_TAG.length)
function renderInliningTree(tree: TreePrefetch): string {
const lines: string[] = []
const isHeadOutlined = (tree.prefetchHints & HeadOutlined) !== 0
collectNodes(tree, '', !isHeadOutlined, false, lines)
if (isHeadOutlined) {
// Metadata is not inlined into any page — render as a standalone sibling.
lines.push(`${OUTLINED_TAG} \u2514\u2500\u2500 metadata`)
}
return '\n' + lines.join('\n') + '\n'
}
function collectNodes(
node: TreePrefetch,
prefix: string,
isLast: boolean,
hasParent: boolean,
lines: string[],
slotKey?: string
): void {
const hasRuntimePrefetch = (node.prefetchHints & HasRuntimePrefetch) !== 0
const prefetchDisabled = (node.prefetchHints & PrefetchDisabled) !== 0
const inlinedIntoChild = (node.prefetchHints & InlinedIntoChild) !== 0
const _parentInlined = (node.prefetchHints & ParentInlinedIntoSelf) !== 0
const headInlined = (node.prefetchHints & HeadInlinedIntoSelf) !== 0
const slotPrefix =
slotKey !== undefined && slotKey !== 'children' ? `@${slotKey}/` : ''
const headSuffix = headInlined ? ' (+metadata)' : ''
const name = hasParent ? `${slotPrefix}"${node.name}"${headSuffix}` : 'root'
// Static prefetch is skipped for runtime and dynamic segments. Distinguish
// them in the snapshot: runtime segments will be fetched via a runtime
// prefetch request, while dynamic segments are not prefetched at all.
const tag = hasRuntimePrefetch
? RUNTIME_TAG
: prefetchDisabled
? DYNAMIC_TAG
: inlinedIntoChild
? INLINED_TAG
: OUTLINED_TAG
const connector = hasParent
? isLast
? '\u2514\u2500\u2500 '
: '\u251C\u2500\u2500 '
: ''
lines.push(`${tag} ${prefix}${connector}${name}`)
// Validate consistency between parent and children.
if (node.slots) {
const children = Object.values(node.slots)
const childrenWithParentInlined = children.filter(
(c) => (c.prefetchHints & ParentInlinedIntoSelf) !== 0
)
if (inlinedIntoChild && childrenWithParentInlined.length === 0) {
throw new Error(
`"${node.name}" has InlinedIntoChild but no child has ParentInlinedIntoSelf`
)
}
if (!inlinedIntoChild && childrenWithParentInlined.length > 0) {
const names = childrenWithParentInlined.map((c) => c.name).join(', ')
throw new Error(
`"${node.name}" does not have InlinedIntoChild but child(ren) ${names} ` +
`have ParentInlinedIntoSelf`
)
}
const childPrefix =
prefix + (hasParent ? (isLast ? ' ' : '\u2502 ') : '')
const keys = Object.keys(node.slots)
const hasMultipleSlots = keys.length > 1
for (let i = 0; i < keys.length; i++) {
collectNodes(
node.slots[keys[i]],
childPrefix,
i === keys.length - 1,
true,
lines,
hasMultipleSlots ? keys[i] : undefined
)
}
}
}
// Temporary helper: fetches the route tree prefetch response and parses the
// RootTreePrefetch object out of it. This will be replaced by end-to-end
// tests that assert on actual client prefetch request behavior once the
// client-side changes are done.
async function fetchRouteTreePrefetch(
next: any,
pathname: string
): Promise<RootTreePrefetch> {
const res = await next.fetch(pathname, {
headers: {
RSC: '1',
'Next-Router-Prefetch': '1',
'Next-Router-Segment-Prefetch': '/_tree',
},
})
const text = await res.text()
// The Flight response for a plain JSON object (no React nodes) is a single
// line: `0:{"tree":...,"staleTime":...}`. Strip the row ID prefix and parse.
const jsonStr = text.slice(text.indexOf(':') + 1)
return JSON.parse(jsonStr)
}
describe('prefetch inlining', () => {
const { next, isNextDev, isTurbopack } = nextTestSetup({
files: __dirname,
})
if (isNextDev) {
it('prefetch hints are only computed during build', () => {})
return
}
it('small chain: inlines multiple ancestors into deepest child', async () => {
// Root → child layout → page, all with minimal content (well under the
// 2KB gzip threshold). Both the root and child layout are small enough
// to be inlined into the page's response. The entire chain fits within
// the 10KB total budget, so everything collapses into a single fetch
// for the page segment.
const data = await fetchRouteTreePrefetch(next, '/test-small-chain')
expect(renderInliningTree(data.tree)).toMatchInlineSnapshot(`
"
⇣ root
⇣ └── "test-small-chain"
outlined ■ └── "__PAGE__" (+metadata)
"
`)
// Verify client navigation works with the inlined data.
let page: Playwright.Page
const browser = await next.browser('/', {
beforePageLoad(p: Playwright.Page) {
page = p
},
})
const act = createRouterAct(page!)
await act(
async () => {
await browser
.elementByCss('input[data-link-accordion="/test-small-chain"]')
.click()
},
{ includes: 'Small chain page' }
)
await act(async () => {
await browser.elementByCss('a[href="/test-small-chain"]').click()
}, 'no-requests')
expect(await browser.elementByCss('#page-small-chain').text()).toBe(
'Small chain page'
)
})
it('outlined: large segment breaks the inlining chain', async () => {
// Root → large layout (> 2KB gzipped) → page. The large layout exceeds
// the per-segment inlining threshold so it can't be inlined into the
// page. Root is still small enough for the large layout to accept, so
// root gets inlined into the large layout's response. The page is
// fetched separately since its parent was too large.
const data = await fetchRouteTreePrefetch(next, '/test-outlined')
expect(renderInliningTree(data.tree)).toMatchInlineSnapshot(`
"
⇣ root
outlined ■ └── "test-outlined"
outlined ■ └── "__PAGE__" (+metadata)
"
`)
let page: Playwright.Page
const browser = await next.browser('/', {
beforePageLoad(p: Playwright.Page) {
page = p
},
})
const act = createRouterAct(page!)
await act(
async () => {
await browser
.elementByCss('input[data-link-accordion="/test-outlined"]')
.click()
},
{ includes: 'Outlined test page' }
)
await act(async () => {
await browser.elementByCss('a[href="/test-outlined"]').click()
}, 'no-requests')
expect(await browser.elementByCss('#page-outlined').text()).toBe(
'Outlined test page'
)
})
it('preserves prefetch hints after on-demand revalidation', async () => {
const beforeTree = await fetchRouteTreePrefetch(
next,
'/test-on-demand-revalidate'
)
expect(renderInliningTree(beforeTree.tree)).toMatchInlineSnapshot(`
"
⇣ root
⇣ └── "test-on-demand-revalidate"
outlined ■ └── "__PAGE__" (+metadata)
"
`)
const before$ = await next.render$('/test-on-demand-revalidate')
const beforeValue = before$('#page-on-demand-revalidate-value').text()
expect(beforeValue).toMatch(/^0\.\d+$/)
const revalidateRes = await next.fetch(
'/api/revalidate-path?path=/test-on-demand-revalidate'
)
expect(revalidateRes.status).toBe(200)
expect(await revalidateRes.json()).toEqual({
revalidated: true,
path: '/test-on-demand-revalidate',
})
await retry(
async () => {
const $ = await next.render$('/test-on-demand-revalidate')
const afterValue = $('#page-on-demand-revalidate-value').text()
expect(afterValue).toMatch(/^0\.\d+$/)
expect(afterValue).not.toBe(beforeValue)
},
15000,
1000
)
const afterTree = await fetchRouteTreePrefetch(
next,
'/test-on-demand-revalidate'
)
expect(renderInliningTree(afterTree.tree)).toBe(
renderInliningTree(beforeTree.tree)
)
})
it('parallel routes: parent inlines into one slot only', async () => {
// Layout with two parallel slots (children + @sidebar), all small. The
// layout can only be inlined into one child — the first slot that
// accepts (children). The @sidebar slot doesn't receive the parent's
// data and is fetched independently.
//
const data = await fetchRouteTreePrefetch(next, '/test-parallel')
if (isTurbopack) {
// Turbopack iterates children before @sidebar, so the parent
// inlines into children/__PAGE__.
expect(renderInliningTree(data.tree)).toMatchInlineSnapshot(`
"
⇣ root
⇣ └── "test-parallel"
outlined ■ ├── "__PAGE__" (+metadata)
⇣ └── @sidebar/"(__SLOT__)"
outlined ■ └── "__PAGE__"
"
`)
} else {
// Webpack iterates @sidebar before children, so the parent
// inlines into @sidebar/__PAGE__ instead.
expect(renderInliningTree(data.tree)).toMatchInlineSnapshot(`
"
⇣ root
⇣ └── "test-parallel"
⇣ ├── @sidebar/"(__SLOT__)"
outlined ■ │ └── "__PAGE__" (+metadata)
outlined ■ └── "__PAGE__"
"
`)
}
let page: Playwright.Page
const browser = await next.browser('/', {
beforePageLoad(p: Playwright.Page) {
page = p
},
})
const act = createRouterAct(page!)
await act(
async () => {
await browser
.elementByCss('input[data-link-accordion="/test-parallel"]')
.click()
},
{ includes: 'Main content' }
)
await act(async () => {
await browser.elementByCss('a[href="/test-parallel"]').click()
}, 'no-requests')
expect(await browser.elementByCss('#page-parallel').text()).toBe(
'Main content'
)
})
it('home: root inlines directly into page', async () => {
// Simplest possible case: root layout + page. Root is small and inlines
// into the page.
const data = await fetchRouteTreePrefetch(next, '/')
expect(renderInliningTree(data.tree)).toMatchInlineSnapshot(`
"
⇣ root
outlined ■ └── "__PAGE__" (+metadata)
"
`)
})
it('restart: large segment in the middle creates two inlining groups', async () => {
// root (small) → test-restart (small) → large-middle (> 2KB) → after
// (small) → page (small). The large segment can't be inlined into its
// children, splitting the tree into two inlining groups:
// [root, test-restart] → large-middle's response, and [after] → page's
// response.
const data = await fetchRouteTreePrefetch(
next,
'/test-restart/large-middle/after'
)
expect(renderInliningTree(data.tree)).toMatchInlineSnapshot(`
"
⇣ root
⇣ └── "test-restart"
outlined ■ └── "large-middle"
⇣ └── "after"
outlined ■ └── "__PAGE__" (+metadata)
"
`)
let page: Playwright.Page
const browser = await next.browser('/', {
beforePageLoad(p: Playwright.Page) {
page = p
},
})
const act = createRouterAct(page!)
await act(
async () => {
await browser
.elementByCss(
'input[data-link-accordion="/test-restart/large-middle/after"]'
)
.click()
},
{ includes: 'After page' }
)
await act(async () => {
await browser
.elementByCss('a[href="/test-restart/large-middle/after"]')
.click()
}, 'no-requests')
expect(await browser.elementByCss('#page-restart').text()).toBe(
'After page'
)
})
it('deep chain: all small segments inline to the leaf', async () => {
// root → test-deep → a → b → c → page, all small. Every segment in
// the chain inlines down to the page, producing a single fetch.
const data = await fetchRouteTreePrefetch(next, '/test-deep/a/b/c')
expect(renderInliningTree(data.tree)).toMatchInlineSnapshot(`
"
⇣ root
⇣ └── "test-deep"
⇣ └── "a"
⇣ └── "b"
⇣ └── "c"
outlined ■ └── "__PAGE__" (+metadata)
"
`)
let page: Playwright.Page
const browser = await next.browser('/', {
beforePageLoad(p: Playwright.Page) {
page = p
},
})
const act = createRouterAct(page!)
await act(
async () => {
await browser
.elementByCss('input[data-link-accordion="/test-deep/a/b/c"]')
.click()
},
{ includes: 'Deep page' }
)
await act(async () => {
await browser.elementByCss('a[href="/test-deep/a/b/c"]').click()
}, 'no-requests')
expect(await browser.elementByCss('#page-deep').text()).toBe('Deep page')
})
it('dynamic route: hints are based on concrete params, not fallback shell', async () => {
// The [slug] layout renders large content gated behind `await params`. In
// the fallback shell, `await params` suspends so the segment appears small.
// In a concrete render the full content is included, pushing it above the
// 2KB threshold. If hints were incorrectly based on the fallback, the
// layout would get inlined. Instead it should be outlined because the
// concrete render is large.
const data = await fetchRouteTreePrefetch(next, '/test-dynamic/hello')
const helloTree = renderInliningTree(data.tree)
expect(helloTree).toMatchInlineSnapshot(`
"
⇣ root
⇣ └── "test-dynamic"
outlined ■ └── "slug"
outlined ■ └── "__PAGE__" (+metadata)
"
`)
// Different param value should produce the same hints (keyed by route
// pattern, not concrete path)
const data2 = await fetchRouteTreePrefetch(next, '/test-dynamic/world')
expect(renderInliningTree(data2.tree)).toBe(helloTree)
let page: Playwright.Page
const browser = await next.browser('/', {
beforePageLoad(p: Playwright.Page) {
page = p
},
})
const act = createRouterAct(page!)
await act(
async () => {
await browser
.elementByCss('input[data-link-accordion="/test-dynamic/hello"]')
.click()
},
{ includes: 'Dynamic page: hello' }
)
await act(async () => {
await browser.elementByCss('a[href="/test-dynamic/hello"]').click()
}, 'no-requests')
expect(await browser.elementByCss('#page-dynamic').text()).toBe(
'Dynamic page: hello'
)
})
// TODO: Add a test for stale hints (InliningHintsStale). The stale hints
// mechanism expires the route cache entry so the next prefetch re-fetches
// the correct tree. This is hard to test reliably with act() because the
// test needs to start on a page with stale hints, navigate away, and
// navigate back — and act() can hang on CI when intercepting requests
// that overlap with background prefetch activity. The server-side logic
// is covered by the build output (the route tree correctly includes
// InliningHintsStale for build-time static pages), but the client-side
// recovery path needs a more robust test harness.
it('instant false at root: does not prefetch segment data', async () => {
// TODO: This test exists as a temporary mitigation for a bug where
// routes with `instant = false` at the root segment cause the
// prerender to run per-request instead of being cached. Until that
// bug is fixed (see https://github.com/vercel/next.js/pull/91407),
// we fall back to treating every segment as unprefetchable. This
// test verifies that fallback works — the route builds successfully
// and the client doesn't attempt to prefetch any segment data.
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 prefetch. Since all segments are
// treated as PrefetchDisabled, the client should fetch the route
// tree but not any segment data.
await act(async () => {
await browser
.elementByCss('input[data-link-accordion="/test-instant-false-root"]')
.click()
})
// The static page content should NOT appear in any prefetch response
// because all segments are marked as unprefetchable.
await act(
async () => {
await browser.elementByCss('a[href="/test-instant-false-root"]').click()
},
// The page content should not have been prefetched — it will be
// fetched during navigation instead.
{ includes: 'Static page below instant:false root' }
)
})
it('runtime prefetch: layout cannot inline into a runtime leaf', async () => {
// Root → small static layout → page with runtime prefetch. Root inlines
// into the layout (the layout accepts root's data). But the layout
// cannot inline into the runtime page — the page is a leaf with no
// static descendants, so there's no response to carry the layout's data.
// The layout remains outlined while root is inlined into it.
const data = await fetchRouteTreePrefetch(next, '/test-runtime-bailout')
expect(renderInliningTree(data.tree)).toMatchInlineSnapshot(`
"
⇣ root
outlined ■ └── "test-runtime-bailout"
runtime ◻ └── "__PAGE__" (+metadata)
"
`)
let page: Playwright.Page
const browser = await next.browser('/', {
beforePageLoad(p: Playwright.Page) {
page = p
},
})
const act = createRouterAct(page!)
await act(
async () => {
await browser
.elementByCss('input[data-link-accordion="/test-runtime-bailout"]')
.click()
},
{ includes: 'Static layout content' }
)
await act(async () => {
await browser.elementByCss('a[href="/test-runtime-bailout"]').click()
}, 'no-requests')
expect(await browser.elementByCss('#layout-runtime-bailout').text()).toBe(
'Static layout content'
)
expect(await browser.elementByCss('#page-runtime-bailout').text()).toMatch(
/Runtime page/
)
})
it('runtime passthrough: static parents inline through runtime layout to static child', async () => {
// Root → runtime layout → inner static layout → static page. The
// runtime layout acts as a transparent pass-through — it has a static
// child that can accept the parent data, so the chain passes through
// it. The runtime layout's slot in the bundle is null (no static data)
// but it still carries InlinedIntoChild.
const data = await fetchRouteTreePrefetch(
next,
'/test-runtime-passthrough/inner'
)
expect(renderInliningTree(data.tree)).toMatchInlineSnapshot(`
"
⇣ root
runtime ◻ └── "test-runtime-passthrough" (+metadata)
⇣ └── "inner"
outlined ■ └── "__PAGE__"
"
`)
let page: Playwright.Page
const browser = await next.browser('/', {
beforePageLoad(p: Playwright.Page) {
page = p
},
})
const act = createRouterAct(page!)
await act(async () => {
await browser
.elementByCss(
'input[data-link-accordion="/test-runtime-passthrough/inner"]'
)
.click()
}, [
{ includes: 'Static page below runtime layout' },
// Appears twice: once in the static bundle and once in the
// runtime prefetch. Static segments below a runtime layout are
// not skipped — they participate in inlining normally because
// sub-navigations within the runtime layout may need them. The
// inlining thresholds ensure the duplication is worth the cost.
{ includes: 'Static page below runtime layout' },
])
await act(async () => {
await browser
.elementByCss('a[href="/test-runtime-passthrough/inner"]')
.click()
}, 'no-requests')
expect(await browser.elementByCss('#page-runtime-passthrough').text()).toBe(
'Static page below runtime layout'
)
})
it('instant false passthrough: static parents inline through dynamic layout to static child', async () => {
// Root → dynamic layout (instant: false, uses connection()) → inner
// static layout → static page. Same pass-through behavior as runtime
// prefetch: the dynamic layout passes parent data through to its static
// descendants. Its slot in the bundle is null but the chain isn't broken.
const data = await fetchRouteTreePrefetch(
next,
'/test-instant-false-passthrough/inner'
)
expect(renderInliningTree(data.tree)).toMatchInlineSnapshot(`
"
⇣ root
dynamic ◻ └── "test-instant-false-passthrough"
⇣ └── "inner"
outlined ■ └── "__PAGE__" (+metadata)
"
`)
// Verify the dynamic layout's content is NOT included in any prefetch
// response. The layout has instant = false, so its data should be
// skipped entirely — fetched only during navigation.
let page: Playwright.Page
const browser = await next.browser('/', {
beforePageLoad(p: Playwright.Page) {
page = p
},
})
const act = createRouterAct(page!)
await act(async () => {
await browser
.elementByCss(
'input[data-link-accordion="/test-instant-false-passthrough/inner"]'
)
.click()
}, [
// The static page below the dynamic layout IS prefetched.
{ includes: 'page-instant-false-passthrough' },
// The dynamic layout content must NOT appear in any prefetch
// response — it has instant = false, so it's skipped entirely.
{ includes: 'Dynamic layout', block: 'reject' },
])
})
it('runtime parallel: pass-through only flows into one child slot', async () => {
// Root → runtime layout with two slots (children + @sidebar) → inner
// layout → page. The runtime layout acts as a pass-through, but
// the parent's data should only flow into one child slot (the first
// that accepts), not both. This extends the existing parallel route
// inlining rule to the pass-through case.
const data = await fetchRouteTreePrefetch(
next,
'/test-runtime-parallel/inner'
)
expect(renderInliningTree(data.tree)).toMatchInlineSnapshot(`
"
⇣ root
runtime ◻ └── "test-runtime-parallel" (+metadata)
⇣ ├── "inner"
outlined ■ │ └── "__PAGE__"
outlined ■ └── @sidebar/"__DEFAULT__"
"
`)
let page: Playwright.Page
const browser = await next.browser('/', {
beforePageLoad(p: Playwright.Page) {
page = p
},
})
const act = createRouterAct(page!)
await act(async () => {
await browser
.elementByCss(
'input[data-link-accordion="/test-runtime-parallel/inner"]'
)
.click()
}, [
{ includes: 'Runtime parallel main content' },
// Appears twice: static bundle + runtime prefetch. Same as
// runtime passthrough — static segments below a runtime layout
// participate in inlining normally.
{ includes: 'Runtime parallel main content' },
])
await act(async () => {
await browser
.elementByCss('a[href="/test-runtime-parallel/inner"]')
.click()
}, 'no-requests')
expect(await browser.elementByCss('#page-runtime-parallel').text()).toBe(
'Runtime parallel main content'
)
})
it('independent head: metadata is prefetched even when no runtime segment request is needed', async () => {
// The layout at /test-independent-head/[item] uses runtime prefetching
// (reads cookies). The pages underneath it are static. The metadata
// (head) accesses both the [item] param and searchParams, making it
// depend on runtime data.
//
// When we prefetch route A, the runtime layout and head are fetched
// together. When we then prefetch sibling route B, the layout is
// already cached — no runtime segment request is needed. But the
// head is different (it includes the [item] param in the title) and
// must still be fetched via a runtime prefetch. This test verifies
// that the head is fetched independently even when no runtime segment
// request is spawned for the sibling.
const data = await fetchRouteTreePrefetch(next, '/test-independent-head/a')
expect(renderInliningTree(data.tree)).toMatchInlineSnapshot(`
"
⇣ root
runtime ◻ └── "test-independent-head" (+metadata)
⇣ └── "item"
outlined ■ └── "__PAGE__"
"
`)
let page: Playwright.Page
const browser = await next.browser('/', {
beforePageLoad(p: Playwright.Page) {
page = p
},
})
const act = createRouterAct(page!)
// Prefetch and navigate to route A. This caches the runtime layout,
// head, and static page, and makes A the current page.
await act(async () => {
await browser
.elementByCss('input[data-link-accordion="/test-independent-head/a"]')
.click()
})
await act(async () => {
await browser.elementByCss('a[href="/test-independent-head/a"]').click()
}, 'no-requests')
// Now we're on route A. Reveal the sibling link to route B. The
// runtime layout is shared between A and B, so it's already cached
// and won't be re-fetched. The only new segment is the [item] page,
// which is static. But the head differs (title includes "Item: b")
// and depends on runtime data, so it must still be fetched via a
// runtime prefetch even though no other runtime request is needed.
await act(
async () => {
await browser
.elementByCss('input[data-link-accordion="/test-independent-head/b"]')
.click()
},
{ includes: 'Independent Head Title: b' }
)
// Navigate to route B. The page segment is unnecessarily marked as
// partial because the metadata outlet in the page's RSC data
// contains an unresolved reference to the dynamic metadata. This
// causes navigation to re-fetch the page even though the actual
// page content is fully static.
// TODO: The page segment should not be considered partial just
// because the metadata is dynamic. Once this is fixed, this
// navigation should not require any network requests.
await act(async () => {
await browser.elementByCss('a[href="/test-independent-head/b"]').click()
})
expect(await browser.elementByCss('#page-independent-head').text()).toBe(
'Independent head page'
)
})
it('notFound() during prerender does not crash build', async () => {
// Regression test: a page that calls notFound() during prerendering
// produces a flight data tree where some child seed data entries are
// undefined. collectPrefetchHints must handle this without crashing.
// The build succeeding is the primary assertion.
const browser = await next.browser('/test-not-found/exists')
expect(await browser.elementByCss('#page-not-found').text()).toBe(
'Found: exists'
)
})
})