Hendrik Liebau
90d · built 2026-09-08
Performance
What Hendrik Liebau shipped in the selected window, measured in ETV, and how it compares with the 90 days before it.
Effective capacity
+1.2engineers
delivers like 2.2 (2.2x pre-AI)
Output (ETV)
17.9ETV
−2.5% vs 18.4 prior
Features share
16.8%
−9.8 pp vs prior window
Fixes share
23.1%
−5.4 pp vs prior window
Work mix
16.8% Features7.6% Maintenance47.4% Tests5.1% Docs23.1% Fixes
100 commits over 90 days, ending 2026-09-08.
Where this dev ranks
Percentile against the global top-100 leaderboard (all-time totals).
- By commits
- Top 58 %
- By Features share
- Top 84 %
Daily performance
Daily ETV, stacked by Features, Maintenance, Tests, Docs and Fixes.
Repository spread
Where this developer's commits land. Concentrated work (top1 > 80%) vs polymath spread (top1 < 30%).
Most impactful commits
Top 10 by ETV in the last 90 days.
- 1.6ETV[Flight/Fizz] Stop the caller's signal from retaining a finished render (#37315) Every server entry point that accepts a `signal` attached an abort listener to it and only ever removed that listener from inside the listener itself. On the success path the signal never aborts, so the listener stayed attached and its closure kept the whole `Request`, and therefore the entire rendered output, reachable for as long as the caller's signal lived. This matters most for composite signals from `AbortSignal.any()` and for timeout signals, because the runtime retains those for as long as they carry a non-weak abort listener, and releases them only when the last listener is removed or the signal aborts. A composite passed to `prerender()` therefore became a garbage collection root holding a finished render for the lifetime of the process. A plain `AbortController` signal is never retained that way, but it still keeps the render reachable for as long as the caller holds the controller. Each listener is now bound to a lifetime signal passed to `addEventListener`, so the runtime removes the listener as soon as that signal aborts and nothing has to track a teardown function. Flight reuses `request.cacheController`, which already aborts on a fatal error, at the completion of the flush loop (depends on #37342), and in `abort()`. Fizz has no equivalent, so it gains a `renderLifetimeController` that aborts at those same three points. `processReply` creates its controller only when a caller passes a signal, so a reply without one allocates nothing. Since `abort()` returns early once the request is past `OPEN`, removing the listener at those points cannot change observable behavior. The fifty-two copies of the listener block across the entry points collapse to a single `attachAbortSignal` call each. Binding the listener to the render also covers a cancelled stream, which calls `abort()` without the request ever reaching a terminal status, so a teardown driven by that status would have left the listener attached. Fizz ends the lifetime in `fatalError` rather than at the `CLOSING` to `CLOSED` transition, because a shell error rejects before the caller receives a stream. Nothing then consumes the request, it never closes, and a listener waiting for that transition would never come off. The two new controllers are aborted with an explicit reason. A call to `abort()` without one constructs an `AbortError` DOMException. Capturing the stack trace dominates that cost, and the cost grows with the depth of the stack, so every render and every reply would pay for an object that no code reads. `processReply` no longer returns its `abort` function, because that return value existed only so each `encodeReply` implementation could wire the signal up itself, and nothing uses it now that the wiring lives inside. A reply whose model settles synchronously gets no listener, since aborting it was already a no-op. The tests assert on the lifetime signal, because the runtime's removal does not go through `removeEventListener` and is therefore invisible to a patched signal. `ReactFlightDOMNode-test` asserts the removal itself with `getEventListeners` from `node:events`, which jsdom has no equivalent for. Two cases stay open. A request whose stream is neither consumed nor cancelled never ends, and a reply with a part that never settles never settles either, so both keep their listener.github.com-facebook-react · 77ed3f54 · 2026-08-22
- 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.7ETVPersist `'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.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.6ETVFix 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.6ETVMake `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.6ETVCompute 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.5ETVAbort 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.5ETVDiscard 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.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