diff --git a/packages/firestore/src/core/firestore_client.ts b/packages/firestore/src/core/firestore_client.ts index 1a74e913ef0..2e59c18884c 100644 --- a/packages/firestore/src/core/firestore_client.ts +++ b/packages/firestore/src/core/firestore_client.ts @@ -51,7 +51,7 @@ import { import { BundleReader } from '../util/bundle_reader'; import { LoadBundleTask } from '../api/bundle'; import { newConnection } from '../platform/connection'; -import { newSerializer } from '../platform/serializer'; +import { newSerializer, newTextEncoder } from '../platform/serializer'; import { toByteStreamReader } from '../platform/byte_stream_reader'; const LOG_TAG = 'FirestoreClient'; @@ -529,7 +529,7 @@ export class FirestoreClient { let content: ReadableStream | ArrayBuffer; if (typeof data === 'string') { - content = new TextEncoder().encode(data); + content = newTextEncoder().encode(data); } else { content = data; } diff --git a/packages/firestore/src/core/sync_engine.ts b/packages/firestore/src/core/sync_engine.ts index e06f3ee6938..10bbdc960e1 100644 --- a/packages/firestore/src/core/sync_engine.ts +++ b/packages/firestore/src/core/sync_engine.ts @@ -293,7 +293,7 @@ class SyncEngineImpl implements SyncEngine { protected remoteStore: RemoteStore, protected datastore: Datastore, // PORTING NOTE: Manages state synchronization in multi-tab environments. - protected sharedClientState: SharedClientState, + public sharedClientState: SharedClientState, private currentUser: User, private maxConcurrentLimboResolutions: number ) {} @@ -1100,6 +1100,12 @@ class MultiTabSyncEngineImpl extends SyncEngineImpl { } } + synchronizeWithChangedDocuments(): Promise { + return this.localStore + .getNewDocumentChanges() + .then(changes => this.emitNewSnapsAndNotifyLocalStore(changes)); + } + async applyBatchState( batchId: BatchId, batchState: MutationBatchState, @@ -1412,7 +1418,9 @@ export function loadBundle( syncEngineImpl.assertSubscribed('loadBundle()'); // eslint-disable-next-line @typescript-eslint/no-floating-promises - loadBundleImpl(syncEngineImpl, bundleReader, task); + loadBundleImpl(syncEngineImpl, bundleReader, task).then(() => { + syncEngineImpl.sharedClientState.notifyBundleLoaded(); + }); } async function loadBundleImpl( diff --git a/packages/firestore/src/local/shared_client_state.ts b/packages/firestore/src/local/shared_client_state.ts index a333ff74c43..83dde78bc0f 100644 --- a/packages/firestore/src/local/shared_client_state.ts +++ b/packages/firestore/src/local/shared_client_state.ts @@ -40,6 +40,7 @@ import { import { CLIENT_STATE_KEY_PREFIX, ClientStateSchema, + createBundleLoadedKey, createWebStorageClientStateKey, createWebStorageMutationBatchKey, createWebStorageOnlineStateKey, @@ -173,6 +174,12 @@ export interface SharedClientState { setOnlineState(onlineState: OnlineState): void; writeSequenceNumber(sequenceNumber: ListenSequenceNumber): void; + + /** + * Notifies other clients when remote documents have changed due to loading + * a bundle. + */ + notifyBundleLoaded(): void; } /** @@ -477,6 +484,7 @@ export class WebStorageSharedClientState implements SharedClientState { private readonly sequenceNumberKey: string; private readonly storageListener = this.handleWebStorageEvent.bind(this); private readonly onlineStateKey: string; + private readonly bundleLoadedKey: string; private readonly clientStateKeyRe: RegExp; private readonly mutationBatchKeyRe: RegExp; private readonly queryTargetKeyRe: RegExp; @@ -532,6 +540,8 @@ export class WebStorageSharedClientState implements SharedClientState { this.onlineStateKey = createWebStorageOnlineStateKey(this.persistenceKey); + this.bundleLoadedKey = createBundleLoadedKey(this.persistenceKey); + // Rather than adding the storage observer during start(), we add the // storage observer during initialization. This ensures that we collect // events before other components populate their initial state (during their @@ -711,6 +721,10 @@ export class WebStorageSharedClientState implements SharedClientState { this.persistOnlineState(onlineState); } + notifyBundleLoaded(): void { + this.persistBundleLoadedState(); + } + shutdown(): void { if (this.started) { this.window.removeEventListener('storage', this.storageListener); @@ -818,6 +832,8 @@ export class WebStorageSharedClientState implements SharedClientState { if (sequenceNumber !== ListenSequence.INVALID) { this.sequenceNumberHandler!(sequenceNumber); } + } else if (storageEvent.key === this.bundleLoadedKey) { + return this.syncEngine!.synchronizeWithChangedDocuments(); } }); } @@ -883,6 +899,10 @@ export class WebStorageSharedClientState implements SharedClientState { this.setItem(targetKey, targetMetadata.toWebStorageJSON()); } + private persistBundleLoadedState(): void { + this.setItem(this.bundleLoadedKey, 'value-not-used'); + } + /** * Parses a client state key in WebStorage. Returns null if the key does not * match the expected key format. @@ -1131,4 +1151,8 @@ export class MemorySharedClientState implements SharedClientState { shutdown(): void {} writeSequenceNumber(sequenceNumber: ListenSequenceNumber): void {} + + notifyBundleLoaded(): void { + // No op. + } } diff --git a/packages/firestore/src/local/shared_client_state_schema.ts b/packages/firestore/src/local/shared_client_state_schema.ts index 01665e91cfa..fd47332bc4a 100644 --- a/packages/firestore/src/local/shared_client_state_schema.ts +++ b/packages/firestore/src/local/shared_client_state_schema.ts @@ -115,6 +115,15 @@ export function createWebStorageOnlineStateKey(persistenceKey: string): string { return `${ONLINE_STATE_KEY_PREFIX}_${persistenceKey}`; } +// The WebStorage prefix that plays as a event to indicate the remote documents +// might have changed due to some secondary tabs loading a bundle. +// format of the key is: +// firestore_remote_documents_changed_ +export const BUNDLE_LOADED_KEY_PREFIX = 'firestore_bundle_loaded'; +export function createBundleLoadedKey(persistenceKey: string): string { + return `${BUNDLE_LOADED_KEY_PREFIX}_${persistenceKey}`; +} + /** * The JSON representation of the system's online state, as written by the * primary client. diff --git a/packages/firestore/src/local/shared_client_state_syncer.ts b/packages/firestore/src/local/shared_client_state_syncer.ts index 29736807277..898c8c78b6d 100644 --- a/packages/firestore/src/local/shared_client_state_syncer.ts +++ b/packages/firestore/src/local/shared_client_state_syncer.ts @@ -49,4 +49,10 @@ export interface SharedClientStateSyncer { /** Returns the IDs of the clients that are currently active. */ getActiveClients(): Promise; + + /** + * Retrieves newly changed documents from remote document cache and raises + * snapshots if needed. + */ + synchronizeWithChangedDocuments(): Promise; } diff --git a/packages/firestore/src/platform/browser/serializer.ts b/packages/firestore/src/platform/browser/serializer.ts index 5e009d89f60..722f40e605f 100644 --- a/packages/firestore/src/platform/browser/serializer.ts +++ b/packages/firestore/src/platform/browser/serializer.ts @@ -22,3 +22,17 @@ import { JsonProtoSerializer } from '../../remote/serializer'; export function newSerializer(databaseId: DatabaseId): JsonProtoSerializer { return new JsonProtoSerializer(databaseId, /* useProto3Json= */ true); } + +/** + * An instance of the Platform's 'TextEncoder' implementation. + */ +export function newTextEncoder(): TextEncoder { + return new TextEncoder(); +} + +/** + * An instance of the Platform's 'TextDecoder' implementation. + */ +export function newTextDecoder(): TextDecoder { + return new TextDecoder('utf-8'); +} diff --git a/packages/firestore/src/platform/node/serializer.ts b/packages/firestore/src/platform/node/serializer.ts index bbc9db1e516..ac26afd0ee7 100644 --- a/packages/firestore/src/platform/node/serializer.ts +++ b/packages/firestore/src/platform/node/serializer.ts @@ -18,7 +18,22 @@ /** Return the Platform-specific serializer monitor. */ import { JsonProtoSerializer } from '../../remote/serializer'; import { DatabaseId } from '../../core/database_info'; +import { TextDecoder, TextEncoder } from 'util'; export function newSerializer(databaseId: DatabaseId): JsonProtoSerializer { return new JsonProtoSerializer(databaseId, /* useProto3Json= */ false); } + +/** + * An instance of the Platform's 'TextEncoder' implementation. + */ +export function newTextEncoder(): TextEncoder { + return new TextEncoder(); +} + +/** + * An instance of the Platform's 'TextDecoder' implementation. + */ +export function newTextDecoder(): TextDecoder { + return new TextDecoder('utf-8'); +} diff --git a/packages/firestore/src/platform/rn/serializer.ts b/packages/firestore/src/platform/rn/serializer.ts index c5ab7bf2bb5..2b168a0dffa 100644 --- a/packages/firestore/src/platform/rn/serializer.ts +++ b/packages/firestore/src/platform/rn/serializer.ts @@ -15,4 +15,8 @@ * limitations under the License. */ -export { newSerializer } from '../browser/serializer'; +export { + newSerializer, + newTextEncoder, + newTextDecoder +} from '../browser/serializer'; diff --git a/packages/firestore/src/platform/serializer.ts b/packages/firestore/src/platform/serializer.ts index f7990dc4496..26553332c36 100644 --- a/packages/firestore/src/platform/serializer.ts +++ b/packages/firestore/src/platform/serializer.ts @@ -16,11 +16,11 @@ */ import { isNode, isReactNative } from '@firebase/util'; +import { DatabaseId } from '../core/database_info'; +import { JsonProtoSerializer } from '../remote/serializer'; import * as node from './node/serializer'; import * as rn from './rn/serializer'; import * as browser from './browser/serializer'; -import { DatabaseId } from '../core/database_info'; -import { JsonProtoSerializer } from '../remote/serializer'; export function newSerializer(databaseId: DatabaseId): JsonProtoSerializer { if (isNode()) { @@ -31,3 +31,29 @@ export function newSerializer(databaseId: DatabaseId): JsonProtoSerializer { return browser.newSerializer(databaseId); } } + +/** + * An instance of the Platform's 'TextEncoder' implementation. + */ +export function newTextEncoder(): TextEncoder { + if (isNode()) { + return node.newTextEncoder(); + } else if (isReactNative()) { + return rn.newTextEncoder(); + } else { + return browser.newTextEncoder(); + } +} + +/** + * An instance of the Platform's 'TextDecoder' implementation. + */ +export function newTextDecoder(): TextDecoder { + if (isNode()) { + return node.newTextDecoder() as TextDecoder; + } else if (isReactNative()) { + return rn.newTextDecoder(); + } else { + return browser.newTextDecoder(); + } +} diff --git a/packages/firestore/src/util/bundle_reader.ts b/packages/firestore/src/util/bundle_reader.ts index e426fa0c825..d2e9c23ac58 100644 --- a/packages/firestore/src/util/bundle_reader.ts +++ b/packages/firestore/src/util/bundle_reader.ts @@ -22,6 +22,7 @@ import { import { Deferred } from './promise'; import { debugAssert } from './assert'; import { toByteStreamReader } from '../platform/byte_stream_reader'; +import { newTextDecoder } from '../platform/serializer'; /** * A complete element in the bundle stream, together with the byte length it @@ -67,7 +68,7 @@ export class BundleReader { */ private buffer: Uint8Array = new Uint8Array(); /** The decoder used to parse binary data into strings. */ - private textDecoder = new TextDecoder('utf-8'); + private textDecoder: TextDecoder; static fromBundleSource(source: BundleSource): BundleReader { return new BundleReader(toByteStreamReader(source, BYTES_PER_READ)); @@ -77,6 +78,7 @@ export class BundleReader { /** The reader to read from underlying binary bundle data source. */ private reader: ReadableStreamReader ) { + this.textDecoder = newTextDecoder(); // Read the metadata (which is the first element). this.nextElementImpl().then( element => { diff --git a/packages/firestore/test/integration/api_internal/bundle.test.ts b/packages/firestore/test/integration/api_internal/bundle.test.ts index b0e18b5b1cf..7894015249f 100644 --- a/packages/firestore/test/integration/api_internal/bundle.test.ts +++ b/packages/firestore/test/integration/api_internal/bundle.test.ts @@ -27,6 +27,9 @@ import { DatabaseId } from '../../../src/core/database_info'; import { key } from '../../util/helpers'; import { EventsAccumulator } from '../util/events_accumulator'; import { TestBundleBuilder } from '../../unit/util/bundle_data'; +import { newTextEncoder } from '../../../src/platform/serializer'; + +export const encoder = newTextEncoder(); function verifySuccessProgress(p: firestore.LoadBundleTaskProgress): void { expect(p.taskState).to.equal('Success'); @@ -45,7 +48,6 @@ function verifyInProgress( } apiDescribe('Bundles', (persistence: boolean) => { - const encoder = new TextEncoder(); const testDocs: { [key: string]: firestore.DocumentData } = { a: { k: { stringValue: 'a' }, bar: { integerValue: 1 } }, b: { k: { stringValue: 'b' }, bar: { integerValue: 2 } } diff --git a/packages/firestore/test/unit/local/persistence_test_helpers.ts b/packages/firestore/test/unit/local/persistence_test_helpers.ts index 23be7ca0d60..7d513376041 100644 --- a/packages/firestore/test/unit/local/persistence_test_helpers.ts +++ b/packages/firestore/test/unit/local/persistence_test_helpers.ts @@ -170,6 +170,8 @@ class NoOpSharedClientStateSyncer implements SharedClientStateSyncer { removed: TargetId[] ): Promise {} applyOnlineStateChange(onlineState: OnlineState): void {} + + async synchronizeWithChangedDocuments(): Promise {} } /** * Populates Web Storage with instance data from a pre-existing client. diff --git a/packages/firestore/test/unit/local/web_storage_shared_client_state.test.ts b/packages/firestore/test/unit/local/web_storage_shared_client_state.test.ts index ecfa554780b..a0136a709b4 100644 --- a/packages/firestore/test/unit/local/web_storage_shared_client_state.test.ts +++ b/packages/firestore/test/unit/local/web_storage_shared_client_state.test.ts @@ -150,6 +150,8 @@ class TestSharedClientSyncer implements SharedClientStateSyncer { applyOnlineStateChange(onlineState: OnlineState): void { this.onlineState = onlineState; } + + async synchronizeWithChangedDocuments(): Promise {} } describe('WebStorageSharedClientState', () => { diff --git a/packages/firestore/test/unit/specs/bundle_spec.test.ts b/packages/firestore/test/unit/specs/bundle_spec.test.ts index fa7b08d8c45..fc20c8b24fb 100644 --- a/packages/firestore/test/unit/specs/bundle_spec.test.ts +++ b/packages/firestore/test/unit/specs/bundle_spec.test.ts @@ -293,12 +293,9 @@ describeSpec('Bundles:', ['no-ios', 'no-android'], () => { .client(1) .loadBundle(bundleString1) .client(0) - .becomeVisible(); - // TODO(wuandy): Loading from secondary client does not notify other - // clients for now. We need to fix it and uncomment below. - // .expectEvents(query, { - // modified: [doc('collection/a', 500, { value: 'b' })], - // }) + .expectEvents(query, { + modified: [doc('collection/a', 500, { value: 'b' })] + }); } ); diff --git a/packages/firestore/test/unit/specs/spec_test_runner.ts b/packages/firestore/test/unit/specs/spec_test_runner.ts index fb7080b8b15..ac9b7a86099 100644 --- a/packages/firestore/test/unit/specs/spec_test_runner.ts +++ b/packages/firestore/test/unit/specs/spec_test_runner.ts @@ -126,6 +126,7 @@ import { } from '../../util/test_platform'; import { toByteStreamReader } from '../../../src/platform/byte_stream_reader'; import { logWarn } from '../../../src/util/log'; +import { newTextEncoder } from '../../../src/platform/serializer'; const ARBITRARY_SEQUENCE_NUMBER = 2; @@ -456,7 +457,7 @@ abstract class TestRunner { private async doLoadBundle(bundle: string): Promise { const reader = new BundleReader( - toByteStreamReader(new TextEncoder().encode(bundle)) + toByteStreamReader(newTextEncoder().encode(bundle)) ); const task = new LoadBundleTask(); return this.queue.enqueue(async () => { diff --git a/packages/firestore/test/unit/util/bundle.test.ts b/packages/firestore/test/unit/util/bundle.test.ts index 9d6527b5fc2..6b74cb02577 100644 --- a/packages/firestore/test/unit/util/bundle.test.ts +++ b/packages/firestore/test/unit/util/bundle.test.ts @@ -39,9 +39,12 @@ import { doc1, doc2 } from './bundle_data'; +import { newTextEncoder } from '../../../src/platform/serializer'; use(chaiAsPromised); +const encoder = newTextEncoder(); + /** * Create a `ReadableStream` from a string. * @@ -53,14 +56,13 @@ export function byteStreamReaderFromString( content: string, bytesPerRead: number ): ReadableStreamReader { - const data = new TextEncoder().encode(content); + const data = encoder.encode(content); return toByteStreamReader(data, bytesPerRead); } // Testing readableStreamFromString() is working as expected. describe('byteStreamReaderFromString()', () => { it('returns a reader stepping readable stream', async () => { - const encoder = new TextEncoder(); const r = byteStreamReaderFromString('0123456789', 4); let result = await r.read(); @@ -93,8 +95,6 @@ function genericBundleReadingTests(bytesPerRead: number): void { return new BundleReader(byteStreamReaderFromString(s, bytesPerRead)); } - const encoder = new TextEncoder(); - async function getAllElements( bundle: BundleReader ): Promise { diff --git a/packages/firestore/test/unit/util/bundle_data.ts b/packages/firestore/test/unit/util/bundle_data.ts index 3984c2be8d3..630d03ddf24 100644 --- a/packages/firestore/test/unit/util/bundle_data.ts +++ b/packages/firestore/test/unit/util/bundle_data.ts @@ -23,11 +23,16 @@ import * as api from '../../../src/protos/firestore_proto_api'; import { Value } from '../../../src/protos/firestore_proto_api'; import { JsonProtoSerializer, toName } from '../../../src/remote/serializer'; import { DocumentKey } from '../../../src/model/document_key'; -import { newSerializer } from '../../../src/platform/serializer'; +import { + newSerializer, + newTextEncoder +} from '../../../src/platform/serializer'; + +export const encoder = newTextEncoder(); function lengthPrefixedString(o: {}): string { const str = JSON.stringify(o); - const l = new TextEncoder().encode(str).byteLength; + const l = encoder.encode(str).byteLength; return `${l}${str}`; } @@ -83,7 +88,6 @@ export class TestBundleBuilder { ): BundleElement { let totalDocuments = 0; let totalBytes = 0; - const encoder = new TextEncoder(); for (const element of this.elements) { if (element.documentMetadata && !element.documentMetadata.exists) { totalDocuments += 1;