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();