Skip to content

fix(nextjs): Don't report Next.js prerender control flow errors - #23691

Draft
chargome wants to merge 1 commit into
developfrom
charlygomez/js-3434-nextjs-fetch-rejects-after-prerender-completes-on
Draft

fix(nextjs): Don't report Next.js prerender control flow errors#23691
chargome wants to merge 1 commit into
developfrom
charlygomez/js-3434-nextjs-fetch-rejects-after-prerender-completes-on

Conversation

@chargome

Copy link
Copy Markdown
Member

Warning

WIP — open for early feedback. See Open questions below; the e2e variant change in particular is a judgement call I'd like a second opinion on.

Next.js throws a set of errors to steer rendering rather than to signal a failure. unstable_rethrow is the contract for anything that wraps user land in a try/catch, and our wrappers only honored half of it:

digest filtered before
NEXT_REDIRECT;… / NEXT_NOT_FOUND
HANGING_PROMISE_REJECTION
NEXT_PRERENDER_INTERRUPTED
DYNAMIC_SERVER_USAGE
BAILOUT_TO_CLIENT_SIDE_RENDERING

Root cause

Under Cache Components an uncached fetch() inside a prerender is never issued. Next hands out a promise that never settles and rejects it once the prerender is aborted. React discards that rejection — it never reaches the user — but our server component wrapper observed it and reported it, which is where the reporter's 10k+ handled events with no user impact came from.

A second, independent bug amplified this. Since #18408 the filtering sat behind getActiveSpan():

let shouldCapture = true;
if (span) {           // ← only filters when a span exists
  if (isNotFoundNavigationError(error)) shouldCapture = false;
  else if (isRedirectNavigationError(error)) shouldCapture = false;
}

Before that refactor startSpanManual guaranteed a span, so the filters always ran. Afterwards, no active span means nothing is filtered — including plain redirect() and notFound(). That is exactly the Cache Components situation, since nextSpan.ts#shouldNoopSpan skips span creation there and the prerender runs detached from the http.server span. Both wrappers now decide independently of span presence.

Decisions

Cause chains are walked. unstable_rethrow recurses through error.cause, so the shared digest helper does too (depth-capped against self-referencing causes). This also changes the existing redirect/not-found checks, which previously only looked at the top-level error — more correct, but it is a behavior change worth noticing.

Filtering is applied in more than the wrappers. captureRequestError and the DropReactControlFlowErrors event processor got the same check, as a net for paths the wrappers don't cover.

Verifying this was harder than expected

A plain request does not reproduce it — Next replays the build-time shell. It needs a runtime prefetch (RSC: 1 + Next-Router-Prefetch: 2), which makes Next re-run a prerender at request time. I found that by bisecting against a throwaway Next 16.3.3 app rather than guessing, and the e2e test drives it the same way.

More importantly: wrappingLoader is webpack-only. Turbopack builds get generateValueInjectionRules and nothing else, so server components are never wrapped there and no error reaches Sentry at all — I confirmed this with a diagnostic run. My first version of the e2e test passed without the fix for exactly this reason. The reporter is therefore almost certainly on a webpack build, consistent with their "Sentry trace wrapper in the SSR chunk", and the captureRequestError / event-processor changes are defense-in-depth rather than the live path.

The unit tests fail 7/11 with the wrapper fix reverted; the e2e test fails without the fix and passes with it, in both prod and dev.

Open questions

  • The e2e webpack variant. nextjs-16-cacheComponents carried "//": "TODO: Add variants for webpack once supported", so I added one — without it the new test is decorative in CI. All 9 tests pass under it, so the TODO looks stale, but this does mean the app's whole suite now also runs under webpack. Happy to drop it if that's unwanted here.
  • An adjacent leak I did not fix. The same prefetch repro surfaces a DOMException (name: AbortError, code: 20, "This operation was aborted") escaping the same wrapper on routes awaiting params/searchParams under an app-shell prefetch (Next-Router-Prefetch: 3). It has no digest, so this fix doesn't catch it, and it isn't in unstable_rethrow either. Blanket-filtering AbortErrors would swallow genuine ones, so I left it alone — probably its own issue.

