From 879bccb781af6bfd99498c190b1503cb2efe72ad Mon Sep 17 00:00:00 2001 From: isaacs Date: Thu, 3 Sep 2026 11:31:40 -0700 Subject: [PATCH 1/2] fix(core): do not unnecessarily defer offline transport send `makeOfflineTransport` awaited the `shouldSend` result unconditionally. The option type allows a plain boolean, and awaiting one still costs a microtask tick, so a synchronous callback pushed the call to `transport.send()` into the next turn. Only await when the callback returns a thenable. Hosts such as Douyin mini-games stop JS execution when the app goes to the background, so that one tick was enough to lose the event: the envelope had not reached the transport, and the offline store only gets written in the `catch` branch, which never runs when `shouldSend` returns true. Behavior is unchanged for callbacks that return a promise. fixes #24005 fixes JS-3560 Co-Authored-By: yaoxp --- packages/core/src/transports/offline.ts | 12 ++++++++-- .../core/test/lib/transports/offline.test.ts | 23 +++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/packages/core/src/transports/offline.ts b/packages/core/src/transports/offline.ts index 33877b1c70b3..ae04bf8277fc 100644 --- a/packages/core/src/transports/offline.ts +++ b/packages/core/src/transports/offline.ts @@ -3,6 +3,7 @@ import type { Envelope } from '../types/envelope'; import type { InternalBaseTransportOptions, Transport, TransportMakeRequestResponse } from '../types/transport'; import { debug } from '../utils/debug-logger'; import { envelopeContainsItemType } from '../utils/envelope'; +import { isThenable } from '../utils/is'; import { safeDateNow } from '../utils/randomSafeContext'; import { parseRetryAfterHeader } from '../utils/ratelimit'; import { safeUnref } from '../utils/timer'; @@ -139,8 +140,15 @@ export function makeOfflineTransport( } try { - if (options.shouldSend && (await options.shouldSend(envelope)) === false) { - throw new Error('Envelope not sent because `shouldSend` callback returned false'); + if (options.shouldSend) { + const decision = options.shouldSend(envelope); + // avoid extra microtask tick, as some hosts stop JS execution + // when the app goes to the background. + const shouldSend = isThenable(decision) ? await decision : decision; + + if (shouldSend === false) { + throw new Error('Envelope not sent because `shouldSend` callback returned false'); + } } const result = await transport.send(envelope); diff --git a/packages/core/test/lib/transports/offline.test.ts b/packages/core/test/lib/transports/offline.test.ts index a43a86a2c409..da946fad0102 100644 --- a/packages/core/test/lib/transports/offline.test.ts +++ b/packages/core/test/lib/transports/offline.test.ts @@ -377,6 +377,29 @@ describe('makeOfflineTransport', () => { expect(getCalls()).toEqual(['push']); }); + it('a synchronous shouldSend does not defer the send by a microtask', async () => { + vi.useFakeTimers(); + onTestFinished(() => { + vi.useRealTimers(); + }); + + const { store } = createTestStore(); + const { getSendCount, baseTransport } = createTestTransport({ statusCode: 200 }); + const transport = makeOfflineTransport(baseTransport)({ + ...transportOptions, + createStore: store, + shouldSend: () => true, + }); + + // Some hosts stop JS execution when the app goes to the background. + // They only send what the transport already got. So a synchronous + // `shouldSend` must not push the send into the next microtask. + const result = transport.send(ERROR_ENVELOPE); + expect(getSendCount()).toEqual(1); + + await expect(result).resolves.toEqual({ statusCode: 200 }); + }); + it('should not store client report envelopes on send failure', async () => { const { getCalls, store } = createTestStore(); const { getSendCount, baseTransport } = createTestTransport(new Error()); From 20d52efe8e67d053d56732a8f6a1e95527726b37 Mon Sep 17 00:00:00 2001 From: isaacs Date: Fri, 4 Sep 2026 08:44:13 -0700 Subject: [PATCH 2/2] fixup! fix(core): do not unnecessarily defer offline transport send --- packages/core/src/transports/offline.ts | 4 ++- .../core/test/lib/transports/offline.test.ts | 25 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/core/src/transports/offline.ts b/packages/core/src/transports/offline.ts index ae04bf8277fc..af003380e4ca 100644 --- a/packages/core/src/transports/offline.ts +++ b/packages/core/src/transports/offline.ts @@ -171,7 +171,9 @@ export function makeOfflineTransport( retryDelay = START_DELAY; return result; } catch (e) { - if (await shouldQueue(envelope, e as Error, retryDelay)) { + // do not unnecessarily await if it's not a Promise + const decision = shouldQueue(envelope, e as Error, retryDelay); + if (isThenable(decision) ? await decision : decision) { // If this envelope was a retry, we want to add it to the front of the queue so it's retried again first. if (isRetry) { await store.unshift(envelope); diff --git a/packages/core/test/lib/transports/offline.test.ts b/packages/core/test/lib/transports/offline.test.ts index da946fad0102..e78b6d1bda89 100644 --- a/packages/core/test/lib/transports/offline.test.ts +++ b/packages/core/test/lib/transports/offline.test.ts @@ -400,6 +400,31 @@ describe('makeOfflineTransport', () => { await expect(result).resolves.toEqual({ statusCode: 200 }); }); + it('a synchronous shouldStore does not defer the store by a microtask', async () => { + vi.useFakeTimers(); + onTestFinished(() => { + vi.useRealTimers(); + }); + + const { getCalls, store } = createTestStore(); + const { getSendCount, baseTransport } = createTestTransport({ statusCode: 200 }); + const transport = makeOfflineTransport(baseTransport)({ + ...transportOptions, + createStore: store, + shouldSend: () => false, + shouldStore: () => true, + }); + + // A synchronous `shouldSend` that says no throws in the same tick, + // so the envelope must reach the store before the host can stop JS + // execution. + const result = transport.send(ERROR_ENVELOPE); + expect(getCalls()).toEqual(['push']); + + await expect(result).resolves.toEqual({}); + expect(getSendCount()).toEqual(0); + }); + it('should not store client report envelopes on send failure', async () => { const { getCalls, store } = createTestStore(); const { getSendCount, baseTransport } = createTestTransport(new Error());