mirror of
https://github.com/hanzoai/next.js.git
synced 2026-09-24 17:00:51 +00:00
instant(): Only render shell, unless prefetch prop is set (#95150)
When using the instant() API, or the Navigation Inspector, the prefetched state should match what a user is most likely to see in production with a warm cache. So, when Partial Prefetching is enabled, and the Link does not have a prefetch prop, only the shell should be allowed to render. Before this PR, the _entire_ prefetched state would display, regardless of the prefetch configuration of the route and the link. This is essential to prevent false negatives in tests that assert on the prefetched state: the test must not depend on data that wouldn't be prefetched in a real production environment. As part of the fix, I also updated the logic to ignore any cache entries that were already present before the `instant()` lock was acquired, to ensure the test is not "polluted" by earlier prefetches or navigations. Theoretically we could optimize this in the future but this is the cleanest way to ensure this property for now. <!-- NEXT_JS_LLM_PR -->
This commit is contained in:
@@ -18,7 +18,7 @@ export const RSC_CONTENT_TYPE_HEADER = 'text/x-component' as const
|
||||
|
||||
// Cookie for the Instant Navigation Testing API. Sent automatically with all
|
||||
// requests while a navigation lock is held; the server uses its presence to
|
||||
// render only the static shell. Not exposed in production builds by default.
|
||||
// render only the shell. Not exposed in production builds by default.
|
||||
export const NEXT_INSTANT_TEST_COOKIE =
|
||||
'next-instant-navigation-testing' as const
|
||||
|
||||
|
||||
@@ -335,7 +335,8 @@ function rescheduleLinkPrefetch(
|
||||
treeAtTimeOfPrefetch,
|
||||
instance.fetchStrategy,
|
||||
priority,
|
||||
null
|
||||
null,
|
||||
null // navigationLockPrefetch
|
||||
)
|
||||
} else {
|
||||
// We already have an old task object that we can reschedule. This is
|
||||
@@ -380,7 +381,8 @@ export function pingVisibleLinks(
|
||||
tree,
|
||||
instance.fetchStrategy,
|
||||
PrefetchPriority.Default,
|
||||
null
|
||||
null,
|
||||
null // navigationLockPrefetch
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
NOT_FOUND_SEGMENT_KEY,
|
||||
} from '../../../shared/lib/segment'
|
||||
import { matchSegment } from '../match-segments'
|
||||
import type { NavigationLockState } from '../segment-cache/navigation-testing-lock'
|
||||
import { createHrefFromUrl } from './create-href-from-url'
|
||||
import { fetchServerResponse } from './fetch-server-response'
|
||||
import { dispatchAppRouterAction } from '../use-action-queue'
|
||||
@@ -127,7 +128,7 @@ export type NavigationRequestAccumulation = {
|
||||
scrollRef: ScrollRef | null
|
||||
}
|
||||
|
||||
export type NavigationLock = Promise<void> | null
|
||||
export type NavigationLock = NavigationLockState | null
|
||||
|
||||
const noop = () => {}
|
||||
|
||||
@@ -144,6 +145,7 @@ export function createInitialCacheNodeForHydration(
|
||||
separateRefreshUrls: null,
|
||||
scrollRef: null,
|
||||
}
|
||||
const restrictToShell = false
|
||||
const task = createCacheNodeOnNavigation(
|
||||
navigatedAt,
|
||||
initialTree,
|
||||
@@ -153,7 +155,8 @@ export function createInitialCacheNodeForHydration(
|
||||
seedHead,
|
||||
seedDynamicStaleAt,
|
||||
false,
|
||||
accumulation
|
||||
accumulation,
|
||||
restrictToShell
|
||||
)
|
||||
return task
|
||||
}
|
||||
@@ -200,7 +203,10 @@ export function startPPRNavigation(
|
||||
seedHead: HeadData | null,
|
||||
seedDynamicStaleAt: number,
|
||||
isSamePageNavigation: boolean,
|
||||
accumulation: NavigationRequestAccumulation
|
||||
accumulation: NavigationRequestAccumulation,
|
||||
// Instant Navigation Testing API only — restricts segment reads to shell
|
||||
// entries. Always false outside the testing API. See navigation-testing-lock.
|
||||
restrictToShell: boolean
|
||||
): NavigationTask | null {
|
||||
const parentNeedsDynamicRequest = false
|
||||
const parentRefreshState = null
|
||||
@@ -223,7 +229,8 @@ export function startPPRNavigation(
|
||||
parentNeedsDynamicRequest,
|
||||
oldRootRefreshState,
|
||||
parentRefreshState,
|
||||
accumulation
|
||||
accumulation,
|
||||
restrictToShell
|
||||
)
|
||||
}
|
||||
|
||||
@@ -242,7 +249,10 @@ function updateCacheNodeOnNavigation(
|
||||
parentNeedsDynamicRequest: boolean,
|
||||
oldRootRefreshState: RefreshState,
|
||||
parentRefreshState: RefreshState | null,
|
||||
accumulation: NavigationRequestAccumulation
|
||||
accumulation: NavigationRequestAccumulation,
|
||||
// Instant Navigation Testing API only — restricts segment reads to shell
|
||||
// entries. Always false outside the testing API. See navigation-testing-lock.
|
||||
restrictToShell: boolean
|
||||
): NavigationTask | null {
|
||||
// Check if this segment matches the one in the previous route. A
|
||||
// search-param-only difference at a page segment falls through to the
|
||||
@@ -302,7 +312,8 @@ function updateCacheNodeOnNavigation(
|
||||
seedHead,
|
||||
seedDynamicStaleAt,
|
||||
parentNeedsDynamicRequest,
|
||||
accumulation
|
||||
accumulation,
|
||||
restrictToShell
|
||||
)
|
||||
}
|
||||
|
||||
@@ -371,7 +382,8 @@ function updateCacheNodeOnNavigation(
|
||||
// route hasn't changed. Otherwise (no prior node) mint a fresh one.
|
||||
oldCacheNode !== undefined
|
||||
? oldCacheNode.bfcacheId
|
||||
: generateBFCacheId(freshness)
|
||||
: generateBFCacheId(freshness),
|
||||
restrictToShell
|
||||
)
|
||||
newCacheNode = result.cacheNode
|
||||
needsDynamicRequest = result.needsDynamicRequest
|
||||
@@ -515,7 +527,8 @@ function updateCacheNodeOnNavigation(
|
||||
parentNeedsDynamicRequest || needsDynamicRequest,
|
||||
oldRootRefreshState,
|
||||
refreshState,
|
||||
accumulation
|
||||
accumulation,
|
||||
restrictToShell
|
||||
)
|
||||
|
||||
if (taskChild === null) {
|
||||
@@ -627,7 +640,10 @@ function createCacheNodeOnNavigation(
|
||||
seedHead: HeadData | null,
|
||||
seedDynamicStaleAt: number,
|
||||
parentNeedsDynamicRequest: boolean,
|
||||
accumulation: NavigationRequestAccumulation
|
||||
accumulation: NavigationRequestAccumulation,
|
||||
// Instant Navigation Testing API only — restricts segment reads to shell
|
||||
// entries. Always false outside the testing API. See navigation-testing-lock.
|
||||
restrictToShell: boolean
|
||||
): NavigationTask {
|
||||
// Same traversal as updateCacheNodeNavigation, but simpler. We switch to this
|
||||
// path once we reach the part of the tree that was not in the previous route.
|
||||
@@ -655,7 +671,8 @@ function createCacheNodeOnNavigation(
|
||||
seedDynamicStaleAt,
|
||||
// This segment was not part of the previous route, so mint a fresh
|
||||
// bfcacheId.
|
||||
generateBFCacheId(freshness)
|
||||
generateBFCacheId(freshness),
|
||||
restrictToShell
|
||||
)
|
||||
const newCacheNode = result.cacheNode
|
||||
const needsDynamicRequest = result.needsDynamicRequest
|
||||
@@ -693,7 +710,8 @@ function createCacheNodeOnNavigation(
|
||||
seedHead,
|
||||
seedDynamicStaleAt,
|
||||
parentNeedsDynamicRequest || needsDynamicRequest,
|
||||
accumulation
|
||||
accumulation,
|
||||
restrictToShell
|
||||
)
|
||||
|
||||
taskChildren.set(parallelRouteKey, taskChild)
|
||||
@@ -918,7 +936,10 @@ function createCacheNodeForSegment(
|
||||
seedHead: HeadData | null,
|
||||
freshness: FreshnessPolicy,
|
||||
dynamicStaleAt: number,
|
||||
bfcacheId: number
|
||||
bfcacheId: number,
|
||||
// Instant Navigation Testing API only — restricts segment reads to shell
|
||||
// entries. Always false outside the testing API. See navigation-testing-lock.
|
||||
restrictToShell: boolean
|
||||
): { cacheNode: CacheNode; needsDynamicRequest: boolean } {
|
||||
// Construct a new CacheNode using data from the BFCache, the client's
|
||||
// Segment Cache, or seeded from a server response.
|
||||
@@ -1064,7 +1085,11 @@ function createCacheNodeForSegment(
|
||||
let cachedRsc: React.ReactNode | null = null
|
||||
let isCachedRscPartial: boolean = true
|
||||
|
||||
const segmentEntry = readSegmentCacheEntryForNavigation(now, tree.varyPath)
|
||||
const segmentEntry = readSegmentCacheEntryForNavigation(
|
||||
now,
|
||||
tree.varyPath,
|
||||
restrictToShell
|
||||
)
|
||||
if (segmentEntry !== null) {
|
||||
switch (segmentEntry.status) {
|
||||
case EntryStatus.Fulfilled: {
|
||||
@@ -1169,7 +1194,8 @@ function createCacheNodeForSegment(
|
||||
if (metadataVaryPath !== null) {
|
||||
const metadataEntry = readSegmentCacheEntryForNavigation(
|
||||
now,
|
||||
metadataVaryPath
|
||||
metadataVaryPath,
|
||||
restrictToShell
|
||||
)
|
||||
if (metadataEntry !== null) {
|
||||
switch (metadataEntry.status) {
|
||||
|
||||
@@ -73,7 +73,9 @@ export function restoreReducer(
|
||||
null,
|
||||
restoreSeed.dynamicStaleAt,
|
||||
false,
|
||||
accumulation
|
||||
accumulation,
|
||||
// A history-traversal restore never restricts to the shell.
|
||||
false
|
||||
)
|
||||
|
||||
if (task === null) {
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
type PrefetchTask,
|
||||
type PrefetchSubtaskResult,
|
||||
} from './scheduler'
|
||||
import type { NavigationLockPrefetch } from './navigation-testing-lock'
|
||||
import {
|
||||
type RouteVaryPath,
|
||||
type SegmentVaryPath,
|
||||
@@ -513,9 +514,65 @@ export function readSegmentCacheEntry(
|
||||
*/
|
||||
export function readSegmentCacheEntryForNavigation(
|
||||
now: number,
|
||||
varyPath: SegmentVaryPath
|
||||
varyPath: SegmentVaryPath,
|
||||
restrictToShell: boolean = false
|
||||
): SegmentCacheEntry | null {
|
||||
const isRevalidation = false
|
||||
|
||||
if (process.env.__NEXT_EXPOSE_TESTING_API) {
|
||||
const { getCurrentNavigationLock } =
|
||||
require('./navigation-testing-lock') as typeof import('./navigation-testing-lock')
|
||||
const lock = getCurrentNavigationLock()
|
||||
if (lock !== null) {
|
||||
// Instant Navigation Testing API
|
||||
//
|
||||
// Modify the lookup logic to simulate the behavior that we would expect
|
||||
// to mostly realistically happen in a production environment with a
|
||||
// warm prefetch cache.
|
||||
|
||||
// If restrictToShell is true, it means we're navigating to a link that
|
||||
// 1) has Partial Prefetching enabled, and 2) does not have a prefetch
|
||||
// prop set. We should only allow the shell to render, not anything that
|
||||
// varies on concrete route params.
|
||||
const lookupVaryPath = restrictToShell
|
||||
? getShellSegmentVaryPath(varyPath)
|
||||
: varyPath
|
||||
|
||||
// To prevent the test navigation from being "polluted" by earlier
|
||||
// prefetches, we'll also only match entries that were created during
|
||||
// the current lock scope. This is tracked by the `ownedEntries` set.
|
||||
const ownedEntries = lock.ownedEntries
|
||||
|
||||
// Besides that, the rest of the logic is the same as production.
|
||||
const fulfilled = getFromCacheMap(
|
||||
now,
|
||||
getCurrentSegmentCacheVersion(),
|
||||
segmentCacheMap,
|
||||
lookupVaryPath,
|
||||
isRevalidation,
|
||||
true
|
||||
)
|
||||
if (fulfilled !== null && ownedEntries.has(fulfilled)) {
|
||||
return fulfilled
|
||||
}
|
||||
const entry = getFromCacheMap(
|
||||
now,
|
||||
getCurrentSegmentCacheVersion(),
|
||||
segmentCacheMap,
|
||||
lookupVaryPath,
|
||||
isRevalidation,
|
||||
false
|
||||
)
|
||||
if (entry !== null && ownedEntries.has(entry)) {
|
||||
return entry
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer a Fulfilled entry (e.g. a cached shell) over a more-specific
|
||||
// Pending/Rejected one so it renders immediately instead of blocking on an
|
||||
// in-flight entry.
|
||||
const fulfilled = getFromCacheMap(
|
||||
now,
|
||||
getCurrentSegmentCacheVersion(),
|
||||
@@ -807,12 +864,34 @@ function deprecated_createOptimisticRouteTree(
|
||||
export function readOrCreateSegmentCacheEntry(
|
||||
now: number,
|
||||
fetchStrategy: FetchStrategy,
|
||||
tree: RouteTree
|
||||
tree: RouteTree,
|
||||
// Non-null when this read is part of a locked navigation's prefetch (Instant
|
||||
// Navigation Testing API only; always null in production). See below.
|
||||
navigationLockPrefetch: NavigationLockPrefetch | null
|
||||
): SegmentCacheEntry {
|
||||
const existingEntry = readSegmentCacheEntry(now, tree.varyPath)
|
||||
if (existingEntry !== null) {
|
||||
return existingEntry
|
||||
if (
|
||||
process.env.__NEXT_EXPOSE_TESTING_API &&
|
||||
navigationLockPrefetch !== null
|
||||
) {
|
||||
// Locked navigation: ignore entries that predate the lock so each
|
||||
// navigation reads only data (re)fetched within the lock scope — a
|
||||
// "clean read." But an entry we already created within this scope is
|
||||
// reused like normal; otherwise the prefetch would discard the entry it
|
||||
// just fetched on every scheduler pass and refetch forever. See
|
||||
// navigation-testing-lock.ts.
|
||||
const { getCurrentNavigationLock } =
|
||||
require('./navigation-testing-lock') as typeof import('./navigation-testing-lock')
|
||||
const lock = getCurrentNavigationLock()
|
||||
if (lock !== null && lock.ownedEntries.has(existingEntry)) {
|
||||
return existingEntry
|
||||
}
|
||||
} else {
|
||||
return existingEntry
|
||||
}
|
||||
}
|
||||
// No reusable entry, or a locked navigation discarding a pre-lock entry.
|
||||
// Create a pending entry and add it to the cache. The stale time is set to a
|
||||
// default value; the actual stale time will be set when the entry is
|
||||
// fulfilled with data from the server response.
|
||||
@@ -981,12 +1060,21 @@ export function createDetachedSegmentCacheEntry(
|
||||
staleAt,
|
||||
version: 0,
|
||||
}
|
||||
if (process.env.__NEXT_EXPOSE_TESTING_API) {
|
||||
// Instant Navigation Testing API: mark entries created during a lock scope
|
||||
// as owned, so locked navigations match only data (re)fetched within the
|
||||
// scope. No-op when no lock is held (always in production).
|
||||
const { recordNavigationLockOwnedEntry } =
|
||||
require('./navigation-testing-lock') as typeof import('./navigation-testing-lock')
|
||||
recordNavigationLockOwnedEntry(emptyEntry)
|
||||
}
|
||||
return emptyEntry
|
||||
}
|
||||
|
||||
export function upgradeToPendingSegment(
|
||||
emptyEntry: EmptySegmentCacheEntry,
|
||||
fetchStrategy: FetchStrategy
|
||||
fetchStrategy: FetchStrategy,
|
||||
navigationLockPrefetch: NavigationLockPrefetch | null
|
||||
): PendingSegmentCacheEntry {
|
||||
const pendingEntry: PendingSegmentCacheEntry = emptyEntry as any
|
||||
pendingEntry.status = EntryStatus.Pending
|
||||
@@ -1005,6 +1093,23 @@ export function upgradeToPendingSegment(
|
||||
// than when receiving the response, because it's guaranteed to happen
|
||||
// before the data is read on the server.
|
||||
pendingEntry.version = getCurrentSegmentCacheVersion()
|
||||
|
||||
if (
|
||||
process.env.__NEXT_EXPOSE_TESTING_API &&
|
||||
// Instant Navigation Testing API only. Non-null when the requesting
|
||||
// prefetch is driving a locked navigation, in which case the
|
||||
// freshly-spawned pending entry is tracked against that navigation's
|
||||
// prefetch state so the navigation waits for it to fulfill before reading
|
||||
// it. Null at non-scheduler call sites (BFCache fulfillment, response
|
||||
// processing), which don't spawn an in-flight request to wait on, and
|
||||
// always in production.
|
||||
navigationLockPrefetch !== null
|
||||
) {
|
||||
const { trackNavigationLockPrefetchEntry } =
|
||||
require('./navigation-testing-lock') as typeof import('./navigation-testing-lock')
|
||||
trackNavigationLockPrefetchEntry(navigationLockPrefetch, pendingEntry)
|
||||
}
|
||||
|
||||
return pendingEntry
|
||||
}
|
||||
|
||||
@@ -1038,7 +1143,13 @@ export function attemptToFulfillDynamicSegmentFromBFCache(
|
||||
return null
|
||||
}
|
||||
|
||||
const pendingSegment = upgradeToPendingSegment(segment, FetchStrategy.Full)
|
||||
const pendingSegment = upgradeToPendingSegment(
|
||||
segment,
|
||||
FetchStrategy.Full,
|
||||
// Fulfilled synchronously from the BFCache; nothing for a locked
|
||||
// navigation to wait on.
|
||||
null
|
||||
)
|
||||
const isPartial = false
|
||||
return fulfillSegmentCacheEntry(
|
||||
pendingSegment,
|
||||
@@ -1072,7 +1183,10 @@ export function attemptToUpgradeSegmentFromBFCache(
|
||||
}
|
||||
const pendingSegment = upgradeToPendingSegment(
|
||||
createDetachedSegmentCacheEntry(now),
|
||||
FetchStrategy.Full
|
||||
FetchStrategy.Full,
|
||||
// Fulfilled synchronously from the BFCache; nothing for a locked
|
||||
// navigation to wait on.
|
||||
null
|
||||
)
|
||||
const isPartial = false
|
||||
const newEntry = fulfillSegmentCacheEntry(
|
||||
@@ -2250,7 +2364,8 @@ function writeSegmentBundleResponse(
|
||||
// to upsert it into the canonical slot.
|
||||
const detachedEntry = createDetachedSegmentCacheEntry(now)
|
||||
fulfilled = fulfillSegmentCacheEntry(
|
||||
upgradeToPendingSegment(detachedEntry, FetchStrategy.PPR),
|
||||
// Response-write path, not a locked-navigation prefetch.
|
||||
upgradeToPendingSegment(detachedEntry, FetchStrategy.PPR, null),
|
||||
data.rsc,
|
||||
entryStaleAt,
|
||||
data.isPartial,
|
||||
@@ -3036,17 +3151,20 @@ function fulfillEntrySpawnedByRuntimePrefetch(
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// There's no matching entry. Attempt to create a new one.
|
||||
// There's no matching entry. Attempt to create a new one. This is a
|
||||
// response-write path, not a locked-navigation prefetch.
|
||||
const possiblyNewEntry = readOrCreateSegmentCacheEntry(
|
||||
now,
|
||||
fetchStrategy,
|
||||
tree
|
||||
tree,
|
||||
null
|
||||
)
|
||||
if (possiblyNewEntry.status === EntryStatus.Empty) {
|
||||
// Confirmed this is a new entry. We can fulfill it.
|
||||
const newEntry = possiblyNewEntry
|
||||
const fulfilledEntry = fulfillSegmentCacheEntry(
|
||||
upgradeToPendingSegment(newEntry, fetchStrategy),
|
||||
// Response-write path, not a locked-navigation prefetch.
|
||||
upgradeToPendingSegment(newEntry, fetchStrategy, null),
|
||||
rsc,
|
||||
staleAt,
|
||||
isPartial,
|
||||
@@ -3068,7 +3186,9 @@ function fulfillEntrySpawnedByRuntimePrefetch(
|
||||
const newEntry = fulfillSegmentCacheEntry(
|
||||
upgradeToPendingSegment(
|
||||
createDetachedSegmentCacheEntry(now),
|
||||
fetchStrategy
|
||||
fetchStrategy,
|
||||
// Response-write path, not a locked-navigation prefetch.
|
||||
null
|
||||
),
|
||||
rsc,
|
||||
staleAt,
|
||||
|
||||
@@ -11,12 +11,20 @@
|
||||
* captured = self-write (ignored).
|
||||
*/
|
||||
|
||||
import type {
|
||||
FlightRouterState,
|
||||
InstantCookie,
|
||||
import {
|
||||
PrefetchHint,
|
||||
type FlightRouterState,
|
||||
type InstantCookie,
|
||||
} from '../../../shared/lib/app-router-types'
|
||||
import { NEXT_INSTANT_TEST_COOKIE } from '../app-router-headers'
|
||||
import { refreshOnInstantNavigationUnlock } from '../use-action-queue'
|
||||
import { subtreeHasSpeculativePrefetch } from './scheduler'
|
||||
import {
|
||||
waitForSegmentCacheEntry,
|
||||
type PendingSegmentCacheEntry,
|
||||
type SegmentCacheEntry,
|
||||
} from './cache'
|
||||
import type { FetchStrategy } from './types'
|
||||
|
||||
type InstantNavCookieState = 'empty' | 'pending' | 'mpa' | 'spa'
|
||||
|
||||
@@ -68,14 +76,48 @@ function writeCookieValue(value: InstantCookie): void {
|
||||
})
|
||||
}
|
||||
|
||||
type NavigationLockState = {
|
||||
/**
|
||||
* The "wait for the locked navigation's prefetch to fulfill" state for a single
|
||||
* locked navigation. `promise` resolves once that prefetch has spawned every
|
||||
* request and all of them have fulfilled, so the navigation reads present data
|
||||
* rather than a still-in-flight entry. Owned by the prefetch task (one per
|
||||
* navigation, so successive navigations in a scope resolve independently) and
|
||||
* also tracked in `NavigationLockState.activePrefetches` so the lock can
|
||||
* force-resolve any that are still pending when it's released.
|
||||
*
|
||||
* `pendingCount` holds one reference for the scheduler while it is still
|
||||
* spawning, plus one per in-flight entry; `promise` resolves when it drains to
|
||||
* 0. `trackedEntries` dedupes entry registration.
|
||||
*/
|
||||
export type NavigationLockPrefetch = {
|
||||
promise: Promise<void>
|
||||
resolve: () => void
|
||||
pendingCount: number
|
||||
trackedEntries: Set<PendingSegmentCacheEntry>
|
||||
}
|
||||
|
||||
export type NavigationLockState = {
|
||||
// Resolves when the lock is released (the testing scope ends). The dynamic-
|
||||
// data write during a locked navigation waits on this; see
|
||||
// `getCurrentNavigationLock` and `waitForNavigationLockIfActive`.
|
||||
released: Promise<void>
|
||||
resolveReleased: () => void
|
||||
// The pre-lock `window.fetch`, captured at `acquireLock` time and
|
||||
// restored at `releaseLock`. Internal Next.js code reads this via
|
||||
// `getPreLockFetch` to bypass the override we install on `window.fetch`
|
||||
// during a lock scope.
|
||||
fetch: typeof fetch
|
||||
// Every prefetch-completion state for this scope that hasn't resolved yet.
|
||||
// A prefetch removes itself when it drains; on release, any still here are
|
||||
// force-resolved so no navigation hangs waiting on a prefetch that the scope
|
||||
// ended before it could finish.
|
||||
activePrefetches: Set<NavigationLockPrefetch>
|
||||
// Every segment entry that was (re)fetched within this lock scope. Navigation
|
||||
// reads are restricted to these, so each instant() navigation observes only
|
||||
// data fetched under the lock — a "clean read" — and never matches a stale
|
||||
// entry left in the cache by an earlier navigation or prefetch. See
|
||||
// `readSegmentCacheEntryForNavigation`.
|
||||
ownedEntries: Set<SegmentCacheEntry>
|
||||
}
|
||||
|
||||
let lockState: NavigationLockState | null = null
|
||||
@@ -84,15 +126,121 @@ export function getPreLockFetch(): typeof fetch | null {
|
||||
return lockState !== null ? lockState.fetch : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the "wait for prefetch to fulfill" state for one locked navigation,
|
||||
* registers it on the current lock, and returns it (the caller stores it on the
|
||||
* prefetch task and awaits `.promise`). Returns null if no lock is held.
|
||||
*
|
||||
* `pendingCount` starts at 1, representing the scheduler itself while it is
|
||||
* still spawning requests; that reference is released by
|
||||
* `finishNavigationLockPrefetchSpawning`. Each spawned pending entry adds
|
||||
* another (see `trackNavigationLockPrefetchEntry`). `promise` resolves when the
|
||||
* count drains to 0 — i.e. spawning finished and every entry fulfilled.
|
||||
*/
|
||||
export function beginNavigationLockPrefetch(): NavigationLockPrefetch | null {
|
||||
if (process.env.__NEXT_EXPOSE_TESTING_API && lockState !== null) {
|
||||
let resolve: () => void
|
||||
const promise = new Promise<void>((r) => {
|
||||
resolve = r
|
||||
})
|
||||
const prefetch: NavigationLockPrefetch = {
|
||||
promise,
|
||||
resolve: resolve!,
|
||||
pendingCount: 1,
|
||||
trackedEntries: new Set(),
|
||||
}
|
||||
lockState.activePrefetches.add(prefetch)
|
||||
return prefetch
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a freshly-created segment entry as owned by the current lock scope, so
|
||||
* navigation reads will match it — and only entries created within the scope
|
||||
* (see `NavigationLockState.ownedEntries`). Called from
|
||||
* `createDetachedSegmentCacheEntry`, the single factory every creation path
|
||||
* funnels through, so re-keyed entries created during response processing (e.g.
|
||||
* a runtime prefetch resolving a concrete param) are owned too. No-op when no
|
||||
* lock is held.
|
||||
*/
|
||||
export function recordNavigationLockOwnedEntry(entry: SegmentCacheEntry): void {
|
||||
if (process.env.__NEXT_EXPOSE_TESTING_API && lockState !== null) {
|
||||
lockState.ownedEntries.add(entry)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by `upgradeToPendingSegment` whenever the locked-navigation prefetch
|
||||
* spawns a pending segment entry. Adds the entry to the prefetch's ref count and
|
||||
* decrements when it fulfills (or rejects — `waitForSegmentCacheEntry` resolves
|
||||
* to null). Deduped so the same entry never double-counts.
|
||||
*/
|
||||
export function trackNavigationLockPrefetchEntry(
|
||||
prefetch: NavigationLockPrefetch,
|
||||
entry: PendingSegmentCacheEntry
|
||||
): void {
|
||||
if (process.env.__NEXT_EXPOSE_TESTING_API) {
|
||||
if (prefetch.trackedEntries.has(entry)) {
|
||||
return
|
||||
}
|
||||
prefetch.trackedEntries.add(entry)
|
||||
prefetch.pendingCount++
|
||||
const onSettled = () => {
|
||||
prefetch.pendingCount--
|
||||
settleNavigationLockPrefetchIfDrained(prefetch)
|
||||
}
|
||||
// Decrement whether the entry fulfills or its request rejects, so a failed
|
||||
// segment can't leave the navigation waiting forever.
|
||||
waitForSegmentCacheEntry(entry).then(onSettled, onSettled)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called once the scheduler has finished spawning every request for the
|
||||
* locked-navigation prefetch, releasing the scheduler's reference from the ref
|
||||
* count. The prefetch resolves here if every spawned entry already fulfilled.
|
||||
*/
|
||||
export function finishNavigationLockPrefetchSpawning(
|
||||
prefetch: NavigationLockPrefetch
|
||||
): void {
|
||||
if (process.env.__NEXT_EXPOSE_TESTING_API) {
|
||||
prefetch.pendingCount--
|
||||
settleNavigationLockPrefetchIfDrained(prefetch)
|
||||
}
|
||||
}
|
||||
|
||||
function settleNavigationLockPrefetchIfDrained(
|
||||
prefetch: NavigationLockPrefetch
|
||||
): void {
|
||||
if (process.env.__NEXT_EXPOSE_TESTING_API) {
|
||||
if (prefetch.pendingCount === 0) {
|
||||
// Unregister from the lock (if still held) and resolve. Resolving is
|
||||
// idempotent, so it's safe even if the lock already force-resolved this on
|
||||
// release.
|
||||
if (lockState !== null) {
|
||||
lockState.activePrefetches.delete(prefetch)
|
||||
}
|
||||
prefetch.resolve()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function acquireLock(): void {
|
||||
if (lockState !== null) {
|
||||
return
|
||||
}
|
||||
let resolve: () => void
|
||||
const promise = new Promise<void>((r) => {
|
||||
resolve = r
|
||||
let resolveReleased: () => void
|
||||
const released = new Promise<void>((r) => {
|
||||
resolveReleased = r
|
||||
})
|
||||
lockState = { promise, resolve: resolve!, fetch: window.fetch }
|
||||
lockState = {
|
||||
released,
|
||||
resolveReleased: resolveReleased!,
|
||||
fetch: window.fetch,
|
||||
activePrefetches: new Set(),
|
||||
ownedEntries: new Set(),
|
||||
}
|
||||
|
||||
// Install the fetch blocker. We only intercept `window.fetch` for the
|
||||
// duration of the lock so that — outside of a testing scope — user-
|
||||
@@ -111,9 +259,15 @@ function releaseLock(): void {
|
||||
if (process.env.__NEXT_EXPOSE_TESTING_API) {
|
||||
window.fetch = lockState.fetch
|
||||
}
|
||||
const { resolve } = lockState
|
||||
const { resolveReleased, activePrefetches } = lockState
|
||||
lockState = null
|
||||
resolve()
|
||||
// Force-resolve every prefetch that hasn't finished, so a navigation still
|
||||
// waiting on one doesn't hang now that the scope is ending.
|
||||
for (const prefetch of activePrefetches) {
|
||||
prefetch.resolve()
|
||||
}
|
||||
// Resolve the release promise so a gated dynamic write unblocks too.
|
||||
resolveReleased()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -143,7 +297,7 @@ export function globalFetchOverride(
|
||||
// (rather than `window.fetch`) pins to the capture even if `window.fetch`
|
||||
// is reassigned after release.
|
||||
const currentLock = lockState
|
||||
return currentLock.promise.then(() => {
|
||||
return currentLock.released.then(() => {
|
||||
const preLockFetch = currentLock.fetch
|
||||
return preLockFetch(input, init)
|
||||
})
|
||||
@@ -157,7 +311,7 @@ export function globalFetchOverride(
|
||||
*/
|
||||
export function startListeningForInstantNavigationCookie(): void {
|
||||
if (process.env.__NEXT_EXPOSE_TESTING_API) {
|
||||
// If the server served a static shell, this is an MPA page load
|
||||
// If the server served a shell, this is an MPA page load
|
||||
// while the lock is held. Transition to captured-MPA and acquire.
|
||||
if (self.__next_instant_test) {
|
||||
if (typeof cookieStore !== 'undefined') {
|
||||
@@ -268,23 +422,53 @@ export function isNavigationLocked(): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
export function getCurrentNavigationLock(): Promise<void> | null {
|
||||
export function getCurrentNavigationLock(): NavigationLockState | null {
|
||||
if (process.env.__NEXT_EXPOSE_TESTING_API) {
|
||||
return lockState !== null ? lockState.promise : null
|
||||
return lockState
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides whether segment reads during a navigation should be restricted to
|
||||
* shell entries (every param substituted with Fallback) rather than matching
|
||||
* entries that vary on concrete route params.
|
||||
*
|
||||
* The testing tools (Navigation Inspector, instant()) simulate what a user
|
||||
* would see with a warm cache. When the lock is held, partial prefetching is
|
||||
* enabled for the target route, and no whole-route ("speculative") prefetch
|
||||
* would have been made, only the shell is prefetched — so that's all a
|
||||
* navigation should be allowed to match. A speculative prefetch happens for a
|
||||
* `<Link prefetch={true}>` or an eagerly-prefetched subtree, in which case the
|
||||
* concrete-param entry is genuinely warm and may be matched.
|
||||
*
|
||||
* Always returns false outside the testing API; the branch below is eliminated
|
||||
* from production bundles.
|
||||
*/
|
||||
export function shouldRestrictNavigationToShell(
|
||||
rootPrefetchHints: number,
|
||||
linkFetchStrategy: FetchStrategy
|
||||
): boolean {
|
||||
if (process.env.__NEXT_EXPOSE_TESTING_API) {
|
||||
return (
|
||||
isNavigationLocked() &&
|
||||
(rootPrefetchHints & PrefetchHint.SubtreeHasPartialPrefetching) !== 0 &&
|
||||
!subtreeHasSpeculativePrefetch(linkFetchStrategy, rootPrefetchHints)
|
||||
)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for the navigation lock to be released, if it's currently held.
|
||||
* No-op if the lock is not acquired.
|
||||
*/
|
||||
export async function waitForNavigationLockIfActive(
|
||||
lock: Promise<void> | null = getCurrentNavigationLock()
|
||||
lock: NavigationLockState | null = getCurrentNavigationLock()
|
||||
): Promise<void> {
|
||||
if (process.env.__NEXT_EXPOSE_TESTING_API) {
|
||||
if (lock !== null) {
|
||||
await lock
|
||||
await lock.released
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ export function navigate(
|
||||
// prefetch task has been initiated before proceeding with the navigation.
|
||||
// This guarantees that segment data requests are at least pending, even
|
||||
// for routes that already have a cached route tree. Without this, the
|
||||
// static shell might be incomplete because some segments were never
|
||||
// shell might be incomplete because some segments were never
|
||||
// requested.
|
||||
if (process.env.__NEXT_EXPOSE_TESTING_API) {
|
||||
const { isNavigationLocked } =
|
||||
@@ -288,6 +288,21 @@ export function navigateToKnownRoute(
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// Instant Navigation Testing API: when the lock is held, restrict segment
|
||||
// reads to shell entries if the target route would only have prefetched
|
||||
// its shell.
|
||||
let restrictToShell = false
|
||||
if (process.env.__NEXT_EXPOSE_TESTING_API) {
|
||||
const { shouldRestrictNavigationToShell } =
|
||||
require('./navigation-testing-lock') as typeof import('./navigation-testing-lock')
|
||||
const link = getLinkForCurrentNavigation()
|
||||
restrictToShell = shouldRestrictNavigationToShell(
|
||||
navigationSeed.routeTree.prefetchHints,
|
||||
link !== null ? link.fetchStrategy : FetchStrategy.PPR
|
||||
)
|
||||
}
|
||||
|
||||
const accumulation: NavigationRequestAccumulation = {
|
||||
separateRefreshUrls: null,
|
||||
scrollRef: null,
|
||||
@@ -324,7 +339,8 @@ export function navigateToKnownRoute(
|
||||
navigationSeed.head,
|
||||
navigationSeed.dynamicStaleAt,
|
||||
isSamePageNavigation,
|
||||
accumulation
|
||||
accumulation,
|
||||
restrictToShell
|
||||
)
|
||||
if (task !== null) {
|
||||
if (freshnessPolicy !== FreshnessPolicy.Gesture) {
|
||||
@@ -1055,16 +1071,25 @@ async function ensurePrefetchThenNavigate(
|
||||
|
||||
const cacheKey = createCacheKey(url.href, nextUrl)
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
schedulePrefetchTask(
|
||||
cacheKey,
|
||||
currentFlightRouterState,
|
||||
fetchStrategy,
|
||||
PrefetchPriority.Default,
|
||||
null, // onInvalidate
|
||||
resolve // _onComplete callback
|
||||
)
|
||||
})
|
||||
// Create this navigation's "wait for prefetch to fulfill" state and schedule
|
||||
// the prefetch as a locked-navigation prefetch. The prefetch's promise
|
||||
// resolves once it has spawned every request and all of them have fulfilled,
|
||||
// so the navigation below reads present data rather than a still-in-flight
|
||||
// entry.
|
||||
const { beginNavigationLockPrefetch } =
|
||||
require('./navigation-testing-lock') as typeof import('./navigation-testing-lock')
|
||||
const navigationLockPrefetch = beginNavigationLockPrefetch()
|
||||
schedulePrefetchTask(
|
||||
cacheKey,
|
||||
currentFlightRouterState,
|
||||
fetchStrategy,
|
||||
PrefetchPriority.Default,
|
||||
null, // onInvalidate
|
||||
navigationLockPrefetch
|
||||
)
|
||||
if (navigationLockPrefetch !== null) {
|
||||
await navigationLockPrefetch.promise
|
||||
}
|
||||
|
||||
// Prefetch is complete. Proceed with the normal navigation flow, which
|
||||
// will now find the route in the cache.
|
||||
|
||||
@@ -42,6 +42,7 @@ export function prefetch(
|
||||
treeAtTimeOfPrefetch,
|
||||
fetchStrategy,
|
||||
PrefetchPriority.Default,
|
||||
onInvalidate
|
||||
onInvalidate,
|
||||
null // navigationLockPrefetch
|
||||
)
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
getCurrentRouteCacheVersion,
|
||||
getCurrentSegmentCacheVersion,
|
||||
} from './cache'
|
||||
import type { NavigationLockPrefetch } from './navigation-testing-lock'
|
||||
import {
|
||||
addSearchParamsIfPageSegment,
|
||||
PAGE_SEGMENT_KEY,
|
||||
@@ -174,15 +175,13 @@ export type PrefetchTask = {
|
||||
_heapIndex: number
|
||||
|
||||
/**
|
||||
* Called when the prefetch task finishes (either completed or canceled).
|
||||
* Used by the Instant Navigation Testing API to await prefetch completion.
|
||||
* Not exposed in production builds by default.
|
||||
*
|
||||
* Note: "Complete" means the scheduler has no more work to do for this task
|
||||
* — all network requests have been spawned. It does not mean all data has
|
||||
* been retrieved; responses may still be in flight.
|
||||
* Instant Navigation Testing API only. Non-null when this prefetch task drives
|
||||
* a locked navigation (the `ensurePrefetchThenNavigate` path). Holds that
|
||||
* navigation's "wait for prefetch to fulfill" state: each spawned pending entry
|
||||
* is tracked against it (see `upgradeToPendingSegment`), and the scheduler
|
||||
* signals it when done spawning. See navigation-testing-lock.ts.
|
||||
*/
|
||||
_onComplete?: () => void
|
||||
_navigationLockPrefetch?: NavigationLockPrefetch | null
|
||||
}
|
||||
|
||||
const enum PrefetchTaskExitStatus {
|
||||
@@ -286,7 +285,9 @@ export type IncludeDynamicData = null | 'full' | 'dynamic'
|
||||
* @param treeAtTimeOfPrefetch The app's current FlightRouterState
|
||||
* @param fetchStrategy Whether to prefetch dynamic data, in addition to
|
||||
* static data. This is used by `<Link prefetch={true}>`.
|
||||
* @param _onComplete Called when the prefetch task finishes. Testing API only.
|
||||
* @param navigationLockPrefetch Testing API only. Non-null when this prefetch
|
||||
* drives a locked navigation (from `ensurePrefetchThenNavigate`); carries that
|
||||
* navigation's "wait for prefetch to fulfill" state. Null otherwise.
|
||||
*/
|
||||
export function schedulePrefetchTask(
|
||||
key: RouteCacheKey,
|
||||
@@ -294,7 +295,7 @@ export function schedulePrefetchTask(
|
||||
fetchStrategy: PrefetchTaskFetchStrategy,
|
||||
priority: PrefetchPriority,
|
||||
onInvalidate: null | (() => void),
|
||||
_onComplete?: () => void
|
||||
navigationLockPrefetch: NavigationLockPrefetch | null
|
||||
): PrefetchTask {
|
||||
// Spawn a new prefetch task
|
||||
const task: PrefetchTask = {
|
||||
@@ -314,7 +315,7 @@ export function schedulePrefetchTask(
|
||||
_heapIndex: -1,
|
||||
}
|
||||
if (process.env.__NEXT_EXPOSE_TESTING_API) {
|
||||
task._onComplete = _onComplete
|
||||
task._navigationLockPrefetch = navigationLockPrefetch
|
||||
}
|
||||
|
||||
trackMostRecentlyHoveredLink(task)
|
||||
@@ -343,14 +344,6 @@ export function cancelPrefetchTask(task: PrefetchTask): void {
|
||||
// A running fallback-retry loop notices `isCanceled` when it next wakes and
|
||||
// bails (settling its status to Rejected), so there's nothing to clean up here.
|
||||
heapDelete(taskHeap, task)
|
||||
if (process.env.__NEXT_EXPOSE_TESTING_API) {
|
||||
// Call completion callback. In practice this shouldn't be reached for
|
||||
// test-initiated prefetches since cancellation is only used by the Link
|
||||
// component when elements scroll out of viewport.
|
||||
const onComplete = task._onComplete
|
||||
task._onComplete = undefined
|
||||
onComplete?.()
|
||||
}
|
||||
}
|
||||
|
||||
export function reschedulePrefetchTask(
|
||||
@@ -365,10 +358,6 @@ export function reschedulePrefetchTask(
|
||||
//
|
||||
// The primary use case is to increase the priority of a Link-initated
|
||||
// prefetch on hover.
|
||||
//
|
||||
// Note: _onComplete is not reset here because it's preserved on the same
|
||||
// task object. When the rescheduled task completes, the original callback
|
||||
// will still be invoked.
|
||||
|
||||
// Un-cancel the task, in case it was previously canceled.
|
||||
task.isCanceled = false
|
||||
@@ -606,12 +595,19 @@ function processQueueInMicrotask() {
|
||||
heapResift(taskHeap, task)
|
||||
} else {
|
||||
// The prefetch is complete. Continue to the next task.
|
||||
if (process.env.__NEXT_EXPOSE_TESTING_API) {
|
||||
// Notify the Instant Navigation Testing API that the prefetch has
|
||||
// completed, so it can proceed with navigation.
|
||||
const onComplete = task._onComplete
|
||||
task._onComplete = undefined
|
||||
onComplete?.()
|
||||
if (
|
||||
process.env.__NEXT_EXPOSE_TESTING_API &&
|
||||
task._navigationLockPrefetch != null
|
||||
) {
|
||||
// The scheduler has spawned every request for this locked-navigation
|
||||
// prefetch, so release its "still spawning" reference. The prefetch's
|
||||
// promise (awaited by `ensurePrefetchThenNavigate`) resolves once the
|
||||
// count reaches 0 — i.e. every spawned entry has also fulfilled, so
|
||||
// the navigation reads present data rather than a still-in-flight
|
||||
// entry. If everything already fulfilled, it resolves synchronously.
|
||||
const { finishNavigationLockPrefetchSpawning } =
|
||||
require('./navigation-testing-lock') as typeof import('./navigation-testing-lock')
|
||||
finishNavigationLockPrefetchSpawning(task._navigationLockPrefetch)
|
||||
}
|
||||
heapPop(taskHeap)
|
||||
}
|
||||
@@ -807,7 +803,10 @@ function pingRootRouteTree(
|
||||
|
||||
if (
|
||||
task.phase === PrefetchPhase.Speculative &&
|
||||
!subtreeHasSpeculativePrefetch(task, tree.prefetchHints)
|
||||
!subtreeHasSpeculativePrefetch(
|
||||
task.fetchStrategy,
|
||||
tree.prefetchHints
|
||||
)
|
||||
) {
|
||||
// Nothing in the target route needs to be speculatively prefetched.
|
||||
// Bail out.
|
||||
@@ -991,7 +990,8 @@ function pingStaticHead(
|
||||
entry: readOrCreateSegmentCacheEntry(
|
||||
now,
|
||||
FetchStrategy.PPR,
|
||||
route.metadata
|
||||
route.metadata,
|
||||
task._navigationLockPrefetch ?? null
|
||||
),
|
||||
parent: null,
|
||||
}
|
||||
@@ -1142,7 +1142,7 @@ function pingNewPartOfCacheComponentsTree(
|
||||
|
||||
if (
|
||||
task.phase === PrefetchPhase.Speculative &&
|
||||
!subtreeHasSpeculativePrefetch(task, tree.prefetchHints)
|
||||
!subtreeHasSpeculativePrefetch(task.fetchStrategy, tree.prefetchHints)
|
||||
) {
|
||||
// Nothing in the new part of the tree needs to be speculatively prefetched.
|
||||
// Bail out.
|
||||
@@ -1380,7 +1380,12 @@ function pingPPRDisabledRouteTreeUpToLoadingBoundary(
|
||||
let refetchMarker: 'refetch' | 'inside-shared-layout' | null =
|
||||
refetchMarkerContext === null ? 'inside-shared-layout' : null
|
||||
|
||||
const segment = readOrCreateSegmentCacheEntry(now, task.fetchStrategy, tree)
|
||||
const segment = readOrCreateSegmentCacheEntry(
|
||||
now,
|
||||
task.fetchStrategy,
|
||||
tree,
|
||||
task._navigationLockPrefetch ?? null
|
||||
)
|
||||
switch (segment.status) {
|
||||
case EntryStatus.Empty: {
|
||||
// This segment is not cached. Add a refetch marker so the server knows
|
||||
@@ -1399,7 +1404,8 @@ function pingPPRDisabledRouteTreeUpToLoadingBoundary(
|
||||
// Set the fetch strategy to LoadingBoundary to indicate that the server
|
||||
// might not include it in the pending response. If another route is able
|
||||
// to issue a per-segment request, we'll do that in the background.
|
||||
FetchStrategy.LoadingBoundary
|
||||
FetchStrategy.LoadingBoundary,
|
||||
task._navigationLockPrefetch ?? null
|
||||
)
|
||||
)
|
||||
if (refetchMarkerContext !== 'refetch') {
|
||||
@@ -1496,7 +1502,8 @@ function pingRouteTreeAndIncludeDynamicData(
|
||||
// always use runtime prefetching (via `export const prefetch`), and those should check for
|
||||
// entries that include search params.
|
||||
fetchStrategy,
|
||||
tree
|
||||
tree,
|
||||
task._navigationLockPrefetch ?? null
|
||||
)
|
||||
|
||||
let spawnedSegment: PendingSegmentCacheEntry | null = null
|
||||
@@ -1517,7 +1524,11 @@ function pingRouteTreeAndIncludeDynamicData(
|
||||
}
|
||||
}
|
||||
// Include it in the request.
|
||||
spawnedSegment = upgradeToPendingSegment(segment, fetchStrategy)
|
||||
spawnedSegment = upgradeToPendingSegment(
|
||||
segment,
|
||||
fetchStrategy,
|
||||
task._navigationLockPrefetch ?? null
|
||||
)
|
||||
break
|
||||
}
|
||||
case EntryStatus.Fulfilled: {
|
||||
@@ -1545,7 +1556,12 @@ function pingRouteTreeAndIncludeDynamicData(
|
||||
break
|
||||
}
|
||||
}
|
||||
spawnedSegment = pingFullSegmentRevalidation(now, tree, fetchStrategy)
|
||||
spawnedSegment = pingFullSegmentRevalidation(
|
||||
now,
|
||||
task,
|
||||
tree,
|
||||
fetchStrategy
|
||||
)
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -1559,7 +1575,12 @@ function pingRouteTreeAndIncludeDynamicData(
|
||||
fetchStrategy
|
||||
)
|
||||
) {
|
||||
spawnedSegment = pingFullSegmentRevalidation(now, tree, fetchStrategy)
|
||||
spawnedSegment = pingFullSegmentRevalidation(
|
||||
now,
|
||||
task,
|
||||
tree,
|
||||
fetchStrategy
|
||||
)
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -1686,7 +1707,11 @@ function pingSegmentBundle(
|
||||
}
|
||||
switch (nodeEntry.status) {
|
||||
case EntryStatus.Empty:
|
||||
upgradeToPendingSegment(nodeEntry, FetchStrategy.PPR)
|
||||
upgradeToPendingSegment(
|
||||
nodeEntry,
|
||||
FetchStrategy.PPR,
|
||||
task._navigationLockPrefetch ?? null
|
||||
)
|
||||
needsFetch = true
|
||||
break
|
||||
case EntryStatus.Pending:
|
||||
@@ -1702,7 +1727,11 @@ function pingSegmentBundle(
|
||||
nodeTree
|
||||
)
|
||||
if (revalidatingEntry.status === EntryStatus.Empty) {
|
||||
upgradeToPendingSegment(revalidatingEntry, FetchStrategy.PPR)
|
||||
upgradeToPendingSegment(
|
||||
revalidatingEntry,
|
||||
FetchStrategy.PPR,
|
||||
task._navigationLockPrefetch ?? null
|
||||
)
|
||||
node.entry = revalidatingEntry
|
||||
needsFetch = true
|
||||
} else {
|
||||
@@ -1725,7 +1754,11 @@ function pingSegmentBundle(
|
||||
nodeTree
|
||||
)
|
||||
if (revalidatingEntry.status === EntryStatus.Empty) {
|
||||
upgradeToPendingSegment(revalidatingEntry, FetchStrategy.PPR)
|
||||
upgradeToPendingSegment(
|
||||
revalidatingEntry,
|
||||
FetchStrategy.PPR,
|
||||
task._navigationLockPrefetch ?? null
|
||||
)
|
||||
node.entry = revalidatingEntry
|
||||
needsFetch = true
|
||||
} else {
|
||||
@@ -1770,7 +1803,11 @@ function pingSegmentBundle(
|
||||
nodeTree
|
||||
)
|
||||
if (revalidatingEntry.status === EntryStatus.Empty) {
|
||||
upgradeToPendingSegment(revalidatingEntry, FetchStrategy.PPR)
|
||||
upgradeToPendingSegment(
|
||||
revalidatingEntry,
|
||||
FetchStrategy.PPR,
|
||||
task._navigationLockPrefetch ?? null
|
||||
)
|
||||
node.entry = revalidatingEntry
|
||||
needsFetch = true
|
||||
} else {
|
||||
@@ -1841,7 +1878,12 @@ function accumulateSegmentBundle(
|
||||
// vary-path placement we need for Fallback reuse). Skip entirely.
|
||||
return null
|
||||
}
|
||||
const segment = readOrCreateSegmentCacheEntry(now, task.fetchStrategy, tree)
|
||||
const segment = readOrCreateSegmentCacheEntry(
|
||||
now,
|
||||
task.fetchStrategy,
|
||||
tree,
|
||||
task._navigationLockPrefetch ?? null
|
||||
)
|
||||
|
||||
if (
|
||||
process.env.__NEXT_PREFETCH_INLINING &&
|
||||
@@ -1867,7 +1909,8 @@ function accumulateSegmentBundle(
|
||||
entry: readOrCreateSegmentCacheEntry(
|
||||
now,
|
||||
FetchStrategy.PPR,
|
||||
route.metadata
|
||||
route.metadata,
|
||||
task._navigationLockPrefetch ?? null
|
||||
),
|
||||
parent: parentBundle,
|
||||
}
|
||||
@@ -1915,6 +1958,7 @@ function finishStaticBundleOnRuntimeBailout(
|
||||
|
||||
function pingFullSegmentRevalidation(
|
||||
now: number,
|
||||
task: PrefetchTask,
|
||||
tree: RouteTree,
|
||||
fetchStrategy:
|
||||
| FetchStrategy.Full
|
||||
@@ -1934,7 +1978,8 @@ function pingFullSegmentRevalidation(
|
||||
// the server.
|
||||
const pendingSegment = upgradeToPendingSegment(
|
||||
revalidatingSegment,
|
||||
fetchStrategy
|
||||
fetchStrategy,
|
||||
task._navigationLockPrefetch ?? null
|
||||
)
|
||||
// The upsert is handled by fulfillEntrySpawnedByRuntimePrefetch
|
||||
// when the dynamic prefetch response is written into the cache.
|
||||
@@ -1957,7 +2002,8 @@ function pingFullSegmentRevalidation(
|
||||
)
|
||||
const pendingSegment = upgradeToPendingSegment(
|
||||
emptySegment,
|
||||
fetchStrategy
|
||||
fetchStrategy,
|
||||
task._navigationLockPrefetch ?? null
|
||||
)
|
||||
// The upsert is handled by fulfillEntrySpawnedByRuntimePrefetch
|
||||
// when the dynamic prefetch response is written into the cache.
|
||||
@@ -2014,8 +2060,8 @@ function doesCurrentSegmentMatchCachedSegment(
|
||||
* 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,
|
||||
export function subtreeHasSpeculativePrefetch(
|
||||
fetchStrategy: FetchStrategy,
|
||||
prefetchHints: number
|
||||
): boolean {
|
||||
if (!process.env.__NEXT_APP_SHELLS) {
|
||||
@@ -2026,7 +2072,7 @@ function subtreeHasSpeculativePrefetch(
|
||||
|
||||
return (
|
||||
// Check if this is a "full" prefetch (<Link prefetch={true}>).
|
||||
task.fetchStrategy === FetchStrategy.Full ||
|
||||
fetchStrategy === FetchStrategy.Full ||
|
||||
// Check if something in this subtree is configured to be eagerly
|
||||
// prefetched at the route level.
|
||||
(prefetchHints & PrefetchHint.SubtreeHasEagerPrefetch) !== 0
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
export default function Loading() {
|
||||
return <div data-testid="route-loading">Loading route...</div>
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import { Suspense } from 'react'
|
||||
|
||||
export function generateStaticParams() {
|
||||
return [{ slug: 'hello' }]
|
||||
}
|
||||
|
||||
export default function DynamicParamsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<h1 data-testid="dynamic-params-title">Dynamic Params Page</h1>
|
||||
<Suspense
|
||||
fallback={<div data-testid="params-fallback">Loading params...</div>}
|
||||
>
|
||||
<ParamContent params={params} />
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
async function ParamContent({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params
|
||||
|
||||
return <div data-testid="param-value">slug: {slug}</div>
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import { ReactNode } from 'react'
|
||||
export default function Root({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<div>
|
||||
<h1 data-testid="home-title">Partial Prefetch Shell Test</h1>
|
||||
{/* Default (partial) prefetch — only the shell is prefetched. */}
|
||||
<Link href="/dynamic-params/hello" id="partial-link">
|
||||
Partial link
|
||||
</Link>
|
||||
{/* Full prefetch — a speculative (whole-route) prefetch that warms the
|
||||
concrete-param entry too. */}
|
||||
<Link href="/dynamic-params/hello" prefetch={true} id="full-link">
|
||||
Full prefetch link
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* @type {import('next').NextConfig}
|
||||
*/
|
||||
const nextConfig = {
|
||||
cacheComponents: true,
|
||||
// Opt every segment into Partial Prefetching, so an auto prefetch only warms
|
||||
// the shell (params -> Fallback) unless the link forces a full
|
||||
// prefetch. This is what makes the navigation lock restrict reads to the
|
||||
// shell.
|
||||
partialPrefetching: true,
|
||||
experimental: {
|
||||
// App Shells must be enabled for the shell restriction to apply — with it
|
||||
// off, every prefetch implicitly includes the speculative (non-shell) part
|
||||
// of the target, so there is nothing to restrict.
|
||||
appShells: true,
|
||||
// Enable the testing API in production builds for these tests.
|
||||
exposeTestingApiInProductionBuild: true,
|
||||
prefetchInlining: false,
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
+78
-3
@@ -167,7 +167,7 @@ describe('instant-navigation-testing-api', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('renders static shell on page reload', async () => {
|
||||
it('renders shell on page reload', async () => {
|
||||
const page = await openPage(next, '/target-page')
|
||||
|
||||
// Wait for the page to fully load with dynamic content
|
||||
@@ -196,7 +196,7 @@ describe('instant-navigation-testing-api', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('renders static shell on MPA navigation via plain anchor', async () => {
|
||||
it('renders shell on MPA navigation via plain anchor', async () => {
|
||||
const page = await openPage(next, '/')
|
||||
|
||||
await instant(page, async () => {
|
||||
@@ -281,7 +281,7 @@ describe('instant-navigation-testing-api', () => {
|
||||
// Fourth MPA navigation: go to target page again
|
||||
await page.click('#plain-link-to-target')
|
||||
|
||||
// Still shows static shell, dynamic content still blocked
|
||||
// Still shows shell, dynamic content still blocked
|
||||
await loadingShell.waitFor({ state: 'visible' })
|
||||
expect(await dynamicContent.count()).toBe(0)
|
||||
})
|
||||
@@ -859,3 +859,78 @@ describe('instant-navigation-testing-api - root params', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('instant-navigation-testing-api - partial prefetching (App Shells)', () => {
|
||||
const { next } = nextTestSetup({
|
||||
files: join(__dirname, 'fixtures', 'partial-prefetch'),
|
||||
skipDeployment: true,
|
||||
// Skew protection (deployment-id asset versioning) is orthogonal to the
|
||||
// shell-restriction behavior under test. Disable it so the suite exercises
|
||||
// only the navigation-lock behavior.
|
||||
disableAutoSkewProtection: true,
|
||||
})
|
||||
|
||||
// Under Partial Prefetching with App Shells, an auto (partial) prefetch only
|
||||
// warms the shell — the concrete-param entry is not speculatively
|
||||
// prefetched. The testing lock simulates that warm cache: a navigation may
|
||||
// only match the shell entry, never an entry that varies on concrete route
|
||||
// params, even if such an entry happens to be warm in the cache (here, from a
|
||||
// sibling prefetch={true} link). Without the restriction, the navigation
|
||||
// would match the warm `slug: hello` entry and show it instantly.
|
||||
it('restricts navigation to the shell even when a concrete-param entry is warm', async () => {
|
||||
const page = await openPage(next, '/')
|
||||
|
||||
// Warm both entries for /dynamic-params/hello:
|
||||
// - the prefetch={true} sibling link does a speculative (whole-route)
|
||||
// prefetch, warming the concrete slug=hello segment entry, and
|
||||
// - the default link does a partial prefetch, warming the shell entry
|
||||
// (params -> Fallback) that the restricted navigation should render.
|
||||
await page.hover('#full-link')
|
||||
await page.hover('#partial-link')
|
||||
await page.waitForTimeout(3000)
|
||||
|
||||
await instant(page, async () => {
|
||||
// Navigate via the default (partial) link. Even though the concrete
|
||||
// slug=hello entry is warm, the lock restricts the read to the shell.
|
||||
await page.click('#partial-link')
|
||||
|
||||
// A shell boundary is shown — either the route-level loading.tsx or the
|
||||
// page's inner Suspense fallback — proving the navigation rendered the
|
||||
// shell rather than the warm concrete entry.
|
||||
const shell = page.locator(
|
||||
'[data-testid="route-loading"], [data-testid="params-fallback"]'
|
||||
)
|
||||
await shell.first().waitFor({ state: 'visible' })
|
||||
|
||||
// The concrete param value is NOT matched.
|
||||
const paramValue = page.locator('[data-testid="param-value"]')
|
||||
expect(await paramValue.count()).toBe(0)
|
||||
})
|
||||
|
||||
// After the instant scope exits, the concrete value streams in normally.
|
||||
const paramValue = page.locator('[data-testid="param-value"]')
|
||||
await paramValue.waitFor({ state: 'visible' })
|
||||
expect(await paramValue.textContent()).toContain('slug: hello')
|
||||
})
|
||||
|
||||
// A prefetch={true} link triggers a speculative (whole-route) prefetch, so
|
||||
// the concrete-param entry IS prefetched. In that case the lock must NOT
|
||||
// restrict to the shell — the navigation is allowed to match the concrete
|
||||
// entry, since that's what a warm cache would actually contain.
|
||||
it('allows matching concrete params when the link uses prefetch={true}', async () => {
|
||||
const page = await openPage(next, '/')
|
||||
|
||||
await instant(page, async () => {
|
||||
await page.click('#full-link')
|
||||
|
||||
// The concrete param value is matched and shown instantly.
|
||||
const paramValue = page.locator('[data-testid="param-value"]')
|
||||
await paramValue.waitFor({ state: 'visible' })
|
||||
expect(await paramValue.textContent()).toContain('slug: hello')
|
||||
|
||||
// The Suspense fallback (shell) is NOT shown.
|
||||
const fallback = page.locator('[data-testid="params-fallback"]')
|
||||
expect(await fallback.count()).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user