Hendrik Liebau
90d · built 2026-09-30
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.5engineers
delivers like 2.5 (2.5x pre-AI)
Output (ETV)
20.4ETV
−6.0% vs 21.7 prior
Features share
10.0%
−10.2 pp vs prior window
Fixes share
28.3%
+0.6 pp vs prior window
Work mix
10% Features7% Maintenance52.1% Tests2.7% Docs28.3% Fixes
76 commits over 90 days, ending 2026-09-30.
Where this dev ranks
Percentile against the global top-100 leaderboard (all-time totals).
- By commits
- Top 59 %
- By Features share
- Top 90 %
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
- 1.4ETV[Flight] Keep an element pending while a referenced row is resolving (#37542) A row that is still parsing can hand its partially built value to references that were registered on it during that parse. `initializeModelChunk` fulfilled every such listener, on the assumption that all of them are cyclic references back into the parsing row. Only some are. A listener from a nested parse that is not part of a cycle belongs to a handler that does not wait on the parsing row, so fulfilling it early completes that handler with an object that still has references outstanding. When that handler owns an element, `initializeElement` runs on incomplete props. In DEV the props are frozen, so the write that arrives later throws `Cannot assign to read only property`, and `rejectReference` escalates the error into the rows that wait on the element, up to the root. Only the debug tree can produce this shape. The RSC stream writes element props inline, while the debug channel outlines a props object that an element shares with its own componentInfo into a separate row, and that row can still wait on a client module. `resolveBlockedCycle` already tells a cyclic reference from any other, but it returned `null` for mid-parse listeners because `handler.chunk` was assigned after the drain loop. This change assigns it before the loop and classifies each listener. A reference whose handler is transitively waiting on the parsing row is a genuine cycle and receives the value now, because neither side can complete before the other. Every other listener is queued back on the parsing row and fulfilled when that row completes, like any reference into a blocked row. A row whose own parse fails used to hand the partial value to its mid-parse listeners before it threw. It now errors through `triggerErrorOnChunk`, which rejects them the same way a reference into any other errored row is rejected. The `if (handler.errored) throw` after the loop is removed. It also caught a rejection during the loop, which now reaches `triggerErrorOnChunk` on its own because `handler.chunk` is set. One behaviour changes beyond the reported bug. When `initializeDebugChunk` errors a chunk before `parseModel` runs, the old code set `INITIALIZED` over that status if the model had no pending references, and left it `ERRORED` otherwise. The chunk now stays `ERRORED` in both cases. That is what the `triggerErrorOnChunk` call in `initializeDebugChunk` intends, and the TODO above the `parseModel` call already notes that the chunk can be `ERRORED` there. PR #37398 deferred `Object.freeze(element.props)` until the outstanding references have resolved. That removes the exception but not the cause: the element is still initialized on an incomplete object and is visible through `_debugInfo` with a `null` placeholder until the late write lands. With the early release fixed, the freeze needs no change. **Alternatives Considered** - Deferring every listener whose handler is not the parsing row's own deadlocks `foo ↔ bar` in `can deduped outlined references inside promises`. One side of a genuine cycle has to accept the partial object. - Holding an element back while its props row is `BLOCKED` breaks `should handle deduped props of re-used elements in fragments`, where the row is blocked on an unrelated module and the props object itself is complete. - A per-object count of pending writes plus a reverse `dependents` edge works, but adds a second dependency graph next to `deps` and special-cases elements. Fixes #37361 Closes #37398github.com-facebook-react · 6c0e1047 · 2026-09-08
- 1.3ETVPreserve error boundaries during on-demand prerendering (#99037) In production, an error while generating an App Router page on demand can return a generic 500 response or a Pages Router 500 page instead of the route's `error.tsx` or `global-error.tsx`. This affects dynamic routes using `generateStaticParams`, even though their error boundaries work in development. A completed prerender failure now carries its error and recovery output through the renderer and response cache as an explicit result. The response cache retains completed failures within each platform invocation, without changing existing in-flight batching. Failed output is not persisted as a successful ISR entry. The server sends recovered documents with status 500 and non-cacheable headers. Failed RSC responses have empty bodies; navigation falls back to loading the document. Build failures and incomplete recovery continue to throw. Asynchronous error reporting runs in the background and uses the platform's `waitUntil` when available. Recovery that requires resuming a partially prerendered page is out of scope for this PR and continues to throw. A follow-up PR will preserve and resume that partial recovery output. The fix applies to self-hosted production servers and adapter-based Vercel deployments. The legacy Vercel deployment path retains its existing behavior of replacing the response with a generic error page. The tests cover both Cache Components settings, batched invocation failures, pending I/O inside Suspense, same-URL recovery, successful-page retention, and original-error reporting. Fixes #62046github.com-vercel-next.js · c54cc098 · 2026-09-23
- 1.3ETVAlign fallback parameter staging with shell validation (#98512) `next dev` reported missing Suspense boundaries in layouts that the build accepted. For `/[top]/items/[bottom]`, `generateStaticParams` returned `[{ top: 't1' }]`, and the page wrapped its `bottom` access in Suspense. A request for `/t2/items/b2` still reported the layout's access to `top` as an error. Development treated both parameters as unresolved because the requested `top` value was not generated, although the required static shell only needed to defer `bottom`. Production Cached Navigations used the same overly broad parameter set and omitted eligible static content from repeat visits. Resumes also reconstructed that set from the original build manifest, even after an on-demand prerender had produced a more complete shell. This replaces the alternative proposed in #98460. That proposal fixes the development error by selecting a separate fallback parameter set for validation while keeping response staging unchanged. The two sets are not intended to differ for the same shell target. Correcting only validation would preserve the incorrect staging decision and leave production Cached Navigations without the eligible static content. Staging and static-shell validation now use one `stagedFallbackParams` set for each selected shell target. Required partial shells retain their unresolved parameters, even when a later request can complete them. Prerenders record their parameter set in postponed state, and resumes use that recorded set rather than reconstructing it from the generic source. Dynamic RSC requests now read and revalidate the completed-shell cache key, so they find partial artifacts that the fully resolved pathname lookup missed. Request metadata and `RequestStore` both expose the set as `stagedFallbackParams`. Action-only fallback detection checks actual unresolved parameters instead of treating deferred values as missing.github.com-vercel-next.js · 1a295a63 · 2026-09-11
- 0.8ETVFix missing content in Cached Navigations (#98767) React can render outlined elements in later `setImmediate` callbacks after an asynchronous cache read resolves. `cacheReady()` can resolve before those elements start their cached reads. The embedded prerender for Cached Navigations then reports unexpected cache misses and omits cacheable content. This change tracks pending native immediates in an `AsyncLocalStorage` scope around each Cache Components render. `CacheSignal` waits for that work before accepting a zero read count. If more native immediates appear before the readiness timer runs, it waits and checks again. Tracking does not make native immediates fast or change the existing explicit, task-local fast-immediate behavior. The e2e regression uses a delayed cache handler and a large outlined tree. It verifies that Cached Navigations reuse outlined content while content behind dynamic I/O remains uncached. Each test invocation uses a fresh cache key through `searchParams`, so cases and retries cannot reuse previously warmed entries. Unit tests in the existing immediates suite cover scope isolation, nested scheduling, cancellation, native scheduling semantics, and error recovery. Fixes #98543github.com-vercel-next.js · ec0abf41 · 2026-09-23
- 0.8ETVFix duplicate background revalidation for `'use cache'` (#98446) An early `cacheHandler.set()` call previously made subsequent same-key `get()` calls wait for generation and persistence. This prevented another background revalidation, but those requests waited instead of receiving the existing stale data. The work in #94694 added private-cache persistence in dev. Its helper refactor made cross-request invocations wait for cache writes before retrying root-param-specific lookups. It also moved the coarse-key `set()` call after entry collection. That unintentionally allowed subsequent requests to receive stale data and start another background revalidation while one was already running. This change tracks background work separately for each lookup key within a runtime instance. Subsequent requests can receive the existing stale entry without waiting for generation or starting duplicate work. The task remains registered until collection and its cache writes settle. Each lookup records existing work before calling `get()`, so a slow lookup does not start another revalidation after the task it observed has completed. The handler still receives `set()` after collection, including asynchronous rendering of the cached result. Subsequent `get()` calls can still block while the handler persists the entry. We accept that remaining wait to preserve the cache-handler contract without bypassing handler reads. **Alternatives Considered** - Restore early `set()` calls and rely on the handler's pending-set promises. This would prevent duplicate regeneration, but would also make subsequent requests wait for generation again instead of receiving stale data. - Extend the existing cross-request sharing mechanism through background revalidation. We prototyped retaining stale and regenerated results, but that required additional stream ownership, invalidation checks, and root-param matching. The separate background-task map avoids those changes and leaves cold and foreground generation unchanged. Fixes #98326github.com-vercel-next.js · ac55fd99 · 2026-09-10
- 0.7ETVFix unexpected query parameters in adapter deployments (#98584) The Vercel adapter enables `passQuery: true`, which forwards named route captures to prerender functions as query parameters instead of using the legacy route-matches header. When a request bypasses prerendered output, this also exposes internal path-building captures through application `searchParams`. With `experimental.collapseAdapterRoutes` disabled, RSC renders can receive `rscSuffix=.rsc`. With the flag enabled, document and Server Action renders can also receive `rscSuffix=`, and collapsed fallback shells can expose values such as `shellPrefix=en`. These additions can overwrite user query parameters and cause unnecessary navigation retries. We fix this in `handleBuildComplete`, where Next.js generates these path-only helper captures. Unnamed captures and numbered destination references preserve the rewrite behavior without requiring adapters to recognize and filter Next.js's internal helper capture names. When using an adapter, we now emit unnamed captures for these path-only values. For example, `(?<shellPrefix>de|en)` and its destination reference `$shellPrefix` become `(de|en)` and `$1` when no locale capture precedes the group. Likewise, `(?<rscSuffix>...)` and `$rscSuffix` become `(...)` and a positional reference such as `$2` when one capture precedes the suffix. The generator accounts for parameter and locale captures and escapes literal base paths. Destination substitution runs once, preserving captured text and correctly distinguishing references such as `$1` and `$10`. Cache-bypass rules, route counts, and route ordering remain unchanged. User-supplied `rscSuffix` and `shellPrefix` remain valid query parameters. Standard `next dev` and `next start` routing, and Vercel's legacy deployment path, do not use these generated adapter routes, and thus are not affected. Verified by running the [full deploy test matrix](https://github.com/vercel/next.js/actions/runs/34835432358) against this PR.github.com-vercel-next.js · bfcf687f · 2026-09-15
- 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.6ETV[Flight] Preserve leading `U+FEFF` in text rows (#37625) The Flight client dropped a leading `U+FEFF` from outlined text rows when it decoded binary input. `TextDecoder` consumed the character as an encoding signature, even though it was part of the serialized string. Both the Node and Web decoders now use `ignoreBOM: true` to preserve it. Regression tests cover one and two leading U+FEFF characters with normal chunks and with every UTF-8 byte delivered separately. They also include an inline-string control. The global `TextDecoder` Flow declaration now makes `fatal` optional and declares `ignoreBOM`, matching the API.github.com-facebook-react · f8e63c66 · 2026-09-22
- 0.6ETVFix custom cache handlers missing on initial requests (#99372) Startup preloading can initialize the built-in handler map before a configured handler finishes loading. A request that reaches `'use cache'` during the import can use the built-in handler instead of the configured handler. Next.js now waits for registration to finish before rendering that request. The server, route module, and export helper share the registration promise. Regression tests verify that the first request uses the configured handler with startup preloading enabled. The affected fixtures no longer disable preloading. Preloading also logs failed handler imports without an unhandled rejection.github.com-vercel-next.js · b2534bc4 · 2026-09-28