Skip to content

Commit 69459e1

Browse files
committed
Resume Concierge trickle on revisit instead of restarting
Anchor the trickle's start time to displayAfter rather than mount time so navigating away and back resumes the reveal at the wall-clock-correct stage. Past the 60s hard cap, drop the optimistic since the canonical reportComment is expected to be in REPORT_ACTIONS by then. Also handle a previously-missed race: if the canonical reportComment is already present on mount (or lands during the pre-trickle setTimeout window) the hook now discards the optimistic instead of completing naturally and clobbering the canonical. Reported in PR 89146 review comment 4352563004.
1 parent eb84ca2 commit 69459e1

2 files changed

Lines changed: 125 additions & 17 deletions

File tree

src/hooks/usePendingConciergeResponse.ts

Lines changed: 49 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,11 @@ import ONYXKEYS from '@src/ONYXKEYS';
1111
import type {ReportAction, ReportActions} from '@src/types/onyx';
1212
import useOnyx from './useOnyx';
1313

14-
/** If displayAfter is more than this far in the past, the response is stale (e.g. app was killed and restarted). */
15-
const STALE_THRESHOLD_MS = 10_000;
1614
/** Default trickle duration. Targets ~19 chars/sec start (~7/sec end after ease-out) across a typical multi-paragraph response — visibly streaming without dragging the user past the moment they want to read. */
1715
const DEFAULT_STREAM_DURATION_MS = 15_000;
1816
/** Trickle tick cadence. 80ms targets ~1 char per tick at char-level granularity — fast enough that the reveal feels continuous, slow enough that the synthetic-bubble re-render budget stays comfortable on RNW (~12 dispatches/sec). */
1917
const TICK_INTERVAL_MS = 80;
20-
/** Hard cap on running trickle. If the loop is still alive past this, force completion to avoid pinning a synthetic bubble forever. */
18+
/** Hard cap on a running trickle and staleness gate on revisit. Past this many ms after `displayAfter`, the canonical reportComment is expected to be in REPORT_ACTIONS already, so we discard the optimistic rather than resume a doomed reveal. */
2119
const TRICKLE_HARD_CAP_MS = 60_000;
2220
/** Once the real reportComment lands in REPORT_ACTIONS, finish the remaining reveal within this window. */
2321
const ACCELERATED_REMAINING_MS = 1_500;
@@ -50,20 +48,27 @@ function usePendingConciergeResponse(reportID: string | undefined) {
5048
// pendingResponse/tokens/fullHtml — without this snapshot, those non-content
5149
// updates would cancel the running interval and restart the reveal. The
5250
// useEffect keeps ref writes in the commit phase (React-Compiler-safe).
53-
const trickleInputsRef = useRef({pendingResponse, fullHtml, tokens, dispatchLocalDraftEvent});
51+
const trickleInputsRef = useRef({pendingResponse, fullHtml, tokens, dispatchLocalDraftEvent, persistedAction});
5452
useEffect(() => {
55-
trickleInputsRef.current = {pendingResponse, fullHtml, tokens, dispatchLocalDraftEvent};
53+
trickleInputsRef.current = {pendingResponse, fullHtml, tokens, dispatchLocalDraftEvent, persistedAction};
5654
});
5755

5856
// Reconciliation: when the canonical reportComment lands in REPORT_ACTIONS
5957
// mid-trickle, fire the running loop's accelerator so the remaining reveal
60-
// finishes in ~1.5s instead of snapping the synthetic bubble closed.
58+
// finishes in ~1.5s instead of snapping the synthetic bubble closed. If the
59+
// canonical lands while no trickle is running (e.g. arrived while the user
60+
// was on a different report), drop the pending optimistic so we don't
61+
// reapply it on top of the canonical on remount.
6162
useEffect(() => {
62-
if (!persistedAction || !accelerateRef.current) {
63+
if (!persistedAction) {
6364
return;
6465
}
65-
accelerateRef.current(Date.now());
66-
}, [persistedAction]);
66+
if (accelerateRef.current) {
67+
accelerateRef.current(Date.now());
68+
} else {
69+
discardPendingConciergeAction(reportID);
70+
}
71+
}, [persistedAction, reportID]);
6772

