From 43d284ebf4d4b2c9c56c730fce82e910dd0bd9d9 Mon Sep 17 00:00:00 2001 From: Akshar Patel Date: Thu, 22 Oct 2026 13:58:36 -0400 Subject: [PATCH 1/2] fix(mobile): keep tracking imports until processing finishes --- apps/mobile/app/(tabs)/upload.tsx | 33 ++- .../dashboard-upload-visibility.test.tsx | 230 +++++++++++++++++- 2 files changed, 248 insertions(+), 15 deletions(-) diff --git a/apps/mobile/app/(tabs)/upload.tsx b/apps/mobile/app/(tabs)/upload.tsx index 0f0a76369dd..4e509d9a050 100644 --- a/apps/mobile/app/(tabs)/upload.tsx +++ b/apps/mobile/app/(tabs)/upload.tsx @@ -166,6 +166,9 @@ export default function UploadScreen() { const mountedRef = useRef(true); const activeIdRef = useRef(null); const sourceBusyRef = useRef(false); + const processingIdsRef = useRef(new Set()); + const processingClientRef = useRef(null); + processingClientRef.current = auth.status === "signedIn" ? auth.client : null; const uploadSourceError = sourceError?.source === "files" || sourceError?.source === "photos" ? sourceError.message @@ -340,14 +343,27 @@ export default function UploadScreen() { }; const waitForProcessing = async (queueItemId: string, capId: string) => { - if (auth.status !== "signedIn") return; + if (!mountedRef.current || processingClientRef.current !== auth.client) { + return; + } + + processingIdsRef.current.add(queueItemId); + const isActive = () => + mountedRef.current && + processingClientRef.current === auth.client && + processingIdsRef.current.has(queueItemId); - for (const delayMs of processingPollDelaysMs) { - await new Promise((resolve) => setTimeout(resolve, delayMs)); - if (!mountedRef.current) return; + try { + for (let attempt = 0; isActive(); attempt += 1) { + const delayMs = + processingPollDelaysMs[ + Math.min(attempt, processingPollDelaysMs.length - 1) + ]; + await new Promise((resolve) => setTimeout(resolve, delayMs)); + if (!isActive()) return; - try { const detail = await auth.client.getCap(capId); + if (!isActive()) return; const action = uploadQueueActionFromCapUpload( queueItemId, detail.cap.upload, @@ -361,9 +377,11 @@ export default function UploadScreen() { return; } } - } catch { - return; } + } catch { + return; + } finally { + processingIdsRef.current.delete(queueItemId); } }; @@ -508,6 +526,7 @@ export default function UploadScreen() { const removeQueueItem = (item: UploadQueueItem) => { if (activeIdRef.current !== null) return; + processingIdsRef.current.delete(item.id); dispatch({ type: "remove", id: item.id }); }; diff --git a/apps/mobile/src/screens/dashboard-upload-visibility.test.tsx b/apps/mobile/src/screens/dashboard-upload-visibility.test.tsx index 2971152e27e..c77832cc43a 100644 --- a/apps/mobile/src/screens/dashboard-upload-visibility.test.tsx +++ b/apps/mobile/src/screens/dashboard-upload-visibility.test.tsx @@ -4,12 +4,13 @@ import TestRenderer, { type ReactTestRenderer, type ReactTestRendererJSON, } from "react-test-renderer"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { MobileCapSummary } from "@/api/mobile"; import CapsScreen from "../../app/(tabs)"; import UploadScreen from "../../app/(tabs)/upload"; type AuthStub = { - status: "signedIn"; + status: "signedIn" | "signedOut"; apiKey: string; bootstrap: { activeOrganizationId: string; @@ -42,7 +43,7 @@ type AuthStub = { }>; getCap: (id: string) => Promise<{ cap: { - upload: null; + upload: MobileCapSummary["upload"]; }; }>; getCapStatuses: (ids: readonly string[]) => Promise<{ @@ -177,9 +178,9 @@ const uploadQueueState = vi.hoisted( }), ); -const uploadQueueActionsState = vi.hoisted((): { value: unknown[] } => ({ - value: [], -})); +const uploadQueueActionsState = vi.hoisted( + (): { value: unknown[]; reduce: boolean } => ({ value: [], reduce: false }), +); const recordingUploadState = vi.hoisted(() => ({ libraryRevision: 0, @@ -592,9 +593,14 @@ vi.mock("@/uploads/uploadQueue", async (importOriginal) => { ...actual, emptyUploadQueue: uploadQueueState.value, uploadProgressPercent: (progress: number) => Math.round(progress * 100), - uploadQueueReducer: (state: { items: unknown[] }, action: unknown) => { + uploadQueueReducer: ( + state: Parameters[0], + action: Parameters[1], + ) => { uploadQueueActionsState.value.push(action); - return state; + return uploadQueueActionsState.reduce + ? actual.uploadQueueReducer(state, action) + : state; }, uploadQueueStatusText: (item: { processingMessage?: string | null; @@ -1754,6 +1760,214 @@ describe("upload and dashboard visibility", () => { } }); + describe("processing status polling", () => { + const processingDetail = { + cap: { + upload: { + phase: "processing" as const, + processingError: null, + processingMessage: "Converting video", + processingProgress: 42, + total: 100, + uploaded: 100, + }, + }, + }; + const cancellations = [ + "removal", + "unmount", + "sign-out", + "new session", + ] as const; + let auth: AuthStub; + let renderer: ReactTestRenderer | undefined; + + beforeEach(async () => { + vi.useFakeTimers(); + uploadQueueActionsState.reduce = true; + auth = createAuth(); + auth.refresh = vi.fn(() => Promise.resolve()); + auth.client.getCap = vi.fn(() => Promise.resolve(processingDetail)); + authState.value = auth; + const DocumentPicker = await import("expo-document-picker"); + vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValueOnce({ + assets: [{ name: "slow.mp4", uri: "file:///tmp/slow.mp4", size: 100 }], + canceled: false, + }); + const { runMobileUpload } = await import("@/uploads/runMobileUpload"); + vi.mocked(runMobileUpload).mockImplementationOnce( + async ({ onCreated }) => { + onCreated?.("video_123", "raw.mp4"); + return { id: "video_123" } as Awaited< + ReturnType + >; + }, + ); + }); + + afterEach(async () => { + await act(async () => renderer?.unmount()); + renderer = undefined; + uploadQueueActionsState.reduce = false; + vi.clearAllTimers(); + vi.useRealTimers(); + }); + + const startImport = async () => { + renderer = await renderComponent(React.createElement(UploadScreen)); + const [browseButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Browse Files", + }); + if (!browseButton) + throw new Error("Browse Files button was not rendered"); + await act(async () => { + void browseButton.props.onPress(); + }); + return renderer; + }; + + const cancel = async (reason: (typeof cancellations)[number]) => { + if (!renderer) throw new Error("Upload screen was not rendered"); + if (reason === "removal") { + const { ActionSheetIOS } = await import("react-native"); + const sheet = vi.mocked(ActionSheetIOS.showActionSheetWithOptions); + sheet.mockClear(); + const [menu] = renderer.root.findAllByProps({ + accessibilityLabel: "More actions for slow.mp4", + }); + if (!menu) throw new Error("Upload queue menu was not rendered"); + await act(async () => { + menu.props.onPress({ stopPropagation: vi.fn() }); + }); + const call = sheet.mock.calls.at(-1); + if (!call) throw new Error("Upload queue sheet did not open"); + await act(async () => { + call[1](call[0].options.indexOf("Remove from Queue")); + }); + expect( + renderer.root.findAllByProps({ + accessibilityLabel: "Upload slow.mp4", + }), + ).toHaveLength(0); + return; + } + if (reason === "unmount") { + await act(async () => renderer?.unmount()); + renderer = undefined; + return; + } + authState.value = + reason === "sign-out" + ? { ...auth, status: "signedOut" } + : { ...createAuth(), apiKey: "new-api-key" }; + await act(async () => + renderer?.update(React.createElement(UploadScreen)), + ); + }; + + it.each(["complete", "error"] as const)( + "observes a late %s after the initial four checks", + async (phase) => { + const startedAt = Date.now(); + auth.client.getCap = vi.fn(async () => { + if (Date.now() - startedAt < 30_000) return processingDetail; + return { + cap: { + upload: { + ...processingDetail.cap.upload, + phase, + processingError: phase === "error" ? "Conversion failed" : null, + }, + }, + }; + }); + const screen = await startImport(); + await act(async () => { + await vi.advanceTimersByTimeAsync(17_500); + }); + expect(auth.client.getCap).toHaveBeenCalledTimes(4); + expect(getTextNodes(screen.toJSON()).join(" ")).toContain( + "Converting video", + ); + await act(async () => { + await vi.advanceTimersByTimeAsync(16_000); + }); + expect(auth.client.getCap).toHaveBeenCalledTimes(6); + expect(getTextNodes(screen.toJSON()).join(" ")).toContain( + phase === "complete" ? "Ready to view" : "Conversion failed", + ); + expect(auth.refresh).toHaveBeenCalledTimes( + phase === "complete" ? 2 : 1, + ); + if (phase === "error") { + expect( + screen.root.findAllByProps({ + accessibilityLabel: "Retry upload slow.mp4", + }).length, + ).toBeGreaterThan(0); + } + await act(async () => { + await vi.advanceTimersByTimeAsync(32_000); + }); + expect(auth.client.getCap).toHaveBeenCalledTimes(6); + }, + ); + + it.each(cancellations)( + "stops requests after %s while waiting", + async (reason) => { + await startImport(); + await cancel(reason); + await act(async () => { + await vi.advanceTimersByTimeAsync(40_000); + }); + expect(auth.client.getCap).not.toHaveBeenCalled(); + expect(auth.refresh).toHaveBeenCalledTimes(1); + }, + ); + + it.each(cancellations)( + "ignores an in-flight reply after %s", + async (reason) => { + const detail = + createDeferred>>(); + auth.client.getCap = vi.fn(() => detail.promise); + await startImport(); + await act(async () => { + await vi.advanceTimersByTimeAsync(1500); + }); + expect(auth.client.getCap).toHaveBeenCalledTimes(1); + await cancel(reason); + await act(async () => { + detail.resolve({ cap: { upload: null } }); + }); + expect(uploadQueueActionsState.value).not.toContainEqual( + expect.objectContaining({ type: "complete" }), + ); + expect(auth.refresh).toHaveBeenCalledTimes(1); + await act(async () => { + await vi.advanceTimersByTimeAsync(40_000); + }); + expect(auth.client.getCap).toHaveBeenCalledTimes(1); + }, + ); + + it("does not start an old upload's poll after a session change during refresh", async () => { + const refresh = createDeferred(); + auth.refresh = vi.fn(() => refresh.promise); + await startImport(); + expect(auth.refresh).toHaveBeenCalledTimes(1); + await cancel("new session"); + await act(async () => { + refresh.resolve(); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(40_000); + }); + expect(auth.client.getCap).not.toHaveBeenCalled(); + }); + }); + it("opens the native iOS upload queue sheet", async () => { uploadQueueState.value.items = [ { From eec206c417e5664494b7e4282cdf85448aa18a2c Mon Sep 17 00:00:00 2001 From: Akshar Patel Date: Thu, 22 Oct 2026 15:30:38 -0400 Subject: [PATCH 2/2] test(mobile): include required file picker metadata --- .../src/screens/dashboard-upload-visibility.test.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/screens/dashboard-upload-visibility.test.tsx b/apps/mobile/src/screens/dashboard-upload-visibility.test.tsx index c77832cc43a..0654133cd7b 100644 --- a/apps/mobile/src/screens/dashboard-upload-visibility.test.tsx +++ b/apps/mobile/src/screens/dashboard-upload-visibility.test.tsx @@ -1791,7 +1791,14 @@ describe("upload and dashboard visibility", () => { authState.value = auth; const DocumentPicker = await import("expo-document-picker"); vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValueOnce({ - assets: [{ name: "slow.mp4", uri: "file:///tmp/slow.mp4", size: 100 }], + assets: [ + { + name: "slow.mp4", + uri: "file:///tmp/slow.mp4", + size: 100, + lastModified: 0, + }, + ], canceled: false, }); const { runMobileUpload } = await import("@/uploads/runMobileUpload");