From 882f9fcc3f15eaeb4857f3e25800c1f38cccc2b6 Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Wed, 4 Nov 2026 12:46:11 +0800 Subject: [PATCH] fix(errors): leave out errors thrown by browser extensions An extension's content script runs in the page, so its errors reach the window's error and unhandledrejection events and were listed as the app's. An error is now skipped when its event names an extension file, or when the first frame of its stack is in one. Co-Authored-By: Claude Opus 5.5 --- .changeset/ignore-extension-errors.md | 5 +++ src/dev-toolbar/extension-errors.test.ts | 39 +++++++++++++++++++++ src/dev-toolbar/extension-errors.ts | 25 ++++++++++++++ src/dev-toolbar/index.tsx | 4 +++ tests/e2e/devtools.spec.ts | 43 ++++++++++++++++++++++++ 5 files changed, 116 insertions(+) create mode 100644 .changeset/ignore-extension-errors.md create mode 100644 src/dev-toolbar/extension-errors.test.ts create mode 100644 src/dev-toolbar/extension-errors.ts diff --git a/.changeset/ignore-extension-errors.md b/.changeset/ignore-extension-errors.md new file mode 100644 index 0000000..6611851 --- /dev/null +++ b/.changeset/ignore-extension-errors.md @@ -0,0 +1,5 @@ +--- +'@solidjs/start-devtools': patch +--- + +Errors thrown by browser extensions' scripts in the page are no longer listed as the app's errors. diff --git a/src/dev-toolbar/extension-errors.test.ts b/src/dev-toolbar/extension-errors.test.ts new file mode 100644 index 0000000..655bd42 --- /dev/null +++ b/src/dev-toolbar/extension-errors.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { isExtensionError } from './extension-errors.js'; + +function withStack(stack: string): Error { + const error = new Error('boom'); + error.stack = stack; + return error; +} + +describe('isExtensionError', () => { + it('trusts the file an error event names', () => { + expect(isExtensionError(undefined, 'chrome-extension://abc/executors/200.js')).toBe(true); + expect(isExtensionError(undefined, 'http://localhost:5173/src/app.tsx')).toBe(false); + }); + + it('reads the first frame of a V8 stack', () => { + const stack = 'TypeError: x\n at Y (chrome-extension://abc/executors/200.js:1:761)'; + expect(isExtensionError(withStack(stack))).toBe(true); + }); + + it('reads the first frame of a Firefox or Safari stack', () => { + expect(isExtensionError(withStack('x@moz-extension://123/content.js:3:9'))).toBe(true); + expect(isExtensionError(withStack('x@safari-web-extension://123/c.js:1:1'))).toBe(true); + }); + + it('keeps an app error that passed through an extension further down', () => { + const stack = [ + 'Error: boom', + ' at Broken (http://localhost:5173/src/app.tsx:12:9)', + ' at wrapped (chrome-extension://abc/hook.js:1:1)', + ].join('\n'); + expect(isExtensionError(withStack(stack))).toBe(false); + }); + + it('keeps values with no stack', () => { + expect(isExtensionError('a string')).toBe(false); + expect(isExtensionError(new Error('no stack'))).toBe(false); + }); +}); diff --git a/src/dev-toolbar/extension-errors.ts b/src/dev-toolbar/extension-errors.ts new file mode 100644 index 0000000..55afc85 --- /dev/null +++ b/src/dev-toolbar/extension-errors.ts @@ -0,0 +1,25 @@ +/** The URL schemes browsers serve extension scripts from. */ +const EXTENSION_URL = /^(?:chrome|moz|safari-web|ms-browser)-extension:\/\//; + +/** The URL of the first frame of a stack, the one the value was thrown in. */ +function firstFrameUrl(stack: string): string | undefined { + for (const line of stack.split('\n')) { + // V8 writes `at fn (url:line:col)`, Firefox and Safari write `fn@url:line:col`. + const match = /(?:\(|@|at\s)([a-z][a-z0-9+.-]*:\/\/[^\s)]+)/i.exec(line); + if (match) return match[1]; + } + return undefined; +} + +/** + * Whether a thrown value came from a browser extension rather than the app. + * An extension's content script runs in the page, so its errors reach the + * window's `error` and `unhandledrejection` events like the app's do. + */ +export function isExtensionError(error: unknown, filename?: string): boolean { + if (filename && EXTENSION_URL.test(filename)) return true; + const stack = error instanceof Error ? error.stack : undefined; + if (!stack) return false; + const url = firstFrameUrl(stack); + return !!url && EXTENSION_URL.test(url); +} diff --git a/src/dev-toolbar/index.tsx b/src/dev-toolbar/index.tsx index 3465de9..ebff7b2 100644 --- a/src/dev-toolbar/index.tsx +++ b/src/dev-toolbar/index.tsx @@ -35,6 +35,7 @@ import { HighlightUpdatesOverlay } from './highlight-updates/overlay.js'; import { installElementTracking, type InspectedComponent } from './inspector/lookup.js'; import { InspectorOverlay } from './inspector/overlay.js'; import { addIssues, captureIssues, type IssueEntry } from './issues/tracker.js'; +import { isExtensionError } from './extension-errors.js'; import { openInEditor } from './open-in-editor.js'; import { CopyReportButton } from './report/CopyReportButton.js'; import { pageRoute } from './report/markdown.js'; @@ -362,10 +363,13 @@ export function DevToolbar(props: DevToolbarProps) { if (!error.error && error.message?.startsWith('ResizeObserver loop')) { return; } + // A browser extension's script throws in the page too. It is not the app's. + if (isExtensionError(error.error, error.filename)) return; pushError(error.error ?? error); }; const onRejectionEvent = (event: PromiseRejectionEvent) => { + if (isExtensionError(event.reason)) return; pushError(event.reason); }; diff --git a/tests/e2e/devtools.spec.ts b/tests/e2e/devtools.spec.ts index b88d493..7a5fa8f 100644 --- a/tests/e2e/devtools.spec.ts +++ b/tests/e2e/devtools.spec.ts @@ -166,6 +166,49 @@ test('fits the dock and the padding to the window', async ({ page }) => { expect(await page.evaluate(() => document.documentElement.style.paddingBottom)).toBe(''); }); +test('leaves out errors thrown by browser extensions', async ({ page }) => { + await page.goto('/'); + + // An extension's script runs in the page, so its errors reach the window too. + await page.evaluate(() => { + const error = new TypeError("Cannot read properties of undefined (reading 'M_ID')"); + error.stack = [ + "TypeError: Cannot read properties of undefined (reading 'M_ID')", + ' at Y (chrome-extension://abcdefghijklmnop/executors/200.js:1:761)', + ' at E (chrome-extension://abcdefghijklmnop/executors/200.js:1:1442)', + ].join('\n'); + window.dispatchEvent( + new ErrorEvent('error', { + error, + message: error.message, + filename: 'chrome-extension://abcdefghijklmnop/executors/200.js', + }), + ); + const rejection = new Error('extension rejection'); + rejection.stack = 'Error: extension rejection\n at x (moz-extension://1234/content.js:3:9)'; + const promise = Promise.reject(rejection); + promise.catch(() => {}); + window.dispatchEvent( + new PromiseRejectionEvent('unhandledrejection', { promise, reason: rejection }), + ); + }); + + // Neither counts as an error of the app, so the dock stays closed. + const launcher = page.getByRole('button', { name: 'Open Start Devtools' }); + await expect(launcher).toHaveAttribute('aria-label', 'Open Start Devtools'); + await expect(page.getByRole('region', { name: 'Start Devtools' })).toBeHidden(); + + // An error of the app still counts. + await page.locator('#throw-client-error').click(); + await expect(page.getByRole('tab', { name: 'Errors', exact: true })).toHaveAttribute( + 'aria-selected', + 'true', + ); + await expect(page.locator('[data-solid-error-viewer-error-info-message]')).toHaveText( + 'client boom', + ); +}); + test('captures client errors', async ({ page }) => { await page.goto('/'); await page.locator('#throw-client-error').click();