6873
useEffect(() => {
6974
if (!reportID || !reportActionID) {
@@ -73,14 +78,23 @@ function usePendingConciergeResponse(reportID: string | undefined) {
7378
// when it began; subsequent updates that share this same reportActionID don't
7479
// disturb the in-progress reveal. A genuinely new Concierge reply produces a
7580
// new reportActionID and re-enters this effect via the deps below.
76-
const {pendingResponse: snapshot, fullHtml: snapshotHtml, tokens: snapshotTokens} = trickleInputsRef.current;
81+
const {pendingResponse: snapshot, fullHtml: snapshotHtml, tokens: snapshotTokens, persistedAction: snapshotPersisted} = trickleInputsRef.current;
7782
if (!snapshot) {
7883
return;
7984
}
85+
// If the canonical reportComment is already in REPORT_ACTIONS at mount,
86+
// there's nothing to optimistically reveal — discard pending so we don't
87+
// re-apply the optimistic on top of the canonical.
88+
if (snapshotPersisted) {
89+
discardPendingConciergeAction(reportID);
90+
return;
91+
}
8092
const {reportAction, displayAfter} = snapshot;
8193
const remainingDelay = displayAfter - Date.now();
8294

83-
if (remainingDelay < -STALE_THRESHOLD_MS) {
95+
// Past the hard cap from displayAfter, the server-side canonical reply
96+
// is expected to be in REPORT_ACTIONS already. Skip the trickle.
97+
if (remainingDelay < -TRICKLE_HARD_CAP_MS) {
8498
discardPendingConciergeAction(reportID);
8599
return;
86100
}
@@ -174,15 +188,35 @@ function usePendingConciergeResponse(reportID: string | undefined) {
174188
if (cancelled) {
175189
return;
176190
}
177-
trickleStart = Date.now();
191+
// Late-arrival guard: the canonical reportComment may have landed
192+
// during the pre-trickle setTimeout window. Skip the trickle so we
193+
// don't apply the optimistic on top of the canonical at completion.
194+
if (trickleInputsRef.current.persistedAction) {
195+
discardPendingConciergeAction(reportID);
196+
return;
197+
}
198+
// Anchor to displayAfter so revisit resumes at the wall-clock-correct
199+
// stage instead of restarting the reveal from char 0.
200+
trickleStart = displayAfter;
201+
const lastIndex = snapshotTokens.length - 1;
202+
const elapsedAtStart = Date.now() - trickleStart;
203+
const initialProgress = easeOut(elapsedAtStart / effectiveDuration);
204+
// Floor at 1 so a fresh trickle (elapsed ≈ 0) still reveals the leading chunk on the first dispatch.
205+
const initialStage = Math.max(1, Math.min(lastIndex, Math.ceil(initialProgress * lastIndex)));
178206
Log.info('[ConciergeTrickle] start', false, {
179207
reportActionID,
180208
tokenCount: snapshotTokens.length,
181209
durationMs: effectiveDuration,
210+
initialStage,
211+
elapsedAtStart,
182212
});
183-
dispatch('started', snapshotTokens.at(1) ?? '');
184-
lastStage = 1;
185-
const lastIndex = snapshotTokens.length - 1;
213+
dispatch('started', snapshotTokens.at(initialStage) ?? '');
214+
lastStage = initialStage;
215+
// If revisited past the duration / cap, finish without scheduling ticks.
216+
if (initialProgress >= 1 || elapsedAtStart >= TRICKLE_HARD_CAP_MS) {
217+
completeAndApply();
218+
return;
219+
}
186220
intervalID = setInterval(() => {
187221
const elapsed = Date.now() - trickleStart;
188222
const progress = easeOut(elapsed / effectiveDuration);

tests/unit/hooks/usePendingConciergeResponse.test.ts

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -170,10 +170,10 @@ describe('usePendingConciergeResponse', () => {
170170
});
171171

172172
it('should discard stale pending responses instead of displaying them', async () => {
173-
// Given a pending concierge response from a previous session (well past the stale threshold)
173+
// Given a pending concierge response from a previous session (well past the hard cap, e.g. app killed and reopened later)
174174
await Onyx.merge(`${ONYXKEYS.COLLECTION.PENDING_CONCIERGE_RESPONSE}${REPORT_ID}`, {
175175
reportAction: fakeConciergeAction,
176-
displayAfter: Date.now() - 30_000,
176+
displayAfter: Date.now() - 90_000,
177177
});
178178
await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_USER_IS_TYPING}${REPORT_ID}`, {
179179
[CONST.ACCOUNT_ID.CONCIERGE]: true,
@@ -257,6 +257,80 @@ describe('usePendingConciergeResponse', () => {
257257
// can't drive completion: the hook reads Date.now() for elapsed progress and
258258
// setInterval-only fake-timer advancement leaves progress stuck at 0.
259259

260+
it('resumes mid-stage on revisit (displayAfter is in the past, within the hard cap)', async () => {
261+
// Given a long pending response whose displayAfter is 5s in the past (user navigated away and came back).
262+
await Onyx.merge(`${ONYXKEYS.COLLECTION.PENDING_CONCIERGE_RESPONSE}${REPORT_ID}`, {
263+
reportAction: fakeLongConciergeAction,
264+
displayAfter: Date.now() - 5_000,
265+
});
266+
await waitForBatchedUpdates();
267+
268+
const {unmount} = renderHook(() => usePendingConciergeResponse(REPORT_ID));
269+
// remainingDelay <= 0 → setTimeout(fn, 0). One tick lets startTrickle run.
270+
await delay(50);
271+
await waitForBatchedUpdates();
272+
273+
// The start log should report a non-trivial initialStage and elapsedAtStart >= 5s,
274+
// proving the trickle resumed at the wall-clock-correct position rather than restarting from char 0.
275+
const calls = logSpy.mock.calls as LogInfoCall[];
276+
const startCall = calls.find((call) => call[0] === '[ConciergeTrickle] start');
277+
expect(startCall).toBeDefined();
278+
const payload = startCall?.[2] as {initialStage?: number; elapsedAtStart?: number} | undefined;
279+
expect(payload?.elapsedAtStart ?? 0).toBeGreaterThanOrEqual(4_900);
280+
expect(payload?.initialStage ?? 0).toBeGreaterThan(1);
281+
282+
unmount();
283+
});
284+
285+
it('completes immediately on revisit if elapsed exceeds the trickle duration but stays under the hard cap', async () => {
286+
// Given a long pending response whose displayAfter is 20s in the past — past the 15s reveal but inside the 60s cap.
287+
await Onyx.merge(`${ONYXKEYS.COLLECTION.PENDING_CONCIERGE_RESPONSE}${REPORT_ID}`, {
288+
reportAction: fakeLongConciergeAction,
289+
displayAfter: Date.now() - 20_000,
290+
});
291+
await waitForBatchedUpdates();
292+
293+
const {unmount} = renderHook(() => usePendingConciergeResponse(REPORT_ID));
294+
await delay(100);
295+
await waitForBatchedUpdates();
296+
297+
// Then the action should land in REPORT_ACTIONS without spinning a 15s reveal.
298+
const reportActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}` as const);
299+
expect(reportActions?.[REPORT_ACTION_ID]?.actorAccountID).toBe(CONST.ACCOUNT_ID.CONCIERGE);
300+
301+
// And the pending response should be cleared.
302+
const pendingResponse = await getOnyxValue(`${ONYXKEYS.COLLECTION.PENDING_CONCIERGE_RESPONSE}${REPORT_ID}` as const);
303+
expect(pendingResponse).toBeUndefined();
304+
305+
unmount();
306+
});
307+
308+
it('discards (does not trickle) on revisit past the hard cap', async () => {
309+
// Given a long pending response whose displayAfter is 90s in the past — well past the 60s hard cap.
310+
await Onyx.merge(`${ONYXKEYS.COLLECTION.PENDING_CONCIERGE_RESPONSE}${REPORT_ID}`, {
311+
reportAction: fakeLongConciergeAction,
312+
displayAfter: Date.now() - 90_000,
313+
});
314+
await waitForBatchedUpdates();
315+
316+
const {unmount} = renderHook(() => usePendingConciergeResponse(REPORT_ID));
317+
await delay(50);
318+
await waitForBatchedUpdates();
319+
320+
// Then no trickle telemetry should have fired and the pending optimistic should be discarded.
321+
const calls = logSpy.mock.calls as LogInfoCall[];
322+
const startCall = calls.find((call) => call[0] === '[ConciergeTrickle] start');
323+
expect(startCall).toBeUndefined();
324+
325+
const reportActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}` as const);
326+
expect(reportActions?.[REPORT_ACTION_ID]).toBeUndefined();
327+
328+
const pendingResponse = await getOnyxValue(`${ONYXKEYS.COLLECTION.PENDING_CONCIERGE_RESPONSE}${REPORT_ID}` as const);
329+
expect(pendingResponse).toBeUndefined();
330+
331+
unmount();
332+
});
333+
260334
it('cleans up the interval on unmount mid-trickle', async () => {
261335
// Given a long pending response
262336
await Onyx.merge(`${ONYXKEYS.COLLECTION.PENDING_CONCIERGE_RESPONSE}${REPORT_ID}`, {

0 commit comments

Comments
 (0)