From f758aedfcad1a4abaa1496d542e59a021617aeb1 Mon Sep 17 00:00:00 2001 From: Issa Nimaga Date: Fri, 4 Sep 2026 19:03:40 +0100 Subject: [PATCH] Reconcile persisted Concierge drafts by action ID --- src/pages/inbox/report/ReportActionsList.tsx | 10 ++- tests/ui/ReportActionsListTest.tsx | 80 ++++++++++++++++++- .../inbox/ConciergeDraftContext.test.tsx | 36 +++++++++ 3 files changed, 123 insertions(+), 3 deletions(-) diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index a733aa6ed3d3..370d9e698def 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -202,7 +202,11 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct hasNewerActions, }); - const persistedDraftReportAction = draftReportAction ? sortedVisibleReportActions.find((action) => action.reportActionID === draftReportAction.reportActionID) : undefined; + const persistedDraftReportAction = draftReportAction + ? (sortedAllReportActions ?? sortedVisibleReportActions).find( + (action) => action.reportActionID === draftReportAction.reportActionID && action.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + ) + : undefined; const renderedVisibleReportActions = (() => { if (!draftReportAction) { @@ -251,10 +255,12 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct }, [clearDraft, draftReportAction, isSyntheticDraftVisible]); useEffect(() => { - if (!draftReportAction || !persistedDraftReportAction || getReportActionHtml(draftReportAction) === getReportActionHtml(persistedDraftReportAction)) { + if (!draftReportAction || !persistedDraftReportAction) { return; } + // The persisted action is the durable completion signal when a terminal Pusher event is missed. + // Reconcile by action ID even when its HTML is byte-identical to the last streamed draft. revealDraftFromReportAction(persistedDraftReportAction); }, [draftReportAction, persistedDraftReportAction, revealDraftFromReportAction]); diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index 0b1aac4cf61e..7b9134abf5ab 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -1,4 +1,4 @@ -import {render, screen} from '@testing-library/react-native'; +import {render, screen, waitFor} from '@testing-library/react-native'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import {useIsReportLoadPending} from '@hooks/useInFlightRequests'; @@ -380,6 +380,84 @@ describe('ReportActionsList (body)', () => { expect(getRenderedReportActionsListItemProps(conciergeDraftReportAction).shouldDisableContextMenuForConciergeDraft).toBe(false); expect((getCapturedListProps()?.extraData as unknown[]).at(-1)).toBe(false); }); + + it('reconciles a pending draft when the matching persisted action has identical HTML', async () => { + const persistedReportAction = mockReportActions.at(-1); + const revealDraftFromReportAction = jest.fn(); + mockUseConciergeDraft.mockReturnValue({ + draftReportAction: persistedReportAction ?? null, + hasActiveDraft: true, + isDraftPendingCompletion: true, + }); + mockUseConciergeDraftActions.mockReturnValue({ + clearDraft: jest.fn(), + dispatchLocalDraftEvent: jest.fn(), + revealDraftFromReportAction, + }); + + renderReportActionsList(); + + await waitFor(() => { + expect(revealDraftFromReportAction).toHaveBeenCalledWith(persistedReportAction); + }); + }); + + it('reconciles from all persisted actions when the matching action is outside the visible page', async () => { + const persistedReportAction: OnyxTypes.ReportAction = { + ...conciergeDraftReportAction, + reportActionID: 'persisted-outside-visible-page', + }; + const revealDraftFromReportAction = jest.fn(); + mockUsePaginatedReportActions.mockReturnValue({ + ...defaultPaginatedReportActionsResult, + reportActions: mockReportActions, + sortedAllReportActions: [...mockReportActions, persistedReportAction], + }); + mockUseConciergeDraft.mockReturnValue({ + draftReportAction: {...persistedReportAction}, + hasActiveDraft: true, + isDraftPendingCompletion: true, + }); + mockUseConciergeDraftActions.mockReturnValue({ + clearDraft: jest.fn(), + dispatchLocalDraftEvent: jest.fn(), + revealDraftFromReportAction, + }); + + renderReportActionsList(); + + await waitFor(() => { + expect(revealDraftFromReportAction).toHaveBeenCalledWith(persistedReportAction); + }); + }); + + it('does not reconcile from a matching optimistic Concierge action', () => { + const optimisticReportAction: OnyxTypes.ReportAction = { + ...conciergeDraftReportAction, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + isOptimisticAction: true, + }; + const revealDraftFromReportAction = jest.fn(); + mockUsePaginatedReportActions.mockReturnValue({ + ...defaultPaginatedReportActionsResult, + reportActions: [...mockReportActions, optimisticReportAction], + sortedAllReportActions: [...mockReportActions, optimisticReportAction], + }); + mockUseConciergeDraft.mockReturnValue({ + draftReportAction: optimisticReportAction, + hasActiveDraft: true, + isDraftPendingCompletion: true, + }); + mockUseConciergeDraftActions.mockReturnValue({ + clearDraft: jest.fn(), + dispatchLocalDraftEvent: jest.fn(), + revealDraftFromReportAction, + }); + + renderReportActionsList(); + + expect(revealDraftFromReportAction).not.toHaveBeenCalled(); + }); }); describe('Skeleton Loading States', () => { diff --git a/tests/unit/pages/inbox/ConciergeDraftContext.test.tsx b/tests/unit/pages/inbox/ConciergeDraftContext.test.tsx index 51c4b8d9a3cf..29e732f75cdf 100644 --- a/tests/unit/pages/inbox/ConciergeDraftContext.test.tsx +++ b/tests/unit/pages/inbox/ConciergeDraftContext.test.tsx @@ -621,6 +621,42 @@ describe('ConciergeDraftContext', () => { } }); + it('completes a pending draft from a matching persisted action with identical HTML', async () => { + const wrapper = ({children}: PropsWithChildren) => {children}; + const {result, unmount} = renderHook( + () => ({ + actions: useConciergeDraftActions(), + state: useConciergeDraft(), + }), + {wrapper}, + ); + + try { + await waitFor(() => { + expect(Pusher.subscribe).toHaveBeenCalledTimes(6); + }); + jest.useFakeTimers(); + + act(() => { + emitPusherEvent(Pusher.TYPE.CONCIERGE_DRAFT_UPDATED, createDraftEvent('OK')); + jest.advanceTimersByTime(100); + }); + + expect(getFirstMessageText(result.current.state.draftReportAction)).toBe('OK'); + expect(result.current.state.isDraftPendingCompletion).toBe(true); + + act(() => { + result.current.actions.revealDraftFromReportAction(createReportAction(SHORT_FINAL_RENDERED_HTML)); + }); + + expect(getFirstMessageText(result.current.state.draftReportAction)).toBe('OK'); + expect(result.current.state.isDraftPendingCompletion).toBe(false); + } finally { + unmount(); + jest.useRealTimers(); + } + }); + it('applies ordered batched Pusher draft events', async () => { const wrapper = ({children}: PropsWithChildren) => {children}; const {result, unmount} = renderHook(() => useConciergeDraft(), {wrapper});