Skip to content

Commit 69c3326

Browse files
authored
Emulator Idempotency: Firestore (#8780)
Update `connectFirestoreEmulator` to support its invocation more than once. If the Firestore instance is already in use, and `connectFirestoreEmulator` is invoked with the same configuration, then the invocation will now succeed instead of assert. The implementation takes the Data Connect implementation as inspiration. Data Connect stores the parameters passed to `connectDataConnectEmulator` on the instance of Data Connect itself, so that they can be quickly checked to see if subsequent invocations match. This PR implements a similar storage and compare process with the optional `emulatorOptions` parameter (host and port are already stored). This PR unlocks support for SSR frameworks which render the page numerous times with the same instances of Firestore. Before this PR customers were required to guard against calling `connectFirestoreEmulator` in their SSR logic, which added to code complexity. Now the Firebase SDK does that guarding logic so that our users' apps don't have to.
1 parent 3418ef8 commit 69c3326

File tree

6 files changed

+87
-14
lines changed

6 files changed

+87
-14
lines changed

common/api-review/firestore-lite.api.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -494,4 +494,5 @@ export class WriteBatch {
494494
// @public
495495
export function writeBatch(firestore: Firestore): WriteBatch;
496496

497+
497498
```

packages/firestore/src/lite-api/database.ts

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
} from '@firebase/app';
2525
import {
2626
createMockUserToken,
27+
deepEqual,
2728
EmulatorMockTokenOptions,
2829
getDefaultEmulatorHostnameAndPort
2930
} from '@firebase/util';
@@ -71,6 +72,9 @@ export class Firestore implements FirestoreService {
7172

7273
private _settings = new FirestoreSettingsImpl({});
7374
private _settingsFrozen = false;
75+
private _emulatorOptions: {
76+
mockUserToken?: EmulatorMockTokenOptions | string;
77+
} = {};
7478

7579
// A task that is assigned when the terminate() is invoked and resolved when
7680
// all components have shut down. Otherwise, Firestore is not terminated,
@@ -119,6 +123,8 @@ export class Firestore implements FirestoreService {
119123
);
120124
}
121125
this._settings = new FirestoreSettingsImpl(settings);
126+
this._emulatorOptions = settings.emulatorOptions || {};
127+
122128
if (settings.credentials !== undefined) {
123129
this._authCredentials = makeAuthCredentialsProvider(settings.credentials);
124130
}
@@ -128,6 +134,10 @@ export class Firestore implements FirestoreService {
128134
return this._settings;
129135
}
130136

137+
_getEmulatorOptions(): { mockUserToken?: EmulatorMockTokenOptions | string } {
138+
return this._emulatorOptions;
139+
}
140+
131141
_freezeSettings(): FirestoreSettingsImpl {
132142
this._settingsFrozen = true;
133143
return this._settings;
@@ -316,20 +326,30 @@ export function connectFirestoreEmulator(
316326
): void {
317327
firestore = cast(firestore, Firestore);
318328
const settings = firestore._getSettings();
329+
const existingConfig = {
330+
...settings,
331+
emulatorOptions: firestore._getEmulatorOptions()
332+
};
319333
const newHostSetting = `${host}:${port}`;
320-
321334
if (settings.host !== DEFAULT_HOST && settings.host !== newHostSetting) {
322335
logWarn(
323336
'Host has been set in both settings() and connectFirestoreEmulator(), emulator host ' +
324337
'will be used.'
325338
);
326339
}
327-
328-
firestore._setSettings({
340+
const newConfig = {
329341
...settings,
330342
host: newHostSetting,
331-
ssl: false
332-
});
343+
ssl: false,
344+
emulatorOptions: options
345+
};
346+
// No-op if the new configuration matches the current configuration. This supports SSR
347+
// enviornments which might call `connectFirestoreEmulator` multiple times as a standard practice.
348+
if (deepEqual(newConfig, existingConfig)) {
349+
return;
350+
}
351+
352+
firestore._setSettings(newConfig);
333353

334354
if (options.mockUserToken) {
335355
let token: string;

packages/firestore/src/lite-api/settings.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
* limitations under the License.
1616
*/
1717

18+
import { EmulatorMockTokenOptions } from '@firebase/util';
19+
1820
import { FirestoreLocalCache } from '../api/cache_config';
1921
import { CredentialsSettings } from '../api/credentials';
2022
import {
@@ -80,6 +82,7 @@ export interface PrivateSettings extends FirestoreSettings {
8082
experimentalAutoDetectLongPolling?: boolean;
8183
experimentalLongPollingOptions?: ExperimentalLongPollingOptions;
8284
useFetchStreams?: boolean;
85+
emulatorOptions?: { mockUserToken?: EmulatorMockTokenOptions | string };
8386

8487
localCache?: FirestoreLocalCache;
8588
}

packages/firestore/test/integration/api/validation.test.ts

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,9 @@ import {
6464
import {
6565
ALT_PROJECT_ID,
6666
DEFAULT_PROJECT_ID,
67-
TARGET_DB_ID
67+
TARGET_DB_ID,
68+
USE_EMULATOR,
69+
getEmulatorPort
6870
} from '../util/settings';
6971

7072
// We're using 'as any' to pass invalid values to APIs for testing purposes.
@@ -179,7 +181,19 @@ apiDescribe('Validation:', persistence => {
179181

180182
validationIt(
181183
persistence,
182-
'disallows calling connectFirestoreEmulator() after use',
184+
'connectFirestoreEmulator() can set mockUserToken object',
185+
() => {
186+
const db = newTestFirestore(newTestApp('test-project'));
187+
// Verify that this doesn't throw.
188+
connectFirestoreEmulator(db, '127.0.0.1', 9000, {
189+
mockUserToken: { sub: 'foo' }
190+
});
191+
}
192+
);
193+
194+
validationIt(
195+
persistence,
196+
'disallows calling connectFirestoreEmulator() for first time after use',
183197
async db => {
184198
const errorMsg =
185199
'Firestore has already been started and its settings can no longer be changed.';
@@ -193,13 +207,33 @@ apiDescribe('Validation:', persistence => {
193207

194208
validationIt(
195209
persistence,
196-
'connectFirestoreEmulator() can set mockUserToken object',
197-
() => {
198-
const db = newTestFirestore(newTestApp('test-project'));
199-
// Verify that this doesn't throw.
200-
connectFirestoreEmulator(db, '127.0.0.1', 9000, {
201-
mockUserToken: { sub: 'foo' }
202-
});
210+
'allows calling connectFirestoreEmulator() after use with same config',
211+
async db => {
212+
if (USE_EMULATOR) {
213+
const port = getEmulatorPort();
214+
connectFirestoreEmulator(db, '127.0.0.1', port);
215+
await setDoc(doc(db, 'foo/bar'), {});
216+
expect(() =>
217+
connectFirestoreEmulator(db, '127.0.0.1', port)
218+
).to.not.throw();
219+
}
220+
}
221+
);
222+
223+
validationIt(
224+
persistence,
225+
'disallows calling connectFirestoreEmulator() after use with different config',
226+
async db => {
227+
if (USE_EMULATOR) {
228+
const errorMsg =
229+
'Firestore has already been started and its settings can no longer be changed.';
230+
const port = getEmulatorPort();
231+
connectFirestoreEmulator(db, '127.0.0.1', port);
232+
await setDoc(doc(db, 'foo/bar'), {});
233+
expect(() =>
234+
connectFirestoreEmulator(db, '127.0.0.1', port + 1)
235+
).to.throw(errorMsg);
236+
}
203237
}
204238
);
205239

packages/firestore/test/integration/util/settings.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,10 @@ function getFirestoreHost(targetBackend: TargetBackend): string {
110110
}
111111
}
112112

113+
export function getEmulatorPort(): number {
114+
return parseInt(process.env.FIRESTORE_EMULATOR_PORT || '8080', 10);
115+
}
116+
113117
function getSslEnabled(targetBackend: TargetBackend): boolean {
114118
return targetBackend !== TargetBackend.EMULATOR;
115119
}

packages/firestore/test/unit/api/database.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -553,6 +553,17 @@ describe('Settings', () => {
553553
expect(db._getSettings().ssl).to.be.false;
554554
});
555555

556+
it('gets privateSettings from useEmulator', () => {
557+
// Use a new instance of Firestore in order to configure settings.
558+
const db = newTestFirestore();
559+
const emulatorOptions = { mockUserToken: 'test' };
560+
connectFirestoreEmulator(db, '127.0.0.1', 9000, emulatorOptions);
561+
562+
expect(db._getSettings().host).to.exist.and.to.equal('127.0.0.1:9000');
563+
expect(db._getSettings().ssl).to.exist.and.to.be.false;
564+
expect(db._getEmulatorOptions()).to.equal(emulatorOptions);
565+
});
566+
556567
it('prefers host from useEmulator to host from settings', () => {
557568
// Use a new instance of Firestore in order to configure settings.
558569
const db = newTestFirestore();

0 commit comments

Comments
 (0)