Skip to content

Commit f33fbed

Browse files
chargomeclaude
andcommitted
feat(remix): Capture Remix 3 component render errors
The `remix/ui` runtime sends every render, scheduler, frame and hydration error to the event target `run()` returns, and dispatching an event does not rethrow. So `window.onerror` and `unhandledrejection` never see them, and the default browser integrations report nothing from the component layer. Removing the listener added here makes the new e2e test fail, which is the cheapest proof of that. The `diagnostics_channel` browser shim ships as its own file because the browser transform imports it by URL, so an inlined copy would leave the page with two subscriber registries. Its subscription is dormant until that transform lands. Costs 0.27 KB gzipped in the client bundle, which stays inside the existing budget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent c1959a3 commit f33fbed

9 files changed

Lines changed: 297 additions & 4 deletions

File tree

‎dev-packages/e2e-tests/test-applications/remix-v3/app/actions/controller.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ function HomePage(handle: Handle<Record<string, never>>) {
2121
<a id="to-user" href="/users/12345">
2222
User
2323
</a>
24+
<button type="button" id="component-error">
25+
Component error
26+
</button>
2427
</body>
2528
</html>
2629
);

‎dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/entry.ts‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as Sentry from '@sentry/remix/v3/client';
2-
import { run } from 'remix/ui';
2+
import { createElement, run } from 'remix/ui';
33

44
// Not Node. The asset server substitutes this when it compiles the module, from the `define` map in
55
// `app/assets.ts`.
@@ -17,3 +17,19 @@ export const app = run({
1717
return mod[exportName];
1818
},
1919
});
20+
21+
// The runtime sends a render error to the event target `run()` returns and does not rethrow, so
22+
// `window.onerror` never sees it. This listener is the only way the SDK learns about it.
23+
Sentry.captureRuntimeErrors(app);
24+
25+
function Boom(): () => never {
26+
return () => {
27+
throw new Error('Component render failed');
28+
};
29+
}
30+
31+
document.addEventListener('click', event => {
32+
if ((event.target as HTMLElement | null)?.id === 'component-error') {
33+
void app.frames.top.replace(createElement(Boom, {}));
34+
}
35+
});

