Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/ignore-extension-errors.md
Original file line number Diff line number Diff line change
@@ -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.
39 changes: 39 additions & 0 deletions src/dev-toolbar/extension-errors.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
25 changes: 25 additions & 0 deletions src/dev-toolbar/extension-errors.ts
Original file line number Diff line number Diff line change
@@ -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);
}
4 changes: 4 additions & 0 deletions src/dev-toolbar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
};

Expand Down
43 changes: 43 additions & 0 deletions tests/e2e/devtools.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading