Skip to content

Fix encoding of CSI keys in Safari #8993

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

Merged
merged 23 commits into from
May 9, 2025
Merged
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
f7067e2
Enable only unicode / utf8 string tests
MarkDuckworth May 2, 2025
2c219e5
Attempting to mitigate failures caused by browser disconnect timout
MarkDuckworth May 5, 2025
d4b667e
Skip compareUtf8Strings should return correct results
MarkDuckworth May 5, 2025
a4b0f8a
Skip sort unicode strings
MarkDuckworth May 5, 2025
f5e632f
browserDisconnectTimeout back to default
MarkDuckworth May 5, 2025
944feac
reenable compareUtf8strings unit test"
MarkDuckworth May 5, 2025
baeb68e
another test
MarkDuckworth May 5, 2025
538f470
Reenable all tests and increase browserDisconnectTimeout to 65 seconds
MarkDuckworth May 7, 2025
aeec17a
Fix IndexDbIndexManager encoding of Uint8Array in Safari
MarkDuckworth May 7, 2025
3854785
Scripts for running tests in WebKit locally
MarkDuckworth May 7, 2025
53ecb67
lint fix
MarkDuckworth May 7, 2025
44cee47
formatting
MarkDuckworth May 7, 2025
2380e61
Create eighty-starfishes-listen.md
MarkDuckworth May 7, 2025
bccc89a
Merge branch 'main' of github.com:firebase/firebase-js-sdk into markd…
MarkDuckworth May 7, 2025
f421368
Merge branch 'markduckworth/timeout-flake' of github.com:firebase/fir…
MarkDuckworth May 7, 2025
e6860ac
cleanup
MarkDuckworth May 7, 2025
dfd8f22
Swap encoding of key safe bytes from number[] to sortable string. Thi…
MarkDuckworth May 8, 2025
022a55e
Update eighty-starfishes-listen.md
MarkDuckworth May 8, 2025
d4086bb
cleanup
MarkDuckworth May 8, 2025
fd64124
Merge branch 'markduckworth/timeout-flake' of github.com:firebase/fir…
MarkDuckworth May 8, 2025
26739c9
fix import
MarkDuckworth May 8, 2025
4d5b5cc
Address pr feedback
MarkDuckworth May 9, 2025
0dc1ea6
fix changelog
MarkDuckworth May 9, 2025
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
6 changes: 6 additions & 0 deletions .changeset/eighty-starfishes-listen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@firebase/firestore": patch
"@firebase/util": minor
---

Fix Safari/WebKit cache issues when client-side indexing is used.
5 changes: 5 additions & 0 deletions common/api-review/util.api.md
Original file line number Diff line number Diff line change
@@ -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 change: 1 addition & 0 deletions config/karma.base.js
Original file line number Diff line number Diff line change
@@ -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 changes: 2 additions & 0 deletions packages/firestore/package.json
Original file line number Diff line number Diff line change
@@ -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",
121 changes: 106 additions & 15 deletions packages/firestore/src/index/index_entry.ts
Original file line number Diff line number Diff line change
@@ -15,65 +15,102 @@
* 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
) {}

/**
* Returns an IndexEntry entry that sorts immediately after the current
* 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;
}
65 changes: 31 additions & 34 deletions packages/firestore/src/local/indexeddb_index_manager.ts
Original file line number Diff line number Diff line change
@@ -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,16 +861,18 @@ export class IndexedDbIndexManager implements IndexManager {
range: IDBKeyRange.only([
fieldIndex.indexId,
this.uid,
this.encodeDirectionalKey(fieldIndex, documentKey)
encodeKeySafeBytes(
this.encodeDirectionalKey(fieldIndex, documentKey)
)
])
},
(_, entry) => {
results = results.add(
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;
12 changes: 7 additions & 5 deletions packages/firestore/src/local/indexeddb_schema.ts
Original file line number Diff line number Diff line change
@@ -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[];
}
18 changes: 18 additions & 0 deletions packages/firestore/src/local/indexeddb_schema_converter.ts
Original file line number Diff line number Diff line change
@@ -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;
}

Loading
Loading