‎dev-packages/e2e-tests/test-applications/remix-v3/tests/client.test.ts‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { expect, test } from '@playwright/test';
2-
import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils';
2+
import { getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils';
33

44
const APP_NAME = 'remix-v3';
55

@@ -33,3 +33,16 @@ test('sends a navigation span for a link the runtime intercepts', async ({ page
3333
expect(span.is_segment).toBe(true);
3434
expect(span.name).toBe('Navigation');
3535
});
36+
37+
test('captures a component render error the runtime never rethrows', async ({ page }) => {
38+
await page.goto('/');
39+
40+
const errorPromise = waitForError(APP_NAME, event => {
41+
return !event.type && event.exception?.values?.[0]?.value === 'Component render failed';
42+
});
43+
44+
await page.locator('#component-error').click();
45+
46+
const error = await errorPromise;
47+
expect(error.exception?.values?.[0]?.mechanism).toEqual({ handled: false, type: 'auto.ui.remix_v3' });
48+
});

‎packages/remix/rollup.npm.config.mjs‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,10 @@ const v3NodeEntry = defineConfig({
2727
*/
2828
const v3ClientBundle = defineConfig({
2929
input: 'build/esm/v3/index.client.js',
30-
external: id => id === 'remix' || id.startsWith('@remix-run/'),
30+
// The channel shim has to stay external. Orchestrion's browser transform imports that file by URL
31+
// into every instrumented module, so an inlined copy would leave the page with two shims and two
32+
// separate subscriber registries, and instrumentation would quietly do nothing.
33+
external: id => /diagnosticsChannelShim/.test(id) || id === 'remix' || id.startsWith('@remix-run/'),
3134
treeshake: { moduleSideEffects: false, propertyReadSideEffects: false },
3235
plugins: [nodeResolve({ browser: true, exportConditions: ['browser', 'import', 'default'] })],
3336
// Emitted inside `build/esm/v3/`, not at `build/`: the one import it keeps is the relative path to
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/**
2+
* A browser stand-in for `node:diagnostics_channel`.
3+
*
4+
* Orchestrion's transform injects `import dc from '<dcModule>'` into every instrumented module and
5+
* drives it through Node's `tracingChannel` API. The browser has no `node:diagnostics_channel`, so
6+
* without this shim the transform cannot run on browser code.
7+
*
8+
* This implements exactly what the injected code calls, which is not the `traceSync` helpers but the
9+
* per-event channels under them:
10+
*
11+
* ```js
12+
* // The gate is true when ANY of the five event channels has a subscriber, so subscribing to `end`
13+
* // alone is enough to get the call wrapped.
14+
* if (!(ch.start.hasSubscribers || ch.end.hasSubscribers || ch.asyncStart.hasSubscribers
15+
* || ch.asyncEnd.hasSubscribers || ch.error.hasSubscribers)) return traced();
16+
* return ch.start.runStores(ctx, () => {
17+
* try { ctx.result = traced(); }
18+
* catch (err) { ctx.error = err; ch.error.publish(ctx); throw err; }
19+
* finally { ch.end.publish(ctx); }
20+
* });
21+
* ```
22+
*
23+
* The browser has no `AsyncLocalStorage`, so `runStores` publishes and then calls the callback directly
24+
* instead of entering a store. The injected code does not need a store: it passes state along on the
25+
* `context` object.
26+
*/
27+
28+
type Handler = (context: unknown) => void;
29+
30+
interface Channel {
31+
readonly hasSubscribers: boolean;
32+
publish(context: unknown): void;
33+
runStores<T>(context: unknown, fn: (...args: unknown[]) => T, thisArg?: unknown, ...args: unknown[]): T;
34+
subscribe(handler: Handler): void;
35+
unsubscribe(handler: Handler): void;
36+
}
37+
38+
const EVENTS = ['start', 'end', 'asyncStart', 'asyncEnd', 'error'] as const;
39+
type EventName = (typeof EVENTS)[number];
40+
41+
export type TracingChannelSubscribers = Partial<Record<EventName, Handler>>;
42+
43+
export interface BrowserTracingChannel extends Record<EventName, Channel> {
44+
subscribe(subscribers: TracingChannelSubscribers): void;
45+
unsubscribe(subscribers: TracingChannelSubscribers): void;
46+
}
47+
48+
function createChannel(): Channel {
49+
const handlers = new Set<Handler>();
50+
51+
return {
52+
get hasSubscribers(): boolean {
53+
return handlers.size > 0;
54+
},
55+
publish(context) {
56+
// Snapshot: a subscriber may unsubscribe itself while being notified.
57+
for (const handler of Array.from(handlers)) {
58+
try {
59+
handler(context);
60+
} catch {
61+
// A throwing subscriber must never break the instrumented library.
62+
}
63+
}
64+
},
65+
runStores(context, fn, thisArg, ...args) {
66+
this.publish(context);
67+
return fn.apply(thisArg, args);
68+
},
69+
subscribe(handler) {
70+
handlers.add(handler);
71+
},
72+
unsubscribe(handler) {
73+
handlers.delete(handler);
74+
},
75+
};
76+
}
77+
78+
const registry = new Map<string, BrowserTracingChannel>();
79+
80+
/** Create (or look up) a tracing channel by name. */
81+
export function tracingChannel(name: string): BrowserTracingChannel {
82+
const existing = registry.get(name);
83+
if (existing) {
84+
return existing;
85+
}
86+
87+
const channels = Object.fromEntries(EVENTS.map(event => [event, createChannel()])) as Record<EventName, Channel>;
88+
89+
const channel: BrowserTracingChannel = {
90+
...channels,
91+
subscribe(subscribers) {
92+
for (const event of EVENTS) {
93+
const handler = subscribers[event];
94+
if (handler) {
95+
channels[event].subscribe(handler);
96+
}
97+
}
98+
},
99+
unsubscribe(subscribers) {
100+
for (const event of EVENTS) {
101+
const handler = subscribers[event];
102+
if (handler) {
103+
channels[event].unsubscribe(handler);
104+
}
105+
}
106+
},
107+
};
108+
109+
registry.set(name, channel);
110+
return channel;
111+
}
112+
113+
export default { tracingChannel };
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { captureException } from '@sentry/browser';
2+
import { tracingChannel } from './diagnosticsChannelShim';
3+
4+
/** The `AppRuntime` returned by `run()` from `remix/ui`, an EventTarget emitting `error`. */
5+
export interface AppRuntimeLike {
6+
addEventListener(type: 'error', listener: (event: Event) => void): void;
7+
}
8+
9+
const MECHANISM_TYPE = 'auto.ui.remix_v3';
10+
11+
// The channel orchestrion will inject into `@remix-run/ui`'s `run()` once the asset server transforms
12+
// browser modules. Nothing publishes to it yet, so the subscription below is dormant. The name is
13+
// written out because the channel names live in `@sentry/server-utils`, which must not reach the
14+
// browser.
15+
const UI_RUN_CHANNEL = 'orchestrion:@remix-run/ui:run';
16+
17+
const attached = new WeakSet<object>();
18+
19+
// `subscribe()` takes a fresh object literal and the channel keys handlers by identity, so a second
20+
// call would add a second handler rather than replace the first.
21+
let subscribed = false;
22+
23+
/**
24+
* Report a Remix 3 client runtime's errors to Sentry.
25+
*
26+
* The runtime sends every render, scheduler, frame and hydration error to the event target `run()`
27+
* returns. Dispatching an event does not rethrow, so `window.onerror` and `unhandledrejection` never
28+
* see them, and without this listener the SDK sees nothing from the component layer.
29+
*
30+
* Called by {@link instrumentClientRuntime}, and exported for apps that do not serve their browser
31+
* modules through an instrumented asset server.
32+
*/
33+
export function captureRuntimeErrors(app: AppRuntimeLike): void {
34+
if (attached.has(app)) {
35+
return;
36+
}
37+
attached.add(app);
38+
39+
app.addEventListener('error', event => {
40+
captureException(readComponentError(event), {
41+
mechanism: { handled: false, type: MECHANISM_TYPE },
42+
});
43+
});
44+
}
45+
46+
/**
47+
* Attach to every `run()` call automatically, so the app never has to call `captureRuntimeErrors()`
48+
* itself. A no-op when the browser module was not transformed, because the channel never fires.
49+
*/
50+
export function instrumentClientRuntime(): void {
51+
if (subscribed) {
52+
return;
53+
}
54+
subscribed = true;
55+
56+
tracingChannel(UI_RUN_CHANNEL).subscribe({
57+
end(context) {
58+
const app = (context as { result?: unknown }).result;
59+
if (isAppRuntime(app)) {
60+
captureRuntimeErrors(app);
61+
}
62+
},
63+
});
64+
}
65+
66+
function isAppRuntime(value: unknown): value is AppRuntimeLike {
67+
return typeof (value as AppRuntimeLike | undefined)?.addEventListener === 'function';
68+
}
69+
70+
// The runtime puts the thrown value on `error`, and it may be any value, not just an `Error`.
71+
function readComponentError(event: Event): unknown {
72+
const error = (event as ErrorEvent).error;
73+
return error !== undefined ? error : event;
74+
}

‎packages/remix/src/v3/client/sdk.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { getDefaultIntegrations as getBrowserDefaultIntegrations, init as browse
33
import { applySdkMetadata, type Client, type Integration } from '@sentry/core';
44

55
import { browserTracingIntegration } from './browserTracingIntegration';
6+
import { instrumentClientRuntime } from './errors';
67

78
/**
89
* Default integrations for the Remix 3 client SDK.
@@ -24,5 +25,11 @@ export function init(options: BrowserOptions): Client | undefined {
2425

2526
applySdkMetadata(opts, 'remix', ['remix', 'browser']);
2627

27-
return browserInit(opts);
28+
const client = browserInit(opts);
29+
30+
// Subscribed before the app calls `run()`, so an app served through an instrumented asset server
31+
// reports component errors without writing any Sentry code itself.
32+
instrumentClientRuntime();
33+
34+
return client;
2835
}

‎packages/remix/src/v3/index.client.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,3 +74,4 @@ export type { BrowserOptions } from '@sentry/browser';
7474

7575
export { getDefaultIntegrations, init } from './client/sdk';
7676
export { browserTracingIntegration } from './client/browserTracingIntegration';
77+
export { captureRuntimeErrors, instrumentClientRuntime } from './client/errors';
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { beforeAll, describe, expect, it, vi } from 'vitest';
2+
3+
const captureException = vi.fn();
4+
vi.mock('@sentry/browser', () => ({ captureException: (...args: unknown[]) => captureException(...args) }));
5+
6+
const { captureRuntimeErrors, instrumentClientRuntime } = await import('../../src/v3/client/errors');
7+
const { tracingChannel } = await import('../../src/v3/client/diagnosticsChannelShim');
8+
9+
/** Stands in for the `AppRuntime` that `run()` returns. */
10+
function fakeApp(): EventTarget {
11+
return new EventTarget();
12+
}
13+
14+
// There is no `ErrorEvent` in this environment. The SDK only reads `.error` off the event, which is
15+
// where the runtime puts the thrown value.
16+
function errorEvent(error: unknown): Event {
17+
return Object.assign(new Event('error'), { error });
18+
}
19+
20+
describe('captureRuntimeErrors', () => {
21+
beforeAll(() => {
22+
// Called twice because `init()` may run more than once. Two guards make that safe, the subscribe
23+
// once flag and the `attached` WeakSet; this pins the outcome, not either one on its own.
24+
instrumentClientRuntime();
25+
instrumentClientRuntime();
26+
});
27+
28+
it('reports one error per dispatch, however often instrumentation was set up', () => {
29+
captureException.mockClear();
30+
const app = fakeApp();
31+
tracingChannel('orchestrion:@remix-run/ui:run').end.publish({ result: app });
32+
33+
const error = new Error('render failed');
34+
app.dispatchEvent(errorEvent(error));
35+
36+
expect(captureException).toHaveBeenCalledTimes(1);
37+
expect(captureException).toHaveBeenCalledWith(error, {
38+
mechanism: { handled: false, type: 'auto.ui.remix_v3' },
39+
});
40+
});
41+
42+
it('attaches only once to the same app', () => {
43+
captureException.mockClear();
44+
const app = fakeApp();
45+
captureRuntimeErrors(app);
46+
captureRuntimeErrors(app);
47+
48+
app.dispatchEvent(errorEvent(new Error('render failed')));
49+
50+
expect(captureException).toHaveBeenCalledTimes(1);
51+
});
52+
53+
it('falls back to the event when it carries no error', () => {
54+
captureException.mockClear();
55+
const app = fakeApp();
56+
captureRuntimeErrors(app);
57+
58+
const event = new Event('error');
59+
app.dispatchEvent(event);
60+
61+
expect(captureException).toHaveBeenCalledWith(event, expect.anything());
62+
});
63+
});

0 commit comments

Comments
 (0)