From b64efea2cf122c5250cf1b3d49fa79b5b04fb3ac Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 11:16:55 +0000 Subject: [PATCH 1/2] fix(sentry): filter stale app chunk load errors --- src/router.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/router.tsx b/src/router.tsx index 7bcb6a98d..bcf13df6d 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -5,7 +5,10 @@ import { DefaultCatchBoundary } from './components/DefaultCatchBoundary' import { NotFound } from './components/NotFound' import { QueryClient } from '@tanstack/react-query' import * as Sentry from '@sentry/tanstackstart-react' -import { installStaleAppReloadHandlers } from './utils/stale-app-reload' +import { + installStaleAppReloadHandlers, + isStaleAppError, +} from './utils/stale-app-reload' import { redactByokRequestHeaders } from './utils/sentry-redaction' if (typeof document !== 'undefined') { @@ -20,6 +23,16 @@ if (typeof document !== 'undefined') { tracePropagationTargets: ['localhost', /^https:\/\/tanstack\.com\//], beforeSend(event) { redactByokRequestHeaders(event) + // Stale/failed app chunk loads (e.g. after a deploy or a dropped + // connection) are handled by installStaleAppReloadHandlers, which + // reloads the page. Don't report them as errors. + const exceptionTexts = + event.exception?.values?.map((v) => + [v.type, v.value].filter(Boolean).join(': '), + ) ?? [] + if (exceptionTexts.length > 0 && isStaleAppError(exceptionTexts)) { + return null + } // Filter out errors from third-party ad tech scripts (e.g. Publift's // Fuse Platform ftUtils.js) that are not actionable by us. const frames = event.exception?.values?.flatMap( From b092eedc05c28a346a5d44e6ee23c3c7cfbc6a9f Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Sat, 17 Oct 2026 07:28:32 -0600 Subject: [PATCH 2/2] fix: retain Sentry reports when stale app reload is throttled --- src/router.tsx | 15 ++---- src/utils/stale-app-reload.ts | 20 ++++++++ tests/stale-app-reload.test.ts | 94 ++++++++++++++++++++++++++++++++++ 3 files changed, 119 insertions(+), 10 deletions(-) create mode 100644 tests/stale-app-reload.test.ts diff --git a/src/router.tsx b/src/router.tsx index 59a38db02..d62b954ba 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -7,7 +7,7 @@ import { QueryClient } from '@tanstack/react-query' import * as Sentry from '@sentry/tanstackstart-react' import { installStaleAppReloadHandlers, - isStaleAppError, + consumeStaleAppRecovery, } from './utils/stale-app-reload' import { redactByokRequestHeaders } from './utils/sentry-redaction' @@ -22,16 +22,11 @@ if (typeof document !== 'undefined') { tracesSampleRate: 1.0, // Capture 100% of the transactions // Set 'tracePropagationTargets' to control for which URLs distributed tracing should be enabled tracePropagationTargets: ['localhost', /^https:\/\/tanstack\.com\//], - beforeSend(event) { + beforeSend(event, hint) { redactByokRequestHeaders(event) - // Stale/failed app chunk loads (e.g. after a deploy or a dropped - // connection) are handled by installStaleAppReloadHandlers, which - // reloads the page. Don't report them as errors. - const exceptionTexts = - event.exception?.values?.map((v) => - [v.type, v.value].filter(Boolean).join(': '), - ) ?? [] - if (exceptionTexts.length > 0 && isStaleAppError(exceptionTexts)) { + // Only suppress the exact error that triggered a reload. Errors during + // the reload cooldown still need reporting. + if (consumeStaleAppRecovery(hint.originalException)) { return null } // Filter out errors from third-party ad tech scripts (e.g. Publift's diff --git a/src/utils/stale-app-reload.ts b/src/utils/stale-app-reload.ts index 6dabf188b..9a1217f51 100644 --- a/src/utils/stale-app-reload.ts +++ b/src/utils/stale-app-reload.ts @@ -17,6 +17,25 @@ const staleAppErrorPatterns = [ 'Seroval Error', ] +const recoveredErrors = new WeakSet() + +export function consumeStaleAppRecovery(error: unknown) { + if (typeof error !== 'object' || error === null) return false + return recoveredErrors.delete(error) +} + +function markReloadedErrors(error: unknown) { + if (Array.isArray(error)) { + error.forEach(markReloadedErrors) + } else if ( + typeof error === 'object' && + error !== null && + isStaleAppError(error) + ) { + recoveredErrors.add(error) + } +} + let handlersInstalled = false let lastReloadAt = 0 @@ -57,6 +76,7 @@ export function reloadOnStaleAppError(error: unknown) { ) { writeLastReloadAt(now) window.location.reload() + markReloadedErrors(error) return true } diff --git a/tests/stale-app-reload.test.ts b/tests/stale-app-reload.test.ts new file mode 100644 index 000000000..92d3092dd --- /dev/null +++ b/tests/stale-app-reload.test.ts @@ -0,0 +1,94 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { BrowserClient, Scope, defaultStackParser } from '@sentry/browser' +import { + consumeStaleAppRecovery, + reloadOnStaleAppError, +} from '../src/utils/stale-app-reload' + +test('Sentry suppresses recovered errors but reports cooldown failures', async (t) => { + const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window') + const storage = new Map() + let now = 20_000 + let reloads = 0 + const location = { + reload: () => { + reloads++ + }, + } + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + location, + sessionStorage: { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + }, + }, + }) + t.after(() => { + if (previousWindow) + Object.defineProperty(globalThis, 'window', previousWindow) + else Reflect.deleteProperty(globalThis, 'window') + }) + t.mock.method(Date, 'now', () => now) + let sent = 0 + const originals: unknown[] = [] + const client = new BrowserClient({ + dsn: 'https://fixture@example.invalid/1', + integrations: [], + stackParser: defaultStackParser, + transport: () => ({ + send: async () => { + sent++ + return { statusCode: 200 } + }, + flush: async () => true, + }), + beforeSend(event, hint) { + originals.push(hint.originalException) + return consumeStaleAppRecovery(hint.originalException) ? null : event + }, + }) + t.after(() => client.close(100)) + const scope = new Scope() + scope.setClient(client) + const first = new Error('Failed to fetch dynamically imported module') + assert.equal(reloadOnStaleAppError([first]), true) + // The boundary can see the same error again before Sentry processes it. + assert.equal(reloadOnStaleAppError(first), false) + scope.captureException(first) + await client.flush(100) + assert.equal(originals[0], first) + assert.equal(sent, 0) + assert.equal(consumeStaleAppRecovery(first), false) + + const second = new Error(first.message) + assert.equal(reloadOnStaleAppError(second), false) + scope.captureException(second) + await client.flush(100) + assert.equal(originals[1], second) + assert.equal(sent, 1) + assert.equal(reloads, 1) + + scope.captureException(new Error('ordinary runtime defect')) + await client.flush(100) + assert.equal(sent, 2) + assert.equal(consumeStaleAppRecovery(first.message), false) + assert.equal(consumeStaleAppRecovery(undefined), false) + + now += 10_001 + const third = new Error(first.message) + assert.equal(reloadOnStaleAppError(third), true) + assert.equal(consumeStaleAppRecovery(third), true) + assert.equal(consumeStaleAppRecovery(third), false) + assert.equal(reloads, 2) + + now += 10_001 + t.mock.method(location, 'reload', () => { + throw new Error('reload blocked') + }) + const blocked = new Error(first.message) + assert.throws(() => reloadOnStaleAppError(blocked), /reload blocked/) + assert.equal(consumeStaleAppRecovery(blocked), false) +})