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]); + }); +});