Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 68 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.

Expand All @@ -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
<RollbarContext context="home">
<ErrorBoundary>
<HomePage />
</ErrorBoundary>
</RollbarContext>
```

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
<RollbarContext context="home" onRender>
<HomePage />
</RollbarContext>
```

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 `<link rel="stylesheet" precedence>` 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
Expand Down Expand Up @@ -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
Expand Down
240 changes: 240 additions & 0 deletions src/context-stack.js
Original file line number Diff line number Diff line change
@@ -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 ?? '' } });
}
}
Loading
Loading