Fixes #23592

Next.js throws a set of errors to steer rendering rather than to signal a
failure. Its `unstable_rethrow` defines the contract any code wrapping user
land in a try/catch has to honor, but the Sentry wrappers only recognized
redirects and not-founds. Everything else - most visibly the
`HANGING_PROMISE_REJECTION` that Cache Components produces for uncached
`fetch()` during a prerender - was reported as an error.

Filtering was also coupled to `getActiveSpan()` being truthy, so with no
active span nothing was filtered at all, including redirects and not-founds.

Fixes #23592

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@linear-code

linear-code Bot commented Aug 27, 2026

Copy link
Copy Markdown

JS-3434

@github-actions

Copy link
Copy Markdown
Contributor

size-limit report 📦

Path Size % Change Change
@sentry/browser 28.57 kB - -
@sentry/browser - with treeshaking flags 26.92 kB - -
@sentry/browser - with treeshaking flags tracing without tracing 26.82 kB - -
@sentry/browser (incl. Tracing) 48.63 kB - -
@sentry/browser (incl. Tracing + Span Streaming) 48.65 kB - -
@sentry/browser (incl. Tracing, Profiling) 51.56 kB - -
@sentry/browser (incl. Tracing, Replay) 88.11 kB - -
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags 77.51 kB - -
@sentry/browser (incl. Tracing, Replay with Canvas) 92.82 kB - -
@sentry/browser (incl. Tracing, Replay, Feedback) 105.51 kB - -
@sentry/browser (incl. Feedback) 45.79 kB - -
@sentry/browser (incl. sendFeedback) 33.35 kB - -
@sentry/browser (incl. FeedbackAsync) 38.46 kB - -
@sentry/browser (incl. Metrics) 29.51 kB - -
@sentry/browser (incl. Logs) 29.8 kB - -
@sentry/browser (incl. Metrics & Logs) 30.43 kB - -
@sentry/react 30.31 kB - -
@sentry/react (incl. Tracing) 50.84 kB - -
@sentry/vue 35.69 kB - -
@sentry/vue (incl. Tracing) 50.88 kB - -
@sentry/svelte 28.59 kB - -
CDN Bundle 30.36 kB - -
CDN Bundle (incl. Tracing) 49.12 kB - -
CDN Bundle (incl. Logs, Metrics) 32.56 kB - -
CDN Bundle (incl. Tracing, Logs, Metrics) 51.01 kB - -
CDN Bundle (incl. Replay, Logs, Metrics) 73 kB - -
CDN Bundle (incl. Tracing, Replay) 86.62 kB - -
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) 88.52 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback) 92.4 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) 94.33 kB - -
CDN Bundle - uncompressed 89.97 kB - -
CDN Bundle (incl. Tracing) - uncompressed 146.82 kB - -
CDN Bundle (incl. Logs, Metrics) - uncompressed 96.26 kB - -
CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed 152.51 kB - -
CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed 225.42 kB - -
CDN Bundle (incl. Tracing, Replay) - uncompressed 266.32 kB - -
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed 272 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed 280.01 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed 285.68 kB - -
@sentry/nextjs (client) 53.45 kB +0.09% +44 B 🔺
@sentry/sveltekit (client) 49.08 kB - -
@sentry/core/server 65.3 kB - -
@sentry/core/browser 52.37 kB - -
@sentry/node 122.5 kB +0.02% +22 B 🔺
@sentry/node/import (ESM hook with diagnostics-channel injection) 85.22 kB - -
@sentry/node - without tracing 87.66 kB +0.03% +22 B 🔺
@sentry/node - without channel injection 102.19 kB +0.02% +20 B 🔺
@sentry/aws-serverless 95.82 kB +0.03% +25 B 🔺
@sentry/cloudflare (withSentry) - minified 199.61 kB - -
@sentry/cloudflare (withSentry) 495.94 kB - -

View base workflow run

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

nextjs: fetch() rejects after prerender completes on cacheComponents route with no dynamic data access (HANGING_PROMISE_REJECTION)

1 participant