diff --git a/src/libs/QueuedFileStorage/index.native.ts b/src/libs/QueuedFileStorage/index.native.ts new file mode 100644 index 000000000000..8c234d205ee2 --- /dev/null +++ b/src/libs/QueuedFileStorage/index.native.ts @@ -0,0 +1,21 @@ +import type {DeleteFiles, GetFile, KeepOnly, QueuedFileRef, StoreFile} from './types'; + +import {isQueuedFileRef} from './types'; + +/** + * Native storage has no IndexedDB blob-write limit, so files stay inline in the queue — there is + * no out-of-band store. isFileStorageSupported is false so the queue skips the file swap; the other + * stubs keep the API shape identical to web. + */ +const isFileStorageSupported = false; + +const storeFile: StoreFile = () => Promise.reject(new Error('[QueuedFileStorage] storeFile is not supported on native')); + +const getFile: GetFile = () => Promise.resolve(undefined); + +const deleteFiles: DeleteFiles = () => Promise.resolve(); + +const keepOnly: KeepOnly = () => Promise.resolve(); + +export {storeFile, getFile, deleteFiles, keepOnly, isQueuedFileRef, isFileStorageSupported}; +export type {QueuedFileRef}; diff --git a/src/libs/QueuedFileStorage/index.ts b/src/libs/QueuedFileStorage/index.ts new file mode 100644 index 000000000000..5bc30ded7cc8 --- /dev/null +++ b/src/libs/QueuedFileStorage/index.ts @@ -0,0 +1,93 @@ +import Log from '@libs/Log'; + +import type {DeleteFiles, GetFile, KeepOnly, QueuedFileRef, StoreFile} from './types'; + +import {isQueuedFileRef} from './types'; + +/** + * Web backend for QueuedFileStorage: keeps a queued upload's bytes out of the Onyx request queue, + * in Cache Storage. Cache Storage uses a different disk backend than the IndexedDB blob path that + * fails with `DataError: Failed to write blobs`, and mirrors Attachment/index.ts. + */ +const isFileStorageSupported = true; + +const CACHE_NAME = 'OnyxQueuedFiles'; +// Same-origin synthetic path; a real origin avoids any Request-URL validation edge cases. +const KEY_PREFIX = '/__queued-file__/'; + +let keyCounter = 0; + +function generateKey(): string { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + keyCounter += 1; + return `qf-${keyCounter}-${Date.now()}`; +} + +/** The synthetic Request URL a queued-file key maps to inside the cache. */ +function urlForKey(key: string): string { + return `${KEY_PREFIX}${encodeURIComponent(key)}`; +} + +/** Recover the queued-file key from a cached Request URL (the cache stores resolved absolute URLs). */ +function keyForUrl(url: string): string | undefined { + const {pathname} = new URL(url); + if (!pathname.startsWith(KEY_PREFIX)) { + return undefined; + } + return decodeURIComponent(pathname.slice(KEY_PREFIX.length)); +} + +const storeFile: StoreFile = async (blob) => { + const key = generateKey(); + try { + const cache = await caches.open(CACHE_NAME); + // Preserve MIME type on the Response so getFile() returns a correctly typed Blob. + const headers = new Headers(); + if (blob.type) { + headers.set('Content-Type', blob.type); + } + await cache.put(urlForKey(key), new Response(blob, {headers})); + } catch (error) { + // A store failure means an upload can't be queued; surface it so prod telemetry shows the rate. + Log.alert('[QueuedFileStorage] Failed to store queued file in Cache Storage', {error}); + throw error; + } + return key; +}; + +const getFile: GetFile = async (key) => { + const cache = await caches.open(CACHE_NAME); + const response = await cache.match(urlForKey(key)); + if (!response) { + return undefined; + } + return response.blob(); +}; + +const deleteFiles: DeleteFiles = async (keys) => { + if (keys.length === 0) { + return; + } + const cache = await caches.open(CACHE_NAME); + await Promise.all(keys.map((key) => cache.delete(urlForKey(key)))); +}; + +const keepOnly: KeepOnly = async (keysToKeep) => { + const cache = await caches.open(CACHE_NAME); + const requests = await cache.keys(); + const keep = new Set(keysToKeep); + await Promise.all( + requests.map((request) => { + const key = keyForUrl(request.url); + if (key === undefined || keep.has(key)) { + return Promise.resolve(false); + } + return cache.delete(request); + }), + ); +}; + +export {storeFile, getFile, deleteFiles, keepOnly, isQueuedFileRef, isFileStorageSupported}; +export type {QueuedFileRef}; diff --git a/src/libs/QueuedFileStorage/types.ts b/src/libs/QueuedFileStorage/types.ts new file mode 100644 index 000000000000..2069839e2027 --- /dev/null +++ b/src/libs/QueuedFileStorage/types.ts @@ -0,0 +1,19 @@ +/** + * A small serializable reference that stands in for a File/Blob inside a queued request. + * The bytes live in a separate file store (see storeFile); this ref points at them. + */ +type QueuedFileRef = {queuedFileKey: string; name?: string; type?: string}; + +/** Narrow an unknown value to a QueuedFileRef (truthy object carrying a string key). */ +function isQueuedFileRef(value: unknown): value is QueuedFileRef { + return typeof value === 'object' && value !== null && 'queuedFileKey' in value && typeof value.queuedFileKey === 'string'; +} + +type StoreFile = (blob: Blob) => Promise; +// Returns the stored value (a Blob in the browser); typed as unknown because IndexedDB reads are untyped. +type GetFile = (key: string) => Promise; +type DeleteFiles = (keys: string[]) => Promise; +type KeepOnly = (keysToKeep: string[]) => Promise; + +export {isQueuedFileRef}; +export type {QueuedFileRef, StoreFile, GetFile, DeleteFiles, KeepOnly}; diff --git a/src/libs/actions/PersistedRequests.ts b/src/libs/actions/PersistedRequests.ts index 080bc69329b9..48fe2ceb6c26 100644 --- a/src/libs/actions/PersistedRequests.ts +++ b/src/libs/actions/PersistedRequests.ts @@ -1,4 +1,6 @@ import Log from '@libs/Log'; +import {deleteFiles, isFileStorageSupported, isQueuedFileRef, keepOnly, storeFile} from '@libs/QueuedFileStorage'; +import type {QueuedFileRef} from '@libs/QueuedFileStorage'; import sanitizeLogParams from '@libs/sanitizeLogParams'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -14,6 +16,11 @@ let persistedRequests: AnyRequest[] = []; let ongoingRequest: AnyRequest | null = null; let pendingSaveOperations: AnyRequest[] = []; let isInitialized = false; +// The initial orphan-file sweep must wait until both the queue and the ongoing request have loaded, +// otherwise it would delete the file of an ongoing (in-flight) upload that isn't in the queue yet. +let persistedRequestsLoaded = false; +let ongoingRequestLoaded = false; +let initialFileSweepDone = false; // Tracks all request indexes this tab has ever seen (from disk init, save(), or other tabs). // Used to distinguish stale own-write callbacks (ignore) from new requests enqueued // by other browser tabs (merge into memory). @@ -56,12 +63,32 @@ function onCrossTabRequestsMerged(callbackFunction: () => void) { crossTabRequestsCallback = callbackFunction; } +/** + * Delete stored files whose owning request is gone (e.g. crashed after storeFile but before the + * queue was persisted). Runs once, only after both the queue and the ongoing request have loaded, + * so the in-flight ongoing request's file is kept. + */ +function runInitialFileSweepIfReady() { + if (initialFileSweepDone || !persistedRequestsLoaded || !ongoingRequestLoaded) { + return; + } + initialFileSweepDone = true; + const requests = ongoingRequest ? [...persistedRequests, ongoingRequest] : persistedRequests; + keepOnly(collectFileKeys(requests)); +} + // We have opted for connectWithoutView here as this module is strictly non-UI Onyx.connectWithoutView({ key: ONYXKEYS.PERSISTED_REQUESTS, callback: (val) => { Log.info('[PersistedRequests] hit Onyx connect callback', false, {isValNullish: val == null, isInitialized}); + // Onyx.clear() (logout/reset) nulls the queue but not the file store, orphaning every + // stored file. Purge it here (initial-load null is covered by the sweep below). + if (isInitialized && val == null) { + keepOnly([]); + } + // After initialization, in-memory is authoritative — ignore stale disk // callbacks to prevent out-of-order Onyx.set() from overwriting the // correct in-memory state (Bug #80759 Issue 4). @@ -85,7 +112,7 @@ Onyx.connectWithoutView({ } } persistedRequests = [...persistedRequests, ...newFromOtherTabs]; - trackOnyxWrite(Onyx.set(ONYXKEYS.PERSISTED_REQUESTS, persistedRequests)); + persistQueue(persistedRequests); crossTabRequestsCallback?.(); return; } @@ -150,7 +177,7 @@ Onyx.connectWithoutView({ } const requests = [...persistedRequests, ...pendingSaveOperations]; persistedRequests = requests; - trackOnyxWrite(Onyx.set(ONYXKEYS.PERSISTED_REQUESTS, requests)); + persistQueue(requests); pendingSaveOperations = []; } @@ -175,6 +202,8 @@ Onyx.connectWithoutView({ Log.info('[PersistedRequests] Triggering initialization callback', false); triggerInitializationCallback(); } + persistedRequestsLoaded = true; + runInitialFileSweepIfReady(); isInitialized = true; }, }); @@ -193,6 +222,9 @@ Onyx.connectWithoutView({ const previousOngoingRequest = ongoingRequest; ongoingRequest = val ?? null; + ongoingRequestLoaded = true; + runInitialFileSweepIfReady(); + Log.info('[PersistedRequests] ONGOING_REQUEST changed', false, { previousOngoingCommand: previousOngoingRequest?.command ?? 'null', newOngoingCommand: ongoingRequest?.command ?? 'null', @@ -216,6 +248,8 @@ function clear() { pendingSaveOperations = []; knownRequestIDs.clear(); knownOngoingRequestIDs.clear(); + // Delete every stored file since the queue is being emptied (fire-and-forget). + keepOnly([]); Onyx.set(ONYXKEYS.PERSISTED_ONGOING_REQUESTS, null); return trackOnyxWrite(Onyx.set(ONYXKEYS.PERSISTED_REQUESTS, [])); } @@ -225,9 +259,14 @@ function getLength(): number { return persistedRequests.length + (ongoingRequest ? 1 : 0); } -function save(requestToPersist: Request): Promise { +async function save(requestToPersist: Request): Promise { + // On web, move any inline File/Blob to the separate file store and keep only a key in the queue. + // Native has no blob-write limit and keeps files inline, so it skips the swap. Only file requests await. + const shouldSwap = isFileStorageSupported && dataContainsFileOrBlob(requestToPersist.data); + const request = shouldSwap ? await storeFilesAndSwap(requestToPersist) : requestToPersist; + Log.info('[PersistedRequests] Saving request to queue started', false, { - command: requestToPersist.command, + command: request.command, currentQueueLength: persistedRequests.length, ongoingRequest: ongoingRequest?.command ?? 'null', isInitialized, @@ -237,36 +276,36 @@ function save(requestToPersist: Request): Promise { Log.info('[PersistedRequests] Request successfully persisted to disk', false, { - command: requestToPersist.command, + command: request.command, queueLength: getLength(), }); }) .catch((error) => { // Disk-write failure risks losing this request on a crash — alert as a storage emergency. Log.alert('[PersistedRequests] ERROR: Failed to persist request to disk', { - command: requestToPersist.command, + command: request.command, queueLength: getLength(), error, }); @@ -300,6 +339,10 @@ function endRequestAndRemoveFromQueue(requestToRemove: Req wasInQueue: index !== -1, }); + // The ongoing request is already sliced out of the queue, so delete its file by its own keys. + // Runs only on terminal outcomes, never on retry (rollback keeps the file). Fire-and-forget. + deleteFiles(collectFileKeys([requestToRemove])); + if (index !== -1) { requests.splice(index, 1); Log.info('[PersistedRequests] Removed request from queue', false, { @@ -335,11 +378,15 @@ function deleteRequestsByIndices(indices: number[]): Promise { // Create a Set from the indices array for efficient lookup const indicesSet = new Set(indices); + // Delete the stored files for the requests we are about to remove (fire-and-forget). + const removedRequests = persistedRequests.filter((_, index) => indicesSet.has(index)); + deleteFiles(collectFileKeys(removedRequests)); + // Create a new array excluding elements at the specified indices persistedRequests = persistedRequests.filter((_, index) => !indicesSet.has(index)); // Update the persisted requests in storage or state as necessary - return trackOnyxWrite(Onyx.set(ONYXKEYS.PERSISTED_REQUESTS, persistedRequests)) + return persistQueue(persistedRequests) .then(() => { Log.info(`Multiple (${indices.length}) requests removed from the queue. Queue length is ${persistedRequests.length}`); }) @@ -363,7 +410,7 @@ function update(oldRequestIndex: number, newRequest: Reque if (requestIndex != null) { knownRequestIDs.add(requestIndex); } - return trackOnyxWrite(Onyx.set(ONYXKEYS.PERSISTED_REQUESTS, requests)).catch((error) => { + return persistQueue(requests).catch((error) => { // Swallow so the conflict promise resolves (in-memory queue is the source of truth); alert as a storage emergency. Log.alert('[PersistedRequests] ERROR: Failed to persist updated request to disk', { command: newRequest.command, @@ -373,16 +420,63 @@ function update(oldRequestIndex: number, newRequest: Reque }); } -function shouldPersistOngoingRequest(request: AnyRequest | null): boolean { - if (!request?.data) { - return true; +function isFileOrBlob(value: unknown): value is File | Blob { + return (typeof File !== 'undefined' && value instanceof File) || (typeof Blob !== 'undefined' && value instanceof Blob); +} + +function dataContainsFileOrBlob(data: Record | undefined): boolean { + if (!data) { + return false; + } + return Object.values(data).some(isFileOrBlob); +} + +/** + * Save each inline File/Blob to Cache Storage and keep only a small QueuedFileRef in the queue, so + * the single networkRequestQueue record stays tiny (an inline file bloated it until the IndexedDB + * write failed with `Failed to write blobs`) while the file still survives a restart. + */ +async function storeFilesAndSwap(request: Request): Promise> { + if (!dataContainsFileOrBlob(request.data)) { + return request; } + const entries = Object.entries(request.data ?? {}); + // Save every file in parallel, then rebuild the data with a key in place of each file. + const swapped = await Promise.all( + entries.map(async ([key, value]): Promise<[string, unknown]> => { + if (!isFileOrBlob(value)) { + return [key, value]; + } + const ref: QueuedFileRef = {queuedFileKey: await storeFile(value), name: value instanceof File ? value.name : undefined, type: value.type}; + return [key, ref]; + }), + ); + const storedCount = entries.filter(([, value]) => isFileOrBlob(value)).length; + Log.info('[PersistedRequests] Saved inline file(s) to the separate file store', false, {storedCount, queueLength: persistedRequests.length}); + return {...request, data: Object.fromEntries(swapped)} as Request; +} - return !Object.values(request.data).some((value) => { - const isFile = typeof File !== 'undefined' && value instanceof File; - const isBlob = typeof Blob !== 'undefined' && value instanceof Blob; - return isFile || isBlob; - }); +/** Collect every stored-file key referenced by the given requests' data. */ +function collectFileKeys(requests: AnyRequest[]): string[] { + const keys: string[] = []; + for (const request of requests) { + for (const value of Object.values(request.data ?? {})) { + if (isQueuedFileRef(value)) { + keys.push(value.queuedFileKey); + } + } + } + return keys; +} + +function persistQueue(requests: AnyRequest[]): Promise { + return trackOnyxWrite(Onyx.set(ONYXKEYS.PERSISTED_REQUESTS, requests)); +} + +// Requests now only ever hold serializable file references, so the ongoing request is always +// safe to persist to disk (and doing so keeps it crash-safe across a restart). +function shouldPersistOngoingRequest(request: AnyRequest | null): boolean { + return !dataContainsFileOrBlob(request?.data); } function updateOngoingRequest(newRequest: Request) { @@ -447,10 +541,8 @@ function processNextRequest(): AnyRequest | null { // Persist both the updated queue and the ongoing request to disk atomically. // This ensures that if the app crashes mid-flight, the ongoing request is not // lost (Bug #80759 Issue 3a) and the queue on disk matches memory (Issue 3c). - // Skip persisting ongoingRequest when it contains non-serializable values - // (e.g. File objects in data.file or data.receipt). IndexedDB cannot clone - // native File objects (DataCloneError). These requests cannot survive a crash - // anyway since File references are lost on restart. + // Requests hold only serializable file references now, so the defensive skip + // below only trips for a raw File that never went through save(). if (shouldPersistOngoingRequest(ongoingRequest)) { trackOnyxWrite( Onyx.multiSet({ diff --git a/src/libs/prepareRequestPayload/index.ts b/src/libs/prepareRequestPayload/index.ts index 5f01591b150d..7520445097ed 100644 --- a/src/libs/prepareRequestPayload/index.ts +++ b/src/libs/prepareRequestPayload/index.ts @@ -1,3 +1,5 @@ +import Log from '@libs/Log'; +import {getFile, isQueuedFileRef} from '@libs/QueuedFileStorage'; import validateFormDataParameter from '@libs/validateFormDataParameter'; import type PrepareRequestPayload from './types'; @@ -5,21 +7,44 @@ import type PrepareRequestPayload from './types'; /** * Prepares the request payload (body) for a given command and data. */ -const prepareRequestPayload: PrepareRequestPayload = (command, data) => { - const formData = new FormData(); +const prepareRequestPayload: PrepareRequestPayload = async (command, data) => { + // Resolve every param up front (in parallel) so we can append synchronously afterwards + // without awaiting inside the append loop. + const resolved = await Promise.all( + Object.keys(data).map(async (key) => { + const value = data[key]; + + if (value === undefined || value === null) { + return undefined; + } + + // The file was saved to the separate file store at queue time; resolve the key back + // into the actual Blob to upload. If it's gone, send without it for a definitive outcome. + if (isQueuedFileRef(value)) { + const blob = await getFile(value.queuedFileKey); + if (!(blob instanceof Blob)) { + Log.alert('[prepareRequestPayload] Queued file missing at upload time, sending without it', {command, key}); + return undefined; + } + // Rebuild a File so FormData keeps the original filename/extension (a bare Blob sends "blob"). + const file = value.name ? new File([blob], value.name, {type: value.type}) : blob; + return {key, value: file}; + } - for (const key of Object.keys(data)) { - const value = data[key]; + return {key, value}; + }), + ); - if (value === undefined || value === null) { + const formData = new FormData(); + for (const entry of resolved) { + if (!entry) { continue; } - - validateFormDataParameter(command, key, value); - formData.append(key, value as string | Blob); + validateFormDataParameter(command, entry.key, entry.value); + formData.append(entry.key, entry.value as string | Blob); } - return Promise.resolve(formData); + return formData; }; export default prepareRequestPayload; diff --git a/tests/unit/PersistedRequests.ts b/tests/unit/PersistedRequests.ts index 18a66f009783..46c8b5a324c4 100644 --- a/tests/unit/PersistedRequests.ts +++ b/tests/unit/PersistedRequests.ts @@ -6,10 +6,59 @@ import OnyxUtils from 'react-native-onyx/dist/OnyxUtils'; import type Request from '../../src/types/onyx/Request'; import * as PersistedRequests from '../../src/libs/actions/PersistedRequests'; +import * as QueuedFileStorage from '../../src/libs/QueuedFileStorage'; import ONYXKEYS from '../../src/ONYXKEYS'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; import wrapOnyxWithWaitForBatchedUpdates from '../utils/wrapOnyxWithWaitForBatchedUpdates'; +// The jest resolver picks the native no-op stub for @libs/QueuedFileStorage; force the web +// (Cache Storage) implementation so file requests are exercised end-to-end like on web. +// eslint-disable-next-line @typescript-eslint/no-unsafe-return +jest.mock('@libs/QueuedFileStorage', () => jest.requireActual('@libs/QueuedFileStorage/index.ts')); + +// jsdom provides neither Cache Storage nor Response; minimal in-memory mocks let the web +// QueuedFileStorage backend run end-to-end without a real browser. Response is the only class +// (the backend calls `new Response(...)`); the cache is a plain closure over a Map. +class FakeResponse { + private readonly body: unknown; + + constructor(body: unknown) { + this.body = body; + } + + blob(): Promise { + return Promise.resolve(this.body); + } +} + +function createFakeCache() { + const entries = new Map(); + const normalize = (url: string) => new URL(url, 'https://localhost').href; + return { + put: (url: string, response: FakeResponse) => { + entries.set(normalize(url), response); + return Promise.resolve(); + }, + match: (url: string) => Promise.resolve(entries.get(normalize(url))), + delete: (target: string | {url: string}) => Promise.resolve(entries.delete(normalize(typeof target === 'string' ? target : target.url))), + keys: () => Promise.resolve([...entries.keys()].map((url) => ({url}))), + }; +} + +const fakeCacheStorage = new Map>(); +global.caches = { + open: (name: string) => { + const existing = fakeCacheStorage.get(name); + if (existing) { + return Promise.resolve(existing); + } + const cache = createFakeCache(); + fakeCacheStorage.set(name, cache); + return Promise.resolve(cache); + }, +} as unknown as CacheStorage; +global.Response = FakeResponse as unknown as typeof Response; + const request: Request<'reportMetadata_1' | 'reportMetadata_2'> = { command: 'OpenReport', successData: [{key: 'reportMetadata_1', onyxMethod: 'merge', value: {}}], @@ -200,37 +249,33 @@ describe('PersistedRequests persistence guarantees', () => { }); }); - it('processNextRequest should keep the in-memory ongoing request when data contains a File/Blob', async () => { + it('processNextRequest persists a file request as ongoing once its file is stored separately', async () => { PersistedRequests.clear(); await waitForBatchedUpdates(); - const originalFile = global.File; - function MockFile() {} - global.File = MockFile as unknown as typeof File; + const requestWithFile: Request<'reportMetadata_1' | 'reportMetadata_2'> = { + command: 'OpenReport', + successData: [{key: 'reportMetadata_1', onyxMethod: 'merge', value: {}}], + failureData: [{key: 'reportMetadata_2', onyxMethod: 'merge', value: {}}], + requestIndex: 30, + data: {file: new Blob(['x'], {type: 'image/jpeg'}) as unknown as File}, + }; - try { - const mockFilePrototype = MockFile.prototype as Record; - const mockFile = Object.create(mockFilePrototype) as File; - const requestWithFile: Request<'reportMetadata_1' | 'reportMetadata_2'> = { - command: 'OpenReport', - successData: [{key: 'reportMetadata_1', onyxMethod: 'merge', value: {}}], - failureData: [{key: 'reportMetadata_2', onyxMethod: 'merge', value: {}}], - requestIndex: 30, - data: {file: mockFile}, - }; + await PersistedRequests.save(requestWithFile); + await waitForBatchedUpdates(); - PersistedRequests.save(requestWithFile); - await waitForBatchedUpdates(); + // save() swapped the inline Blob for a serializable reference, so the queued request + // no longer holds the Blob itself. + const queuedFile = PersistedRequests.getAll().at(0)?.data?.file; + expect(queuedFile).not.toBeInstanceOf(Blob); + expect(QueuedFileStorage.isQueuedFileRef(queuedFile)).toBe(true); - const nextRequest = PersistedRequests.processNextRequest(); - await waitForBatchedUpdates(); + const nextRequest = PersistedRequests.processNextRequest(); + await waitForBatchedUpdates(); - expect(nextRequest).toEqual(requestWithFile); - expect(PersistedRequests.getOngoingRequest()).toEqual(requestWithFile); - expect((await OnyxUtils.get(ONYXKEYS.PERSISTED_ONGOING_REQUESTS)) == null).toBe(true); - } finally { - global.File = originalFile; - } + // Because the request is now fully serializable, the ongoing request is crash-safe on disk. + expect(nextRequest?.command).toBe('OpenReport'); + expect(await OnyxUtils.get(ONYXKEYS.PERSISTED_ONGOING_REQUESTS)).not.toBeNull(); }); // BUG: save() at PersistedRequests.ts:124-134 does a read-modify-write @@ -360,3 +405,109 @@ describe('PersistedRequests persistence guarantees', () => { expect(PersistedRequests.getAll().map((r) => r.command)).toEqual(['CommandB', 'CommandC']); }); }); + +// Root cause of the `Failed to write blobs (InvalidBlob)` storm: file-upload requests used to be +// persisted with their File/Blob INLINE in the single networkRequestQueue value, so the one record +// grew with (queued file requests × file size) until its IndexedDB blob write failed and deadlocked +// the queue. The fix saves each file to a separate store and keeps only a small +// QueuedFileRef in the queue, so the record stays tiny AND the file survives a browser restart. +describe('PersistedRequests separate file storage (InvalidBlob root-cause fix)', () => { + const receiptBytes = 'x'.repeat(100_000); + const makeReceiptRequest = (index: number): Request => + ({ + command: 'ReplaceReceipt', + data: {transactionID: String(index), receipt: new Blob([receiptBytes], {type: 'image/jpeg'})}, + requestIndex: index, + }) as Request; + + const getQueuedFileKey = (r: Request | undefined): string | undefined => { + const receipt = r?.data?.receipt; + return QueuedFileStorage.isQueuedFileRef(receipt) ? receipt.queuedFileKey : undefined; + }; + + beforeEach(async () => { + await PersistedRequests.clear(); + await waitForBatchedUpdates(); + }); + + it('persists the receipt as a QueuedFileRef and keeps the bytes in the separate store', async () => { + await PersistedRequests.save(makeReceiptRequest(1)); + await waitForBatchedUpdates(); + + // The persisted request references the file by key instead of embedding the Blob, + // so the single networkRequestQueue record cannot balloon. + const persisted = await OnyxUtils.get(ONYXKEYS.PERSISTED_REQUESTS); + expect(persisted).toHaveLength(1); + const persistedReceipt = persisted?.at(0)?.data?.receipt; + expect(persistedReceipt).not.toBeInstanceOf(Blob); + expect(persistedReceipt).toBeDefined(); + + const key = getQueuedFileKey(persisted?.at(0)); + expect(typeof key).toBe('string'); + + // The rest of the request is preserved on disk so ordering / reconciliation are unaffected. + expect(persisted?.at(0)?.command).toBe('ReplaceReceipt'); + expect(persisted?.at(0)?.data?.transactionID).toBe('1'); + + // A record is durably stored in the separate store under the referenced key (so it survives a browser + // restart). jsdom's structuredClone can't round-trip Blob bytes, so we assert presence only — + // real browsers preserve the bytes. + const stored = await QueuedFileStorage.getFile(key ?? ''); + expect(stored).toBeDefined(); + }); + + it('reclaims the stored file once its request is removed from the queue', async () => { + await PersistedRequests.save(makeReceiptRequest(2)); + await waitForBatchedUpdates(); + + const persisted = await OnyxUtils.get(ONYXKEYS.PERSISTED_REQUESTS); + const key = getQueuedFileKey(persisted?.at(0)); + expect(typeof key).toBe('string'); + expect(await QueuedFileStorage.getFile(key ?? '')).toBeDefined(); + + // Removing the request should fire-and-forget delete of its stored file. + const persistedRequest = PersistedRequests.getAll().at(0); + if (persistedRequest) { + PersistedRequests.endRequestAndRemoveFromQueue(persistedRequest); + } + await waitForBatchedUpdates(); + + expect(await QueuedFileStorage.getFile(key ?? '')).toBeUndefined(); + }); + + it('reclaims the stored file after a successful send (request was the ongoing one)', async () => { + await PersistedRequests.save(makeReceiptRequest(4)); + await waitForBatchedUpdates(); + + const persisted = await OnyxUtils.get(ONYXKEYS.PERSISTED_REQUESTS); + const key = getQueuedFileKey(persisted?.at(0)); + expect(await QueuedFileStorage.getFile(key ?? '')).toBeDefined(); + + // Mirror the real SequentialQueue success path: the request is moved to ongoing (sliced + // out of the queue) and then removed on success — so it is no longer found in the queue. + // Its file must still be reclaimed. + const ongoing = PersistedRequests.processNextRequest(); + if (ongoing) { + PersistedRequests.endRequestAndRemoveFromQueue(ongoing); + } + await waitForBatchedUpdates(); + + expect(await QueuedFileStorage.getFile(key ?? '')).toBeUndefined(); + }); + + it('purges stored files when Onyx is cleared (logout/reset)', async () => { + await PersistedRequests.save(makeReceiptRequest(3)); + await waitForBatchedUpdates(); + + const persisted = await OnyxUtils.get(ONYXKEYS.PERSISTED_REQUESTS); + const key = getQueuedFileKey(persisted?.at(0)); + expect(await QueuedFileStorage.getFile(key ?? '')).toBeDefined(); + + // Onyx.clear() is the real logout/reset path — it wipes the queue key but lives outside + // the file store, so the connect callback must purge the orphaned file itself. + await Onyx.clear(); + await waitForBatchedUpdates(); + + expect(await QueuedFileStorage.getFile(key ?? '')).toBeUndefined(); + }); +});