mirror of
https://github.com/hanzoai/next.js.git
synced 2026-09-24 17:00:51 +00:00
Partial Prefetching: Default to App Shell only (#94510)
When Partial Prefetching is enabled (unstable_prefetch = 'partial'),
this changes the default behavior of Link to only prefetch the App Shell
of the target page, not the page data. Per-page data is only prefetched
if the Link's `prefetch` prop is set to `true`.
For dynamic/partially static pages, this roughly matches the pre-Cache
Components behavior: links to those pages never include page content,
only a reusable App Shell defined by `loading.tsx` (if defined).
For fully static pages, this is a significant behavior change: the prior
behavior of Next.js was to prefetch the entire static page, regardless
of whether `prefetch={true}` was set. As a default behavior, this made
sense for certain kinds of content-heavy sites, like blogs, where most
of the content is expected to be generated at build time. These apps
will now need to set the prefetch prop to `true` to maintain the
previous behavior.
The main motivation is to simplify the cost/performance model for
prefetching: rendering a Link is now "free" unless `prefetch={true}` is
set.
I've put "free" in quotes because even if `prefetch={true}` is not set,
Next.js will still prefetch a generic App Shell. However, this App Shell
only needs to be rendered once per filesystem route, as opposed to once
per link. So it's not prefetching in the usual sense that is meant in
the context of SPA-based web applications. It's more like incremental
bundle loading, except instead of loading just the code for the route,
we also load the generic UI.
To aid in incremental adoption of this feature, this also adds an
"eager" prefetch mode that can be set on any layout or page
(unstable_prefetch = 'unstable_eager'). When "eager" is set, every Link
to that route is assigned an implied `prefetch={true}`, restoring the
pre-Partial Prefetching behavior. Blog-like sites may find this useful,
but it's mostly intended to support migrating existing apps.
(For canary testers of Partial Prefetching, you can also enable eager
mode globally by setting `partialPrefetching: 'unstable_eager'`. This is
not recommended except for migration purposes, even for blog-like
sites.)
<!-- NEXT_JS_LLM_PR -->
This commit is contained in:
@@ -39,13 +39,19 @@ const InstantConfigSchema = z.union([
|
||||
const PrefetchSchema = z.enum([
|
||||
'auto',
|
||||
'partial',
|
||||
'unstable_eager',
|
||||
'force-disabled',
|
||||
'force-runtime',
|
||||
])
|
||||
|
||||
export type Instant = InstantConfig | true | false
|
||||
|
||||
export type Prefetch = 'auto' | 'partial' | 'force-disabled' | 'force-runtime'
|
||||
export type Prefetch =
|
||||
| 'auto'
|
||||
| 'partial'
|
||||
| 'unstable_eager'
|
||||
| 'force-disabled'
|
||||
| 'force-runtime'
|
||||
|
||||
export type InstantConfigForTypeCheckInternal = __GenericInstantConfig | Instant
|
||||
// the __GenericInstantConfig type is used to avoid type widening issues with
|
||||
@@ -137,6 +143,9 @@ const AppSegmentConfigSchema = z.object({
|
||||
* - 'auto' (default) is a noop.
|
||||
* - 'partial' enables Partial Prefetching. Only Cache Components are
|
||||
* prefetched, not dynamic ones.
|
||||
* - 'unstable_eager' behaves like 'partial' but, when App Shells are enabled,
|
||||
* keeps eagerly prefetching the route's segments instead of relying on the
|
||||
* shared app shell. Internal migration aid; not part of the public API.
|
||||
* - 'force-runtime' is a superset of 'partial' and prefetches using a
|
||||
* runtime request, instead of a static one.
|
||||
* - 'force-disabled' disables prefetching for the segment.
|
||||
@@ -195,7 +204,7 @@ export function parseAppSegmentConfig(
|
||||
}
|
||||
case 'unstable_prefetch': {
|
||||
return {
|
||||
message: `Invalid unstable_prefetch value ${JSON.stringify(ctx.data)} on "${route}", must be "auto", "partial", "force-disabled", or "force-runtime".`,
|
||||
message: `Invalid unstable_prefetch value ${JSON.stringify(ctx.data)} on "${route}", must be "auto", "partial", "unstable_eager", "force-disabled", or "force-runtime".`,
|
||||
}
|
||||
}
|
||||
case 'unstable_dynamicStaleTime': {
|
||||
@@ -262,6 +271,9 @@ export type AppSegmentConfig = {
|
||||
* - 'auto' (default) is a noop.
|
||||
* - 'partial' enables Partial Prefetching. Only Cache Components are
|
||||
* prefetched, not dynamic ones.
|
||||
* - 'unstable_eager' behaves like 'partial' but, when App Shells are enabled,
|
||||
* keeps eagerly prefetching the route's segments instead of relying on the
|
||||
* shared app shell. Internal migration aid; not part of the public API.
|
||||
* - 'force-runtime' is a superset of 'partial' and prefetches using a
|
||||
* runtime request, instead of a static one.
|
||||
* - 'force-disabled' disables prefetching for the segment.
|
||||
|
||||
@@ -776,6 +776,16 @@ function pingRootRouteTree(
|
||||
//
|
||||
// Then, if there are any segments that need a runtime request,
|
||||
// do another pass to perform a runtime prefetch.
|
||||
|
||||
if (
|
||||
task.phase === PrefetchPhase.Speculative &&
|
||||
!subtreeHasSpeculativePrefetch(task, tree.prefetchHints)
|
||||
) {
|
||||
// Nothing in the target route needs to be speculatively prefetched.
|
||||
// Bail out.
|
||||
return PrefetchTaskExitStatus.Done
|
||||
}
|
||||
|
||||
pingStaticHead(now, task, route)
|
||||
const exitStatus = pingSharedPartOfCacheComponentsTree(
|
||||
now,
|
||||
@@ -797,15 +807,15 @@ function pingRootRouteTree(
|
||||
// Do any segments have runtime prefetching configured? This is
|
||||
// sent by the server as part of the prefetch hints.
|
||||
tree.prefetchHints & PrefetchHint.SubtreeHasRuntimePrefetch ||
|
||||
// Are we in the Shell prefetching phase? The Shell phase is allowed
|
||||
// to perform runtime prefetches even without an explicit opt-in
|
||||
// because the Shell for a given route is reusable across all given
|
||||
// params, by definition. So it does not lead to an explosion in
|
||||
// prefetching costs.
|
||||
// Are we in the Shell prefetching phase? The Shell phase is
|
||||
// allowed to perform runtime prefetches even without an explicit
|
||||
// opt-in because the Shell for a given route is reusable across
|
||||
// all given params, by definition. So it does not lead to an
|
||||
// explosion in prefetching costs.
|
||||
// TODO: In the future, the server could emit a hint to tell us
|
||||
// *not* to prefetch via a runtime request, via build-time
|
||||
// heuristics, like if no `cookies()` call was detected. We'll leave
|
||||
// this optimization for later.
|
||||
// heuristics, like if no `cookies()` call was detected. We'll
|
||||
// leave this optimization for later.
|
||||
task.phase === PrefetchPhase.Shell
|
||||
) {
|
||||
const runtimeStrategy =
|
||||
@@ -1101,6 +1111,16 @@ function pingNewPartOfCacheComponentsTree(
|
||||
// runtime shell request. For fully static pages, it doesn't matter since
|
||||
// server will respond to even a runtime request with a static response. For
|
||||
// a partially static page, we can send down a hint from the server.
|
||||
|
||||
if (
|
||||
task.phase === PrefetchPhase.Speculative &&
|
||||
!subtreeHasSpeculativePrefetch(task, tree.prefetchHints)
|
||||
) {
|
||||
// Nothing in the new part of the tree needs to be speculatively prefetched.
|
||||
// Bail out.
|
||||
return PrefetchTaskExitStatus.Done
|
||||
}
|
||||
|
||||
if (
|
||||
tree.prefetchHints & PrefetchHint.HasRuntimePrefetch ||
|
||||
task.phase === PrefetchPhase.Shell
|
||||
@@ -1919,6 +1939,31 @@ function doesCurrentSegmentMatchCachedSegment(
|
||||
return matchSegment(cachedSegment, currentSegment)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides whether to skip the speculative prefetch of a subtree. Usually we
|
||||
* only perform a speculative prefetch if the Link's prefetch prop is set to
|
||||
* true. However, we also will do a speculative prefetch if the prefetching
|
||||
* mode of the segment is set to "unstable_eager".
|
||||
*/
|
||||
function subtreeHasSpeculativePrefetch(
|
||||
task: PrefetchTask,
|
||||
prefetchHints: number
|
||||
): boolean {
|
||||
if (!process.env.__NEXT_APP_SHELLS) {
|
||||
// When App Shells is disabled, all prefetches implicitly include the
|
||||
// speculative (non-shell) part of the target.
|
||||
return true
|
||||
}
|
||||
|
||||
return (
|
||||
// Check if this is a "full" prefetch (<Link prefetch={true}>).
|
||||
task.fetchStrategy === FetchStrategy.Full ||
|
||||
// Check if something in this subtree is configured to be eagerly
|
||||
// prefetched at the route level.
|
||||
(prefetchHints & PrefetchHint.SubtreeHasEagerPrefetch) !== 0
|
||||
)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// The remainder of the module is a MinHeap implementation. Try not to put any
|
||||
// logic below here unless it's related to the heap algorithm. We can extract
|
||||
|
||||
@@ -14,7 +14,7 @@ async function createFlightRouterStateFromLoaderTreeImpl(
|
||||
hintTree: PrefetchHints | null,
|
||||
prefetchInliningEnabled: boolean,
|
||||
cacheComponents: boolean,
|
||||
partialPrefetching: boolean | undefined,
|
||||
partialPrefetching: boolean | 'unstable_eager' | undefined,
|
||||
isStaticGeneration: boolean,
|
||||
isBuildTimePrerendering: boolean,
|
||||
getDynamicParamFromSegment: GetDynamicParamFromSegment,
|
||||
@@ -40,7 +40,11 @@ async function createFlightRouterStateFromLoaderTreeImpl(
|
||||
: undefined
|
||||
const prefetchConfig =
|
||||
(mod ? (mod as AppSegmentConfig).unstable_prefetch : undefined) ??
|
||||
(partialPrefetching ? 'partial' : undefined)
|
||||
(partialPrefetching === 'unstable_eager'
|
||||
? 'unstable_eager'
|
||||
: partialPrefetching
|
||||
? 'partial'
|
||||
: undefined)
|
||||
let prefetchHints = 0
|
||||
|
||||
// Union in the precomputed build-time hints (e.g. segment inlining
|
||||
@@ -93,21 +97,41 @@ async function createFlightRouterStateFromLoaderTreeImpl(
|
||||
prefetchHints |= PrefetchHint.IsRootLayout
|
||||
}
|
||||
|
||||
if (
|
||||
const isInstant =
|
||||
instantConfig === true ||
|
||||
(typeof instantConfig === 'object' && instantConfig !== null)
|
||||
) {
|
||||
if (isInstant) {
|
||||
prefetchHints |= PrefetchHint.SubtreeHasPartialPrefetching
|
||||
}
|
||||
|
||||
if (prefetchConfig === 'partial') {
|
||||
prefetchHints |= PrefetchHint.SubtreeHasPartialPrefetching
|
||||
} else if (prefetchConfig === 'unstable_eager') {
|
||||
// Like 'partial' (uses the PPR fetch strategy) but also marks the segment
|
||||
// as eager, so App Shells keeps prefetching it instead of relying on the
|
||||
// shared app shell.
|
||||
prefetchHints |=
|
||||
PrefetchHint.SubtreeHasPartialPrefetching |
|
||||
PrefetchHint.SubtreeHasEagerPrefetch
|
||||
} else if (prefetchConfig === 'force-disabled') {
|
||||
prefetchHints |= PrefetchHint.PrefetchDisabled
|
||||
} else if (prefetchConfig === 'force-runtime') {
|
||||
prefetchHints |= PrefetchHint.HasRuntimePrefetch
|
||||
}
|
||||
|
||||
// Mark the segment as "eager" unless its effective prefetch strategy is
|
||||
// 'partial' or 'force-runtime'. A truthy unstable_instant is treated as
|
||||
// 'partial' (not eager). 'unstable_eager' already set the bit above. Under
|
||||
// App Shells, a subtree with no eager segment skips its Speculative prefetch
|
||||
// and relies on the shared app shell instead.
|
||||
if (
|
||||
!isInstant &&
|
||||
prefetchConfig !== 'partial' &&
|
||||
prefetchConfig !== 'force-runtime'
|
||||
) {
|
||||
prefetchHints |= PrefetchHint.SubtreeHasEagerPrefetch
|
||||
}
|
||||
|
||||
// Check if this segment has a loading boundary
|
||||
if (loading) {
|
||||
prefetchHints |= PrefetchHint.SegmentHasLoadingBoundary
|
||||
@@ -151,7 +175,7 @@ export async function createFlightRouterStateFromLoaderTree(
|
||||
hintTree: PrefetchHints | null,
|
||||
prefetchInliningEnabled: boolean,
|
||||
cacheComponents: boolean,
|
||||
partialPrefetching: boolean | undefined,
|
||||
partialPrefetching: boolean | 'unstable_eager' | undefined,
|
||||
isStaticGeneration: boolean,
|
||||
isBuildTimePrerendering: boolean,
|
||||
getDynamicParamFromSegment: GetDynamicParamFromSegment,
|
||||
@@ -177,7 +201,7 @@ export async function createRouteTreePrefetch(
|
||||
hintTree: PrefetchHints | null,
|
||||
prefetchInliningEnabled: boolean,
|
||||
cacheComponents: boolean,
|
||||
partialPrefetching: boolean | undefined,
|
||||
partialPrefetching: boolean | 'unstable_eager' | undefined,
|
||||
isStaticGeneration: boolean,
|
||||
isBuildTimePrerendering: boolean,
|
||||
getDynamicParamFromSegment: GetDynamicParamFromSegment
|
||||
|
||||
@@ -766,7 +766,9 @@ export const configSchema: zod.ZodType<NextConfig> = z.lazy(() =>
|
||||
.optional(),
|
||||
pageExtensions: z.array(z.string()).min(1).optional(),
|
||||
instrumentationClientInject: z.array(z.string()).optional(),
|
||||
partialPrefetching: z.boolean().optional(),
|
||||
partialPrefetching: z
|
||||
.union([z.boolean(), z.literal('unstable_eager')])
|
||||
.optional(),
|
||||
poweredByHeader: z.boolean().optional(),
|
||||
productionBrowserSourceMaps: z.boolean().optional(),
|
||||
reactCompiler: z.union([
|
||||
|
||||
@@ -1783,8 +1783,12 @@ export interface NextConfig {
|
||||
*
|
||||
* When `false` or omitted, this does nothing (the legacy behavior, where
|
||||
* dynamic data is included in the prefetch).
|
||||
*
|
||||
* `'unstable_eager'` is like `true`, except the default becomes
|
||||
* `'unstable_eager'` instead of `'partial'`: every Link has an implied
|
||||
* prefetch={true}. Internal migration aid; not part of the public API.
|
||||
*/
|
||||
partialPrefetching?: boolean
|
||||
partialPrefetching?: boolean | 'unstable_eager'
|
||||
|
||||
cacheLife?: {
|
||||
[profile: string]: {
|
||||
|
||||
@@ -159,10 +159,15 @@ const API_DOCS: Record<
|
||||
unstable_prefetch: {
|
||||
description: `Controls prefetching behavior for this segment. This configuration is currently under development and will change.`,
|
||||
link: '(docs coming soon)',
|
||||
type: `"auto" | "partial" | "force-disabled" | "force-runtime"`,
|
||||
type: `"auto" | "partial" | "unstable_eager" | "force-disabled" | "force-runtime"`,
|
||||
options: {
|
||||
auto: 'Default. Framework decides based on instant validation and segment configuration. You do not need to set this explicitly.',
|
||||
partial: 'Enables Partial Prefetching for this segment.',
|
||||
unstable_eager:
|
||||
'Like "partial", but adds an implied prop of prefetch={true} to ' +
|
||||
'every Link. This option only exists to aid migration of apps that ' +
|
||||
'adopted Partial Prefetching in canary before the behavior changed to ' +
|
||||
'only fetch the shell by default.',
|
||||
'force-disabled': 'Never prefetch this segment.',
|
||||
'force-runtime':
|
||||
'Prefetch this segment with a runtime server request so it can access session data, such as cookies.',
|
||||
|
||||
@@ -222,6 +222,12 @@ export const enum PrefetchHint {
|
||||
// (HasRuntimePrefetch). Propagates upward so the root reflects the
|
||||
// entire subtree.
|
||||
SubtreeHasRuntimePrefetch = 0b100000000000,
|
||||
// This segment or one of its descendants prefetches "eagerly" — i.e. its
|
||||
// effective prefetch strategy is anything other than 'partial' or
|
||||
// 'force-runtime'. Used by App Shells: a non-eager subtree relies on the
|
||||
// shared app shell and skips its Speculative prefetch. Propagates upward so
|
||||
// the root reflects the entire subtree.
|
||||
SubtreeHasEagerPrefetch = 0b1000000000000,
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -245,7 +251,8 @@ export const StaticPrefetchDisabled =
|
||||
export const SubtreePrefetchHints =
|
||||
PrefetchHint.SubtreeHasPartialPrefetching |
|
||||
PrefetchHint.SubtreeHasLoadingBoundary |
|
||||
PrefetchHint.SubtreeHasRuntimePrefetch
|
||||
PrefetchHint.SubtreeHasRuntimePrefetch |
|
||||
PrefetchHint.SubtreeHasEagerPrefetch
|
||||
|
||||
/**
|
||||
* Folds a child segment's prefetch hints into its parent's, propagating the
|
||||
@@ -281,6 +288,11 @@ export function propagateSubtreeBits(
|
||||
) {
|
||||
parentHints |= PrefetchHint.SubtreeHasRuntimePrefetch
|
||||
}
|
||||
// And for eager prefetch. The bit is set directly on each eager segment, so
|
||||
// there's no separate segment-local flag — propagate it as-is.
|
||||
if (childHints & PrefetchHint.SubtreeHasEagerPrefetch) {
|
||||
parentHints |= PrefetchHint.SubtreeHasEagerPrefetch
|
||||
}
|
||||
return parentHints
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { LinkAccordion } from '../components/link-accordion'
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<main>
|
||||
<h1>Home</h1>
|
||||
<ul>
|
||||
<li>
|
||||
<LinkAccordion href="/posts/1">Post 1 (default)</LinkAccordion>
|
||||
</li>
|
||||
<li>
|
||||
<LinkAccordion href="/posts/2">Post 2 (default)</LinkAccordion>
|
||||
</li>
|
||||
</ul>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { Suspense } from 'react'
|
||||
|
||||
type Params = { id: string }
|
||||
|
||||
// No per-segment `unstable_prefetch`. The route's prefetch config comes from
|
||||
// the global `partialPrefetching: 'unstable_eager'` in next.config, which makes
|
||||
// it eager — so the App Shells skip does NOT apply.
|
||||
export function generateStaticParams() {
|
||||
return [{ id: '1' }, { id: '2' }, { id: '3' }]
|
||||
}
|
||||
|
||||
export default function Page({ params }: { params: Promise<Params> }) {
|
||||
return (
|
||||
<main>
|
||||
<Suspense fallback={<p id="shell">App shell</p>}>
|
||||
<ParamContent params={params} />
|
||||
</Suspense>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
async function ParamContent({ params }: { params: Promise<Params> }) {
|
||||
const { id } = await params
|
||||
return <p id="param-value">{`Eager post ${id}`}</p>
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
'use client'
|
||||
|
||||
import Link, { type LinkProps } from 'next/link'
|
||||
import { useState } from 'react'
|
||||
|
||||
export function LinkAccordion({
|
||||
href,
|
||||
children,
|
||||
prefetch,
|
||||
}: {
|
||||
href: string
|
||||
children: React.ReactNode
|
||||
prefetch?: LinkProps['prefetch']
|
||||
}) {
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isVisible}
|
||||
onChange={() => setIsVisible(!isVisible)}
|
||||
data-link-accordion={href}
|
||||
/>
|
||||
{isVisible ? (
|
||||
<Link href={href} prefetch={prefetch}>
|
||||
{children}
|
||||
</Link>
|
||||
) : (
|
||||
<>{children} (link is hidden)</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { NextConfig } from 'next'
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
cacheComponents: true,
|
||||
// Opt the whole app into Partial Prefetching in "eager" mode. Every route's
|
||||
// default prefetch config becomes 'unstable_eager', so under App Shells the
|
||||
// per-link Speculative prefetch is NOT skipped — even for routes with no
|
||||
// per-segment `unstable_prefetch` export.
|
||||
partialPrefetching: 'unstable_eager',
|
||||
experimental: {
|
||||
prefetchInlining: true,
|
||||
optimisticRouting: true,
|
||||
cachedNavigations: true,
|
||||
appShells: true,
|
||||
varyParams: true,
|
||||
},
|
||||
}
|
||||
|
||||
export default nextConfig
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import { nextTestSetup } from 'e2e-utils'
|
||||
import type * as Playwright from 'playwright'
|
||||
import { createRouterAct } from 'router-act'
|
||||
|
||||
describe('App Shell prefetching - global unstable_eager', () => {
|
||||
const { next, isNextDev } = nextTestSetup({
|
||||
files: __dirname,
|
||||
})
|
||||
if (isNextDev) {
|
||||
it('is skipped', () => {})
|
||||
return
|
||||
}
|
||||
|
||||
it('does NOT skip the Speculative prefetch when partialPrefetching is "unstable_eager" globally', async () => {
|
||||
let page: Playwright.Page
|
||||
const browser = await next.browser('/', {
|
||||
beforePageLoad(p: Playwright.Page) {
|
||||
page = p
|
||||
},
|
||||
})
|
||||
const act = createRouterAct(page)
|
||||
|
||||
// /posts/[id] has no per-segment prefetch config, but the global
|
||||
// `partialPrefetching: 'unstable_eager'` makes it eager. Reveal /posts/1 to
|
||||
// prime the shared app shell.
|
||||
await act(async () => {
|
||||
await browser
|
||||
.elementByCss('input[data-link-accordion="/posts/1"]')
|
||||
.click()
|
||||
})
|
||||
|
||||
// Reveal /posts/2 — a different param, shell already cached. Because the
|
||||
// global config makes the route eager, the per-link Speculative prefetch
|
||||
// still fires for param 2 (a single request carrying "Eager post 2"),
|
||||
// rather than firing no requests as a non-eager route's second link would.
|
||||
await act(
|
||||
async () => {
|
||||
await browser
|
||||
.elementByCss('input[data-link-accordion="/posts/2"]')
|
||||
.click()
|
||||
},
|
||||
{ includes: 'Eager post 2' }
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Suspense } from 'react'
|
||||
|
||||
type Params = { id: string }
|
||||
|
||||
// Combines both segment-level opt-ins: `unstable_instant` (which on its own
|
||||
// behaves like 'partial' — not eager) AND `unstable_prefetch = 'unstable_eager'`.
|
||||
// 'unstable_eager' wins: the segment is marked eager, so under App Shells the
|
||||
// per-link Speculative prefetch still fires and the param-specific content
|
||||
// below IS prefetched.
|
||||
export const unstable_instant = true
|
||||
export const unstable_prefetch = 'unstable_eager'
|
||||
|
||||
export function generateStaticParams() {
|
||||
return [{ id: '1' }, { id: '2' }, { id: '3' }]
|
||||
}
|
||||
|
||||
export default function Page({ params }: { params: Promise<Params> }) {
|
||||
return (
|
||||
<main>
|
||||
{/* The fallback is the param-independent app shell. */}
|
||||
<Suspense fallback={<p id="shell">Eager-instant app shell</p>}>
|
||||
<ParamContent params={params} />
|
||||
</Suspense>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
async function ParamContent({ params }: { params: Promise<Params> }) {
|
||||
const { id } = await params
|
||||
return <p id="param-value">{`Eager-instant post ${id}`}</p>
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Suspense } from 'react'
|
||||
|
||||
type Params = { id: string }
|
||||
|
||||
// Opts into Partial Prefetching in "eager" mode. Behaves like 'partial', but
|
||||
// under App Shells it keeps prefetching the route's segments instead of relying
|
||||
// on the shared app shell — so the param-specific content below IS prefetched.
|
||||
export const unstable_prefetch = 'unstable_eager'
|
||||
|
||||
export function generateStaticParams() {
|
||||
return [{ id: '1' }, { id: '2' }, { id: '3' }]
|
||||
}
|
||||
|
||||
export default function Page({ params }: { params: Promise<Params> }) {
|
||||
return (
|
||||
<main>
|
||||
{/* The fallback is the param-independent app shell. */}
|
||||
<Suspense fallback={<p id="shell">Eager app shell</p>}>
|
||||
<ParamContent params={params} />
|
||||
</Suspense>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
async function ParamContent({ params }: { params: Promise<Params> }) {
|
||||
const { id } = await params
|
||||
return <p id="param-value">{`Eager post ${id}`}</p>
|
||||
}
|
||||
@@ -6,7 +6,7 @@ export default function Page() {
|
||||
<main>
|
||||
<h1>Home</h1>
|
||||
|
||||
<h2>Dynamic posts</h2>
|
||||
<h2>Dynamic posts (force-runtime)</h2>
|
||||
<p>
|
||||
These posts read request-time data (cookies). Their App Shell is the
|
||||
part of the page that doesn't depend on the URL params, so it can
|
||||
@@ -53,6 +53,41 @@ export default function Page() {
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2>Partial posts</h2>
|
||||
<ul>
|
||||
<li>
|
||||
<LinkAccordion href="/partial/1">Partial 1 (default)</LinkAccordion>
|
||||
</li>
|
||||
<li>
|
||||
<LinkAccordion href="/partial/2">Partial 2 (default)</LinkAccordion>
|
||||
</li>
|
||||
<li>
|
||||
<LinkAccordion href="/partial/3" prefetch={true}>
|
||||
Partial 3 (prefetch=true)
|
||||
</LinkAccordion>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2>Eager posts</h2>
|
||||
<ul>
|
||||
<li>
|
||||
<LinkAccordion href="/eager/1">Eager 1 (default)</LinkAccordion>
|
||||
</li>
|
||||
<li>
|
||||
<LinkAccordion href="/eager/2">Eager 2 (default)</LinkAccordion>
|
||||
</li>
|
||||
<li>
|
||||
<LinkAccordion href="/eager-instant/1">
|
||||
Eager-instant 1 (unstable_instant + unstable_eager)
|
||||
</LinkAccordion>
|
||||
</li>
|
||||
<li>
|
||||
<LinkAccordion href="/eager-instant/2">
|
||||
Eager-instant 2 (unstable_instant + unstable_eager)
|
||||
</LinkAccordion>
|
||||
</li>
|
||||
</ul>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Suspense } from 'react'
|
||||
|
||||
type Params = { id: string }
|
||||
|
||||
// Opts into Partial Prefetching. Under App Shells, a partial (non-Full)
|
||||
// prefetch of this route relies on the shared app shell and skips the per-link
|
||||
// Speculative prefetch — so the param-specific content below is NOT prefetched.
|
||||
export const unstable_prefetch = 'partial'
|
||||
|
||||
export function generateStaticParams() {
|
||||
return [{ id: '1' }, { id: '2' }, { id: '3' }]
|
||||
}
|
||||
|
||||
export default function Page({ params }: { params: Promise<Params> }) {
|
||||
return (
|
||||
<main>
|
||||
{/* The fallback is the param-independent app shell. */}
|
||||
<Suspense fallback={<p id="shell">Partial app shell</p>}>
|
||||
<ParamContent params={params} />
|
||||
</Suspense>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
async function ParamContent({ params }: { params: Promise<Params> }) {
|
||||
const { id } = await params
|
||||
return <p id="param-value">{`Partial post ${id}`}</p>
|
||||
}
|
||||
@@ -22,20 +22,17 @@ describe('App Shell prefetching', () => {
|
||||
|
||||
// Reveal the LinkAccordion for /posts/1. This caches the App Shell
|
||||
// for the route — the param-independent content of the page that's
|
||||
// reusable for any /posts/[id].
|
||||
//
|
||||
// The "App shell for posts" substring appears in two responses: one
|
||||
// for the App Shell itself and one for the per-link prefetch of
|
||||
// /posts/1 (which, for a dynamic page, also includes the shell
|
||||
// content above its inner Suspense fallback).
|
||||
await act(async () => {
|
||||
await browser
|
||||
.elementByCss('input[data-link-accordion="/posts/1"]')
|
||||
.click()
|
||||
}, [
|
||||
{ includes: 'App shell for posts' },
|
||||
{ includes: 'App shell for posts' },
|
||||
])
|
||||
// reusable for any /posts/[id]. The link uses the default (auto)
|
||||
// prefetch, so under App Shells it prefetches only the shared shell;
|
||||
// the per-link Speculative prefetch is skipped.
|
||||
await act(
|
||||
async () => {
|
||||
await browser
|
||||
.elementByCss('input[data-link-accordion="/posts/1"]')
|
||||
.click()
|
||||
},
|
||||
{ includes: 'App shell for posts' }
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
// Click the link to /posts/124. This link is rendered with
|
||||
@@ -64,7 +61,7 @@ describe('App Shell prefetching', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('reuses the app shell across different search param values', async () => {
|
||||
it('skips the per-link Speculative prefetch for a non-eager (force-runtime) route', async () => {
|
||||
let page: Playwright.Page
|
||||
const browser = await next.browser('/', {
|
||||
beforePageLoad(p: Playwright.Page) {
|
||||
@@ -73,64 +70,166 @@ describe('App Shell prefetching', () => {
|
||||
})
|
||||
const act = createRouterAct(page)
|
||||
|
||||
// Reveal the LinkAccordion for /posts/1. This caches the App Shell.
|
||||
// Because the App Shell does not depend on params or search params,
|
||||
// it should be reusable across all combinations of either.
|
||||
await act(async () => {
|
||||
await browser
|
||||
.elementByCss('input[data-link-accordion="/posts/1"]')
|
||||
.click()
|
||||
}, [
|
||||
{ includes: 'App shell for posts' },
|
||||
{ includes: 'App shell for posts' },
|
||||
])
|
||||
|
||||
// Navigate to /posts/125?foo=bar — a different param AND a different
|
||||
// search-params value than what was prefetched. The cached App Shell
|
||||
// should be reused since it doesn't vary on either.
|
||||
await act(async () => {
|
||||
await browser.elementByCss('a[href="/posts/125?foo=bar"]').click()
|
||||
|
||||
// While the navigation response is blocked, the cached App Shell
|
||||
// should already be visible.
|
||||
expect(await browser.elementById('shell').text()).toEqual(
|
||||
'App shell for posts'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('issues a concrete prefetch for a second link with a different param, even though the shell is already cached', async () => {
|
||||
let page: Playwright.Page
|
||||
const browser = await next.browser('/', {
|
||||
beforePageLoad(p: Playwright.Page) {
|
||||
page = p
|
||||
// Reveal /posts/1 (default/auto prefetch). The route is force-runtime, which
|
||||
// is NOT eager, so under App Shells only the shared App Shell is prefetched.
|
||||
await act(
|
||||
async () => {
|
||||
await browser
|
||||
.elementByCss('input[data-link-accordion="/posts/1"]')
|
||||
.click()
|
||||
},
|
||||
})
|
||||
const act = createRouterAct(page)
|
||||
{ includes: 'App shell for posts' }
|
||||
)
|
||||
|
||||
// Reveal the LinkAccordion for /posts/1. The "App shell for posts"
|
||||
// substring appears in two responses: one for the App Shell itself
|
||||
// and one for the per-link prefetch of /posts/1 (which, for a
|
||||
// dynamic page, also includes the shell content above its inner
|
||||
// Suspense fallback).
|
||||
await act(async () => {
|
||||
await browser
|
||||
.elementByCss('input[data-link-accordion="/posts/1"]')
|
||||
.click()
|
||||
}, [
|
||||
{ includes: 'App shell for posts' },
|
||||
{ includes: 'App shell for posts' },
|
||||
])
|
||||
|
||||
// Reveal the LinkAccordion for /posts/2. The App Shell is already
|
||||
// cached and reused, but a per-link prefetch should still fire for
|
||||
// /posts/2 so that its param-specific content lands in the cache.
|
||||
// We assert on that param-specific content to confirm.
|
||||
// Reveal /posts/2 — a different param that shares the same App Shell. The
|
||||
// shell is already cached and the per-link Speculative prefetch is skipped,
|
||||
// so this fires NO requests at all. This is the clearest signal that the
|
||||
// Speculative phase was skipped: a subsequent link to the same route needs
|
||||
// nothing from the server.
|
||||
await act(async () => {
|
||||
await browser
|
||||
.elementByCss('input[data-link-accordion="/posts/2"]')
|
||||
.click()
|
||||
}, [{ includes: 'Post 2' }])
|
||||
}, 'no-requests')
|
||||
})
|
||||
|
||||
it('skips the per-link Speculative prefetch for a route with unstable_prefetch = "partial"', async () => {
|
||||
let page: Playwright.Page
|
||||
const browser = await next.browser('/', {
|
||||
beforePageLoad(p: Playwright.Page) {
|
||||
page = p
|
||||
},
|
||||
})
|
||||
const act = createRouterAct(page)
|
||||
|
||||
// Reveal /partial/1. /partial/[id] is fully static and opts into Partial
|
||||
// Prefetching, so this prefetches the shared app shell ("Partial app
|
||||
// shell"). We assert on the shell text — not the page content. (A fully
|
||||
// static prerender can't be truncated, so this response also happens to
|
||||
// carry "Partial post 1", but that's incidental to how static prerenders
|
||||
// work, not part of the App Shells model, so we don't assert on it.)
|
||||
await act(
|
||||
async () => {
|
||||
await browser
|
||||
.elementByCss('input[data-link-accordion="/partial/1"]')
|
||||
.click()
|
||||
},
|
||||
{ includes: 'Partial app shell' }
|
||||
)
|
||||
|
||||
// Reveal /partial/2 — a different param that shares the same app shell. The
|
||||
// shell is already cached and the per-link Speculative prefetch is skipped,
|
||||
// so this fires NO requests at all. This is the clearest signal that the
|
||||
// Speculative phase was skipped: a subsequent link to the same route needs
|
||||
// nothing from the server.
|
||||
await act(async () => {
|
||||
await browser
|
||||
.elementByCss('input[data-link-accordion="/partial/2"]')
|
||||
.click()
|
||||
}, 'no-requests')
|
||||
})
|
||||
|
||||
it('does NOT skip the Speculative prefetch for a route with unstable_prefetch = "unstable_eager"', async () => {
|
||||
let page: Playwright.Page
|
||||
const browser = await next.browser('/', {
|
||||
beforePageLoad(p: Playwright.Page) {
|
||||
page = p
|
||||
},
|
||||
})
|
||||
const act = createRouterAct(page)
|
||||
|
||||
// Reveal /eager/1. /eager/[id] opts into Partial Prefetching in "eager"
|
||||
// mode, so this primes the shared app shell. (Because the route is eager it
|
||||
// also speculatively prefetches param 1 here, but the assertion that
|
||||
// demonstrates the eager behavior is on the second link below, where the
|
||||
// shell is already cached and only the Speculative prefetch can fire.)
|
||||
await act(async () => {
|
||||
await browser
|
||||
.elementByCss('input[data-link-accordion="/eager/1"]')
|
||||
.click()
|
||||
})
|
||||
|
||||
// Reveal /eager/2 — a different param that shares the same app shell. The
|
||||
// shell is already cached, so it is NOT re-fetched. Because the route is
|
||||
// eager, the per-link Speculative prefetch fires for param 2 — a single
|
||||
// request carrying that param's content ("Eager post 2"). This is the
|
||||
// counterpart to the partial route's second link, which fired no requests:
|
||||
// an eager route keeps speculatively prefetching each new param.
|
||||
await act(
|
||||
async () => {
|
||||
await browser
|
||||
.elementByCss('input[data-link-accordion="/eager/2"]')
|
||||
.click()
|
||||
},
|
||||
{ includes: 'Eager post 2' }
|
||||
)
|
||||
})
|
||||
|
||||
it('treats a segment with both unstable_instant and unstable_prefetch = "unstable_eager" as eager', async () => {
|
||||
let page: Playwright.Page
|
||||
const browser = await next.browser('/', {
|
||||
beforePageLoad(p: Playwright.Page) {
|
||||
page = p
|
||||
},
|
||||
})
|
||||
const act = createRouterAct(page)
|
||||
|
||||
// /eager-instant/[id] sets BOTH unstable_instant (which alone behaves like
|
||||
// 'partial' — not eager) and unstable_prefetch = 'unstable_eager'. The eager
|
||||
// opt-in wins, so the segment is treated as eager. Same two-link pattern as
|
||||
// the plain eager test: the first link primes the shared shell...
|
||||
await act(async () => {
|
||||
await browser
|
||||
.elementByCss('input[data-link-accordion="/eager-instant/1"]')
|
||||
.click()
|
||||
})
|
||||
|
||||
// ...and the second link (different param, shell already cached) fires the
|
||||
// per-link Speculative prefetch for param 2, proving the route is treated as
|
||||
// eager rather than skipping the Speculative phase.
|
||||
await act(
|
||||
async () => {
|
||||
await browser
|
||||
.elementByCss('input[data-link-accordion="/eager-instant/2"]')
|
||||
.click()
|
||||
},
|
||||
{ includes: 'Eager-instant post 2' }
|
||||
)
|
||||
})
|
||||
|
||||
it('does NOT skip the Speculative prefetch for a prefetch={true} link, even on a partial route', async () => {
|
||||
let page: Playwright.Page
|
||||
const browser = await next.browser('/', {
|
||||
beforePageLoad(p: Playwright.Page) {
|
||||
page = p
|
||||
},
|
||||
})
|
||||
const act = createRouterAct(page)
|
||||
|
||||
// Reveal /partial/1 (default). /partial/[id] opts into Partial Prefetching,
|
||||
// so the default link primes the shared shell and skips the Speculative
|
||||
// prefetch (asserted by the other partial test). Here we just prime.
|
||||
await act(async () => {
|
||||
await browser
|
||||
.elementByCss('input[data-link-accordion="/partial/1"]')
|
||||
.click()
|
||||
})
|
||||
|
||||
// Reveal /partial/3 — a different param, but this link is prefetch={true}
|
||||
// (a Full prefetch). prefetch={true} always prefetches the route's segments,
|
||||
// bypassing the App Shells skip. The shell is already cached, so the only
|
||||
// request is the Speculative prefetch for param 3, carrying its content
|
||||
// ("Partial post 3"). Contrast with the default partial link, whose second
|
||||
// link fires no requests: prefetch={true} opts back into per-link
|
||||
// prefetching even on a partial route.
|
||||
await act(
|
||||
async () => {
|
||||
await browser
|
||||
.elementByCss('input[data-link-accordion="/partial/3"]')
|
||||
.click()
|
||||
},
|
||||
{ includes: 'Partial post 3' }
|
||||
)
|
||||
})
|
||||
|
||||
it('extracts the App Shell from a fully-static prerender response', async () => {
|
||||
|
||||
Reference in New Issue
Block a user