diff --git a/.changeset/fix-auth-indexeddb-retry.md b/.changeset/fix-auth-indexeddb-retry.md new file mode 100644 index 0000000000..64747c612d --- /dev/null +++ b/.changeset/fix-auth-indexeddb-retry.md @@ -0,0 +1,7 @@ +--- +'@firebase/auth': patch +'@firebase/auth-compat': patch +'firebase': patch +--- + +Updated `_isAvailable()` to use retry logic for the initial IndexedDB availability check, preventing incorrect fallbacks to in-memory persistence in environments where transactions may occasionally drop on startup. diff --git a/packages/auth/src/platform_browser/persistence/indexed_db.test.ts b/packages/auth/src/platform_browser/persistence/indexed_db.test.ts index 9d78e18de0..e4e1fb144c 100644 --- a/packages/auth/src/platform_browser/persistence/indexed_db.test.ts +++ b/packages/auth/src/platform_browser/persistence/indexed_db.test.ts @@ -39,6 +39,7 @@ import { _clearDatabase, _openDatabase, _POLLING_INTERVAL_MS, + _TRANSACTION_RETRY_COUNT, _putObject } from './indexed_db'; @@ -53,6 +54,14 @@ describe('platform_browser/persistence/indexed_db', () => { indexedDBLocalPersistence ); + beforeEach(() => { + (persistence as any).dbPromise = null; + (persistence as any).listeners = {}; + (persistence as any).localCache = {}; + (persistence as any).pendingWrites = 0; + (persistence as any).stopPolling(); + }); + afterEach(sinon.restore); async function waitUntilPoll(clock: sinon.SinonFakeTimers): Promise { @@ -91,7 +100,8 @@ describe('platform_browser/persistence/indexed_db', () => { expect(await persistence._isAvailable()).to.be.true; }); - it('should return false if db creation errors', async () => { + it('should return false if db creation errors repeatedly', async () => { + (persistence as any).dbPromise = null; sinon.stub(indexedDB, 'open').returns({ addEventListener(evt: string, cb: () => void) { if (evt === 'error') { @@ -102,6 +112,36 @@ describe('platform_browser/persistence/indexed_db', () => { } as any); expect(await persistence._isAvailable()).to.be.false; + expect((indexedDB.open as sinon.SinonStub).callCount).to.eq( + _TRANSACTION_RETRY_COUNT + 2 + ); + }); + + it('should retry if db creation errors temporarily and then succeed', async () => { + (persistence as any).dbPromise = null; + const originalOpen = indexedDB.open.bind(indexedDB); + let errorsToThrow = 2; + + sinon.stub(indexedDB, 'open').callsFake((( + name: string, + version?: number + ) => { + if (errorsToThrow > 0) { + errorsToThrow--; + return { + addEventListener(evt: string, cb: () => void) { + if (evt === 'error') { + cb(); + } + }, + error: new DOMException('temporary error') + } as any; + } + return originalOpen(name, version); + }) as typeof indexedDB.open); + + expect(await persistence._isAvailable()).to.be.true; + expect((indexedDB.open as sinon.SinonStub).callCount).to.eq(3); }); }); @@ -116,6 +156,10 @@ describe('platform_browser/persistence/indexed_db', () => { db = await _openDatabase(); }); + after(async () => { + db.close(); + }); + beforeEach(async () => { clock = sinon.useFakeTimers(); callback = sinon.spy(); @@ -134,6 +178,7 @@ describe('platform_browser/persistence/indexed_db', () => { }); it('should trigger a listener when the key changes', async () => { + await persistence._get(key); // Ensure cache is populated before change await _putObject(db, key, newValue); await waitUntilPoll(clock); @@ -154,6 +199,7 @@ describe('platform_browser/persistence/indexed_db', () => { }); it('should not trigger the listener when a different key changes', async () => { + await persistence._get(key); // Ensure cache is populated await _putObject(db, 'other-key', newValue); await waitUntilPoll(clock); @@ -162,6 +208,7 @@ describe('platform_browser/persistence/indexed_db', () => { }); it('should not trigger if a write is pending', async () => { + await persistence._get(key); // Ensure cache is populated await _putObject(db, key, newValue); (persistence as any)['pendingWrites'] = 1; @@ -184,6 +231,7 @@ describe('platform_browser/persistence/indexed_db', () => { }); it('should trigger both listeners if multiple listeners are registered', async () => { + await persistence._get(key); // Ensure cache is populated await _putObject(db, key, newValue); await waitUntilPoll(clock); diff --git a/packages/auth/src/platform_browser/persistence/indexed_db.ts b/packages/auth/src/platform_browser/persistence/indexed_db.ts index 597b45d3ff..c7f4a07eab 100644 --- a/packages/auth/src/platform_browser/persistence/indexed_db.ts +++ b/packages/auth/src/platform_browser/persistence/indexed_db.ts @@ -157,7 +157,7 @@ class IndexedDBLocalPersistence implements InternalPersistence { static type: 'LOCAL' = 'LOCAL'; type = PersistenceType.LOCAL; - db?: IDBDatabase; + private dbPromise: Promise | null = null; readonly _shouldAllowMigration = true; private readonly listeners: Record> = {}; @@ -184,11 +184,14 @@ class IndexedDBLocalPersistence implements InternalPersistence { } async _openDb(): Promise { - if (this.db) { - return this.db; + if (this.dbPromise) { + return this.dbPromise; } - this.db = await _openDatabase(); - return this.db; + this.dbPromise = _openDatabase(); + this.dbPromise.catch(() => { + this.dbPromise = null; + }); + return this.dbPromise; } async _withRetries(op: (db: IDBDatabase) => Promise): Promise { @@ -202,9 +205,10 @@ class IndexedDBLocalPersistence implements InternalPersistence { if (numAttempts++ > _TRANSACTION_RETRY_COUNT) { throw e; } - if (this.db) { - this.db.close(); - this.db = undefined; + if (this.dbPromise) { + const db = await this.dbPromise; + db.close(); + this.dbPromise = null; } // TODO: consider adding exponential backoff } @@ -310,9 +314,10 @@ class IndexedDBLocalPersistence implements InternalPersistence { if (!indexedDB) { return false; } - const db = await _openDatabase(); - await _putObject(db, STORAGE_AVAILABLE_KEY, '1'); - await _deleteObject(db, STORAGE_AVAILABLE_KEY); + await this._withRetries(async (db: IDBDatabase) => { + await _putObject(db, STORAGE_AVAILABLE_KEY, '1'); + await _deleteObject(db, STORAGE_AVAILABLE_KEY); + }); return true; } catch {} return false;