From 0785c111e0d3c0b53f124046e12946564c192e5a Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 30 Oct 2026 15:47:27 +0100 Subject: [PATCH 1/4] fix(web): shortcuts survive the key-less autofill keydown; extension errors stay out of error tracking Chrome fires a keydown with no `key` when it autofills a form. The time range picker hotkey and the shell's global shortcuts called `event.key.toLowerCase()` on every document keydown, so autofilling the sign-in, sign-up or org-required form threw an uncaught TypeError (5 in prd since 10-22). Both read the key through `pressedKey`, which is "" when it is missing. The effect SDK's browser client now drops what `@maple/browser` already drops: errors from a browser extension's script (e.g. a wallet extension's rejected connect) and the benign ResizeObserver loop notices, which made up most of maple-web's uncaught errors. --- .changeset/effect-sdk-browser-error-noise.md | 5 ++ .../page/kit/time-range-picker.scene.test.ts | 4 ++ apps/web/src/page/kit/time-range-picker.ts | 10 ++-- apps/web/src/shell/subscription.ts | 4 +- .../effect-sdk/src/client/error-noise.test.ts | 52 +++++++++++++++++++ packages/effect-sdk/src/client/error-noise.ts | 28 ++++++++++ packages/effect-sdk/src/client/flushable.ts | 4 +- packages/ui/src/lib/keyboard.ts | 3 ++ 8 files changed, 104 insertions(+), 6 deletions(-) create mode 100644 .changeset/effect-sdk-browser-error-noise.md create mode 100644 packages/effect-sdk/src/client/error-noise.test.ts create mode 100644 packages/effect-sdk/src/client/error-noise.ts diff --git a/.changeset/effect-sdk-browser-error-noise.md b/.changeset/effect-sdk-browser-error-noise.md new file mode 100644 index 0000000000..3f743409a3 --- /dev/null +++ b/.changeset/effect-sdk-browser-error-noise.md @@ -0,0 +1,5 @@ +--- +"@maple-dev/effect-sdk": patch +--- + +The browser client's uncaught-error capture no longer reports errors thrown by browser extensions (an extension's script, or a rejection whose top frame is one) or the benign `ResizeObserver loop` notices, as `@maple/browser` already does. diff --git a/apps/web/src/page/kit/time-range-picker.scene.test.ts b/apps/web/src/page/kit/time-range-picker.scene.test.ts index 25a2efc948..6b8c72f765 100644 --- a/apps/web/src/page/kit/time-range-picker.scene.test.ts +++ b/apps/web/src/page/kit/time-range-picker.scene.test.ts @@ -213,4 +213,8 @@ describe("time range picker hotkey", () => { expect(isOpenKey(press("D", { shiftKey: true }))).toBe(false) expect(isOpenKey(press("f"))).toBe(false) }) + + test("the key-less keydown Chrome fires on autofill opens nothing", () => { + expect(isOpenKey({ ...press("d"), key: undefined })).toBe(false) + }) }) diff --git a/apps/web/src/page/kit/time-range-picker.ts b/apps/web/src/page/kit/time-range-picker.ts index 1159695fb2..14985a9cae 100644 --- a/apps/web/src/page/kit/time-range-picker.ts +++ b/apps/web/src/page/kit/time-range-picker.ts @@ -5,7 +5,7 @@ import { parseWarehouseDateTime, relativeRangeSeconds } from "@maple/query-engin import { TONE_TEXT } from "@maple/ui/lib/tone" import { badgeVariants } from "@maple/ui/styles/ui/badge" import { kbdStyles } from "@maple/ui/styles/ui/kbd" -import { isDialogOpen, isEditableTarget } from "@maple/ui/lib/keyboard" +import { isDialogOpen, isEditableTarget, pressedKey } from "@maple/ui/lib/keyboard" import { Clock, Effect, Option, Schema, type Stream } from "effect" import { Command, Dom, Mount, Submodel, type Update } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" @@ -503,7 +503,7 @@ export const SelectTimeZone = Command.define("SelectTimeZone", { const HOTKEY_ATTRIBUTE = "time-range-hotkey" type KeyPress = Readonly<{ - key: string + key?: string | undefined metaKey: boolean ctrlKey: boolean altKey: boolean @@ -512,7 +512,11 @@ type KeyPress = Readonly<{ /** The `time.open` combo: a bare "D" (either case), no modifier held. */ export const isOpenKey = (event: KeyPress): boolean => - event.key.toLowerCase() === "d" && !event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey + pressedKey(event).toLowerCase() === "d" && + !event.metaKey && + !event.ctrlKey && + !event.altKey && + !event.shiftKey /** * `useAppHotkey("time.open", () => setOpen(true))`: "D" outside fields and dialogs, unmodified, diff --git a/apps/web/src/shell/subscription.ts b/apps/web/src/shell/subscription.ts index 7a0ff6a59e..ebbbfcc8dd 100644 --- a/apps/web/src/shell/subscription.ts +++ b/apps/web/src/shell/subscription.ts @@ -1,4 +1,4 @@ -import { isDialogOpen, isEditableTarget } from "@maple/ui/lib/keyboard" +import { isDialogOpen, isEditableTarget, pressedKey } from "@maple/ui/lib/keyboard" import { Option, Stream } from "effect" import { Dom } from "foldkit" import { DASHBOARD_FAVORITES_EVENT, readDashboardFavorites } from "./favorites" @@ -47,7 +47,7 @@ export const globalShortcuts: Stream.Stream = Dom.streamFromEventFilter target: () => document, type: "keydown", filterMapEvent: (event): Option.Option => { - const key = event.key.toLowerCase() + const key = pressedKey(event).toLowerCase() if ((event.metaKey || event.ctrlKey) && key === "k") { event.preventDefault() return Option.some(Message.PressedPaletteShortcut()) diff --git a/packages/effect-sdk/src/client/error-noise.test.ts b/packages/effect-sdk/src/client/error-noise.test.ts new file mode 100644 index 0000000000..04dc0ec696 --- /dev/null +++ b/packages/effect-sdk/src/client/error-noise.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest" +import { isBrowserNoise } from "./error-noise.js" + +const withStack = (message: string, stack: string): Error => { + const error = new Error(message) + error.stack = stack + return error +} + +describe("isBrowserNoise", () => { + it("drops the ResizeObserver loop notices", () => { + expect( + isBrowserNoise(new Error("ResizeObserver loop completed with undelivered notifications.")), + ).toBe(true) + expect(isBrowserNoise(new Error("ResizeObserver loop limit exceeded"))).toBe(true) + }) + + it("drops a rejection thrown from an extension's injected script", () => { + const metaMask = withStack( + "Failed to connect to MetaMask", + "i: Failed to connect to MetaMask\n at Object.connect (chrome-extension://nkbihfbeogaeaoehlefnkodbefgpgknn/scripts/inpage.js:12:4822)", + ) + expect(isBrowserNoise(metaMask)).toBe(true) + expect(isBrowserNoise(withStack("x", "connect@moz-extension://abc/inpage.js:1:2"))).toBe(true) + }) + + it("drops an uncaught error whose script is an extension's", () => { + expect( + isBrowserNoise( + new Error("boom"), + "chrome-extension://fmkadmapgofadopljbjfkapdkoienihi/build/backendManager.js", + ), + ).toBe(true) + }) + + it("keeps the app's own errors, even when an extension frame sits further down the stack", () => { + const own = withStack( + "Cannot read properties of undefined (reading 'toLowerCase')", + "TypeError: Cannot read properties of undefined (reading 'toLowerCase')\n at isOpenKey (https://app.maple.dev/assets/time-range-picker.js:1:2)\n at chrome-extension://abc/content.js:3:4", + ) + expect(isBrowserNoise(own, "https://app.maple.dev/assets/time-range-picker.js")).toBe(false) + expect( + isBrowserNoise( + withStack( + "mail a@b.co failed", + "Error: mail a@b.co failed\n at send (https://app.test/a.js:1:1)", + ), + ), + ).toBe(false) + expect(isBrowserNoise("plain rejection")).toBe(false) + }) +}) diff --git a/packages/effect-sdk/src/client/error-noise.ts b/packages/effect-sdk/src/client/error-noise.ts new file mode 100644 index 0000000000..54f96daeb3 --- /dev/null +++ b/packages/effect-sdk/src/client/error-noise.ts @@ -0,0 +1,28 @@ +// Uncaught errors that are not the app's: a browser extension's own failures and +// the benign `ResizeObserver loop` notice. `@maple/browser` drops the same two. + +const EXTENSION_URL = /^(?:chrome|moz|safari(?:-web)?|ms-browser)-extension:\/\// +const BENIGN_MESSAGE = /^ResizeObserver loop (?:limit exceeded|completed with undelivered notifications)/ + +/** The stack's first frame: after `at ` (V8), else after `@` (SpiderMonkey, JavaScriptCore). */ +const topFrame = (stack: string): string | undefined => { + const lines = stack.split("\n").map((line) => line.trim()) + // V8 opens with the message line, which may itself contain an `@`. + const v8 = lines.find((line) => line.startsWith("at ")) + if (v8 !== undefined) { + const open = v8.indexOf("(") + return open === -1 ? v8.slice(3) : v8.slice(open + 1) + } + const frame = lines.find((line) => line.includes("@")) + return frame === undefined ? undefined : frame.slice(frame.indexOf("@") + 1) +} + +/** True for an error the page's own code did not throw. `filename` is `ErrorEvent.filename`. */ +export const isBrowserNoise = (error: unknown, filename?: string): boolean => { + const message = error instanceof Error ? error.message : typeof error === "string" ? error : "" + if (BENIGN_MESSAGE.test(message)) return true + if (filename !== undefined && EXTENSION_URL.test(filename)) return true + const frame = + error instanceof Error && typeof error.stack === "string" ? topFrame(error.stack) : undefined + return frame !== undefined && EXTENSION_URL.test(frame) +} diff --git a/packages/effect-sdk/src/client/flushable.ts b/packages/effect-sdk/src/client/flushable.ts index a5cfbeb838..2d922b8b01 100644 --- a/packages/effect-sdk/src/client/flushable.ts +++ b/packages/effect-sdk/src/client/flushable.ts @@ -32,6 +32,7 @@ import { makeMetricBuffer } from "../shared/flushable-metrics.js" import { type CaptureExceptionOptions, makeSpanBuffer, type SpanBuffer } from "../shared/flushable-tracer.js" import type { MapleSpanOptions } from "../shared/span-options.js" import { browserDocument, browserNavigator, browserResourceAttributes } from "./browser-globals.js" +import { isBrowserNoise } from "./error-noise.js" import { type ClientReplayConfig, startClientSession } from "./replay-loader.js" import { withSessionLink } from "./session-link.js" import type { PrivacyOptions } from "./track.js" @@ -359,7 +360,7 @@ export const make = (config: MapleClientFlushableConfig): FlushableTelemetry => // louder error tracker. const error: unknown = event.error ?? (event.message && event.filename ? new Error(event.message) : undefined) - if (error === undefined) return + if (error === undefined || isBrowserNoise(error, event.filename)) return captureException(error, { name: "browser.uncaught_error", attributes: { @@ -375,6 +376,7 @@ export const make = (config: MapleClientFlushableConfig): FlushableTelemetry => } const onUnhandledRejection = (event: PromiseRejectionEvent): void => { + if (isBrowserNoise(event.reason)) return captureException(event.reason, { name: "browser.unhandled_rejection", attributes: { "maple.exception.source": "unhandledrejection" }, diff --git a/packages/ui/src/lib/keyboard.ts b/packages/ui/src/lib/keyboard.ts index 4ded726931..4dd84f0814 100644 --- a/packages/ui/src/lib/keyboard.ts +++ b/packages/ui/src/lib/keyboard.ts @@ -3,6 +3,9 @@ const EDITABLE_TAGS = new Set(["INPUT", "TEXTAREA", "SELECT"]) +/** `event.key`, or "" for the key-less `keydown` Chrome fires when it autofills a form. */ +export const pressedKey = (event: { readonly key?: string | undefined }): string => event.key ?? "" + /** True when the event target is a text field / contentEditable — skip shortcuts there. */ export function isEditableTarget(target: EventTarget | null): boolean { if (!(target instanceof HTMLElement)) return false From 142a0ef7c0afc9796a857d0e388d2239c598ebd0 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 30 Oct 2026 15:50:51 +0100 Subject: [PATCH 2/4] refactor(effect-sdk): browser noise filter reuses @maple/sdk-core's default error filters One copy of the extension and ResizeObserver patterns, shared with @maple/browser, instead of a second hand-kept parser. --- bun.lock | 1 + packages/effect-sdk/package.json | 1 + .../effect-sdk/src/client/error-noise.test.ts | 20 +++++++++---- packages/effect-sdk/src/client/error-noise.ts | 30 ++++--------------- packages/effect-sdk/src/client/flushable.ts | 4 +-- packages/tsconfig.effect-sdk.dts.json | 4 +-- 6 files changed, 26 insertions(+), 34 deletions(-) diff --git a/bun.lock b/bun.lock index 97a4bf669f..a3d0bbc71c 100644 --- a/bun.lock +++ b/bun.lock @@ -712,6 +712,7 @@ "devDependencies": { "@effect/vitest": "catalog:effect", "@maple/browser-session": "workspace:*", + "@maple/sdk-core": "workspace:*", "@types/node": "catalog:tooling", "effect": "catalog:effect", "tsdown": "^0.23.0", diff --git a/packages/effect-sdk/package.json b/packages/effect-sdk/package.json index 793c4cb1c6..42390077b9 100644 --- a/packages/effect-sdk/package.json +++ b/packages/effect-sdk/package.json @@ -68,6 +68,7 @@ "devDependencies": { "@effect/vitest": "catalog:effect", "@maple/browser-session": "workspace:*", + "@maple/sdk-core": "workspace:*", "@types/node": "catalog:tooling", "effect": "catalog:effect", "tsdown": "^0.23.0", diff --git a/packages/effect-sdk/src/client/error-noise.test.ts b/packages/effect-sdk/src/client/error-noise.test.ts index 04dc0ec696..7a9ea810f5 100644 --- a/packages/effect-sdk/src/client/error-noise.test.ts +++ b/packages/effect-sdk/src/client/error-noise.test.ts @@ -10,9 +10,13 @@ const withStack = (message: string, stack: string): Error => { describe("isBrowserNoise", () => { it("drops the ResizeObserver loop notices", () => { expect( - isBrowserNoise(new Error("ResizeObserver loop completed with undelivered notifications.")), + isBrowserNoise( + new Error("ResizeObserver loop completed with undelivered notifications."), + "window.onerror", + "https://app.maple.dev/", + ), ).toBe(true) - expect(isBrowserNoise(new Error("ResizeObserver loop limit exceeded"))).toBe(true) + expect(isBrowserNoise(new Error("ResizeObserver loop limit exceeded"), "window.onerror")).toBe(true) }) it("drops a rejection thrown from an extension's injected script", () => { @@ -20,14 +24,17 @@ describe("isBrowserNoise", () => { "Failed to connect to MetaMask", "i: Failed to connect to MetaMask\n at Object.connect (chrome-extension://nkbihfbeogaeaoehlefnkodbefgpgknn/scripts/inpage.js:12:4822)", ) - expect(isBrowserNoise(metaMask)).toBe(true) - expect(isBrowserNoise(withStack("x", "connect@moz-extension://abc/inpage.js:1:2"))).toBe(true) + expect(isBrowserNoise(metaMask, "unhandledrejection")).toBe(true) + expect( + isBrowserNoise(withStack("x", "connect@moz-extension://abc/inpage.js:1:2"), "unhandledrejection"), + ).toBe(true) }) it("drops an uncaught error whose script is an extension's", () => { expect( isBrowserNoise( new Error("boom"), + "window.onerror", "chrome-extension://fmkadmapgofadopljbjfkapdkoienihi/build/backendManager.js", ), ).toBe(true) @@ -38,15 +45,16 @@ describe("isBrowserNoise", () => { "Cannot read properties of undefined (reading 'toLowerCase')", "TypeError: Cannot read properties of undefined (reading 'toLowerCase')\n at isOpenKey (https://app.maple.dev/assets/time-range-picker.js:1:2)\n at chrome-extension://abc/content.js:3:4", ) - expect(isBrowserNoise(own, "https://app.maple.dev/assets/time-range-picker.js")).toBe(false) + expect(isBrowserNoise(own, "unhandledrejection")).toBe(false) expect( isBrowserNoise( withStack( "mail a@b.co failed", "Error: mail a@b.co failed\n at send (https://app.test/a.js:1:1)", ), + "window.onerror", ), ).toBe(false) - expect(isBrowserNoise("plain rejection")).toBe(false) + expect(isBrowserNoise("plain rejection", "unhandledrejection")).toBe(false) }) }) diff --git a/packages/effect-sdk/src/client/error-noise.ts b/packages/effect-sdk/src/client/error-noise.ts index 54f96daeb3..ccff6eb6af 100644 --- a/packages/effect-sdk/src/client/error-noise.ts +++ b/packages/effect-sdk/src/client/error-noise.ts @@ -1,28 +1,10 @@ // Uncaught errors that are not the app's: a browser extension's own failures and -// the benign `ResizeObserver loop` notice. `@maple/browser` drops the same two. +// the benign `ResizeObserver loop` notice. The patterns are `@maple/sdk-core`'s +// default filters, the same ones `@maple/browser` applies. +import { asError, type ErrorSource, makeErrorFilter } from "@maple/sdk-core" -const EXTENSION_URL = /^(?:chrome|moz|safari(?:-web)?|ms-browser)-extension:\/\// -const BENIGN_MESSAGE = /^ResizeObserver loop (?:limit exceeded|completed with undelivered notifications)/ - -/** The stack's first frame: after `at ` (V8), else after `@` (SpiderMonkey, JavaScriptCore). */ -const topFrame = (stack: string): string | undefined => { - const lines = stack.split("\n").map((line) => line.trim()) - // V8 opens with the message line, which may itself contain an `@`. - const v8 = lines.find((line) => line.startsWith("at ")) - if (v8 !== undefined) { - const open = v8.indexOf("(") - return open === -1 ? v8.slice(3) : v8.slice(open + 1) - } - const frame = lines.find((line) => line.includes("@")) - return frame === undefined ? undefined : frame.slice(frame.indexOf("@") + 1) -} +const defaultFilters = makeErrorFilter() /** True for an error the page's own code did not throw. `filename` is `ErrorEvent.filename`. */ -export const isBrowserNoise = (error: unknown, filename?: string): boolean => { - const message = error instanceof Error ? error.message : typeof error === "string" ? error : "" - if (BENIGN_MESSAGE.test(message)) return true - if (filename !== undefined && EXTENSION_URL.test(filename)) return true - const frame = - error instanceof Error && typeof error.stack === "string" ? topFrame(error.stack) : undefined - return frame !== undefined && EXTENSION_URL.test(frame) -} +export const isBrowserNoise = (error: unknown, source: ErrorSource, filename?: string): boolean => + !defaultFilters(asError(error), { source, originalError: error }, filename || undefined) diff --git a/packages/effect-sdk/src/client/flushable.ts b/packages/effect-sdk/src/client/flushable.ts index 2d922b8b01..8a78df902a 100644 --- a/packages/effect-sdk/src/client/flushable.ts +++ b/packages/effect-sdk/src/client/flushable.ts @@ -360,7 +360,7 @@ export const make = (config: MapleClientFlushableConfig): FlushableTelemetry => // louder error tracker. const error: unknown = event.error ?? (event.message && event.filename ? new Error(event.message) : undefined) - if (error === undefined || isBrowserNoise(error, event.filename)) return + if (error === undefined || isBrowserNoise(error, "window.onerror", event.filename)) return captureException(error, { name: "browser.uncaught_error", attributes: { @@ -376,7 +376,7 @@ export const make = (config: MapleClientFlushableConfig): FlushableTelemetry => } const onUnhandledRejection = (event: PromiseRejectionEvent): void => { - if (isBrowserNoise(event.reason)) return + if (isBrowserNoise(event.reason, "unhandledrejection")) return captureException(event.reason, { name: "browser.unhandled_rejection", attributes: { "maple.exception.source": "unhandledrejection" }, diff --git a/packages/tsconfig.effect-sdk.dts.json b/packages/tsconfig.effect-sdk.dts.json index 442a507b2c..d1a7a1ccd2 100644 --- a/packages/tsconfig.effect-sdk.dts.json +++ b/packages/tsconfig.effect-sdk.dts.json @@ -1,7 +1,7 @@ { // Declaration emit for effect-sdk's tsdown build only. It lives here so tsgo's - // rootDir covers @maple/browser-session, whose types the SDK bundles. + // rootDir covers @maple/browser-session and @maple/sdk-core, whose types the SDK bundles. "extends": "./effect-sdk/tsconfig.json", - "include": ["./effect-sdk/src/**/*.ts", "./browser-session/src/**/*.ts"], + "include": ["./effect-sdk/src/**/*.ts", "./browser-session/src/**/*.ts", "./sdk-core/src/**/*.ts"], "exclude": ["**/*.test.ts", "**/*.bench.ts"] } From 5cb40cd692e816ed1906e20c2acea706473db365 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 30 Oct 2026 15:54:29 +0100 Subject: [PATCH 3/4] test(effect-sdk): the global error handlers drop extension and ResizeObserver noise end to end --- .../effect-sdk/src/client/flushable.test.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/packages/effect-sdk/src/client/flushable.test.ts b/packages/effect-sdk/src/client/flushable.test.ts index 7de5366b97..2b67218913 100644 --- a/packages/effect-sdk/src/client/flushable.test.ts +++ b/packages/effect-sdk/src/client/flushable.test.ts @@ -101,6 +101,9 @@ const setupDom = () => { fire: (type: string) => { for (const fn of listeners[type] ?? []) (fn as () => void)() }, + dispatch: (type: string, event: unknown) => { + for (const fn of listeners[type] ?? []) (fn as (event: unknown) => void)(event) + }, // A browser runs a microtask checkpoint after each listener; `fire` does not. fireWithCheckpoints: async (type: string) => { for (const fn of listeners[type] ?? []) { @@ -414,6 +417,33 @@ describe("MapleFlush.make (client)", () => { expect(dom.listenerCount("visibilitychange")).toBe(0) }) + it("reports the app's uncaught errors and drops extension and ResizeObserver noise", async () => { + const { calls, restore: rf } = setupFetch() + const dom = setupDom() + restore = () => { + rf() + dom.restore() + } + const telemetry = make(baseConfig) + const extensionFrame = new Error("Failed to connect to MetaMask") + extensionFrame.stack = + "i: Failed to connect to MetaMask\n at Object.connect (chrome-extension://nkbihfbeogaeaoehlefnkodbefgpgknn/scripts/inpage.js:12:4822)" + + dom.dispatch("error", { + error: null, + message: "ResizeObserver loop completed with undelivered notifications.", + filename: "https://app.test/", + }) + dom.dispatch("error", { error: new Error("boom"), filename: "chrome-extension://abc/content.js" }) + dom.dispatch("unhandledrejection", { reason: extensionFrame }) + dom.dispatch("error", { error: new TypeError("app bug"), filename: "https://app.test/assets/app.js" }) + dom.dispatch("unhandledrejection", { reason: new Error("app rejection") }) + await telemetry.flush() + + expect(spanNames(calls)).toEqual(["browser.uncaught_error", "browser.unhandled_rejection"]) + await telemetry.dispose() + }) + it("auto-flushes on the interval, and dispose() stops the timer", async () => { vi.useFakeTimers() const { calls, restore: r } = setupFetch() From 3dda4a75f656a1798f3c7860c93805dbb598ee5a Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 30 Oct 2026 15:54:42 +0100 Subject: [PATCH 4/4] test(effect-sdk): type the DOM shim's dispatched event --- packages/effect-sdk/src/client/flushable.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/effect-sdk/src/client/flushable.test.ts b/packages/effect-sdk/src/client/flushable.test.ts index 2b67218913..1f25164daf 100644 --- a/packages/effect-sdk/src/client/flushable.test.ts +++ b/packages/effect-sdk/src/client/flushable.test.ts @@ -79,6 +79,9 @@ const spanNames = (calls: ReadonlyArray): Array => ).resourceSpans[0].scopeSpans[0].spans.map((span) => span.name), ) +/** The `ErrorEvent` / `PromiseRejectionEvent` fields the global error handlers read. */ +type ShimEvent = Readonly<{ error?: Error | null; message?: string; filename?: string; reason?: Error }> + // Minimal DOM event shim — vitest runs in node, where globalThis isn't an // EventTarget. Lets us drive `pagehide` / `visibilitychange` without jsdom. const setupDom = () => { @@ -101,8 +104,8 @@ const setupDom = () => { fire: (type: string) => { for (const fn of listeners[type] ?? []) (fn as () => void)() }, - dispatch: (type: string, event: unknown) => { - for (const fn of listeners[type] ?? []) (fn as (event: unknown) => void)(event) + dispatch: (type: string, event: ShimEvent) => { + for (const fn of listeners[type] ?? []) (fn as (event: ShimEvent) => void)(event) }, // A browser runs a microtask checkpoint after each listener; `fire` does not. fireWithCheckpoints: async (type: string) => {