diff --git a/lib/storage/index.ts b/lib/storage/index.ts index c143c8e85..6dcd8e0bd 100644 --- a/lib/storage/index.ts +++ b/lib/storage/index.ts @@ -29,30 +29,16 @@ function degradePerformance(error: Error) { * Runs a piece of code and degrades performance if certain errors are thrown */ function tryOrDegradePerformance(fn: () => Promise | T, waitForInitialization = true): Promise { - return new Promise((resolve, reject) => { - const promise = waitForInitialization ? initPromise : Promise.resolve(); - - promise.then(() => { - try { - resolve(fn()); - } catch (error) { - // Test for known critical errors that the storage provider throws, e.g. when storage is full - if (error instanceof Error) { - // IndexedDB error when storage is full (https://github.com/Expensify/App/issues/29403) - if (error.message.includes('Internal error opening backing store for indexedDB.open')) { - degradePerformance(error); - } - - // catch the error if DB connection can not be established/DB can not be created - if (error.message.includes('IDBKeyVal store could not be created')) { - degradePerformance(error); - } - } - - reject(error); + const initialization = waitForInitialization ? initPromise : Promise.resolve(); + return initialization + .then(() => fn()) + .catch((error: unknown) => { + // catch the error if DB connection can not be established/DB can not be created + if (error instanceof Error && error.message.includes('IDBKeyVal store could not be created')) { + degradePerformance(error); } + return Promise.reject(error); }); - }); } const storage: Storage = { diff --git a/tests/unit/storage/tryOrDegradePerformanceTest.ts b/tests/unit/storage/tryOrDegradePerformanceTest.ts new file mode 100644 index 000000000..b160b5903 --- /dev/null +++ b/tests/unit/storage/tryOrDegradePerformanceTest.ts @@ -0,0 +1,128 @@ +import type * as LoggerModule from '../../../lib/Logger'; +import type storageModule from '../../../lib/storage'; + +// `jestSetup.js` globally mocks `lib/storage`; this suite tests the real implementation. +jest.unmock('../../../lib/storage'); + +type Storage = typeof storageModule; +type Logger = typeof LoggerModule; +type LoggerCallback = Parameters[0]; +type LogData = Parameters[0]; + +type IsolatedModules = { + storage: Storage; + Logger: Logger; +}; + +type CapturedLog = {level: string; message: string}; + +/** + * Load a fresh copy of `lib/storage` (and its `Logger` dependency) in an isolated + * module registry so the module-private `provider` state in `lib/storage/index.ts` + * does not leak between tests. + */ +function loadIsolatedStorage(): IsolatedModules { + let storage!: Storage; + let Logger!: Logger; + + jest.isolateModules(() => { + Logger = require('../../../lib/Logger'); + storage = require('../../../lib/storage').default; + }); + + return {storage, Logger}; +} + +function noop() { + // intentionally empty +} + +describe('storage/tryOrDegradePerformance', () => { + // Fake timers cause the init promise chain to hang. + beforeAll(() => jest.useRealTimers()); + + let consoleErrorSpy: jest.SpyInstance; + + beforeEach(() => { + // `degradePerformance` calls `console.error` — silence it to keep test output clean. + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(noop); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + }); + + it('falls back to MemoryOnlyProvider when a storage op rejects asynchronously with "IDBKeyVal store could not be created"', async () => { + const {storage, Logger} = loadIsolatedStorage(); + const capturedLogs: CapturedLog[] = []; + Logger.registerLogger((data: LogData) => capturedLogs.push({level: data.level, message: data.message})); + + storage.init(); + + const originalProvider = storage.getStorageProvider(); + const targetError = new Error('IDBKeyVal store could not be created'); + originalProvider.getAllKeys = jest.fn().mockReturnValue(Promise.reject(targetError)); + + await expect(storage.getAllKeys()).rejects.toBe(targetError); + + expect(capturedLogs.some((log) => log.level === 'hmmm' && log.message.includes('Falling back to only using cache'))).toBe(true); + expect(storage.getStorageProvider().name).toBe('MemoryOnlyProvider'); + }); + + it('propagates async rejections with unrelated messages without falling back', async () => { + const {storage, Logger} = loadIsolatedStorage(); + const capturedLogs: CapturedLog[] = []; + Logger.registerLogger((data: LogData) => capturedLogs.push({level: data.level, message: data.message})); + + storage.init(); + + const originalProvider = storage.getStorageProvider(); + const originalProviderName = originalProvider.name; + const unrelatedError = new Error('Some unrelated storage failure'); + originalProvider.getAllKeys = jest.fn().mockReturnValue(Promise.reject(unrelatedError)); + + await expect(storage.getAllKeys()).rejects.toBe(unrelatedError); + + expect(capturedLogs.some((log) => log.level === 'hmmm' && log.message.includes('Falling back to only using cache'))).toBe(false); + expect(storage.getStorageProvider().name).toBe(originalProviderName); + }); + + it('falls back to MemoryOnlyProvider when a storage op throws synchronously with "IDBKeyVal store could not be created"', async () => { + const {storage, Logger} = loadIsolatedStorage(); + const capturedLogs: CapturedLog[] = []; + Logger.registerLogger((data: LogData) => capturedLogs.push({level: data.level, message: data.message})); + + storage.init(); + + const originalProvider = storage.getStorageProvider(); + const targetError = new Error('IDBKeyVal store could not be created'); + originalProvider.getAllKeys = jest.fn().mockImplementation(() => { + throw targetError; + }); + + await expect(storage.getAllKeys()).rejects.toBe(targetError); + + expect(capturedLogs.some((log) => log.level === 'hmmm' && log.message.includes('Falling back to only using cache'))).toBe(true); + expect(storage.getStorageProvider().name).toBe('MemoryOnlyProvider'); + }); + + it('propagates sync throws with unrelated messages without falling back', async () => { + const {storage, Logger} = loadIsolatedStorage(); + const capturedLogs: CapturedLog[] = []; + Logger.registerLogger((data: LogData) => capturedLogs.push({level: data.level, message: data.message})); + + storage.init(); + + const originalProvider = storage.getStorageProvider(); + const originalProviderName = originalProvider.name; + const unrelatedError = new Error('Some unrelated storage failure'); + originalProvider.getAllKeys = jest.fn().mockImplementation(() => { + throw unrelatedError; + }); + + await expect(storage.getAllKeys()).rejects.toBe(unrelatedError); + + expect(capturedLogs.some((log) => log.level === 'hmmm' && log.message.includes('Falling back to only using cache'))).toBe(false); + expect(storage.getStorageProvider().name).toBe(originalProviderName); + }); +});