Hendrik Liebau
90d · built 2026-07-24
90-day totals
- Commits
- 102
- Grow
- 5.6
- Maintenance
- 11.8
- Fixes
- 7.5
- Total ETV
- 24.8
Where this dev ranks
Percentile against the global top-100 leaderboard (all-time totals).
- By commits
- Top 59 %
- By Growth share
- Top 78 %
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).
↓-13.2 %
vs 38 prior
↓-5.0 pp
recent vs prior
↑+12.9 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.1ETVDetect `'use cache'` module-scope deadlocks early in dev (#93500) When a `'use cache'` fill stalls in dev today, the user has to wait the full `useCacheTimeout` — 54 seconds by default — before any error surfaces, and the resulting `UseCacheTimeoutError` is too generic to point at the cause. A common cause is module-scoped state that ends up joining a promise from the outer render scope — for example a top-level `Map<string, Promise>` used to dedupe fetches, where the cache body and the outer scope both await the same promise. That promise hangs because Next.js intentionally converts uncached fetches into hanging promises during prerendering (and in dev while filling caches in the static or runtime stage), so the cache function ends up waiting forever on an outer-scope fetch that will never resolve. Most users reload long before the timeout appears, so they never see any signal that something specific is wrong. This change adds a dev-only probe that surfaces this class of deadlock earlier. Once a cache fill has been idle for ten seconds, the dev server re-runs the same cache function in a worker thread with a fresh module scope. If it completes there, the hang in the main process is attributable to outer-scope state, and we abort the fill with `UseCacheDeadlockError` whose message points the user at the dedupe pattern and how to fix it. If the probe also hangs or fails for any other reason — decode failure, missing module, the body throwing — we fall back to the regular cache-fill timeout, so the probe is a positive signal only and never false-positives a deadlock. The probe is gated on `__NEXT_DEV_SERVER` and tree-shakes out of the production runtime entirely. The worker pool is lazy, with no process forked until a probe actually fires, reused across probes for the same dev session, and torn down on HMR or worker crash. A snapshot of the outer request store is forwarded to the worker so that cache functions — including private caches that read `cookies()`, `headers()`, or `draftMode()` — see the same values they would in a real invocation.github.com-vercel-next.js · 88368254 · 2026-05-06
- 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.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.5ETVHonor the route-level `expire` value with blocking revalidation (#93211) A prerendered route's `expire` — set via `cacheLife({ expire })` inside `'use cache'` or via the `expireTime` config fallback — lands in the prerender manifest as `initialExpireSeconds` / `fallbackExpire` (#76207), but the runtime never read it: `IncrementalCache.get` only considered `revalidate`. So past expire, Next.js served stale with a background refresh instead of the blocking regeneration the `cacheLife` `expire` docs describe. The fix is three coordinated changes. The render-time `responseGenerator` in `app-page.ts`, `app-route.ts`, and `pages-handler.ts` now applies the `expireTime` fallback as soon as it has the render's `cacheControl`, so every downstream consumer (the cache stored via `IncrementalCache.set`, the response `Cache-Control` header, the entry returned to `handleResponse`) sees a finalized `cacheControl` with a populated `expire` — mirroring the build-time fallback. `IncrementalCache.get` then returns `isStale = -1` when `lastModified + expire * 1000 < now`, and `response-cache.handleGet` skips its early `resolve(previousEntry)` for `isStale === -1` so the blocking revalidation inside `responseGenerator` (which already picks `BLOCKING_STATIC_RENDER` on that signal) can return its fresh output to the user. Previously the early resolve committed the stale value to the response first, so even though `responseGenerator` still ran a fresh render its output only warmed the cache for the next request. As a side effect this also closes the same early-resolve hole on the existing tag-expired `isStale = -1` path. On Vercel, ISR cache decisions live at the Proxy and the Proxy currently ignores `staleExpiration` (using a hard-coded one-year value instead). It is also expected, once it starts honoring `staleExpiration`, to pick up updated values from the `stale-while-revalidate` response header. Until that lands this change is only observable on `next start` — deploy-mode behavior is tracked independently of Next.js. Two test suites cover the new behavior. `test/production/app-dir/use-cache-expire` uses `cacheComponents` + `cacheLife({ expire: 300 })` with a custom cache handler that shifts `lastModified` via an `x-test-cache-age-offset-ms` header, exercising the fully-static shell, the partially-static route shell for a known param, and the partially-static fallback shell for unknown params. `test/e2e/app-dir/expire-time` covers classic ISR (`revalidate = 1`, `expireTime: 2`) with a real three-second wait and is `it.failing` on deploy, so it will flip the moment the Proxy honors the expire value. fixes #78269github.com-vercel-next.js · 8e4cfc50 · 2026-04-29
- 0.5ETVEncode non-ASCII characters in cache tags at construction (#93601) When a cache tag contains a non-ASCII character (Hebrew, CJK, emoji, …) it gets written into the internal `x-next-cache-tags` HTTP header on ISR responses. Node's `validateHeaderValue` rejects any byte outside `\t\x20-\x7e`, so the response crashes with `ERR_INVALID_CHAR`. On Vercel deploys stale-if-error masks the 500 from clients, but revalidation itself keeps failing and the cache stops refreshing for affected routes. This change introduces a single `encodeCacheTag` helper and applies it at every public boundary — `validateTags` (which `cacheTag()`, `unstable_cache()`, and `fetch` tags all funnel through), `getImplicitTags` for path-derived tags, and `revalidatePath` / `revalidateTag` / `updateTag` for invalidation inputs. The encoder matches runs of out-of-class code units so surrogate pairs reach `encodeURIComponent` intact, and it is idempotent on already-encoded `%xx` sequences, so callers can pass either the raw or the encoded form interchangeably. PR #93139 already encodes path-derived tags at construction, but it misses every user-supplied tag entry point and uses a `decodeURIComponent` round-trip that silently mangles literal `%xx` characters in tag values. PR #93167 encodes only at the `setHeader` sites, which leaves storage and invalidation diverging and requires every new write site to remember the encoding step. The canonical-form-at-the-boundary approach taken here covers all entry points and keeps storage, comparison, and the wire in sync. fixes #93142 closes #93139 closes #93167 Co-authored-by: Swarnava Sengupta <swarnava.sengupta@vercel.com> Co-authored-by: Or Nakash <ornakash@gmail.com>github.com-vercel-next.js · 9e183033 · 2026-05-07
- 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