diff --git a/packages/firebase/index.d.ts b/packages/firebase/index.d.ts index 9987266aaad..93df8ad400f 100644 --- a/packages/firebase/index.d.ts +++ b/packages/firebase/index.d.ts @@ -7760,7 +7760,7 @@ declare namespace firebase.firestore { export interface PersistenceSettings { /** * Whether to synchronize the in-memory state of multiple tabs. Setting this - * to 'true' in all open tabs enables shared access to local persistence, + * to `true` in all open tabs enables shared access to local persistence, * shared execution of queries and latency-compensated local document updates * across all connected instances. * @@ -7772,14 +7772,27 @@ declare namespace firebase.firestore { /** * Whether to synchronize the in-memory state of multiple tabs. Setting this - * to 'true' in all open tabs enables shared access to local persistence, + * to `true` in all open tabs enables shared access to local persistence, * shared execution of queries and latency-compensated local document updates * across all connected instances. * - * @deprecated This setting is deprecated. To enabled synchronization between + * @deprecated This setting is deprecated. To enable synchronization between * multiple tabs, please use `synchronizeTabs: true` instead. */ experimentalTabSynchronization?: boolean; + + /** + * Whether to force enable persistence for the client. This cannot be used + * with `synchronizeTabs:true` and is primarily intended for use with Web + * Workers. Setting this to `true` will enable persistence, but cause other + * tabs using persistence to fail. + * + * This setting may be removed in a future release. If you find yourself + * using it for a specific use case or run into any issues, please tell us + * about it in + * https://github.com/firebase/firebase-js-sdk/issues/983. + */ + experimentalForceOwningTab?: boolean; } export type LogLevel = 'debug' | 'error' | 'silent'; diff --git a/packages/firestore-types/index.d.ts b/packages/firestore-types/index.d.ts index 4b5e745f70a..24a8f3088cc 100644 --- a/packages/firestore-types/index.d.ts +++ b/packages/firestore-types/index.d.ts @@ -35,6 +35,7 @@ export interface Settings { export interface PersistenceSettings { synchronizeTabs?: boolean; experimentalTabSynchronization?: boolean; + experimentalForceOwningTab?: boolean; } export type LogLevel = 'debug' | 'error' | 'silent'; diff --git a/packages/firestore/CHANGELOG.md b/packages/firestore/CHANGELOG.md index 3950a296405..acbcef77393 100644 --- a/packages/firestore/CHANGELOG.md +++ b/packages/firestore/CHANGELOG.md @@ -14,6 +14,9 @@ in IndexedDB. Previously, these errors crashed the client. - [fixed] Fixed a source of IndexedDB-related crashes for tabs that receive multi-tab notifications while the file system is locked. +- [feature] Added an `experimentalForceOwningTab` setting that can be used to + enable persistence in environments without LocalStorage, which allows + persistence to be used in Web Workers (#983). # 1.10.2 - [fixed] Temporarily reverted the use of window.crypto to generate document diff --git a/packages/firestore/src/api/database.ts b/packages/firestore/src/api/database.ts index 0757e1955da..70a7a307c72 100644 --- a/packages/firestore/src/api/database.ts +++ b/packages/firestore/src/api/database.ts @@ -384,6 +384,7 @@ export class Firestore implements firestore.FirebaseFirestore, FirebaseService { } let synchronizeTabs = false; + let experimentalForceOwningTab = false; if (settings) { if (settings.experimentalTabSynchronization !== undefined) { @@ -395,12 +396,24 @@ export class Firestore implements firestore.FirebaseFirestore, FirebaseService { settings.synchronizeTabs ?? settings.experimentalTabSynchronization ?? DEFAULT_SYNCHRONIZE_TABS; + + experimentalForceOwningTab = settings.experimentalForceOwningTab + ? settings.experimentalForceOwningTab + : false; + + if (synchronizeTabs && experimentalForceOwningTab) { + throw new FirestoreError( + Code.INVALID_ARGUMENT, + "The 'experimentalForceOwningTab' setting cannot be used with 'synchronizeTabs'." + ); + } } return this.configureClient(this._componentProvider, { durable: true, cacheSizeBytes: this._settings.cacheSizeBytes, - synchronizeTabs + synchronizeTabs, + forceOwningTab: experimentalForceOwningTab }); } diff --git a/packages/firestore/src/core/component_provider.ts b/packages/firestore/src/core/component_provider.ts index e0d458dfd25..e542da22257 100644 --- a/packages/firestore/src/core/component_provider.ts +++ b/packages/firestore/src/core/component_provider.ts @@ -259,7 +259,8 @@ export class IndexedDbComponentProvider extends MemoryComponentProvider { LruParams.withCacheSize(cfg.persistenceSettings.cacheSizeBytes), cfg.asyncQueue, serializer, - this.sharedClientState + this.sharedClientState, + cfg.persistenceSettings.forceOwningTab ); } diff --git a/packages/firestore/src/core/firestore_client.ts b/packages/firestore/src/core/firestore_client.ts index 42fbe0173f5..296e3798146 100644 --- a/packages/firestore/src/core/firestore_client.ts +++ b/packages/firestore/src/core/firestore_client.ts @@ -65,6 +65,7 @@ export type PersistenceSettings = readonly durable: true; readonly cacheSizeBytes: number; readonly synchronizeTabs: boolean; + readonly forceOwningTab: boolean; }; /** diff --git a/packages/firestore/src/local/indexeddb_persistence.ts b/packages/firestore/src/local/indexeddb_persistence.ts index a0207e7e1c2..2f42dd52fb9 100644 --- a/packages/firestore/src/local/indexeddb_persistence.ts +++ b/packages/firestore/src/local/indexeddb_persistence.ts @@ -104,7 +104,9 @@ const CLIENT_METADATA_REFRESH_INTERVAL_MS = 4000; const PRIMARY_LEASE_EXCLUSIVE_ERROR_MSG = 'Failed to obtain exclusive access to the persistence layer. ' + 'To allow shared access, make sure to invoke ' + - '`enablePersistence()` with `synchronizeTabs:true` in all tabs.'; + '`enablePersistence()` with `synchronizeTabs:true` in all tabs. ' + + 'If you are using `experimentalForceOwningTab:true`, make sure that only ' + + 'one tab has persistence enabled at any given time.'; const UNSUPPORTED_PLATFORM_ERROR_MSG = 'This platform is either missing' + ' IndexedDB or is known to have an incomplete implementation. Offline' + @@ -190,7 +192,7 @@ export class IndexedDbPersistence implements Persistence { static MAIN_DATABASE = 'main'; private readonly document: Document | null; - private readonly window: Window; + private readonly window: Window | null; // Technically `simpleDb` should be `| undefined` because it is // initialized asynchronously by start(), but that would be more misleading @@ -225,18 +227,29 @@ export class IndexedDbPersistence implements Persistence { private readonly targetCache: IndexedDbTargetCache; private readonly indexManager: IndexedDbIndexManager; private readonly remoteDocumentCache: IndexedDbRemoteDocumentCache; - private readonly webStorage: Storage; + private readonly webStorage: Storage | null; readonly referenceDelegate: IndexedDbLruDelegate; constructor( + /** + * Whether to synchronize the in-memory state of multiple tabs and share + * access to local persistence. + */ private readonly allowTabSynchronization: boolean, + private readonly persistenceKey: string, private readonly clientId: ClientId, platform: Platform, lruParams: LruParams, private readonly queue: AsyncQueue, serializer: JsonProtoSerializer, - private readonly sequenceNumberSyncer: SequenceNumberSyncer + private readonly sequenceNumberSyncer: SequenceNumberSyncer, + + /** + * If set to true, forcefully obtains database access. Existing tabs will + * no longer be able to access IndexedDB. + */ + private readonly forceOwningTab: boolean ) { if (!IndexedDbPersistence.isAvailable()) { throw new FirestoreError( @@ -258,14 +271,19 @@ export class IndexedDbPersistence implements Persistence { this.serializer, this.indexManager ); + this.window = platform.window; if (platform.window && platform.window.localStorage) { - this.window = platform.window; - this.webStorage = this.window.localStorage; + this.webStorage = platform.window.localStorage; } else { - throw new FirestoreError( - Code.UNIMPLEMENTED, - 'IndexedDB persistence is only available on platforms that support LocalStorage.' - ); + this.webStorage = null; + if (forceOwningTab === false) { + logError( + LOG_TAG, + 'LocalStorage is unavailable. As a result, persistence may not work ' + + 'reliably. In particular enablePersistence() could fail immediately ' + + 'after refreshing the page.' + ); + } } } @@ -287,7 +305,9 @@ export class IndexedDbPersistence implements Persistence { this.simpleDb = db; // NOTE: This is expected to fail sometimes (in the case of another tab already // having the persistence lock), so it's the first thing we should do. - return this.updateClientMetadataAndTryBecomePrimary(); + return this.updateClientMetadataAndTryBecomePrimary( + this.forceOwningTab + ); }) .then(() => { if (!this.isPrimary && !this.allowTabSynchronization) { @@ -384,7 +404,9 @@ export class IndexedDbPersistence implements Persistence { * primary state listener if the client either newly obtained or released its * primary lease. */ - private updateClientMetadataAndTryBecomePrimary(): Promise { + private updateClientMetadataAndTryBecomePrimary( + forceOwningTab = false + ): Promise { return this.runTransaction( 'updateClientMetadataAndTryBecomePrimary', 'readwrite', @@ -519,11 +541,13 @@ export class IndexedDbPersistence implements Persistence { // Ideally we'd delete the IndexedDb and LocalStorage zombie entries for // the client atomically, but we can't. So we opt to delete the IndexedDb // entries first to avoid potentially reviving a zombied client. - inactiveClients.forEach(inactiveClient => { - this.window.localStorage.removeItem( - this.zombiedClientLocalStorageKey(inactiveClient.clientId) - ); - }); + if (this.webStorage) { + for (const inactiveClient of inactiveClients) { + this.webStorage.removeItem( + this.zombiedClientLocalStorageKey(inactiveClient.clientId) + ); + } + } } } @@ -558,6 +582,9 @@ export class IndexedDbPersistence implements Persistence { private canActAsPrimary( txn: PersistenceTransaction ): PersistencePromise { + if (this.forceOwningTab) { + return PersistencePromise.resolve(true); + } const store = primaryClientStore(txn); return store .get(DbPrimaryClient.key) @@ -578,6 +605,7 @@ export class IndexedDbPersistence implements Persistence { // foreground. // - every clients network is disabled and no other client's tab is in // the foreground. + // - the `forceOwningTab` setting was passed in. if (currentLeaseIsValid) { if (this.isLocalClient(currentPrimary) && this.networkEnabled) { return true; @@ -853,8 +881,9 @@ export class IndexedDbPersistence implements Persistence { if (currentLeaseIsValid && !this.isLocalClient(currentPrimary)) { if ( - !this.allowTabSynchronization || - !currentPrimary!.allowTabSynchronization + !this.forceOwningTab && + (!this.allowTabSynchronization || + !currentPrimary!.allowTabSynchronization) ) { throw new FirestoreError( Code.FAILED_PRECONDITION, @@ -983,7 +1012,7 @@ export class IndexedDbPersistence implements Persistence { * handler. */ private attachWindowUnloadHook(): void { - if (typeof this.window.addEventListener === 'function') { + if (typeof this.window?.addEventListener === 'function') { this.windowUnloadHandler = () => { // Note: In theory, this should be scheduled on the AsyncQueue since it // accesses internal state. We execute this code directly during shutdown @@ -1003,10 +1032,10 @@ export class IndexedDbPersistence implements Persistence { private detachWindowUnloadHook(): void { if (this.windowUnloadHandler) { debugAssert( - typeof this.window.removeEventListener === 'function', + typeof this.window?.removeEventListener === 'function', "Expected 'window.removeEventListener' to be a function" ); - this.window.removeEventListener('unload', this.windowUnloadHandler); + this.window!.removeEventListener('unload', this.windowUnloadHandler); this.windowUnloadHandler = null; } } @@ -1019,8 +1048,9 @@ export class IndexedDbPersistence implements Persistence { private isClientZombied(clientId: ClientId): boolean { try { const isZombied = - this.webStorage.getItem(this.zombiedClientLocalStorageKey(clientId)) !== - null; + this.webStorage?.getItem( + this.zombiedClientLocalStorageKey(clientId) + ) !== null; logDebug( LOG_TAG, `Client '${clientId}' ${ @@ -1040,6 +1070,9 @@ export class IndexedDbPersistence implements Persistence { * clients are ignored during primary tab selection. */ private markClientZombied(): void { + if (!this.webStorage) { + return; + } try { this.webStorage.setItem( this.zombiedClientLocalStorageKey(this.clientId), @@ -1053,6 +1086,9 @@ export class IndexedDbPersistence implements Persistence { /** Removes the zombied client entry if it exists. */ private removeClientZombiedEntry(): void { + if (!this.webStorage) { + return; + } try { this.webStorage.removeItem( this.zombiedClientLocalStorageKey(this.clientId) diff --git a/packages/firestore/src/local/simple_db.ts b/packages/firestore/src/local/simple_db.ts index 974d720095a..492e71d3714 100644 --- a/packages/firestore/src/local/simple_db.ts +++ b/packages/firestore/src/local/simple_db.ts @@ -78,7 +78,7 @@ export class SimpleDb { // suggests IE9 and older WebKit browsers handle upgrade // differently. They expect setVersion, as described here: // https://developer.mozilla.org/en-US/docs/Web/API/IDBVersionChangeRequest/setVersion - const request = window.indexedDB.open(name, version); + const request = indexedDB.open(name, version); request.onsuccess = (event: Event) => { const db = (event.target as IDBOpenDBRequest).result; @@ -145,7 +145,7 @@ export class SimpleDb { /** Returns true if IndexedDB is available in the current environment. */ static isAvailable(): boolean { - if (typeof window === 'undefined' || window.indexedDB == null) { + if (typeof indexedDB === 'undefined') { return false; } @@ -153,13 +153,6 @@ export class SimpleDb { return true; } - // In some Node environments, `window` is defined, but `window.navigator` is - // not. We don't support IndexedDB persistence in Node if the - // isMockPersistence() check above returns false. - if (window.navigator === undefined) { - return false; - } - // We extensively use indexed array values and compound keys, // which IE and Edge do not support. However, they still have indexedDB // defined on the window, so we need to check for them here and make sure diff --git a/packages/firestore/test/unit/local/indexeddb_persistence.test.ts b/packages/firestore/test/unit/local/indexeddb_persistence.test.ts index 4d334d0580d..eea95151db6 100644 --- a/packages/firestore/test/unit/local/indexeddb_persistence.test.ts +++ b/packages/firestore/test/unit/local/indexeddb_persistence.test.ts @@ -79,7 +79,6 @@ import { use(chaiAsPromised); /* eslint-disable no-restricted-globals */ - function withDb( schemaVersion: number, fn: (db: IDBDatabase) => Promise @@ -116,6 +115,7 @@ function withDb( async function withUnstartedCustomPersistence( clientId: ClientId, multiClient: boolean, + forceOwningTab: boolean, fn: ( persistence: MockIndexedDbPersistence, platform: TestPlatform, @@ -139,7 +139,8 @@ async function withUnstartedCustomPersistence( LruParams.DEFAULT, queue, serializer, - MOCK_SEQUENCE_NUMBER_SYNCER + MOCK_SEQUENCE_NUMBER_SYNCER, + forceOwningTab ); await fn(persistence, platform, queue); @@ -148,6 +149,7 @@ async function withUnstartedCustomPersistence( function withCustomPersistence( clientId: ClientId, multiClient: boolean, + forceOwningTab: boolean, fn: ( persistence: MockIndexedDbPersistence, platform: TestPlatform, @@ -157,6 +159,7 @@ function withCustomPersistence( return withUnstartedCustomPersistence( clientId, multiClient, + forceOwningTab, async (persistence, platform, queue) => { await persistence.start(); await fn(persistence, platform, queue); @@ -173,7 +176,12 @@ async function withPersistence( queue: AsyncQueue ) => Promise ): Promise { - return withCustomPersistence(clientId, /* multiClient= */ false, fn); + return withCustomPersistence( + clientId, + /* multiClient= */ false, + /* forceOwningTab= */ false, + fn + ); } async function withMultiClientPersistence( @@ -184,7 +192,28 @@ async function withMultiClientPersistence( queue: AsyncQueue ) => Promise ): Promise { - return withCustomPersistence(clientId, /* multiClient= */ true, fn); + return withCustomPersistence( + clientId, + /* multiClient= */ true, + /* forceOwningTab= */ false, + fn + ); +} + +async function withForcedPersistence( + clientId: ClientId, + fn: ( + persistence: IndexedDbPersistence, + platform: TestPlatform, + queue: AsyncQueue + ) => Promise +): Promise { + return withCustomPersistence( + clientId, + /* multiClient= */ false, + /* forceOwningTab= */ true, + fn + ); } function getAllObjectStores(db: IDBDatabase): string[] { @@ -1131,6 +1160,18 @@ describe('IndexedDb: canActAsPrimary', () => { expect(await getCurrentLeaseOwner()).to.not.be.null; }); }); + + it('obtains lease if forceOwningTab is set', () => { + return withPersistence('clientA', async clientA => { + await withForcedPersistence('clientB', async () => { + return expect( + clientA.runTransaction('tx', 'readwrite-primary', () => + PersistencePromise.resolve() + ) + ).to.be.eventually.rejected; + }); + }); + }); }); describe('IndexedDb: allowTabSynchronization', () => { @@ -1147,6 +1188,7 @@ describe('IndexedDb: allowTabSynchronization', () => { await withUnstartedCustomPersistence( 'clientA', /* multiClient= */ false, + /* forceOwningTab= */ false, async db => { db.injectFailures = ['updateClientMetadataAndTryBecomePrimary']; await expect(db.start()).to.eventually.be.rejectedWith( @@ -1161,6 +1203,7 @@ describe('IndexedDb: allowTabSynchronization', () => { await withUnstartedCustomPersistence( 'clientA', /* multiClient= */ true, + /* forceOwningTab= */ false, async db => { db.injectFailures = ['updateClientMetadataAndTryBecomePrimary']; await db.start(); @@ -1173,6 +1216,7 @@ describe('IndexedDb: allowTabSynchronization', () => { await withUnstartedCustomPersistence( 'clientA', /* multiClient= */ false, + /* forceOwningTab= */ false, async db1 => { db1.injectFailures = ['getHighestListenSequenceNumber']; await expect(db1.start()).to.eventually.be.rejectedWith( diff --git a/packages/firestore/test/unit/local/persistence_test_helpers.ts b/packages/firestore/test/unit/local/persistence_test_helpers.ts index b60b32d4264..2a2913d590a 100644 --- a/packages/firestore/test/unit/local/persistence_test_helpers.ts +++ b/packages/firestore/test/unit/local/persistence_test_helpers.ts @@ -123,7 +123,8 @@ export async function testIndexedDbPersistence( lruParams, queue, JSON_SERIALIZER, - MOCK_SEQUENCE_NUMBER_SYNCER + MOCK_SEQUENCE_NUMBER_SYNCER, + /** forceOwningTab= */ false ); await persistence.start(); return persistence; diff --git a/packages/firestore/test/unit/specs/spec_test_components.ts b/packages/firestore/test/unit/specs/spec_test_components.ts index 9fba3e611a4..bd7ad4820e8 100644 --- a/packages/firestore/test/unit/specs/spec_test_components.ts +++ b/packages/firestore/test/unit/specs/spec_test_components.ts @@ -135,7 +135,8 @@ export class MockIndexedDbComponentProvider extends IndexedDbComponentProvider { LruParams.withCacheSize(cfg.persistenceSettings.cacheSizeBytes), cfg.asyncQueue, serializer, - this.sharedClientState + this.sharedClientState, + cfg.persistenceSettings.forceOwningTab ); } } diff --git a/packages/firestore/test/unit/specs/spec_test_runner.ts b/packages/firestore/test/unit/specs/spec_test_runner.ts index 74c66c74544..90ed1f6ebbf 100644 --- a/packages/firestore/test/unit/specs/spec_test_runner.ts +++ b/packages/firestore/test/unit/specs/spec_test_runner.ts @@ -1086,7 +1086,8 @@ class IndexedDbTestRunner extends TestRunner { { durable: true, cacheSizeBytes: LruParams.DEFAULT_CACHE_SIZE_BYTES, - synchronizeTabs: true + synchronizeTabs: true, + forceOwningTab: false }, clientIndex, config diff --git a/packages/firestore/test/util/node_persistence.ts b/packages/firestore/test/util/node_persistence.ts index aa95831da50..49857fc1625 100644 --- a/packages/firestore/test/util/node_persistence.ts +++ b/packages/firestore/test/util/node_persistence.ts @@ -42,6 +42,8 @@ if (process.env.USE_MOCK_PERSISTENCE === 'YES') { deleteDatabaseFiles: true }); + // 'indexeddbshim' installs IndexedDB onto `globalAny`, which means we don't + // have to register it ourselves. const fakeWindow = new FakeWindow( new SharedFakeWebStorage(), globalAny.indexedDB