From 9006faeb442e3190c498fcb719a0a4a6d60ca318 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Mon, 12 Oct 2026 11:51:08 +0200 Subject: [PATCH 01/13] fix(replay): Attach Session Replay buffer to user feedback Flush the on-error Session Replay buffer when the feedback widget opens and link it to the feedback event on submit, mirroring the web widget and the native iOS form. Previously feedback from the RN form never carried a replay unless replay happened to be in session mode. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 6 + .../core/src/js/feedback/FeedbackForm.tsx | 9 +- packages/core/src/js/replay/mobilereplay.ts | 68 ++++++++++ .../core/test/feedback/FeedbackForm.test.tsx | 12 +- .../core/test/replay/mobilereplay.test.ts | 117 ++++++++++++++++++ 5 files changed, 210 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 12b79ca1c8..a1311a62fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ > make sure you follow our [migration guide](https://docs.sentry.io/platforms/react-native/migration/) first. +## Unreleased + +### Fixes + +- Attach the on-error Session Replay buffer to feedback submitted from the feedback form ([#XXXX](https://github.com/getsentry/sentry-react-native/pull/XXXX)) + ## 8.30.0 ### Features diff --git a/packages/core/src/js/feedback/FeedbackForm.tsx b/packages/core/src/js/feedback/FeedbackForm.tsx index a7e2c2a23a..0b3a11f093 100644 --- a/packages/core/src/js/feedback/FeedbackForm.tsx +++ b/packages/core/src/js/feedback/FeedbackForm.tsx @@ -156,6 +156,7 @@ export class FeedbackForm extends React.Component { this.forceUpdate(); }); + + // Signals the Session Replay integration to upload the buffered replay so it's ready to attach + // when the form is submitted. + getClient()?.emit('openFeedbackWidget'); } /** diff --git a/packages/core/src/js/replay/mobilereplay.ts b/packages/core/src/js/replay/mobilereplay.ts index a2b79a6edc..71e494701c 100644 --- a/packages/core/src/js/replay/mobilereplay.ts +++ b/packages/core/src/js/replay/mobilereplay.ts @@ -1,3 +1,4 @@ +/* oxlint-disable eslint(max-lines) */ import type { Breadcrumb, BreadcrumbHint, @@ -6,6 +7,7 @@ import type { ErrorEvent, Event, EventHint, + FeedbackEvent, Metric, } from '@sentry/core'; @@ -381,6 +383,9 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau // This will be updated when we know the replay ID changes (e.g., after captureReplay) let cachedReplayId: string | null = null; + // Replay uploaded when the feedback widget opened, read back when the feedback is submitted. + let feedbackReplayId: string | null = null; + function updateCachedReplayId(replayId: string | null): void { cachedReplayId = replayId; } @@ -514,6 +519,65 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau } } + // Upload the buffered replay when the widget opens (not on submit) so it finishes in the + // background while the user types. `false` = not a hard crash. This still rolls + // `replaysOnErrorSampleRate` natively, so a sampling miss leaves the feedback without a replay. + function flushReplayForFeedbackWidget(): void { + feedbackReplayId = null; + NATIVE.captureReplay(false) + .then(replayId => { + feedbackReplayId = replayId; + if (replayId) { + updateCachedReplayId(replayId); + } + debug.log( + `[Sentry] ${MOBILE_REPLAY_INTEGRATION_NAME} ${ + replayId ? `flushed replay ${replayId}` : 'no replay' + } for opened feedback widget.`, + ); + }) + .then(undefined, (error: unknown) => { + debug.error(`[Sentry] ${MOBILE_REPLAY_INTEGRATION_NAME} Failed to flush replay for feedback widget`, error); + }); + } + + // `beforeSendFeedback` is emitted synchronously right before the event is sent, so the replay id + // must be resolved synchronously here. That is safe: the id is fixed when recording starts and + // does not change when the replay is uploaded, so an in-flight upload does not matter. + function linkReplayToFeedback(feedback: FeedbackEvent, options?: { includeReplay?: boolean }): void { + if (!options?.includeReplay) { + return; + } + + // Widget feedback already uploaded on open; reuse that id. Direct `captureFeedback` has not + // uploaded yet, so read the current recording id and trigger the upload in the background. + let replayId = feedbackReplayId; + if (!replayId) { + replayId = NATIVE.getCurrentReplayId(); + if (replayId) { + NATIVE.captureReplay(false).then(undefined, (error: unknown) => { + debug.error(`[Sentry] ${MOBILE_REPLAY_INTEGRATION_NAME} Failed to flush replay for feedback`, error); + }); + } + } + + // Consume the id so it can't attach to a later, unrelated feedback. + feedbackReplayId = null; + + if (!replayId) { + debug.log(`[Sentry] ${MOBILE_REPLAY_INTEGRATION_NAME} no active recording to link to feedback.`); + return; + } + + updateCachedReplayId(replayId); + feedback.contexts = feedback.contexts || {}; + feedback.contexts.feedback = { + ...feedback.contexts.feedback, + replay_id: replayId, + }; + debug.log(`[Sentry] ${MOBILE_REPLAY_INTEGRATION_NAME} linked replay ${replayId} to feedback ${feedback.event_id}.`); + } + function setup(client: Client): void { if (!hasHooks(client)) { return; @@ -626,6 +690,10 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau // errors are logged inside flushReplayForSentEvent flushReplayForSentEvent(event).then(undefined, () => {}); }); + + // Attach the Session Replay to user feedback: upload on open, link on submit. + client.on('openFeedbackWidget', flushReplayForFeedbackWidget); + client.on('beforeSendFeedback', linkReplayToFeedback); } function getReplayId(): string | null { diff --git a/packages/core/test/feedback/FeedbackForm.test.tsx b/packages/core/test/feedback/FeedbackForm.test.tsx index 8cbf7ceaf3..d502f15792 100644 --- a/packages/core/test/feedback/FeedbackForm.test.tsx +++ b/packages/core/test/feedback/FeedbackForm.test.tsx @@ -345,12 +345,22 @@ describe('FeedbackForm', () => { message: 'This is a feedback message.', name: 'John Doe', email: 'john.doe@example.com', + source: 'widget', }, - undefined, + { includeReplay: true }, ); }); }); + it('emits openFeedbackWidget on mount so the replay buffer can be primed', () => { + const client = getClient()!; + const emitSpy = jest.spyOn(client, 'emit'); + + render(); + + expect(emitSpy).toHaveBeenCalledWith('openFeedbackWidget'); + }); + it('shows success message when the form is submitted successfully', async () => { const { getByPlaceholderText, getByText } = render(); diff --git a/packages/core/test/replay/mobilereplay.test.ts b/packages/core/test/replay/mobilereplay.test.ts index 60423b9fbf..41a204c3c9 100644 --- a/packages/core/test/replay/mobilereplay.test.ts +++ b/packages/core/test/replay/mobilereplay.test.ts @@ -6,6 +6,7 @@ import type { ErrorEvent, Event, EventHint, + FeedbackEvent, } from '@sentry/core'; import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; @@ -1307,6 +1308,122 @@ describe('Mobile Replay Integration', () => { expect(id3).toBe(cachedReplayId); }); }); + + describe('feedback replay', () => { + const getOpenFeedbackWidget = (): (() => void) => { + const call = mockOn.mock.calls.find(c => c[0] === 'openFeedbackWidget'); + return call![1] as () => void; + }; + + const getBeforeSendFeedback = (): ((feedback: FeedbackEvent, options?: { includeReplay?: boolean }) => void) => { + const call = mockOn.mock.calls.find(c => c[0] === 'beforeSendFeedback'); + return call![1] as (feedback: FeedbackEvent, options?: { includeReplay?: boolean }) => void; + }; + + const makeFeedbackEvent = (source: string = 'widget'): FeedbackEvent => + ({ + event_id: 'test-feedback-id', + type: 'feedback', + contexts: { feedback: { message: 'hi', source } }, + }) as unknown as FeedbackEvent; + + it('registers the openFeedbackWidget and beforeSendFeedback hooks', () => { + const integration = mobileReplayIntegration(); + integration.setup?.(mockClient); + + expect(mockOn.mock.calls.some(c => c[0] === 'openFeedbackWidget')).toBe(true); + expect(mockOn.mock.calls.some(c => c[0] === 'beforeSendFeedback')).toBe(true); + }); + + it('flushes the on-error buffer (not a hard crash) when the widget opens', async () => { + mockCaptureReplay.mockResolvedValue('feedback-replay-id'); + const integration = mobileReplayIntegration(); + integration.setup?.(mockClient); + + getOpenFeedbackWidget()(); + await flushAsync(); + + expect(mockCaptureReplay).toHaveBeenCalledTimes(1); + expect(mockCaptureReplay).toHaveBeenCalledWith(false); + }); + + it('links the replay flushed on widget open to the submitted feedback', async () => { + mockCaptureReplay.mockResolvedValue('feedback-replay-id'); + const integration = mobileReplayIntegration(); + integration.setup?.(mockClient); + + getOpenFeedbackWidget()(); + await flushAsync(); + + const feedback = makeFeedbackEvent('widget'); + getBeforeSendFeedback()(feedback, { includeReplay: true }); + + expect(feedback.contexts.feedback.replay_id).toBe('feedback-replay-id'); + // The widget already flushed on open; submit must not flush again. + expect(mockCaptureReplay).toHaveBeenCalledTimes(1); + }); + + it('does not link a replay when includeReplay is not set', async () => { + mockCaptureReplay.mockResolvedValue('feedback-replay-id'); + const integration = mobileReplayIntegration(); + integration.setup?.(mockClient); + + getOpenFeedbackWidget()(); + await flushAsync(); + + const feedback = makeFeedbackEvent('widget'); + getBeforeSendFeedback()(feedback); + + expect(feedback.contexts.feedback.replay_id).toBeUndefined(); + }); + + it('flushes at submit time for API feedback without a widget open', () => { + mockGetCurrentReplayId.mockReturnValue('buffer-replay-id'); + const integration = mobileReplayIntegration(); + integration.setup?.(mockClient); + mockCaptureReplay.mockClear(); + + const feedback = makeFeedbackEvent('api'); + getBeforeSendFeedback()(feedback, { includeReplay: true }); + + // Buffer id read synchronously for the link (stable across the flush) ... + expect(feedback.contexts.feedback.replay_id).toBe('buffer-replay-id'); + // ... and the upload is triggered fire-and-forget. + expect(mockCaptureReplay).toHaveBeenCalledWith(false); + }); + + it('does not link a replay when there is no active recording', () => { + mockGetCurrentReplayId.mockReturnValue(null); + const integration = mobileReplayIntegration(); + integration.setup?.(mockClient); + mockCaptureReplay.mockClear(); + + const feedback = makeFeedbackEvent('api'); + getBeforeSendFeedback()(feedback, { includeReplay: true }); + + expect(feedback.contexts.feedback.replay_id).toBeUndefined(); + expect(mockCaptureReplay).not.toHaveBeenCalled(); + }); + + it('does not leak a widget replay id onto a later unrelated feedback', async () => { + mockCaptureReplay.mockResolvedValue('feedback-replay-id'); + const integration = mobileReplayIntegration(); + integration.setup?.(mockClient); + + getOpenFeedbackWidget()(); + await flushAsync(); + + // First submit consumes the flushed id. + getBeforeSendFeedback()(makeFeedbackEvent('widget'), { includeReplay: true }); + + // A second submit without a fresh widget open falls back to the current recording id. + mockGetCurrentReplayId.mockReturnValue('later-buffer-id'); + const second = makeFeedbackEvent('widget'); + getBeforeSendFeedback()(second, { includeReplay: true }); + + expect(second.contexts.feedback.replay_id).toBe('later-buffer-id'); + }); + }); }); describe('serializeNetworkDetailUrlsForNative', () => { From 86bbbf5641bf72066b6a6a92fb51f529bc6a2e18 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Mon, 12 Oct 2026 11:52:14 +0200 Subject: [PATCH 02/13] chore: Add PR number to CHANGELOG entry Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61656320ad..68922aa111 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ ### Fixes -- Attach the on-error Session Replay buffer to feedback submitted from the feedback form ([#XXXX](https://github.com/getsentry/sentry-react-native/pull/XXXX)) +- Attach the on-error Session Replay buffer to feedback submitted from the feedback form ([#6870](https://github.com/getsentry/sentry-react-native/pull/6870)) ### Dependencies From 2c229cc63d7b8cd5c59936b36b5496d3fde3b9f9 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Mon, 12 Oct 2026 13:07:59 +0200 Subject: [PATCH 03/13] fix(replay): Harden feedback replay id state against widget race, cancel, and sampling miss Capture the recording id synchronously when the feedback widget opens (so a late flush resolution can't revive a consumed id), and route by feedback source so a cancelled widget never leaks its id to a later captureFeedback. On a sampling miss the id is linked optimistically, consistent with the error path. Co-Authored-By: Claude Opus 4.8 --- packages/core/src/js/replay/mobilereplay.ts | 40 ++++------ .../core/test/replay/mobilereplay.test.ts | 73 +++++++++++++++---- 2 files changed, 75 insertions(+), 38 deletions(-) diff --git a/packages/core/src/js/replay/mobilereplay.ts b/packages/core/src/js/replay/mobilereplay.ts index 71e494701c..a6e6d06840 100644 --- a/packages/core/src/js/replay/mobilereplay.ts +++ b/packages/core/src/js/replay/mobilereplay.ts @@ -383,7 +383,7 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau // This will be updated when we know the replay ID changes (e.g., after captureReplay) let cachedReplayId: string | null = null; - // Replay uploaded when the feedback widget opened, read back when the feedback is submitted. + // Recording id captured when the feedback widget opened, linked to widget feedback on submit. let feedbackReplayId: string | null = null; function updateCachedReplayId(replayId: string | null): void { @@ -519,40 +519,35 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau } } - // Upload the buffered replay when the widget opens (not on submit) so it finishes in the - // background while the user types. `false` = not a hard crash. This still rolls - // `replaysOnErrorSampleRate` natively, so a sampling miss leaves the feedback without a replay. + // Capture the id synchronously on open so a late `.then` can't revive a consumed one, then upload + // the buffer in the background. The upload still rolls `replaysOnErrorSampleRate`; on a miss the + // id is linked optimistically anyway, like the error path (see `tagEventWithReplayId`). function flushReplayForFeedbackWidget(): void { - feedbackReplayId = null; + feedbackReplayId = NATIVE.getCurrentReplayId(); NATIVE.captureReplay(false) - .then(replayId => { - feedbackReplayId = replayId; - if (replayId) { - updateCachedReplayId(replayId); + .then(uploadedId => { + if (uploadedId) { + updateCachedReplayId(uploadedId); } - debug.log( - `[Sentry] ${MOBILE_REPLAY_INTEGRATION_NAME} ${ - replayId ? `flushed replay ${replayId}` : 'no replay' - } for opened feedback widget.`, - ); }) .then(undefined, (error: unknown) => { debug.error(`[Sentry] ${MOBILE_REPLAY_INTEGRATION_NAME} Failed to flush replay for feedback widget`, error); }); } - // `beforeSendFeedback` is emitted synchronously right before the event is sent, so the replay id - // must be resolved synchronously here. That is safe: the id is fixed when recording starts and - // does not change when the replay is uploaded, so an in-flight upload does not matter. + // Emitted synchronously before the event is sent, so resolve the id synchronously. Widget + // feedback uses the id captured on open; other feedback ignores that slot (so a cancelled widget + // can't leak its id) and links the current recording, flushing it now. function linkReplayToFeedback(feedback: FeedbackEvent, options?: { includeReplay?: boolean }): void { if (!options?.includeReplay) { return; } - // Widget feedback already uploaded on open; reuse that id. Direct `captureFeedback` has not - // uploaded yet, so read the current recording id and trigger the upload in the background. - let replayId = feedbackReplayId; - if (!replayId) { + let replayId: string | null; + if (feedback.contexts?.feedback?.source === 'widget') { + replayId = feedbackReplayId; + feedbackReplayId = null; + } else { replayId = NATIVE.getCurrentReplayId(); if (replayId) { NATIVE.captureReplay(false).then(undefined, (error: unknown) => { @@ -561,9 +556,6 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau } } - // Consume the id so it can't attach to a later, unrelated feedback. - feedbackReplayId = null; - if (!replayId) { debug.log(`[Sentry] ${MOBILE_REPLAY_INTEGRATION_NAME} no active recording to link to feedback.`); return; diff --git a/packages/core/test/replay/mobilereplay.test.ts b/packages/core/test/replay/mobilereplay.test.ts index 41a204c3c9..1e9af4835a 100644 --- a/packages/core/test/replay/mobilereplay.test.ts +++ b/packages/core/test/replay/mobilereplay.test.ts @@ -1347,24 +1347,25 @@ describe('Mobile Replay Integration', () => { expect(mockCaptureReplay).toHaveBeenCalledWith(false); }); - it('links the replay flushed on widget open to the submitted feedback', async () => { - mockCaptureReplay.mockResolvedValue('feedback-replay-id'); + it('links the id captured on widget open to the submitted widget feedback', async () => { + mockGetCurrentReplayId.mockReturnValue('open-replay-id'); const integration = mobileReplayIntegration(); integration.setup?.(mockClient); getOpenFeedbackWidget()(); await flushAsync(); + mockCaptureReplay.mockClear(); const feedback = makeFeedbackEvent('widget'); getBeforeSendFeedback()(feedback, { includeReplay: true }); - expect(feedback.contexts.feedback.replay_id).toBe('feedback-replay-id'); - // The widget already flushed on open; submit must not flush again. - expect(mockCaptureReplay).toHaveBeenCalledTimes(1); + expect(feedback.contexts.feedback.replay_id).toBe('open-replay-id'); + // The widget already flushed on open; a widget submit must not flush again. + expect(mockCaptureReplay).not.toHaveBeenCalled(); }); it('does not link a replay when includeReplay is not set', async () => { - mockCaptureReplay.mockResolvedValue('feedback-replay-id'); + mockGetCurrentReplayId.mockReturnValue('open-replay-id'); const integration = mobileReplayIntegration(); integration.setup?.(mockClient); @@ -1405,23 +1406,67 @@ describe('Mobile Replay Integration', () => { expect(mockCaptureReplay).not.toHaveBeenCalled(); }); - it('does not leak a widget replay id onto a later unrelated feedback', async () => { - mockCaptureReplay.mockResolvedValue('feedback-replay-id'); + it('does not leak a cancelled widget id onto later API feedback', async () => { + mockGetCurrentReplayId.mockReturnValue('widget-open-id'); const integration = mobileReplayIntegration(); integration.setup?.(mockClient); - getOpenFeedbackWidget()(); + getOpenFeedbackWidget()(); // widget opens, captures 'widget-open-id' await flushAsync(); - // First submit consumes the flushed id. - getBeforeSendFeedback()(makeFeedbackEvent('widget'), { includeReplay: true }); + // Widget cancelled (no submit). A later direct captureFeedback should use the CURRENT + // recording and flush it, not the stale widget id. + mockGetCurrentReplayId.mockReturnValue('current-buffer-id'); + mockCaptureReplay.mockClear(); + const api = makeFeedbackEvent('api'); + getBeforeSendFeedback()(api, { includeReplay: true }); + + expect(api.contexts.feedback.replay_id).toBe('current-buffer-id'); + expect(mockCaptureReplay).toHaveBeenCalledWith(false); + }); + + it('does not revive a consumed widget id after the open flush resolves', async () => { + mockGetCurrentReplayId.mockReturnValue('widget-open-id'); + let resolveOpenFlush!: (v: string | null) => void; + mockCaptureReplay.mockReturnValueOnce( + new Promise(resolve => { + resolveOpenFlush = resolve; + }), + ); + const integration = mobileReplayIntegration(); + integration.setup?.(mockClient); + + getOpenFeedbackWidget()(); // captures 'widget-open-id' synchronously; open flush pending + + // User submits before the open flush resolves — the id is read synchronously. + const first = makeFeedbackEvent('widget'); + getBeforeSendFeedback()(first, { includeReplay: true }); + expect(first.contexts.feedback.replay_id).toBe('widget-open-id'); + + // The open flush resolves late with a different id; it must not revive the consumed slot. + resolveOpenFlush('late-uploaded-id'); + await flushAsync(); - // A second submit without a fresh widget open falls back to the current recording id. - mockGetCurrentReplayId.mockReturnValue('later-buffer-id'); const second = makeFeedbackEvent('widget'); getBeforeSendFeedback()(second, { includeReplay: true }); + expect(second.contexts.feedback.replay_id).toBeUndefined(); + }); - expect(second.contexts.feedback.replay_id).toBe('later-buffer-id'); + it('links the open-time id even when the widget-open flush is a sampling miss', async () => { + mockGetCurrentReplayId.mockReturnValue('widget-open-id'); + mockCaptureReplay.mockResolvedValue(null); // on-error sampling miss: nothing uploaded + const integration = mobileReplayIntegration(); + integration.setup?.(mockClient); + + getOpenFeedbackWidget()(); + await flushAsync(); + mockCaptureReplay.mockClear(); + + const feedback = makeFeedbackEvent('widget'); + getBeforeSendFeedback()(feedback, { includeReplay: true }); + + expect(feedback.contexts.feedback.replay_id).toBe('widget-open-id'); + expect(mockCaptureReplay).not.toHaveBeenCalled(); }); }); }); From 3bee85e69be15908c65f8e576d0e01b66962a8de Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Mon, 12 Oct 2026 13:55:05 +0200 Subject: [PATCH 04/13] fix(replay): Link the uploaded feedback replay by flush result, handle remount Use the result of the widget-open flush (null on a sampling miss => no link) instead of an eagerly-read id, and start a fresh flush per open so a form remount (screenshot flow) supersedes the previous one instead of overwriting the captured id. Co-Authored-By: Claude Opus 4.8 --- packages/core/src/js/replay/mobilereplay.ts | 29 ++++--- .../core/test/replay/mobilereplay.test.ts | 82 +++++++++++-------- 2 files changed, 68 insertions(+), 43 deletions(-) diff --git a/packages/core/src/js/replay/mobilereplay.ts b/packages/core/src/js/replay/mobilereplay.ts index a6e6d06840..5bddbafbb2 100644 --- a/packages/core/src/js/replay/mobilereplay.ts +++ b/packages/core/src/js/replay/mobilereplay.ts @@ -383,8 +383,11 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau // This will be updated when we know the replay ID changes (e.g., after captureReplay) let cachedReplayId: string | null = null; - // Recording id captured when the feedback widget opened, linked to widget feedback on submit. - let feedbackReplayId: string | null = null; + // The replay upload started when the feedback widget opened. `id` is the uploaded replay id once + // the flush resolves (`null` on a sampling miss). A fresh object per open means a superseded or + // already-consumed flush can't affect a later feedback (its late resolution mutates an object no + // one reads any more). + let feedbackReplayFlush: { id: string | null; resolved: boolean } | null = null; function updateCachedReplayId(replayId: string | null): void { cachedReplayId = replayId; @@ -519,25 +522,29 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau } } - // Capture the id synchronously on open so a late `.then` can't revive a consumed one, then upload - // the buffer in the background. The upload still rolls `replaysOnErrorSampleRate`; on a miss the - // id is linked optimistically anyway, like the error path (see `tagEventWithReplayId`). + // Start uploading the buffer when the widget opens. Each open starts a fresh flush, so a remount + // (e.g. the screenshot flow closes and reopens the form) just supersedes the previous one. function flushReplayForFeedbackWidget(): void { - feedbackReplayId = NATIVE.getCurrentReplayId(); + const flush: { id: string | null; resolved: boolean } = { id: null, resolved: false }; + feedbackReplayFlush = flush; NATIVE.captureReplay(false) .then(uploadedId => { + flush.id = uploadedId; + flush.resolved = true; if (uploadedId) { updateCachedReplayId(uploadedId); } }) .then(undefined, (error: unknown) => { + flush.resolved = true; debug.error(`[Sentry] ${MOBILE_REPLAY_INTEGRATION_NAME} Failed to flush replay for feedback widget`, error); }); } // Emitted synchronously before the event is sent, so resolve the id synchronously. Widget - // feedback uses the id captured on open; other feedback ignores that slot (so a cancelled widget - // can't leak its id) and links the current recording, flushing it now. + // feedback links the id the open flush uploaded — `null` on a sampling miss means no replay was + // sent, so nothing is linked. Other feedback (direct `captureFeedback`) ignores the widget flush + // and links the current recording, flushing it now. function linkReplayToFeedback(feedback: FeedbackEvent, options?: { includeReplay?: boolean }): void { if (!options?.includeReplay) { return; @@ -545,8 +552,10 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau let replayId: string | null; if (feedback.contexts?.feedback?.source === 'widget') { - replayId = feedbackReplayId; - feedbackReplayId = null; + const flush = feedbackReplayFlush; + feedbackReplayFlush = null; + // Fall back to the current id only when the open flush hasn't resolved yet (very fast submit). + replayId = flush?.resolved ? flush.id : NATIVE.getCurrentReplayId(); } else { replayId = NATIVE.getCurrentReplayId(); if (replayId) { diff --git a/packages/core/test/replay/mobilereplay.test.ts b/packages/core/test/replay/mobilereplay.test.ts index 1e9af4835a..e86d125357 100644 --- a/packages/core/test/replay/mobilereplay.test.ts +++ b/packages/core/test/replay/mobilereplay.test.ts @@ -1347,8 +1347,8 @@ describe('Mobile Replay Integration', () => { expect(mockCaptureReplay).toHaveBeenCalledWith(false); }); - it('links the id captured on widget open to the submitted widget feedback', async () => { - mockGetCurrentReplayId.mockReturnValue('open-replay-id'); + it('links the uploaded replay to widget feedback after the open flush resolves', async () => { + mockCaptureReplay.mockResolvedValue('uploaded-id'); const integration = mobileReplayIntegration(); integration.setup?.(mockClient); @@ -1359,13 +1359,13 @@ describe('Mobile Replay Integration', () => { const feedback = makeFeedbackEvent('widget'); getBeforeSendFeedback()(feedback, { includeReplay: true }); - expect(feedback.contexts.feedback.replay_id).toBe('open-replay-id'); + expect(feedback.contexts.feedback.replay_id).toBe('uploaded-id'); // The widget already flushed on open; a widget submit must not flush again. expect(mockCaptureReplay).not.toHaveBeenCalled(); }); it('does not link a replay when includeReplay is not set', async () => { - mockGetCurrentReplayId.mockReturnValue('open-replay-id'); + mockCaptureReplay.mockResolvedValue('uploaded-id'); const integration = mobileReplayIntegration(); integration.setup?.(mockClient); @@ -1387,7 +1387,7 @@ describe('Mobile Replay Integration', () => { const feedback = makeFeedbackEvent('api'); getBeforeSendFeedback()(feedback, { includeReplay: true }); - // Buffer id read synchronously for the link (stable across the flush) ... + // Current recording id linked synchronously ... expect(feedback.contexts.feedback.replay_id).toBe('buffer-replay-id'); // ... and the upload is triggered fire-and-forget. expect(mockCaptureReplay).toHaveBeenCalledWith(false); @@ -1406,16 +1406,49 @@ describe('Mobile Replay Integration', () => { expect(mockCaptureReplay).not.toHaveBeenCalled(); }); - it('does not leak a cancelled widget id onto later API feedback', async () => { - mockGetCurrentReplayId.mockReturnValue('widget-open-id'); + it('does not link a replay to widget feedback when the open flush is a sampling miss', async () => { + mockGetCurrentReplayId.mockReturnValue('buffer-id'); + mockCaptureReplay.mockResolvedValue(null); // on-error sampling miss: nothing uploaded + const integration = mobileReplayIntegration(); + integration.setup?.(mockClient); + + getOpenFeedbackWidget()(); + await flushAsync(); + mockCaptureReplay.mockClear(); + + const feedback = makeFeedbackEvent('widget'); + getBeforeSendFeedback()(feedback, { includeReplay: true }); + + expect(feedback.contexts.feedback.replay_id).toBeUndefined(); + expect(mockCaptureReplay).not.toHaveBeenCalled(); + }); + + it('uses the latest open flush when the form remounts (e.g. screenshot flow)', async () => { + mockCaptureReplay.mockResolvedValueOnce('first-id').mockResolvedValueOnce('second-id'); + const integration = mobileReplayIntegration(); + integration.setup?.(mockClient); + + getOpenFeedbackWidget()(); // first open + await flushAsync(); + getOpenFeedbackWidget()(); // remount supersedes the first flush + await flushAsync(); + + const feedback = makeFeedbackEvent('widget'); + getBeforeSendFeedback()(feedback, { includeReplay: true }); + + expect(feedback.contexts.feedback.replay_id).toBe('second-id'); + }); + + it('does not leak a cancelled widget flush onto later API feedback', async () => { + mockCaptureReplay.mockResolvedValue('widget-upload-id'); const integration = mobileReplayIntegration(); integration.setup?.(mockClient); - getOpenFeedbackWidget()(); // widget opens, captures 'widget-open-id' + getOpenFeedbackWidget()(); // widget opens, starts a flush await flushAsync(); // Widget cancelled (no submit). A later direct captureFeedback should use the CURRENT - // recording and flush it, not the stale widget id. + // recording and flush it, not the widget flush. mockGetCurrentReplayId.mockReturnValue('current-buffer-id'); mockCaptureReplay.mockClear(); const api = makeFeedbackEvent('api'); @@ -1425,8 +1458,8 @@ describe('Mobile Replay Integration', () => { expect(mockCaptureReplay).toHaveBeenCalledWith(false); }); - it('does not revive a consumed widget id after the open flush resolves', async () => { - mockGetCurrentReplayId.mockReturnValue('widget-open-id'); + it('does not revive a consumed widget flush after it resolves late', async () => { + mockGetCurrentReplayId.mockReturnValue('current-id'); let resolveOpenFlush!: (v: string | null) => void; mockCaptureReplay.mockReturnValueOnce( new Promise(resolve => { @@ -1436,37 +1469,20 @@ describe('Mobile Replay Integration', () => { const integration = mobileReplayIntegration(); integration.setup?.(mockClient); - getOpenFeedbackWidget()(); // captures 'widget-open-id' synchronously; open flush pending + getOpenFeedbackWidget()(); // open flush pending (unresolved) - // User submits before the open flush resolves — the id is read synchronously. + // Submit before the flush resolves: falls back to the current recording id. const first = makeFeedbackEvent('widget'); getBeforeSendFeedback()(first, { includeReplay: true }); - expect(first.contexts.feedback.replay_id).toBe('widget-open-id'); + expect(first.contexts.feedback.replay_id).toBe('current-id'); - // The open flush resolves late with a different id; it must not revive the consumed slot. + // The open flush resolves late with a different id; it must not revive the consumed flush. resolveOpenFlush('late-uploaded-id'); await flushAsync(); const second = makeFeedbackEvent('widget'); getBeforeSendFeedback()(second, { includeReplay: true }); - expect(second.contexts.feedback.replay_id).toBeUndefined(); - }); - - it('links the open-time id even when the widget-open flush is a sampling miss', async () => { - mockGetCurrentReplayId.mockReturnValue('widget-open-id'); - mockCaptureReplay.mockResolvedValue(null); // on-error sampling miss: nothing uploaded - const integration = mobileReplayIntegration(); - integration.setup?.(mockClient); - - getOpenFeedbackWidget()(); - await flushAsync(); - mockCaptureReplay.mockClear(); - - const feedback = makeFeedbackEvent('widget'); - getBeforeSendFeedback()(feedback, { includeReplay: true }); - - expect(feedback.contexts.feedback.replay_id).toBe('widget-open-id'); - expect(mockCaptureReplay).not.toHaveBeenCalled(); + expect(second.contexts.feedback.replay_id).toBe('current-id'); }); }); }); From c6f5bd84ceaf1ca550fd75f9db341207a5192f24 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Mon, 12 Oct 2026 14:26:48 +0200 Subject: [PATCH 05/13] fix(replay): Link feedback by pre-capture buffer id and stop stale cache writes Read the buffer id before the widget-open flush and use it as the fallback when a submit precedes the flush resolving (the controller id can already be a new buffer after capture on Android), and stop a superseded/consumed feedback flush from overwriting the shared replay-id cache used by DSCs and metrics. Co-Authored-By: Claude Opus 4.8 --- packages/core/src/js/replay/mobilereplay.ts | 37 ++++++++++-------- .../core/test/replay/mobilereplay.test.ts | 39 ++++++++++++++++--- 2 files changed, 55 insertions(+), 21 deletions(-) diff --git a/packages/core/src/js/replay/mobilereplay.ts b/packages/core/src/js/replay/mobilereplay.ts index 5bddbafbb2..cf38e6dcab 100644 --- a/packages/core/src/js/replay/mobilereplay.ts +++ b/packages/core/src/js/replay/mobilereplay.ts @@ -383,11 +383,12 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau // This will be updated when we know the replay ID changes (e.g., after captureReplay) let cachedReplayId: string | null = null; - // The replay upload started when the feedback widget opened. `id` is the uploaded replay id once - // the flush resolves (`null` on a sampling miss). A fresh object per open means a superseded or - // already-consumed flush can't affect a later feedback (its late resolution mutates an object no - // one reads any more). - let feedbackReplayFlush: { id: string | null; resolved: boolean } | null = null; + // The replay upload started when the feedback widget opened. `openId` is the buffer id read + // before the flush (what it uploads, and the fallback for a submit before it resolves); `id` is + // the uploaded replay id once resolved (`null` on a sampling miss). A fresh object per open means + // a superseded or already-consumed flush can't affect a later feedback — its late resolution + // mutates an object no one reads any more. + let feedbackReplayFlush: { openId: string | null; id: string | null; resolved: boolean } | null = null; function updateCachedReplayId(replayId: string | null): void { cachedReplayId = replayId; @@ -522,18 +523,21 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau } } - // Start uploading the buffer when the widget opens. Each open starts a fresh flush, so a remount - // (e.g. the screenshot flow closes and reopens the form) just supersedes the previous one. + // Start uploading the buffer when the widget opens. Read the buffer id first (before capture may + // restart buffering) so the id we link matches what this flush uploads. Each open starts a fresh + // flush, so a remount (e.g. the screenshot flow closes and reopens the form) supersedes the + // previous one. The flush doesn't touch the shared cache — a superseded one must not stale it. function flushReplayForFeedbackWidget(): void { - const flush: { id: string | null; resolved: boolean } = { id: null, resolved: false }; + const flush: { openId: string | null; id: string | null; resolved: boolean } = { + openId: NATIVE.getCurrentReplayId(), + id: null, + resolved: false, + }; feedbackReplayFlush = flush; NATIVE.captureReplay(false) .then(uploadedId => { flush.id = uploadedId; flush.resolved = true; - if (uploadedId) { - updateCachedReplayId(uploadedId); - } }) .then(undefined, (error: unknown) => { flush.resolved = true; @@ -543,8 +547,9 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau // Emitted synchronously before the event is sent, so resolve the id synchronously. Widget // feedback links the id the open flush uploaded — `null` on a sampling miss means no replay was - // sent, so nothing is linked. Other feedback (direct `captureFeedback`) ignores the widget flush - // and links the current recording, flushing it now. + // sent, so nothing is linked; before it resolves, fall back to the id read at open. Other + // feedback (direct `captureFeedback`) ignores the widget flush and links the current recording, + // flushing it now. function linkReplayToFeedback(feedback: FeedbackEvent, options?: { includeReplay?: boolean }): void { if (!options?.includeReplay) { return; @@ -554,11 +559,12 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau if (feedback.contexts?.feedback?.source === 'widget') { const flush = feedbackReplayFlush; feedbackReplayFlush = null; - // Fall back to the current id only when the open flush hasn't resolved yet (very fast submit). - replayId = flush?.resolved ? flush.id : NATIVE.getCurrentReplayId(); + replayId = flush?.resolved ? flush.id : (flush?.openId ?? null); } else { replayId = NATIVE.getCurrentReplayId(); if (replayId) { + // API feedback links the current recording, so keep the shared cache in step. + updateCachedReplayId(replayId); NATIVE.captureReplay(false).then(undefined, (error: unknown) => { debug.error(`[Sentry] ${MOBILE_REPLAY_INTEGRATION_NAME} Failed to flush replay for feedback`, error); }); @@ -570,7 +576,6 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau return; } - updateCachedReplayId(replayId); feedback.contexts = feedback.contexts || {}; feedback.contexts.feedback = { ...feedback.contexts.feedback, diff --git a/packages/core/test/replay/mobilereplay.test.ts b/packages/core/test/replay/mobilereplay.test.ts index e86d125357..83dd75198b 100644 --- a/packages/core/test/replay/mobilereplay.test.ts +++ b/packages/core/test/replay/mobilereplay.test.ts @@ -1458,8 +1458,24 @@ describe('Mobile Replay Integration', () => { expect(mockCaptureReplay).toHaveBeenCalledWith(false); }); + it('uses the id read at open (not at submit) when the flush is still pending', () => { + mockGetCurrentReplayId.mockReturnValue('open-buffer'); + mockCaptureReplay.mockReturnValueOnce(new Promise(() => {})); // never resolves + const integration = mobileReplayIntegration(); + integration.setup?.(mockClient); + + getOpenFeedbackWidget()(); // openId = 'open-buffer' + + // The native buffer rolls over before the user submits. + mockGetCurrentReplayId.mockReturnValue('new-buffer'); + const feedback = makeFeedbackEvent('widget'); + getBeforeSendFeedback()(feedback, { includeReplay: true }); + + expect(feedback.contexts.feedback.replay_id).toBe('open-buffer'); + }); + it('does not revive a consumed widget flush after it resolves late', async () => { - mockGetCurrentReplayId.mockReturnValue('current-id'); + mockGetCurrentReplayId.mockReturnValue('open-buffer'); let resolveOpenFlush!: (v: string | null) => void; mockCaptureReplay.mockReturnValueOnce( new Promise(resolve => { @@ -1471,18 +1487,31 @@ describe('Mobile Replay Integration', () => { getOpenFeedbackWidget()(); // open flush pending (unresolved) - // Submit before the flush resolves: falls back to the current recording id. + // Submit before the flush resolves: falls back to the id read at open. const first = makeFeedbackEvent('widget'); getBeforeSendFeedback()(first, { includeReplay: true }); - expect(first.contexts.feedback.replay_id).toBe('current-id'); + expect(first.contexts.feedback.replay_id).toBe('open-buffer'); - // The open flush resolves late with a different id; it must not revive the consumed flush. + // The open flush resolves late; it must not revive the consumed flush for a later feedback. resolveOpenFlush('late-uploaded-id'); await flushAsync(); const second = makeFeedbackEvent('widget'); getBeforeSendFeedback()(second, { includeReplay: true }); - expect(second.contexts.feedback.replay_id).toBe('current-id'); + expect(second.contexts.feedback.replay_id).toBeUndefined(); + }); + + it('does not overwrite the cached replay id from a widget flush', async () => { + mockGetCurrentReplayId.mockReturnValue('current-id'); + mockCaptureReplay.mockResolvedValue('uploaded-id'); + const integration = mobileReplayIntegration(); + integration.setup?.(mockClient); // cache initialized to 'current-id' + + getOpenFeedbackWidget()(); + await flushAsync(); + + // The flush uploaded 'uploaded-id', but the shared cache (DSC/metrics) stays the current id. + expect(integration.getReplayId()).toBe('current-id'); }); }); }); From 549b28f58561885d78a1f1b4b021278081aea0f0 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Mon, 12 Oct 2026 14:57:29 +0200 Subject: [PATCH 06/13] fix(replay): Invalidate the replay-id cache after a feedback widget flush captureReplay can change the native replay identity, so drop the cached id when the widget-open flush settles (the next DSC/metric re-reads the current one) rather than leaving the stale pre-flush id in the cache. Co-Authored-By: Claude Opus 4.8 --- packages/core/src/js/replay/mobilereplay.ts | 10 +++++++--- packages/core/test/replay/mobilereplay.test.ts | 13 ++++++++----- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/packages/core/src/js/replay/mobilereplay.ts b/packages/core/src/js/replay/mobilereplay.ts index cf38e6dcab..310243faca 100644 --- a/packages/core/src/js/replay/mobilereplay.ts +++ b/packages/core/src/js/replay/mobilereplay.ts @@ -526,7 +526,7 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau // Start uploading the buffer when the widget opens. Read the buffer id first (before capture may // restart buffering) so the id we link matches what this flush uploads. Each open starts a fresh // flush, so a remount (e.g. the screenshot flow closes and reopens the form) supersedes the - // previous one. The flush doesn't touch the shared cache — a superseded one must not stale it. + // previous one. function flushReplayForFeedbackWidget(): void { const flush: { openId: string | null; id: string | null; resolved: boolean } = { openId: NATIVE.getCurrentReplayId(), @@ -537,11 +537,15 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau NATIVE.captureReplay(false) .then(uploadedId => { flush.id = uploadedId; - flush.resolved = true; }) .then(undefined, (error: unknown) => { - flush.resolved = true; debug.error(`[Sentry] ${MOBILE_REPLAY_INTEGRATION_NAME} Failed to flush replay for feedback widget`, error); + }) + .then(() => { + flush.resolved = true; + // Capture may change the native replay identity, so drop the cached id rather than caching + // the uploaded one; the next DSC/metric re-reads the current id (as the runtime controls do). + invalidateCachedReplayId(); }); } diff --git a/packages/core/test/replay/mobilereplay.test.ts b/packages/core/test/replay/mobilereplay.test.ts index 83dd75198b..57aed363ce 100644 --- a/packages/core/test/replay/mobilereplay.test.ts +++ b/packages/core/test/replay/mobilereplay.test.ts @@ -1501,17 +1501,20 @@ describe('Mobile Replay Integration', () => { expect(second.contexts.feedback.replay_id).toBeUndefined(); }); - it('does not overwrite the cached replay id from a widget flush', async () => { - mockGetCurrentReplayId.mockReturnValue('current-id'); + it('invalidates the cached replay id after a widget flush instead of caching the uploaded id', async () => { + mockGetCurrentReplayId.mockReturnValue('id-before'); mockCaptureReplay.mockResolvedValue('uploaded-id'); const integration = mobileReplayIntegration(); - integration.setup?.(mockClient); // cache initialized to 'current-id' + integration.setup?.(mockClient); // cache initialized to 'id-before' getOpenFeedbackWidget()(); + // The native replay identity changes during the capture. + mockGetCurrentReplayId.mockReturnValue('id-after'); await flushAsync(); - // The flush uploaded 'uploaded-id', but the shared cache (DSC/metrics) stays the current id. - expect(integration.getReplayId()).toBe('current-id'); + // The flush invalidated the cache, so the next read reflects the current native id — neither + // the stale pre-flush id nor the uploaded id. + expect(integration.getReplayId()).toBe('id-after'); }); }); }); From 00c3ca12440b5ecb15ee7a3a6af418760027f6a5 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Mon, 12 Oct 2026 15:13:26 +0200 Subject: [PATCH 07/13] fix(replay): Avoid floating promise in feedback widget flush End the capture chain with a rejection handler (no trailing `.then`) so the no-floating-promises lint passes; the settle step runs in both branches. Co-Authored-By: Claude Opus 4.8 --- packages/core/src/js/replay/mobilereplay.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/core/src/js/replay/mobilereplay.ts b/packages/core/src/js/replay/mobilereplay.ts index 310243faca..801259cf17 100644 --- a/packages/core/src/js/replay/mobilereplay.ts +++ b/packages/core/src/js/replay/mobilereplay.ts @@ -534,18 +534,21 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau resolved: false, }; feedbackReplayFlush = flush; + // On settle, mark resolved and drop the cached id: capture may change the native replay + // identity, so the next DSC/metric re-reads the current id (as the runtime controls do) rather + // than caching the uploaded one. + const settle = (): void => { + flush.resolved = true; + invalidateCachedReplayId(); + }; NATIVE.captureReplay(false) .then(uploadedId => { flush.id = uploadedId; + settle(); }) .then(undefined, (error: unknown) => { + settle(); debug.error(`[Sentry] ${MOBILE_REPLAY_INTEGRATION_NAME} Failed to flush replay for feedback widget`, error); - }) - .then(() => { - flush.resolved = true; - // Capture may change the native replay identity, so drop the cached id rather than caching - // the uploaded one; the next DSC/metric re-reads the current id (as the runtime controls do). - invalidateCachedReplayId(); }); } From 353ec57da81ea665d17b5ee098c4ce3b973fbadb Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Mon, 12 Oct 2026 15:34:46 +0200 Subject: [PATCH 08/13] fix(replay): Invalidate the replay-id cache after an API feedback flush too The direct captureFeedback path flushes the buffer but left the cached id stale; invalidate it on settle like the widget flush, so DSCs and metrics re-read the current native id instead of the pre-flush one. Co-Authored-By: Claude Opus 4.8 --- packages/core/src/js/replay/mobilereplay.ts | 16 +++++++++++----- packages/core/test/replay/mobilereplay.test.ts | 17 +++++++++++++++++ 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/packages/core/src/js/replay/mobilereplay.ts b/packages/core/src/js/replay/mobilereplay.ts index 801259cf17..b554490b71 100644 --- a/packages/core/src/js/replay/mobilereplay.ts +++ b/packages/core/src/js/replay/mobilereplay.ts @@ -570,11 +570,17 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau } else { replayId = NATIVE.getCurrentReplayId(); if (replayId) { - // API feedback links the current recording, so keep the shared cache in step. - updateCachedReplayId(replayId); - NATIVE.captureReplay(false).then(undefined, (error: unknown) => { - debug.error(`[Sentry] ${MOBILE_REPLAY_INTEGRATION_NAME} Failed to flush replay for feedback`, error); - }); + // Flush the current buffer and, like the widget flush, invalidate the cached id on settle: + // capture may change the native replay identity, so the next DSC/metric re-reads it. + NATIVE.captureReplay(false).then( + () => { + invalidateCachedReplayId(); + }, + (error: unknown) => { + invalidateCachedReplayId(); + debug.error(`[Sentry] ${MOBILE_REPLAY_INTEGRATION_NAME} Failed to flush replay for feedback`, error); + }, + ); } } diff --git a/packages/core/test/replay/mobilereplay.test.ts b/packages/core/test/replay/mobilereplay.test.ts index 57aed363ce..bd1cba0fe9 100644 --- a/packages/core/test/replay/mobilereplay.test.ts +++ b/packages/core/test/replay/mobilereplay.test.ts @@ -1516,6 +1516,23 @@ describe('Mobile Replay Integration', () => { // the stale pre-flush id nor the uploaded id. expect(integration.getReplayId()).toBe('id-after'); }); + + it('invalidates the cached replay id after an API feedback flush', async () => { + mockGetCurrentReplayId.mockReturnValue('id-before'); + mockCaptureReplay.mockResolvedValue('uploaded-id'); + const integration = mobileReplayIntegration(); + integration.setup?.(mockClient); // cache initialized to 'id-before' + + const feedback = makeFeedbackEvent('api'); + getBeforeSendFeedback()(feedback, { includeReplay: true }); + expect(feedback.contexts.feedback.replay_id).toBe('id-before'); + + // The native replay identity changes during the capture. + mockGetCurrentReplayId.mockReturnValue('id-after'); + await flushAsync(); + + expect(integration.getReplayId()).toBe('id-after'); + }); }); }); From 4bdd11f8658281479a0414d94fada7afe50c3259 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Mon, 12 Oct 2026 16:39:01 +0200 Subject: [PATCH 09/13] fix(replay): Isolate feedback replay hooks and preserve carried replay id Guard the openFeedbackWidget and beforeSendFeedback listeners so a synchronous bridge throw from getCurrentReplayId/captureReplay cannot propagate out of the widget's componentDidMount or abort feedback capture, matching the beforeSend wrapper. Also carry forward a replay id an earlier widget open already uploaded so a remount whose own flush uploads nothing links it instead of discarding it. Co-Authored-By: Claude Opus 4.8 --- packages/core/src/js/replay/mobilereplay.ts | 102 ++++++++++-------- .../core/test/replay/mobilereplay.test.ts | 44 ++++++++ 2 files changed, 102 insertions(+), 44 deletions(-) diff --git a/packages/core/src/js/replay/mobilereplay.ts b/packages/core/src/js/replay/mobilereplay.ts index b554490b71..4c6ae9ef65 100644 --- a/packages/core/src/js/replay/mobilereplay.ts +++ b/packages/core/src/js/replay/mobilereplay.ts @@ -525,63 +525,77 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau // Start uploading the buffer when the widget opens. Read the buffer id first (before capture may // restart buffering) so the id we link matches what this flush uploads. Each open starts a fresh - // flush, so a remount (e.g. the screenshot flow closes and reopens the form) supersedes the - // previous one. + // flush but carries forward an id a previous open already uploaded, so a remount (e.g. the + // screenshot flow closes and reopens the form) whose own flush uploads nothing still links it. + // Wrapped: a synchronous bridge throw must not propagate out of the widget's `componentDidMount`. function flushReplayForFeedbackWidget(): void { - const flush: { openId: string | null; id: string | null; resolved: boolean } = { - openId: NATIVE.getCurrentReplayId(), - id: null, - resolved: false, - }; - feedbackReplayFlush = flush; - // On settle, mark resolved and drop the cached id: capture may change the native replay - // identity, so the next DSC/metric re-reads the current id (as the runtime controls do) rather - // than caching the uploaded one. - const settle = (): void => { - flush.resolved = true; - invalidateCachedReplayId(); - }; - NATIVE.captureReplay(false) - .then(uploadedId => { - flush.id = uploadedId; - settle(); - }) - .then(undefined, (error: unknown) => { - settle(); - debug.error(`[Sentry] ${MOBILE_REPLAY_INTEGRATION_NAME} Failed to flush replay for feedback widget`, error); - }); + try { + const carried = feedbackReplayFlush?.resolved ? feedbackReplayFlush.id : null; + const flush: { openId: string | null; id: string | null; resolved: boolean } = { + openId: NATIVE.getCurrentReplayId() ?? carried, + id: carried, + resolved: false, + }; + feedbackReplayFlush = flush; + // On settle, mark resolved and drop the cached id: capture may change the native replay + // identity, so the next DSC/metric re-reads the current id (as the runtime controls do) rather + // than caching the uploaded one. + const settle = (): void => { + flush.resolved = true; + invalidateCachedReplayId(); + }; + NATIVE.captureReplay(false) + .then(uploadedId => { + // Keep the carried id when this open uploaded nothing, so it isn't discarded. + if (uploadedId) { + flush.id = uploadedId; + } + settle(); + }) + .then(undefined, (error: unknown) => { + settle(); + debug.error(`[Sentry] ${MOBILE_REPLAY_INTEGRATION_NAME} Failed to flush replay for feedback widget`, error); + }); + } catch (error) { + debug.error(`[Sentry] ${MOBILE_REPLAY_INTEGRATION_NAME} Failed to start replay flush for feedback widget`, error); + } } // Emitted synchronously before the event is sent, so resolve the id synchronously. Widget // feedback links the id the open flush uploaded — `null` on a sampling miss means no replay was // sent, so nothing is linked; before it resolves, fall back to the id read at open. Other // feedback (direct `captureFeedback`) ignores the widget flush and links the current recording, - // flushing it now. + // flushing it now. Wrapped: a synchronous bridge throw must not abort feedback capture. function linkReplayToFeedback(feedback: FeedbackEvent, options?: { includeReplay?: boolean }): void { if (!options?.includeReplay) { return; } - let replayId: string | null; - if (feedback.contexts?.feedback?.source === 'widget') { - const flush = feedbackReplayFlush; - feedbackReplayFlush = null; - replayId = flush?.resolved ? flush.id : (flush?.openId ?? null); - } else { - replayId = NATIVE.getCurrentReplayId(); - if (replayId) { - // Flush the current buffer and, like the widget flush, invalidate the cached id on settle: - // capture may change the native replay identity, so the next DSC/metric re-reads it. - NATIVE.captureReplay(false).then( - () => { - invalidateCachedReplayId(); - }, - (error: unknown) => { - invalidateCachedReplayId(); - debug.error(`[Sentry] ${MOBILE_REPLAY_INTEGRATION_NAME} Failed to flush replay for feedback`, error); - }, - ); + let replayId: string | null = null; + try { + if (feedback.contexts?.feedback?.source === 'widget') { + const flush = feedbackReplayFlush; + feedbackReplayFlush = null; + replayId = flush?.resolved ? flush.id : (flush?.openId ?? null); + } else { + replayId = NATIVE.getCurrentReplayId(); + if (replayId) { + // Flush the current buffer and, like the widget flush, invalidate the cached id on settle: + // capture may change the native replay identity, so the next DSC/metric re-reads it. + NATIVE.captureReplay(false).then( + () => { + invalidateCachedReplayId(); + }, + (error: unknown) => { + invalidateCachedReplayId(); + debug.error(`[Sentry] ${MOBILE_REPLAY_INTEGRATION_NAME} Failed to flush replay for feedback`, error); + }, + ); + } } + } catch (error) { + debug.error(`[Sentry] ${MOBILE_REPLAY_INTEGRATION_NAME} Failed to resolve replay for feedback`, error); + return; } if (!replayId) { diff --git a/packages/core/test/replay/mobilereplay.test.ts b/packages/core/test/replay/mobilereplay.test.ts index bd1cba0fe9..c1119fdfc5 100644 --- a/packages/core/test/replay/mobilereplay.test.ts +++ b/packages/core/test/replay/mobilereplay.test.ts @@ -1533,6 +1533,50 @@ describe('Mobile Replay Integration', () => { expect(integration.getReplayId()).toBe('id-after'); }); + + it('keeps a replay an earlier open uploaded when a remount flush uploads nothing', async () => { + // First open uploads a replay; the remount's flush uploads nothing (e.g. buffer already + // consumed). The earlier id must be linked, not discarded. + mockCaptureReplay.mockResolvedValueOnce('first-id').mockResolvedValueOnce(null); + mockGetCurrentReplayId.mockReturnValue(null); + const integration = mobileReplayIntegration(); + integration.setup?.(mockClient); + + getOpenFeedbackWidget()(); // first open uploads 'first-id' + await flushAsync(); + getOpenFeedbackWidget()(); // remount, its own flush uploads nothing + await flushAsync(); + + const feedback = makeFeedbackEvent('widget'); + getBeforeSendFeedback()(feedback, { includeReplay: true }); + + expect(feedback.contexts.feedback.replay_id).toBe('first-id'); + }); + + it('does not propagate a synchronous bridge error when the widget opens', () => { + const integration = mobileReplayIntegration(); + integration.setup?.(mockClient); + + // The bridge starts throwing only after setup (which reads the id once). + mockGetCurrentReplayId.mockImplementation(() => { + throw new Error('bridge boom'); + }); + + expect(() => getOpenFeedbackWidget()()).not.toThrow(); + }); + + it('does not abort feedback capture when a synchronous bridge error occurs', () => { + const integration = mobileReplayIntegration(); + integration.setup?.(mockClient); + + mockGetCurrentReplayId.mockImplementation(() => { + throw new Error('bridge boom'); + }); + + const feedback = makeFeedbackEvent('api'); + expect(() => getBeforeSendFeedback()(feedback, { includeReplay: true })).not.toThrow(); + expect(feedback.contexts.feedback.replay_id).toBeUndefined(); + }); }); }); From 6bb871e95ef44d18cd19dcb9e608c0f1a8fbfa64 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Mon, 12 Oct 2026 17:05:01 +0200 Subject: [PATCH 10/13] fix(replay): Reuse the in-flight feedback flush on form remount A remount while the first open's captureReplay is still pending previously replaced the in-flight flush with a fresh one, discarding the pending upload so submit could link nothing. Keep the in-flight flush and only start a new one once the previous has settled, alongside the existing carry-forward of a completed upload. Co-Authored-By: Claude Opus 4.8 --- packages/core/src/js/replay/mobilereplay.ts | 14 +++++++--- .../core/test/replay/mobilereplay.test.ts | 27 +++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/packages/core/src/js/replay/mobilereplay.ts b/packages/core/src/js/replay/mobilereplay.ts index 4c6ae9ef65..1962bdae31 100644 --- a/packages/core/src/js/replay/mobilereplay.ts +++ b/packages/core/src/js/replay/mobilereplay.ts @@ -524,12 +524,18 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau } // Start uploading the buffer when the widget opens. Read the buffer id first (before capture may - // restart buffering) so the id we link matches what this flush uploads. Each open starts a fresh - // flush but carries forward an id a previous open already uploaded, so a remount (e.g. the - // screenshot flow closes and reopens the form) whose own flush uploads nothing still links it. - // Wrapped: a synchronous bridge throw must not propagate out of the widget's `componentDidMount`. + // restart buffering) so the id we link matches what this flush uploads. A remount (e.g. the + // screenshot flow closes and reopens the form) reuses a flush that is still in flight, and + // otherwise carries forward an id a previous open already uploaded, so neither a pending nor a + // completed upload is discarded. Wrapped: a synchronous bridge throw must not propagate out of + // the widget's `componentDidMount`. function flushReplayForFeedbackWidget(): void { try { + // A flush from an earlier open in the same flow is still uploading: keep it rather than + // starting another, so its pending upload isn't discarded. + if (feedbackReplayFlush && !feedbackReplayFlush.resolved) { + return; + } const carried = feedbackReplayFlush?.resolved ? feedbackReplayFlush.id : null; const flush: { openId: string | null; id: string | null; resolved: boolean } = { openId: NATIVE.getCurrentReplayId() ?? carried, diff --git a/packages/core/test/replay/mobilereplay.test.ts b/packages/core/test/replay/mobilereplay.test.ts index c1119fdfc5..ab2b9a96e0 100644 --- a/packages/core/test/replay/mobilereplay.test.ts +++ b/packages/core/test/replay/mobilereplay.test.ts @@ -1553,6 +1553,33 @@ describe('Mobile Replay Integration', () => { expect(feedback.contexts.feedback.replay_id).toBe('first-id'); }); + it('keeps the in-flight open flush when the form remounts before it settles', async () => { + // The first open's capture is still pending when the form remounts. + let resolveFirst!: (v: string | null) => void; + mockCaptureReplay.mockReturnValueOnce( + new Promise(resolve => { + resolveFirst = resolve; + }), + ); + mockGetCurrentReplayId.mockReturnValue(null); + const integration = mobileReplayIntegration(); + integration.setup?.(mockClient); + + getOpenFeedbackWidget()(); // first open, flush in flight + getOpenFeedbackWidget()(); // remount before the first settles + + // The remount reuses the in-flight flush instead of starting another. + expect(mockCaptureReplay).toHaveBeenCalledTimes(1); + + resolveFirst('in-flight-id'); + await flushAsync(); + + const feedback = makeFeedbackEvent('widget'); + getBeforeSendFeedback()(feedback, { includeReplay: true }); + + expect(feedback.contexts.feedback.replay_id).toBe('in-flight-id'); + }); + it('does not propagate a synchronous bridge error when the widget opens', () => { const integration = mobileReplayIntegration(); integration.setup?.(mockClient); From 6f659d764b702c46fb2fdc98331d8228ec76a2fc Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Tue, 13 Oct 2026 10:05:11 +0200 Subject: [PATCH 11/13] fix(replay): Do not link a dismissed form's replay to a later feedback A new feedback-widget open no longer inherits a previous open's completed replay id. Carrying it forward meant a later open whose own capture uploaded nothing (e.g. a sampling miss) would still link the earlier id, attaching a replay from a form the user had already dismissed. Each open now links only what its own capture uploads; an in-flight flush is still reused on remount so a pending upload is not discarded. Co-Authored-By: Claude Opus 4.8 --- packages/core/src/js/replay/mobilereplay.ts | 23 ++++++++----------- .../core/test/replay/mobilereplay.test.ts | 15 ++++++------ 2 files changed, 17 insertions(+), 21 deletions(-) diff --git a/packages/core/src/js/replay/mobilereplay.ts b/packages/core/src/js/replay/mobilereplay.ts index 1962bdae31..b9b73f1520 100644 --- a/packages/core/src/js/replay/mobilereplay.ts +++ b/packages/core/src/js/replay/mobilereplay.ts @@ -524,22 +524,20 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau } // Start uploading the buffer when the widget opens. Read the buffer id first (before capture may - // restart buffering) so the id we link matches what this flush uploads. A remount (e.g. the - // screenshot flow closes and reopens the form) reuses a flush that is still in flight, and - // otherwise carries forward an id a previous open already uploaded, so neither a pending nor a - // completed upload is discarded. Wrapped: a synchronous bridge throw must not propagate out of - // the widget's `componentDidMount`. + // restart buffering) so the id we link matches what this flush uploads. Each open starts a fresh + // flush, except that a remount while an earlier open's upload is still in flight (e.g. the + // screenshot flow closes and reopens the form) reuses it rather than discarding the pending + // upload. A new open never inherits a previous open's completed id: that id belongs to the form + // that opened it, so reusing it could link a replay from a form the user already dismissed. + // Wrapped: a synchronous bridge throw must not propagate out of the widget's `componentDidMount`. function flushReplayForFeedbackWidget(): void { try { - // A flush from an earlier open in the same flow is still uploading: keep it rather than - // starting another, so its pending upload isn't discarded. if (feedbackReplayFlush && !feedbackReplayFlush.resolved) { return; } - const carried = feedbackReplayFlush?.resolved ? feedbackReplayFlush.id : null; const flush: { openId: string | null; id: string | null; resolved: boolean } = { - openId: NATIVE.getCurrentReplayId() ?? carried, - id: carried, + openId: NATIVE.getCurrentReplayId(), + id: null, resolved: false, }; feedbackReplayFlush = flush; @@ -552,10 +550,7 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau }; NATIVE.captureReplay(false) .then(uploadedId => { - // Keep the carried id when this open uploaded nothing, so it isn't discarded. - if (uploadedId) { - flush.id = uploadedId; - } + flush.id = uploadedId; settle(); }) .then(undefined, (error: unknown) => { diff --git a/packages/core/test/replay/mobilereplay.test.ts b/packages/core/test/replay/mobilereplay.test.ts index ab2b9a96e0..04c388fd1a 100644 --- a/packages/core/test/replay/mobilereplay.test.ts +++ b/packages/core/test/replay/mobilereplay.test.ts @@ -1534,23 +1534,24 @@ describe('Mobile Replay Integration', () => { expect(integration.getReplayId()).toBe('id-after'); }); - it('keeps a replay an earlier open uploaded when a remount flush uploads nothing', async () => { - // First open uploads a replay; the remount's flush uploads nothing (e.g. buffer already - // consumed). The earlier id must be linked, not discarded. - mockCaptureReplay.mockResolvedValueOnce('first-id').mockResolvedValueOnce(null); + it('does not link a replay from a dismissed form when a later open uploads nothing', async () => { + // First open (a form the user dismisses) uploads a replay; a later open's flush uploads + // nothing (e.g. a sampling miss). The later submission must not inherit the dismissed form's + // replay id - it belongs to a form the user already closed. + mockCaptureReplay.mockResolvedValueOnce('dismissed-form-id').mockResolvedValueOnce(null); mockGetCurrentReplayId.mockReturnValue(null); const integration = mobileReplayIntegration(); integration.setup?.(mockClient); - getOpenFeedbackWidget()(); // first open uploads 'first-id' + getOpenFeedbackWidget()(); // first open uploads 'dismissed-form-id' await flushAsync(); - getOpenFeedbackWidget()(); // remount, its own flush uploads nothing + getOpenFeedbackWidget()(); // later open, its own flush uploads nothing await flushAsync(); const feedback = makeFeedbackEvent('widget'); getBeforeSendFeedback()(feedback, { includeReplay: true }); - expect(feedback.contexts.feedback.replay_id).toBe('first-id'); + expect(feedback.contexts.feedback.replay_id).toBeUndefined(); }); it('keeps the in-flight open flush when the form remounts before it settles', async () => { From 661681b7ccec2b38c8eb66b7bdf0865b6dd8f764 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Tue, 13 Oct 2026 11:03:27 +0200 Subject: [PATCH 12/13] fix(replay): Keep a draft's uploaded replay across a feedback form reopen A widget reopen resumes the same draft (the screenshot flow closes and reopens the form, restoring the draft; only a submit discards it), so it carries the replay it already uploaded forward - upgrading to its own if that capture uploads a fresher one. This stops a reopen whose capture uploads nothing (e.g. an on-error sampling miss) from dropping the replay already sent for the draft. The flush is cleared on submit, so an unrelated later feedback never inherits it. Co-Authored-By: Claude Opus 4.8 --- packages/core/src/js/replay/mobilereplay.ts | 24 +++++++----- .../core/test/replay/mobilereplay.test.ts | 37 +++++++++++++++---- 2 files changed, 44 insertions(+), 17 deletions(-) diff --git a/packages/core/src/js/replay/mobilereplay.ts b/packages/core/src/js/replay/mobilereplay.ts index b9b73f1520..5914bd90a0 100644 --- a/packages/core/src/js/replay/mobilereplay.ts +++ b/packages/core/src/js/replay/mobilereplay.ts @@ -524,20 +524,23 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau } // Start uploading the buffer when the widget opens. Read the buffer id first (before capture may - // restart buffering) so the id we link matches what this flush uploads. Each open starts a fresh - // flush, except that a remount while an earlier open's upload is still in flight (e.g. the - // screenshot flow closes and reopens the form) reuses it rather than discarding the pending - // upload. A new open never inherits a previous open's completed id: that id belongs to the form - // that opened it, so reusing it could link a replay from a form the user already dismissed. - // Wrapped: a synchronous bridge throw must not propagate out of the widget's `componentDidMount`. + // restart buffering) so the id we link matches what this flush uploads. A reopen is the same draft + // resumed (the screenshot flow closes and reopens the form, restoring the draft; only a submit + // discards it), so it continues the earlier flush: while its upload is still in flight the reopen + // reuses it, and once it has settled the reopen carries its uploaded id forward (upgrading to its + // own if that capture uploads a fresher replay). That keeps the replay already sent for this draft + // instead of dropping it on a reopen that uploads nothing (e.g. an on-error sampling miss). The + // flush is cleared on submit, so an unrelated later feedback never inherits it. Wrapped: a + // synchronous bridge throw must not propagate out of the widget's `componentDidMount`. function flushReplayForFeedbackWidget(): void { try { if (feedbackReplayFlush && !feedbackReplayFlush.resolved) { return; } + const carried = feedbackReplayFlush?.resolved ? feedbackReplayFlush.id : null; const flush: { openId: string | null; id: string | null; resolved: boolean } = { - openId: NATIVE.getCurrentReplayId(), - id: null, + openId: NATIVE.getCurrentReplayId() ?? carried, + id: carried, resolved: false, }; feedbackReplayFlush = flush; @@ -550,7 +553,10 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau }; NATIVE.captureReplay(false) .then(uploadedId => { - flush.id = uploadedId; + // Keep the carried id when this reopen uploaded nothing, so it isn't dropped. + if (uploadedId) { + flush.id = uploadedId; + } settle(); }) .then(undefined, (error: unknown) => { diff --git a/packages/core/test/replay/mobilereplay.test.ts b/packages/core/test/replay/mobilereplay.test.ts index 04c388fd1a..02b07f807a 100644 --- a/packages/core/test/replay/mobilereplay.test.ts +++ b/packages/core/test/replay/mobilereplay.test.ts @@ -1534,24 +1534,45 @@ describe('Mobile Replay Integration', () => { expect(integration.getReplayId()).toBe('id-after'); }); - it('does not link a replay from a dismissed form when a later open uploads nothing', async () => { - // First open (a form the user dismisses) uploads a replay; a later open's flush uploads - // nothing (e.g. a sampling miss). The later submission must not inherit the dismissed form's - // replay id - it belongs to a form the user already closed. - mockCaptureReplay.mockResolvedValueOnce('dismissed-form-id').mockResolvedValueOnce(null); + it('keeps the replay a draft already uploaded when a reopen flush uploads nothing', async () => { + // The form reopens for the same draft (e.g. the screenshot flow). The first open uploaded a + // replay; the reopen's flush uploads nothing (e.g. a sampling miss). The submission must still + // link the replay already sent for this draft rather than drop it. + mockCaptureReplay.mockResolvedValueOnce('first-id').mockResolvedValueOnce(null); mockGetCurrentReplayId.mockReturnValue(null); const integration = mobileReplayIntegration(); integration.setup?.(mockClient); - getOpenFeedbackWidget()(); // first open uploads 'dismissed-form-id' + getOpenFeedbackWidget()(); // first open uploads 'first-id' await flushAsync(); - getOpenFeedbackWidget()(); // later open, its own flush uploads nothing + getOpenFeedbackWidget()(); // reopen (same draft), its own flush uploads nothing await flushAsync(); const feedback = makeFeedbackEvent('widget'); getBeforeSendFeedback()(feedback, { includeReplay: true }); - expect(feedback.contexts.feedback.replay_id).toBeUndefined(); + expect(feedback.contexts.feedback.replay_id).toBe('first-id'); + }); + + it('does not revive a consumed flush for an unrelated later feedback', async () => { + // A widget submit consumes (clears) the flush. A later widget feedback that uploads nothing + // must not inherit the previous submission's replay id. + mockCaptureReplay.mockResolvedValueOnce('first-id').mockResolvedValueOnce(null); + mockGetCurrentReplayId.mockReturnValue(null); + const integration = mobileReplayIntegration(); + integration.setup?.(mockClient); + + getOpenFeedbackWidget()(); + await flushAsync(); + const first = makeFeedbackEvent('widget'); + getBeforeSendFeedback()(first, { includeReplay: true }); // submit consumes the flush + expect(first.contexts.feedback.replay_id).toBe('first-id'); + + getOpenFeedbackWidget()(); // a fresh, unrelated open whose flush uploads nothing + await flushAsync(); + const second = makeFeedbackEvent('widget'); + getBeforeSendFeedback()(second, { includeReplay: true }); + expect(second.contexts.feedback.replay_id).toBeUndefined(); }); it('keeps the in-flight open flush when the form remounts before it settles', async () => { From d612e05d09fb9ee63d7477a269c6e70081f9bf6d Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Tue, 13 Oct 2026 11:50:13 +0200 Subject: [PATCH 13/13] fix(replay): Prefer the carried replay id over the current buffer on reopen When a feedback form reopens for the same draft, the pre-resolve fallback now prefers the id already uploaded for the draft over the current buffer id. A submit while the reopen's capture is still in flight previously fell back to the current (unconfirmed, maybe-never-sent) buffer, dropping the replay already sent for the draft; it now links the confirmed id instead. Co-Authored-By: Claude Opus 4.8 --- packages/core/src/js/replay/mobilereplay.ts | 5 +++- .../core/test/replay/mobilereplay.test.ts | 29 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/packages/core/src/js/replay/mobilereplay.ts b/packages/core/src/js/replay/mobilereplay.ts index 5914bd90a0..0275957452 100644 --- a/packages/core/src/js/replay/mobilereplay.ts +++ b/packages/core/src/js/replay/mobilereplay.ts @@ -539,7 +539,10 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau } const carried = feedbackReplayFlush?.resolved ? feedbackReplayFlush.id : null; const flush: { openId: string | null; id: string | null; resolved: boolean } = { - openId: NATIVE.getCurrentReplayId() ?? carried, + // Prefer a carried id as the pre-resolve fallback: it is a replay already sent for this + // draft, so a submit while this reopen's capture is still in flight must not fall back to + // the current (unconfirmed, maybe-never-sent) buffer id instead. + openId: carried ?? NATIVE.getCurrentReplayId(), id: carried, resolved: false, }; diff --git a/packages/core/test/replay/mobilereplay.test.ts b/packages/core/test/replay/mobilereplay.test.ts index 02b07f807a..0c73a0f0ac 100644 --- a/packages/core/test/replay/mobilereplay.test.ts +++ b/packages/core/test/replay/mobilereplay.test.ts @@ -1554,6 +1554,35 @@ describe('Mobile Replay Integration', () => { expect(feedback.contexts.feedback.replay_id).toBe('first-id'); }); + it('links the carried id, not the current buffer, when submitting during a reopen flush', async () => { + // First open uploads 'first-id'. The form reopens and its capture is still in flight when the + // user submits, while a current buffer id exists. The submission must link the replay already + // sent for this draft ('first-id'), not the unconfirmed current buffer. + let resolveReopen!: (v: string | null) => void; + mockCaptureReplay.mockResolvedValueOnce('first-id').mockReturnValueOnce( + new Promise(resolve => { + resolveReopen = resolve; + }), + ); + mockGetCurrentReplayId.mockReturnValue(null); + const integration = mobileReplayIntegration(); + integration.setup?.(mockClient); + + getOpenFeedbackWidget()(); // first open uploads 'first-id' + await flushAsync(); + + mockGetCurrentReplayId.mockReturnValue('current-buffer'); // a buffer exists at reopen + getOpenFeedbackWidget()(); // reopen, capture still pending + + const feedback = makeFeedbackEvent('widget'); + getBeforeSendFeedback()(feedback, { includeReplay: true }); // submit before the reopen settles + expect(feedback.contexts.feedback.replay_id).toBe('first-id'); + + // The reopen's capture then uploads nothing; nothing to revive, nothing changes. + resolveReopen(null); + await flushAsync(); + }); + it('does not revive a consumed flush for an unrelated later feedback', async () => { // A widget submit consumes (clears) the flush. A later widget feedback that uploads nothing // must not inherit the previous submission's replay id.