diff --git a/README.md b/README.md
index 1fd571d..de8d3a2 100644
--- a/README.md
+++ b/README.md
@@ -198,6 +198,15 @@ 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`. 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
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/src/global-capture.js b/src/global-capture.js
new file mode 100644
index 0000000..63540b5
--- /dev/null
+++ b/src/global-capture.js
@@ -0,0 +1,162 @@
+// window.onerror and unhandledrejection are shared by the whole page, but
+// 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 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'],
+ },
+};
+
+// Claims that are mounted or whose instance captures something. Window
+// already keeps the latter instances alive through their handlers.
+let claims = [];
+// Managed instance → its own handlers, by kind.
+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])
+ );
+}
+
+function capturesAny(claim) {
+ return Object.keys(KINDS).some((kind) => captures(claim, kind));
+}
+
+function project(rollbar) {
+ return rollbar.options?.accessToken;
+}
+
+function register(claim) {
+ if (!claims.includes(claim)) {
+ claims.push(claim);
+ }
+}
+
+// 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));
+ }
+ const unmounted = candidates.filter((claim) => claim.hasMounted);
+ return (unmounted.length ? unmounted : candidates).reduce(
+ (a, b) => (!a || b.seq > a.seq ? b : a),
+ null,
+ );
+}
+
+// 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 === ''
+ );
+}
+
+function gate(rollbar, handlers, kind) {
+ const original = rollbar[KINDS[kind].handler];
+ 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(rollbar, kind);
+ const target = chosen ? chosen.rollbar : rollbar;
+ return managed.get(target)[kind].apply(target, args);
+ }
+ }
+ const chosen = reporter(rollbar, kind);
+ if (chosen && chosen.rollbar !== rollbar) {
+ return undefined;
+ }
+ return original.apply(rollbar, args);
+ };
+}
+
+// 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;
+ }
+ if (!managed.has(rollbar)) {
+ const handlers = {};
+ Object.keys(KINDS).forEach((kind) => {
+ if (typeof rollbar[KINDS[kind].handler] === 'function') {
+ gate(rollbar, handlers, kind);
+ }
+ });
+ managed.set(rollbar, handlers);
+ }
+ const claim = { rollbar, seq: created++, mounted: false, hasMounted: false };
+ if (capturesAny(claim)) {
+ register(claim);
+ }
+ return claim;
+}
+
+export function mountGlobalCapture(claim) {
+ if (!claim) {
+ return;
+ }
+ claim.mounted = true;
+ claim.hasMounted = true;
+ register(claim);
+}
+
+export function unmountGlobalCapture(claim) {
+ if (!claim) {
+ return;
+ }
+ claim.mounted = false;
+ // 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) =>
+ !unmounted.includes(other) || (other === newest && capturesAny(other)),
+ );
+}
diff --git a/src/provider.js b/src/provider.js
index 86a7945..59a1b4c 100644
--- a/src/provider.js
+++ b/src/provider.js
@@ -4,7 +4,12 @@ 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 {
+ manageGlobalCapture,
+ mountGlobalCapture,
+ unmountGlobalCapture,
+} from './global-capture';
export const Context = createContext();
Context.displayName = 'Rollbar';
@@ -65,14 +70,21 @@ export class Provider extends Component {
!instance || isRollbarInstance(instance),
'`instance` must be a configured instance of Rollbar',
);
- const options = typeof config === 'function' ? config() : config;
+ const options = value(config);
const rollbar = instance || new ctor(options);
- // TODO: use isUncaught to filter if this is 2nd Provider added
- // unless customer wants that
+ // So that several Providers, or StrictMode's second construction, report
+ // each global error once.
+ this.captureClaim = instance ? null : manageGlobalCapture(rollbar);
this.state = { rollbar, options };
}
- // componentDidUpdate()
+ componentDidMount() {
+ mountGlobalCapture(this.captureClaim);
+ }
+
+ componentWillUnmount() {
+ unmountGlobalCapture(this.captureClaim);
+ }
render() {
const { children, Rollbar: ctor = Rollbar } = this.props;
diff --git a/src/tests/components/global-capture.test.tsx b/src/tests/components/global-capture.test.tsx
new file mode 100644
index 0000000..05f6e10
--- /dev/null
+++ b/src/tests/components/global-capture.test.tsx
@@ -0,0 +1,672 @@
+import React, { StrictMode, ReactNode } from 'react';
+import { render } from '@testing-library/react';
+import Rollbar from 'rollbar';
+import {
+ Provider,
+ useRollbar,
+ useRollbarConfiguration,
+} from '../rollbar-react';
+
+const CAPTURE_OFF = {
+ captureUncaught: false,
+ captureUnhandledRejections: false,
+};
+
+const CAPTURE_ON = {
+ captureUncaught: true,
+ 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, token = accessToken) {
+ const reports: unknown[] = [];
+ const config: Rollbar.Configuration = {
+ accessToken: token,
+ ...(capture && CAPTURE_ON),
+ checkIgnore: (isUncaught, args) => {
+ if (isUncaught) reports.push(args);
+ return true; // never send
+ },
+ };
+ 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() {
+ 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();
+}
+
+// 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 }> {
+ 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()];
+ render(
+ <>
+ {providers.map(({ config }, i) => (
+
+
+
+ ))}
+ >,
+ );
+
+ await throwUncaught();
+ await rejectUnhandled();
+
+ expect(providers.map(({ reports }) => reports.length)).toEqual([2, 0, 0]);
+ });
+
+ 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();
+ return null;
+ };
+
+ render(
+
+
+
+
+ ,
+ );
+
+ await throwUncaught();
+ await rejectUnhandled();
+
+ // 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 () => {
+ 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('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();
+ const { ctor, reports } = countingRollbar();
+ let provided: Rollbar | undefined;
+ const Consumer = () => {
+ provided = useRollbar();
+ return null;
+ };
+ const ThrowOnce = throwOnce();
+
+ await renderQuietly(
+
+
+
+
+
+ ,
+ );
+ 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('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('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("leaves the app's options and configure alone", () => {
+ const owner = makeConfig();
+ const other = makeConfig();
+ const instances: Rollbar[] = [];
+ const Consumer = () => {
+ instances.push(useRollbar());
+ return null;
+ };
+
+ render(
+ <>
+
+
+
+
+
+
+ >,
+ );
+ const configure = jest.spyOn(instances[1], 'configure');
+ instances[1].configure({ captureUncaught: true, payload: {} });
+
+ 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 () => {
+ 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);
+ });
+
+ 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
new file mode 100644
index 0000000..eedb3f2
--- /dev/null
+++ b/src/tests/global-capture.test.js
@@ -0,0 +1,274 @@
+// 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 an instance a Provider creates from `options`, recording
+// which global errors it reported.
+function instance(options) {
+ const rollbar = {
+ options: { accessToken: 'A', captureUncaught: true, ...options },
+ reports: [],
+ configure(changes) {
+ this.options = { ...this.options, ...changes };
+ },
+ handleUncaughtException(message) {
+ if (this.options.captureUncaught) this.reports.push(message);
+ },
+ };
+ return rollbar;
+}
+
+function provider(capture, options, rollbar = instance(options)) {
+ const claim = capture.manageGlobalCapture(rollbar);
+ return {
+ rollbar,
+ mount: () => capture.mountGlobalCapture(claim),
+ unmount: () => capture.unmountGlobalCapture(claim),
+ };
+}
+
+// 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);
+
+ inner.mount();
+ outer.mount();
+ // Unmounts parents first, then remounts children first.
+ outer.unmount();
+ inner.unmount();
+ inner.mount();
+ outer.mount();
+ throwUncaught(outer, inner);
+
+ 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);
+
+ inner.mount();
+ outer.mount();
+ // Unmounts and remounts children first.
+ inner.unmount();
+ outer.unmount();
+ inner.mount();
+ outer.mount();
+ throwUncaught(outer, inner);
+
+ expect(reported(outer, inner)).toEqual([1, 0]);
+ });
+
+ it('gives capture to an outer Provider that opts in, with or without StrictMode', () => {
+ const capture = load();
+ const outer = provider(capture, { captureUncaught: false });
+ const inner = provider(capture);
+
+ // A child of the outer Provider opts in while it renders.
+ outer.rollbar.configure({ captureUncaught: true });
+ inner.mount();
+ outer.mount();
+ throwUncaught(outer, inner);
+
+ expect(reported(outer, inner)).toEqual([1, 0]);
+
+ // React 18 StrictMode, then React 19 StrictMode.
+ inner.unmount();
+ outer.unmount();
+ inner.mount();
+ outer.mount();
+ outer.unmount();
+ inner.unmount();
+ inner.mount();
+ outer.mount();
+ throwUncaught(outer, inner);
+
+ expect(reported(outer, inner)).toEqual([2, 0]);
+ });
+
+ 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();
+ first.unmount();
+ second.unmount();
+ first.mount();
+ second.mount();
+ first.unmount();
+ throwUncaught(first, second);
+
+ // Nobody mounted wants capture, so the unmounted first one keeps it.
+ expect(reported(first, second)).toEqual([1, 0]);
+ });
+
+ it("prefers an unmounted Provider to StrictMode's extra construction", () => {
+ const capture = load();
+ const first = provider(capture);
+ // StrictMode constructs the second Provider twice; one never mounts.
+ const discarded = provider(capture);
+ const second = provider(capture);
+
+ second.rollbar.configure({ captureUncaught: false });
+ first.mount();
+ second.mount();
+ first.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(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' });
+ const otherA = provider(capture, { accessToken: 'A' });
+ const b = provider(capture, { accessToken: 'B' });
+
+ a.mount();
+ otherA.mount();
+ b.mount();
+ throwUncaught(a, otherA, b);
+
+ expect(reported(a, otherA, b)).toEqual([1, 0, 1]);
+ });
+
+ 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 second = provider(capture);
+ const third = provider(capture);
+
+ first.mount();
+ second.mount();
+ third.mount();
+ first.unmount();
+ second.unmount();
+ third.unmount();
+ throwUncaught(first, second, third);
+
+ expect(reported(first, second, third)).toEqual([0, 0, 1]);
+ });
+});