Skip to content
Closed
Show file tree
Hide file tree
Changes from 5 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.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the comment. I presume onsuccess always gets called after onupgradeneeded ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that is correct: onsuccess is called after onupgradeneeded (as long as onupgradeneeded doesn't throw an exception)

const clearSiteDataEvent: ClearSiteDataDatabaseDeletedEvent[] = [];

request.onsuccess = (event: Event) => {
let error: unknown;
if (clearSiteDataEvent[0]) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the c++ side of my brain hurts when i see this :)

TS playground suggests it's safe to do, but why not use if (clearSiteDataEvent.length > 0) { ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What, you don't like the looks of accessing uninitialized memory? ;)

My memory told me that the TypeScript compiler was failing to infer that clearSiteDataEvent[0] is defined when the if statement checked clearSiteDataEvent.length > 0; however, I just tested it out and it appears to compile just fine. So, I've changed if to check for a non-zero length.

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
Loading