Repository navigation
Report global errors once with several Providers or StrictMode (#108) - #171
devtools-agent[bot] wants to merge 6 commits into
Conversation
Every Provider built its own Rollbar instance in its constructor, and every instance with captureUncaught / captureUnhandledRejections adds its own window.onerror and unhandledrejection handler. rollbar.js only filters duplicates within one instance, so one error was reported once per Provider (#108). Under StrictMode a single Provider reported twice, because React constructs class components twice in development and the discarded instance's handlers stay installed. - Create the instance on first render instead of in the constructor, so the constructor StrictMode discards never builds one. - Coordinate global capture between Provider-created instances: the first one to render owns each kind of capture; later ones are built with it off and take over when the owner unmounts. The handoff flips the options through configure(), which rollbar.js checks on every call. Instances passed via `instance` aren't touched. Fixes #108 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
rollbar-circleci-machine
left a comment
There was a problem hiding this comment.
AI Agent Review (openai, openai-astra)
Coordinates global error capture across Providers so that only one Provider-created Rollbar instance handles window.onerror / unhandledrejection at a time. It also moves instance creation from the constructor into the first render, so StrictMode's thrown-away constructor call doesn't build a second instance.
What I traced against the installed rollbar 3.1.0 and React 17.0.2 source:
configure()merges options (utility.js:791-801) and re-runssetupUnhandledCapture()(core.js:111-135).- A disabled instance's handlers stay on
windowbut return early (core.js:234-240,338-344), so turning off the previous owner stops duplicate reports. - The takeover, remount, nested and StrictMode paths behave as the new tests expect. I traced the tests by hand; I have not seen them run.
One issue: the instance and its claim are created during render, and a claim is only released when its Provider unmounts. If React throws away a Provider's first render, the claim stays pending forever. That instance keeps capturing global errors, and the Provider that actually mounts never takes over. Details are in the finding.
Minor, not flagged: Provider no longer sets this.state, so ref.current.state.rollbar, which the old ProviderState typing exposed, is now undefined at runtime. It was never documented API, but it is an observable change.
A Provider mounting next to a pending owner only waited for it, since a pending owner could be an ancestor that mounts later in the same commit. But if React threw that owner's render away (for example a concurrent root re-rendering after a caught render error), it never mounts or unmounts: its instance kept reporting uncaught errors without the person or context set on the provided instance, and no later Provider on the page ever took over. Each claim now records the enclosing Provider's claim, passed down through context. On mount, a pending owner keeps capture only if it is an ancestor; any other pending owner is replaced like an unmounted one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
rollbar-circleci-machine
left a comment
There was a problem hiding this comment.
AI Agent Review (openai, openai-astra)
Reviewed the new src/global-capture.js coordination, the Provider changes, the README text and the new tests against the checkout and the installed rollbar@3.1.0 / react@17.0.2.
What holds up
- Creating the instance in
render()instead of the constructor fixes the StrictMode double instance on React 17. The throwaway instance never renders, and the doublerender()reuses the cachedthis.rollbar. - The PR switches capture on and off with
rollbar.configure(...). In rollbar 3.1.0 afalseflag overridestrue(utility.js:858-866), a switched-off instance's handler returns immediately (core.js:234-240,338-344), and switching capture back on installs the handlers (core.js:133,208-232). - One Provider at a time owns capture, both across siblings (earlier renders mount first) and when nested (children mount before parents).
Issue (low)
- If nested Providers are both inside a render React throws away (an error boundary that remounts its children, like the test's
Retry), the inner Provider ends up owning global capture and the outer one waits. That contradicts the nested test, the comment inmountGlobalCaptureand the README. Errors are still reported only once, but under the inner Provider's config.
Minor notes (not raised as findings)
- If a custom
Rollbarconstructor throws right after its claim switched off an unmounted owner, nothing captures global errors until another Provider claims. ProviderStateis gone fromindex.d.ts; nothing in the repo references it.
The tests were not run as part of this review.
When a render holding nested Providers was thrown away, the inner Provider that mounted took over global capture, because it mounts before its parent and the stale owner wasn't its ancestor. It now hands capture to its outermost enclosing Provider that captures the same kind, which mounts later in the same commit. The same thing happened with nested Providers under StrictMode on React 18 and 19 (createRoot), with no thrown-away render: StrictMode unmounts and remounts every component after the first mount, and the owner's unmount handed capture to its own child. An unmounting owner now skips Providers nested inside it, since they unmount or disappear with it, and hands capture to the outermost waiting Provider around the one it picks. If a custom Rollbar constructor throws after its claim switched off an unmounted owner, that owner is now switched back on instead of leaving nothing capturing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
rollbar-circleci-machine
left a comment
There was a problem hiding this comment.
AI Agent Review (openai, openai-astra)
Report global errors once with several Providers or StrictMode (#108)
The coordination is correct in every scenario I traced. I read src/global-capture.js and src/provider.js in full and walked through React 17 legacy order, React 18 StrictMode (children unmount first, then remount) and React 19 StrictMode (parents disappear first, children reappear first). I also covered siblings, nesting (including through an instance Provider), renders thrown away by an error boundary or Suspense, a constructor that throws (including React 17's DEV replay), and interleaved commits across multiple roots. Two things held throughout:
- At most one managed instance has each kind of capture enabled at a time.
- Every path that disables an owner also enables a replacement (
takeOver, or the unmounted-owner branch ofclaimGlobalCapture), andabandonGlobalCaptureputs the previous owner back.
Switching capture through configure works in the pinned Rollbar (3.1.0). configure re-runs setupUnhandledCapture (node_modules/rollbar/src/browser/core.js:111-135, :208-232). handleUncaughtException and handleUnhandledRejection check the options each time they run (core.js:234-240, :338-344). The window.onerror chaining (node_modules/rollbar/src/browser/globalSetup.js:1-27) explains the original duplicate reports. The peer range also allows rollbar ^2.26.4, which isn't installed here, so I couldn't check 2.x.
Behaviour changes worth noting (none of them defects):
- Several Providers with different access tokens no longer all get uncaught errors. Only the owner reports them. This is what #108 asks for, and the README documents it.
Providerno longer hasthis.state, at runtime or inindex.d.ts. Code that readref.current.state.rollbarwill break. That was undocumented, and nothing in this repo uses it.- The ownership state is module-level, so it only coordinates Providers that share one copy of the package.
Outside the changed files: the experimental, unexported useScopedConfiguration (src/hooks/use-scoped-rollbar-config.js:18) builds a new instance from base.options. Used under the owner, that instance would capture global errors too.
One finding (low): the new static contextType makes nested Providers re-render more often (see below).
I did not run the tests; nothing here shows whether they pass.
Provider read the enclosing Provider's claim from `Context`, whose value is a new object on every render. That subscribed each nested Provider to its parent, so a parent re-render forced every inner Provider, and the `useRollbar` consumers under it, to re-render as well. The claim now travels in a private context whose value is the claim itself, which doesn't change after a Provider's first render. `Context` carries what it did before this branch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AI Agent Review (openai, openai-astra)Review: global capture coordination across Providers (#108)No defects found in the changed lines. I checked the mechanism against the actual code and the pinned
I did not run the test suite or lint. Nothing here claims they pass. Worth deciding deliberately (not defects in this diff)
|
|
[P2] Preserve runtime capture settings during ownership handoff
Reproduction:
The coordinator should distinguish the application’s current requested settings from its own temporary suppression of capture, and preserve those requested settings during a handoff. |
waltjones
left a comment
There was a problem hiding this comment.
Blocking on the issue @aborek-rollbar raised.
What I ran: I used the PR's built package on React 18.3.1 and 19.2.0, without StrictMode, which matches production. The main column comes from reading the code, not from running it.
┌────────────────────────────────────────────────────────────────┬───────────────────────────────────────────────────────────────────────────────┬─────────────────────────────────────┐
│ Case │ PR 171 │ main │
├────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────┤
│ Reviewer's repro: B opts out with useRollbarConfiguration, │ B reports both errors, despite the opt-out, until the component that opted │ B stays silent; A's instance keeps │
│ then A unmounts │ out happens to re-render │ reporting │
├────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────┤
│ The capturing Provider (A) opts out while B is mounted │ Nothing reports. B never takes over │ B reports │
├────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────┤
│ B, not capturing, opts in at runtime │ The uncaught error is reported twice, by A and B │ Twice (the existing bug) │
└────────────────────────────────────────────────────────────────┴───────────────────────────────────────────────────────────────────────────────┴─────────────────────────────────────┘
With StrictMode in development it gets more confusing. StrictMode unmounts and remounts everything once after mounting, and that runs the handoff code. The handoff puts every Provider back to its original capture settings, which undoes any opt-in or opt-out made during the first render. So development and production behave differently.
Why it happens: the coordinator saves the capture settings when the Provider is created (claim.captures) and switches them through configure(). The app changes the same two options through the same configure(), using the public useRollbarConfiguration (typed at index.d.ts:79) or useRollbar().configure(...). Neither side sees the other's changes, so whichever writes last wins.
Severity: P2 is fair. Apps that set the capture flags once in config are fine. Still, an opt-out that's silently undone matters, since an app may turn capture off on purpose. In the second row, global errors stop being reported at all. The fix only touches global-capture.js and provider.js, so I'd do it in this PR rather than as a follow-up.
Suggested fix: let the coordinator own the capture flags on the instances Providers create, by wrapping each instance's configure:
- Record requests: capture keys in an app's configure call become that Provider's requested settings, replacing what was saved at creation.
- Apply only when owning: apply them only if that Provider currently captures that kind of error; otherwise keep them off.
- Opting out: a capturing Provider that opts out hands capture to the next waiting Provider, the same as on unmount. A waiting Provider that has opted out is skipped at handoff.
- Internal switching: enable and disable call the original configure, not the wrapped one.
- Providers that start without capture: they don't get a claim today (claimGlobalCapture returns null), so a later opt-in on one would still double-report. They need a claim too.
Fixing only the hook isn't enough, because useRollbarConfiguration is just a two-line wrapper around rollbar.configure. A direct call would hit the same bug.
The three rows above would make good regression tests in src/tests/components/global-capture.test.tsx. React 17 can reproduce all of them without StrictMode. They're also in my harness at /private/tmp/claude-501/-Users-waltjones-projects-rollbar-rollbar-react/797d7152-fa2f-447d-898e-57a72dbe421f/scratchpad/harness/run.cjs, as runtimeOptOut, ownerOptOut and runtimeOptIn.
The coordinator saved each Provider's capture options when it built the instance and restored them on every handoff, while the app changes the same options through configure() or useRollbarConfiguration(). Whichever wrote last won: an opt-out was undone when the owner unmounted, an owner that opted out left nothing capturing, and a Provider that opted in reported every error a second time. Each managed instance's configure is now wrapped. Capture options passed to it become what that Provider wants, and are applied only while it owns that kind of capture. An owner that opts out hands capture to the next mounted Provider that wants it, Providers that opted out are skipped at handoff, and a Provider that opts in captures only once nobody else does. Internal switching calls the original configure. Providers that start without capture now get a claim too, so a later opt-in is coordinated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Addressing the CHANGES_REQUESTED review from waltjones and the P2 comment from aborek-rollbar (runtime capture settings undone at handoff). Fixed in f798951, following the suggested design. What changed (
Regression tests. The three rows of the table are now in Built
All four modes give the same numbers. The earlier scenarios are unchanged: three StrictMode siblings (A reports, then B after A and C unmount), nested Providers, and a nested render thrown away by a boundary above them (the outer instance reports). Not covered: capture options assigned to |
AI Agent Review (openai, openai-astra)No defects found; I did not run the tests. Checked against the pinned rollbar 3.1.0 (
Coordinator logic ( Scenarios traced by hand, all with exactly one instance capturing: sibling Providers; nested Providers, including through an
Context (not findings):
|
Fixes #108.
What was wrong
Each
<Provider config={…}>built its own Rollbar instance in its constructor. Every instance withcaptureUncaught/captureUnhandledRejectionsadds its ownwindow.onerror/unhandledrejectionhandler, and rollbar.js only filters duplicates within one instance. So one uncaught error or unhandled rejection was reported once per Provider. Under<React.StrictMode>a single Provider reported twice: React constructs class components twice in development and throws one away, but that discarded instance's handlers stay installed.What changed
src/provider.js: the instance is now created on first render, not in the constructor. StrictMode's discarded constructor call never builds one (double render runs on the same component instance, so it reuses the one it has). This replaces the oldTODO: use isUncaught to filter if this is 2nd Provider added.src/global-capture.js(new): coordinates global capture between Provider-created instances.configure({ captureUncaught: … }), which works because rollbar.js checks those options on every call.createRootre-render after a render error, or an error boundary or Suspense retry above the Provider), the Provider that does mount takes over. Each claim records the enclosing Provider's claim, passed down through a private context whose value is the claim itself, so nested Providers don't re-render when their parent does. The publicContextcarries only what it did before. When a Provider mounts, an owner that hasn't mounted yet keeps capture only if it is an ancestor, because React mounts children before their parents. Any other owner that hasn't mounted belongs to a thrown-away render and is replaced.keychange) moves capture to the new instance.instanceProvider in between. A Provider that replaces a stale owner hands capture to its outermost enclosing Provider that captures the same kind, which mounts later in the same commit. An owner that unmounts skips waiting Providers nested inside it, because they unmount (or, under StrictMode and Suspense, disappear and reappear) along with it. It hands capture to the outermost waiting Provider around the one it picks. Without this, StrictMode on React 18/19 moved capture to the inner Provider when it unmounted and remounted everything.Rollbarconstructor throws, the owner its claim switched off is switched back on.configure()oruseRollbarConfiguration(), so each instance a Provider creates has itsconfigurewrapped. Capture options passed to it become what that Provider wants, and are applied only while it owns that kind of capture; otherwise they stay off. An owner that opts out hands capture to the next mounted Provider that wants it, or to nobody if none does. Providers that opted out are skipped at handoff. A Provider that opts in captures only once no other Provider does. The coordinator's own switching calls the originalconfigure, so it doesn't count as a request. Providers that start without capture get a claim too, so a later opt-in is coordinated. Opt-ins and opt-outs made during the first render now survive StrictMode's unmount and remount, so development and production behave the same.window) and for instances passed with theinstanceprop.index.d.ts: removed the internalProviderStategeneric, because the Provider no longer keeps the instance instate. This is observable:providerRef.current.state.rollbaris nowundefined. It was never documented;useRollbar()andContextare the supported ways to get the instance.README.md: short note on both behaviours.Validation
New
src/tests/components/global-capture.test.tsxcovers 15 cases: sibling Providers, StrictMode, handoff on unmount, keyed remount, nesting under StrictMode, nesting through aninstanceProvider, a Provider whose first render is thrown away by an error boundary above it, the same with nested Providers, a Provider whose constructor throws, a nested Provider not re-rendering when the outer one does, which instances have capture off, and four for capture options changed at runtime: a Provider that opted out stays off when the owner unmounts, the owner opting out hands capture to the next Provider, a Provider that opts in stays off while another owns capture, and the only owner opting out and back in. The first six fail onmain(e.g. 3 reports from 3 Providers, 2 constructions under StrictMode) and pass with the fix. The thrown-away-render case failed on this PR's first commit: the discarded instance sent both reports and the provided one none. The nested thrown-away and throwing-constructor cases failed on the second commit (1b7a5b4). The re-render case failed on the third (f7d66fa) and passes onmain. The first three runtime-option cases failed on the fourth (d1f9094).New
src/tests/global-capture.test.jsdrives the coordinator directly in the order React 18 and React 19 StrictMode unmount and remount components, which the React 17 suite can't produce. It also covers the unmount handoff with nested Providers. The first 4 cases fail on 1b7a5b4. 3 more cover an opt-out or opt-in made during the first render surviving a StrictMode remount, and an owner opting out handing capture to the outer of nested Providers.Full suite: 35/35 passing.
npm run lint,npm run typecheckandnpm run buildare clean, and so isprettier --checkon the changed files. Repo-wide it still flags 12 files inexamples/androllup.config.mjs, the same onmain.The repo's tests run React 17, so I also checked the built
lib/with React 18.3.1 and 19.3.0 (createRoot+ StrictMode, which also runs mount effects twice, 3 Providers, rollbar 3.1.0 browser build, one uncaught error plus one rejection):@rollbar/react@1.0.0from npm: 6 instances built, 12 reports.Also with React 18.3.1 and 19.3.0
createRoot, nested Providers with StrictMode on and off, with and without a render thrown away by a boundary above them: the outer instance that mounted sends both reports in all 8 runs. On 1b7a5b4 the inner one did in 6 of 8, including StrictMode with no thrown-away render.Also with React 18.3.1 and 19.3.0
createRoot: a StrictMode Provider whose child's render error is caught by a boundary below it (React throws away that render and renders again). The first commit on this branch built 2 instances, the discarded one sent both reports, and the provided one sent none. Worse, every Provider mounted on the page afterwards also stayed off. With the fix, the provided instance sends both reports and later Providers coordinate normally.Runtime capture options, with the built
lib/on React 18.3.1 and 19.2.0createRoot, StrictMode on and off, one uncaught error plus one rejection:useRollbarConfiguration, then A unmounts: A (unmounted, nobody else wants capture) sends both reports. On d1f9094, B sent them despite its opt-out.Known limitation
When a render that creates the owning instance is thrown away, that instance keeps its (disabled) handlers installed, because nothing unmounts it. It captures only until a Provider mounts, usually the retry of the same Provider, which then takes over. So while a first mount is still suspended, errors are reported once but by that instance, without person or context from hooks. Before this change, that case created an extra instance that reported every error again.
Capture options set by assigning to
rollbar.optionsdirectly, rather than throughconfigure, aren't seen by the coordinator. The experimental, unexportedRollbarConfigurationcomponent restores its parent options on unmount by passing the whole oldoptionsobject toconfigure. For a Provider that isn't capturing, that object has capture off, so it would count as an opt-out.🤖 Generated with Claude Code