From abacf5256453f07f5dfa609c6828a080d3d049d7 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Fri, 17 Jul 2026 17:21:16 -0400 Subject: [PATCH] fix(replay): Distinguish worker load failures from page-teardown aborts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Failed to load Replay compression worker: Unknown error" message is captured across Chrome, Edge and Firefox as well as Safari. On those engines the blob MIME type is irrelevant — the error comes from the worker fetch being aborted when the page navigates away or is backgrounded while the worker is still loading. `WorkerHandler.ensureReady()` now routes the worker `error` event through a helper that distinguishes an aborted load (document hidden/unloading) from a genuine load failure, so the captured message is accurate instead of always blaming CSP/network. --- CHANGELOG.md | 4 ++ .../src/eventBuffer/WorkerHandler.ts | 36 +++++++++-- .../unit/eventBuffer/WorkerHandler.test.ts | 64 ++++++++++++++++++- 3 files changed, 98 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b588cf99ab66..ec8f8cd4f8d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ Work in this release was contributed by @PeterWadie and @akshitsinha. Thank you The SvelteKit SDK now supports SvelteKit 3, including client-side pageload and navigation tracing and server-side native tracing, alongside continued SvelteKit 2 support. No Sentry-specific setup changes are required. The SDK detects your SvelteKit version and picks the right implementation automatically. +### Other Changes + +- fix(replay): Distinguish compression worker load failures from page-teardown aborts ([#22378](https://github.com/getsentry/sentry-javascript/pull/22378)) + ## 10.66.0 - chore(node-core): Deprecate `@sentry/node-core` package ([#22285](https://github.com/getsentry/sentry-javascript/pull/22285)) diff --git a/packages/replay-internal/src/eventBuffer/WorkerHandler.ts b/packages/replay-internal/src/eventBuffer/WorkerHandler.ts index dba3c858b711..adaa78d6c3ce 100644 --- a/packages/replay-internal/src/eventBuffer/WorkerHandler.ts +++ b/packages/replay-internal/src/eventBuffer/WorkerHandler.ts @@ -1,7 +1,37 @@ +import { WINDOW } from '../constants'; import { DEBUG_BUILD } from '../debug-build'; import type { WorkerRequest, WorkerResponse } from '../types'; import { debug } from '../util/logger'; +/** + * Build the error thrown when the compression worker fails to load. + * + * The worker's `error` event is a bare `Event` (not an `ErrorEvent`) when the + * script fails to *load* — it carries no `message`, so we can't read a cause + * off it. Rather than always blaming CSP/network, we check whether the document + * is no longer visible: when the page is navigating away or backgrounded, an + * in-flight worker fetch is aborted and this error is expected teardown noise, + * not a real load failure. Distinguishing the two keeps the captured signal + * accurate across browsers (this fires on Safari, Chrome, Firefox alike). + */ +function _getWorkerLoadError(error: unknown): Error { + if (error instanceof ErrorEvent && error.message) { + return new Error(`Failed to load Replay compression worker: ${error.message}`); + } + + // `document` may be undefined in non-browser worker/SSR contexts. + const isDocumentHidden = WINDOW.document && WINDOW.document.visibilityState !== 'visible'; + if (isDocumentHidden) { + return new Error( + 'Failed to load Replay compression worker: the page was hidden or unloaded before the worker loaded.', + ); + } + + return new Error( + 'Failed to load Replay compression worker: Unknown error. This can happen due to CSP policy restrictions, network issues, or the worker script failing to load.', + ); +} + interface PendingRequest { method: WorkerRequest['method']; resolve: (value: unknown) => void; @@ -56,11 +86,7 @@ export class WorkerHandler { 'error', error => { DEBUG_BUILD && debug.warn('Failed to load Replay compression worker', error); - reject( - new Error( - `Failed to load Replay compression worker: ${error instanceof ErrorEvent && error.message ? error.message : 'Unknown error. This can happen due to CSP policy restrictions, network issues, or the worker script failing to load.'}`, - ), - ); + reject(_getWorkerLoadError(error)); }, { once: true }, ); diff --git a/packages/replay-internal/test/unit/eventBuffer/WorkerHandler.test.ts b/packages/replay-internal/test/unit/eventBuffer/WorkerHandler.test.ts index 0b28cec37348..e4776cc57ab7 100644 --- a/packages/replay-internal/test/unit/eventBuffer/WorkerHandler.test.ts +++ b/packages/replay-internal/test/unit/eventBuffer/WorkerHandler.test.ts @@ -2,7 +2,7 @@ * @vitest-environment jsdom */ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; import { WorkerHandler } from '../../../src/eventBuffer/WorkerHandler'; import type { WorkerResponse } from '../../../src/types'; @@ -62,6 +62,16 @@ class MockWorker implements Pick { expect(worker.terminated).toBe(true); expect(worker.listenerCount).toBe(0); }); + + describe('ensureReady', () => { + const originalVisibility = Object.getOwnPropertyDescriptor(Document.prototype, 'visibilityState'); + + const setVisibility = (state: DocumentVisibilityState): void => { + Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => state }); + }; + + afterEach(() => { + if (originalVisibility) { + Object.defineProperty(Document.prototype, 'visibilityState', originalVisibility); + } + }); + + it('resolves once the worker reports readiness', async () => { + const { worker, handler } = makeHandler(); + const ready = handler.ensureReady(); + worker.dispatchReady(); + await expect(ready).resolves.toBeUndefined(); + }); + + it('caches the ready promise so listeners are only attached once', () => { + const { handler } = makeHandler(); + expect(handler.ensureReady()).toBe(handler.ensureReady()); + }); + + it("uses an ErrorEvent's message when one is present", async () => { + const { worker, handler } = makeHandler(); + const ready = handler.ensureReady(); + worker.dispatchError(new ErrorEvent('error', { message: 'CSP blocked worker-src' })); + await expect(ready).rejects.toThrow('Failed to load Replay compression worker: CSP blocked worker-src'); + }); + + it('attributes a bare error event to teardown when the document is hidden', async () => { + setVisibility('hidden'); + const { worker, handler } = makeHandler(); + const ready = handler.ensureReady(); + // A load failure fires a plain Event with no message (Safari/Chrome/Firefox alike). + worker.dispatchError(new Event('error')); + await expect(ready).rejects.toThrow( + 'Failed to load Replay compression worker: the page was hidden or unloaded before the worker loaded.', + ); + }); + + it('falls back to the generic message for a bare error event while visible', async () => { + setVisibility('visible'); + const { worker, handler } = makeHandler(); + const ready = handler.ensureReady(); + worker.dispatchError(new Event('error')); + await expect(ready).rejects.toThrow(/Unknown error\. This can happen due to CSP policy restrictions/); + }); + }); });