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) => (
+
+
+
+
+
+
+
+
+ );
+
+ const { rerender } = render(ui('/', false));
+ await afterMicrotasks();
+ rerender(ui('/', true));
+ rerender(ui('/about', false));
+ await afterMicrotasks();
+ rerender(ui('/about', true));
+ expect(reported).toEqual(['app#footer', 'app#footer']);
+ });
+
+ it('keeps the context of a useRollbarContext whose component rendered with the error', 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']);
+ await afterMicrotasks();
+ expect(contextOf(rollbar)).toBe('home');
+ });
+
+ it('reports with the new page useRollbarContext when it throws on its first render', async () => {
+ const { rollbar, reported } = makeReporting();
+ const HomePage = () => {
+ useRollbarContext('home#index');
+ return null;
+ };
+ const AboutPage = () => {
+ useRollbarContext('about#index');
+ throw new Error('render error');
+ };
+ const ui = (page: string) => (
+
+
+
+ {page === 'home' ? : }
+
+
+
+ );
+
+ const { rerender } = render(ui('home'));
+ await afterMicrotasks();
+ rerender(ui('about'));
+ expect(reported).toEqual(['about#index']);
+ await afterMicrotasks();
+ expect(contextOf(rollbar)).toBe('about');
+ });
+
+ // React removes the hook's component before the ErrorBoundary reports,
+ // and it didn't render with the error.
+ it('reports with this context when a child throws on its own update', async () => {
+ const { rollbar, reported } = makeReporting();
+ let throwOnUpdate = () => {};
+ const Child = () => {
+ const [throws, setThrows] = useState(false);
+ throwOnUpdate = () => setThrows(true);
+ return ;
+ };
+ const Page = () => {
+ useRollbarContext('home#index');
+ return ;
+ };
+ render(
+
+
+
+
+
+
+ ,
+ );
+ await afterMicrotasks();
+ expect(contextOf(rollbar)).toBe('home#index');
+ act(() => throwOnUpdate());
+ expect(reported).toEqual(['home']);
+ });
+
+ // They render before it, but in a sibling's scope.
+ it('ignores a useRollbarContext inside a sibling ErrorBoundary or 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('nav#menu');
+ rerender(ui(true));
+ expect(reported).toEqual(['dashboard']);
+ expect(contextOf(rollbar)).toBe('nav#menu');
+ });
+
+ // With onRender, it's still rendering when the ErrorBoundary reports.
+ it('ignores a later sibling onRender RollbarContext in the same commit', async () => {
+ const { rollbar, reported } = makeReporting();
+ render(
+
+
+
+
+
+
+
+ ,
+ );
+ expect(reported).toEqual(['home']);
+ await afterMicrotasks();
+ expect(contextOf(rollbar)).toBe('sidebar');
+ });
+
+ it('keeps the innermost of several useRollbarContext hooks between it and the ErrorBoundary', async () => {
+ const { rollbar, reported } = makeReporting();
+ const Inner = ({ throws }: { throws: boolean }) => {
+ useRollbarContext('home#show');
+ return (
+
+
+
+ );
+ };
+ const Outer = ({ 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#show']);
+ });
+
+ // React calls componentDidCatch before the RollbarContext mounts.
+ it('puts the previous context back after reporting', async () => {
+ const { rollbar, reported } = makeReporting();
+ let afterReport: unknown;
+ const Probe = () => {
+ useLayoutEffect(() => {
+ afterReport = contextOf(rollbar);
+ }, []);
+ return null;
+ };
+
+ render(
+
+
+
+
+
+
+
+ ,
+ );
+ expect(reported).toEqual(['home']);
+ // With onRender, the context set during render is still there.
+ expect(afterReport).toBe(onRender ? 'home' : 'root');
+ await afterMicrotasks();
+ expect(contextOf(rollbar)).toBe('home');
+ });
+ },
+ );
+
+ it('leaves nothing behind when the ErrorBoundary replaces an updated onRender context', async () => {
+ const { rollbar, reported } = makeReporting();
+ const ui = (page: string) => (
+
+
+
+
+
+
+
+ );
+
+ const { rerender } = render(ui('home'));
+ rerender(ui('about'));
+ expect(reported).toEqual(['about']);
+ await afterMicrotasks();
+ expect(contextOf(rollbar)).toBe('root');
+ });
+ });
+
+ describe('with onRender', () => {
+ it('sets the context before children render', () => {
+ const rollbar = makeRollbar({ payload: { context: 'root' } });
+ let seen: unknown;
+ const Child = () => {
+ seen = contextOf(rollbar);
+ return null;
+ };
+
+ const { unmount } = render(
+
+
+
+
+ ,
+ );
+ expect(seen).toBe('home');
+
+ unmount();
+ expect(contextOf(rollbar)).toBe('root');
+ });
+
+ it('puts the context back after rendering on the server', async () => {
+ const rollbar = makeRollbar({ payload: { context: 'root' } });
+ let seen: unknown;
+ const Child = () => {
+ seen = contextOf(rollbar);
+ return null;
+ };
+
+ renderToString(
+
+
+
+
+ ,
+ );
+ expect(seen).toBe('home');
+
+ // Nothing mounts on the server.
+ await afterMicrotasks();
+ expect(contextOf(rollbar)).toBe('root');
+ });
+
+ it('does not call setState during render', () => {
+ const rollbar = makeRollbar();
+ render(
+
+
+
+
+ ,
+ );
+ expect(consoleError).not.toHaveBeenCalled();
+ });
+
+ it('follows changes to the context prop', () => {
+ const rollbar = makeRollbar({ payload: { context: 'root' } });
+ const ui = (context: string) => (
+
+
+
+
+
+ );
+
+ const { rerender, unmount } = render(ui('home'));
+ rerender(ui('about'));
+ expect(contextOf(rollbar)).toBe('about');
+
+ unmount();
+ expect(contextOf(rollbar)).toBe('root');
+ });
+ });
+});
+
+describe('nested contexts', () => {
+ type NestedProps = { outer: string; inner: string; showInner: boolean };
+
+ const HookInner = ({ context }: { context: string }) => {
+ useRollbarContext(context);
+ return null;
+ };
+ const HookOuter = ({ outer, inner, showInner }: NestedProps) => {
+ useRollbarContext(outer);
+ return showInner ? : null;
+ };
+
+ const modes: [string, React.ComponentType][] = [
+ [
+ 'RollbarContext',
+ ({ outer, inner, showInner }) => (
+
+ {showInner && (
+
+
+
+ )}
+
+ ),
+ ],
+ [
+ 'RollbarContext with onRender',
+ ({ outer, inner, showInner }) => (
+
+ {showInner && (
+
+
+
+ )}
+
+ ),
+ ],
+ ['useRollbarContext', HookOuter],
+ [
+ 'useRollbarContext inside RollbarContext',
+ ({ outer, inner, showInner }) => (
+
+ {showInner && }
+
+ ),
+ ],
+ ];
+
+ it.each(modes)('%s: the innermost context wins', (_, Nested) => {
+ const rollbar = makeRollbar({ payload: { context: 'root' } });
+ const ui = (props: NestedProps) => (
+
+
+
+ );
+
+ // React mounts children before their parents.
+ const { rerender, unmount } = render(
+ ui({ outer: 'outer', inner: 'inner', showInner: true }),
+ );
+ expect(contextOf(rollbar)).toBe('inner');
+
+ // The outer context changes while the inner one is still mounted.
+ rerender(ui({ outer: 'outer2', inner: 'inner', showInner: true }));
+ expect(contextOf(rollbar)).toBe('inner');
+
+ rerender(ui({ outer: 'outer2', inner: 'inner2', showInner: true }));
+ expect(contextOf(rollbar)).toBe('inner2');
+
+ // The outer context's current value, not the one it had when the inner
+ // one mounted.
+ rerender(ui({ outer: 'outer2', inner: 'inner2', showInner: false }));
+ expect(contextOf(rollbar)).toBe('outer2');
+
+ rerender(ui({ outer: 'outer2', inner: 'inner3', showInner: true }));
+ expect(contextOf(rollbar)).toBe('inner3');
+
+ unmount();
+ expect(contextOf(rollbar)).toBe('root');
+ });
+});
+
+describe('useRollbarContext', () => {
+ // #102
+ it('works when the config has no payload', () => {
+ const rollbar = makeRollbar();
+ const Page = () => {
+ useRollbarContext('home');
+ return null;
+ };
+
+ 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('');
+ });
+});
diff --git a/src/tests/jest-setup.ts b/src/tests/jest-setup.ts
index 7de749a..5c875b5 100644
--- a/src/tests/jest-setup.ts
+++ b/src/tests/jest-setup.ts
@@ -1,9 +1,12 @@
import '@testing-library/jest-dom';
import 'regenerator-runtime';
+import { format } from 'util';
-// Let propType errors cause test failure.
+// Let propType errors cause test failure. React passes the message as a
+// format string ('Failed %s type: %s%s', 'prop', ...), so format it first.
const originalConsoleError = console.error;
-console.error = (message: string) => {
+console.error = (...args: unknown[]) => {
+ const message = format(...args);
if (/(Failed prop type)/.test(message)) throw new Error(message);
- originalConsoleError(message);
+ originalConsoleError(...args);
};