Hendrik Liebau
90d · built 2026-08-09
90-day totals
- Commits
- 96
- Grow
- 5.5
- Maintenance
- 10.2
- Fixes
- 7.4
- Total ETV
- 23.1
Where this dev ranks
Percentile against the global top-100 leaderboard (all-time totals).
- By commits
- Top 60 %
- By Growth share
- Top 80 %
30-day trajectory
Last 30 days vs. the 30 days before. Up arrows on Growth and ETV mean improvement; up arrow on Fixes share means more time on fixes (worse).
↓-70.6 %
vs 51 prior
↓-8.2 pp
recent vs prior
↑+49.1 pp
recent vs prior
Daily performance
Daily ETV, stacked by Growth, Maintenance and Fixes.
Work-mix over time
Share of Growth / Maintenance / Fixes over a rolling 7-day window. Reads as 'where is effort flowing right now'.
Repository spread
Where this developer's commits land. Concentrated work (top1 > 80%) vs polymath spread (top1 < 30%).
Most impactful commits
Top 20 by ETV in the 90-day window.
- 1.2ETVStream Cache Components dev render instead of restarting on cache miss (#94457) When Cache Components is enabled, `next dev` previously simulated a production loading experience on every cold request. The render did a prospective pass to detect cache misses, and on any miss it waited for every cache to fill via `cacheSignal.cacheReady()` and then restarted the render with warm caches before streaming anything, so the browser saw nothing until the slowest cache had filled. Every cold load blocked on cache population. This change replaces the restart-on-cache-miss flow with a single non-abandoning staged render that streams immediately and fills caches as a side effect. On a cold load the Suspense fallbacks stream right away and the cached content resolves as its cache fills; on a warm reload the staged progression matches the previous no-cache-miss path. The render is split into clearly owned pieces: `setUpStagedDevRender` builds the staged controller, cache signal, and resume cache; `streamStagedRenderInDev{Node,Web}` runs the streaming render and reports a result once the stream has fully finished; and `stagedRenderWithCachesInDev{Node,Web}` returns the stream and leaves the validation follow-up detached so it never blocks the response. The render advances its stages in sequential tasks, and the stream is not handed back the instant it exists: it is held until the render has advanced through the stage whose content belongs in the shell. That is the static stage for initial loads, HMR refreshes, and plain navigations, or the runtime stage for client navigations to a route with a runtime prefetch config, whose runtime-prefetchable content the navigation's prefetch would have settled. It never waits for the dynamic stage. Buffering the shell before the first flush keeps the streaming renderer from emitting a premature Suspense fallback for content that belongs in the shell, and it mirrors production, where the static shell (plus runtime-prefetchable content where configured) is served and the remaining holes stream in as fallbacks. Two internal reads that would otherwise register as synchronous IO and wrongly force the render to the dynamic stage, the cache handler's tag-expiry clock check and the hot reloader's module-scope dev client id, are now read untracked: via `performance.timeOrigin + performance.now()` like the `'use cache'` handler, and only in the browser where the HMR connection reads it, respectively. Cache Components rules validation now runs in that background follow-up, once the streamed render has fully settled. `planDevValidation` inspects the finished render and picks one of three paths: forward an invalid dynamic usage error the streamed render already recorded and stop (for example a request API used inside `'use cache'`); validate the streamed render's own chunks when it neither missed caches nor hit sync IO; or, when it did either, validate a dedicated warm-cache render instead. Because that warm render reads the filled caches back rather than filling them, it can surface an invalid dynamic usage error the cold streamed render cannot, such as a nested dynamic `use cache` cache life that propagated to a parent with no explicit `cacheLife`; that error is forwarded and validation is skipped, just as one recorded by the streamed render is. Since cold loads no longer block on cache fills, the transient cache-status indicator that reflected that wait is no longer emitted; a follow-up will instead add an indicator that tells the user whether a render streamed with cache misses, and so wasn't representative of production.github.com-vercel-next.js · 6f4d94ac · 2026-06-09
- 1.0ETVHonor Suspense-above-body opt-in for dynamic `generateViewport` (#93759)github.com-vercel-next.js · 3cf7aa24 · 2026-05-12
- 0.9ETVMake `cacheMaxMemorySize: 0` and custom cache handlers fast in dev (#94784) When Cache Components is enabled, `next dev` treats a `'use cache'` value as a miss and renders as if the cache were empty whenever the read does not resolve right away. Two development configurations triggered that even for values that were already cached, so warm reloads streamed slowly instead of serving the cached value: `cacheMaxMemorySize: 0` replaced the built-in default handler with a no-op stub, so nothing was cached at all, and custom cache handlers with a slow or remote `get` did not return in time. For the size-0 case, development now uses a real in-memory handler instead of the no-op stub, and the `'use cache'` wrapper forces a dynamic cache life (`revalidate: 0`, a 5-minute `expire`) for it, the same treatment private caches already receive, so every read serves the stale entry and re-warms a fresh one in the background. This also fixes the dev private handler, which was sized from `cacheMaxMemorySize` and so degraded to the no-op stub whenever `cacheMaxMemorySize: 0` was set. Custom cache handlers keep their configured cache life, since their backing owns it. Instead we put a fast built-in in-memory front handler in front of the configured one through the new `TieredCacheHandler`, which serves warm reads from the front, writes through to both tiers, and reconciles the front against the backing in the background, evicting the front entry when the backing no longer has it (the handler interface has no per-key delete, so it overwrites the entry with an already-expired copy). These dev-only handlers are kept out of the registered handler set and merged in only where tag operations iterate, so `revalidateTag` still reaches them. Everything is gated on `process.env.__NEXT_DEV_SERVER`, so production is unchanged: `cacheMaxMemorySize: 0` still caches nothing, private entries are still never persisted, and configured handlers are used directly. New development test suites cover the size-0 and custom-handler behavior.github.com-vercel-next.js · 96e9a8e2 · 2026-06-16
- 0.8ETVFix stale dev `'use cache'` for cookieless requests and route handlers (#96022) In development, editing a file does not evict existing `'use cache'` entries; an entry is invalidated only by re-keying, and the key includes an HMR refresh hash. That hash was delivered through the `__next_hmr_refresh_hash__` cookie, which the browser HMR client set from each server-components change and sent back on later requests. Two cases were therefore broken. First, any request that does not carry the cookie (a `curl`, a plain `fetch`, a fresh browser profile, a second device) served stale cached content after an edit, for both pages and route handlers. Second, on Turbopack a route handler served stale content even when fetched from a page whose HMR client had the cookie, because a route-handler edit never advanced the hash there and so never updated the cookie; on webpack that case happens to work, leaving only cookieless route handlers broken. The hash is server-authored to begin with (webpack's `stats.hash`, Turbopack's `hmrHash` counter), so this change sources it directly on the server and removes the cookie entirely. The hot-reloader exposes it, and `base-server` attaches it to each request with `addRequestMeta`, next to `serverComponentsHmrCache`, so it reaches every render, including the internal Cache Components validation and warmup renders. It is threaded onto the request store the same way `serverComponentsHmrCache` is, so `'use cache'` is invalidated for every client regardless of whether it ran the HMR client. Route handlers, whose `NextRequest` does not carry request meta, receive it through the app-route template's `renderOpts`. Editing a route handler also has to advance the hash in the first place. On webpack it already did, but on Turbopack the `app-route` entry wired no change subscription, so a route edit recompiled the module without advancing `hmrHash`; it now subscribes to the route endpoint so an edit advances the hash without broadcasting a page refresh. That subscription is also made the only thing that advances `hmrHash`: the config-invalidation and server Fast Refresh reset paths previously bumped it even without a content change, for example on a route's first load, and with the hash now read synchronously instead of bounced back through a cookie, such a bump would land between a route's cold write and warm read and evict a still-valid entry. The getter returns the counter unconditionally so the key is present and stable for every request, matching webpack's always-present `stats.hash`. With the cookie gone, the `hash` field on the `SERVER_COMPONENT_CHANGES` HMR message has no remaining consumer, so it is dropped from the message type and both bundlers. This is a short-term stopgap until the granular implementation hash (`codeHash` plus `runtimeEnvVars` plus the Next.js version) is used in `next dev`, which invalidates server-authoritatively by construction and will let this mechanism be removed. > [!TIP] > Best reviewed with hidden whitespace changes. closes NAR-894 --- <sub>Stack created with <a href="https://github.com/github/gh-stack">GitHub Stacks CLI</a> • <a href="https://gh.io/stacks-feedback">Give Feedback 💬</a></sub>github.com-vercel-next.js · 286862e3 · 2026-07-22
- 0.8ETVPersist `'use cache: private'` entries in dev (#94694) Private caches were never stored in a cache handler, so every reload in `next dev` re-ran them from scratch and they registered as a cache miss on each load. This change persists `'use cache: private'` entries in development in a dedicated built-in in-memory handler so that warm reloads are fast. The handler is gated on `process.env.__NEXT_DEV_SERVER` and is kept out of the kind-keyed handlers map so it can never be replaced by a user-configured `default` handler: private cache entries can hold data specific to the incoming request (for example, derived from its cookies or headers) and must never reach a remote or otherwise persistent handler. Production keeps private caches non-persisted as before. The coarse cache-handler key for a dev private cache is scoped by the request's cookies and headers so entries for requests with different request data don't collide. It excludes Next-internal cookies that aren't application data (the HMR refresh hash, already part of the cache key, and the instant-navigation cookie, which toggles while a navigation lock is held) and the transport and content-negotiation headers that vary between otherwise-equivalent requests (a browser reload adds `cache-control`, and `accept` and `sec-fetch-*` differ between an HTML navigation and an RSC request). Keying by only the cookies and headers a cache actually reads is left as a follow-up; read root params are already tracked that way, the same as for public caches. The cache life is forced to `revalidate: 0` with a 5-minute `expire`, so each read serves the stale entry immediately and warms a fresh one in the background through the existing stale-while-revalidate path. Cross-request deduplication now applies to private caches in development too, so concurrent requests with identical request data share a single fill; it remains skipped in production where request-specific data must not be shared across requests. To make this deterministic, `saveToCacheHandler` resolves the metadata a cross-request joiner awaits only after the entry has been written to the handler, so the joiner finds it when re-reading its recomputed key. This closes a pre-existing race in that path, present for public caches too but never surfaced by their cross-request test, where the joiner's metadata could resolve before the handler write had landed. The defensive invariant that rejected reading a private entry from a handler is removed: it only existed to narrow `cacheContext.kind` for a code path that no longer needs it, and dev now legitimately reads persisted private entries while production never registers a private handler to read from.github.com-vercel-next.js · 5b0aa04b · 2026-06-11
- 0.8ETVAdd a cold cache dev indicator (#94611) When Cache Components is enabled, a `next dev` load that streams while filling an empty cache is not representative of production: cached content streams in as it is computed rather than being served instantly, and React's DevTools cannot accurately show what would normally suspend. This surfaces that state in the dev indicator. While a client navigation is pending the rendering pill is colored and labeled by the cache state (teal "Rendering" normally, orange "Rendering (cold cache)" when the render hit an empty cache, and orange "Rendering (cache disabled)" when caches were bypassed), and once the load settles a cold or bypassed load leaves a persistent, dismissible orange badge ("Cold cache" or "Cache disabled") with an info panel that explains why the load was not production-like and suggests reloading once the caches are warm. https://github.com/user-attachments/assets/9be2c35a-3a36-47d7-8803-6e284c332a4b The indicator's displayed state is now owned by a single state machine, `useIndicatorDisplay`, rather than being composed from a debounce (`useDebouncedValue`) and a delayed render (`useDelayedRender`) whose delays compounded and were hard to reason about. It models the indicator as an explicit set of phases (idle, entering, pill, exiting, badge) driven by the raw compiling, rendering, and cache-status signals, and it hands the rendering pill off to the persistent badge in a single commit so the indicator never collapses to the bare logo between them. It also unifies the pre-existing "Cache disabled" badge with the new cold-cache state so both flow through one path (a navigation shows "Rendering (cache disabled)" and then settles into the badge). The Cold cache badge tracks the most recent load, so a later navigation that settles warm clears it. The rework also collapses the timing into a single 200ms window for both showing and hiding, matching the transition used elsewhere in the dev overlay, and relabeling between active states (for example "Compiling" to "Rendering", or the flip to the cold-cache color) is now immediate. This is intentional: the previous debounce held a label on screen past the moment its underlying state ended, so "Compiling" could linger after the compile had finished and make the bundler look slower than it actually was. The one genuine flicker the old debounce guarded against, the pill blinking out to the bare logo when the status briefly drops between rapid compile bursts, is still prevented by the new exit linger. Two cases are knowingly not handled yet: a short-lived `'use cache'` entry and a `'use cache: private'` entry both report a miss on every load, so they show the badge even on a warm reload. These are limitations of the current dev cache behavior rather than of the indicator, and the tests cover them with `TODO`s that point at the follow-up changes that will fix them.github.com-vercel-next.js · 96d7526c · 2026-06-10
- 0.8ETVSurface empty `generateStaticParams` as a redbox with a real stack (#95269) Under Cache Components, a dynamic route whose `generateStaticParams` returns an empty array is intentionally an error, but it surfaced badly: in development the request failed with an uncaught error that left a blank screen and a 500, and a production build printed the error without a stack. The cause was that `throwEmptyGenerateStaticParamsError` deliberately discarded the stack, because the throw happens in framework code in `buildAppStaticPaths` after the user's function has already returned, so there was no user frame to point at. This change adds an SWC transform that gives the error a stack anchored at the user's code. For a `page`, `layout`, or `default` file that exports `generateStaticParams`, it emits a `__next_create_empty_gsp_error` factory whose `new Error` is span-mapped back to the source. It keys off the export rather than any declaration named `generateStaticParams`, so it covers `export function`/`export const`, `export { x as generateStaticParams }`, and `export { generateStaticParams } from '...'`, while ignoring a same-named local helper that is never exported. The anchor is the most specific available: the `return []` literal when it is unambiguously the function's only return, the declaration, or the export statement when the body lives in another module. The transform is registered for both bundlers, gated on Cache Components and excluded from the edge runtime, mirroring the existing `debug_instant_stack` wiring. At runtime the factory is attached to the segment as `createEmptyParamsError` in `collectSegments`, and `buildAppStaticPaths` throws it when it detects an empty result. Because the error now originates in the page module rather than framework code, development surfaces a proper redbox with a meaningful stack and production fails the build with a stacked CLI error. The custom `EmptyGenerateStaticParamsError` name has been dropped since it only added noise to the logs. A few export forms, notably a wildcard `export *` re-export, can't be detected from the page module alone, so when the factory is absent the helper falls back to throwing the message with its natural stack; those framework frames are ignore-listed in the output but stay available for debugging. The build-mode error does not yet include a code frame, because `generateStaticParams` runs in the page-data worker, which lacks the code-frame support that the prerender export worker has. That is left for a follow-up. A new end-to-end test covers the redbox in development for both a literal and a computed empty array, and asserts the per-route build errors in production using `--debug-build-paths` so each route is isolated. Transform fixtures cover the literal, computed, preceding-statement, multiple-return, aliased-export, re-export, and not-exported cases.github.com-vercel-next.js · 814135a8 · 2026-06-30
- 0.8ETVCompute dev fallback params from the most-specific prerendered route (#95066) When Cache Components is enabled, the development server threads a `fallbackParams` request meta for dynamic app routes so the staged render knows which params are not statically known and must be deferred to a later stage. The previous computation walked the prerendered routes from `getStaticPaths` and kept the one with the fewest fallback params, without checking that the route actually matched the requested URL. Consider `/mixed/[lang]/[id]` where `generateStaticParams` covers `lang: 'en'` but not `id`: the prerendered routes are the base `/mixed/[lang]/[id]`, which defers `[lang, id]`, and the covered `/mixed/en/[id]`, which defers only `[id]`. For the request `/mixed/fr/123` the fewest-fallback route is `/mixed/en/[id]`, but `en` does not match `fr`, so applying its `[id]` set left `lang` out of the fallback set and `fr` was treated as a statically known value. Because this meta decides which stage each param resolves in, and the stage decides the environment a replayed `console.log` is attributed to, treating `fr` as static resolved it in the prerender stage instead of deferring it to the runtime stage. The computation now matches the requested URL against each prerendered route with the canonical `getRouteRegex` and, among the routes that match, picks the most-specific one, the one with the fewest fallback params. For `/mixed/fr/123` only the base route matches, so its `[lang, id]` set is used and both params defer, while for `/mixed/en/123` the covered `/mixed/en/[id]` still matches and wins, so `lang` resolves statically and only `id` defers. This mirrors what a production build writes to the prerender manifest, where the server matches the URL to the most-specific prerendered route at request time. The change is development-only, gated on the route module being in dev mode, and production continues to read the manifest. A later change in this stack reads the same `fallbackParams` meta for the Instant Navigation testing API's on-demand shell render, so that path defers the identical per-URL set a production prefetch would.github.com-vercel-next.js · e59eb73b · 2026-06-23
- 0.7ETVHard-navigate to app routes shadowed by a pages dynamic route (#95185) When an App Router route begins with a dynamic segment (for example `app/[locale]/about`), a client-side navigation from a Pages Router page could render the wrong route. The Pages Router resolves the destination against its own routes only, so `/en/about` would match a less specific Pages Router dynamic route such as `pages/[locale]/[category]` instead of the App Router page. The server pools app and pages routes and ranks them by specificity, so a hard reload always rendered the correct App Router page; only the client-side soft navigation diverged. The client router filter is responsible for detecting destinations owned by the other router and forcing a hard navigation, but `createClientRouterFilter` only recorded the static prefix of a dynamic route. App routes whose first segment is dynamic have no static prefix, so they contributed nothing to the filter and the Pages Router never learned to hand them off. The filter now also stores a normalized pattern for those routes, with dynamic segments replaced by a placeholder token (`/[locale]/about` becomes `/[]/about`). After the Pages Router resolves a navigation to a dynamic route, `hasDynamicFilterCandidate` reconstructs the candidate app-route patterns from that route and the concrete path and triggers a hard navigation when any candidate is present in the dynamic filter. This also covers catch-all and optional catch-all pages routes, whose final parameter absorbs a variable number of segments. The shared placeholder token and the client-side check live in the new `dynamic-filter-pattern.ts`, while the build-only encoder stays in `create-client-router-filter.ts`. The change only ever turns a soft navigation into a hard navigation, and only when an app route is genuinely more specific than the resolved pages route, so a structural match always agrees with the server's resolution. Apps without dynamic app routes are unaffected because the dynamic filter stays empty. The `pages-to-app-routing` end-to-end suite is restructured around a `fixtures/` directory and gains coverage for the dynamic-segment shadowing, the catch-all and optional catch-all variants, `basePath`, middleware, and a guard that legitimate Pages Router dynamic routes still navigate client-side. The first request to a not-yet-compiled dynamic route can return a transient 404 in development, a pre-existing source of CI flakiness, so the tests now warm the route up with a direct request before driving the browser. fixes #74696github.com-vercel-next.js · f81c34f8 · 2026-06-26
- 0.7ETVFix Instant Navs DevTools capture bugs and re-enable its test suite (#94866)github.com-vercel-next.js · 553d0b9f · 2026-06-18
- 0.7ETVDiscard only cache entries that predate a tag revalidation (#96726) Calling `updateTag()` in a server action made every later read of a cache carrying that tag regenerate for the remainder of the request, including reads of an entry that had just been generated after the invalidation and therefore already reflected it. Two sequential reads of the same cache function during the re-render produced two different values within a single render, and each one repeated the work. `isRecentlyRevalidatedTag` only asked whether a tag appeared in `pendingRevalidatedTags`, with no notion of when the revalidation happened. That array lives for the whole `WorkStore`, which spans a server action and the render that follows it, so once a tag was in it every entry carrying that tag looked stale regardless of when it had been produced. Each pending revalidated tag now records a `revalidatedAt` timestamp taken from the same clock as `CacheEntry.timestamp`, and the renamed `isRevalidatedAfter` reports an entry as stale only when the revalidation is newer than the entry. `CacheEntry.timestamp` is captured before a fill begins, so a fill straddling a revalidation is still discarded, which is the conservative answer for a body that may have read pre-invalidation data. Revalidating the same tag again moves the timestamp forward, since the later invalidation decides which entries are stale. `previouslyRevalidatedTags` are forwarded from an earlier request by a redirecting server action and carry no timestamp of their own, so the work store now records when the request started and treats them as revalidated at that instant. Entries predating the request are still discarded while entries generated during it survive. The hang-detection probe worker and the dev validation worker take that value from the request they serve rather than reading the clock when they start, which would otherwise date every entry from the outer request as older than the request's own start. The `action-dedupe` fixture covers this end to end: it reads a tagged cache twice inside a server action, revalidates the tag, then reads it twice again during the re-render, asserting that each pair shares a value and that the two pairs differ.github.com-vercel-next.js · 5da1c1ae · 2026-08-05
- 0.7ETVAbort superseded Server Components HMR requests on the client (#95463) In `next dev`, rapid edits can start overlapping Server Components HMR refreshes, but only the newest can commit. When a newer refresh supersedes an older one, we abort the older request on the client so it stops transferring and decoding RSC data, and we keep the browser from reissuing the superseding request as a duplicate. Everything here is gated on the `serverComponentsHmrCancellation` flag. On the client, the router's `hmrRefresh` method tracks the newest refresh generation in an `AbortController` and aborts the previous one before scheduling the new one, threading the abort signal through the HMR action down to the `fetch` call. An aborted request becomes a `NavigationTaskExitStatus.Canceled` rather than a failure, so `finishNavigationTask` leaves its cache nodes for the newer navigation to fulfill and does not retry or fall back to an MPA navigation. `hmrRefreshReducer` also bails out when its generation was aborted before it ran, which happens when an HMR action waits behind a Server Action in the router queue. The subtle part is how an aborted request's partially received response is handled. A superseded refresh can already have committed part of its tree with a Suspense boundary still streaming. Rather than letting the aborted fetch reject the still-pending rows, which a committed boundary would throw on, we decode the response through a wrapper stream that we close, so React marks the unresolved rows as halted: they suspend during render instead of rejecting, and the superseded boundary keeps its fallback until the newer response commits. Closing the stream also avoids the unclosed-stream memory leak that #89610 fixed for prefetch streams. Aborting the superseded fetch exposed a Chromium-only side effect. In development, App Router responses are served with `Cache-Control: no-cache, must-revalidate` so the browser can restore them on back and forward navigation, which keeps each response stored and keyed by URL. Successive refreshes of the same page share one cache entry, and aborting the superseded refresh mid-write leaves that entry half-written; Chromium discards it and reissues another superseding refresh on a second connection. The dev server then renders it twice and emits its debug channel twice under the same request id, producing `Cannot write to a CLOSED writable stream` errors on the client. Firefox and Safari do not reissue. To prevent this we serve HMR refresh responses, identified by the `next-hmr-refresh` request header, with `Cache-Control: no-store`, so no shared entry exists for an aborted refresh to leave half-written. All other dev responses keep `no-cache`, so their restore behavior is unchanged. A development test suite covers a superseded request being aborted while the newest commits without a hard reload, a partially committed render whose Suspense boundary is left streaming not surfacing an error when superseded, and disabling the flag preserving the previous behavior. The supersession tests also assert no browser console errors, which guards against the duplicate debug channel regression. Cancelling the superseded request's server-side render and validation, so the dev server also stops the discarded work, is left to a follow-up.github.com-vercel-next.js · 5f577743 · 2026-07-06
- 0.6ETVReplicate production prefetch shells for instant navigations in dev (#95067) The Instant Navigation lock makes a development render reflect the route's prefetched state: while the lock is held, a navigation shows only what was prefetched and keeps navigation-time data deferred. Both the `instant()` testing API from `@next/playwright` and the Instant Navigation devtools rely on it. The client side of that behavior, restricting the navigation read to the prefetched shell unless the link opts into a runtime prefetch and ignoring cache entries acquired before the lock, landed separately in #95150, which this change builds on. What remained were two ways the development app render diverged from what a production build serves, both in `app-page.ts`, so this PR contains no client-side router changes. While the lock is held, the app render now permits an empty static shell. A route that reads dynamic data such as `cookies()` outside any `<Suspense>` boundary has no static shell, so the on-demand render previously threw a static generation bailout, served an error page, and entered a `/_tree` redirect loop that committed the blocked data. Permitting an empty shell lets the render emit the shell instead. The override is scoped to the prefetch render made while the lock is held, a document request or a `'1'` static prefetch; a regular dynamic navigation, including the one that commits once the lock releases, runs without it. So the validation that flags a blocking route is not weakened: production validation runs at build time, and the development validation still runs and is shown in the dev overlay. The development fallback-shell render now reads the per-URL `fallbackParams` request meta that base-server derives for the requested URL, so the instant shell defers exactly the params a production prefetch would: `generateStaticParams`-covered params resolve in the shell and only the uncovered ones are deferred. When the URL is fully covered the meta is absent and nothing is deferred. That per-URL computation landed in #95066, which this change depends on. This suite now runs with App Shells enabled, which is the default under Cache Components. #94516 had temporarily forced it back to `false` here while the prefetch behavior settled and left the migration as a follow-up; re-enabling it is what lets these tests exercise the app-shell prefetch path the changes above target. New end-to-end coverage exercises the cases this render path affects. A deeper-segment blocking navigation must stay parked on the committed parent while the lock is held. Two mixed routes pair a `generateStaticParams`-covered param with an uncovered one: a plain route where a normal navigation surfaces only the covered param while the uncovered param and a request-time `connection()` sibling stay deferred, and an `allow-runtime` route where a `prefetch={true}` link additionally surfaces the uncovered param from the runtime prefetch. A blocking route that reads request data outside any `<Suspense>` boundary cannot be built for production, so it lives in a development-only fixture, guarded so the production job registers only a placeholder. The default fixture moves the dev-tools indicator to `bottom-right` so the Instant Navigation panel does not overlap the left-aligned test links during a navigation. One client-navigation cookie case is marked `it.failing`: on a non-partial route the speculative static prefetch is fuller than the app-shell render and supersedes it in the segment cache, so the cookie that only the app shell carries never reaches the instant shell. #95150's shell handling only engages under partial prefetching. Closing this needs a separate server-side change so that a route reading `cookies()` during app-shell generation opts into either partial prefetching, where only the app shell is fetched and nothing fuller can supersede it, or a runtime prefetch, where the speculative prefetch carries the cookie and no longer regresses what the app shell initially showed.github.com-vercel-next.js · 1d8e8381 · 2026-06-25
- 0.5ETVReuse completed cache entries for the rest of a request (#96727) Calling the same `'use cache: private'` function twice in one request executed its body twice in production. Preloading at the top of a segment and reading the same function again lower down for composability, which is the shape that motivates a preload in the first place, therefore did the work twice instead of once. The intra-request dedupe map dropped an entry as soon as its fill completed, so it only ever covered concurrent invocations. A later invocation fell through to the cache handler, and the `React.cache` memo wrapping every cache function missed whenever the arguments were not reference-equal. Public caches got a handler hit out of that, but private caches have no handler in production and their entries are excluded from the immutable Resume Data Cache of a dynamic request, so nothing had stored the entry. Completed invocations now move into `completedCacheInvocations` on the work store instead of being dropped, and a later invocation joins that entry. The pending map keeps its previous semantics untouched, because a concurrent joiner shares a fill that is genuinely in flight and must not re-run the discard checks against it, whereas a completed entry is a stored value and is only reused when the caller has not asked to bypass caches and nothing has invalidated it since. Private caches still get no cache handler, so the map is what backs them, and it lives on the work store and so cannot carry request-derived data beyond the request that produced it. Retention is limited to the kinds where it saves real work: private caches, and kinds whose handler came from the platform or from `cacheHandlers` config, where a read can be a network round trip. `isBuiltInCacheHandler` answers that by tracking the handler instances `initializeCacheHandlers` constructs itself. Recording instances rather than kinds is what keeps aliasing right, since a self-hosted `remote` resolves to the very same in-memory handler as `default` and retaining its entries would duplicate that cache for nothing, while a platform-supplied `remote` does not. The `use-cache-custom-handler` suite covers the mechanism rather than the observable value, counting `::get` calls to assert that two sequential reads reach the handler once. Value equality alone cannot distinguish a retained entry from a handler hit, which is also why the new `dedup-sequential` fixture passes without this change and serves only as a regression pin next to `private-dedup-sequential`.github.com-vercel-next.js · 1ff6a803 · 2026-08-05
- 0.5ETVfix: cache miss in App Shell for cached pages with gSP (#95665) If we're prerendering an App Shell, then url data is excluded (i.e. we don't advance beyond the `ShellRuntime` stage). however the prospective prerender was still letting params/searchParams resolve, so if those ended up being inputs to a page, they wouldn't be hanging inputs, and we'd get a cache miss for them in the final prerender. closes NAR-883 --------- Co-authored-by: Hendrik Liebau <mail@hendrik-liebau.de>github.com-vercel-next.js · 63f14c6c · 2026-07-22
- 0.5ETVOpt Partial Prefetching routes into the runtime stage of Cached Navs (#95097) In #95064 we added a global opt-in to the runtime stage of Cached Navigations: setting `cachedNavigations: 'allow-runtime'` makes every route runtime-cache, on top of the existing per-segment `prefetch = 'allow-runtime'`. This extends the opt-in: a `prefetch` config of `'partial'`, `'unstable_eager'`, or `'allow-runtime'` all enable Partial Prefetching for a route, and a route already doing PPR-style partial prefetching should also runtime-cache its navigations. The next-config `partialPrefetching`, which enables Partial Prefetching globally, opts every route in as well. Both runtime-prefetch spawn gates in `app-render.tsx` now broaden their condition to include `partialPrefetching` and any segment `prefetch` that enables Partial Prefetching, via a new `anySegmentHasPartialPrefetchingEnabled` helper. The narrower callers that gate metadata staging and the dev reveal stage keep using `anySegmentHasRuntimePrefetchEnabled`, since they specifically concern routes the client runtime-prefetches (`'allow-runtime'`), not partial ones. Because `partialPrefetching` has no default and is not auto-enabled by `cacheComponents`, apps that do not set it are unaffected. Tests add `prefetch = 'partial'` and `prefetch = 'unstable_eager'` routes to the existing `default` fixture and a new `partial-prefetching` fixture for the global config, each asserting the route runtime-caches its request-derived content on a second navigation without a per-segment `prefetch = 'allow-runtime'` export.github.com-vercel-next.js · 824fcd63 · 2026-06-23
- 0.4ETVCache short-`expire` `'use cache'` values across dev reloads (#95362) Development has recently gained several mechanisms that make `'use cache'` reloads fast under Cache Components: `'use cache: private'` entries are persisted in a dedicated in-memory handler, `cacheMaxMemorySize: 0` uses a real in-memory handler instead of the no-op stub, and custom handlers are fronted by a fast built-in handler through the tiered handler. One case was still missing. A value that opts into a dynamic, client-only life with an explicit short `expire` (for example `cacheLife({ expire: 0 })`, or the built-in `'seconds'` profile) was treated as a miss on every reload, for both the built-in default handler and custom handlers, so reloads re-ran the cache function and streamed slowly. The reason is that `expire` is the value's expiration bound, the longest it may still be served before it has to be treated as expired. That is its purpose in both dev and production; what differs is which threshold the built-in in-memory handler enforces. In `next dev` it serves stale entries up to `expire` to keep reloads fast, whereas in production it drops them earlier, once past `revalidate`. An `expire` of zero therefore leaves the dev handler no window in which a reload can be served from the cache, and the wrapper's serve-vs-regenerate check, which also keys on `expire`, regenerates instead. This change extends the same dev-only treatment to those values without altering their resolved cache life. The built-in default handler now retains an entry for at least `MIN_PRERENDERABLE_EXPIRE` in dev, a minimum the custom front handler inherits by being a built-in default handler, and the wrapper applies the same minimum when deciding whether to serve or regenerate. That affects the retain and serve decisions only; the entry keeps its real `expire`, so the staged dev render still resolves it at the appropriate stage rather than in the shell stage. A short-`expire` entry is also re-warmed in the background on every dynamic request render, so a reload serves the previously cached value immediately and the freshly recomputed one appears on the next reload. This is the same stale-while-revalidate trade-off already accepted for the private-cache and `cacheMaxMemorySize: 0` dev optimizations, which likewise favor a fast reload over serving a value these configurations would not otherwise cache at all. For custom handlers the re-warm re-executes the function and writes through to the backing. Unlike the private-cache case, we deliberately do not force a dynamic cache life here, because forcing `revalidate: 0` would leak into the cache life propagated to an enclosing `'use cache'` and trip the nested-dynamic error with the wrong message. And unlike the size-0 case, keeping the resolved life alone is not enough, because a short `expire` is exactly what makes the dev handler drop the entry, which is why the minimum retention is needed. Because the dev front handler now enforces that minimum, the tiered handler can no longer evict a stale front entry by writing `expire: 0` (the minimum would keep it alive), so `toExpiredEntry` now writes a negative `expire`, which the default handler recognizes as an eviction sentinel and reports as missing regardless of the retention minimum. This mirrors the existing `revalidate = -1` convention, though a negative `expire` means the entry is dropped rather than served-but-revalidated. Everything is gated on `process.env.__NEXT_DEV_SERVER`, so production behaves exactly as before: short-`expire` values keep their real cache life, and configured handlers are used directly. New development tests cover the built-in and custom-handler cases, asserting that a cache-miss navigation shows the Suspense fallback while a cache-hit one does not, and that a reload serves the cached value yet converges to a fresh one. --- <sub>Stack created with <a href="https://github.com/github/gh-stack">GitHub Stacks CLI</a> • <a href="https://gh.io/stacks-feedback">Give Feedback 💬</a></sub>github.com-vercel-next.js · 2850659b · 2026-07-02
- 0.4ETVStatically prerender metadata image routes under Cache Components (#94957) Under Cache Components, metadata image routes such as `opengraph-image` and `icon` that return an `ImageResponse` were always rendered on demand (`ƒ`) rather than prerendered. `ImageResponse` defers rasterizing its element tree into the response body stream, and the route-handler prerender unwraps that body within a single task; the rasterization never finishes within that budget, so the route was classified as dynamic. This change renders and caches the image during the prerender so these routes become static (`○`). During the prerender we serialize the `ImageResponse` arguments with React Flight's `prerenderToNodeStream` inside the prerender work-unit store, which runs the user's component tree once in the correct scope. This brings any user-space I/O inside that tree, such as `cookies()` or an uncached `fetch`, under the same Cache Components rules that already governed I/O elsewhere in the handler. The hanging-input abort signal bounds the serialization and decides static versus dynamic: if the tree is still waiting on dynamic input once the prerender's cache-sourced input is ready, the serialization can't complete and we return a hanging promise, so the final prerender's macrotask budget classifies the route as dynamic; a tree that resolves entirely from static data or `use cache` finishes serializing and is rendered to an image. This mirrors `encryptActionBoundArgs`, which serializes server action bound args with React Flight under the same hanging-input abort signal during a prerender. The fully resolved element tree is then handed to satori. Because React Flight encodes an async Server Component's output as a `React.lazy` that satori can't walk, those references are resolved into plain elements first; this lets an async server component, including one that uses `use cache`, be passed as the `ImageResponse` element. Rasterization runs outside the prerender work-unit store: inside a Cache Components prerender an uncached `fetch`, such as the renderer loading a font, is turned into a hanging promise (Cache Components skips I/O that would not be cached anyway), so running satori with no store lets those framework fetches resolve normally. Crucially, because satori only walks the already-resolved tree, no user component runs in that storeless scope, so uncached user-space I/O can't be wrongly allowed there and let a route that should be dynamic render as static. The rendered image is stored as an `ArrayBuffer` in a new in-memory `imageResponses` store on the Resume Data Cache, keyed by a base64 encoding of its serialized arguments, and the cache signal is held open until it is stored so the prospective prerender waits for it. The final prerender retrieves the array buffer from memory within microtasks. The store is in-memory only and is never serialized, so the image array buffers never enter the resume data that ships with the prerender. The caching path lives in a separate `cache-image-response` module that is loaded only for Cache Components builds, gated behind `process.env.__NEXT_CACHE_COMPONENTS` so the `require` and its React Flight dependencies are eliminated as dead code otherwise; apps without Cache Components keep `ImageResponse`'s original streaming behavior unchanged. --------- Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com>github.com-vercel-next.js · 89c67992 · 2026-06-22
- 0.4ETVPersist debug channel via `IndexedDB` without blocking hydration (#94243) In development the debug channel that streams React's Server Component debug information to the browser is buffered for the initial document and persisted, so that it can be replayed when the browser later serves the page from its HTTP cache — for example on back/forward navigation or tab duplication — instead of forcing a full reload. This changes where that buffer is persisted, moving it from `sessionStorage` to `IndexedDB`. The largest win comes from no longer having to serialize the data. `sessionStorage` can only hold strings, so the binary debug chunks had to be encoded into a string before being written and parsed back out again on restore, which becomes expensive once the payload grows to several megabytes. `IndexedDB` stores the chunks directly as the `Uint8Array`s they already are, so there is no encode and parse round trip on either side. Its asynchronous API helps a little on top of that, since the write itself no longer has to happen synchronously. The other improvement is that persistence no longer competes with hydration. The initial document's debug stream closes while hydration is still running, and the previous synchronous `sessionStorage` write happened at exactly that moment, taking main thread time away from hydration. The write is now deferred with `requestIdleCallback`, so it only runs once the main thread is genuinely idle, which is after hydration has drained, and it is skipped entirely if the page navigates away before that happens, in which case a later restore simply falls back to a reload. This is visible in the profiles below: with `sessionStorage` there is still hydration work running after the persistence task, whereas with the idle-scheduled `IndexedDB` write the persistence only runs once hydration is done. In a profile of a test page that deliberately transfers a large amount of debug information, the persistence work on the main thread dropped from more than 700 ms to roughly 25 to 45 ms. A real application with far less debug data will see a smaller difference, so the test page amplifies the effect, but the direction is the same. The number of persisted entries stays bounded to 10, with the oldest pruned on each write, and an end-to-end test covers the case where an entry is pushed out by newer page loads so that navigating back to it recovers through a page reload. **Before with `sessionStorage`**: <img width="973" height="627" alt="sessionStorage" src="https://github.com/user-attachments/assets/b8ff8464-9ccf-4363-b1fe-78db0fd3e1eb" /> **After with idle-scheduled `IndexedDB`:** <img width="973" height="627" alt="indexedDB" src="https://github.com/user-attachments/assets/94a557a3-c82d-4771-bd81-3b4dfd906f25" />github.com-vercel-next.js · 24ae1613 · 2026-06-02
- 0.4ETV[Flight] Fix stranded row content under Node stream backpressure (#36516) The Flight Server emits Text and TypedArray rows as two chunks: a header that gives the row's id, type, and content length, followed by the content itself. These two chunks were pushed into `completedRegularChunks` (and `completedDebugChunks` in DEV) as separate items, so when the destination signaled backpressure between them, the flush would write the header and then break out before reaching the content. The content chunk was left stranded at the head of the queue. Async work running while the destination was paused appended new rows to `completedImportChunks` / `completedHintChunks`, and the next drain flushed those queues first — splicing the newly-arrived bytes into the position the Flight Client expects to read as the original row's content. From there the Flight Client read rows from the wrong byte offsets and the model failed to deserialize. This only surfaced on the Node stream path. `createFakeWritableFromReadableStreamController`, used by `renderToReadableStream`, always returns `true` from `write()`, so the flush loop never saw backpressure. The fix pushes a `NEXT_TWO_CHUNKS_ARE_ATOMIC` sentinel ahead of each `headerChunk` / `contentChunk` pair in `completedRegularChunks` and `completedDebugChunks`. The flush loops detect the sentinel and write the two chunks that follow it together before re-checking backpressure, so backpressure can still break between rows but never within one. ### Alternatives considered - **Pushing the pair as a `[headerChunk, contentChunk]` tuple.** Simpler but allocates an array per row. The required `isArray` branch in the flush hot path is likely comparable with the symbol check. This also violates the opaque `Chunk` type boundary. - **Concatenating header and content into one chunk.** Bad for memory — typed-array content can be large. - **Storing atomic groups in a separate queue.** Conceptually wrong and risks breaking reference-ordering assumptions between rows. - **Ignoring backpressure until the regulars queue is empty.** Defeats the point of backpressure. - **Wrapping the tuple behind a host-config API** (`writeAdjacentChunks` / `isAdjacentChunks` / `chunksToAdjacentChunks` / `getAdjacentChunksLength`). Keeps the implementation opaque but adds four exports per host config. Also has the tuple overhead. - **Begin/end sentinels for variable-length atomic groups.** Not needed — only Text and TypedArray rows use this pattern, and both are pairs.github.com-facebook-react · c0148134 · 2026-05-27