Skip to content

Commit 129efe6

Browse files
committed
e2e tests for navigation() and <Link prefetch="navigation">
Test suite for `await navigation()` and `<Link prefetch="navigation">`. I've split these into a separate PR for ease of review.
1 parent b6469bc commit 129efe6

9 files changed

Lines changed: 704 additions & 0 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { Suspense } from 'react'
2+
import { unstable_navigation } from 'next/stages'
3+
4+
export default function Page() {
5+
return (
6+
<main>
7+
<div id="above-navigation">Above navigation content</div>
8+
<Suspense
9+
fallback={<div id="below-fallback">Loading below navigation...</div>}
10+
>
11+
<BelowNavigation />
12+
</Suspense>
13+
</main>
14+
)
15+
}
16+
17+
async function BelowNavigation() {
18+
// On a fully static page, `await unstable_navigation()` has no effect. It
19+
// only defers content during runtime prefetches, which are rendered
20+
// per-user, per-link. A static prerender is computed once and shared across
21+
// many clients, so there's no per-request cost to save — this content is
22+
// included in the static output (and thus in static prefetches).
23+
await unstable_navigation()
24+
return <div id="below-navigation">Below navigation content</div>
25+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { ReactNode } from 'react'
2+
export default function Root({ children }: { children: ReactNode }) {
3+
return (
4+
<html>
5+
<body>{children}</body>
6+
</html>
7+
)
8+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { LinkAccordion } from '../components/link-accordion'
2+
3+
export default function Page() {
4+
return (
5+
<main>
6+
<h1>Stages navigation home</h1>
7+
<ul>
8+
<li>
9+
{/* Use a full prefetch: the suite asserts that a prefetch of this
10+
fully static page includes everything, by navigating without any
11+
additional requests. (The app enables `partialPrefetching`, so a
12+
default prefetch would be deliberately partial.) */}
13+
<LinkAccordion href="/basic" prefetch={true}>
14+
Basic
15+
</LinkAccordion>
16+
</li>
17+
<li>
18+
<LinkAccordion href="/workaround">Workaround</LinkAccordion>
19+
</li>
20+
<li>
21+
<LinkAccordion href="/runtime-prefetch">
22+
Runtime prefetch
23+
</LinkAccordion>
24+
</li>
25+
<li>
26+
{/* Second accordion to the same href, but at navigation depth.
27+
Revealing this link issues a runtime prefetch that renders past
28+
`await unstable_navigation()`. */}
29+
<LinkAccordion href="/runtime-prefetch" prefetch="navigation">
30+
Runtime prefetch (navigation depth)
31+
</LinkAccordion>
32+
</li>
33+
<li>
34+
{/* Third accordion to the same href, with a full prefetch. Under
35+
Cache Components, `prefetch={true}` uses the same two-phase flow
36+
as the default prefetch, but additionally performs the
37+
speculative per-link pass, which issues a standalone runtime
38+
prefetch for the page (it opts in via
39+
`prefetch = 'allow-runtime'`). The suite uses this to put
40+
runtime-depth entries in the cache before revealing a
41+
navigation-depth link to the same route. */}
42+
<LinkAccordion href="/runtime-prefetch" prefetch={true}>
43+
Runtime prefetch (full)
44+
</LinkAccordion>
45+
</li>
46+
<li>
47+
{/* Full prefetch: issues a standalone runtime prefetch for the
48+
page (see the comment on the equivalent /runtime-prefetch
49+
accordion above). */}
50+
<LinkAccordion href="/runtime-ungated" prefetch={true}>
51+
Runtime ungated (full)
52+
</LinkAccordion>
53+
</li>
54+
<li>
55+
<LinkAccordion href="/runtime-ungated" prefetch="navigation">
56+
Runtime ungated (navigation depth)
57+
</LinkAccordion>
58+
</li>
59+
</ul>
60+
</main>
61+
)
62+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { cookies } from 'next/headers'
2+
import { Suspense } from 'react'
3+
import { unstable_navigation } from 'next/stages'
4+
5+
// Opt this route into runtime prefetching. When a link to this page becomes
6+
// visible, the client issues a runtime prefetch — a 'prerender-runtime' work
7+
// unit on the server — which, unlike a static prefetch, is allowed to read
8+
// request data like cookies.
9+
export const prefetch = 'allow-runtime'
10+
11+
// Sample cookie values used at build time to validate the runtime prefetch.
12+
export const instant = {
13+
unstable_samples: [{ cookies: [{ name: 'testCookie', value: 'testValue' }] }],
14+
}
15+
16+
export default function Page() {
17+
return (
18+
<main>
19+
<Suspense
20+
fallback={<div id="cookie-fallback">Loading cookie content...</div>}
21+
>
22+
<CookieContent />
23+
</Suspense>
24+
</main>
25+
)
26+
}
27+
28+
// Reads cookies *above* the `await unstable_navigation()` gate. The
29+
// cookie-derived content is included in a runtime prefetch, which proves the
30+
// prefetch response contains runtime data — while the gated content below
31+
// is excluded from it. (It's also absent from static prefetches, since a
32+
// static prerender suspends at the cookies() read before reaching it.)
33+
async function CookieContent() {
34+
const cookieStore = await cookies()
35+
const cookieValue = cookieStore.get('testCookie')?.value ?? null
36+
return (
37+
<>
38+
<div id="cookie-value">{`Cookie: ${cookieValue}`}</div>
39+
<Suspense
40+
fallback={<div id="gated-fallback">Loading gated content...</div>}
41+
>
42+
<Gated />
43+
</Suspense>
44+
</>
45+
)
46+
}
47+
48+
async function Gated() {
49+
// Everything below is deferred to the actual navigation instead of
50+
// rendering during a runtime prefetch. Runtime prefetches are rendered
51+
// per-user, per-link, so this is exactly the per-request rendering cost
52+
// that unstable_navigation() exists to save.
53+
await unstable_navigation()
54+
return <div id="gated-content">Runtime gated content</div>
55+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { cookies } from 'next/headers'
2+
import { Suspense } from 'react'
3+
4+
// Opt this route into runtime prefetching, like /runtime-prefetch — but this
5+
// page has no `await unstable_navigation()` gate. A runtime prefetch renders
6+
// the entire page, so nothing is deferred to the navigation stage. The suite
7+
// uses this to assert that the client records such an entry as
8+
// navigation-complete: a later `prefetch="navigation"` link to this route
9+
// must not issue a second, deeper prefetch.
10+
export const prefetch = 'allow-runtime'
11+
12+
// Sample cookie values used at build time to validate the runtime prefetch.
13+
export const instant = {
14+
unstable_samples: [{ cookies: [{ name: 'testCookie', value: 'testValue' }] }],
15+
}
16+
17+
export default function Page() {
18+
return (
19+
<main>
20+
<Suspense
21+
fallback={
22+
<div id="ungated-cookie-fallback">Loading cookie content...</div>
23+
}
24+
>
25+
<CookieContent />
26+
</Suspense>
27+
</main>
28+
)
29+
}
30+
31+
// Reads cookies, so the content only renders in a runtime prefetch (or a
32+
// navigation) — never in a static prefetch. There's no gate below it: once
33+
// the runtime prefetch completes, the whole page is in the cache.
34+
async function CookieContent() {
35+
const cookieStore = await cookies()
36+
const cookieValue = cookieStore.get('testCookie')?.value ?? null
37+
return <div id="ungated-cookie-value">{`Ungated cookie: ${cookieValue}`}</div>
38+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { cookies } from 'next/headers'
2+
import { Suspense } from 'react'
3+
import { unstable_navigation } from 'next/stages'
4+
5+
// Opt this route into runtime prefetching. unstable_navigation() only has an
6+
// observable effect during runtime prefetches (it's a no-op during static
7+
// prerendering), so the route must be runtime-prefetch-enabled for the
8+
// exclusion to be testable.
9+
export const prefetch = 'allow-runtime'
10+
11+
// Sample cookie values used at build time to validate the runtime prefetch.
12+
export const instant = {
13+
unstable_samples: [{ cookies: [{ name: 'testCookie', value: 'testValue' }] }],
14+
}
15+
16+
// This page demonstrates the canonical pattern for combining
17+
// unstable_navigation() with "use cache". Awaiting unstable_navigation()
18+
// inside a cache scope is an error, so keep the unstable_navigation() gate in
19+
// an uncached wrapper and put the "use cache" directive on inner functions
20+
// called below the gate. Cached content that isn't behind the gate is still
21+
// included in runtime prefetches.
22+
export default function Page() {
23+
return (
24+
<main>
25+
<Suspense
26+
fallback={<div id="cookie-fallback">Loading cookie content...</div>}
27+
>
28+
<CookieSection />
29+
</Suspense>
30+
</main>
31+
)
32+
}
33+
34+
// Reads cookies above the `await unstable_navigation()` gate, so a runtime
35+
// prefetch of this page actually renders runtime content. Everything below is
36+
// nested under the cookie read, so none of it can appear in a static prefetch
37+
// (a static prerender suspends at cookies() before reaching it) — which lets
38+
// the test assert unambiguously on the runtime prefetch response.
39+
async function CookieSection() {
40+
const cookieStore = await cookies()
41+
const cookieValue = cookieStore.get('testCookie')?.value ?? null
42+
return (
43+
<>
44+
<div id="cookie-value">{`Workaround cookie: ${cookieValue}`}</div>
45+
<Suspense
46+
fallback={<div id="visible-fallback">Loading cached visible...</div>}
47+
>
48+
<CachedVisible />
49+
</Suspense>
50+
<Suspense fallback={<div id="gated-fallback">Loading gated...</div>}>
51+
<GatedSection />
52+
</Suspense>
53+
</>
54+
)
55+
}
56+
57+
// Cached, not gated: included in runtime prefetches.
58+
async function CachedVisible() {
59+
'use cache'
60+
return <div id="cached-visible">Cached visible content</div>
61+
}
62+
63+
// Uncached wrapper: gate first, then read the cached data. Only this subtree
64+
// is excluded from the runtime prefetch and deferred to the navigation.
65+
async function GatedSection() {
66+
await unstable_navigation()
67+
const data = await getWorkaroundData()
68+
return <div id="gated-data">{data}</div>
69+
}
70+
71+
async function getWorkaroundData() {
72+
'use cache'
73+
return 'Cached workaround data'
74+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
'use client'
2+
3+
import Link, { type LinkProps } from 'next/link'
4+
import { useState } from 'react'
5+
6+
// Controls when a <Link> enters the DOM. A Next.js <Link> triggers a prefetch
7+
// when it enters the viewport (via IntersectionObserver). By hiding the Link
8+
// behind a checkbox toggle, tests control exactly when prefetches happen —
9+
// only when the accordion is explicitly toggled inside an `act` scope.
10+
export function LinkAccordion({
11+
href,
12+
children,
13+
prefetch,
14+
}: {
15+
href: string
16+
children: React.ReactNode
17+
prefetch?: LinkProps['prefetch']
18+
}) {
19+
const [isVisible, setIsVisible] = useState(false)
20+
return (
21+
<>
22+
<input
23+
type="checkbox"
24+
checked={isVisible}
25+
onChange={() => setIsVisible(!isVisible)}
26+
data-link-accordion={href}
27+
data-prefetch={getPrefetchKind(prefetch)}
28+
/>
29+
{isVisible ? (
30+
<Link href={href} prefetch={prefetch}>
31+
{children}
32+
</Link>
33+
) : (
34+
<>{children} (link is hidden)</>
35+
)}
36+
</>
37+
)
38+
}
39+
40+
// A stable label for the `prefetch` prop, exposed as a `data-prefetch`
41+
// attribute so tests can distinguish multiple accordions that point to the
42+
// same href but use different prefetch modes.
43+
function getPrefetchKind(prefetch: LinkProps['prefetch']): string {
44+
switch (prefetch) {
45+
case false:
46+
return 'disabled'
47+
case undefined:
48+
case null:
49+
case 'auto':
50+
return 'auto'
51+
case true:
52+
return 'true'
53+
case 'prefetch':
54+
return 'prefetch'
55+
case 'navigation':
56+
return 'navigation'
57+
default:
58+
prefetch satisfies never
59+
return 'unknown'
60+
}
61+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/**
2+
* @type {import('next').NextConfig}
3+
*/
4+
const nextConfig = {
5+
cacheComponents: true,
6+
partialPrefetching: true,
7+
}
8+
9+
module.exports = nextConfig

0 commit comments

Comments
 (0)