diff --git a/README.md b/README.md index 8af8414..ed1add6 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,8 @@ next month. - [Pass a Fallback UI](#pass-a-fallback-ui) - [`RollbarContext` Component](#rollbarcontext-component) - [Basic Usage](#basic-usage) + - [Using with `ErrorBoundary`](#using-with-errorboundary) + - [Setting the context before children render](#setting-the-context-before-children-render) - [Using with React Router](#using-with-react-router) - [Functions](#functions) - [`historyContext` to create `history.listener`](#historycontext-to-create-historylistener) @@ -357,8 +359,8 @@ export function App(props) { Use the `RollbarContext` component to declaratively set the `context` value used by [Rollbar.js] when it's sending any messages to [Rollbar]. -This works for your `ErrorBoundary` from above or any other log or message sent to [Rollbar] while the `RollbarContext` -is mounted on the tree. +The context applies to any log or message sent to [Rollbar] while the `RollbarContext` is mounted on the tree, and to +errors that your `ErrorBoundary` from above catches inside it; see [Using with `ErrorBoundary`](#using-with-errorboundary). Like `ErrorBoundary` above, `RollbarContext` relies on a [`Provider`] for an instance of a [Rollbar.js] client. @@ -376,6 +378,61 @@ function HomePage() { } ``` +A change to the `context` prop is applied, and the previous context is restored on unmount. + +`RollbarContext` components can be nested, including with the `useRollbarContext` hook. The innermost one that's +mounted sets the context. When it unmounts, the next one out applies again, with its current `context`. + +#### Using with `ErrorBoundary` + +Put the `RollbarContext` outside the `ErrorBoundary`: + +```javascript + + + + + +``` + +The `ErrorBoundary` reports an error with the context of the nearest `RollbarContext` around it. That includes an +error thrown while they're first rendering, before the `RollbarContext` has mounted, and an error thrown after a +change to the `context` prop, before it has been applied. A `useRollbarContext` hook between the two still takes +precedence, as the innermost one, and so does one inside the `ErrorBoundary` in a component that rendered with the +error; see [the hook](#userollbarcontext-hook). A `RollbarContext` or hook elsewhere in the tree doesn't, even if it +was mounted later and set the client's context, like a sibling of the `RollbarContext`. A hook doesn't provide a scope +of its own, so one in another component inside the same `RollbarContext`, like a sidebar or a footer, counts as between +them, as it does for the client's context. One inside another `ErrorBoundary` or `RollbarContext` there doesn't. + +Outside, because when the `ErrorBoundary` catches an error, React removes everything inside it before the error is +reported. A `RollbarContext` inside it has already been removed by then. + +#### Setting the context before children render + +By default, `RollbarContext` sets the context when it mounts, which React does after the children have rendered and +mounted. Apart from errors that an `ErrorBoundary` inside it catches, anything sent to [Rollbar] before then, for +example from the children's own effects when they mount, gets the previous context. The `onRender` prop sets the +context during render instead, before the children render, on the first render and whenever the `context` prop +changes: + +```javascript + + + +``` + +That means the context is set before React has committed anything, which is why `onRender` isn't the default: + +- Until React commits, anything else sent to [Rollbar] also gets this context. +- React can throw the render away, for example when an `ErrorBoundary` around the `RollbarContext` catches an error, + and nothing mounts when rendering on the server. So in a microtask after rendering, `RollbarContext` puts back the + context of whatever is mounted. React usually commits before then, but not always: a transition can yield partway + through rendering, and React 19 can hold a commit back until a `` in it has + loaded. The children then mount with the previous context, as without `onRender`. +- On the server, give the `Provider` a `config` rather than a shared `instance`. If the instance's config doesn't set + `payload.context`, `RollbarContext` can only put back an empty context, and on the server that replaces the context + rollbar.js takes from the request's route for later errors. + #### Using with React Router It's useful to set the `context` in [Rollbar] associated with areas of your application. On the server it's usually @@ -554,6 +611,15 @@ function ContactDetails({ contactId }) { As an alternative to the [`RollbarContext`] component, you can use the `useRollbarContext` hook in your [Functional Component] to set the `context` in the [Rollbar.js] client provided by the [`Provider`] above in the React Tree. +The hook sets the context in an effect, so like `RollbarContext` without `onRender`, it doesn't apply to anything sent +while the component and its children are first rendering and mounting. An `ErrorBoundary` around or inside the +component does report with it, including then, if the component rendered with the error: it threw, or a child that +rendered with it did. React removes the component before an `ErrorBoundary` around it reports, and the hook's context +with it, so an error the component didn't render with, like one a child throws on its own state update or in an +effect, gets the context around the component. That's also why, in the +[`ErrorBoundary` pattern](#using-with-errorboundary), a page that used the hook doesn't set the context of an error the +next page throws. + Here's an example of using it in several components: ```javascript diff --git a/src/context-stack.js b/src/context-stack.js new file mode 100644 index 0000000..97a7f3f --- /dev/null +++ b/src/context-stack.js @@ -0,0 +1,240 @@ +// The RollbarContext components and useRollbarContext hooks that are mounted +// and setting a context, per Rollbar client. The innermost one decides the +// client's context. When the last one goes away, the context from before the +// first one is restored. +const stacks = new WeakMap(); + +let lastOrder = 0; + +// Parents render before their children, so an order number taken during the +// first render ranks nested contexts from outermost to innermost, even though +// React mounts (and runs effects for) children first. A context that mounts +// later under an existing one also ranks after it. +export function nextContextOrder() { + lastOrder += 1; + return lastOrder; +} + +function getStack(rollbar) { + let stack = stacks.get(rollbar); + if (!stack) { + // rollbar.js has no default payload, so options.payload is undefined + // unless the config sets it. + stack = { + base: rollbar.options.payload?.context, + contexts: new Map(), + // From setRenderContext: set during render, until React has committed. + rendering: new Map(), + // The scope path (see ScopeContext in rollbar-context.js) of the + // component each order belongs to, for reportWithContext. + paths: new Map(), + }; + stacks.set(rollbar, stack); + } + return stack; +} + +function innermostOrder({ contexts, rendering }) { + return Math.max(...contexts.keys(), ...rendering.keys()); +} + +// A component that's rendering with a new context has the old one in +// `contexts` until it updates. +function contextAt({ contexts, rendering }, order) { + return rendering.has(order) + ? rendering.get(order).context + : contexts.get(order); +} + +function forgetPath(stack, order) { + if (!stack.contexts.has(order) && !stack.rendering.has(order)) { + stack.paths.delete(order); + } +} + +function applyStack(rollbar, stack) { + const { contexts, rendering } = stack; + if (contexts.size || rendering.size) { + const context = contextAt(stack, innermostOrder(stack)); + rollbar.configure({ payload: { context } }); + return; + } + stacks.delete(rollbar); + // configure() ignores undefined values and there's no way to remove the key, + // so restoring an unset context needs ''. In the browser that's sent the same + // as an unset context. On the server it isn't: rollbar.js takes the context + // from the request's route, and payload.context, even '', replaces it. Only + // onRender restores on the server, since nothing mounts there; the README + // covers this. + rollbar.configure({ payload: { context: stack.base ?? '' } }); +} + +// Adds the context for `order`, or applies its new value if it's already +// there. It replaces any context setRenderContext set for `order`: the +// component has mounted or updated, so that render was committed. `path` is +// the component's scope path. +export function setContext(rollbar, order, context, path) { + const stack = getStack(rollbar); + stack.rendering.delete(order); + stack.contexts.set(order, context); + stack.paths.set(order, path); + applyStack(rollbar, stack); +} + +export function removeContext(rollbar, order) { + const stack = stacks.get(rollbar); + if (stack?.contexts.delete(order)) { + forgetPath(stack, order); + applyStack(rollbar, stack); + } +} + +// For RollbarContext's onRender: sets `context` for `order` while the +// component renders, before it has mounted or updated and called setContext. +// It's kept in the stack so that anything else applied before React finishes, +// like another context unmounting or updating in the same commit, doesn't +// replace it before the children have mounted. +// +// React can throw the render away without committing it, for example when an +// ErrorBoundary around the component catches an error from its children, and +// nothing mounts on the server. So if the component hasn't mounted or updated +// by then, a microtask removes it again, leaving whatever did. When React +// commits in the same task that it finished rendering in, that's after the +// commit. React doesn't always: a transition can yield partway through +// rendering, and React 19 can hold a commit back until a stylesheet in it has +// loaded. Then the microtask runs first, and the children mount with the +// previous context, as without onRender. ErrorBoundary doesn't rely on this; +// see reportWithContext. +export function setRenderContext(rollbar, order, context, path) { + const stack = getStack(rollbar); + const entry = { context }; + stack.rendering.set(order, entry); + stack.paths.set(order, path); + applyStack(rollbar, stack); + Promise.resolve().then(() => { + // Unless a later render replaced it; its own microtask removes that. + if (stack.rendering.get(order) === entry) { + stack.rendering.delete(order); + forgetPath(stack, order); + applyStack(rollbar, stack); + } + }); +} + +// From useRollbarContext: the context and scope path each hook rendered with, +// per Rollbar client, from its render until it commits. When React removes a +// component, it removes the hook's entry from the stack before an +// ErrorBoundary reports, so this is how the ErrorBoundary knows the context of +// a hook that rendered with the error, like one in the component that threw. +// It never changes the client's context. If React throws the render away, a +// microtask removes it; the ErrorBoundary takes a copy while it renders after +// catching the error, so when React commits doesn't matter. +const hookRenders = new WeakMap(); + +export function setHookRender(rollbar, order, context, path) { + let rendered = hookRenders.get(rollbar); + if (!rendered) { + rendered = new Map(); + hookRenders.set(rollbar, rendered); + Promise.resolve().then(() => { + if (hookRenders.get(rollbar) === rendered) { + hookRenders.delete(rollbar); + } + }); + } + rendered.set(order, { context, path }); +} + +export function clearHookRender(rollbar, order) { + hookRenders.get(rollbar)?.delete(order); +} + +// For ErrorBoundary, while it renders after catching an error. +export function getHookRenders(rollbar) { + const rendered = hookRenders.get(rollbar); + return rendered && new Map(rendered); +} + +// For ErrorBoundary: calls `report` with the context of the nearest +// RollbarContext around the ErrorBoundary, `reportContext`, which React +// resolved while rendering it. That RollbarContext sets its context when it +// mounts or updates, which React does after the ErrorBoundary inside has +// reported, so on its first render or a change to its `context` prop the +// client still has the previous context. Under onRender, React may also have +// committed after the microtask in setRenderContext. +// +// An entry inside that RollbarContext takes precedence, the innermost one, if +// it's one of: +// - inside the ErrorBoundary. React has removed what was mounted there before +// the ErrorBoundary reports, so that's an onRender context whose render +// React threw away, around the child that threw, or a hook in +// `renderedHooks`, the copy of setHookRender's entries the ErrorBoundary +// took when it rendered. A hook in a component that didn't render with the +// error, like the previous page, has been removed. +// - a useRollbarContext whose scopes are all around the ErrorBoundary: one +// between them. A hook doesn't provide a scope, so one in a sibling of the +// ErrorBoundary counts too, before or after it, as it does for the client's +// context, unless it's inside a sibling ErrorBoundary or RollbarContext. +// Other entries can rank after the RollbarContext without being around the +// ErrorBoundary, like a sibling RollbarContext or hook created later, so the +// scope paths decide. A RollbarContext between them would be the nearest one. +// A hook's entry in `renderedHooks` replaces its entry in the stack: it's the +// context it has rendered with, which it sets once it commits. +// +// Without a RollbarContext, the same entries take precedence over the +// client's context, and if there aren't any, the client isn't touched, as +// before. +// +// rollbar.js has no per-item context, so the context is applied around the +// report: rollbar.js takes the options an item is sent with when it's logged, +// and configure() replaces them rather than changing them. +export function reportWithContext( + rollbar, + reportContext, + boundaryPath, + renderedHooks, + report, +) { + const scope = reportContext?.order; + const boundary = boundaryPath[boundaryPath.length - 1]; + let context = reportContext?.context; + // Orders start at 1. + let innermost = scope ?? 0; + const entries = new Map(); + const stack = stacks.get(rollbar); + stack?.paths.forEach((path, order) => { + entries.set(order, { path, context: contextAt(stack, order) }); + }); + renderedHooks?.forEach((entry, order) => { + entries.set(order, entry); + }); + entries.forEach(({ path, context: entryContext }, order) => { + if (order <= innermost || (scope && !path.includes(scope))) { + return; + } + // A RollbarContext's path ends with its own order, a hook's doesn't. + const isHook = path[path.length - 1] !== order; + // Every scope around the hook is around the ErrorBoundary too. + const aroundBoundary = path.every((o, i) => o === boundaryPath[i]); + if (path.includes(boundary) || (isHook && aroundBoundary)) { + innermost = order; + context = entryContext; + } + }); + if (!innermost) { + report(); + return; + } + const previous = rollbar.options.payload?.context; + if (context === previous) { + report(); + return; + } + rollbar.configure({ payload: { context } }); + try { + report(); + } finally { + // As in applyStack, configure() ignores undefined. + rollbar.configure({ payload: { context: previous ?? '' } }); + } +} diff --git a/src/error-boundary.js b/src/error-boundary.js index afbbc7a..3845a98 100644 --- a/src/error-boundary.js +++ b/src/error-boundary.js @@ -5,6 +5,12 @@ import PropTypes from 'prop-types'; import invariant from 'tiny-invariant'; import { LEVEL_ERROR } from './constants'; import { Context, getRollbarFromContext } from './provider'; +import { ReportContext, ScopeContext } from './rollbar-context'; +import { + getHookRenders, + nextContextOrder, + reportWithContext, +} from './context-stack'; import * as utils from './utils'; const INITIAL_ERROR_STATE = { hasError: false, error: null }; @@ -34,6 +40,14 @@ export class ErrorBoundary extends Component { this.state = { ...INITIAL_ERROR_STATE }; } + // Ranks this among the contexts around and inside it, and what ScopeContext + // provides; see reportWithContext. + order = nextContextOrder(); + path = undefined; + // The useRollbarContext hooks that rendered with the error; see + // setHookRender. + renderedHooks = undefined; + static getDerivedStateFromError(error) { return { hasError: true, error }; } @@ -44,12 +58,20 @@ export class ErrorBoundary extends Component { const data = { ...info, ...custom }; const level = utils.value(targetLevel, LEVEL_ERROR, error, info); const rollbar = getRollbarFromContext(this.context); - if (!errorMessage) { - rollbar[level](error, data, callback); - } else { - let logMessage = utils.value(errorMessage, '', error, info); - rollbar[level](logMessage, error, data, callback); - } + reportWithContext( + rollbar, + this.reportContext, + this.path, + this.renderedHooks, + () => { + if (!errorMessage) { + rollbar[level](error, data, callback); + } else { + let logMessage = utils.value(errorMessage, '', error, info); + rollbar[level](logMessage, error, data, callback); + } + }, + ); } resetError = () => { @@ -60,14 +82,40 @@ export class ErrorBoundary extends Component { const { hasError, error } = this.state; const { fallbackUI: FallbackUI, children } = this.props; - if (!hasError) { - return children; - } + // React renders this right after the child that threw, before it commits, + // which may not be in the same task. + this.renderedHooks = hasError + ? getHookRenders(getRollbarFromContext(this.context)) + : undefined; - if (!FallbackUI) { - return null; + let content = null; + if (!hasError) { + content = children; + } else if (FallbackUI) { + content = ; } - return ; + // contextType is taken by the Provider's context. The value is read again + // whenever this renders, including the render after a child throws, which + // is the one React commits before calling componentDidCatch. + return ( + + {(parentPath) => { + this.path ??= [...parentPath, this.order]; + return ( + + {(reportContext) => { + this.reportContext = reportContext; + return ( + + {content} + + ); + }} + + ); + }} + + ); } } diff --git a/src/hooks/use-rollbar-context.js b/src/hooks/use-rollbar-context.js index 46b6a2b..73d5b99 100644 --- a/src/hooks/use-rollbar-context.js +++ b/src/hooks/use-rollbar-context.js @@ -1,8 +1,31 @@ 'use client'; import invariant from 'tiny-invariant'; -import { useEffect, useLayoutEffect } from 'react'; +import { + useContext, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react'; import { useRollbar } from './use-rollbar'; +import { + clearHookRender, + nextContextOrder, + removeContext, + setContext, + setHookRender, +} from '../context-stack'; +import { ScopeContext } from '../rollbar-context'; + +// Before React 19, useLayoutEffect warns when rendering on the server, where +// no effects run anyway. +const useClientLayoutEffect = + typeof window === 'undefined' ? useEffect : useLayoutEffect; + +function useEffectOfType(isLayout, effect, deps) { + (isLayout ? useClientLayoutEffect : useEffect)(effect, deps); +} // Simple version does its job // export function useRollbarContext(context) { @@ -13,11 +36,39 @@ import { useRollbar } from './use-rollbar'; export function useRollbarContext(ctx = '', isLayout = false) { invariant(typeof ctx === 'string', '`ctx` must be a string'); const rollbar = useRollbar(); - (isLayout ? useLayoutEffect : useEffect)(() => { - const origCtx = rollbar.options.payload.context; - rollbar.configure({ payload: { context: ctx } }); - return () => { - rollbar.configure({ payload: { context: origCtx } }); - }; - }, [ctx]); + // Where this component sits among nested contexts; see context-stack.js. + const [order] = useState(nextContextOrder); + // The RollbarContexts and ErrorBoundaries around this hook; see + // reportWithContext in context-stack.js. + const path = useContext(ScopeContext); + // The context this hook has set, once it has. + const appliedRef = useRef(); + // For an ErrorBoundary that catches an error thrown while this renders, by + // this component or one inside it: React removes the component, and the + // entry below, before the ErrorBoundary reports. It doesn't change the + // client's context; see setHookRender in context-stack.js. + setHookRender(rollbar, order, ctx, path); + useClientLayoutEffect(() => clearHookRender(rollbar, order)); + // Removed in a layout effect cleanup, whatever `isLayout` is. When React + // removes a component, it runs its layout cleanups during the commit, but + // its passive ones only after it, so after an ErrorBoundary elsewhere in the + // tree has reported an error thrown in that commit, like the next page's. + // The ErrorBoundary gets the context of a hook that rendered with the error + // from setHookRender above. + useClientLayoutEffect(() => { + // React 18 and later also run layout cleanups when Suspense hides a tree + // that was showing, and run only layout effects again when it shows it. + if (appliedRef.current !== undefined) { + setContext(rollbar, order, appliedRef.current, path); + } + return () => removeContext(rollbar, order); + }, []); + useEffectOfType( + isLayout, + () => { + appliedRef.current = ctx; + setContext(rollbar, order, ctx, path); + }, + [ctx], + ); } diff --git a/src/rollbar-context.js b/src/rollbar-context.js index df1394c..9886931 100644 --- a/src/rollbar-context.js +++ b/src/rollbar-context.js @@ -1,8 +1,26 @@ 'use client'; -import { Component } from 'react'; +import React, { Component, createContext } from 'react'; import PropTypes from 'prop-types'; import { Context, getRollbarFromContext } from './provider'; +import { + nextContextOrder, + removeContext, + setContext, + setRenderContext, +} from './context-stack'; + +// The nearest RollbarContext's order and context, for an ErrorBoundary inside +// it to report with; see reportWithContext in context-stack.js. +export const ReportContext = createContext(undefined); +ReportContext.displayName = 'RollbarReportContext'; + +// The scope path: the orders of the RollbarContexts and ErrorBoundaries +// around a component, outermost first, for reportWithContext. Each one +// provides the path to itself, which never changes while it's mounted, so the +// components that use useRollbarContext don't re-render when it changes. +export const ScopeContext = createContext([]); +ScopeContext.displayName = 'RollbarScopeContext'; export class RollbarContext extends Component { static propTypes = { @@ -17,48 +35,72 @@ export class RollbarContext extends Component { static contextType = Context; - firstRender = true; + // Where this component sits among nested contexts; see context-stack.js. + order = nextContextOrder(); + // The context this component has set since mounting, if it's mounted. + mountedContext = undefined; + // What ReportContext provides, kept until the context prop changes. + reportContext = undefined; + // What ScopeContext provides, set on the first render. + path = undefined; - constructor(props) { - super(props); - this.state = { previousContext: null }; - } - - changeContext = (storePrevious = true) => { - const rollbar = getRollbarFromContext(this.context); - const { context } = this.props; - if (storePrevious) { - this.setState({ previousContext: rollbar.options.payload.context }); - } - rollbar.configure({ payload: { context } }); + changeContext = () => { + this.mountedContext = this.props.context; + setContext( + getRollbarFromContext(this.context), + this.order, + this.props.context, + this.path, + ); }; componentDidMount() { - const { onRender } = this.props; - if (!onRender) { - this.changeContext(true); - } + this.changeContext(); } - componentDidUpdate() { - const { onRender } = this.props; - if (!onRender) { - this.changeContext(false); + componentDidUpdate(prevProps) { + if (this.props.context !== prevProps.context) { + this.changeContext(); } } componentWillUnmount() { - const rollbar = getRollbarFromContext(this.context); - const { previousContext } = this.state; - rollbar.configure({ payload: { context: previousContext } }); + removeContext(getRollbarFromContext(this.context), this.order); + this.mountedContext = undefined; } render() { - const { onRender } = this.props; - if (onRender && this.firstRender) { - this.changeContext(true); + // contextType is taken by the Provider's context. + return ( + + {(parentPath) => this.renderInScope(parentPath)} + + ); + } + + renderInScope(parentPath) { + const { onRender, context } = this.props; + this.path ??= [...parentPath, this.order]; + if (onRender && context !== this.mountedContext) { + // Before the children render, on the first render and when the context + // prop changes, so that errors they throw are reported with this + // context. It's applied for good on mount or update. + setRenderContext( + getRollbarFromContext(this.context), + this.order, + context, + this.path, + ); + } + if (this.reportContext?.context !== context) { + this.reportContext = { order: this.order, context }; } - this.firstRender = false; - return this.props.children; + return ( + + + {this.props.children} + + + ); } } diff --git a/src/tests/components/rollbar-context.server.test.tsx b/src/tests/components/rollbar-context.server.test.tsx new file mode 100644 index 0000000..68b84f6 --- /dev/null +++ b/src/tests/components/rollbar-context.server.test.tsx @@ -0,0 +1,40 @@ +/** + * @jest-environment node + */ +import React from 'react'; +import { renderToString } from 'react-dom/server'; +import Rollbar from 'rollbar'; +import { Provider, useRollbarContext } from '../rollbar-react'; + +describe('useRollbarContext on the server', () => { + let consoleError: jest.SpyInstance; + + beforeEach(() => { + consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleError.mockRestore(); + }); + + it.each([false, true])( + 'renders without the useLayoutEffect warning (isLayout: %s)', + (isLayout) => { + const rollbar = new Rollbar({ + accessToken: 'POST_SERVER_ITEM_TOKEN', + enabled: false, + }); + const Page = () => { + useRollbarContext('home', isLayout); + return null; + }; + + renderToString( + + + , + ); + expect(consoleError).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/src/tests/components/rollbar-context.test.tsx b/src/tests/components/rollbar-context.test.tsx new file mode 100644 index 0000000..95251ba --- /dev/null +++ b/src/tests/components/rollbar-context.test.tsx @@ -0,0 +1,883 @@ +import React, { useLayoutEffect, useState } from 'react'; +import { format } from 'util'; +import { renderToString } from 'react-dom/server'; +import { act, render } from '@testing-library/react'; +import Rollbar from 'rollbar'; +import { + ErrorBoundary, + Provider, + RollbarContext, + useRollbarContext, +} from '../rollbar-react'; + +const makeRollbar = (config: Rollbar.Configuration = {}) => + new Rollbar({ + accessToken: 'POST_CLIENT_ITEM_TOKEN', + enabled: false, + ...config, + }); + +const contextOf = (rollbar: Rollbar): unknown => + rollbar.options.payload?.context; + +// With onRender, RollbarContext puts back the context of whatever is mounted +// in a microtask after rendering. +const afterMicrotasks = () => + new Promise((resolve) => { + setTimeout(resolve, 0); + }); + +describe('RollbarContext', () => { + let consoleError: jest.SpyInstance; + + beforeEach(() => { + const setupConsoleError = console.error; + consoleError = jest + .spyOn(console, 'error') + .mockImplementation((...args: unknown[]) => { + // Keep jest-setup.ts failing the test on a prop-type error. + if (/Failed prop type/.test(format(...args))) { + setupConsoleError(...args); + } + }); + }); + + afterEach(() => { + consoleError.mockRestore(); + }); + + // #102 + it('works when the config has no payload', () => { + const rollbar = makeRollbar(); + expect(rollbar.options.payload).toBeUndefined(); + + const { unmount } = render( + + +
+ + , + ); + expect(contextOf(rollbar)).toBe('home'); + + // Not undefined: configure() ignores undefined values, which used to + // leave 'home' in place after unmount. + unmount(); + expect(contextOf(rollbar)).toBe(''); + }); + + it('sets the context on mount and restores the previous one on unmount', () => { + const rollbar = makeRollbar({ payload: { context: 'root' } }); + + const { rerender, unmount } = render( + + +
+ + , + ); + expect(contextOf(rollbar)).toBe('home'); + + rerender( + + +
+ + , + ); + expect(contextOf(rollbar)).toBe('about'); + + unmount(); + expect(contextOf(rollbar)).toBe('root'); + }); + + it('does not re-apply an unchanged context when it re-renders', () => { + const rollbar = makeRollbar({ payload: { context: 'root' } }); + const ui = (text: string) => ( + + +
{text}
+
+
+ ); + + const { rerender } = render(ui('a')); + rollbar.configure({ payload: { context: 'elsewhere' } }); + rerender(ui('b')); + expect(contextOf(rollbar)).toBe('elsewhere'); + }); + + it('renders without children', () => { + const rollbar = makeRollbar(); + render( + + + , + ); + expect(contextOf(rollbar)).toBe('home'); + }); + + // #88 + describe('when a child throws while rendering', () => { + const makeReporting = () => { + const rollbar = makeRollbar({ payload: { context: 'root' } }); + const reported: unknown[] = []; + rollbar.error = jest.fn(() => { + reported.push(contextOf(rollbar)); + return { uuid: '' }; + }); + return { rollbar, reported }; + }; + const Throw = ({ when = true }: { when?: boolean }) => { + if (when) { + throw new Error('render error'); + } + return null; + }; + const renderThrowing = (onRender: boolean) => { + const reporting = makeReporting(); + render( + + + + + + + , + ); + return reporting; + }; + + it('reports without touching the client outside a RollbarContext', () => { + const error = jest.fn(); + // Like jest.mock('rollbar'): no options, and no configure(). + const MockRollbar = function (this: { error: jest.Mock }) { + this.error = error; + } as unknown as typeof Rollbar; + + render( + + + + + , + ); + expect(error).toHaveBeenCalledTimes(1); + }); + + // React removes the component, and the hook's entry, before the + // ErrorBoundary reports. + it('reports with the context of a useRollbarContext in the component that throws on an update', () => { + const { rollbar, reported } = makeReporting(); + const Page = ({ throws }: { throws: boolean }) => { + useRollbarContext('home#index'); + if (throws) { + throw new Error('update error'); + } + return null; + }; + const ui = (throws: boolean) => ( + + + + + + ); + + const { rerender } = render(ui(false)); + expect(contextOf(rollbar)).toBe('home#index'); + rerender(ui(true)); + expect(reported).toEqual(['home#index']); + expect(contextOf(rollbar)).toBe('root'); + }); + + it('reports with the context of a useRollbarContext around it on first render', () => { + const { rollbar, reported } = makeReporting(); + const Page = () => { + useRollbarContext('home#index'); + return ( + + + + ); + }; + render( + + + , + ); + expect(reported).toEqual(['home#index']); + expect(contextOf(rollbar)).toBe('home#index'); + }); + + it('reports with the previous context by default', () => { + expect(renderThrowing(false).reported).toEqual(['root']); + }); + + it('reports with this context when onRender is set', async () => { + const { rollbar, reported } = renderThrowing(true); + expect(reported).toEqual(['home']); + + // The ErrorBoundary replaced the RollbarContext before it mounted. + await afterMicrotasks(); + expect(contextOf(rollbar)).toBe('root'); + }); + + it('leaves nothing behind when the ErrorBoundary replaces an onRender context', async () => { + const { rollbar, reported } = makeReporting(); + const ui = ( + outer: string, + page: 'throws' | 'renders' | 'none', + boundaryKey: number, + ) => ( + + + + {page !== 'none' && ( + + + + )} + + + + ); + + const { rerender } = render(ui('app', 'throws', 1)); + expect(reported).toEqual(['home']); + await afterMicrotasks(); + expect(contextOf(rollbar)).toBe('app'); + + rerender(ui('app2', 'throws', 1)); + expect(contextOf(rollbar)).toBe('app2'); + + // A new ErrorBoundary, and this time the page renders. + rerender(ui('app2', 'renders', 2)); + expect(contextOf(rollbar)).toBe('home'); + + rerender(ui('app2', 'none', 2)); + expect(contextOf(rollbar)).toBe('app2'); + }); + + // From here on the RollbarContext is outside the ErrorBoundary, which + // reports with its context with or without onRender. + describe.each([false, true])( + 'outside the ErrorBoundary (onRender: %s)', + (onRender) => { + it('reports with this context, on first render and after', () => { + const { rollbar, reported } = makeReporting(); + const ui = (throws: boolean, boundaryKey: number) => ( + + + + + + + + ); + + const { rerender } = render(ui(true, 1)); + rerender(ui(false, 2)); + rerender(ui(true, 2)); + expect(reported).toEqual(['home', 'home']); + }); + + // React unmounts the old page before the new page's ErrorBoundary reports, + // in the same commit. + it('reports with the new page context when the old page unmounts in the same commit', async () => { + const { rollbar, reported } = makeReporting(); + const Page = ({ + name, + throws, + }: { + name: string; + throws: boolean; + }) => ( + + + + + + ); + const ui = (page: string) => ( + + + + ); + + const { rerender } = render(ui('home')); + await afterMicrotasks(); + expect(contextOf(rollbar)).toBe('home'); + + rerender(ui('about')); + expect(reported).toEqual(['about']); + await afterMicrotasks(); + expect(contextOf(rollbar)).toBe('about'); + }); + + // React runs passive effect cleanups after the commit in which the + // ErrorBoundary reports, so the old page's hook has to leave before then. + it('reports with the new page context when the old page used useRollbarContext', async () => { + const { rollbar, reported } = makeReporting(); + const HomePage = () => { + useRollbarContext('home#index'); + return null; + }; + const ui = (page: string) => ( + + + + {page === 'home' ? : } + + + + ); + + const { rerender } = render(ui('home')); + await afterMicrotasks(); + expect(contextOf(rollbar)).toBe('home#index'); + + rerender(ui('about')); + expect(reported).toEqual(['about']); + await afterMicrotasks(); + expect(contextOf(rollbar)).toBe('about'); + }); + + // An earlier sibling's componentDidUpdate runs before the ErrorBoundary + // reports, in the same commit. + it('reports with this context when a sibling context updates in the same commit', async () => { + const { rollbar, reported } = makeReporting(); + const ui = (showPage: boolean) => ( + + +
+ + {showPage && ( + + + + + + )} + + ); + + const { rerender } = render(ui(false)); + rerender(ui(true)); + expect(reported).toEqual(['home']); + await afterMicrotasks(); + expect(contextOf(rollbar)).toBe('home'); + }); + + it('reports with the new context when the context prop changes', async () => { + const { rollbar, reported } = makeReporting(); + // React reuses the RollbarContext and mounts a new page inside it. + const ui = (page: string) => ( + + + + + + + + ); + + const { rerender } = render(ui('home')); + rerender(ui('about')); + expect(reported).toEqual(['about']); + await afterMicrotasks(); + expect(contextOf(rollbar)).toBe('about'); + }); + + it('keeps the context of a useRollbarContext between it and the ErrorBoundary', async () => { + const { rollbar, reported } = makeReporting(); + const Page = ({ throws }: { throws: boolean }) => { + useRollbarContext('home#index'); + return ( + + + + ); + }; + const ui = (throws: boolean) => ( + + + + + + ); + + const { rerender } = render(ui(false)); + await afterMicrotasks(); + rerender(ui(true)); + expect(reported).toEqual(['home#index']); + expect(contextOf(rollbar)).toBe('home#index'); + }); + + // They rank after it too, but aren't between it and the ErrorBoundary. + it('ignores a later sibling RollbarContext', async () => { + const { rollbar, reported } = makeReporting(); + const ui = (throws: boolean) => ( + + + + + + + + + ); + + const { rerender } = render(ui(false)); + await afterMicrotasks(); + expect(contextOf(rollbar)).toBe('footer'); + rerender(ui(true)); + expect(reported).toEqual(['home']); + expect(contextOf(rollbar)).toBe('footer'); + }); + + // A hook doesn't provide a scope, so where it renders among the + // ErrorBoundary's siblings isn't known, and it counts as between them + // like it does for the client's context. + it('keeps the context of a useRollbarContext after the ErrorBoundary, but not outside the RollbarContext', async () => { + const { rollbar, reported } = makeReporting(); + const Hook = ({ ctx }: { ctx: string }) => { + useRollbarContext(ctx); + return null; + }; + const ui = (throws: boolean) => ( + + + + + + + + + + ); + + const { rerender } = render(ui(false)); + await afterMicrotasks(); + expect(contextOf(rollbar)).toBe('footer'); + rerender(ui(true)); + expect(reported).toEqual(['home#footer']); + expect(contextOf(rollbar)).toBe('footer'); + }); + + // A new ErrorBoundary takes a new order, after the hook's. + it('treats a sibling useRollbarContext the same after the ErrorBoundary remounts', async () => { + const { rollbar, reported } = makeReporting(); + const Footer = () => { + useRollbarContext('app#footer'); + return null; + }; + const ui = (pathname: string, throws: boolean) => ( + + + + + +