Fix encoding of CSI keys in Safari (#8993)

This commit is contained in:
Mark Duckworth
2025-05-09 14:45:08 -06:00
committed by GitHub
parent 8afa40af94
commit a028af9859
10 changed files with 204 additions and 59 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"@firebase/firestore": patch
"@firebase/util": minor
---
Fix Safari/WebKit cache issues when client-side indexing is used.
+5
View File
@@ -317,6 +317,11 @@ export function isReactNative(): boolean;
// @public
export function isSafari(): boolean;
// Warning: (ae-missing-release-tag) "isSafariOrWebkit" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
export function isSafariOrWebkit(): boolean;
// Warning: (ae-missing-release-tag) "issuedAtTime" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
+1
View File
@@ -53,6 +53,7 @@ const config = {
// Doing 65 seconds to allow for the 20 second firestore tests
browserNoActivityTimeout: 65000,
browserDisconnectTimeout: 65000,
// Preprocess matching files before serving them to the browser.
// Available preprocessors:
+2
View File
@@ -36,6 +36,8 @@
"test:browser:emulator": "karma start --targetBackend=emulator",
"test:browser:nightly": "karma start --targetBackend=nightly",
"test:browser:prod": "karma start --targetBackend=prod",
"test:webkit:prod": "BROWSERS=WebkitHeadless karma start --targetBackend=prod",
"test:webkit:unit": "BROWSERS=WebkitHeadless karma start --unit --targetBackend=prod",
"test:browser:prod:nameddb": "karma start --targetBackend=prod --databaseId=test-db",
"test:browser:unit": "karma start --unit",
"test:browser:debug": "karma start --browsers=Chrome --auto-watch",
+106 -15
View File
@@ -15,15 +15,19 @@
* limitations under the License.
*/
import { isSafariOrWebkit } from '@firebase/util';
import { DbIndexEntry } from '../local/indexeddb_schema';
import { DbIndexEntryKey, KeySafeBytes } from '../local/indexeddb_sentinels';
import { DocumentKey } from '../model/document_key';
/** Represents an index entry saved by the SDK in persisted storage. */
export class IndexEntry {
constructor(
readonly indexId: number,
readonly documentKey: DocumentKey,
readonly arrayValue: Uint8Array,
readonly directionalValue: Uint8Array
readonly _indexId: number,
readonly _documentKey: DocumentKey,
readonly _arrayValue: Uint8Array,
readonly _directionalValue: Uint8Array
) {}
/**
@@ -31,49 +35,82 @@ export class IndexEntry {
* directional value.
*/
successor(): IndexEntry {
const currentLength = this.directionalValue.length;
const currentLength = this._directionalValue.length;
const newLength =
currentLength === 0 || this.directionalValue[currentLength - 1] === 255
currentLength === 0 || this._directionalValue[currentLength - 1] === 255
? currentLength + 1
: currentLength;
const successor = new Uint8Array(newLength);
successor.set(this.directionalValue, 0);
successor.set(this._directionalValue, 0);
if (newLength !== currentLength) {
successor.set([0], this.directionalValue.length);
successor.set([0], this._directionalValue.length);
} else {
++successor[successor.length - 1];
}
return new IndexEntry(
this.indexId,
this.documentKey,
this.arrayValue,
this._indexId,
this._documentKey,
this._arrayValue,
successor
);
}
// Create a representation of the Index Entry as a DbIndexEntry
dbIndexEntry(
uid: string,
orderedDocumentKey: Uint8Array,
documentKey: DocumentKey
): DbIndexEntry {
return {
indexId: this._indexId,
uid,
arrayValue: encodeKeySafeBytes(this._arrayValue),
directionalValue: encodeKeySafeBytes(this._directionalValue),
orderedDocumentKey: encodeKeySafeBytes(orderedDocumentKey),
documentKey: documentKey.path.toArray()
};
}
// Create a representation of the Index Entry as a DbIndexEntryKey
dbIndexEntryKey(
uid: string,
orderedDocumentKey: Uint8Array,
documentKey: DocumentKey
): DbIndexEntryKey {
const entry = this.dbIndexEntry(uid, orderedDocumentKey, documentKey);
return [
entry.indexId,
entry.uid,
entry.arrayValue,
entry.directionalValue,
entry.orderedDocumentKey,
entry.documentKey
];
}
}
export function indexEntryComparator(
left: IndexEntry,
right: IndexEntry
): number {
let cmp = left.indexId - right.indexId;
let cmp = left._indexId - right._indexId;
if (cmp !== 0) {
return cmp;
}
cmp = compareByteArrays(left.arrayValue, right.arrayValue);
cmp = compareByteArrays(left._arrayValue, right._arrayValue);
if (cmp !== 0) {
return cmp;
}
cmp = compareByteArrays(left.directionalValue, right.directionalValue);
cmp = compareByteArrays(left._directionalValue, right._directionalValue);
if (cmp !== 0) {
return cmp;
}
return DocumentKey.comparator(left.documentKey, right.documentKey);
return DocumentKey.comparator(left._documentKey, right._documentKey);
}
export function compareByteArrays(left: Uint8Array, right: Uint8Array): number {
@@ -85,3 +122,57 @@ export function compareByteArrays(left: Uint8Array, right: Uint8Array): number {
}
return left.length - right.length;
}
/**
* Workaround for WebKit bug: https://bugs.webkit.org/show_bug.cgi?id=292721
* Create a key safe representation of Uint8Array values.
* If the browser is detected as Safari or WebKit, then
* the input array will be converted to "sortable byte string".
* Otherwise, the input array will be returned in its original type.
*/
export function encodeKeySafeBytes(array: Uint8Array): KeySafeBytes {
if (isSafariOrWebkit()) {
return encodeUint8ArrayToSortableString(array);
}
return array;
}
/**
* Reverts the key safe representation of Uint8Array (created by
* encodeKeySafeBytes) to a normal Uint8Array.
*/
export function decodeKeySafeBytes(input: KeySafeBytes): Uint8Array {
if (typeof input !== 'string') {
return input;
}
return decodeSortableStringToUint8Array(input);
}
/**
* Encodes a Uint8Array into a "sortable byte string".
* A "sortable byte string" sorts in the same order as the Uint8Array.
* This works because JS string comparison sorts strings based on code points.
*/
function encodeUint8ArrayToSortableString(array: Uint8Array): string {
let byteString = '';
for (let i = 0; i < array.length; i++) {
byteString += String.fromCharCode(array[i]);
}
return byteString;
}
/**
* Decodes a "sortable byte string" back into a Uint8Array.
* A "sortable byte string" is assumed to be created where each character's
* Unicode code point directly corresponds to a single byte value (0-255).
*/
function decodeSortableStringToUint8Array(byteString: string): Uint8Array {
const uint8array = new Uint8Array(byteString.length);
for (let i = 0; i < byteString.length; i++) {
uint8array[i] = byteString.charCodeAt(i);
}
return uint8array;
}
@@ -39,7 +39,12 @@ import {
} from '../core/target';
import { FirestoreIndexValueWriter } from '../index/firestore_index_value_writer';
import { IndexByteEncoder } from '../index/index_byte_encoder';
import { IndexEntry, indexEntryComparator } from '../index/index_entry';
import {
IndexEntry,
indexEntryComparator,
encodeKeySafeBytes,
decodeKeySafeBytes
} from '../index/index_entry';
import { documentKeySet, DocumentMap } from '../model/collections';
import { Document } from '../model/document';
import { DocumentKey } from '../model/document_key';
@@ -817,14 +822,13 @@ export class IndexedDbIndexManager implements IndexManager {
indexEntry: IndexEntry
): PersistencePromise<void> {
const indexEntries = indexEntriesStore(transaction);
return indexEntries.put({
indexId: indexEntry.indexId,
uid: this.uid,
arrayValue: indexEntry.arrayValue,
directionalValue: indexEntry.directionalValue,
orderedDocumentKey: this.encodeDirectionalKey(fieldIndex, document.key),
documentKey: document.key.path.toArray()
});
return indexEntries.put(
indexEntry.dbIndexEntry(
this.uid,
this.encodeDirectionalKey(fieldIndex, document.key),
document.key
)
);
}
private deleteIndexEntry(
@@ -834,14 +838,13 @@ export class IndexedDbIndexManager implements IndexManager {
indexEntry: IndexEntry
): PersistencePromise<void> {
const indexEntries = indexEntriesStore(transaction);
return indexEntries.delete([
indexEntry.indexId,
this.uid,
indexEntry.arrayValue,
indexEntry.directionalValue,
this.encodeDirectionalKey(fieldIndex, document.key),
document.key.path.toArray()
]);
return indexEntries.delete(
indexEntry.dbIndexEntryKey(
this.uid,
this.encodeDirectionalKey(fieldIndex, document.key),
document.key
)
);
}
private getExistingIndexEntries(
@@ -858,7 +861,9 @@ export class IndexedDbIndexManager implements IndexManager {
range: IDBKeyRange.only([
fieldIndex.indexId,
this.uid,
this.encodeDirectionalKey(fieldIndex, documentKey)
encodeKeySafeBytes(
this.encodeDirectionalKey(fieldIndex, documentKey)
)
])
},
(_, entry) => {
@@ -866,8 +871,8 @@ export class IndexedDbIndexManager implements IndexManager {
new IndexEntry(
fieldIndex.indexId,
documentKey,
entry.arrayValue,
entry.directionalValue
decodeKeySafeBytes(entry.arrayValue),
decodeKeySafeBytes(entry.directionalValue)
)
);
}
@@ -1020,24 +1025,16 @@ export class IndexedDbIndexManager implements IndexManager {
return [];
}
const lowerBound = [
bounds[i].indexId,
const lowerBound = bounds[i].dbIndexEntryKey(
this.uid,
bounds[i].arrayValue,
bounds[i].directionalValue,
EMPTY_VALUE,
[]
] as DbIndexEntryKey;
const upperBound = [
bounds[i + 1].indexId,
DocumentKey.empty()
);
const upperBound = bounds[i + 1].dbIndexEntryKey(
this.uid,
bounds[i + 1].arrayValue,
bounds[i + 1].directionalValue,
EMPTY_VALUE,
[]
] as DbIndexEntryKey;
DocumentKey.empty()
);
ranges.push(IDBKeyRange.bound(lowerBound, upperBound));
}
return ranges;
@@ -26,7 +26,7 @@ import {
} from '../protos/firestore_proto_api';
import { EncodedResourcePath } from './encoded_resource_path';
import { DbTimestampKey } from './indexeddb_sentinels';
import { DbTimestampKey, KeySafeBytes } from './indexeddb_sentinels';
/**
* Schema Version for the Web client:
@@ -52,9 +52,11 @@ import { DbTimestampKey } from './indexeddb_sentinels';
* 14. Add overlays.
* 15. Add indexing support.
* 16. Parse timestamp strings before creating index entries.
* 17. TODO(tomandersen)
* 18. Encode key safe representations of IndexEntry in DbIndexEntryStore.
*/
export const SCHEMA_VERSION = 17;
export const SCHEMA_VERSION = 18;
/**
* Wrapper class to store timestamps (seconds and nanos) in IndexedDb objects.
@@ -507,14 +509,14 @@ export interface DbIndexEntry {
/** The user id for this entry. */
uid: string;
/** The encoded array index value for this entry. */
arrayValue: Uint8Array;
arrayValue: KeySafeBytes;
/** The encoded directional value for equality and inequality filters. */
directionalValue: Uint8Array;
directionalValue: KeySafeBytes;
/**
* The document key this entry points to. This entry is encoded by an ordered
* encoder to match the key order of the index.
*/
orderedDocumentKey: Uint8Array;
orderedDocumentKey: KeySafeBytes;
/** The segments of the document key this entry points to. */
documentKey: string[];
}
@@ -15,6 +15,8 @@
* limitations under the License.
*/
import { isSafariOrWebkit } from '@firebase/util';
import { User } from '../auth/user';
import { ListenSequence } from '../core/listen_sequence';
import { SnapshotVersion } from '../core/snapshot_version';
@@ -277,6 +279,22 @@ export class SchemaConverter implements SimpleDbSchemaConverter {
});
}
if (fromVersion < 18 && toVersion >= 18) {
// Clear the IndexEntryStores on WebKit and Safari to remove possibly
// corrupted index entries
if (isSafariOrWebkit()) {
p = p
.next(() => {
const indexStateStore = txn.objectStore(DbIndexStateStore);
indexStateStore.clear();
})
.next(() => {
const indexEntryStore = txn.objectStore(DbIndexEntryStore);
indexEntryStore.clear();
});
}
}
return p;
}
@@ -305,6 +305,15 @@ export const DbIndexStateSequenceNumberIndex = 'sequenceNumberIndex';
export const DbIndexStateSequenceNumberIndexPath = ['uid', 'sequenceNumber'];
/**
* Representation of a byte array that is safe for
* use in an IndexedDb key. The value is either
* a "sortable byte string", which is key safe in
* Safari/WebKit, or the value is a Uint8Array,
* which is key safe in other browsers.
*/
export type KeySafeBytes = Uint8Array | string;
/**
* The key for each index entry consists of the index id and its user id,
* the encoded array and directional value for the indexed fields as well as
@@ -313,9 +322,9 @@ export const DbIndexStateSequenceNumberIndexPath = ['uid', 'sequenceNumber'];
export type DbIndexEntryKey = [
number,
string,
Uint8Array,
Uint8Array,
Uint8Array,
KeySafeBytes,
KeySafeBytes,
KeySafeBytes,
string[]
];
@@ -425,6 +434,7 @@ export const V15_STORES = [
];
export const V16_STORES = V15_STORES;
export const V17_STORES = [...V15_STORES, DbGlobalsStore];
export const V18_STORES = V17_STORES;
/**
* The list of all default IndexedDB stores used throughout the SDK. This is
@@ -435,7 +445,9 @@ export const ALL_STORES = V12_STORES;
/** Returns the object stores for the provided schema. */
export function getObjectStores(schemaVersion: number): string[] {
if (schemaVersion === 17) {
if (schemaVersion === 18) {
return V18_STORES;
} else if (schemaVersion === 17) {
return V17_STORES;
} else if (schemaVersion === 16) {
return V16_STORES;
@@ -450,6 +462,6 @@ export function getObjectStores(schemaVersion: number): string[] {
} else if (schemaVersion === 11) {
return V11_STORES;
} else {
fail(0xeb55, 'Only schema version 11 and 12 and 13 are supported');
fail(0xeb55, 'Only schema versions >11 are supported');
}
}
+11
View File
@@ -173,6 +173,17 @@ export function isSafari(): boolean {
);
}
/** Returns true if we are running in Safari or WebKit */
export function isSafariOrWebkit(): boolean {
return (
!isNode() &&
!!navigator.userAgent &&
(navigator.userAgent.includes('Safari') ||
navigator.userAgent.includes('WebKit')) &&
!navigator.userAgent.includes('Chrome')
);
}
/**
* This method checks if indexedDB is supported by current browser/service worker context
* @return true if indexedDB is supported by current browser/service worker context