Skip to content

Commit 6bb871e

Browse files
antonisclaude
andcommitted
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 <noreply@anthropic.com>
1 parent 4bdd11f commit 6bb871e

2 files changed

Lines changed: 37 additions & 4 deletions

File tree

‎packages/core/src/js/replay/mobilereplay.ts‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -524,12 +524,18 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau
524524
}
525525

526526
// Start uploading the buffer when the widget opens. Read the buffer id first (before capture may
527-
// restart buffering) so the id we link matches what this flush uploads. Each open starts a fresh
528-
// flush but carries forward an id a previous open already uploaded, so a remount (e.g. the
529-
// screenshot flow closes and reopens the form) whose own flush uploads nothing still links it.
530-
// Wrapped: a synchronous bridge throw must not propagate out of the widget's `componentDidMount`.
527+
// restart buffering) so the id we link matches what this flush uploads. A remount (e.g. the
528+
// screenshot flow closes and reopens the form) reuses a flush that is still in flight, and
529+
// otherwise carries forward an id a previous open already uploaded, so neither a pending nor a
530+
// completed upload is discarded. Wrapped: a synchronous bridge throw must not propagate out of
531+
// the widget's `componentDidMount`.
531532
function flushReplayForFeedbackWidget(): void {
532533
try {
534+
// A flush from an earlier open in the same flow is still uploading: keep it rather than
535+
// starting another, so its pending upload isn't discarded.
536+
if (feedbackReplayFlush && !feedbackReplayFlush.resolved) {
537+
return;
538+
}
533539
const carried = feedbackReplayFlush?.resolved ? feedbackReplayFlush.id : null;
534540
const flush: { openId: string | null; id: string | null; resolved: boolean } = {
535541
openId: NATIVE.getCurrentReplayId() ?? carried,

‎packages/core/test/replay/mobilereplay.test.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1553,6 +1553,33 @@ describe('Mobile Replay Integration', () => {
15531553
expect(feedback.contexts.feedback.replay_id).toBe('first-id');
15541554
});
15551555

1556+
it('keeps the in-flight open flush when the form remounts before it settles', async () => {
1557+
// The first open's capture is still pending when the form remounts.
1558+
let resolveFirst!: (v: string | null) => void;
1559+
mockCaptureReplay.mockReturnValueOnce(
1560+
new Promise<string | null>(resolve => {
1561+
resolveFirst = resolve;
1562+
}),
1563+
);
1564+
mockGetCurrentReplayId.mockReturnValue(null);
1565+
const integration = mobileReplayIntegration();
1566+
integration.setup?.(mockClient);
1567+
1568+
getOpenFeedbackWidget()(); // first open, flush in flight
1569+
getOpenFeedbackWidget()(); // remount before the first settles
1570+
1571+
// The remount reuses the in-flight flush instead of starting another.
1572+
expect(mockCaptureReplay).toHaveBeenCalledTimes(1);
1573+
1574+
resolveFirst('in-flight-id');
1575+
await flushAsync();
1576+
1577+
const feedback = makeFeedbackEvent('widget');
1578+
getBeforeSendFeedback()(feedback, { includeReplay: true });
1579+
1580+
expect(feedback.contexts.feedback.replay_id).toBe('in-flight-id');
1581+
});
1582+
15561583
it('does not propagate a synchronous bridge error when the widget opens', () => {
15571584
const integration = mobileReplayIntegration();
15581585
integration.setup?.(mockClient);

0 commit comments

Comments
 (0)