From e6c1bd3339bd48256a5e7c93ea0d7065b79796b5 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 30 Sep 2026 11:35:32 +0200 Subject: [PATCH] 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 --- .../remix-v3/app/actions/controller.tsx | 3 + .../remix-v3/app/actions/public/entry.ts | 18 ++- .../remix-v3/tests/client.test.ts | 15 ++- packages/remix/rollup.npm.config.mjs | 5 +- .../src/v3/client/diagnosticsChannelShim.ts | 113 ++++++++++++++++++ packages/remix/src/v3/client/errors.ts | 74 ++++++++++++ packages/remix/src/v3/client/sdk.ts | 9 +- packages/remix/src/v3/index.client.ts | 1 + packages/remix/test/v3/errors.test.ts | 63 ++++++++++ 9 files changed, 297 insertions(+), 4 deletions(-) create mode 100644 packages/remix/src/v3/client/diagnosticsChannelShim.ts create mode 100644 packages/remix/src/v3/client/errors.ts create mode 100644 packages/remix/test/v3/errors.test.ts diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/controller.tsx b/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/controller.tsx index 4999bd4127cc..59d6567f28bb 100644 --- a/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/controller.tsx +++ b/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/controller.tsx @@ -21,6 +21,9 @@ function HomePage(handle: Handle>) { User + ); diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/entry.ts b/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/entry.ts index d4cc91b051d5..f558bda2b048 100644 --- a/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/entry.ts +++ b/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/entry.ts @@ -1,5 +1,5 @@ import * as Sentry from '@sentry/remix/v3/client'; -import { run } from 'remix/ui'; +import { createElement, run } from 'remix/ui'; // Not Node. The asset server substitutes this when it compiles the module, from the `define` map in // `app/assets.ts`. @@ -17,3 +17,19 @@ export const app = run({ return mod[exportName]; }, }); + +// The runtime sends a render error to the event target `run()` returns and does not rethrow, so +// `window.onerror` never sees it. This listener is the only way the SDK learns about it. +Sentry.captureRuntimeErrors(app); + +function Boom(): () => never { + return () => { + throw new Error('Component render failed'); + }; +} + +document.addEventListener('click', event => { + if ((event.target as HTMLElement | null)?.id === 'component-error') { + void app.frames.top.replace(createElement(Boom, {})); + } +}); diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/tests/client.test.ts b/dev-packages/e2e-tests/test-applications/remix-v3/tests/client.test.ts index 60cc59e24379..79d36cf682c0 100644 --- a/dev-packages/e2e-tests/test-applications/remix-v3/tests/client.test.ts +++ b/dev-packages/e2e-tests/test-applications/remix-v3/tests/client.test.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; const APP_NAME = 'remix-v3'; @@ -33,3 +33,16 @@ test('sends a navigation span for a link the runtime intercepts', async ({ page expect(span.is_segment).toBe(true); expect(span.name).toBe('Navigation'); }); + +test('captures a component render error the runtime never rethrows', async ({ page }) => { + await page.goto('/'); + + const errorPromise = waitForError(APP_NAME, event => { + return !event.type && event.exception?.values?.[0]?.value === 'Component render failed'; + }); + + await page.locator('#component-error').click(); + + const error = await errorPromise; + expect(error.exception?.values?.[0]?.mechanism).toEqual({ handled: false, type: 'auto.ui.remix_v3' }); +}); diff --git a/packages/remix/rollup.npm.config.mjs b/packages/remix/rollup.npm.config.mjs index e4938499f1ec..0abd34e17818 100644 --- a/packages/remix/rollup.npm.config.mjs +++ b/packages/remix/rollup.npm.config.mjs @@ -27,7 +27,10 @@ const v3NodeEntry = defineConfig({ */ const v3ClientBundle = defineConfig({ input: 'build/esm/v3/index.client.js', - external: id => id === 'remix' || id.startsWith('@remix-run/'), + // The channel shim has to stay external. Orchestrion's browser transform imports that file by URL + // into every instrumented module, so an inlined copy would leave the page with two shims and two + // separate subscriber registries, and instrumentation would quietly do nothing. + external: id => /diagnosticsChannelShim/.test(id) || id === 'remix' || id.startsWith('@remix-run/'), treeshake: { moduleSideEffects: false, propertyReadSideEffects: false }, plugins: [nodeResolve({ browser: true, exportConditions: ['browser', 'import', 'default'] })], // Emitted inside `build/esm/v3/`, not at `build/`: the one import it keeps is the relative path to diff --git a/packages/remix/src/v3/client/diagnosticsChannelShim.ts b/packages/remix/src/v3/client/diagnosticsChannelShim.ts new file mode 100644 index 000000000000..410447e8054d --- /dev/null +++ b/packages/remix/src/v3/client/diagnosticsChannelShim.ts @@ -0,0 +1,113 @@ +/** + * A browser stand-in for `node:diagnostics_channel`. + * + * Orchestrion's transform injects `import dc from ''` into every instrumented module and + * drives it through Node's `tracingChannel` API. The browser has no `node:diagnostics_channel`, so + * without this shim the transform cannot run on browser code. + * + * This implements exactly what the injected code calls, which is not the `traceSync` helpers but the + * per-event channels under them: + * + * ```js + * // The gate is true when ANY of the five event channels has a subscriber, so subscribing to `end` + * // alone is enough to get the call wrapped. + * if (!(ch.start.hasSubscribers || ch.end.hasSubscribers || ch.asyncStart.hasSubscribers + * || ch.asyncEnd.hasSubscribers || ch.error.hasSubscribers)) return traced(); + * return ch.start.runStores(ctx, () => { + * try { ctx.result = traced(); } + * catch (err) { ctx.error = err; ch.error.publish(ctx); throw err; } + * finally { ch.end.publish(ctx); } + * }); + * ``` + * + * The browser has no `AsyncLocalStorage`, so `runStores` publishes and then calls the callback directly + * instead of entering a store. The injected code does not need a store: it passes state along on the + * `context` object. + */ + +type Handler = (context: unknown) => void; + +interface Channel { + readonly hasSubscribers: boolean; + publish(context: unknown): void; + runStores(context: unknown, fn: (...args: unknown[]) => T, thisArg?: unknown, ...args: unknown[]): T; + subscribe(handler: Handler): void; + unsubscribe(handler: Handler): void; +} + +const EVENTS = ['start', 'end', 'asyncStart', 'asyncEnd', 'error'] as const; +type EventName = (typeof EVENTS)[number]; + +export type TracingChannelSubscribers = Partial>; + +export interface BrowserTracingChannel extends Record { + subscribe(subscribers: TracingChannelSubscribers): void; + unsubscribe(subscribers: TracingChannelSubscribers): void; +} + +function createChannel(): Channel { + const handlers = new Set(); + + return { + get hasSubscribers(): boolean { + return handlers.size > 0; + }, + publish(context) { + // Snapshot: a subscriber may unsubscribe itself while being notified. + for (const handler of Array.from(handlers)) { + try { + handler(context); + } catch { + // A throwing subscriber must never break the instrumented library. + } + } + }, + runStores(context, fn, thisArg, ...args) { + this.publish(context); + return fn.apply(thisArg, args); + }, + subscribe(handler) { + handlers.add(handler); + }, + unsubscribe(handler) { + handlers.delete(handler); + }, + }; +} + +const registry = new Map(); + +/** Create (or look up) a tracing channel by name. */ +export function tracingChannel(name: string): BrowserTracingChannel { + const existing = registry.get(name); + if (existing) { + return existing; + } + + const channels = Object.fromEntries(EVENTS.map(event => [event, createChannel()])) as Record; + + const channel: BrowserTracingChannel = { + ...channels, + subscribe(subscribers) { + for (const event of EVENTS) { + const handler = subscribers[event]; + if (handler) { + channels[event].subscribe(handler); + } + } + }, + unsubscribe(subscribers) { + for (const event of EVENTS) { + const handler = subscribers[event]; + if (handler) { + channels[event].unsubscribe(handler); + } + } + }, + }; + + registry.set(name, channel); + return channel; +} + +export default { tracingChannel }; diff --git a/packages/remix/src/v3/client/errors.ts b/packages/remix/src/v3/client/errors.ts new file mode 100644 index 000000000000..fd636cacba27 --- /dev/null +++ b/packages/remix/src/v3/client/errors.ts @@ -0,0 +1,74 @@ +import { captureException } from '@sentry/browser'; +import { tracingChannel } from './diagnosticsChannelShim'; + +/** The `AppRuntime` returned by `run()` from `remix/ui`, an EventTarget emitting `error`. */ +export interface AppRuntimeLike { + addEventListener(type: 'error', listener: (event: Event) => void): void; +} + +const MECHANISM_TYPE = 'auto.ui.remix_v3'; + +// The channel orchestrion will inject into `@remix-run/ui`'s `run()` once the asset server transforms +// browser modules. Nothing publishes to it yet, so the subscription below is dormant. The name is +// written out because the channel names live in `@sentry/server-utils`, which must not reach the +// browser. +const UI_RUN_CHANNEL = 'orchestrion:@remix-run/ui:run'; + +const attached = new WeakSet(); + +// `subscribe()` takes a fresh object literal and the channel keys handlers by identity, so a second +// call would add a second handler rather than replace the first. +let subscribed = false; + +/** + * Report a Remix 3 client runtime's errors to Sentry. + * + * The runtime sends every render, scheduler, frame and hydration error to the event target `run()` + * returns. Dispatching an event does not rethrow, so `window.onerror` and `unhandledrejection` never + * see them, and without this listener the SDK sees nothing from the component layer. + * + * Called by {@link instrumentClientRuntime}, and exported for apps that do not serve their browser + * modules through an instrumented asset server. + */ +export function captureRuntimeErrors(app: AppRuntimeLike): void { + if (attached.has(app)) { + return; + } + attached.add(app); + + app.addEventListener('error', event => { + captureException(readComponentError(event), { + mechanism: { handled: false, type: MECHANISM_TYPE }, + }); + }); +} + +/** + * Attach to every `run()` call automatically, so the app never has to call `captureRuntimeErrors()` + * itself. A no-op when the browser module was not transformed, because the channel never fires. + */ +export function instrumentClientRuntime(): void { + if (subscribed) { + return; + } + subscribed = true; + + tracingChannel(UI_RUN_CHANNEL).subscribe({ + end(context) { + const app = (context as { result?: unknown }).result; + if (isAppRuntime(app)) { + captureRuntimeErrors(app); + } + }, + }); +} + +function isAppRuntime(value: unknown): value is AppRuntimeLike { + return typeof (value as AppRuntimeLike | undefined)?.addEventListener === 'function'; +} + +// The runtime puts the thrown value on `error`, and it may be any value, not just an `Error`. +function readComponentError(event: Event): unknown { + const error = (event as ErrorEvent).error; + return error !== undefined ? error : event; +} diff --git a/packages/remix/src/v3/client/sdk.ts b/packages/remix/src/v3/client/sdk.ts index 463de70e504b..cbf5560c1296 100644 --- a/packages/remix/src/v3/client/sdk.ts +++ b/packages/remix/src/v3/client/sdk.ts @@ -3,6 +3,7 @@ import { getDefaultIntegrations as getBrowserDefaultIntegrations, init as browse import { applySdkMetadata, type Client, type Integration } from '@sentry/core'; import { browserTracingIntegration } from './browserTracingIntegration'; +import { instrumentClientRuntime } from './errors'; /** * Default integrations for the Remix 3 client SDK. @@ -24,5 +25,11 @@ export function init(options: BrowserOptions): Client | undefined { applySdkMetadata(opts, 'remix', ['remix', 'browser']); - return browserInit(opts); + const client = browserInit(opts); + + // Subscribed before the app calls `run()`, so an app served through an instrumented asset server + // reports component errors without writing any Sentry code itself. + instrumentClientRuntime(); + + return client; } diff --git a/packages/remix/src/v3/index.client.ts b/packages/remix/src/v3/index.client.ts index 761d76bd0e54..ffe5c04d3175 100644 --- a/packages/remix/src/v3/index.client.ts +++ b/packages/remix/src/v3/index.client.ts @@ -74,3 +74,4 @@ export type { BrowserOptions } from '@sentry/browser'; export { getDefaultIntegrations, init } from './client/sdk'; export { browserTracingIntegration } from './client/browserTracingIntegration'; +export { captureRuntimeErrors, instrumentClientRuntime } from './client/errors'; diff --git a/packages/remix/test/v3/errors.test.ts b/packages/remix/test/v3/errors.test.ts new file mode 100644 index 000000000000..c5224d7fd77e --- /dev/null +++ b/packages/remix/test/v3/errors.test.ts @@ -0,0 +1,63 @@ +import { beforeAll, describe, expect, it, vi } from 'vitest'; + +const captureException = vi.fn(); +vi.mock('@sentry/browser', () => ({ captureException: (...args: unknown[]) => captureException(...args) })); + +const { captureRuntimeErrors, instrumentClientRuntime } = await import('../../src/v3/client/errors'); +const { tracingChannel } = await import('../../src/v3/client/diagnosticsChannelShim'); + +/** Stands in for the `AppRuntime` that `run()` returns. */ +function fakeApp(): EventTarget { + return new EventTarget(); +} + +// There is no `ErrorEvent` in this environment. The SDK only reads `.error` off the event, which is +// where the runtime puts the thrown value. +function errorEvent(error: unknown): Event { + return Object.assign(new Event('error'), { error }); +} + +describe('captureRuntimeErrors', () => { + beforeAll(() => { + // Called twice because `init()` may run more than once. Two guards make that safe, the subscribe + // once flag and the `attached` WeakSet; this pins the outcome, not either one on its own. + instrumentClientRuntime(); + instrumentClientRuntime(); + }); + + it('reports one error per dispatch, however often instrumentation was set up', () => { + captureException.mockClear(); + const app = fakeApp(); + tracingChannel('orchestrion:@remix-run/ui:run').end.publish({ result: app }); + + const error = new Error('render failed'); + app.dispatchEvent(errorEvent(error)); + + expect(captureException).toHaveBeenCalledTimes(1); + expect(captureException).toHaveBeenCalledWith(error, { + mechanism: { handled: false, type: 'auto.ui.remix_v3' }, + }); + }); + + it('attaches only once to the same app', () => { + captureException.mockClear(); + const app = fakeApp(); + captureRuntimeErrors(app); + captureRuntimeErrors(app); + + app.dispatchEvent(errorEvent(new Error('render failed'))); + + expect(captureException).toHaveBeenCalledTimes(1); + }); + + it('falls back to the event when it carries no error', () => { + captureException.mockClear(); + const app = fakeApp(); + captureRuntimeErrors(app); + + const event = new Event('error'); + app.dispatchEvent(event); + + expect(captureException).toHaveBeenCalledWith(event, expect.anything()); + }); +});