Add test for render errors caused by headers-keyed module-level caches#96085
Draft
eps1lon wants to merge 1 commit into
Draft
Add test for render errors caused by headers-keyed module-level caches#96085eps1lon wants to merge 1 commit into
eps1lon wants to merge 1 commit into
Conversation
Contributor
Stats from current PR✅ No significant changes detected📊 All Metrics📖 Metrics GlossaryDev Server Metrics:
Build Metrics:
Change Thresholds:
⚡ Dev Server
📦 Dev Server (Webpack) (Legacy)📦 Dev Server (Webpack)
⚡ Production Builds
📦 Production Builds (Webpack) (Legacy)📦 Production Builds (Webpack)
📦 Bundle SizesBundle Sizes⚡ TurbopackClient Main Bundles
Server Middleware
Build DetailsBuild Manifests
📦 WebpackClient Main Bundles
Polyfills
Pages
Server Edge SSR
Middleware
Build DetailsBuild Manifests
Build Cache
🔄 Shared (bundler-independent)Runtimes
📎 Tarball URLCommit: 31f673c |
Contributor
Tests PassedCommit: 31f673c |
A runtime prefetch consists of a prospective prerender that fills caches and a final prerender that produces the response. Both passes share the request and thus the same headers object, but they have different semantics for `connection()`: the promise hangs and is rejected when the pass is aborted. The same sharing exists between a navigation's dynamic render and the runtime prerender that is spawned from it to refresh the client's prefetch cache, where `connection()` resolves in one pass and hangs in the other. A module-level cache that memoizes promises keyed on the identity of the headers object (like `dedupe()` from the Flags SDK, or any per-request memoization that treats the headers object as "the request") therefore leaks promises between passes: the prospective prerender creates a hanging `connection()` promise and is aborted, and the final prerender awaits the same, now-rejected promise and genuinely fails to render the affected subtree. Request-scoped memoization must not outlive a render pass; `React.cache` has the correct scoping. This test documents that the resulting render error is real and is intentionally reported: instrumentation `onRequestError` receives the hanging promise rejection and the error is logged to the server console, pointing users at the unsound caching pattern, while the runtime prefetch response itself stays correct and omits the dynamic content. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
eps1lon
force-pushed
the
sebbie/no-hanging-promise-rejection-instrumentation
branch
from
July 22, 2026 22:33
045cf27 to
31f673c
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Adds a production-mode e2e test documenting that a module-level cache keyed on the identity of the headers object genuinely breaks rendering across prerender passes, and that Next.js intentionally reports the resulting error to instrumentation
onRequestErrorand the server console.Why
A runtime prefetch consists of a prospective prerender that fills caches and a final prerender that produces the response. Both passes share the request and thus the same headers object, but they have different semantics for
connection(): the promise hangs and is rejected when the pass is aborted. The same sharing exists between a navigation's dynamic render and the runtime prerender spawned from it to refresh the client's prefetch cache, whereconnection()resolves in one pass and hangs in the other.A module-level cache that memoizes promises keyed on the headers object (like
dedupe()from the Flags SDK, or any per-request memoization that treats the headers object as "the request") leaks promises between those passes: the prospective prerender creates a hangingconnection()promise and is aborted, and the final prerender awaits the same, now-rejected promise and genuinely fails to render the affected subtree. The rejection then surfaces asreported through
onRequestErrorand logged with the ⨯ prefix. This is exactly what we observed at volume on vercel.com dashboard routes, carried by the Flags SDK's headers-keyed evaluation cache.An earlier iteration of this PR silenced the report by adding
HANGING_PROMISE_REJECTIONtogetDigestForWellKnownError. That was the wrong direction: the render is legitimately failing, and the report is the only signal pointing at the unsound caching pattern. Rejections that stay within a single pass are already handled by React and never reach the error handlers, so no filtering is needed for the well-behaved case. Request-scoped memoization must not outlive a render pass;React.cachehas the correct per-render scoping and is the fix on the userland/SDK side.How
The fixture's
/dynamicpage usesprefetch = 'allow-runtime'and gates its dynamic content on aconnection()promise memoized in a module-levelWeakMapkeyed on the headers object. The test triggers a runtime prefetch through a viewport link and asserts that the prefetch response still omits the dynamic content, and that the hanging promise rejection is reported toonRequestErrorand logged to the server console.🤖 Generated with Claude Code