next.js/test/e2e/app-dir/segment-cache/prefetch-inlining/prefetch-inlining.test.ts
prefetch-inlining.test.ts820 lines28.6 KB
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'
    )
  })
})
Quest for Codev2.0.0
/
SIGN IN