In #96074 I put the `partialFallback` behavior behind the
`partialPrefetching` flag, so that we don't risk an explosion in ISR
costs for apps that haven't opted into Partial Prefetching. But it turns
out I only handled this for deploy mode — i.e. the build output consumed
by the Vercel adapter. The behavior was still unconditionally on for
`next start`.
The reason it was easy to miss is that `next start` and the adapter
express partial fallback shells through completely separate mechanisms.
The adapter emits a `partialFallback` flag into the build output and
lets the platform's ISR layer perform the shell upgrade. `next start`
has no such flag; the server performs the upgrade itself, in the
compiled page runtime. So the gate I added to the adapter output had no
effect on the self-hosted path.
To understand how the `next start` path was configured, I looked at the
PR that removed the original top-level `partialFallbacks` config flag
(#93859). Before that PR, the behavior was gated in the runtime; when
the flag was removed those gates were deleted and the behavior became
always-on.
The important subtlety is that only the *upgrade* should be gated, not
the shell machinery as a whole. On `next start`, the value that decides
whether a shell can be specialized (`remainingPrerenderableParams`) also
drives core Cache Components behavior — build-time partial prerendering
of params and serving build-time sub-shells — which must stay on
regardless of `partialPrefetching`. So this gates only the two things
that make up the "upgrade on first request" cost risk: the background
ISR revalidation that specializes a shell per request, and the
client-facing `isFallbackUpgradeable` signal that tells the client to
retry a prefetch waiting for that upgrade.
Adds coverage with a `partialPrefetching`-disabled fixture asserting
that fallback shells stay shared rather than specializing per request.
Removes the "allow-runtime" prefetch config, and turns its behavior on
implicitly wherever Partial Prefetching is enabled.
The original motivation for "allow-runtime" was to give apps more
control over server costs triggered by prefetches. Until a route
explicitly opts in, prefetches would only be served from the CDN, not
from the server. The problem, though, was it was very confusing to know
when to add or remove this configuration. The incentive for many apps
was to add it everywhere, with no clear signal for when to remove it.
Our updated thinking is that Partial Prefetching itself already provides
sufficient protection against runaway prefetching costs: per-link
prefetches only happen on Link components that explicitly opt in with
the prefetch prop.
The optimizations landed earlier in this stack also make allow-runtime
less necessary: on pages where all the content is statically renderable,
prefetches are served from the static cache and no runtime request is
ever issued; only a page that accesses non-static data is prefetched at
runtime.
The upshot of this decision is that runtime versus static becomes an
internal optimization; the same content gets prefetched regardless of
whether or how Next.js is able to optimize it.
If we're reasonably confident that a segment can be prefetched
statically without omitting data that would have been included during a
runtime prefetch (e.g. cookies), the client should attempt prefetch the
segment statically instead of going straight to a runtime request.
The decision for whether to do this is based on the
ShouldAttemptStaticPrefetch added in previous steps.
If it turns out the static response is not sufficient, then it will fall
back to a runtime request.
This makes prefetching cheaper for pages that are fully statically
renderable. We can make the optimization better in the future with more
reliable per-segment computation of the ShouldAttemptStaticPrefetch, but
the current approach should at least work for fully static pages, which
is what's most important.
This optimization applies during both the Shell phase and the
Speculative phase of the prefetching algorithm.
This is a refactor of the prefetching algorithm to block the prefetch
task from completing until the response is received.
Currently, a prefetch task is fire-and-forget: once a prefetch's
requests are spawned, the scheduler marks the originating task as
complete regardless of what the server ends up returning. However,
certain planned optimizations require us to verify the response from the
server to determine whether additional work is necessary. For example,
we may attempt to do a static prefetch instead of a runtime prefetch if
we optimistically assume that the static prefetch does not vary on
runtime data. We need to verify that assumption is correct before
marking the task as complete.
Now, whenever a pass over the task spawns a segment request, or
encounters an entry whose response hasn't arrived yet, the task
registers itself on the entry and exits as Blocked instead of Done. When
the entry resolves, the blocked task is pinged and the pass re-runs
against the received data. Only a pass that observes every response it
cares about advances the task to the next phase or completes it.
Rejected entries are handled per segment: a rejection counts as an
observed response, so the pass skips that segment and keeps prefetching
the rest. The task never registers on a rejected entry, since nothing
ever pings one. Retries are governed by the existing per-entry logic.
Also fixes a cache bug this exposed: when a revalidation response is
keyed at a more generic vary path than the stale partial entry that
prompted it (because the segment turned out not to vary on some param),
the stale entry shadowed the result on every lookup — the upgraded data
was unreachable and the revalidation was wasted. With blocking, that
dead end became an infinite loop: each fulfillment re-pinged the blocked
task, which re-read the same stale entry and spawned another
revalidation. Upserting (or re-keying) a segment now deletes settled
entries at more specific vary paths that the incoming entry supersedes.
The router-act test helper's "at least one request initiated" watchdog
is adjusted to account for the new blocking behavior: countable requests
can now be legitimately gated on an in-flight App Shell response (which
the watchdog intentionally doesn't count). On expiry it now proceeds to
response processing if the act scope intercepted a shell request, and
keeps waiting while shell requests are in flight elsewhere on the page,
instead of timing out.
Adds a server-computed signal to static per-segment prefetch responses
that tells the client whether a runtime prefetch request would return
more content than the static response already contains.
The signal is computed by tracking whether the prerender accessed any
data source that hangs during a static prerender but would resolve
during a runtime prerender. For example: cookies, headers, fallback
params, and search params.
The information is encoded two places:
- As a prefetch hint called ShouldAttemptStaticPrefetch. This tells the
client that it's worth attempting to do a static prefetch instead of a
runtime one. It's only an optimization, though: if the static response
ends up being insufficient, the client will follow it up with a runtime
request. It's semantically OK if there are false positives.
- Embedded in the static segment response. This tells the client whether
the static response is missing data that would have been included in a
runtime response. Unlike the prefetch hint, this value must never
falsely claim that no runtime request is needed.
A follow-up will update the client to skip the runtime request using
this information. This commit only encodes the signal into the response.
Updates the per-segment, static prefetch response type to include a
bytes offset for the App Shell. This will allow the client to extract a
shell response from a more complete response, like we already do for
page-level prerender responses.
All of the significant changes are confined to the server, during the
step when a full page prerender is used to create per-segment responses
(collect-segment-data). The overall strategy is the same one used at the
page level. The input data is unblocked in distinct stages, separated by
macrotasks. At each stage, we record the number of bytes that have been
emitted to the output stream. Then, before closing the final stream, we
embed the byte offsets into the response.
During this phase, no actual user code runs: all we're doing is decoding
and reencoding an existing Flight response. So the timing logic is more
straightforward. For example, we don't need to worry about dynamic data
leaking into the result.
This commit is purely an in-place refactor. Subsequent steps will update
the client to take advantage of the new offsets.
## Root cause
`RouteCacheEntry` objects cycled through three hidden classes over their
lifecycle, found by running `pnpm bench:deopt --scenario segment-cache`:
(A) the pending object literal in `createDetachedRouteCacheEntry`, which
did not declare `hasDynamicRewrite`; (B) that shape plus
`hasDynamicRewrite` appended post-construction by
`fulfillRouteCacheEntry`, a shape transition that also deprecates the
original map; and (C) the two fulfilled-entry literals
(`deprecated_requestOptimisticRouteCacheEntry` in `cache.ts` and the
synthetic entry in `matchKnownRoute` in `optimistic-routes.ts`) which
declare `hasDynamicRewrite` in a different key position, between
`supportsPerSegmentPrefetching` and `renderedSearch`. Every property
access on a route cache entry in the hot navigation/prefetch paths was
therefore polymorphic.
This PR pre-declares `hasDynamicRewrite: false` in
`createDetachedRouteCacheEntry` at the same key position as the
fulfilled literals, collapsing all three shapes into one hidden class,
and moves the `hasDynamicRewrite: boolean` type declaration from
`FulfilledRouteCacheEntry` to `RouteCacheEntryShared` to match. The
`fulfilledEntry.hasDynamicRewrite = false` store in
`fulfillRouteCacheEntry` is left in place; it is now an in-place store
rather than a shape transition, and it re-clears the flag on
re-fulfillment.
## Behavior neutrality
The field is pre-initialized to `false` on pending/empty entries and
semantics are identical: no runtime code reads `hasDynamicRewrite` from
an unfulfilled entry, and there are no `in` checks, `Object.keys`, or
`delete` operations on these entries — only property reads. `pnpm
--filter=next types` passes with the field declared on the shared type.
## Verification
Measured with `pnpm bench:deopt --scenario segment-cache` (fixture
force-rebuilt against the patched build). The `hasDynamicRewrite`
polymorphic IC cleared, along with ~24 entry-field polymorphic IC lines
across `navigation.ts`, `optimistic-routes.ts`, `scheduler.ts`, and
`cache.ts` (status, tree, canonicalUrl, renderedSearch, metadata,
couldBeIntercepted, supportsPerSegmentPrefetching, staleAt, version,
size). Total `ic-polymorphic` findings dropped from 84 lines to 60.
Results were confirmed stable across a second run.
Before/after `findings.txt` diff:
```diff
6,7d5
< high deopt-eager packages/next/src/client/components/segment-cache/scheduler.ts _heapIndex Insufficient type feedback for generic named access
< high deopt-eager packages/next/src/client/components/segment-cache/scheduler.ts cancelPrefetchTask Insufficient type feedback for generic named access
8a7
> high deopt-eager packages/next/src/client/components/segment-cache/scheduler.ts isPrefetchTaskDirty Insufficient type feedback for generic named access
16d14
< info deopt-lazy packages/next/src/client/components/segment-cache/scheduler.ts pingSharedPartOfCacheComponentsTree (unknown)
29d26
< info ic-polymorphic packages/next/src/client/components/segment-cache/cache.ts eR keys: push
37d33
< info ic-polymorphic packages/next/src/client/components/segment-cache/cache.ts eg keys: canonicalUrl
47,51d42
< info ic-polymorphic packages/next/src/client/components/segment-cache/navigation.ts v keys: canonicalUrl
< info ic-polymorphic packages/next/src/client/components/segment-cache/navigation.ts v keys: metadata
< info ic-polymorphic packages/next/src/client/components/segment-cache/navigation.ts v keys: renderedSearch
< info ic-polymorphic packages/next/src/client/components/segment-cache/navigation.ts v keys: status
< info ic-polymorphic packages/next/src/client/components/segment-cache/navigation.ts v keys: tree
53d43
< info ic-polymorphic packages/next/src/client/components/segment-cache/optimistic-routes.ts e keys: hasDynamicRewrite
63d52
< info ic-polymorphic packages/next/src/client/components/segment-cache/optimistic-routes.ts g keys: couldBeIntercepted
65d53
< info ic-polymorphic packages/next/src/client/components/segment-cache/optimistic-routes.ts g keys: size
67,70d54
< info ic-polymorphic packages/next/src/client/components/segment-cache/optimistic-routes.ts g keys: staleAt
< info ic-polymorphic packages/next/src/client/components/segment-cache/optimistic-routes.ts g keys: supportsPerSegmentPrefetching
< info ic-polymorphic packages/next/src/client/components/segment-cache/optimistic-routes.ts g keys: tree
< info ic-polymorphic packages/next/src/client/components/segment-cache/optimistic-routes.ts g keys: version
75,78d58
< info ic-polymorphic packages/next/src/client/components/segment-cache/scheduler.ts <anonymous> keys: status
< info ic-polymorphic packages/next/src/client/components/segment-cache/scheduler.ts <anonymous> keys: supportsPerSegmentPrefetching
< info ic-polymorphic packages/next/src/client/components/segment-cache/scheduler.ts <anonymous> keys: tree
< info ic-polymorphic packages/next/src/client/components/segment-cache/scheduler.ts F keys: metadata, tree
80,81d59
< info ic-polymorphic packages/next/src/client/components/segment-cache/scheduler.ts I keys: metadata
< info ic-polymorphic packages/next/src/client/components/segment-cache/scheduler.ts L keys: renderedSearch
86,87d63
< info ic-polymorphic packages/next/src/client/components/segment-cache/scheduler.ts e keys: 0
< info ic-polymorphic packages/next/src/client/components/segment-cache/scheduler.ts e keys: 1
93,94d68
< info ic-polymorphic packages/next/src/client/components/segment-cache/scheduler.ts j keys: status
< info ic-polymorphic packages/next/src/client/components/segment-cache/scheduler.ts j keys: tree
```
Two high-severity `wrong map` lines remain and are explicitly NOT
claimed by this PR: the `cache-map.ts` `isValueExpired` line reads the
intentionally 3-typed route|segment|bfcache map-value union, and the
`optimistic-routes.ts` residual (now attributed to the
`hasDynamicRewrite` read at 791:26) is a one-time map-deprecation deopt
caused by in-place fulfillment generalizing null-initialized field
types. The latter is tracked separately with a possible follow-up that
fulfills entries via fresh literals instead of mutation.
## Tests
-
`test/e2e/app-dir/segment-cache/optimistic-routing-rewrite-detection-regression`
(test-start-turbo): 3 passed
- `test/e2e/app-dir/segment-cache/staleness` (test-start-turbo): 4
passed, 5 skipped (mode skips)
## Note on base branch
Found by `pnpm bench:deopt --scenario segment-cache` (V8 deopt/IC
analysis of the client segment cache), with map-level IC evidence. Two
related sources of megamorphism in the optimistic route matching path:
(1) `String.prototype.split` returns arrays whose elements kind
alternates between PACKED_ELEMENTS and HOLEY_ELEMENTS depending on
internal fast paths, and `filter`/`slice` propagate the kind, so the
arrays flowing through `matchKnownRoutePart` and `reifyRouteTree` had
unstable maps, causing repeated "wrong map" eager deopts at the `slice`
site in `matchKnownRoutePart` and the `join` site in `reifyRouteTree`
(which runs once per tree node on every optimistic match); and (2) the
private `ResolvedParams` map typed as `Map<string, string | string[]>`
forced a polymorphic `Array.isArray`/`join` branch on every dynamic
segment during reification.
Semantics are preserved exactly. Catch-all param values are now joined
into a single string at the moment they are resolved in
`matchKnownRoutePart` instead of at read time in `reifyRouteTree` — the
value was only ever read once and immediately collapsed to a joined
string cache key, and the empty optional catch-all case writes `''`,
which is identical to `[].join('/')`. The new `splitPathnameIntoParts`
helper is byte-for-byte equivalent to `pathname.split('/').filter((p) =>
p !== '')`: empty tokens from leading, trailing, or consecutive slashes
are skipped, but the parts array is built by pushing into an array
literal so it always has a packed elements kind.
Before/after `findings.txt` diff from the bench scenario:
```diff
-high deopt-eager packages/next/src/client/components/segment-cache/optimistic-routes.ts matchKnownRoutePart wrong map
-high deopt-eager packages/next/src/client/components/segment-cache/optimistic-routes.ts reifyRouteTree wrong map
-info deopt-lazy packages/next/src/client/components/segment-cache/optimistic-routes.ts matchKnownRoutePart (unknown)
-info deopt-lazy packages/next/src/client/components/segment-cache/optimistic-routes.ts reifyRouteTree (unknown)
-info ic-polymorphic packages/next/src/client/components/segment-cache/cache.ts eh keys: filter
-info ic-polymorphic packages/next/src/client/components/segment-cache/cache.ts eh keys: split
-info ic-polymorphic packages/next/src/client/components/segment-cache/optimistic-routes.ts e keys: join
-info ic-polymorphic packages/next/src/client/components/segment-cache/optimistic-routes.ts e keys: length
-info ic-polymorphic packages/next/src/client/components/segment-cache/optimistic-routes.ts e keys: slice
-info ic-polymorphic packages/next/src/client/components/segment-cache/optimistic-routes.ts g keys: filter
-info ic-polymorphic packages/next/src/client/components/segment-cache/optimistic-routes.ts g keys: split
-info ic-polymorphic packages/next/src/client/components/segment-cache/optimistic-routes.ts h keys: split
+info ic-polymorphic packages/next/src/client/components/segment-cache/cache-key.ts u keys: charCodeAt
+info ic-polymorphic packages/next/src/client/components/segment-cache/cache-key.ts u keys: length
+info ic-polymorphic packages/next/src/client/components/segment-cache/cache-key.ts u keys: slice
```
Both high-severity wrong-map deopts in `optimistic-routes.ts` are gone
(confirmed across two independent after-runs); the remaining new lines
are info-level string-receiver ICs inside the splitter helper. Total
ic-polymorphic findings dropped from 90 to 80.
Tests (all passing, `test-start-turbo`):
-
`test/e2e/app-dir/segment-cache/optimistic-route-cache-keying-regression`
— 1 passed
-
`test/e2e/app-dir/segment-cache/optimistic-routing-parallel-slot-catchall-regression`
— 1 passed
- `test/e2e/app-dir/segment-cache/encoded-slash-params` — 2 passed
### Root cause
Parallel route slot names are app-defined, so a `RouteTree.slots` stored
as a plain object has an unbounded set of hidden classes: every distinct
combination of slot names (`{children}`, `{children, side}`, ...)
produces a different shape. Keyed access over these objects makes the
inline cache megamorphic. This was the single `ic-megamorphic` finding
reported by `pnpm bench:deopt --scenario segment-cache`, at the keyed
store in `convertTreePrefetchToRouteTree` (`cache.ts:1531`, keys
`children`, `side`).
This PR converts `RouteTree.slots` from `{ [parallelRouteKey: string]:
RouteTree }` to `Map<string, RouteTree>`. All reads, writes, and
iterations over RouteTree slots now go through monomorphic `Map` method
calls, and iteration order is preserved (Map iterates in insertion
order, matching plain-object string-key order).
### Scope
This was an approved design decision: `RouteTree` is an in-memory-only
structure, so only it changes. `CacheNode.slots` (spread into JSX props)
and the wire formats (`TreePrefetch.slots`, `FlightRouterState[1]`)
remain plain objects; the existing conversion functions bridge them into
and out of the Map.
### Before/after findings diff
```diff
--- bench/deopt/artifacts/slots-map-before/findings.txt
+++ bench/deopt/artifacts/slots-map-after/findings.txt
@@
-high ic-megamorphic packages/next/src/client/components/segment-cache/cache.ts e keys: children, side
+high ic-megamorphic packages/next/src/client/components/segment-cache/scheduler.ts e keys: children, side
@@
+info deopt-dependency-change packages/next/src/client/components/segment-cache/scheduler.ts pingSharedPartOfCacheComponentsTree dependent prototype chain changed
+info deopt-eager packages/next/src/client/components/segment-cache/scheduler.ts _heapIndex Insufficient type feedback for generic named access
+info deopt-eager packages/next/src/client/components/segment-cache/scheduler.ts cancelPrefetchTask Insufficient type feedback for generic named access
+info ic-polymorphic packages/next/src/client/components/segment-cache/cache.ts eR keys: push
```
The targeted finding at `cache.ts:1531` is cleared. The remaining
megamorphic site (stable across two runs) is `scheduler.ts:1068:23` in
`pingSharedPartOfCacheComponentsTree` — the keyed load
`oldTreeChildren[parallelRouteKey]`, where `oldTreeChildren` is
`FlightRouterState[1]`, i.e. the wire-format plain object that is
intentionally out of scope for this change. That access existed before
this PR; previously the `for...in` loop key qualified the sibling load
for V8's fast enum-cache path, and with Map iteration keys it now
surfaces as a generic keyed load. Eliminating it would require changing
the `FlightRouterState` children format, which is a separate decision.
The new info-level lines are one-time warm-up/prototype-registration
deopts from the introduced `Map` usage, not steady-state IC misses.
### Tests
- `test/e2e/app-dir/segment-cache/basic` (start-turbo): 10 passed, 1
skipped
-
`test/e2e/app-dir/segment-cache/optimistic-routing-parallel-slot-catchall-regression`
(start-turbo): 1 passed
- `test/e2e/app-dir/segment-cache/cached-navigations` (start-turbo): 16
passed
- `pnpm --filter=next types` passes (also re-run after rebasing onto
canary)
The segment cache's `RouteTree` objects were being created with two
different hidden-class families: the two object literals returned by
`reifyRouteTree` in `optimistic-routes.ts` listed their keys in a
different order (`slots`, `prefetchHints`, `isPage`, `varyPath` last)
than every other `RouteTree` constructor site in `cache.ts` (`varyPath`,
`isPage`, `slots`, `prefetchHints` last). In V8, object literals with
different key orders get different hidden classes (maps), so every code
path that touches both optimistically-reified trees and server-derived
trees saw polymorphic shapes. Found with `pnpm bench:deopt --scenario
segment-cache` (the V8 deopt detection tooling from the base branch).
This change is behavior-neutral: it only reorders the keys in the two
`reifyRouteTree` object literals to match the canonical order used by
`cache.ts`. No values, types, or logic change.
With all `RouteTree` literals collapsed to one hidden class, the fix
eliminates the polymorphic `RouteTree`-field inline caches: 22 findings
removed across `optimistic-routes.ts`, `scheduler.ts`, `cache.ts`, and
`vary-path.ts` in the before/after diff below (keys
`requestKey`/`segment`/`refreshState`/`prefetchHints`/`slots`/`isPage`/`varyPath`).
Note: the two remaining `wrong map` deopts at `optimistic-routes.ts:904`
and `scheduler.ts:1058` are different object families — resolved-param
values (string vs string[] at `newValue.join`) and `FlightRouterState`
tuples (`oldTree[1]`) respectively — and are tracked separately. This PR
does not claim to fix them.
Before/after `findings.txt` diff (`bench:deopt --scenario
segment-cache`, after run with `--force-build`):
```diff
9a10
> high ic-megamorphic packages/next/src/client/components/segment-cache/cache.ts eA keys: done, value
26,27c27
< info ic-polymorphic packages/next/src/client/components/segment-cache/cache.ts eA keys: done, value
< info ic-polymorphic packages/next/src/client/components/segment-cache/cache.ts e_ keys: varyPath
---
> info ic-polymorphic packages/next/src/client/components/segment-cache/cache.ts eR keys: push
35d34
< info ic-polymorphic packages/next/src/client/components/segment-cache/cache.ts eg keys: requestKey
38d36
< info ic-polymorphic packages/next/src/client/components/segment-cache/cache.ts q keys: varyPath
51d48
< info ic-polymorphic packages/next/src/client/components/segment-cache/optimistic-routes.ts e keys: isPage
54,57d50
< info ic-polymorphic packages/next/src/client/components/segment-cache/optimistic-routes.ts e keys: prefetchHints
< info ic-polymorphic packages/next/src/client/components/segment-cache/optimistic-routes.ts e keys: refreshState
< info ic-polymorphic packages/next/src/client/components/segment-cache/optimistic-routes.ts e keys: requestKey
< info ic-polymorphic packages/next/src/client/components/segment-cache/optimistic-routes.ts e keys: segment
59d51
< info ic-polymorphic packages/next/src/client/components/segment-cache/optimistic-routes.ts e keys: slots
71d62
< info ic-polymorphic packages/next/src/client/components/segment-cache/scheduler.ts <anonymous> keys: prefetchHints
76d66
< info ic-polymorphic packages/next/src/client/components/segment-cache/scheduler.ts F keys: prefetchHints
79,86d68
< info ic-polymorphic packages/next/src/client/components/segment-cache/scheduler.ts M keys: prefetchHints
< info ic-polymorphic packages/next/src/client/components/segment-cache/scheduler.ts M keys: segment, 0
< info ic-polymorphic packages/next/src/client/components/segment-cache/scheduler.ts M keys: slots
< info ic-polymorphic packages/next/src/client/components/segment-cache/scheduler.ts e keys: prefetchHints
< info ic-polymorphic packages/next/src/client/components/segment-cache/scheduler.ts e keys: segment
< info ic-polymorphic packages/next/src/client/components/segment-cache/scheduler.ts e keys: segment, 0
< info ic-polymorphic packages/next/src/client/components/segment-cache/scheduler.ts e keys: slots
< info ic-polymorphic packages/next/src/client/components/segment-cache/scheduler.ts j keys: prefetchHints
93,94d74
< info ic-polymorphic packages/next/src/client/components/segment-cache/vary-path.ts g keys: isPage
< info ic-polymorphic packages/next/src/client/components/segment-cache/vary-path.ts g keys: varyPath
```
`VaryPath` nodes are created and traversed on a very hot path in the
segment cache, so their object shape matters for performance.
`isRootParam` was optional: most nodes left it undefined while a few set
it, so nodes had inconsistent shapes. That prevents V8 from sharing a
single hidden class across them and deopts the cache hot paths.
This makes `isRootParam` a required boolean, always set (`false` for
structural and search param nodes), so every `VaryPath` node has the
same shape and these paths stay monomorphic.
`partialFallback` is an internal flag in the build output that controls
the behavior of ISR. When it's enabled, in a Cache Components app, pages
that are not generated at build time are upgraded to a full ISR entry
upon the first request.
This is a useful feature but there's a risk it could lead to an
explosion in ISR costs, because even a prefetch request is sufficient to
trigger an upgrade.
The desired behavior is that we only trigger an upgrade for an actual
navigation request, or if a link opts into prefetching with the
`prefetch` prop.
Currently there's no way for ISR to differentiate between a shell
prefetch request (no prefetch prop on the link) and an actual prefetch
(`prefetch={true}`). The plan is to introduce additional configuration
to the build output that specifies which headers should trigger which
operations.
So by default, this PR turns the `partialFallback` flag back off until
the new mechanism is available to us.
However, for apps that opt into Partial Prefetching, we will continue to
set `partialFallback` to true. That's because in a Partial Prefetching
app, per-link prefetch requests are always opt-in. So even without
additional support at the ISR behavior, we avoid an unexpected surge in
ISR costs by not sending the prefetching request in the first place.
There will still be a single request per distinct route (_not_ per
distinct URL!), which we will optimize later.
A back/forward (history traversal) navigation should not be captured by
the Nav Inspector, because it's not the same as a regular navigation: it
reads the entire navigation from cache, rather than fetching new data.
It might be the case that data in the bfcache is missing for some
reason, but in the normal case all the data will be available and the
navigation will be instant. So that's how we should model it in the Nav
Inspector.
In the future we may want to update the Nav Inspector UI to show that
the current screen was the result of a back/forward navigation, but for
now we will just reset the Nav Inspector back to the pending ("Waiting
for navigation...") state.
Includes the regression tests from #95793.
Co-authored-by: Dan Abramov <[email protected]>
With the Instant Navigation Testing lock held (the Navigation Inspector
paused), repeated client navigations between pages that share a layout
could sometimes get stuck without ever resolving.
The root cause turned out to be a subtle problem with the client
router's use of `useDeferredValue` to switch between the cached UI and
the final UI. The mechanism only works as we intend when mounting a new
part of the tree. When updating an existing part of the tree, like a
shared layout, React will skip the cached UI entirely.
We were already aware of this issue with the `useDeferredValue`
approach. The plan is to instead model cached navigation as an
optimistic update followed by a transition update. Before we can make
such a change, though, we need to make a change in React to allow
optimistic updates to suspend (currently, optimistic updates act like
sync updates, and can trigger existing Suspense boundaries to switch
back to their fallback state).
To workaround this issue when using the Navigation Inspector, we're
going to change how the inspector works when multiple navigations are
performed during the same "lock". Instead of keeping the entire sequence
blocked until the end, we will instead only block the most recent one.
This more closely matches what the inspector UI implies, anyway: it
doesn't show a stack of pending navigations, only the most recent one.
This doesn't absolve us from needing to switch to using an optimistic
update. That's needed regardless to address similar behavior in
production. But this sidesteps the issue for now, and we were planning
to make this change to the Navigation Inspector regardless.
Co-authored-by: Sam Selikoff <[email protected]>
The experimental `appShells` flag was added while the feature was being
developed. It was not meant to be a public-facing flag.
Some of the behavior that was gated behind the `appShells` flag is
purely an optimization and can be landed without any gate.
There are also some behaviors that we don't want to turn on unless
you've opted into the new prefetching model via Partial Prefetching.
Arguably these too are internal optimizations, but because they can
change the number of requests that are made to the server (e.g. one
request for the shell, another for the page data), we will gate these
behind Partial Prefetching to minimize the impact on existing
applications.
The result is that until you opt into Partial Prefetching, you should
not see any meaningfully detrimental impact to prefetching costs,
regardless of how your app is structured.
### Why
Most viewport prefetches are scheduled via a shared
`IntersectionObserver`. When several links enter the viewport at once
(e.g. on initial load), the observer reports them in document order and
we scheduled them in that order. Because the prefetch scheduler gives
the highest priority to the *most recently scheduled* task, this meant
the link **lowest** in the document ended up with the highest priority —
the opposite of a sensible default.
### What
Iterate each observer batch in reverse, so the link nearest the **top**
of the document is scheduled last and therefore prioritized. The topmost
link isn't guaranteed to be the most important, but as a default
heuristic it's more reasonable than favoring whichever link happens to
be lowest in the document.
Also updates the two hover tests in `prefetch-scheduling`, whose
expected ordering was coupled to the old bottom-first default (the set
of route trees prefetched within the concurrency limit flips from pages
7,6,5,4 to 1,2,3,4). The behaviors they assert are unchanged, and a new
test covers the top-of-document prioritization.
---
Supersedes #94902, which was opened from a fork. The "Test new and
changed tests when deployed" jobs cannot pass on fork PRs — `vercel
link` fails with "No existing credentials found" because
`secrets.VERCEL_ADAPTER_TEST_TOKEN` is not available to `pull_request`
runs from forks. This PR is the same change (rebased onto latest canary)
on an upstream branch so the deploy jobs get credentials.
<!-- NEXT_JS_LLM_PR -->
With `cacheComponents` + `partialPrefetching`, navigating to an
already-prefetched route with a dynamic `generateMetadata` left
`document.title` permanently empty until a hard reload.
The prefetch cached the head as complete using the server's
`isHeadPartial` flag, which is unreliable under Cache Components. Derive
the head's partiality from `isResponsePartial` instead, as segment data
already does, so a dynamic head is fetched and applied on navigation
while a fully static head stays complete.
Fixing the flag exposed a latent issue in the prefetch scheduler: during
a speculative prefetch, the head was unconditionally runtime-prefetched,
which previously went unnoticed because the head was mismarked as
complete. The head is now runtime-prefetched only when a segment in the
new part of the tree is a candidate for runtime prefetching — it rides
along with that request rather than spawning a standalone one. If
nothing in the new part of the tree is a runtime prefetch candidate, the
head is fetched during the navigation instead. This makes the
scheduler's metadata-only request path dead code, so it's removed.
Fixes#95268.
<!-- NEXT_JS_LLM_PR -->
On a client-side navigation, when a proxy/middleware redirected the
requested URL to a destination that was itself rewritten to a
dynamically rendered route, the page content updated correctly but the
address bar stayed on the original URL. Hard navigations (reload, typing
the URL) were unaffected.
This happened because the navigation optimistically committed the
predicted URL up front, and when the redirect resolved to a route with
the same shape as the prediction, the router treated the navigation as
already correct and never reconciled the address bar.
A redirect discovered during a navigation now invalidates the prediction
and re-resolves the route, so the address bar reflects the redirect
destination.
Adds an end-to-end test covering the regression.
Fixes#95195
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 -->
In dev with Cache Components, navigating via a `<Link prefetch={true}>`
to a route that hasn't opted into Partial Prefetching logs a warning,
since the full prefetch pulls in dynamic data and defeats the
static/dynamic split.
`instant = false` is the explicit API for opting a route out of this
validation, so it should silence the warning. Previously it didn't: an
`instant = false` segment set no prefetch hint at all, so the warning
still fired.
Add a `SubtreeHasInstantFalse` PrefetchHint, set on any segment that
exports `instant = false` and propagated up to the root (mirroring
`SubtreeHasPartialPrefetching`). The dev warning now skips routes where
this bit is present anywhere on the target subtree.
This is warning-only and kept separate from the existing
`PrefetchDisabled` bit, which feeds `StaticPrefetchDisabled` and would
change actual prefetch behavior.
When the Navigation Inspector (Instant Navigation Testing API) is
active, the server renders only a route's static shell. A blocking route
— one that reads a dynamic value such as `await cookies()` at the root
with no Suspense boundary above it, opting into blocking via `export
const instant = false` — produces an empty shell. We served that empty
shell directly, so the browser showed a blank document with no DevTools.
With no DevTools, there's no way to release the inspector lock from the
UI, so every reload stayed blank and the user was stuck.
Detect the empty static shell at serve time and throw to surface a
normal error page instead, and clear the instant-navigation cookie so
the next reload renders the route normally.
In the future we plan to render this error message inline in the
Navigation Inspector panel rather than as a full-page error.
Previously, a static segment prefetch that hit a route with an
unresolved ISR entry could not be served a fallback shell, so the
prefetch cache was left cold until the entry regenerated. This enables
prefetch requests to be served the ISR fallback shell immediately, so
the prefetch cache can be warmed right away instead of waiting.
Because the shell is only a partial response, the client retries the
prefetch a bounded number of times so it eventually warms the cache with
the full (concrete) response once the server finishes regenerating in
the background. Only shells that can actually be upgraded are retried —
a route with no generateStaticParams never upgrades, so its shell isn't
flagged and the client doesn't waste retries on it.
The new serving behavior is gated behind the experimental `appShells`
flag, so existing behavior is unchanged when it's off.
<!-- NEXT_JS_LLM_PR -->
Refactors how vary params are serialized in the Flight stream so that
it's compatible with stage rewinding.
The immediate motivation is preparation for allowing root params to be
accessed in the App Shell, but this new approach will work more
generally for any other kind of rewinding we add in the future.
The design is similar to how stale time is already serialized: each
access of a param is written to an AsyncIterable that is embedded into
the Flight stream. When decoding the params, the client first buffers
the entire stream, then synchronously drains the iterable using the
special `status` and `value` fields used by the React protocol, until it
reaches a suspended entry.
This is compatible with rewinding because the stream can be cut off at
any point and the client can still decode all the param accesses up to
that point.
As of this PR, this is a pure refactor: it won't change any observable
behavior until the server starts allowing root params in the shell.
<!-- NEXT_JS_LLM_PR -->
The App Shell is the generic loading state for a route. It does not
depend on concrete param values, so it can be used for every navigation
to a route before its params are known.
We've decided to special case root params: those that appear above the
topmost layout for a given route. These are typically used for params
that are low cardinality for a given session, such as the locale. So we
allow them to appear in the generic part of the UI, even though they
technically might change across pages.
We already treat root params as special during static generation, by
always including them during fallback shell generation. So this decision
is consistent with our existing treatment.
This PR updates the client to key App Shells by their root params.
Currently, the client assumes that the shell never varies on _any_
params. This updates the assumption so that it only applies to non-root
params.
Under Cache Components, a `<Link prefetch={true}>` pointing at a route
that hasn't enabled Partial Prefetching quietly falls back to a legacy
full prefetch: it pulls down the route's dynamic data instead of just
the static shell, defeating the static/dynamic split. In development we
now surface this with a console error that explains the fallback and
points at how to opt in — app-wide with `partialPrefetching`, or
per-route with `prefetch = 'partial'`.
The check runs when the navigation happens, not when the link is
prefetched. Dev doesn't prefetch, so navigation is the only point where
both the originating link's fetch strategy and the resolved route tree
are in hand; it also means an app that has just turned on Cache
Components isn't flooded with warnings for every `prefetch={true}` link
on the page, since you only hear about a route when you actually
navigate to it. The decision reads a single hint at the root of the
resolved route tree that records whether any segment in the route opted
into Partial Prefetching.
Scope: the warning fires on every qualifying navigation, including ones
to fully static routes where a full prefetch costs nothing. Suppressing
it for fully-static targets is deferred — the route-level "has dynamic
data" signal isn't cheaply available on cache-served repeat navigations.
<!-- NEXT_JS_LLM_PR -->
Turns on `experimental.appShells` by default for projects already on
`cacheComponents`, unless explicitly disabled.
Thanks to the preceding router-act change, most prefetch tests pass
unchanged once App Shells is enabled. A few tests are genuinely coupled
to the non-App-Shells behavior — they assert on per-link or runtime
prefetch responses that App Shells instead defers to navigation — so the
fixtures for those tests set `appShells` back to `false`. I'll address
those in follow-ups.
<!-- NEXT_JS_LLM_PR -->
When App Shells is enabled, prefetching is split into two phases: a
prefetch for the shell, and a separate phase for the per-page content.
This would cause a handful of the existing prefetch-related tests to
fail once App Shells is turned on. However, the fact that the shell is
prefetched separately from the page content is an incidental detail of
how prefetching works. The App Shell is conceptually part of the route,
not part of the "prefetch". Or in other words, App Shell prefetching has
more in common with incremental module loading than it does with
prefetching in the traditional sense used in the context of web apps.
So, this updates router-act to ignore App Shell requests for the
purposes of making assertions about prefetch responses. They're still
captured, fulfilled, and awaited — they just don't participate in the
matching logic. Tests that specifically assert on App Shell behavior can
opt back in with `createRouterAct(page, { includeAppShellRequests: true
})`.
This is groundwork for the next PR, which enables App Shells by default.
With these requests ignored, most prefetch tests pass unchanged
regardless of whether App Shells is on.
<!-- NEXT_JS_LLM_PR -->
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 -->
Fixes a mistake in the client when constructing a route tree from a
partial server response. When applying the patch, we must propagate the
new subtree hints all the way up to the root.
This was an existing bug that I found when implementing the next PR in
the stack, which relies on the subtree hint mechanism. I've split it out
into its own step and confirmed the bug + fix using a regression test
expressed via existing behavior.
<!-- NEXT_JS_LLM_PR -->
Adds a global config option called `partialPrefetching` that changes the
behavior of `<Link prefetch={true}>`.
When Partial Prefetching is enabled, only Cache Components are included
in a prefetch response. Dynamic data is omitted. This is the same idea
as Partial Prerendering for initial page loads, now applied to client
navigations.
In the initial Cache Components release, Partial Prefetching was enabled
for any Link that did not explicitly set a `prefetch` prop. However, if
the `prefetch` prop was set to `true`, then the Link retained the old
behavior where the _entire_ page was prefetched, including dynamic data.
This was to ease the migration path for existing apps that already
relied on dynamic data being included in the prefetch.
For new apps that have Cache Components enabled from the start, it's
suggested to also enable `partialPrefetching`.
This PR also adds a per-segment opt-in: a route segment can set
`unstable_prefetch = 'partial'` to enable Partial Prefetching just for
that segment, so apps can migrate gradually before enabling it globally.
<!-- NEXT_JS_LLM_PR -->
Adds support for extracting an App Shell from a more concrete prerender
response. The server sends down a byte offset that represents the subset
of the stream that corresponds to the reusable App Shell.
This does _not_ yet implement shell extraction for per-segment prefetch
responses. Implementing this adds an additional layer of complexity,
because those responses are generated during a separate phase of the
build process. We do intend to implement this, but it's a non-essential
optimization that can come later.
This also does not yet implement shell extraction from a navigation
response (the Cached Navigations feature), though both features are
based on essentially the same mechanism. I'm deferring this to a
subsequent PR because some of the existing implementation needs to be
rethought in light of the new App Shells based model; for example, the
"static stage" boundary might not make sense to track separately from
the App Shell.
The main practical upshot of the PR is that if you have a fully
statically prerendered page with no dynamic holes, that page's App Shell
can now be fetched by the client without incurring any runtime server
execution cost: the server will return the full static page, and the
client will extract the App Shell from that concrete response.
<!-- NEXT_JS_LLM_PR -->
The route cache write was keying entries on `renderedSearch` — the
search string the server actually used to render the page — but lookups
use the requested URL's search component. These two are not equivalent:
for any search-invariant route (e.g. a static page), `renderedSearch` is
`''` regardless of what the request URL carried.
The result was that a request for `/a?foo=bar` against a static page
parked the cache entry at vary path `(/a, '', null)` — the same slot a
clean `/a` lookup keys to. The entry's stored `canonicalUrl` came from
the requesting URL (`/a?foo=bar`), so a later `router.push('/a')` or
`router.replace('/a')` would hit that slot and restore the stale search
string into the browser address bar, `window.location`, and
`useSearchParams()`.
The fix keys cache writes on the request URL's search, not
`renderedSearch`. Entries now sit at the slot keyed by the request that
wrote them, so a lookup for a different search variant doesn't find
them.
Regression test covers the reproduction from the user repo linked in the
bug reports.
Closes#91658Closes#92187
<!-- NEXT_JS_LLM_PR -->
Gated behind `experimental.appShells`. Clicking a link to a route the
user has never specifically prefetched now renders the route's App Shell
instantly; the per-link concrete request continues in the background and
streams in the param-specific content.
### Motivation
Today, prefetching a parameterized route like `/chat/[id]` requires a
concrete `id`. The framework caches a separate prefetch entry per link,
so the cost of prefetching scales with the number of visible links — not
with the number of routes. On a page with high link cardinality (a feed,
a search-results page, a chat list) it is impractical to prefetch every
concrete URL up front. If a per-link prefetch for `/chat/123` is still
in flight when the user clicks the link, the navigation blocks until
that prefetch completes; there's no cached generic shell to fall back
to. The cost of a prefetch miss is a full blocking navigation.
The property we want: once a user has visited a Next.js app, every
subsequent navigation should transition to _something_ instantly — at
minimum an App Shell — regardless of whether per-link prefetches for the
concrete destination have completed. This matters most under adverse
conditions (slow networks, offline, high-cardinality routes), but the
guarantee is unconditional.
App Shells make that property hold. A shell is a per-_route_ resource,
not a per-link one — the number of shells in flight at any time scales
with filesystem routes, which is bounded and small. Aggressive App Shell
prefetching is affordable in a way that aggressive per-link runtime
prefetching is not.
### Mechanism
A new `Shell` phase sits in the prefetch scheduler between the existing
`RouteTree` and `Speculative` (formerly `Segments`) phases. Shell-phase
tasks issue an App Shell request and write the response under a
param-independent vary path. Concurrent shell tasks for sibling links to
the same route dedupe at this keypath, so the cache holds at most one
shell entry per route.
The headline property: if N links on a page resolve to the same route
under different params, they share _one_ App Shell request collectively.
Once it lands, every one of those navigations can render an instant
shell, regardless of whether the param-specific concrete prefetch has
completed.
The Speculative phase is mostly unchanged — it still issues the per-link
concrete prefetches that fill in param-specific content over time.
Routes that are fully static (no runtime data anywhere in their tree)
skip the Shell phase entirely, since their existing static prefetches
are already shell-like in shape.
### Navigation-time cache lookup
A small change in how the cache is read at navigation time is what makes
the instant-shell guarantee actually hold. The cache normally returns
the _most-specific_ matching entry — the right semantics for prefetch
dedup, but the wrong semantics for navigation. If a fulfilled shell
entry coexists with an in-flight Pending entry for a more-specific
keypath that the navigation also matches, the most-specific entry is the
empty Pending one, and the navigation would block on it instead of
rendering the shell.
Navigation now does a two-pass lookup: first prefer Fulfilled entries
anywhere along the vary path (so a less-specific shell beats a
more-specific Pending), then fall back to the regular behavior if
nothing fulfilled is found. Prefetch reads keep the original semantics,
since they need to see in-flight entries to dedupe.
### Scope
The shape of the prefetch request and the response interpretation differ
between static (`PPR`) and runtime (`PPRRuntime`) prefetches. Only the
runtime path is covered here. The static path uses a different strategy
— rewinding a single response into a shell prefix and a concrete suffix,
rather than issuing a separate shell-only request — and will be added in
a future PR alongside the server-side byte-offset machinery it depends
on.
<!-- NEXT_JS_LLM_PR -->
Turns on `experimental.cachedNavigations` by default for projects
already on `cacheComponents`. Last of the Sparkle stack.
Tied to `cacheComponents` rather than flipped unconditionally because
`cachedNavigations` is a no-op without it, and the existing validation
already throws if you set it without `cacheComponents` — so an
unconditional flip would break every non-Cache-Components project.
<!-- NEXT_JS_LLM_PR -->
Turns on `experimental.optimisticRouting` by default. Second of the
Sparkle stack.
A few tests pin `optimisticRouting: false` because their assertions
observe the pre-flip navigation timing — search-params, a11y route
announcer, and hash scroll. To be updated separately.
<!-- NEXT_JS_LLM_PR -->
Turns on `experimental.varyParams` by default. First of a short stack
flipping the remaining Sparkle flags to their new defaults — landing one
flag at a time so any regressions are bisectable.
A test in `segment-cache/prefetch-runtime` asserts on the pre-flip
root-param cache-keying; pinned to `varyParams: false` until the test
catches up.
<!-- NEXT_JS_LLM_PR -->
Introduces a new prefetch request type — the App Shell — and the
server-side handler that renders it. With this PR, a client (or a test
harness) can issue a request with `NEXT_ROUTER_PREFETCH_HEADER: '3'` and
receive the param-independent shell of a route: param- and
searchParam-bearing segments suspend, leaving everything that doesn't
depend on them intact.
The App Shell render is a subtype of the existing runtime prerender,
distinguished by a single bit on the prerender store. When that bit is
set, `await params` and `await searchParams` both hang forever, so any
segment that depends on either suspends to its nearest fallback. The
"shell" of the route is whatever renders above those suspense boundaries
— layouts, cookie-derived UI, and other request-context-derived content.
Static shell prefetches are not covered by these changes; those will be
addressed by a future PR and use a slightly different strategy. (Instead
of creating a new response type, the existing static prefetch response
will be updated to support extraction of the shell via "rewinding",
similar to what we do for cached navigations.)
No client-side changes yet, only the server parts.
<!-- NEXT_JS_LLM_PR -->
Plumbing for upcoming App Shell prefetching support: sets up the config
field, the zod entry, and the build-time `process.env.__NEXT_APP_SHELLS`
define. No behavior change in this PR — nothing reads the flag yet.
Subsequent PRs in this sequence wire the server- and client-side work
behind it.
Enabling `experimental.appShells` requires several adjacent flags to
also be enabled: `cacheComponents`, `experimental.prefetchInlining`,
`experimental.varyParams`, `experimental.optimisticRouting`, and
`experimental.cachedNavigations`. All of these are on track to become
defaults soon, so there's no value in supporting App Shells against
arbitrary subsets — the validation enforces that App Shells is tested in
the configuration it will ship with. Each requirement drops out as the
corresponding flag becomes a default.
<!-- NEXT_JS_LLM_PR -->
When `cacheComponents` is enabled, the App Router preserves state across
navigations by rendering inactive routes inside React `<Activity>`
boundaries.
As a default behavior, this is a huge convenience because it lets you
navigate between routes without resetting or losing ephemeral UI state
(scroll position, expand/collapse, in-progress form edits). Previously
the only way to do this was to explicitly track each state with an
external state manager, or hoist it to a parent component.
It's still the often the case that you should be explicitly tracking all
important UI state, anyway, so that it survives a hard refresh of the
app, or the browser window being accidentally closed. For example, forms
draft states should be persisted to a server-side database or local
storage engine. If you're already doing that, then it doesn't matter so
much whether client state is preserved via `<Activity>` boundaries or
not.
For the long tail of ephemeral state that is not tracked, Next.js's
philosophy is that it's a better UX default to preserve as much
emphemeral state as possible. It's easier to model the cases where you
_do_ want state to be reset on navigation as exceptions, compared to the
other way around.
However, this is a significant change compared to the pre-Cache
Components previous behavior of Next.js, and compared to other web
frameworks, and indeed the browser's own native bfcache (which
implements state restoration for history traversal navigations only, not
push/replace).
There's are also lots of existing codebases that may rely on the current
behavior, and may break subtly under these new semantics. Even if this
new default unlocks better UX patterns, we don't want to force everyone
to migrate all their code all at once.
So, this PR introduces a drop-in mechanism for opting out of state
preservation when navigating to a previously visited route.
The API is exposed as `useRouter().bfcacheId`. It's intended to be
passed to a React `key`:
<form key={useRouter().bfcacheId}>
The id is contextual: read from a layout, you get the layout's id; read
from a page, you get the page's id. It's stable across back/forward
navigations, `router.refresh()`, server actions that call `refresh()`,
and search-param- or hash-only navigations — i.e., any time the
surrounding segment is preserved. It changes when the segment is freshly
created by a push or replace into a different route.
An important detail is that the previous id is restored during a back/
foward navigation. So state preservation will still work if you navigate
via the browser's back button.
Why add this to `useRouter()` instead of giving it its own hook? The
intent is communicate that `bfcacheId` is not considered an idiomatic
pattern — the recommended fix for "I want this state to reset on
navigation" is almost always something else: an explicit reset in a
submit handler, or a key derived from the underlying data (e.g., a draft
id from the server). `useRouter` is the hook where we expose low-level
APIs that are supported but are only recommended for advanced or
exceptional cases. (For example, instead of `router.push()`, you should
almost always use a `<Link>` component instead.)
When optimistic routing is enabled, the client uses responses it
receives from the server (during a navigation or a prefetch) to resolve
the routes of structurally similar URLs.
However, if the server rewrites a URL to a different path than the one
requested by the client, then we disable optimistic routing for that
pattern.
We detect a rewrite by comparing the route tree returned from the server
to the requested URL. If the route tree does not match, according to the
Next.js routing algorithm, then the route must have been rewritten; we
do not write this pattern into the client's route table.
The basic mechanism for this was already implemented, but there were
some cases we weren't handling correctly:
- A static or non-optional dynamic segment is reached with no URL part
to consume.
- A page leaf is reached with URL parts still pending.
- A dynamic segment whose URL part matches one of its known static
siblings (which would have taken precedence under normal routing).
These cases have been fixed. Regression tests live in
`optimistic-routing-rewrite-detection-regression/` and
`optimistic-routing-parallel-slot-catchall-regression/`.
---
Replaces #93578 (re-opened from an `origin` branch to satisfy CI
requirements).
In dev with cacheComponents, the instant-nav prefetch for a dynamic
route resolved the slug into the cached shell instead of returning the
generic fallback, so `params` never suspended under a locked instant
scope.
Existing e2e coverage missed this because the fixture's dynamic-params
page had `generateStaticParams` defined. That populates the dev
prerender manifest and masks the bug — the broken path is the one where
no `generateStaticParams` is defined. The regression test uses a new
`ungenerated-params/[slug]` fixture that leaves it out.
The Instant Navigation Testing API simulates what a user would see on an
arbitrarily slow connection — like an infinitely slow network — when the
app is at rest and the prefetch cache is completely warm. It works by
blocking any dynamic RSC data fetches until the end of the testing scope
(i.e. the callback passed to instant()).
It's designed to test the behavior of data fetched by Next.js, via a
Server Component. So currently, it does not affect the behavior
out-of-band client fetches — e.g. fetch('/api/...') in useEffect or an
external data fetching library. Those requests proceed without being
blocked by the `instant()` scope. But this is misleading for testing
purposes, because it implies that client-side data fetches are not
affected by slow network conditions.
To address this, this PR overrides the global `fetch` API and blocks
out-of-band client fetches until the lock is released. The change also
impacts the behavior of the Instant DevTools panel (since both tools use
the same underlying mechanism).
The change only affects environments where the Instant Navigation
Testing API is enabled. It has no impact on live production behavior. It
does not account for other sources of I/O, like images, scripts, fonts,
etc.
<!-- NEXT_JS_LLM_PR -->
Flips the `experimental.prefetchInlining` default from `false` to
`true`. This is the first step toward removing the flag entirely.
Tests that explicitly set `prefetchInlining: true` have been cleaned up
since they now match the default. Any CI failures from this PR will be
addressed in follow-up PRs by temporarily opting out individual tests
with `prefetchInlining: false` until they are migrated.
<!-- NEXT_JS_LLM_PR -->
When traversing the route tree in collectPrefetchHints, we use keys from
the FlightRouterState to look up corresponding entries in the seed data
object. Usually these two structures agree, but there are subtle cases
like notFound() where the router state contains keys that don't exist in
the seed data. TypeScript doesn't catch this because the `in` operator
narrows the key to `string`, which is assignable to the seed data
object's index signature — but the lookup returns `undefined` rather
than `null`.
Eventually the plan is to converge these into a single data structure so
this kind of mismatch can't happen structurally. For now, coalesce
undefined to null with `?? null`, matching the pattern already used in
ppr-navigations.ts and segment-cache/cache.ts on the client.
## Test plan
- Added regression test: page with `notFound()` + `generateStaticParams`
under `prefetchInlining`
- Verified the build crashes without the fix and passes with it
- All existing prefetch-inlining tests continue to pass
<!-- NEXT_JS_LLM_PR -->
The original reason for using 204 was to prevent segment cache misses
from appearing as errors in the browser network panel, e.g. if you
prefetch a same-origin link that happens to route to a different app on
the same domain. However, in output: export mode, we can't use custom
status codes — responses are served as static files, so we're limited to
standard HTTP semantics. Using 404 lets the server-driven and static
deployment strategies converge on the same client-server protocol. The
network panel noise is a minor, non-essential compromise.
<!-- NEXT_JS_LLM_PR -->
The prefetch hints manifest was not loaded during revalidation renders,
causing all segments to fall back to PrefetchDisabled — effectively
disabling prefetching for that route until the next full build.
The app-page template handler constructs its own renderOpts from scratch
without including prefetch-hints.json. Normal requests go through the
server's renderOpts (which loads the manifest at startup), but
background revalidation renders go through the template handler, so the
hints were always empty.
Load the manifest in the route module's loadManifests() and pass it
through to renderOpts.
Closes#92255
---------
Co-authored-by: Zack Tanner <[email protected]>
**Previous:**
1. https://github.com/vercel/next.js/pull/92011
**Current:**
2. (this PR)
---
Adds a `useOffline()` hook exported from `next/navigation` that returns
`true` when the app is offline. The state is owned by a provider
component rendered in the app router, using `useState` + `useOptimistic`
so the value can update even during blocked transitions (e.g., a
navigation that's waiting for connectivity).
Gated behind `experimental.useOffline`.
**Current:**
1. https://github.com/vercel/next.js/pull/92011
**Up next:**
2. https://github.com/vercel/next.js/pull/92012
---
When a navigation, server action, or prefetch fails due to a network
error, instead of falling back to MPA navigation or surfacing an error,
the request blocks until connectivity is restored and then retries
automatically.
Offline detection works by treating any `fetch()` rejection as a network
error (server errors resolve with a status code, they don't reject).
Once offline, a polling loop does HEAD requests with exponential backoff
to detect when connectivity returns. Browser offline/online events also
feed into this loop. Successful fetches from other code paths can
short-circuit the loop if they happen to land first.
Follow-up work will add a `useOffline` hook so apps can show an offline
indicator to the user, and allow navigations to read from stale cache
entries while offline.
Gated behind `experimental.useOffline`.
This is the final step in the segment bundling system. The first commit
ensured correctness when inlining hints are stale or unavailable. The
second taught the build-time hint computation to identify which segments
can be omitted and how parent data flows through disabled segments to
static descendants. This commit makes the server and client act on those
decisions: small static segments are combined into a single prefetch
response rather than fetched individually.
This step is inherently larger than the previous ones. We moved as much
complexity as possible into the earlier commits — the hint bits, the
pass-through logic, the metadata assignment, the safety invariants — but
the server output changes and the client scheduling changes need to land
together to keep behavior coherent.
### Unified response model
The brute-force inlining path that previously existed is removed. Both
the old per-segment behavior and the brute-force "inline everything"
mode are now modeled in terms of the size-based system:
- **Per-segment (flag off):** each segment's response contains only its
own data. This is the behavior when `prefetchInlining` is not enabled,
or when a segment exceeds the size threshold.
- **Size-based (flag on):** setting `prefetchInlining: true` uses
default thresholds (2KB per-segment, 10KB total budget). Segments below
the threshold are bundled into their children's responses. Segments
above it get standalone responses.
- **Brute-force (infinite thresholds):** setting the thresholds to
infinity means every segment is bundled into a single response for the
entire route. The size-based heuristics should make this unnecessary.
There is no separate code path for any of these modes. The same server
and client logic handles all three — the only difference is the hint
bits computed at build time.
### Metadata bundling
The metadata (head/viewport) is bundled into the response of whichever
segment is responsible for it, avoiding a separate metadata fetch in
most cases.
On routes with runtime prefetch, the first runtime segment is
responsible — its response already includes the metadata. On purely
static routes, the first page terminal with budget room gets the
metadata. If no terminal has room, the metadata is fetched separately.
### Independent metadata prefetching
When navigating between sibling routes under a shared runtime layout,
the layout is already cached and no runtime segment request is needed.
But the metadata (head) may differ between siblings. This commit ensures
the metadata is always prefetched independently via a runtime request
when `SubtreeHasRuntimePrefetch` is set on the route tree, even when no
runtime segments need fetching.
### Skipping unnecessary static output
Segments with `instant = false` or runtime prefetch enabled don't
benefit from static prefetch responses. Now the server skips generating
static output for these segments entirely, reducing build output size.
The client also skips creating cache entries for them. They still
participate in the bundle chain as null placeholders — parent data flows
through them to static descendants — but no actual data is produced or
fetched.
This skipping is NOT gated by the `prefetchInlining` feature flag. The
hints that drive it (`HasRuntimePrefetch`, `PrefetchDisabled`) are
computed at build time and embedded in the route tree regardless of
bundling.
### Test plan
The prefetch-inlining test suite covers: basic inlining chains, large
segments that break chains, deep chains, parallel routes, dynamic routes
with concrete params, runtime prefetch boundaries (both leaf and
pass-through), `instant = false` segments, stale hint recovery, metadata
assignment, the `instant = false` at root fallback, independent metadata
prefetching for routes with runtime data in the head, and runtime
parallel slot pass-through. Each test verifies both the hint computation
(via snapshot assertions) and the actual navigation behavior (prefetch
via link accordion, then navigate to confirm data was prefetched).
Some segments in a route don't benefit from static prefetching. A
segment with runtime prefetching enabled will be fetched and computed at
runtime instead, and a segment with `instant = false` is not
prefetchable at all. There's no reason to pay the prefetch cost for
these segments — neither the server (measuring sizes, generating
responses) nor the client (fetching, caching) gains anything from
including them.
### Pass-through segments
This commit teaches the hint computation pass to identify these segments
and mark them accordingly. During the size measurement traversal,
disabled segments are skipped (zero size contribution) and act as
transparent pass-throughs: parent data flows through them to the nearest
static descendant rather than being blocked. This means a small static
layout can still be bundled into a grandchild's response even when the
intermediate segment is dynamic.
### No behavioral changes yet
The actual server output and client fetching behavior don't change yet —
disabled segments still produce standalone responses for now. A
follow-up commit will use these hints to actually omit the segments from
the output and teach the client to handle the gaps.
### Head (metadata) inlining
The head (metadata/viewport) inlining logic is also updated: the head is
only inlined into page segments (not layouts, since pages may access
additional params like `searchParams`), and routes with runtime prefetch
skip static head inlining entirely since the runtime response already
includes the head.
Inlining hints (which segments to bundle together) are computed once at
build time by measuring gzip sizes, then persisted to
`prefetch-hints.json`. There are several scenarios where these hints may
not be available at render time. This commit addresses each scenario to
ensure the client always receives correct hints and never enters a bad
state.
### Build-time static pages (stale hints)
The initial RSC payload baked into the HTML is generated before hints
are computed, since hint computation requires at least one completed
render to measure sizes. The server marks these trees with
`InliningHintsStale`. The client immediately expires the route cache
entry so that the next prefetch re-fetches the correct tree from the
`/_tree` response. The segment data doesn't need re-fetching since it
was already cached from the initial HTML payload.
### ISR / revalidation
Hints are always available from the manifest. A missing entry is an
internal error — it means the build pipeline failed to produce hints for
a route that needs them.
### Fully dynamic routes at runtime
No hints exist and none will ever be computed. Every segment gets
`PrefetchDisabled`, which tells the client not to attempt prefetching.
This avoids an infinite re-fetch loop that would occur if the client
kept trying to fetch "correct" hints.
### Unified response format
Also simplifies the segment prefetch response format: every response is
now a `SegmentPrefetchResponse` with a top-level `buildId` and a `data`
array, treating a single-segment response as a bundle that happens to
have length one. This unifies the format so that bundled multi-segment
responses (added in a follow-up) require no additional client parsing
logic.
**Previous:**
1. https://github.com/vercel/next.js/pull/91487
2. https://github.com/vercel/next.js/pull/91488
**Current:**
3. https://github.com/vercel/next.js/pull/89297
---
Most of the vary params infrastructure was already implemented in
previous PRs for static prerenders. This wires up the remaining pieces
for runtime prefetches — creating the accumulator, setting it on the
prerender store, and resolving it before abort — and adds additional
test cases covering empty/full vary sets, searchParams, metadata, and
per-segment layout/page splits with runtime prefetching.
**Previous:**
1. https://github.com/vercel/next.js/pull/91487
**Current:**
2. https://github.com/vercel/next.js/pull/91488
**Up next:**
3. https://github.com/vercel/next.js/pull/89297
---
When a prefetch response includes vary params, the segment cache rekeys
entries to a more generic path based on which params the segment
actually depends on. Previously, the rekeying only happened when vary
params were provided. Now that vary params are tracked for more response
types (and eventually will always be tracked), entries are rekeyed in
more cases than before.
This exposed a potential race condition: the scheduler would capture a
vary path at scheduling time and upsert the entry at that path when the
fetch completed. But the fetch functions themselves rekey entries to a
different (more generic) path upon fulfillment. The deferred upsert
could then move the entry back to the less generic path, undoing the
rekeying.
To fix this, move the upsert logic inline into the fetch functions that
fulfill entries, rather than deferring it to an external callback. This
removes the race condition, simplifies the model, and reduces
implementation complexity. The previous structure existed to avoid the
rekeying cost when vary params weren't available, but rekeying is
inexpensive and not worth the added indirection.
The upsert function itself already handles concurrent writes by
comparing fetch strategies and checking whether the new entry provides
more complete data than any existing entry. So it's safe to always call
it inline — whichever entry wins will be the most complete one.
**Current:**
1. https://github.com/vercel/next.js/pull/91487
**Up next:**
2. https://github.com/vercel/next.js/pull/91488
3. https://github.com/vercel/next.js/pull/89297
---
Prefetch responses include metadata (in the Flight stream sense, not
HTML document metadata) that describes properties of the overall
response — things like the stale time and the set of params that were
accessed during rendering. Conceptually these are like late HTTP
headers: information that's only known once the response is complete.
Since we can't rely on actual HTTP late headers being supported
everywhere, we encode this metadata in the body of the Flight response.
The mechanism works by including an unresolved thenable in the Flight
payload, then resolving it just before closing the stream. On the
client, after the stream is fully received, we unwrap the thenable
synchronously. This synchronous unwrap relies on the assumption that the
server resolved the thenable before closing the stream.
The server already buffers prefetch responses before sending them, so
the resolved thenable data is always present in the response. However,
HTTP chunking in the browser layer can introduce taskiness when
processing the response, which could prevent Flight from decoding the
full payload synchronously. The existing code includes fallback behavior
for this case (e.g. treating the vary params as unknown), so this
doesn't fix a semantic issue — it strengthens the guarantee so that the
fallback path is never reached.
To do this, we buffer the full response on the client and concatenate it
into a single chunk before passing it to Flight. A single chunk is
necessary because Flight's `processBinaryChunk` processes all rows
synchronously within one call. Multiple chunks would not be sufficient
even if pre-enqueued: the `await` continuation from
`createFromReadableStream` can interleave between chunks, causing
promise value rows to be processed after the root model initializes,
which leaves thenables in a pending state. Since the server already
buffers these responses and they complete during a prefetch (not during
a navigation), this is not a performance consideration.
Full (dynamic) prefetches are not affected by this change. These are
streaming responses — even though they are cached, they are a special
case where dynamic data is treated as if it were cached. They don't need
to be buffered on either the server or the client the way normal cached
responses are.
## Summary
- Fixes flaky test "per-page value overrides global staleTimes.dynamic
regardless of direction" introduced by #91437
- The test was flaky because `browser.back()` restored accordion state
from BFCache, causing previously-opened `LinkAccordion` links to be
immediately visible. This triggered uncontrolled re-prefetches outside
the `act` scope. When the `IntersectionObserver` fired inside a
subsequent `act` scope (after clock advancement), stale data would
trigger a prefetch that violated the `no-requests` assertion.
- Fix: instead of navigating back to a previously visited page, navigate
forward to fresh "hub" pages with their own `LinkAccordion` components.
Since these are never-visited pages, accordions start closed and no
uncontrolled prefetches are triggered.
- General principle: when using the router `act` test utility, always
use `LinkAccordion` to control when prefetches happen. Prefetches should
only be triggered inside an `act` scope by toggling an accordion, never
by navigating back to a page where links are already visible.
## Test plan
- [x] Ran the full test file 4 times locally — all 5 tests pass
consistently
## Background
Next.js has an existing global config,
`experimental.staleTimes.dynamic`, that controls how long the
client-side router cache retains data from dynamic navigation responses.
By default this is 0 — meaning dynamic data is always refetched on every
navigation. Some users set this to a higher value (e.g. 30 seconds) so
that navigating back to a recently visited page reuses the cached
response instead of making a new request.
The limitation of the global config is that it applies uniformly to
every page in the app. Users have asked for the ability to set different
stale times for different pages — for example, a dashboard page that
should always show fresh data vs. a product listing page where it's
acceptable to show slightly stale data for a smoother navigation
experience.
## What this PR adds
`export const unstable_dynamicStaleTime` is a per-page version of
`staleTimes.dynamic`. It accepts a number in seconds with the same unit
and semantics, but scoped to a single page. When present, it overrides
the global config for that page — whether the per-page value is larger
or smaller than the global default. Static and cached responses are
unaffected.
When parallel routes render multiple pages in a single response, the
minimum value across all parallel slots wins, consistent with how stale
times compose generally.
Exporting this from a layout is a build error (pages only).
## Relationship to Cache Components
The recommended way to control data freshness in Next.js is to use Cache
Components (`"use cache"`) with `cacheLife`. This API exists as a
simpler migration path for apps that are already using
`staleTimes.dynamic` and haven't yet adopted Cache Components. It's
prefixed with `unstable_` not because the implementation is unstable,
but because it's not the recommended long-term idiom — apps should
migrate to Cache Components for more granular and composable control
over data freshness.
Based on the design from #88609.
Co-authored-by: Jimmy Lai <[email protected]>
Co-authored-by: Sam Selikoff <[email protected]>
Replaces the segment-path-matching scroll system with a simpler model
based on a shared mutable ScrollRef on CacheNode.
The old system accumulated segment paths during navigation and matched
them in layout-router to decide which segments should scroll. This was
necessary when CacheNodes were created lazily during render. Now that we
construct the entire CacheNode tree immediately upon navigation, we can
assign a shared ScrollRef directly to each new leaf node. When any
segment scrolls, it flips the ref to false, preventing other segments
from also scrolling. This removes all the segment path accumulation and
matching logic.
Fixes a regression where calling `refresh()` from a server action
scrolled the page to the top. The old system had a semantic gap between
`null` (no segments) and `[]` (scroll everything) — a server action
refresh with no new segments fell through to a path that scrolled
unconditionally. The new model avoids this: refresh creates no new
CacheNodes, so no ScrollRef is assigned, and nothing scrolls.
Repro: https://github.com/stipsan/nextjs-refresh-regression-repro
There is extensive existing test coverage for scroll restoration
behavior. This adds one additional test for the server action refresh
bug.
Part 1 of 2. This commit adds the server-side infrastructure for
size-based segment bundling but does not change any observable behavior.
The client-side changes that actually consume bundled responses are in
the next commit.
At build time, a measurement pass renders each segment's prefetch
response, measures its gzip size, and decides which segments should be
bundled together vs fetched separately. The decisions are persisted to a
manifest and embedded into the route tree prefetch response so the
client can act on them.
The decisions are computed once at build and remain fixed for the
lifetime of the deployment. They are not recomputed during
ISR/revalidation — if they could change, the client would need to
re-fetch the route tree after every revalidation, defeating the purpose
of caching it independently.
Refer to the next commit for a full description of the design and
motivation.
## Config
experimental.prefetchInlining accepts either a boolean or an object with
threshold overrides (maxSize, maxBundleSize). When true, the default
thresholds are used (2KB per-segment, 10KB total budget). The auto
behavior will eventually become the default. The config will remain
available for overriding thresholds.
Previously, MPA page loads (reload, anchor navigation) while the instant
navigation lock was held would skip hydration entirely and hard-reload
when the lock released. This meant the page was non-interactive during
the locked period and tests couldn't assert on the hydrated React tree.
Now the page actually hydrates from the static shell. The server injects
a script that fetches the static RSC payload, and the client uses
createFromFetch with allowPartialStream so React can hydrate without
erroring on the incomplete Suspense boundaries. When the lock is
released, a soft refresh fetches the dynamic data (falling back to hard
reload if the router hasn't initialized yet).
Also refactors the cookie protocol to encode the lock state as a JSON
array (`[0]` = pending, `[1, null]` = captured MPA, `[1, {}]` = captured
SPA) for better integration with the devtools UI. This is somewhat
entangled with the hydration changes so they're grouped together, but
can be reviewed as atomic commits.
When matching a URL against the known route trie, if a URL part matches
a static child but the remaining parts don't resolve within that child's
subtree, the algorithm incorrectly falls back to a dynamic catch-all
sibling at the same level. This causes the catch-all to consume the
already-matched static part plus the remaining parts, producing a
synthetic entry for the wrong route.
For example, given routes `/dashboard/settings/profile` and
`/dashboard/[...catchall]`, navigating to `/dashboard/settings/profile`
could incorrectly match the catch-all with params `["settings",
"profile"]` instead of the intended static route.
The fix treats the static child match as authoritative: if a real
(non-placeholder) static child exists but its subtree can't resolve the
remaining URL parts, bail out to server resolution rather than trying
the dynamic sibling. This is safe because it just means an extra network
request for routes whose subtree hasn't been fully discovered.
When prefetchInlining is enabled, the segment data collection returns a
special key for the inlined response. Previously this key was
`/_inlined`, but the build validation code in
`packages/next/src/build/index.ts` expects segment paths to contain
`__PAGE__`. This caused builds to fail with "Invariant: missing __PAGE__
segmentPath" for any dynamic route using prefetchInlining.
Fix by using the same `PAGE_SEGMENT_KEY` (`__PAGE__`) constant for the
inlined segment response key, matching what the build code already
expects. This is consistent since the inlined response serves the same
role as the per-page segment data — it just bundles all segments
together.
Closes#90850
Co-authored-by: Ben Salmon <[email protected]>
Co-authored-by: Ben Salmon <[email protected]>
Remove the `parallelRoutesKeys.length === 0` condition from
`renderComponentsOnThisLevel` in walk-tree-with-flight-router-state.
This check was added to support the behavior where clicking the same
link twice forces all page segments to re-render on the server. This
should no longer be necessary because the client already adds a refetch
marker on those segments, so there's no need to special-case leaf
segments on the server side.
@next/playwright is an experimental package that provides Playwright
helpers for testing Next.js navigation behavior. Its main feature is an
`instant()` API that lets you assert whether a route is correctly
structured for instant navigation — rendering only cached/prefetched
content without waiting for dynamic data.
The package requires Cache Components to be enabled. It works by setting
a cookie that tells Next.js to serve only cached data during
navigations, deferring dynamic content until the cookie is cleared. The
design is intentionally thin so that other testing frameworks can
replicate the same mechanism.
Removing the "private" field so the package is included in the next
canary publish.
Previously, instant() used page.evaluate() to set the cookie via
document.cookie, which required a page to already be loaded. This meant
the cookie wasn't present on the very first navigation.
Switch to Playwright's browser context cookie API so the cookie is set
before any page loads. If the page is already loaded, the base URL is
inferred automatically. Otherwise, a new baseURL option allows setting
the cookie before the first navigation.
## Background
Next.js 16 introduced per-segment prefetching through the Client Segment
Cache. Rather than fetching all data for a route in a single request,
the client issues individual requests for each segment in the route
tree. This design improves cache efficiency: shared layouts between
sibling routes (e.g., /dashboard/settings and /dashboard/profile sharing
a /dashboard layout) are fetched once and reused from the client cache,
avoiding redundant data transfer.
The trade-off is request volume. A route with N segments now produces N
prefetch requests instead of one. Users upgrading from older versions
notice significantly more network activity in their devtools, even
though the total bytes transferred may be similar or lower due to
deduplication.
Per-segment fetching is still a reasonable default for many sites. These
prefetch requests are served from cache, they're fast, and they run in
parallel. The main scenario where the trade-off breaks down is
deployment environments that charge per-request. But even setting aside
cost, there is a theoretical performance threshold where very small
segments are better off inlined — the per-request overhead (connection
setup, headers, scheduling) exceeds the cost of transferring duplicate
bytes. This is analogous to JS bundlers, which inline small modules
rather than creating separate chunks, because the overhead of an
additional script tag or dynamic import outweighs the bytes saved.
## What this change does
This adds `experimental.prefetchInlining`, a boolean option in
next.config.js. When enabled, the server bundles all segment data for a
route into a single `/_inlined` response rather than serving each
segment individually. The tree prefetch (`/_tree`), which provides route
structure metadata, remains a separate request — but optimistic routing
(#88965) eliminates that request entirely by predicting the route
structure client-side. With both features enabled, prefetching is
effectively one request per link.
The fundamental trade-off is straightforward: inlining reduces request
count at the cost of deduplication. Each inlined response includes its
own copy of any shared layout data, so two sibling routes will each
transfer the shared layout rather than sharing a single cached copy.
This is the same trade-off that compilers and bundlers face when
deciding whether to inline a function: inlining eliminates the overhead
of indirection (here, extra HTTP requests) but increases total size when
the same data appears in multiple call sites.
## Future direction
The boolean flag is a stepping stone. The observation that there is a
natural size threshold below which inlining is strictly better — where
per-request overhead dominates the cost of any duplicate bytes — points
toward a size-based heuristic, analogous to how compilers choose an
inlining threshold. Small segments would be inlined automatically;
segments exceeding a byte threshold would be "outlined" into separate
requests where deduplication can take effect. For most applications,
this would require no configuration. For applications with specific
latency or bandwidth constraints, an option to adjust the threshold
would let developers tune their position on the requests-vs-bytes curve.
Adaptive heuristics based on network conditions are also possible,
though further out.
The 500ms settling period in router-act's `act()` polled for 500ms after
the request queue emptied before exiting. This was added to accommodate
the 300ms revalidation cooldown in the client router — after a server
action calls `revalidatePath`/`revalidateTag`, the client delays
re-prefetching for 300ms. The settling period ensured `act()` waited
long enough to catch the resulting re-prefetch.
The settling period applied to every `act()` call, not just ones
involving revalidation, adding ~500ms per call across 28 test files.
Remove it entirely and instead use Playwright's fake clock
(`page.clock.install` / `page.clock.fastForward`) in the revalidation
tests to deterministically control the cooldown. This is both faster and
architecturally cleaner — the test utility no longer embeds knowledge of
a specific client-side timer.
When a navigation triggers a server-patch retry (due to a route tree
mismatch), the retry was unconditionally using navigateType='replace'.
This assumed the original navigation had already committed to the
browser history. But if the original transition was still suspended,
pushState never ran, so replaceState overwrote the initial history entry
instead of creating a new one, breaking the back button.
The fix tracks the last committed router tree. When a server-patch retry
fires, it checks whether the original navigation has committed. If not,
it inherits the original transition's push/replace intent instead of
forcing 'replace'. This works because React entangles the retry with the
original pending transition — they commit together.
Fixes#90513
When an optional catch-all param like `[[...slug]]` has no value (e.g.,
visiting `/docs` instead of `/docs/foo`), the param doesn't exist as a
property on the params object. The existing varyParams tracking uses
`defineProperty` getters, which only work for properties that exist.
This meant the segment cache couldn't tell that the page depended on the
missing param, causing it to incorrectly reuse cached content across
different param values.
Fix this by using a Proxy when an optional catch-all param has no value.
The Proxy intercepts property access, the `in` operator, and object
enumeration to track whether the page's rendering depends on the param,
even though it's absent from the object.
The optional catch-all param name is propagated from the catch-all's
layout segment to its child page segment during tree traversal, so the
page's params object knows to use Proxy-based tracking.
Extract the instant navigation testing API into a standalone
@next/playwright package. The instant() helper lets Playwright tests
assert on prefetched/cached UI by deferring dynamic data during
navigations — similar to throttling the network in DevTools.
The package is private: true for now. The goal is to get the scaffolding
ready so we can flip it to public when the API is ready to ship.
The existing inline implementation in the instant navigation test file
is replaced with an import from @next/playwright. The nested scope test
now exercises actual nested instant() calls rather than manipulating the
cookie directly.
The protocol is intentionally minimal (a single cookie) so that other
testing frameworks and dev tools can replicate the behavior with minimal
effort.
Populate `fallbackRouteParams` in the `isDebugStaticShell` prerender
path so that params suspend correctly during instant navigation shell
rendering in dev mode.
When the instant lock is active, the server forces pages through the
prerender path (`isDebugStaticShell`). During a prerender, `cookies()`,
`searchParams`, and `connection()` all return hanging promises
unconditionally, but params only suspends if `fallbackRouteParams` is
set. Previously, only `isDebugFallbackShell` populated it — now
`isDebugStaticShell` does too.
This fixes both the MPA and SPA cases: the MPA HTML shell no longer
contains resolved param values, and the SPA segment prefetch data no
longer includes them either.
Also adds a safety net: if the prefetch fails while the instant lock is
held, the navigation waits for the lock to be released before falling
through, preventing silent data leakage from future bugs.
## Test plan
- `pnpm test-start-turbo
test/e2e/app-dir/instant-navigation-testing-api/` — 16/16 pass
- `pnpm test-dev-turbo test/e2e/app-dir/instant-navigation-testing-api/`
— 16/16 pass
- Manual verification via DevTools toggle: shell shows title + fallback,
params are blocked until lock is released
---------
Signed-off-by: Andrew Clark <[email protected]>
When navigating to a route that wasn't previously prefetched (e.g. via a
Link with prefetch={false}, or router.push()), the client stores the
route data from the server response in the cache so it can be reused by
future navigations and prefetches to the same URL.
These entries were being stored with a null "nextUrl" cache key, but
lookups use the real nextUrl from router state, so every subsequent
lookup missed. This caused redundant requests for route data the client
already had.
The fix threads the nextUrl through the route discovery path so it
reaches the cache write with the correct value. For non-intercepted
routes the entry is keyed with a wildcard (reusable regardless of
nextUrl); for intercepted routes it uses the specific nextUrl, since the
route structure varies by referrer.
Also cleans up how interception routes interact with the optimistic
route matching system: patterns are now always stored during discovery
(previously they were skipped), and the interception bailout happens in
a single place during matching rather than being duplicated across
multiple check sites.
Closes#88863
Co-authored-by: Hendrik Liebau <[email protected]>
Co-authored-by: Hamidreza Hanafi <[email protected]>
---------
Co-authored-by: Hendrik Liebau <[email protected]>
Co-authored-by: Hamidreza Hanafi <[email protected]>
Adds an "Instant Navigation Mode" toggle to the Dev Tools indicator
menu. When enabled, navigations only show the cached/prefetched state —
the same behavior as the instant navigation testing API, but toggled
from the dev tools UI instead of test code.
Gated behind `experimental.instantNavigationDevToolsToggle`.
This is a proof of concept — the UX needs more work before it's ready to
ship broadly (e.g. the toggle should be more discoverable, and the
locked state needs clearer visual feedback). But it's sufficient to
demonstrate the workflow and get feedback.
https://github.com/user-attachments/assets/ce2992e1-2794-43d6-baef-d10750d8d8e8
The Instant Navigation Testing API already uses a cookie to persist lock
state across MPA navigations (page reloads, plain anchor links), since
window globals don't survive navigation. Rather than maintaining two
parallel mechanisms — a window global for SPA and a cookie for MPA —
this makes the cookie the single source of truth for all cases.
Playwright sets/clears the cookie directly via the browser context, and
the client reacts to changes through the CookieStore API's change event.
This removes the window.__EXPERIMENTAL_NEXT_TESTING__ global and the
page.evaluate() calls that went with it.
## Summary
Prefetch hints were previously stored in CacheNodeSeedData — the payload
the server sends for each segment's React content. This moves them to
FlightRouterState, the route tree structure that the server sends
alongside the seed data. FlightRouterState is a better home because
prefetch hints describe properties of the route structure (loading
boundaries, instant configs) rather than properties of the rendered
content. It also ensures that hints are always available during
navigations, not just prefetches.
Since this changes the FlightRouterState tuple layout anyway, it also
folds the existing `isRootLayout` boolean and `HasLoadingBoundary` enum
into the same bitmask field at FlightRouterState[4].
## Test plan
- Existing unit tests updated for new format
(`flight-data-helpers.test.ts`, `types.test.ts`)
- MPA navigation test validates root layout change detection via bitmask
- `prefetch-true-instant` e2e test validates Link `prefetch={true}`
behavior with instant routes
When a route has `unstable_instant` defined on any segment, setting
`prefetch={true}` on a Link to that route is now a no-op. The prefetch
behaves the same as the default auto strategy — cached content is
prefetched but dynamic content is deferred to navigation time.
A full static prefetch doesn't make sense for routes with instant
configs, since those segments use runtime prefetching. Rather than
silently doing something the developer didn't intend, we ignore the prop
entirely.
To detect this on the client, the server now sends a bitmask instead of
a boolean for the per-segment prefetch metadata. One bit tracks whether
the subtree contains any instant config, which propagates up so the
client can check it at the root without traversing.
## Summary
Adds `experimental.exposeTestingApiInProductionBuild` to make the
Instant Navigation Testing API available in production builds. This is a
stopgap until a dedicated "profiling build" mode exists—similar to
React's profiling builds that include extra instrumentation without the
dev-mode overhead.
The testing API lets e2e tests assert on prefetched UI before dynamic
data streams in. Previously it was dev-only, but production builds have
different timing characteristics (prefetches complete faster, code paths
differ), so testing against production builds is valuable.
## Approach
Introduces a single bundler environment variable
`process.env.__NEXT_EXPOSE_TESTING_API` that's true when either:
- Running in dev mode, OR
- The experimental flag is enabled
This replaces scattered `NODE_ENV !== 'production'` checks throughout
the testing API code paths, making the gating logic consistent and
centralized in the build configuration.
Also renames `dev-navigation-lock.ts` to `navigation-testing-lock.ts`
and updates header/cookie names from "dev-only" to
"instant-navigation-testing" to reflect that the API is no longer
dev-exclusive.
## Test plan
- Updated existing e2e tests to run in both dev and production modes
- Tests pass with `NEXT_TEST_MODE=start` (production build)
Based on:
- https://github.com/vercel/next.js/pull/89465
---
The `instant()` testing API allows e2e tests to assert on prefetched UI
before dynamic data streams in. This extends it to handle full page
navigations: page reloads, plain anchor clicks, and browser
back/forward.
For client-side navigations, the API uses request headers to trigger
static shell rendering. For MPA navigations, headers aren't available,
so this uses a cookie-based approach instead. When `instant()` acquires
a lock, it sets a cookie that tells the server to render the static
shell. When the lock is released, the cookie is cleared and the page
reloads to fetch dynamic content.
On pages loaded with the test cookie, hydration is skipped entirely and
a minimal testing API is set up in the bootstrap script. This is similar
to how the `__nextppronly` debug parameter works (an internal experiment
that was never shipped). This feature is the productionized version of
that approach, designed for actual use in e2e tests.
New test cases:
- Static shell on page reload
- Static shell on plain anchor navigation
- Multiple MPA navigations within a single `instant()` scope
- Verifies navigations work normally after exiting `instant()` scope
## Summary
Adds a dev-mode primitive for writing e2e tests that assert on the
prefetched/cached portion of a navigation. The API blocks dynamic data
from streaming in until assertions complete, allowing deterministic
testing of loading states without race conditions.
This gives developers a way to verify that specific UI elements render
instantly from cache, rather than relying on network timing. It also
reinforces the mental model that navigation performance should be
thought of primarily in terms of cached vs. dynamic.
This PR implements the primitive that Next.js exposes on the window
object. In practice, it's meant to be used via a testing framework
integration, like a Playwright helper:
```typescript
await instant(page, async () => {
await page.click('a[href="/products/123"]');
await expect(page.locator('[data-testid="skeleton"]')).toBeVisible();
});
```
The implementation forces a prefetch before navigation when inside an
`instant` scope (since prefetching is normally disabled in dev), then
holds dynamic data until the scope ends. All code is wrapped in dev
checks and gets dead code eliminated in production.
Current scope is client-side navigations only. SSR/MPA support is
planned as a follow-up.
## Test plan
- `test/e2e/app-dir/instant-navigation-testing-api`
## Summary
- Adds `experimental.varyParams` feature flag (defaults to `false`)
- When disabled, the client skips vary params rekeying and treats params
as unknown
- Allows internal rollout with easy revert if regressions occur during
dogfooding
The flag only gates the client-side rekeying logic in `cache.ts`.
Server-side param tracking continues regardless.
## Test plan
- Enabled the flag in the vary-params and optimistic-routing test
fixtures
During prerendering, track which route params each segment accesses.
Params that are NOT accessed can be omitted from the client router's
cache key for that segment, allowing it to be shared across multiple
pages and reducing the number of prefetch requests.
For example, if a page at /shop/[category]/[itemId] only accesses
`category` but not `itemId`, navigating between /shop/electronics/phone
and /shop/electronics/tablet can reuse the same cached page segment,
since only `category` affects its output.
Most of the client-side changes were already implemented in previous
PRs; the core mechanism was already being used for omitting search
params from the cache keys of static segments. This generalizes the
mechanism for all params. So this PR is primarily about adding per-param
tracking on the server and wiring up the logic to send the params to the
client.
Each segment's CacheNodeSeedData contains its own VaryParamsThenable
that resolves to a Set<string> of accessed param names. The thenable is
a mutable tracker during render that accumulates param accesses, then
resolves when rendering completes. The response also includes an `h`
field for head vary params, which tracks params accessed by
generateMetadata/generateViewport separately from segment body access.
The trickiest part of this implementation is the timing. The vary params
thenables represent metadata about the response, but they're also part
of the response itself - they're streamed alongside the segment data. We
can't know which params were accessed until rendering completes, but we
need to resolve all thenables before aborting the stream, or else the
client would block waiting for data that will never arrive.
We address this on both sides. On the server, we resolve all thenables
immediately before aborting. On the client, we read vary params
synchronously using a React Flight optimization: calling
thenable.then(noop) forces Flight to transition from 'resolved_model' to
'fulfilled' without scheduling a microtask. If the thenable still isn't
fulfilled after this, we fall back to null (unknown) rather than
blocking. This ensures the client never suspends on these thenables,
providing a safe fallback if something goes wrong with server timing.
A key distinction is null vs empty Set. An empty Set means the segment
accesses no params and can be shared across all param values - this is
the case for client components (when Cache Components is enabled),
segments without user code, etc. A null value means tracking failed -
either because it's not wired up yet (like runtime prefetches), or due
to some edge case where the thenables weren't resolved in time.
For segments where we know upfront that no params will be accessed, we
use a singleton emptyVaryParamsTracker that's already resolved. This
ensures these segments resolve correctly even if other tracking fails.
In the future, we'll likely optimize the response format by sending a
bitmask instead of a Set of param names. But this is mostly just an
optimization — sending the Set doesn't expose anything new since the
param names are already embedded in the route tree (although that's also
something we could obfuscate in the future).
This does not yet implement vary param tracking during runtime
prefetches - the abort timing needs additional coordination. Deferred to
a separate PR.
Based on:
- https://github.com/vercel/next.js/pull/88834
- https://github.com/vercel/next.js/pull/88989
- https://github.com/vercel/next.js/pull/88998
---
Adds optimistic routing, enabling the client to predict route structure
for URLs that haven't been prefetched yet. When navigating to a URL like
/blog/post-2, if we've previously learned the pattern from /blog/post-1,
we can predict the route structure without waiting for a server
response.
The client learns route patterns from server responses and builds a trie
indexed by URL parts. When a cache miss occurs, we check if the URL
matches a known pattern. If so, we create a synthetic cache entry from
the template, allowing immediate rendering of loading boundaries.
Static siblings (like /blog/featured alongside /blog/[slug]) are tracked
to avoid incorrect predictions — we only predict dynamic routes when
we're confident no static sibling matches.
Based on:
- https://github.com/vercel/next.js/pull/88834
- https://github.com/vercel/next.js/pull/88989
---
This implements a partial "vary params" optimization for the segment
cache. When a segment doesn't access params on the server, its
prefetched data can be reused across different param values.
Two cases are handled:
1. Segments without a user-provided layout (only loading.tsx or nothing)
2. Segments with a 'use client' layout
The server now includes a varyParams field in segment prefetch
responses. When varyParams is an empty Set, the client re-keys the cache
entry with Fallback for all param values, making it reusable across
different params.
This is a stepping stone toward full vary params tracking where Server
Components would track which specific params they access during
rendering.
Based on:
- https://github.com/vercel/next.js/pull/88834
---
Follows from the previous commits that decoupled route stale time from
segment data. Now that routes have their own lifecycle, we can simplify
the implementation: route stale times are always the static constant,
rather than being derived from server-provided values.
Route structure is essentially static — it only changes on deploy. The
exception is rewrites/redirects in middleware or proxy, which can change
routes dynamically based on request state. But we have other strategies
for handling those cases (e.g., route interception headers, cache
invalidation on auth state changes).
This simplification removes the need to thread stale time through
various call sites. The `fulfillRouteCacheEntry` function now computes
the stale time internally from a `now` timestamp parameter, following
the convention used elsewhere in the codebase.
## Summary
Refactors segment cache entries to receive their stale time from the
server response rather than inheriting from the parent route cache
entry. This decouples the lifetime of segment data from the route tree,
preparing for future optimizations.
When I say "route" here, what I'm referring to is the mapping of URL ->
layout/page hierarchy. Typically this is a predictable mapping based on
the App Router's file-based routing conventions; however, it's possible
to rewrite the URL to a different internal route path.
Conceptually, the cache life of a route corresponds to how often a proxy
is expected to dynamically rewrite or redirect a URL. We don't currently
expose a way to directly control this, but if we did, you could think of
it as being equivalent to adding `use cache` (and `cacheLife`,
`cacheTag`, etc.) to the `proxy()` function.
The point, though, is that the route tree has a different lifetime than
data rendered on the page. Route trees change relatively infrequently,
like due to a logged in/out state change, or a feature flag change. So
we can/should cache them fairly aggressively by default.
This is related to an upcoming change where we'll go even further and
_predict_ routes we haven't visited yet based on structurally similar
routes we've already seen.
This PR itself, though, makes no behavioral changes; it just sets up the
subsequent steps.
## Test Plan
No behavioral changes - existing tests should pass.
This change adds infrastructure for a planned client-side optimization
where the router can infer route structure from previously prefetched
routes, avoiding redundant route tree requests.
When the client prefetches /blog/post-1 and learns the route structure
is /blog/[slug], it could potentially reuse that knowledge for
/blog/post-2 without another server roundtrip. However, if a static
route like /blog/featured exists at the same level, the client needs to
know NOT to apply the dynamic template for that URL.
staticSiblings provides this information. When a dynamic segment like
[slug] has static siblings (e.g., "featured", "archive"), the server
includes them in the prefetch response. The client can then check
whether an incoming URL matches a known static sibling, and if so,
recognize that the previously learned route structure does not apply.
During a Full prefetch (`<Link prefetch={true}>`), check the bfcache for
matching segment data before issuing a new request. The bfcache stores
data from previous navigations, which always includes dynamic data.
This optimization avoids redundant server requests when the user has
already navigated to a route and the data is still fresh according to
the staleTimes.dynamic configuration.
## Summary
This adds a new experimental router method `experimental_gesturePush`
that enables gesture-driven navigation. The feature is gated behind a
new `experimental.gestureTransition` config flag, which implicitly opts
the app into using the experimental React build.
It is _not_ suitable for production use yet; it's a proof-of-concept so
we can test the integration between the Next.js client router and
React's Gesture Transitions experiment. You can use it to build demos,
but it shouldn't be used in a real app.
It dispatches updates through React's useOptimistic hook instead of the
main router state queue, essentially forking the router state until the
gesture completes.
This should only be called inside `startGestureTransition`. It's a
low-level method that is not meant to be used in application code, only
in libraries that know how to properly integrate with both Next and
React.
## Test Plan
- E2E test that simulates a gesture by holding an async transition open
between button clicks
- Run: `pnpm testonly test/e2e/app-dir/gesture-transitions`
Based on:
- #87991
---
The Mutable type was used by an earlier implementation of App Router
when the reducer used to run during React's render phase. It was used to
prevent certain operations from running multiple times if an update was
reapplied by React.
Now that router "reducer" actions run outside React's render phase, we
no longer need this indirection — we can compute the next state object
directly.
Most of the Mutable-related logic was deleted in previous PRs; this
finishes the migration by inlining handleMutable into its callers.
Instead of storing old sibling CacheNodes in the CacheNode tree, thisPR
puts them into a separate BFCache, backed by the same data structure and
LRU that's used by the prefetch cache.
Based on:
- https://github.com/vercel/next.js/pull/87203
- https://github.com/vercel/next.js/pull/87256
---
Currently, there's a cliff when navigating to a route that is unknown to
the client. The prefetch cache is bypassed completely because we don't
know the structure of the target route, and therefore we don't know
which segments we might be able to use to construct a loading state (or
even a fully cached response).
However, because RSC responses are streaming, we can take advantage of
the fact that the first chunk of the response contains the route tree.
So as soon as we receive the first chunk, we can show a cached loading
state to the user (if available), even while we wait for the rest of the
response to arrive from the server.
To access a segment's cache, you need its corresponding RouteTree.
Previously this was not available during an unprefetched navigation
because those objects were only created during a prefetch. But now that
we've migrated the navigation implementation to use RouteTrees, too, we
can access the cache directly inside the main navigation function,
rather than having to do two separate passes like before.
In other words, the only difference between a navigation to an unknown
route versus a prefetched route is the extra latency required to get
those first bytes from the server. Once the route tree resolves, the
rest of the implementation and behavior is identical.
RouteTree is the client version of FlightRouterState. It's the same
representation of the route tree, but it structured for optimized
lookups of the client cache. The plan is the make this the primary/only
type used for dealing with routes on the client; FlightRouterState will
be used a transport format only.
When a parallel route does not match the current URL, in its place we
render the render the last active route that matched that slot. When we
next refresh the page, we track some additional state to inform the
router how to refresh the old, "inactive" routes that aren't reachable
from the outermost, "active" route.
Before this PR, the only state we tracked was the URL of the page that
last matched the inactive segment. After this PR, I am also tracking the
search query string of the old route. This can't be inferred from the
URL alone because the URL may have been rewritten by the server.
We don't need the search query to perform a network request — that is
handled by the URL alone — but we do need it in order to construct a
cache key for the page segments. Today we read the cache key from the
FlightRouterState, which has all the params embedded inside of it, but
eventually I want to remove all uses of FlightRouterState that aren't
about sending/receiving data from the server or storing the tree as
state in the browser's history entry. So this is an incremental step
towards that goal.
Based on:
- #87128
- #87147
- #87134
- #87135
---
This completes the migration of navigation response handling to the new
Segment Cache and PPR-based implementation.
Most of this PR is just deleting vestigial code. No behavioral changes
are expected. The substance of the migration was completed in earlier
steps.
Based on:
- #87134
---
hmr-refresh-reducer is identical to refresh-reducer except for the fact
that it doesn't evict the prefetch cache. So this refactors both
implementations to call the same shared function.
Based on:
- #87128
---
Small refactor. During the initial render to hydrate the page, we create
the CacheNode tree using the same function that is used to create new
trees during soft navigation.
Unlike during a soft navigation, we intentionally don't check if any
data needs to be fetched from the server. We assume the initial
hydration payload is sufficient to render the page.
The completeness of the initial data is an important property that we
rely on as a last-ditch mechanism for recovering the app; we must always
be able to reload a fresh HTML document to get to a consistent state.
This was one of the last remaining places where
fillLazyItemsTillLeafWithHead was used.
This merges the logic inside updateCacheNodeOnPopstateRestoration into
the function used for all other navigation types.
The main difference between history traversal navigations and other
navigation types is our treatment of dynamic data. History traversal
operations are allowed to reuse dynamic data regardless of its freshness
or staleness. So I've updated the `shouldRefreshDynamicData` argument to
be an enum that accounts for our various freshness policies. As of now,
there are three: Default, HistoryTraversal, and RefreshAll.
This is part of an ongoing series of refactors to unify all navigation
types into a single flow, both to simplify the implementation, and to
ensure the behavior is consistent.
Fix for a regression introduced by
https://github.com/vercel/next.js/pull/86367.
When a Server Action performed an external redirect, Next.js was no
longer triggering an MPA navigation from directly inside the response
handler. It would end up working regardless if the Server Action was
called from a React action, like a form action or useTransition hook,
because the Server Action would rethrow the redirect error and trigger a
"late" redirect during rendering. But if the Server Action was called
from outside of a React scope — onClick, for example — it would do
nothing.
Unfortunately this field is not strongly typed because it's a cyclic
type and I wasn't able to soundly model it with TypeScript. Skill issue
I'm sure.
Don't have an exact repro yet but the previous code was obviously wrong.
The way this should have been structured regardless is to delete the
wrapper MapEntry object instead of the value it contains. The
non-nullness of the wrapper object _is_ properly typed, and avoids an
unnecessary indirection.
As a follow up, I'll look into how to model this type properly.