Skip to content

change(firestore): close Firestore instance more gracefully when "Clear Site Data" button pressed in browser #1

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/hot-birds-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@firebase/firestore': patch
---

Terminate Firestore more gracefully when "Clear Site Data" button is pressed in a web browser
39 changes: 33 additions & 6 deletions packages/firestore/src/core/firestore_client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
69 changes: 68 additions & 1 deletion packages/firestore/src/local/persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,74 @@ export interface ReferenceDelegate {
): PersistencePromise<void>;
}

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
Expand Down
64 changes: 45 additions & 19 deletions packages/firestore/src/local/simple_db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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 = () => {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 }
Expand Down