From c9139473f2a21d1076d19029eb2346651f4b1bb3 Mon Sep 17 00:00:00 2001 From: AI Agent Date: Sun, 27 Sep 2026 09:21:29 +0000 Subject: [PATCH 1/7] Report global errors once with several Providers or StrictMode 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 --- README.md | 7 + index.d.ts | 7 +- src/global-capture.js | 124 +++++++++++++ src/provider.js | 50 +++++- src/tests/components/global-capture.test.tsx | 179 +++++++++++++++++++ 5 files changed, 353 insertions(+), 14 deletions(-) create mode 100644 src/global-capture.js create mode 100644 src/tests/components/global-capture.test.tsx diff --git a/README.md b/README.md index 8af8414..4263813 100644 --- a/README.md +++ b/README.md @@ -198,6 +198,13 @@ export function App(props) { } ``` +The instance is created when the `Provider` first renders, so React's `StrictMode` doesn't create a second one. + +If more than one `Provider` creates an instance with `captureUncaught` or `captureUnhandledRejections` enabled, only +the first one to render captures those global errors, so each error is reported once. When it unmounts, the next one +takes over. Instances you pass with the `instance` prop aren't coordinated this way, so create them once and share +them. + #### Instance Usage Sometimes you may need to instantiate an instance of Rollbar before adding it to your App tree. In that case use the diff --git a/index.d.ts b/index.d.ts index 4214ea3..d2e24f9 100644 --- a/index.d.ts +++ b/index.d.ts @@ -53,12 +53,7 @@ export interface ProviderProps { instance?: Rollbar; } -interface ProviderState { - rollbar: Rollbar; - options: Rollbar.Configuration; -} - -export class Provider extends Component {} +export class Provider extends Component {} declare const RollbarInstance: unique symbol; declare const BaseOptions: unique symbol; diff --git a/src/global-capture.js b/src/global-capture.js new file mode 100644 index 0000000..ddb5b58 --- /dev/null +++ b/src/global-capture.js @@ -0,0 +1,124 @@ +// window.onerror and unhandledrejection are shared by the whole page, but +// every Rollbar instance that captures them adds its own handler. With several +// Providers each building an instance from `config`, one error was reported +// once per Provider. So among the instances Providers create, only one owns +// each kind of capture at a time; the others have it turned off until the +// owner unmounts. Instances passed in with the `instance` prop aren't managed. + +const CAPTURE_OPTIONS = { + uncaught: ['captureUncaught', 'handleUncaughtExceptions'], + rejections: ['captureUnhandledRejections', 'handleUnhandledRejections'], +}; + +const KINDS = Object.keys(CAPTURE_OPTIONS); + +const owners = { uncaught: null, rejections: null }; +const waiting = { uncaught: [], rejections: [] }; + +function captureOff(kind) { + const off = {}; + CAPTURE_OPTIONS[kind].forEach((key) => { + off[key] = false; + }); + return off; +} + +function enable(claim, kind) { + claim.rollbar?.configure?.(claim.captures[kind]); +} + +function disable(claim, kind) { + claim.rollbar?.configure?.(captureOff(kind)); +} + +function takeOver(claim, kind) { + const previous = owners[kind]; + if (previous) { + disable(previous, kind); + } + owners[kind] = claim; + enable(claim, kind); +} + +// Call before constructing the instance. Returns the options to construct it +// with (global capture turned off where another instance already owns it) and +// a claim to pass to the other functions once the instance exists. +export function claimGlobalCapture(options) { + if (typeof window === 'undefined' || !options) { + return { options, claim: null }; + } + + const claim = { rollbar: null, state: 'pending', captures: {} }; + let ctorOptions = options; + + KINDS.forEach((kind) => { + const keys = CAPTURE_OPTIONS[kind]; + if (!keys.some((key) => options[key])) { + return; + } + const captures = {}; + keys.forEach((key) => { + if (key in options) { + captures[key] = options[key]; + } + }); + claim.captures[kind] = captures; + + const owner = owners[kind]; + if (!owner) { + owners[kind] = claim; + } else if (owner.state === 'unmounted') { + // Its handler is still installed, so it has to be switched off. + disable(owner, kind); + owners[kind] = claim; + } else { + ctorOptions = { ...ctorOptions, ...captureOff(kind) }; + } + }); + + if (!Object.keys(claim.captures).length) { + return { options, claim: null }; + } + return { options: ctorOptions, claim }; +} + +export function mountGlobalCapture(claim) { + if (!claim) { + return; + } + claim.state = 'mounted'; + + Object.keys(claim.captures).forEach((kind) => { + const owner = owners[kind]; + if (owner === claim) { + return; + } + // A pending owner may be an ancestor Provider that mounts later in this + // same commit, so only an owner that has already unmounted is replaced. + if (!owner || owner.state === 'unmounted') { + takeOver(claim, kind); + } else if (!waiting[kind].includes(claim)) { + waiting[kind].push(claim); + } + }); +} + +export function unmountGlobalCapture(claim) { + if (!claim) { + return; + } + claim.state = 'unmounted'; + + Object.keys(claim.captures).forEach((kind) => { + waiting[kind] = waiting[kind].filter((other) => other !== claim); + if (owners[kind] !== claim) { + return; + } + const next = waiting[kind].shift(); + if (next) { + takeOver(next, kind); + } + // With nobody waiting, the owner keeps capturing, as an unmounted + // Provider's instance always has. The next Provider to claim takes over. + }); +} diff --git a/src/provider.js b/src/provider.js index 86a7945..d086101 100644 --- a/src/provider.js +++ b/src/provider.js @@ -5,6 +5,11 @@ import PropTypes from 'prop-types'; import Rollbar from 'rollbar'; import invariant from 'tiny-invariant'; import { isRollbarInstance } from './utils'; +import { + claimGlobalCapture, + mountGlobalCapture, + unmountGlobalCapture, +} from './global-capture'; export const Context = createContext(); Context.displayName = 'Rollbar'; @@ -60,23 +65,52 @@ export class Provider extends Component { constructor(props) { super(props); - const { config, Rollbar: ctor = Rollbar, instance } = this.props; + const { instance } = this.props; invariant( !instance || isRollbarInstance(instance), '`instance` must be a configured instance of Rollbar', ); - const options = typeof config === 'function' ? config() : config; - const rollbar = instance || new ctor(options); - // TODO: use isUncaught to filter if this is 2nd Provider added - // unless customer wants that - this.state = { rollbar, options }; } - // componentDidUpdate() + // Created on first render rather than in the constructor: in development, + // StrictMode constructs class components twice and throws one away, and a + // Rollbar instance built there would keep its global handlers regardless. + getRollbar() { + if (!this.rollbar) { + const { config, Rollbar: ctor = Rollbar, instance } = this.props; + const options = typeof config === 'function' ? config() : config; + if (instance) { + this.rollbar = instance; + } else { + const { options: ctorOptions, claim } = claimGlobalCapture(options); + try { + this.rollbar = new ctor(ctorOptions); + } catch (e) { + unmountGlobalCapture(claim); + throw e; + } + if (claim) { + claim.rollbar = this.rollbar; + this.captureClaim = claim; + } + } + this.options = options; + } + return this.rollbar; + } + + componentDidMount() { + mountGlobalCapture(this.captureClaim); + } + + componentWillUnmount() { + unmountGlobalCapture(this.captureClaim); + } render() { const { children, Rollbar: ctor = Rollbar } = this.props; - const { rollbar, options } = this.state; + const rollbar = this.getRollbar(); + const { options } = this; return ( { + if (isUncaught) reports.push(args); + return true; // never send + }, + }; + return { config, reports }; +} + +const settle = () => new Promise((resolve) => setTimeout(resolve, 50)); + +async function throwUncaught() { + window.onerror?.('boom', 'http://localhost/app.js', 1, 1, new Error('boom')); + await settle(); +} + +async function rejectUnhandled() { + const event = new Event('unhandledrejection'); + Object.assign(event, { + reason: new Error('nope'), + promise: Promise.resolve(), + }); + window.dispatchEvent(event); + await settle(); +} + +describe('Provider global capture', () => { + it('reports an uncaught error once with several Providers', async () => { + const providers = [makeConfig(), makeConfig(), makeConfig()]; + render( + <> + {providers.map(({ config }, i) => ( + +
+ + ))} + , + ); + + await throwUncaught(); + await rejectUnhandled(); + + expect(providers.map(({ reports }) => reports.length)).toEqual([2, 0, 0]); + }); + + it('builds one instance under StrictMode', async () => { + const { config, reports } = makeConfig(); + const ctor = jest.fn( + (options: Rollbar.Configuration) => new Rollbar(options), + ); + let provided: Rollbar | undefined; + const Consumer = () => { + provided = useRollbar(); + return null; + }; + + render( + + + + + , + ); + + await throwUncaught(); + + expect(ctor).toHaveBeenCalledTimes(1); + expect(provided).toBe(ctor.mock.results[0].value); + expect(reports).toHaveLength(1); + }); + + it('hands capture to the next Provider when the owner unmounts', async () => { + const first = makeConfig(); + const second = makeConfig(); + const App = ({ showFirst }: { showFirst: boolean }) => ( + <> + {showFirst && ( + +
+ + )} + +
+ + + ); + + const { rerender } = render(); + await throwUncaught(); + rerender(); + await throwUncaught(); + await rejectUnhandled(); + + expect(first.reports).toHaveLength(1); + expect(second.reports).toHaveLength(2); + }); + + it('hands capture to a remounted Provider', async () => { + const before = makeConfig(); + const after = makeConfig(); + const App = ({ + config, + id, + }: { + config: Rollbar.Configuration; + id: number; + }) => ( + +
+ + ); + + const { rerender } = render(); + rerender(); + await throwUncaught(); + await rejectUnhandled(); + + expect(before.reports).toHaveLength(0); + expect(after.reports).toHaveLength(2); + }); + + it('keeps capture with the outer Provider when nested', async () => { + const outer = makeConfig(); + const inner = makeConfig(); + const Wrap = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + + render( + + +
+ + , + ); + await throwUncaught(); + + expect(outer.reports).toHaveLength(1); + expect(inner.reports).toHaveLength(0); + }); + + it('turns capture off only where another instance owns it', () => { + const owner = makeConfig(); + const other = makeConfig(); + const instances: Rollbar[] = []; + const Consumer = () => { + instances.push(useRollbar()); + return null; + }; + + render( + <> + + + + + + + , + ); + + expect(instances[0].options.captureUncaught).toBe(true); + expect(instances[1].options.captureUncaught).toBe(false); + expect(instances[1].options.accessToken).toBe('POST_CLIENT_ITEM_TOKEN'); + }); +}); From 1b7a5b4933a36e1a1487c22016a24859adbfe0a0 Mon Sep 17 00:00:00 2001 From: AI Agent Date: Sun, 27 Sep 2026 10:07:48 +0000 Subject: [PATCH 2/7] Take over global capture from a Provider render React threw away 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 --- src/global-capture.js | 33 +++++-- src/provider.js | 14 ++- src/tests/components/global-capture.test.tsx | 91 ++++++++++++++++++++ 3 files changed, 129 insertions(+), 9 deletions(-) diff --git a/src/global-capture.js b/src/global-capture.js index ddb5b58..04d60b9 100644 --- a/src/global-capture.js +++ b/src/global-capture.js @@ -40,15 +40,25 @@ function takeOver(claim, kind) { enable(claim, kind); } -// Call before constructing the instance. Returns the options to construct it -// with (global capture turned off where another instance already owns it) and -// a claim to pass to the other functions once the instance exists. -export function claimGlobalCapture(options) { +function isAncestor(other, claim) { + for (let parent = claim.parent; parent; parent = parent.parent) { + if (parent === other) { + return true; + } + } + return false; +} + +// Call before constructing the instance, with the claim of the nearest +// enclosing Provider, if any. Returns the options to construct it with (global +// capture turned off where another instance already owns it) and a claim to +// pass to the other functions once the instance exists. +export function claimGlobalCapture(options, parent = null) { if (typeof window === 'undefined' || !options) { return { options, claim: null }; } - const claim = { rollbar: null, state: 'pending', captures: {} }; + const claim = { rollbar: null, state: 'pending', captures: {}, parent }; let ctorOptions = options; KINDS.forEach((kind) => { @@ -93,9 +103,16 @@ export function mountGlobalCapture(claim) { if (owner === claim) { return; } - // A pending owner may be an ancestor Provider that mounts later in this - // same commit, so only an owner that has already unmounted is replaced. - if (!owner || owner.state === 'unmounted') { + // Mounting runs children before parents and earlier siblings before later + // ones, so of the Providers rendered in this commit only an ancestor mounts + // after this one, and it keeps capture. Any other owner still pending was + // rendered in a pass React threw away (or hasn't committed), so it is + // replaced like an unmounted one. + if ( + !owner || + owner.state === 'unmounted' || + (owner.state === 'pending' && !isAncestor(owner, claim)) + ) { takeOver(claim, kind); } else if (!waiting[kind].includes(claim)) { waiting[kind].push(claim); diff --git a/src/provider.js b/src/provider.js index d086101..136c230 100644 --- a/src/provider.js +++ b/src/provider.js @@ -17,6 +17,7 @@ Context.displayName = 'Rollbar'; export const RollbarInstance = Symbol('RollbarInstance'); export const BaseOptions = Symbol('BaseOptions'); export const RollbarCtor = Symbol('RollbarCtor'); +const CaptureClaim = Symbol('CaptureClaim'); export function getRollbarFromContext(context) { const { [RollbarInstance]: rollbar } = context; @@ -63,6 +64,9 @@ export class Provider extends Component { children: PropTypes.node, }; + // Read only to find the enclosing Provider's global capture claim. + static contextType = Context; + constructor(props) { super(props); const { instance } = this.props; @@ -82,7 +86,10 @@ export class Provider extends Component { if (instance) { this.rollbar = instance; } else { - const { options: ctorOptions, claim } = claimGlobalCapture(options); + const { options: ctorOptions, claim } = claimGlobalCapture( + options, + this.parentClaim(), + ); try { this.rollbar = new ctor(ctorOptions); } catch (e) { @@ -99,6 +106,10 @@ export class Provider extends Component { return this.rollbar; } + parentClaim() { + return this.context?.[CaptureClaim] ?? null; + } + componentDidMount() { mountGlobalCapture(this.captureClaim); } @@ -118,6 +129,7 @@ export class Provider extends Component { [RollbarInstance]: rollbar, [BaseOptions]: options, [RollbarCtor]: ctor, + [CaptureClaim]: this.captureClaim ?? this.parentClaim(), }} > {children} diff --git a/src/tests/components/global-capture.test.tsx b/src/tests/components/global-capture.test.tsx index c0a7180..4d4a4a8 100644 --- a/src/tests/components/global-capture.test.tsx +++ b/src/tests/components/global-capture.test.tsx @@ -152,6 +152,97 @@ describe('Provider global capture', () => { expect(inner.reports).toHaveLength(0); }); + it('keeps capture with the outer Provider through an instance Provider', async () => { + const outer = makeConfig(); + const inner = makeConfig(); + const shared = new Rollbar({ accessToken: 'POST_CLIENT_ITEM_TOKEN' }); + + render( + + + +
+ + + , + ); + await throwUncaught(); + + expect(outer.reports).toHaveLength(1); + expect(inner.reports).toHaveLength(0); + }); + + it('hands capture to the Provider that mounts when a render is thrown away', async () => { + const { config } = makeConfig(); + // Each instance counts its own uncaught items, since both share `config`. + const reports: number[] = []; + const ctor = jest.fn((options: Rollbar.Configuration) => { + const index = reports.push(0) - 1; + return new Rollbar({ + ...options, + checkIgnore: (isUncaught) => { + if (isUncaught) reports[index] += 1; + return true; + }, + }); + }); + let provided: Rollbar | undefined; + const Consumer = () => { + provided = useRollbar(); + return null; + }; + let throwOnce = true; + const ThrowOnce = () => { + if (throwOnce) { + throwOnce = false; + throw new Error('first render'); + } + return null; + }; + // Rendering its children again after an error throws away the first + // attempt, including the Provider inside it, which never mounts. + class Retry extends React.Component<{ children: ReactNode }> { + state = { attempt: 0 }; + static getDerivedStateFromError() { + return { attempt: 1 }; + } + render() { + const { children } = this.props; + return ( + {children} + ); + } + } + + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + const onError = (event: ErrorEvent) => event.preventDefault(); + window.addEventListener('error', onError); + try { + render( + + + + + + , + ); + } finally { + window.removeEventListener('error', onError); + consoleError.mockRestore(); + } + await settle(); + reports.fill(0); + + await throwUncaught(); + await rejectUnhandled(); + + expect(ctor).toHaveBeenCalledTimes(2); + expect(provided).toBe(ctor.mock.results[1].value); + expect(reports).toEqual([0, 2]); + }); + it('turns capture off only where another instance owns it', () => { const owner = makeConfig(); const other = makeConfig(); From f7d66fa8c3675a532f7e49f19eaa7fe7eb7275d5 Mon Sep 17 00:00:00 2001 From: AI Agent Date: Sun, 27 Sep 2026 11:03:55 +0000 Subject: [PATCH 3/7] Keep global capture with the outer of nested Providers 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 --- src/global-capture.js | 69 ++++++- src/provider.js | 3 +- src/tests/components/global-capture.test.tsx | 187 +++++++++++++------ src/tests/global-capture.test.js | 99 ++++++++++ 4 files changed, 297 insertions(+), 61 deletions(-) create mode 100644 src/tests/global-capture.test.js diff --git a/src/global-capture.js b/src/global-capture.js index 04d60b9..c4d16d4 100644 --- a/src/global-capture.js +++ b/src/global-capture.js @@ -37,6 +37,7 @@ function takeOver(claim, kind) { disable(previous, kind); } owners[kind] = claim; + waiting[kind] = waiting[kind].filter((other) => other !== claim); enable(claim, kind); } @@ -49,6 +50,18 @@ function isAncestor(other, claim) { return false; } +// The outermost of `claim` and its enclosing Providers' claims that captures +// `kind` and passes `eligible`. +function outermost(claim, kind, eligible = () => true) { + let found = claim; + for (let parent = claim.parent; parent; parent = parent.parent) { + if (parent.captures[kind] && eligible(parent)) { + found = parent; + } + } + return found; +} + // Call before constructing the instance, with the claim of the nearest // enclosing Provider, if any. Returns the options to construct it with (global // capture turned off where another instance already owns it) and a claim to @@ -58,7 +71,13 @@ export function claimGlobalCapture(options, parent = null) { return { options, claim: null }; } - const claim = { rollbar: null, state: 'pending', captures: {}, parent }; + const claim = { + rollbar: null, + state: 'pending', + captures: {}, + replaced: {}, + parent, + }; let ctorOptions = options; KINDS.forEach((kind) => { @@ -77,10 +96,12 @@ export function claimGlobalCapture(options, parent = null) { const owner = owners[kind]; if (!owner) { owners[kind] = claim; + claim.replaced[kind] = null; } else if (owner.state === 'unmounted') { // Its handler is still installed, so it has to be switched off. disable(owner, kind); owners[kind] = claim; + claim.replaced[kind] = owner; } else { ctorOptions = { ...ctorOptions, ...captureOff(kind) }; } @@ -107,14 +128,20 @@ export function mountGlobalCapture(claim) { // ones, so of the Providers rendered in this commit only an ancestor mounts // after this one, and it keeps capture. Any other owner still pending was // rendered in a pass React threw away (or hasn't committed), so it is - // replaced like an unmounted one. + // replaced like an unmounted one: by the outermost enclosing Provider + // that captures this kind, which is mounting in this same commit. if ( !owner || owner.state === 'unmounted' || (owner.state === 'pending' && !isAncestor(owner, claim)) ) { - takeOver(claim, kind); - } else if (!waiting[kind].includes(claim)) { + const next = outermost(claim, kind); + takeOver(next, kind); + if (next === claim) { + return; + } + } + if (!waiting[kind].includes(claim)) { waiting[kind].push(claim); } }); @@ -131,11 +158,39 @@ export function unmountGlobalCapture(claim) { if (owners[kind] !== claim) { return; } - const next = waiting[kind].shift(); + // Providers nested inside this one unmount with it (or, under StrictMode + // and Suspense, disappear and reappear with it), so they are skipped. Of + // the rest, the first to mount goes to its outermost enclosing Provider + // that is also waiting, so nested Providers keep capture with the outer one. + const next = waiting[kind].find((other) => !isAncestor(claim, other)); if (next) { - takeOver(next, kind); + takeOver( + outermost(next, kind, (parent) => waiting[kind].includes(parent)), + kind, + ); } // With nobody waiting, the owner keeps capturing, as an unmounted - // Provider's instance always has. The next Provider to claim takes over. + // Provider's instance always has. The next Provider to claim or mount + // takes over. + }); +} + +// Call instead of the others when constructing the instance fails, to give +// capture back to whichever owner the claim replaced. +export function abandonGlobalCapture(claim) { + if (!claim) { + return; + } + claim.state = 'unmounted'; + + Object.keys(claim.replaced).forEach((kind) => { + if (owners[kind] !== claim) { + return; + } + const previous = claim.replaced[kind]; + owners[kind] = previous; + if (previous) { + enable(previous, kind); + } }); } diff --git a/src/provider.js b/src/provider.js index 136c230..2f19bf3 100644 --- a/src/provider.js +++ b/src/provider.js @@ -6,6 +6,7 @@ import Rollbar from 'rollbar'; import invariant from 'tiny-invariant'; import { isRollbarInstance } from './utils'; import { + abandonGlobalCapture, claimGlobalCapture, mountGlobalCapture, unmountGlobalCapture, @@ -93,7 +94,7 @@ export class Provider extends Component { try { this.rollbar = new ctor(ctorOptions); } catch (e) { - unmountGlobalCapture(claim); + abandonGlobalCapture(claim); throw e; } if (claim) { diff --git a/src/tests/components/global-capture.test.tsx b/src/tests/components/global-capture.test.tsx index 4d4a4a8..94938ca 100644 --- a/src/tests/components/global-capture.test.tsx +++ b/src/tests/components/global-capture.test.tsx @@ -18,6 +18,23 @@ function makeConfig() { return { config, reports }; } +// A Rollbar constructor whose instances each count their own uncaught items, +// in construction order, for Providers that share a config across renders. +function countingRollbar() { + const reports: number[] = []; + const ctor = jest.fn((options: Rollbar.Configuration) => { + const index = reports.push(0) - 1; + return new Rollbar({ + ...options, + checkIgnore: (isUncaught) => { + if (isUncaught) reports[index] += 1; + return true; + }, + }); + }); + return { ctor, reports }; +} + const settle = () => new Promise((resolve) => setTimeout(resolve, 50)); async function throwUncaught() { @@ -35,6 +52,47 @@ async function rejectUnhandled() { await settle(); } +// Rendering its children again after an error throws away the first attempt, +// including any Provider inside it, which never mounts. +class Retry extends React.Component<{ children: ReactNode }> { + state = { attempt: 0 }; + static getDerivedStateFromError() { + return { attempt: 1 }; + } + render() { + const { children } = this.props; + return {children}; + } +} + +// A component that throws on its first render only. +function throwOnce() { + let thrown = false; + return function ThrowOnce() { + if (!thrown) { + thrown = true; + throw new Error('first render'); + } + return null; + }; +} + +// Renders without the error React reports for a render that throws. +async function renderQuietly(ui: React.ReactElement) { + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + const onError = (event: ErrorEvent) => event.preventDefault(); + window.addEventListener('error', onError); + try { + render(ui); + } finally { + window.removeEventListener('error', onError); + consoleError.mockRestore(); + } + await settle(); +} + describe('Provider global capture', () => { it('reports an uncaught error once with several Providers', async () => { const providers = [makeConfig(), makeConfig(), makeConfig()]; @@ -174,65 +232,22 @@ describe('Provider global capture', () => { it('hands capture to the Provider that mounts when a render is thrown away', async () => { const { config } = makeConfig(); - // Each instance counts its own uncaught items, since both share `config`. - const reports: number[] = []; - const ctor = jest.fn((options: Rollbar.Configuration) => { - const index = reports.push(0) - 1; - return new Rollbar({ - ...options, - checkIgnore: (isUncaught) => { - if (isUncaught) reports[index] += 1; - return true; - }, - }); - }); + const { ctor, reports } = countingRollbar(); let provided: Rollbar | undefined; const Consumer = () => { provided = useRollbar(); return null; }; - let throwOnce = true; - const ThrowOnce = () => { - if (throwOnce) { - throwOnce = false; - throw new Error('first render'); - } - return null; - }; - // Rendering its children again after an error throws away the first - // attempt, including the Provider inside it, which never mounts. - class Retry extends React.Component<{ children: ReactNode }> { - state = { attempt: 0 }; - static getDerivedStateFromError() { - return { attempt: 1 }; - } - render() { - const { children } = this.props; - return ( - {children} - ); - } - } + const ThrowOnce = throwOnce(); - const consoleError = jest - .spyOn(console, 'error') - .mockImplementation(() => undefined); - const onError = (event: ErrorEvent) => event.preventDefault(); - window.addEventListener('error', onError); - try { - render( - - - - - - , - ); - } finally { - window.removeEventListener('error', onError); - consoleError.mockRestore(); - } - await settle(); + await renderQuietly( + + + + + + , + ); reports.fill(0); await throwUncaught(); @@ -243,6 +258,72 @@ describe('Provider global capture', () => { expect(reports).toEqual([0, 2]); }); + it('keeps capture with the outer Provider when a nested render is thrown away', async () => { + const outer = makeConfig(); + const inner = makeConfig(); + const { ctor, reports } = countingRollbar(); + const Counting = ctor as unknown as typeof Rollbar; + const ThrowOnce = throwOnce(); + + await renderQuietly( + + + + + + + , + ); + reports.fill(0); + + await throwUncaught(); + await rejectUnhandled(); + + // Thrown-away outer and inner, then the outer and inner that mounted. + expect(ctor).toHaveBeenCalledTimes(4); + expect(reports).toEqual([0, 0, 2, 0]); + }); + + it('keeps the previous owner capturing when a constructor throws', async () => { + const before = makeConfig(); + const after = makeConfig(); + const failing = jest.fn(() => { + throw new Error('bad config'); + }); + class Fallback extends React.Component<{ children: ReactNode }> { + state = { failed: false }; + static getDerivedStateFromError() { + return { failed: true }; + } + render() { + const { children } = this.props; + return this.state.failed ? null : children; + } + } + + const { unmount } = render( + +
+ , + ); + unmount(); + await renderQuietly( + + +
+ + , + ); + before.reports.length = 0; + await throwUncaught(); + + expect(failing).toHaveBeenCalled(); + expect(before.reports).toHaveLength(1); + }); + it('turns capture off only where another instance owns it', () => { const owner = makeConfig(); const other = makeConfig(); diff --git a/src/tests/global-capture.test.js b/src/tests/global-capture.test.js new file mode 100644 index 0000000..1405c0c --- /dev/null +++ b/src/tests/global-capture.test.js @@ -0,0 +1,99 @@ +// Drives the capture coordination directly, in the order React calls +// componentDidMount and componentWillUnmount, for cases the React 17 used by +// the component tests can't produce: StrictMode in React 18 and 19 unmounts +// and remounts every component once after it first mounts. + +function load() { + let module; + jest.isolateModules(() => { + module = require('../global-capture'); + }); + return module; +} + +// A stand-in for a Provider that creates its instance from `config`. +function provider(capture, parent = null) { + const { options, claim } = capture.claimGlobalCapture( + { captureUncaught: true }, + parent?.claim, + ); + const rollbar = { + capturing: options.captureUncaught, + configure(changes) { + if ('captureUncaught' in changes) { + this.capturing = changes.captureUncaught; + } + }, + }; + claim.rollbar = rollbar; + return { + claim, + rollbar, + mount: () => capture.mountGlobalCapture(claim), + unmount: () => capture.unmountGlobalCapture(claim), + }; +} + +const capturing = (...providers) => providers.map((p) => p.rollbar.capturing); + +describe('global capture coordination', () => { + it('keeps capture with the outer Provider under React 19 StrictMode', () => { + const capture = load(); + const outer = provider(capture); + const inner = provider(capture, outer); + + inner.mount(); + outer.mount(); + // Unmounts parents first, then remounts children first. + outer.unmount(); + inner.unmount(); + inner.mount(); + outer.mount(); + + expect(capturing(outer, inner)).toEqual([true, false]); + }); + + it('keeps capture with the outer Provider under React 18 StrictMode', () => { + const capture = load(); + const outer = provider(capture); + const inner = provider(capture, outer); + + inner.mount(); + outer.mount(); + // Unmounts and remounts children first. + inner.unmount(); + outer.unmount(); + inner.mount(); + outer.mount(); + + expect(capturing(outer, inner)).toEqual([true, false]); + }); + + it('hands capture to the outer of nested Providers', () => { + const capture = load(); + const first = provider(capture); + const outer = provider(capture); + const inner = provider(capture, outer); + + first.mount(); + inner.mount(); + outer.mount(); + first.unmount(); + + expect(capturing(first, outer, inner)).toEqual([false, true, false]); + }); + + it('skips Providers nested in the owner when handing capture over', () => { + const capture = load(); + const outer = provider(capture); + const inner = provider(capture, outer); + const sibling = provider(capture); + + inner.mount(); + outer.mount(); + sibling.mount(); + outer.unmount(); + + expect(capturing(outer, inner, sibling)).toEqual([false, false, true]); + }); +}); From d1f90943deae4c39f27c151a0826f0c13f26d309 Mon Sep 17 00:00:00 2001 From: AI Agent Date: Sun, 27 Sep 2026 11:45:14 +0000 Subject: [PATCH 4/7] Pass the capture claim through its own context 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 --- src/provider.js | 32 ++++++++++++-------- src/tests/components/global-capture.test.tsx | 22 ++++++++++++++ 2 files changed, 41 insertions(+), 13 deletions(-) diff --git a/src/provider.js b/src/provider.js index 2f19bf3..1f2b4e3 100644 --- a/src/provider.js +++ b/src/provider.js @@ -18,7 +18,11 @@ Context.displayName = 'Rollbar'; export const RollbarInstance = Symbol('RollbarInstance'); export const BaseOptions = Symbol('BaseOptions'); export const RollbarCtor = Symbol('RollbarCtor'); -const CaptureClaim = Symbol('CaptureClaim'); + +// Carries the enclosing Provider's global capture claim. Kept apart from +// `Context`, whose value is new on every render, so that nested Providers +// only subscribe to a value that doesn't change after their first render. +const CaptureClaimContext = createContext(null); export function getRollbarFromContext(context) { const { [RollbarInstance]: rollbar } = context; @@ -65,8 +69,7 @@ export class Provider extends Component { children: PropTypes.node, }; - // Read only to find the enclosing Provider's global capture claim. - static contextType = Context; + static contextType = CaptureClaimContext; constructor(props) { super(props); @@ -108,7 +111,7 @@ export class Provider extends Component { } parentClaim() { - return this.context?.[CaptureClaim] ?? null; + return this.context; } componentDidMount() { @@ -125,16 +128,19 @@ export class Provider extends Component { const { options } = this; return ( - - {children} - + + {children} + + ); } } diff --git a/src/tests/components/global-capture.test.tsx b/src/tests/components/global-capture.test.tsx index 94938ca..20c64b3 100644 --- a/src/tests/components/global-capture.test.tsx +++ b/src/tests/components/global-capture.test.tsx @@ -324,6 +324,28 @@ describe('Provider global capture', () => { expect(before.reports).toHaveLength(1); }); + it('does not re-render a nested Provider when the outer one re-renders', () => { + const outer = makeConfig(); + const inner = makeConfig(); + let renders = 0; + const Consumer = () => { + useRollbar(); + renders += 1; + return null; + }; + const nested = ( + + + + ); + const App = () => {nested}; + + const { rerender } = render(); + rerender(); + + expect(renders).toBe(1); + }); + it('turns capture off only where another instance owns it', () => { const owner = makeConfig(); const other = makeConfig(); From f798951093475bbf8148ad885cc8a514689d50a2 Mon Sep 17 00:00:00 2001 From: AI Agent Date: Fri, 23 Oct 2026 14:44:54 +0000 Subject: [PATCH 5/7] Respect capture options changed at runtime through configure 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 --- README.md | 5 +- src/global-capture.js | 168 +++++++++++++------ src/provider.js | 3 +- src/tests/components/global-capture.test.tsx | 131 ++++++++++++++- src/tests/global-capture.test.js | 60 ++++++- 5 files changed, 308 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index 4263813..72bd0ef 100644 --- a/README.md +++ b/README.md @@ -202,8 +202,9 @@ The instance is created when the `Provider` first renders, so React's `StrictMod If more than one `Provider` creates an instance with `captureUncaught` or `captureUnhandledRejections` enabled, only the first one to render captures those global errors, so each error is reported once. When it unmounts, the next one -takes over. Instances you pass with the `instance` prop aren't coordinated this way, so create them once and share -them. +takes over. Turning those options off or on later with `configure` or `useRollbarConfiguration` works the same way: a +`Provider` that turns capture off hands it to the next one, and one that turns it on captures once no other `Provider` +does. Instances you pass with the `instance` prop aren't coordinated this way, so create them once and share them. #### Instance Usage diff --git a/src/global-capture.js b/src/global-capture.js index c4d16d4..1d0d761 100644 --- a/src/global-capture.js +++ b/src/global-capture.js @@ -3,7 +3,13 @@ // Providers each building an instance from `config`, one error was reported // once per Provider. So among the instances Providers create, only one owns // each kind of capture at a time; the others have it turned off until the -// owner unmounts. Instances passed in with the `instance` prop aren't managed. +// owner unmounts or opts out. Instances passed in with the `instance` prop +// aren't managed. +// +// The app can change the capture options at any time through `configure` +// (directly or with useRollbarConfiguration), so each managed instance's +// `configure` is wrapped: capture options passed to it are recorded as what +// that Provider wants, and applied only while it owns that kind of capture. const CAPTURE_OPTIONS = { uncaught: ['captureUncaught', 'handleUncaughtExceptions'], @@ -13,7 +19,8 @@ const CAPTURE_OPTIONS = { const KINDS = Object.keys(CAPTURE_OPTIONS); const owners = { uncaught: null, rejections: null }; -const waiting = { uncaught: [], rejections: [] }; +// Mounted claims, in the order they mounted. +const mounted = []; function captureOff(kind) { const off = {}; @@ -23,21 +30,37 @@ function captureOff(kind) { return off; } +// Records the capture options in `options` as what `claim` wants. Returns the +// kinds they belong to. +function request(claim, options) { + return KINDS.filter((kind) => { + const keys = CAPTURE_OPTIONS[kind].filter((key) => key in options); + keys.forEach((key) => { + claim.requested[kind][key] = options[key]; + }); + return keys.length > 0; + }); +} + +function wants(claim, kind) { + return CAPTURE_OPTIONS[kind].some((key) => claim.requested[kind][key]); +} + +// These bypass the wrapper, so they don't count as the app's requests. function enable(claim, kind) { - claim.rollbar?.configure?.(claim.captures[kind]); + claim.configure?.(claim.requested[kind]); } function disable(claim, kind) { - claim.rollbar?.configure?.(captureOff(kind)); + claim.configure?.(captureOff(kind)); } function takeOver(claim, kind) { const previous = owners[kind]; - if (previous) { + if (previous && previous !== claim) { disable(previous, kind); } owners[kind] = claim; - waiting[kind] = waiting[kind].filter((other) => other !== claim); enable(claim, kind); } @@ -50,49 +73,80 @@ function isAncestor(other, claim) { return false; } -// The outermost of `claim` and its enclosing Providers' claims that captures +// The outermost of `claim` and its enclosing Providers' claims that wants // `kind` and passes `eligible`. function outermost(claim, kind, eligible = () => true) { let found = claim; for (let parent = claim.parent; parent; parent = parent.parent) { - if (parent.captures[kind] && eligible(parent)) { + if (wants(parent, kind) && eligible(parent)) { found = parent; } } return found; } +// Gives `kind` from its owner `claim` to the first other mounted Provider that +// wants it and that `skip` doesn't exclude, or to the outermost such Provider +// around it, so nested Providers keep capture with the outer one. Returns +// false if there is none. +function handOff(claim, kind, skip = () => false) { + const eligible = (other) => + other !== claim && + mounted.includes(other) && + wants(other, kind) && + !skip(other); + const next = mounted.find(eligible); + if (!next) { + return false; + } + takeOver(outermost(next, kind, eligible), kind); + return true; +} + +// After the app changed what `claim` wants for `kind`. +function reconcile(claim, kind) { + const owner = owners[kind]; + if (owner === claim) { + if (wants(claim, kind)) { + enable(claim, kind); + } else if (!handOff(claim, kind)) { + disable(claim, kind); + owners[kind] = null; + } + return; + } + // Otherwise it stays off unless nobody else captures this kind. + if ( + wants(claim, kind) && + claim.state !== 'unmounted' && + (!owner || owner.state === 'unmounted') + ) { + takeOver(claim, kind); + } +} + // Call before constructing the instance, with the claim of the nearest // enclosing Provider, if any. Returns the options to construct it with (global // capture turned off where another instance already owns it) and a claim to -// pass to the other functions once the instance exists. +// pass to the other functions. export function claimGlobalCapture(options, parent = null) { if (typeof window === 'undefined' || !options) { return { options, claim: null }; } const claim = { - rollbar: null, + configure: null, state: 'pending', - captures: {}, + requested: { uncaught: {}, rejections: {} }, replaced: {}, parent, }; let ctorOptions = options; - KINDS.forEach((kind) => { - const keys = CAPTURE_OPTIONS[kind]; - if (!keys.some((key) => options[key])) { + request(claim, options).forEach((kind) => { + if (!wants(claim, kind)) { return; } - const captures = {}; - keys.forEach((key) => { - if (key in options) { - captures[key] = options[key]; - } - }); - claim.captures[kind] = captures; - const owner = owners[kind]; if (!owner) { owners[kind] = claim; @@ -107,21 +161,49 @@ export function claimGlobalCapture(options, parent = null) { } }); - if (!Object.keys(claim.captures).length) { - return { options, claim: null }; - } return { options: ctorOptions, claim }; } +// Call once the instance is constructed. +export function manageGlobalCapture(claim, rollbar) { + if (!claim) { + return; + } + const configure = rollbar?.configure; + if (typeof configure !== 'function') { + return; + } + claim.configure = (options) => configure.call(rollbar, options); + + rollbar.configure = (options, ...rest) => { + if (!options) { + return configure.call(rollbar, options, ...rest); + } + const kinds = request(claim, options); + const others = { ...options }; + kinds.forEach((kind) => { + CAPTURE_OPTIONS[kind].forEach((key) => { + delete others[key]; + }); + }); + const result = configure.call(rollbar, others, ...rest); + kinds.forEach((kind) => reconcile(claim, kind)); + return result; + }; +} + export function mountGlobalCapture(claim) { if (!claim) { return; } claim.state = 'mounted'; + if (!mounted.includes(claim)) { + mounted.push(claim); + } - Object.keys(claim.captures).forEach((kind) => { + KINDS.forEach((kind) => { const owner = owners[kind]; - if (owner === claim) { + if (owner === claim || !wants(claim, kind)) { return; } // Mounting runs children before parents and earlier siblings before later @@ -129,20 +211,13 @@ export function mountGlobalCapture(claim) { // after this one, and it keeps capture. Any other owner still pending was // rendered in a pass React threw away (or hasn't committed), so it is // replaced like an unmounted one: by the outermost enclosing Provider - // that captures this kind, which is mounting in this same commit. + // that wants this kind, which is mounting in this same commit. if ( !owner || owner.state === 'unmounted' || (owner.state === 'pending' && !isAncestor(owner, claim)) ) { - const next = outermost(claim, kind); - takeOver(next, kind); - if (next === claim) { - return; - } - } - if (!waiting[kind].includes(claim)) { - waiting[kind].push(claim); + takeOver(outermost(claim, kind), kind); } }); } @@ -152,24 +227,19 @@ export function unmountGlobalCapture(claim) { return; } claim.state = 'unmounted'; + const index = mounted.indexOf(claim); + if (index !== -1) { + mounted.splice(index, 1); + } - Object.keys(claim.captures).forEach((kind) => { - waiting[kind] = waiting[kind].filter((other) => other !== claim); + KINDS.forEach((kind) => { if (owners[kind] !== claim) { return; } // Providers nested inside this one unmount with it (or, under StrictMode - // and Suspense, disappear and reappear with it), so they are skipped. Of - // the rest, the first to mount goes to its outermost enclosing Provider - // that is also waiting, so nested Providers keep capture with the outer one. - const next = waiting[kind].find((other) => !isAncestor(claim, other)); - if (next) { - takeOver( - outermost(next, kind, (parent) => waiting[kind].includes(parent)), - kind, - ); - } - // With nobody waiting, the owner keeps capturing, as an unmounted + // and Suspense, disappear and reappear with it), so they are skipped. + handOff(claim, kind, (other) => isAncestor(claim, other)); + // With nobody to hand to, the owner keeps capturing, as an unmounted // Provider's instance always has. The next Provider to claim or mount // takes over. }); diff --git a/src/provider.js b/src/provider.js index 1f2b4e3..85d8869 100644 --- a/src/provider.js +++ b/src/provider.js @@ -8,6 +8,7 @@ import { isRollbarInstance } from './utils'; import { abandonGlobalCapture, claimGlobalCapture, + manageGlobalCapture, mountGlobalCapture, unmountGlobalCapture, } from './global-capture'; @@ -101,7 +102,7 @@ export class Provider extends Component { throw e; } if (claim) { - claim.rollbar = this.rollbar; + manageGlobalCapture(claim, this.rollbar); this.captureClaim = claim; } } diff --git a/src/tests/components/global-capture.test.tsx b/src/tests/components/global-capture.test.tsx index 20c64b3..0a94c21 100644 --- a/src/tests/components/global-capture.test.tsx +++ b/src/tests/components/global-capture.test.tsx @@ -1,15 +1,28 @@ import React, { StrictMode, ReactNode } from 'react'; import { render } from '@testing-library/react'; import Rollbar from 'rollbar'; -import { Provider, useRollbar } from '../rollbar-react'; +import { + Provider, + useRollbar, + useRollbarConfiguration, +} from '../rollbar-react'; + +const CAPTURE_OFF = { + captureUncaught: false, + captureUnhandledRejections: false, +}; + +const CAPTURE_ON = { + captureUncaught: true, + captureUnhandledRejections: true, +}; // Each config records the uncaught items its instance tried to send. -function makeConfig() { +function makeConfig(capture = true) { const reports: unknown[] = []; const config: Rollbar.Configuration = { accessToken: 'POST_CLIENT_ITEM_TOKEN', - captureUncaught: true, - captureUnhandledRejections: true, + ...(capture && CAPTURE_ON), checkIgnore: (isUncaught, args) => { if (isUncaught) reports.push(args); return true; // never send @@ -52,6 +65,12 @@ async function rejectUnhandled() { await settle(); } +// Applies `config` to the enclosing Provider's instance on every render. +const Configure = ({ config }: { config: Rollbar.Configuration }) => { + useRollbarConfiguration(config); + return null; +}; + // Rendering its children again after an error throws away the first attempt, // including any Provider inside it, which never mounts. class Retry extends React.Component<{ children: ReactNode }> { @@ -370,4 +389,108 @@ describe('Provider global capture', () => { expect(instances[1].options.captureUncaught).toBe(false); expect(instances[1].options.accessToken).toBe('POST_CLIENT_ITEM_TOKEN'); }); + + it('keeps a Provider that opted out off when the owner unmounts', async () => { + const first = makeConfig(); + const second = makeConfig(); + const App = ({ showFirst }: { showFirst: boolean }) => ( + <> + {showFirst && ( + +
+ + )} + + + + + ); + + const { rerender } = render(); + rerender(); + await throwUncaught(); + await rejectUnhandled(); + + // Nobody else wants capture, so the unmounted owner keeps it. + expect(first.reports).toHaveLength(2); + expect(second.reports).toHaveLength(0); + }); + + it('hands capture to the next Provider when the owner opts out', async () => { + const first = makeConfig(); + const second = makeConfig(); + const App = ({ optOut }: { optOut: boolean }) => ( + <> + + {optOut && } + + +
+ + + ); + + const { rerender } = render(); + rerender(); + await throwUncaught(); + await rejectUnhandled(); + + expect(first.reports).toHaveLength(0); + expect(second.reports).toHaveLength(2); + }); + + it('keeps a Provider that opts in off while another owns capture', async () => { + const first = makeConfig(); + const second = makeConfig(false); + const App = ({ showFirst }: { showFirst: boolean }) => ( + <> + {showFirst && ( + +
+ + )} + + + + + ); + + const { rerender } = render(); + await throwUncaught(); + await rejectUnhandled(); + + expect(first.reports).toHaveLength(2); + expect(second.reports).toHaveLength(0); + + rerender(); + await throwUncaught(); + await rejectUnhandled(); + + expect(first.reports).toHaveLength(2); + expect(second.reports).toHaveLength(2); + }); + + it('stops capturing when the only owner opts out and resumes when it opts in', async () => { + const { config, reports } = makeConfig(); + let rollbar: Rollbar | undefined; + const Consumer = () => { + rollbar = useRollbar(); + return null; + }; + + render( + + + , + ); + rollbar?.configure(CAPTURE_OFF); + await throwUncaught(); + + expect(reports).toHaveLength(0); + + rollbar?.configure(CAPTURE_ON); + await throwUncaught(); + + expect(reports).toHaveLength(1); + }); }); diff --git a/src/tests/global-capture.test.js b/src/tests/global-capture.test.js index 1405c0c..ca80263 100644 --- a/src/tests/global-capture.test.js +++ b/src/tests/global-capture.test.js @@ -12,9 +12,9 @@ function load() { } // A stand-in for a Provider that creates its instance from `config`. -function provider(capture, parent = null) { +function provider(capture, parent = null, captureUncaught = true) { const { options, claim } = capture.claimGlobalCapture( - { captureUncaught: true }, + { captureUncaught }, parent?.claim, ); const rollbar = { @@ -25,7 +25,7 @@ function provider(capture, parent = null) { } }, }; - claim.rollbar = rollbar; + capture.manageGlobalCapture(claim, rollbar); return { claim, rollbar, @@ -96,4 +96,58 @@ describe('global capture coordination', () => { expect(capturing(outer, inner, sibling)).toEqual([false, false, true]); }); + + it('keeps an opt-out made before StrictMode remounts', () => { + const capture = load(); + const first = provider(capture); + const second = provider(capture); + + // A child of the second Provider opts out while it renders. + second.rollbar.configure({ captureUncaught: false }); + first.mount(); + second.mount(); + // React 18 and 19 StrictMode unmount and remount in the same order. + first.unmount(); + second.unmount(); + first.mount(); + second.mount(); + first.unmount(); + + expect(capturing(first, second)).toEqual([true, false]); + }); + + it('keeps an opt-in made before StrictMode remounts', () => { + const capture = load(); + const first = provider(capture); + const second = provider(capture, null, false); + + // A child of the second Provider opts in while it renders. + second.rollbar.configure({ captureUncaught: true }); + first.mount(); + second.mount(); + first.unmount(); + second.unmount(); + first.mount(); + second.mount(); + + expect(capturing(first, second)).toEqual([true, false]); + + first.unmount(); + + expect(capturing(first, second)).toEqual([false, true]); + }); + + it('hands capture to the outer of nested Providers when the owner opts out', () => { + const capture = load(); + const first = provider(capture); + const outer = provider(capture); + const inner = provider(capture, outer); + + first.mount(); + inner.mount(); + outer.mount(); + first.rollbar.configure({ captureUncaught: false }); + + expect(capturing(first, outer, inner)).toEqual([false, true, false]); + }); }); From d83c9ab4e3415ebb0c665a1f6127369af04c8f8f Mon Sep 17 00:00:00 2001 From: "devtools-agent[bot]" Date: Thu, 5 Nov 2026 21:51:31 +0000 Subject: [PATCH 6/7] Decide which instance reports a global error when it happens Addresses the review on #171. Instead of switching capture on and off through configure, each instance a Provider creates now checks, when its uncaught-error or rejection handler runs, whether it is the one to report: of the instances with the same access token that capture that kind, the first-created mounted one; with none mounted, the newest that has mounted, else the newest. The choice is made from current options each time. - Scoped per accessToken, so Providers for different projects each report. - configure and the app's options are untouched; state.rollbar is kept (instance is created in the constructor again; ProviderState restored). - A shared instance from a custom constructor is managed once. - Same owner with or without StrictMode; nothing is enabled at teardown. - Chrome anonymous errors reach the reporting instance. - Component tests use a token per test so each starts from a clean page. Co-Authored-By: Claude Opus 5.5 --- README.md | 14 +- index.d.ts | 7 +- src/global-capture.js | 347 +++++++------------ src/provider.js | 76 +--- src/tests/components/global-capture.test.tsx | 204 ++++++++++- src/tests/global-capture.test.js | 155 ++++++--- 6 files changed, 445 insertions(+), 358 deletions(-) diff --git a/README.md b/README.md index 20c2abd..0033e4d 100644 --- a/README.md +++ b/README.md @@ -198,13 +198,13 @@ export function App(props) { } ``` -The instance is created when the `Provider` first renders, so React's `StrictMode` doesn't create a second one. - -If more than one `Provider` creates an instance with `captureUncaught` or `captureUnhandledRejections` enabled, only -the first one to render captures those global errors, so each error is reported once. When it unmounts, the next one -takes over. Turning those options off or on later with `configure` or `useRollbarConfiguration` works the same way: a -`Provider` that turns capture off hands it to the next one, and one that turns it on captures once no other `Provider` -does. Instances you pass with the `instance` prop aren't coordinated this way, so create them once and share them. +If more than one `Provider` creates an instance with `captureUncaught` or `captureUnhandledRejections` enabled for the +same `accessToken`, each uncaught error or unhandled rejection is reported once, by the first of them to be created +that is still mounted, rather than once per `Provider`. The same applies to the second instance React's `StrictMode` +creates in development. `Provider`s with different access tokens each report every error to their own project. The +capture options are read when an error happens, so turning them off or on later with `configure` or +`useRollbarConfiguration` decides which `Provider` reports. Instances you pass with the `instance` prop aren't +coordinated this way, so create them once and share them. #### Instance Usage diff --git a/index.d.ts b/index.d.ts index d2e24f9..4214ea3 100644 --- a/index.d.ts +++ b/index.d.ts @@ -53,7 +53,12 @@ export interface ProviderProps { instance?: Rollbar; } -export class Provider extends Component {} +interface ProviderState { + rollbar: Rollbar; + options: Rollbar.Configuration; +} + +export class Provider extends Component {} declare const RollbarInstance: unique symbol; declare const BaseOptions: unique symbol; diff --git a/src/global-capture.js b/src/global-capture.js index 1d0d761..904fd6e 100644 --- a/src/global-capture.js +++ b/src/global-capture.js @@ -1,266 +1,159 @@ // window.onerror and unhandledrejection are shared by the whole page, but -// every Rollbar instance that captures them adds its own handler. With several +// every Rollbar instance that captures them adds its own handler, and +// rollbar.js only filters duplicates within one instance. With several // Providers each building an instance from `config`, one error was reported -// once per Provider. So among the instances Providers create, only one owns -// each kind of capture at a time; the others have it turned off until the -// owner unmounts or opts out. Instances passed in with the `instance` prop -// aren't managed. +// once per Provider. // -// The app can change the capture options at any time through `configure` -// (directly or with useRollbarConfiguration), so each managed instance's -// `configure` is wrapped: capture options passed to it are recorded as what -// that Provider wants, and applied only while it owns that kind of capture. - -const CAPTURE_OPTIONS = { - uncaught: ['captureUncaught', 'handleUncaughtExceptions'], - rejections: ['captureUnhandledRejections', 'handleUnhandledRejections'], +// So each instance a Provider creates decides, when its handler runs, whether +// it is the one to report. Of the instances reporting to the same access token +// that capture that kind of error, that is the one whose Provider was created +// first among those mounted. While none is mounted, it is the one created +// last among those that have mounted before, or failing that among those that +// haven't mounted yet (or never will, like StrictMode's extra construction). +// The decision is made from the instances' current options every time, so +// options changed with `configure` are respected and nothing about it depends +// on the order Providers mounted or unmounted in. The app's options are left +// alone. Instances passed in with the `instance` prop aren't managed. + +const KINDS = { + uncaught: { + handler: 'handleUncaughtException', + options: ['captureUncaught', 'handleUncaughtExceptions'], + }, + rejections: { + handler: 'handleUnhandledRejection', + options: ['captureUnhandledRejections', 'handleUnhandledRejections'], + }, }; -const KINDS = Object.keys(CAPTURE_OPTIONS); - -const owners = { uncaught: null, rejections: null }; -// Mounted claims, in the order they mounted. -const mounted = []; - -function captureOff(kind) { - const off = {}; - CAPTURE_OPTIONS[kind].forEach((key) => { - off[key] = false; - }); - return off; +// Claims that are mounted or whose instance captures something. Window +// already keeps the latter instances alive through their handlers. +let claims = []; +// Managed instance → { handlers: its own handlers, latest: its newest claim }. +const managed = new WeakMap(); +let created = 0; + +function captures(claim, kind) { + const options = claim.rollbar.options || {}; + return ( + options.enabled !== false && KINDS[kind].options.some((key) => options[key]) + ); } -// Records the capture options in `options` as what `claim` wants. Returns the -// kinds they belong to. -function request(claim, options) { - return KINDS.filter((kind) => { - const keys = CAPTURE_OPTIONS[kind].filter((key) => key in options); - keys.forEach((key) => { - claim.requested[kind][key] = options[key]; - }); - return keys.length > 0; - }); +function capturesAny(claim) { + return Object.keys(KINDS).some((kind) => captures(claim, kind)); } -function wants(claim, kind) { - return CAPTURE_OPTIONS[kind].some((key) => claim.requested[kind][key]); +function project(claim) { + return claim.rollbar.options?.accessToken; } -// These bypass the wrapper, so they don't count as the app's requests. -function enable(claim, kind) { - claim.configure?.(claim.requested[kind]); -} - -function disable(claim, kind) { - claim.configure?.(captureOff(kind)); -} - -function takeOver(claim, kind) { - const previous = owners[kind]; - if (previous && previous !== claim) { - disable(previous, kind); +function register(claim) { + if (!claims.includes(claim)) { + claims.push(claim); } - owners[kind] = claim; - enable(claim, kind); } -function isAncestor(other, claim) { - for (let parent = claim.parent; parent; parent = parent.parent) { - if (parent === other) { - return true; - } +// The claim whose instance reports `kind` for the project `self` reports to. +function reporter(self, kind) { + const candidates = claims + .concat(self) + .filter( + (claim) => captures(claim, kind) && project(claim) === project(self), + ); + const mounted = candidates.filter((claim) => claim.mounted); + if (mounted.length) { + return mounted.reduce((a, b) => (b.seq < a.seq ? b : a)); } - return false; + const unmounted = candidates.filter((claim) => claim.hasMounted); + return (unmounted.length ? unmounted : candidates).reduce( + (a, b) => (!a || b.seq > a.seq ? b : a), + null, + ); } -// The outermost of `claim` and its enclosing Providers' claims that wants -// `kind` and passes `eligible`. -function outermost(claim, kind, eligible = () => true) { - let found = claim; - for (let parent = claim.parent; parent; parent = parent.parent) { - if (wants(parent, kind) && eligible(parent)) { - found = parent; - } - } - return found; +// In Chrome, rollbar.js can't see an anonymous (cross-origin) error in +// onerror. Every instance counts it there, and whichever instance set +// Error.prepareStackTrace last reports it from there with `_isAnonymous` set. +// Mirrors the check in rollbar.js's handleUncaughtException. +function isAnonymousProbe(rollbar, url, error) { + return ( + rollbar.options?.inspectAnonymousErrors && + rollbar.isChrome && + error === null && + url === '' + ); } -// Gives `kind` from its owner `claim` to the first other mounted Provider that -// wants it and that `skip` doesn't exclude, or to the outermost such Provider -// around it, so nested Providers keep capture with the outer one. Returns -// false if there is none. -function handOff(claim, kind, skip = () => false) { - const eligible = (other) => - other !== claim && - mounted.includes(other) && - wants(other, kind) && - !skip(other); - const next = mounted.find(eligible); - if (!next) { - return false; - } - takeOver(outermost(next, kind, eligible), kind); - return true; -} - -// After the app changed what `claim` wants for `kind`. -function reconcile(claim, kind) { - const owner = owners[kind]; - if (owner === claim) { - if (wants(claim, kind)) { - enable(claim, kind); - } else if (!handOff(claim, kind)) { - disable(claim, kind); - owners[kind] = null; - } - return; - } - // Otherwise it stays off unless nobody else captures this kind. - if ( - wants(claim, kind) && - claim.state !== 'unmounted' && - (!owner || owner.state === 'unmounted') - ) { - takeOver(claim, kind); - } -} - -// Call before constructing the instance, with the claim of the nearest -// enclosing Provider, if any. Returns the options to construct it with (global -// capture turned off where another instance already owns it) and a claim to -// pass to the other functions. -export function claimGlobalCapture(options, parent = null) { - if (typeof window === 'undefined' || !options) { - return { options, claim: null }; - } - - const claim = { - configure: null, - state: 'pending', - requested: { uncaught: {}, rejections: {} }, - replaced: {}, - parent, - }; - let ctorOptions = options; - - request(claim, options).forEach((kind) => { - if (!wants(claim, kind)) { - return; +function gate(rollbar, record, kind) { + const original = rollbar[KINDS[kind].handler]; + record.handlers[kind] = original; + rollbar[KINDS[kind].handler] = function (...args) { + if (kind === 'uncaught') { + const [, url, , , error] = args; + if (isAnonymousProbe(rollbar, url, error)) { + return original.apply(rollbar, args); + } + if (error?._isAnonymous) { + // Only this instance received it, so hand it to the reporter. + const chosen = reporter(record.latest, kind); + const target = chosen ? chosen.rollbar : rollbar; + return managed.get(target).handlers[kind].apply(target, args); + } } - const owner = owners[kind]; - if (!owner) { - owners[kind] = claim; - claim.replaced[kind] = null; - } else if (owner.state === 'unmounted') { - // Its handler is still installed, so it has to be switched off. - disable(owner, kind); - owners[kind] = claim; - claim.replaced[kind] = owner; - } else { - ctorOptions = { ...ctorOptions, ...captureOff(kind) }; + const chosen = reporter(record.latest, kind); + if (chosen && chosen.rollbar !== rollbar) { + return undefined; } - }); - - return { options: ctorOptions, claim }; + return original.apply(rollbar, args); + }; } -// Call once the instance is constructed. -export function manageGlobalCapture(claim, rollbar) { - if (!claim) { - return; +// Call once a Provider has created `rollbar` from its config. Returns a claim +// to pass to the other functions. +export function manageGlobalCapture(rollbar) { + if (typeof window === 'undefined' || !rollbar) { + return null; } - const configure = rollbar?.configure; - if (typeof configure !== 'function') { - return; - } - claim.configure = (options) => configure.call(rollbar, options); - - rollbar.configure = (options, ...rest) => { - if (!options) { - return configure.call(rollbar, options, ...rest); - } - const kinds = request(claim, options); - const others = { ...options }; - kinds.forEach((kind) => { - CAPTURE_OPTIONS[kind].forEach((key) => { - delete others[key]; - }); + let record = managed.get(rollbar); + if (!record) { + record = { handlers: {}, latest: null }; + Object.keys(KINDS).forEach((kind) => { + if (typeof rollbar[KINDS[kind].handler] === 'function') { + gate(rollbar, record, kind); + } }); - const result = configure.call(rollbar, others, ...rest); - kinds.forEach((kind) => reconcile(claim, kind)); - return result; - }; + managed.set(rollbar, record); + } + const claim = { rollbar, seq: created++, mounted: false, hasMounted: false }; + record.latest = claim; + if (capturesAny(claim)) { + register(claim); + } + return claim; } export function mountGlobalCapture(claim) { if (!claim) { return; } - claim.state = 'mounted'; - if (!mounted.includes(claim)) { - mounted.push(claim); - } - - KINDS.forEach((kind) => { - const owner = owners[kind]; - if (owner === claim || !wants(claim, kind)) { - return; - } - // Mounting runs children before parents and earlier siblings before later - // ones, so of the Providers rendered in this commit only an ancestor mounts - // after this one, and it keeps capture. Any other owner still pending was - // rendered in a pass React threw away (or hasn't committed), so it is - // replaced like an unmounted one: by the outermost enclosing Provider - // that wants this kind, which is mounting in this same commit. - if ( - !owner || - owner.state === 'unmounted' || - (owner.state === 'pending' && !isAncestor(owner, claim)) - ) { - takeOver(outermost(claim, kind), kind); - } - }); + claim.mounted = true; + claim.hasMounted = true; + register(claim); } export function unmountGlobalCapture(claim) { if (!claim) { return; } - claim.state = 'unmounted'; - const index = mounted.indexOf(claim); - if (index !== -1) { - mounted.splice(index, 1); - } - - KINDS.forEach((kind) => { - if (owners[kind] !== claim) { - return; - } - // Providers nested inside this one unmount with it (or, under StrictMode - // and Suspense, disappear and reappear with it), so they are skipped. - handOff(claim, kind, (other) => isAncestor(claim, other)); - // With nobody to hand to, the owner keeps capturing, as an unmounted - // Provider's instance always has. The next Provider to claim or mount - // takes over. - }); -} - -// Call instead of the others when constructing the instance fails, to give -// capture back to whichever owner the claim replaced. -export function abandonGlobalCapture(claim) { - if (!claim) { - return; + claim.mounted = false; + // Other unmounted claims for the same instance (a shared one, say) would + // decide nothing this one doesn't. + claims = claims.filter( + (other) => + other === claim || other.mounted || other.rollbar !== claim.rollbar, + ); + if (!capturesAny(claim)) { + claims = claims.filter((other) => other !== claim); } - claim.state = 'unmounted'; - - Object.keys(claim.replaced).forEach((kind) => { - if (owners[kind] !== claim) { - return; - } - const previous = claim.replaced[kind]; - owners[kind] = previous; - if (previous) { - enable(previous, kind); - } - }); } diff --git a/src/provider.js b/src/provider.js index 85d8869..59a1b4c 100644 --- a/src/provider.js +++ b/src/provider.js @@ -4,10 +4,8 @@ import React, { Component, createContext } from 'react'; import PropTypes from 'prop-types'; import Rollbar from 'rollbar'; import invariant from 'tiny-invariant'; -import { isRollbarInstance } from './utils'; +import { isRollbarInstance, value } from './utils'; import { - abandonGlobalCapture, - claimGlobalCapture, manageGlobalCapture, mountGlobalCapture, unmountGlobalCapture, @@ -20,11 +18,6 @@ export const RollbarInstance = Symbol('RollbarInstance'); export const BaseOptions = Symbol('BaseOptions'); export const RollbarCtor = Symbol('RollbarCtor'); -// Carries the enclosing Provider's global capture claim. Kept apart from -// `Context`, whose value is new on every render, so that nested Providers -// only subscribe to a value that doesn't change after their first render. -const CaptureClaimContext = createContext(null); - export function getRollbarFromContext(context) { const { [RollbarInstance]: rollbar } = context; return rollbar; @@ -70,49 +63,19 @@ export class Provider extends Component { children: PropTypes.node, }; - static contextType = CaptureClaimContext; - constructor(props) { super(props); - const { instance } = this.props; + const { config, Rollbar: ctor = Rollbar, instance } = this.props; invariant( !instance || isRollbarInstance(instance), '`instance` must be a configured instance of Rollbar', ); - } - - // Created on first render rather than in the constructor: in development, - // StrictMode constructs class components twice and throws one away, and a - // Rollbar instance built there would keep its global handlers regardless. - getRollbar() { - if (!this.rollbar) { - const { config, Rollbar: ctor = Rollbar, instance } = this.props; - const options = typeof config === 'function' ? config() : config; - if (instance) { - this.rollbar = instance; - } else { - const { options: ctorOptions, claim } = claimGlobalCapture( - options, - this.parentClaim(), - ); - try { - this.rollbar = new ctor(ctorOptions); - } catch (e) { - abandonGlobalCapture(claim); - throw e; - } - if (claim) { - manageGlobalCapture(claim, this.rollbar); - this.captureClaim = claim; - } - } - this.options = options; - } - return this.rollbar; - } - - parentClaim() { - return this.context; + const options = value(config); + const rollbar = instance || new ctor(options); + // So that several Providers, or StrictMode's second construction, report + // each global error once. + this.captureClaim = instance ? null : manageGlobalCapture(rollbar); + this.state = { rollbar, options }; } componentDidMount() { @@ -125,23 +88,18 @@ export class Provider extends Component { render() { const { children, Rollbar: ctor = Rollbar } = this.props; - const rollbar = this.getRollbar(); - const { options } = this; + const { rollbar, options } = this.state; return ( - - - {children} - - + {children} + ); } } diff --git a/src/tests/components/global-capture.test.tsx b/src/tests/components/global-capture.test.tsx index 0a94c21..05f6e10 100644 --- a/src/tests/components/global-capture.test.tsx +++ b/src/tests/components/global-capture.test.tsx @@ -17,11 +17,22 @@ const CAPTURE_ON = { captureUnhandledRejections: true, }; +// Capture is coordinated per access token, so each test gets its own and +// starts as a page with no Providers does. +let tokens = 0; +let accessToken = ''; + +beforeEach(() => { + tokens += 1; + accessToken = `POST_CLIENT_ITEM_TOKEN_${tokens}`; + window.onerror = null; +}); + // Each config records the uncaught items its instance tried to send. -function makeConfig(capture = true) { +function makeConfig(capture = true, token = accessToken) { const reports: unknown[] = []; const config: Rollbar.Configuration = { - accessToken: 'POST_CLIENT_ITEM_TOKEN', + accessToken: token, ...(capture && CAPTURE_ON), checkIgnore: (isUncaught, args) => { if (isUncaught) reports.push(args); @@ -131,11 +142,9 @@ describe('Provider global capture', () => { expect(providers.map(({ reports }) => reports.length)).toEqual([2, 0, 0]); }); - it('builds one instance under StrictMode', async () => { - const { config, reports } = makeConfig(); - const ctor = jest.fn( - (options: Rollbar.Configuration) => new Rollbar(options), - ); + it('reports once, from the provided instance, under StrictMode', async () => { + const { config } = makeConfig(); + const { ctor, reports } = countingRollbar(); let provided: Rollbar | undefined; const Consumer = () => { provided = useRollbar(); @@ -151,10 +160,82 @@ describe('Provider global capture', () => { ); await throwUncaught(); + await rejectUnhandled(); - expect(ctor).toHaveBeenCalledTimes(1); - expect(provided).toBe(ctor.mock.results[0].value); - expect(reports).toHaveLength(1); + // StrictMode constructs the Provider twice and keeps one. + expect(ctor).toHaveBeenCalledTimes(2); + const kept = ctor.mock.results.findIndex(({ value }) => value === provided); + expect(kept).not.toBe(-1); + expect(reports[kept]).toBe(2); + expect(reports.reduce((a, b) => a + b)).toBe(2); + }); + + it('reports to each project once', async () => { + const a = makeConfig(true, `${accessToken}_A`); + const otherA = makeConfig(true, `${accessToken}_A`); + const b = makeConfig(true, `${accessToken}_B`); + render( + <> + {[a, otherA, b].map(({ config }, i) => ( + +
+ + ))} + , + ); + + await throwUncaught(); + await rejectUnhandled(); + + expect([a, otherA, b].map(({ reports }) => reports.length)).toEqual([ + 2, 0, 2, + ]); + }); + + it('reports once with an instance shared through a custom constructor', async () => { + const { config, reports } = makeConfig(); + const shared = new Rollbar(config); + const ctor = jest.fn(() => shared); + const App = ({ showFirst }: { showFirst: boolean }) => ( + <> + {showFirst && ( + +
+ + )} + +
+ + + ); + + const { rerender } = render(); + await throwUncaught(); + rerender(); + await throwUncaught(); + await rejectUnhandled(); + + expect(reports).toHaveLength(3); + }); + + it('keeps the instance and options in state', () => { + const { config } = makeConfig(); + const ref = React.createRef(); + let provided: Rollbar | undefined; + const Consumer = () => { + provided = useRollbar(); + return null; + }; + + render( + + + , + ); + + const state = ref.current?.state as { rollbar: Rollbar; options: unknown }; + expect(state.rollbar).toBe(provided); + expect(state.options).toBe(config); }); it('hands capture to the next Provider when the owner unmounts', async () => { @@ -365,7 +446,7 @@ describe('Provider global capture', () => { expect(renders).toBe(1); }); - it('turns capture off only where another instance owns it', () => { + it("leaves the app's options and configure alone", () => { const owner = makeConfig(); const other = makeConfig(); const instances: Rollbar[] = []; @@ -384,10 +465,56 @@ describe('Provider global capture', () => { , ); + const configure = jest.spyOn(instances[1], 'configure'); + instances[1].configure({ captureUncaught: true, payload: {} }); - expect(instances[0].options.captureUncaught).toBe(true); - expect(instances[1].options.captureUncaught).toBe(false); - expect(instances[1].options.accessToken).toBe('POST_CLIENT_ITEM_TOKEN'); + expect(instances.map((rollbar) => rollbar.options.captureUncaught)).toEqual( + [true, true], + ); + expect(instances[0].configure).toBe(Rollbar.prototype.configure); + expect(configure).toHaveBeenCalledTimes(1); + expect(configure).toHaveBeenCalledWith({ + captureUncaught: true, + payload: {}, + }); + }); + + it('gives capture to an outer Provider that opts in', async () => { + const outer = makeConfig(false); + const inner = makeConfig(); + const App = ({ optIn }: { optIn: boolean }) => ( + + +
+ + {optIn && } + + ); + + const { rerender } = render(); + await throwUncaught(); + rerender(); + await throwUncaught(); + + expect(outer.reports).toHaveLength(1); + expect(inner.reports).toHaveLength(1); + }); + + it('reports once after every Provider unmounts', async () => { + const providers = [makeConfig(), makeConfig(false), makeConfig()]; + const { unmount } = render( + <> + {providers.map(({ config }, i) => ( + +
+ + ))} + , + ); + unmount(); + await throwUncaught(); + + expect(providers.map(({ reports }) => reports.length)).toEqual([0, 0, 1]); }); it('keeps a Provider that opted out off when the owner unmounts', async () => { @@ -493,4 +620,53 @@ describe('Provider global capture', () => { expect(reports).toHaveLength(1); }); + + describe('in Chrome', () => { + const { prepareStackTrace } = Error; + + beforeEach(() => { + Object.assign(window, { chrome: { runtime: {} } }); + }); + + afterEach(() => { + delete (window as { chrome?: unknown }).chrome; + Error.prepareStackTrace = prepareStackTrace; + }); + + // Chrome calls onerror without the error, then Error.prepareStackTrace, + // which the instance that installed its handlers last set, with it. + async function throwAnonymous() { + window.onerror?.('Script error.', '', 0, 0, null as unknown as Error); + Error.prepareStackTrace?.(new Error('anonymous'), []); + await settle(); + } + + it('reports an anonymous error once, from the reporting instance', async () => { + const first = makeConfig(); + const second = makeConfig(); + const App = ({ optOut, showSecond }: Record) => ( + <> + + + + {showSecond && ( + +
+ + )} + + ); + + const { rerender } = render(); + await throwAnonymous(); + rerender(); + await throwAnonymous(); + rerender(); + rerender(); + await throwAnonymous(); + + expect(first.reports).toHaveLength(2); + expect(second.reports).toHaveLength(1); + }); + }); }); diff --git a/src/tests/global-capture.test.js b/src/tests/global-capture.test.js index ca80263..49a15f5 100644 --- a/src/tests/global-capture.test.js +++ b/src/tests/global-capture.test.js @@ -11,36 +11,48 @@ function load() { return module; } -// A stand-in for a Provider that creates its instance from `config`. -function provider(capture, parent = null, captureUncaught = true) { - const { options, claim } = capture.claimGlobalCapture( - { captureUncaught }, - parent?.claim, - ); +// A stand-in for an instance a Provider creates from `options`, recording +// which global errors it reported. +function instance(options) { const rollbar = { - capturing: options.captureUncaught, + options: { accessToken: 'A', captureUncaught: true, ...options }, + reports: [], configure(changes) { - if ('captureUncaught' in changes) { - this.capturing = changes.captureUncaught; - } + this.options = { ...this.options, ...changes }; + }, + handleUncaughtException(message) { + if (this.options.captureUncaught) this.reports.push(message); }, }; - capture.manageGlobalCapture(claim, rollbar); + return rollbar; +} + +function provider(capture, options, rollbar = instance(options)) { + const claim = capture.manageGlobalCapture(rollbar); return { - claim, rollbar, mount: () => capture.mountGlobalCapture(claim), unmount: () => capture.unmountGlobalCapture(claim), }; } -const capturing = (...providers) => providers.map((p) => p.rollbar.capturing); +// Calls every instance's handler, as the chained window.onerror does. +function throwUncaught(...providers) { + [...new Set(providers.map((p) => p.rollbar))] + .reverse() + .forEach((rollbar) => + rollbar.handleUncaughtException('boom', 'app.js', 1, 1, new Error()), + ); +} + +const reported = (...providers) => + providers.map((p) => p.rollbar.reports.length); describe('global capture coordination', () => { it('keeps capture with the outer Provider under React 19 StrictMode', () => { const capture = load(); const outer = provider(capture); - const inner = provider(capture, outer); + const inner = provider(capture); inner.mount(); outer.mount(); @@ -49,14 +61,15 @@ describe('global capture coordination', () => { inner.unmount(); inner.mount(); outer.mount(); + throwUncaught(outer, inner); - expect(capturing(outer, inner)).toEqual([true, false]); + expect(reported(outer, inner)).toEqual([1, 0]); }); it('keeps capture with the outer Provider under React 18 StrictMode', () => { const capture = load(); const outer = provider(capture); - const inner = provider(capture, outer); + const inner = provider(capture); inner.mount(); outer.mount(); @@ -65,36 +78,36 @@ describe('global capture coordination', () => { outer.unmount(); inner.mount(); outer.mount(); + throwUncaught(outer, inner); - expect(capturing(outer, inner)).toEqual([true, false]); + expect(reported(outer, inner)).toEqual([1, 0]); }); - it('hands capture to the outer of nested Providers', () => { + it('gives capture to an outer Provider that opts in, with or without StrictMode', () => { const capture = load(); - const first = provider(capture); - const outer = provider(capture); - const inner = provider(capture, outer); + const outer = provider(capture, { captureUncaught: false }); + const inner = provider(capture); - first.mount(); + // A child of the outer Provider opts in while it renders. + outer.rollbar.configure({ captureUncaught: true }); inner.mount(); outer.mount(); - first.unmount(); + throwUncaught(outer, inner); - expect(capturing(first, outer, inner)).toEqual([false, true, false]); - }); - - it('skips Providers nested in the owner when handing capture over', () => { - const capture = load(); - const outer = provider(capture); - const inner = provider(capture, outer); - const sibling = provider(capture); + expect(reported(outer, inner)).toEqual([1, 0]); + // React 18 StrictMode, then React 19 StrictMode. + inner.unmount(); + outer.unmount(); inner.mount(); outer.mount(); - sibling.mount(); outer.unmount(); + inner.unmount(); + inner.mount(); + outer.mount(); + throwUncaught(outer, inner); - expect(capturing(outer, inner, sibling)).toEqual([false, false, true]); + expect(reported(outer, inner)).toEqual([2, 0]); }); it('keeps an opt-out made before StrictMode remounts', () => { @@ -106,48 +119,90 @@ describe('global capture coordination', () => { second.rollbar.configure({ captureUncaught: false }); first.mount(); second.mount(); - // React 18 and 19 StrictMode unmount and remount in the same order. first.unmount(); second.unmount(); first.mount(); second.mount(); first.unmount(); + throwUncaught(first, second); - expect(capturing(first, second)).toEqual([true, false]); + // Nobody mounted wants capture, so the unmounted first one keeps it. + expect(reported(first, second)).toEqual([1, 0]); }); - it('keeps an opt-in made before StrictMode remounts', () => { + it("prefers an unmounted Provider to StrictMode's extra construction", () => { const capture = load(); const first = provider(capture); - const second = provider(capture, null, false); + // StrictMode constructs the second Provider twice; one never mounts. + const discarded = provider(capture); + const second = provider(capture); - // A child of the second Provider opts in while it renders. - second.rollbar.configure({ captureUncaught: true }); + second.rollbar.configure({ captureUncaught: false }); first.mount(); second.mount(); first.unmount(); - second.unmount(); + throwUncaught(first, discarded, second); + + expect(reported(first, discarded, second)).toEqual([1, 0, 0]); + }); + + it('reports once with an instance shared by several Providers', () => { + const capture = load(); + const shared = instance(); + const first = provider(capture, undefined, shared); + const second = provider(capture, undefined, shared); + first.mount(); second.mount(); + throwUncaught(first, second); + first.unmount(); + throwUncaught(first, second); + second.unmount(); + throwUncaught(first, second); - expect(capturing(first, second)).toEqual([true, false]); + expect(shared.reports).toHaveLength(3); + }); - first.unmount(); + it('reports to every project', () => { + const capture = load(); + const a = provider(capture, { accessToken: 'A' }); + const otherA = provider(capture, { accessToken: 'A' }); + const b = provider(capture, { accessToken: 'B' }); + + a.mount(); + otherA.mount(); + b.mount(); + throwUncaught(a, otherA, b); - expect(capturing(first, second)).toEqual([false, true]); + expect(reported(a, otherA, b)).toEqual([1, 0, 1]); }); - it('hands capture to the outer of nested Providers when the owner opts out', () => { + it('skips a Provider whose instance is disabled', () => { + const capture = load(); + const first = provider(capture, { enabled: false }); + const second = provider(capture); + + first.mount(); + second.mount(); + throwUncaught(first, second); + + expect(reported(first, second)).toEqual([0, 1]); + }); + + it('reports once after every Provider unmounts', () => { const capture = load(); const first = provider(capture); - const outer = provider(capture); - const inner = provider(capture, outer); + const second = provider(capture); + const third = provider(capture); first.mount(); - inner.mount(); - outer.mount(); - first.rollbar.configure({ captureUncaught: false }); + second.mount(); + third.mount(); + first.unmount(); + second.unmount(); + third.unmount(); + throwUncaught(first, second, third); - expect(capturing(first, outer, inner)).toEqual([false, true, false]); + expect(reported(first, second, third)).toEqual([0, 0, 1]); }); }); From c7d52d58fe909195aa80ca17f6f2fe38bced24df Mon Sep 17 00:00:00 2001 From: AI Agent Date: Thu, 5 Nov 2026 22:56:14 +0000 Subject: [PATCH 7/7] Have every instance choose the reporter from the same claims A shared instance (custom Rollbar constructor) and a separate instance for the same token could both report an error once all their Providers had unmounted. Each instance added its own newest claim to the candidates, and unmounting dropped the newer claims of a shared instance in favour of the one just unmounted, so the two instances disagreed. - reporter() chooses only from the registered claims, so every instance reaches the same answer; an instance with none registered reports only when no other instance for its token does. - On unmount, an instance keeps its newest claim that has mounted, so the result no longer depends on unmount order and StrictMode's extra construction can't stand in for it. - README: the reporting Provider's options decide how the error is sent. Co-Authored-By: Claude Opus 5.5 --- README.md | 13 ++++--- src/global-capture.js | 57 ++++++++++++++------------- src/tests/global-capture.test.js | 66 ++++++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 0033e4d..de8d3a2 100644 --- a/README.md +++ b/README.md @@ -199,12 +199,13 @@ export function App(props) { ``` If more than one `Provider` creates an instance with `captureUncaught` or `captureUnhandledRejections` enabled for the -same `accessToken`, each uncaught error or unhandled rejection is reported once, by the first of them to be created -that is still mounted, rather than once per `Provider`. The same applies to the second instance React's `StrictMode` -creates in development. `Provider`s with different access tokens each report every error to their own project. The -capture options are read when an error happens, so turning them off or on later with `configure` or -`useRollbarConfiguration` decides which `Provider` reports. Instances you pass with the `instance` prop aren't -coordinated this way, so create them once and share them. +same `accessToken`, each uncaught error or unhandled rejection is reported once, by the first of them to be created that +is still mounted, rather than once per `Provider`. That `Provider`'s own options (`environment`, `checkIgnore` and so +on) decide how the error is reported, and the others don't send a copy of their own. The same applies to the second +instance React's `StrictMode` creates in development. `Provider`s with different access tokens each report every error +to their own project. The capture options are read when an error happens, so turning them off or on later with +`configure` or `useRollbarConfiguration` decides which `Provider` reports. Instances you pass with the `instance` prop +aren't coordinated this way, so create them once and share them. #### Instance Usage diff --git a/src/global-capture.js b/src/global-capture.js index 904fd6e..63540b5 100644 --- a/src/global-capture.js +++ b/src/global-capture.js @@ -29,7 +29,7 @@ const KINDS = { // Claims that are mounted or whose instance captures something. Window // already keeps the latter instances alive through their handlers. let claims = []; -// Managed instance → { handlers: its own handlers, latest: its newest claim }. +// Managed instance → its own handlers, by kind. const managed = new WeakMap(); let created = 0; @@ -44,8 +44,8 @@ function capturesAny(claim) { return Object.keys(KINDS).some((kind) => captures(claim, kind)); } -function project(claim) { - return claim.rollbar.options?.accessToken; +function project(rollbar) { + return rollbar.options?.accessToken; } function register(claim) { @@ -54,13 +54,14 @@ function register(claim) { } } -// The claim whose instance reports `kind` for the project `self` reports to. -function reporter(self, kind) { - const candidates = claims - .concat(self) - .filter( - (claim) => captures(claim, kind) && project(claim) === project(self), - ); +// The claim whose instance reports `kind` for the project `rollbar` reports +// to, or null if none is registered. Every instance chooses from the same +// claims, so they all agree on it. +function reporter(rollbar, kind) { + const candidates = claims.filter( + (claim) => + captures(claim, kind) && project(claim.rollbar) === project(rollbar), + ); const mounted = candidates.filter((claim) => claim.mounted); if (mounted.length) { return mounted.reduce((a, b) => (b.seq < a.seq ? b : a)); @@ -85,9 +86,9 @@ function isAnonymousProbe(rollbar, url, error) { ); } -function gate(rollbar, record, kind) { +function gate(rollbar, handlers, kind) { const original = rollbar[KINDS[kind].handler]; - record.handlers[kind] = original; + handlers[kind] = original; rollbar[KINDS[kind].handler] = function (...args) { if (kind === 'uncaught') { const [, url, , , error] = args; @@ -96,12 +97,12 @@ function gate(rollbar, record, kind) { } if (error?._isAnonymous) { // Only this instance received it, so hand it to the reporter. - const chosen = reporter(record.latest, kind); + const chosen = reporter(rollbar, kind); const target = chosen ? chosen.rollbar : rollbar; - return managed.get(target).handlers[kind].apply(target, args); + return managed.get(target)[kind].apply(target, args); } } - const chosen = reporter(record.latest, kind); + const chosen = reporter(rollbar, kind); if (chosen && chosen.rollbar !== rollbar) { return undefined; } @@ -115,18 +116,16 @@ export function manageGlobalCapture(rollbar) { if (typeof window === 'undefined' || !rollbar) { return null; } - let record = managed.get(rollbar); - if (!record) { - record = { handlers: {}, latest: null }; + if (!managed.has(rollbar)) { + const handlers = {}; Object.keys(KINDS).forEach((kind) => { if (typeof rollbar[KINDS[kind].handler] === 'function') { - gate(rollbar, record, kind); + gate(rollbar, handlers, kind); } }); - managed.set(rollbar, record); + managed.set(rollbar, handlers); } const claim = { rollbar, seq: created++, mounted: false, hasMounted: false }; - record.latest = claim; if (capturesAny(claim)) { register(claim); } @@ -147,13 +146,17 @@ export function unmountGlobalCapture(claim) { return; } claim.mounted = false; - // Other unmounted claims for the same instance (a shared one, say) would - // decide nothing this one doesn't. + // Of an instance's unmounted claims (a shared one has several), only the + // newest that has mounted can be chosen, so keep just that one, or none if + // the instance captures nothing. + const unmounted = claims + .filter((other) => other.rollbar === claim.rollbar && !other.mounted) + .concat(claim); + const newest = unmounted + .filter((other) => other.hasMounted) + .reduce((a, b) => (b.seq > a.seq ? b : a)); claims = claims.filter( (other) => - other === claim || other.mounted || other.rollbar !== claim.rollbar, + !unmounted.includes(other) || (other === newest && capturesAny(other)), ); - if (!capturesAny(claim)) { - claims = claims.filter((other) => other !== claim); - } } diff --git a/src/tests/global-capture.test.js b/src/tests/global-capture.test.js index 49a15f5..eedb3f2 100644 --- a/src/tests/global-capture.test.js +++ b/src/tests/global-capture.test.js @@ -163,6 +163,72 @@ describe('global capture coordination', () => { expect(shared.reports).toHaveLength(3); }); + it.each([ + ['newest first', ['second', 'first', 'own']], + ['oldest first', ['first', 'second', 'own']], + ])( + 'reports once with a shared and a separate instance, unmounting the shared one %s', + (_, order) => { + const capture = load(); + const shared = instance(); + const providers = { + first: provider(capture, undefined, shared), + own: provider(capture), + second: provider(capture, undefined, shared), + }; + const { first, own, second } = providers; + + first.mount(); + own.mount(); + second.mount(); + order.forEach((name) => providers[name].unmount()); + throwUncaught(first, own, second); + + // The second Provider was created last, so its instance reports. + expect([shared.reports.length, own.rollbar.reports.length]).toEqual([ + 1, 0, + ]); + }, + ); + + it("doesn't let StrictMode's extra construction stand for a shared instance", () => { + const capture = load(); + const shared = instance(); + const first = provider(capture, undefined, shared); + const own = provider(capture); + const second = provider(capture, undefined, shared); + // StrictMode constructs a third Provider for the shared instance twice; + // this one never mounts. + provider(capture, undefined, shared); + + first.mount(); + own.mount(); + second.mount(); + second.unmount(); + first.unmount(); + own.unmount(); + throwUncaught(first, own, second); + + expect([shared.reports.length, own.rollbar.reports.length]).toEqual([1, 0]); + }); + + it('reports once when an instance turns capture back on after its Providers unmounted', () => { + const capture = load(); + const first = provider(capture); + const second = provider(capture); + + first.mount(); + second.mount(); + // The app keeps the second instance and switches capture off, then on. + second.rollbar.configure({ captureUncaught: false }); + second.unmount(); + first.unmount(); + second.rollbar.configure({ captureUncaught: true }); + throwUncaught(first, second); + + expect(reported(first, second)).toEqual([1, 0]); + }); + it('reports to every project', () => { const capture = load(); const a = provider(capture, { accessToken: 'A' });