diff --git a/.changeset/hot-birds-report.md b/.changeset/hot-birds-report.md new file mode 100644 index 00000000000..7dcc7092d4d --- /dev/null +++ b/.changeset/hot-birds-report.md @@ -0,0 +1,5 @@ +--- +'@firebase/firestore': patch +--- + +Terminate Firestore more gracefully when "Clear Site Data" button is pressed in a web browser diff --git a/packages/firestore/src/core/firestore_client.ts b/packages/firestore/src/core/firestore_client.ts index d04f37a3d7b..c18aa27f28b 100644 --- a/packages/firestore/src/core/firestore_client.ts +++ b/packages/firestore/src/core/firestore_client.ts @@ -231,22 +231,49 @@ export async function setOfflineComponentProvider( } }); - offlineComponentProvider.persistence.setDatabaseDeletedListener(() => { - logWarn('Terminating Firestore due to IndexedDb database deletion'); + offlineComponentProvider.persistence.setDatabaseDeletedListener(event => { + let error: FirestoreError | undefined; + + if (event.type === 'ClearSiteDataDatabaseDeletedEvent') { + // Throw FirestoreError rather than just Error so that the error will + // be treated as "non-retryable". + error = new FirestoreError( + 'failed-precondition', + `Terminating Firestore in response to "${event.type}" event ` + + `to prevent potential IndexedDB database corruption. ` + + `This situation could be caused by clicking the ` + + `"Clear Site Data" button in a web browser. ` + + `Try reloading the web page to re-initialize the ` + + `IndexedDB database.` + ); + logWarn(error.message, event.data); + } else { + logWarn( + `Terminating Firestore in response to "${event.type}" event`, + event.data + ); + } + client .terminate() .then(() => { logDebug( - 'Terminating Firestore due to IndexedDb database deletion ' + - 'completed successfully' + `Terminating Firestore in response to "${event.type}" event ` + + 'completed successfully', + event.data ); }) .catch(error => { logWarn( - 'Terminating Firestore due to IndexedDb database deletion failed', - error + `Terminating Firestore in response to "${event.type}" event failed:`, + error, + event.data ); }); + + if (error) { + throw error; + } }); client._offlineComponents = offlineComponentProvider; diff --git a/packages/firestore/src/local/persistence.ts b/packages/firestore/src/local/persistence.ts index 113efe7b7d3..5cf580a4cd0 100644 --- a/packages/firestore/src/local/persistence.ts +++ b/packages/firestore/src/local/persistence.ts @@ -98,7 +98,74 @@ export interface ReferenceDelegate { ): PersistencePromise; } -export type DatabaseDeletedListener = () => void; +/** + * A {@link DatabaseDeletedListener} event indicating that the IndexedDB + * database received a "versionchange" event with a null value for "newVersion". + * This event indicates that another tab in multi-tab IndexedDB persistence mode + * has called `clearIndexedDbPersistence()` and requires this tab to close its + * IndexedDB connection in order to allow the "clear" operation to proceed. + */ +export class VersionChangeDatabaseDeletedEvent { + /** A type discriminator. */ + readonly type = 'VersionChangeDatabaseDeletedEvent' as const; + + constructor( + readonly data: { + /** A unique ID for this event. */ + eventId: string; + /** + * The value of the "newVersion" property of the "versionchange" event + * that triggered this event. Its value is _always_ `null`, but is kept + * here for posterity. + */ + eventNewVersion: null; + } + ) {} +} + +/** + * A {@link DatabaseDeletedListener} event indicating that the "Clear Site Data" + * button in a web browser was (likely) clicked, deleting the IndexedDB + * database. + */ +export class ClearSiteDataDatabaseDeletedEvent { + /** A type discriminator. */ + readonly type = 'ClearSiteDataDatabaseDeletedEvent' as const; + + constructor( + readonly data: { + /** A unique ID for this event. */ + eventId: string; + /** The IndexedDB version that was last reported by the database. */ + lastClosedVersion: number; + /** + * The value of the "oldVersion" property of the "onupgradeneeded" + * IndexedDB event that triggered this event. + */ + eventOldVersion: number; + /** + * The value of the "newVersion" property of the "onupgradeneeded" + * IndexedDB event that triggered this event. + */ + eventNewVersion: number | null; + /** + * The value of the "version" property of the "IDBDatabase" object. + */ + dbVersion: number; + } + ) {} +} + +/** + * The type of the "event" parameter of {@link DatabaseDeletedListener}. + */ +export type DatabaseDeletedListenerEvent = + | VersionChangeDatabaseDeletedEvent + | ClearSiteDataDatabaseDeletedEvent; + +export type DatabaseDeletedListener = ( + event: DatabaseDeletedListenerEvent +) => void; /** * Persistence is the lowest-level shared interface to persistent storage in diff --git a/packages/firestore/src/local/simple_db.ts b/packages/firestore/src/local/simple_db.ts index 1e315c5dae6..7cedad217fd 100644 --- a/packages/firestore/src/local/simple_db.ts +++ b/packages/firestore/src/local/simple_db.ts @@ -18,11 +18,16 @@ import { getGlobal, getUA, isIndexedDBAvailable } from '@firebase/util'; import { debugAssert } from '../util/assert'; +import { generateUniqueDebugId } from '../util/debug_uid'; import { Code, FirestoreError } from '../util/error'; -import { logDebug, logError, logWarn } from '../util/log'; +import { logDebug, logError } from '../util/log'; import { Deferred } from '../util/promise'; -import { DatabaseDeletedListener } from './persistence'; +import { + ClearSiteDataDatabaseDeletedEvent, + DatabaseDeletedListener, + VersionChangeDatabaseDeletedEvent +} from './persistence'; import { PersistencePromise } from './persistence_promise'; // References to `indexedDB` are guarded by SimpleDb.isAvailable() and getGlobal() @@ -299,9 +304,33 @@ export class SimpleDb { // https://developer.mozilla.org/en-US/docs/Web/API/IDBVersionChangeRequest/setVersion const request = indexedDB.open(this.name, this.version); + // Store information about "Clear Site Data" being detected in the + // "onupgradeneeded" event listener and handle it in the "onsuccess" + // event listener, as opposed to throwing directly from the + // "onupgradeneeded" event listener. Do this because throwing from the + // "onupgradeneeded" event listener results in a generic error being + // reported to the "onerror" event listener that cannot be distinguished + // from other errors. + const clearSiteDataEvent: ClearSiteDataDatabaseDeletedEvent[] = []; + request.onsuccess = (event: Event) => { + let error: unknown; + if (clearSiteDataEvent[0]) { + try { + this.databaseDeletedListener?.(clearSiteDataEvent[0]); + } catch (e) { + error = e; + } + } + const db = (event.target as IDBOpenDBRequest).result; - resolve(db); + + if (error) { + reject(error); + db.close(); + } else { + resolve(db); + } }; request.onblocked = () => { @@ -353,18 +382,14 @@ export class SimpleDb { this.lastClosedDbVersion !== null && this.lastClosedDbVersion !== event.oldVersion ) { - // This thrown error will get passed to the `onerror` callback - // registered above, and will then be propagated correctly. - throw new Error( - `refusing to open IndexedDB database due to potential ` + - `corruption of the IndexedDB database data; this corruption ` + - `could be caused by clicking the "clear site data" button in ` + - `a web browser; try reloading the web page to re-initialize ` + - `the IndexedDB database: ` + - `lastClosedDbVersion=${this.lastClosedDbVersion}, ` + - `event.oldVersion=${event.oldVersion}, ` + - `event.newVersion=${event.newVersion}, ` + - `db.version=${db.version}` + clearSiteDataEvent.push( + new ClearSiteDataDatabaseDeletedEvent({ + eventId: generateUniqueDebugId(), + lastClosedVersion: this.lastClosedDbVersion, + eventOldVersion: event.oldVersion, + eventNewVersion: event.newVersion, + dbVersion: db.version + }) ); } this.schemaConverter @@ -399,11 +424,12 @@ export class SimpleDb { // Notify the listener if another tab attempted to delete the IndexedDb // database, such as by calling clearIndexedDbPersistence(). if (event.newVersion === null) { - logWarn( - `Received "versionchange" event with newVersion===null; ` + - 'notifying the registered DatabaseDeletedListener, if any' + this.databaseDeletedListener?.( + new VersionChangeDatabaseDeletedEvent({ + eventId: generateUniqueDebugId(), + eventNewVersion: event.newVersion + }) ); - this.databaseDeletedListener?.(); } }, { passive: true }