Skip to content

Commit d47dad5

Browse files
authored
fix(firestore): key document cache by Firestore instance, converter presence, and path (#840)
The document hooks keyed their cache by app name and path, so two databases on one app, or the same path read with and without a converter, shared an entry and returned each other's data. The key is now a per-instance id, converter presence, and the path. Instance ids come from a WeakMap on globalThis, so duplicate copies of ReactFire agree on them. Converters are keyed by presence rather than identity, so an inline withConverter({...}) created during render does not loop. Two different converters on the same path still share an entry, as before. Tests cover raw vs converter, inline converters, named databases, and two module copies reading two databases.
1 parent 635b5b8 commit d47dad5

2 files changed

Lines changed: 203 additions & 7 deletions

File tree

‎src/firestore.tsx‎

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export async function preloadFirestoreDoc(refProvider: () => Promise<DocumentRef
3131
}
3232

3333
function getDocObservableId<T = DocumentData>(ref: DocumentReference<T>) {
34-
return `firestore:doc:${ref.firestore.app.name}:${ref.path}`;
34+
return `firestore:doc:${getUniqueIdForFirestoreDoc(ref)}`;
3535
}
3636

3737
/**
@@ -50,7 +50,7 @@ export function useFirestoreDoc<T = DocumentData>(ref: DocumentReference<T>, opt
5050
* Get a firestore document and don't subscribe to changes
5151
*/
5252
export function useFirestoreDocOnce<T = DocumentData>(ref: DocumentReference<T>, options?: ReactFireOptions<T>): ObservableStatus<DocumentSnapshot<T>> {
53-
const observableId = `firestore:docOnce:${ref.firestore.app.name}:${ref.path}`;
53+
const observableId = `firestore:docOnce:${getUniqueIdForFirestoreDoc(ref)}`;
5454
const observable$ = doc(ref).pipe(first());
5555

5656
return useObservable(observableId, observable$, options);
@@ -62,7 +62,7 @@ export function useFirestoreDocOnce<T = DocumentData>(ref: DocumentReference<T>,
6262
export function useFirestoreDocData<T = unknown>(ref: DocumentReference<T>, options?: ReactFireOptions<T>): ObservableStatus<T | undefined> {
6363
const idField = options ? checkIdField(options) : undefined;
6464

65-
const observableId = `firestore:docData:${ref.firestore.app.name}:${ref.path}:idField=${JSON.stringify(idField)}`;
65+
const observableId = `firestore:docData:${getUniqueIdForFirestoreDoc(ref)}:idField=${JSON.stringify(idField)}`;
6666
const observable = docData(ref, { idField });
6767

6868
return useObservable(observableId, observable, options) as ObservableStatus<T>;
@@ -74,7 +74,7 @@ export function useFirestoreDocData<T = unknown>(ref: DocumentReference<T>, opti
7474
export function useFirestoreDocDataOnce<T = unknown>(ref: DocumentReference<T>, options?: ReactFireOptions<T>): ObservableStatus<T | undefined> {
7575
const idField = options ? checkIdField(options) : undefined;
7676

77-
const observableId = `firestore:docDataOnce:${ref.firestore.app.name}:${ref.path}:idField=${JSON.stringify(idField)}`;
77+
const observableId = `firestore:docDataOnce:${getUniqueIdForFirestoreDoc(ref)}:idField=${JSON.stringify(idField)}`;
7878
const observable$ = docData(ref, { idField }).pipe(first());
7979

8080
return useObservable(observableId, observable$, options) as ObservableStatus<T>;
@@ -100,3 +100,25 @@ export function useFirestoreCollectionData<T = DocumentData>(query: FirestoreQue
100100

101101
return useObservable(observableId, observable$, options);
102102
}
103+
104+
// Small ids for Firestore instances (one per app and database), so refs to the same path in another database get
105+
// their own cache entry without scanning every ref seen so far. Kept on globalThis, like the query cache, so duplicate
106+
// copies of ReactFire hand out the same ids. Converters are keyed by presence, not identity: a converter object
107+
// created during render would otherwise change the key on every render.
108+
const docKeyIds: { ids: WeakMap<object, number>; next: number } = ((globalThis as any)._reactFireFirestoreDocKeyIds ??= {
109+
ids: new WeakMap(),
110+
next: 0,
111+
});
112+
113+
function idFor(target: object): number {
114+
let id = docKeyIds.ids.get(target);
115+
if (id === undefined) {
116+
id = docKeyIds.next++;
117+
docKeyIds.ids.set(target, id);
118+
}
119+
return id;
120+
}
121+
122+
function getUniqueIdForFirestoreDoc<T = DocumentData>(ref: DocumentReference<T>) {
123+
return `${idFor(ref.firestore)}:${ref.converter ? 'converter' : 'raw'}:${ref.path}`;
124+
}

‎test/firestore.test.tsx‎

Lines changed: 177 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { renderHook, act, cleanup, waitFor } from '@testing-library/react';
1+
import { render, renderHook, act, cleanup, waitFor } from '@testing-library/react';
22

33
import * as React from 'react';
44
import '@testing-library/jest-dom/extend-expect';
@@ -17,12 +17,16 @@ import { baseConfig } from './appConfig';
1717
import { randomString } from './test-utils';
1818

1919
import { addDoc, collection, doc, getFirestore, query, setDoc, connectFirestoreEmulator, where } from 'firebase/firestore';
20-
import type { DocumentReference } from 'firebase/firestore';
20+
import type { DocumentReference, FirestoreDataConverter } from 'firebase/firestore';
2121

2222
describe('Firestore', () => {
2323
const app = initializeApp(baseConfig, 'firestore-test-suite');
2424
const db = getFirestore(app);
2525
connectFirestoreEmulator(db, 'localhost', 8085);
26+
const secondDb = getFirestore(app, 'second-db');
27+
connectFirestoreEmulator(secondDb, 'localhost', 8085);
28+
const thirdDb = getFirestore(app, 'third-db');
29+
connectFirestoreEmulator(thirdDb, 'localhost', 8085);
2630

2731
const Provider = ({ children }: { children: React.ReactNode }) => (
2832
<FirebaseAppProvider firebaseApp={app}>
@@ -51,7 +55,9 @@ describe('Firestore', () => {
5155

5256
// clear all Firestore emulator data
5357
// do this AFTER cleaning up hooks, otherwise they'll re-emit values
54-
await fetch(`http://localhost:8085/emulator/v1/projects/rxfire-525a3/databases/(default)/documents`, { method: 'DELETE' });
58+
for (const database of ['(default)', 'second-db', 'third-db']) {
59+
await fetch(`http://localhost:8085/emulator/v1/projects/rxfire-525a3/databases/${database}/documents`, { method: 'DELETE' });
60+
}
5561
});
5662

5763
test('double check - emulator is running', async () => {
@@ -352,5 +358,173 @@ describe('Firestore', () => {
352358

353359
expect(result.current.data).toEqual([mockData]);
354360
});
361+
362+
it('separates cache entries for the same document path with and without a converter', async () => {
363+
const colRef = collection(db, randomString());
364+
const rawRef = doc(colRef, 'shared');
365+
await setDoc(rawRef, { v: 7 });
366+
367+
const converter: FirestoreDataConverter<{ converted: boolean; v: number }> = {
368+
toFirestore: (d) => ({ v: d.v }),
369+
fromFirestore: (snap) => ({ converted: true, v: (snap.data() as { v: number }).v }),
370+
};
371+
const convertedRef = rawRef.withConverter(converter);
372+
373+
const RawDoc = () => {
374+
const { data } = useFirestoreDocData(rawRef, { suspense: true });
375+
return <div data-testid="raw">{JSON.stringify(data)}</div>;
376+
};
377+
const ConvertedDoc = () => {
378+
const { data } = useFirestoreDocData(convertedRef, { suspense: true });
379+
return <div data-testid="conv">{JSON.stringify(data)}</div>;
380+
};
381+
382+
const { getByTestId } = render(
383+
<Provider>
384+
<React.Suspense fallback={<div data-testid="fb">loading</div>}>
385+
<RawDoc />
386+
<ConvertedDoc />
387+
</React.Suspense>
388+
</Provider>
389+
);
390+
391+
await waitFor(() => expect(getByTestId('conv')).toBeInTheDocument());
392+
expect(JSON.parse(getByTestId('conv').textContent || '{}').converted).toBe(true);
393+
});
394+
395+
it('resolves when a converter object is created inline during render', async () => {
396+
const colRef = collection(db, randomString());
397+
await setDoc(doc(colRef, 'inline'), { name: 'alice' });
398+
399+
const makeConverter = (): FirestoreDataConverter<{ name: string }> => ({
400+
toFirestore: (p) => p,
401+
fromFirestore: (snap) => ({ name: String(snap.get('name')) }),
402+
});
403+
404+
let statusRenders = 0;
405+
const StatusProfile = () => {
406+
statusRenders++;
407+
const { status, data } = useFirestoreDocData(doc(colRef, 'inline').withConverter(makeConverter()));
408+
return <div data-testid="status-out">{status === 'success' ? data?.name : 'loading'}</div>;
409+
};
410+
411+
let suspenseRenders = 0;
412+
const SuspenseProfile = () => {
413+
suspenseRenders++;
414+
const { data } = useFirestoreDocData(doc(colRef, 'inline').withConverter(makeConverter()), { suspense: true });
415+
return <div data-testid="suspense-out">{data?.name}</div>;
416+
};
417+
418+
const { getByTestId } = render(
419+
<Provider>
420+
<StatusProfile />
421+
<React.Suspense fallback={<div data-testid="suspense-fb">fallback</div>}>
422+
<SuspenseProfile />
423+
</React.Suspense>
424+
</Provider>
425+
);
426+
427+
await waitFor(() => expect(getByTestId('status-out')).toHaveTextContent('alice'));
428+
await waitFor(() => expect(getByTestId('suspense-out')).toHaveTextContent('alice'));
429+
expect(statusRenders).toBeLessThan(10);
430+
expect(suspenseRenders).toBeLessThan(10);
431+
});
432+
433+
it('never shares a cache entry for different documents across two module instances', async () => {
434+
const colRef = collection(db, randomString());
435+
const x = doc(colRef, 'x');
436+
const y = doc(colRef, 'y');
437+
await setDoc(x, { v: 'x' });
438+
await setDoc(y, { v: 'y' });
439+
440+
const copyA = await import('../src/index');
441+
vi.resetModules();
442+
const copyB = await import('../src/index');
443+
expect(copyA.useFirestoreDocData).not.toBe(copyB.useFirestoreDocData);
444+
445+
const A = () => {
446+
const { data } = copyA.useFirestoreDocData(x);
447+
return <span data-testid="dual-a">{String((data as { v?: string })?.v)}</span>;
448+
};
449+
const B = () => {
450+
const { data } = copyB.useFirestoreDocData(y);
451+
return <span data-testid="dual-b">{String((data as { v?: string })?.v)}</span>;
452+
};
453+
454+
const { getByTestId } = render(
455+
<copyA.FirebaseAppProvider firebaseApp={app}>
456+
<copyA.FirestoreProvider sdk={db}>
457+
<A />
458+
</copyA.FirestoreProvider>
459+
<copyB.FirebaseAppProvider firebaseApp={app}>
460+
<copyB.FirestoreProvider sdk={db}>
461+
<B />
462+
</copyB.FirestoreProvider>
463+
</copyB.FirebaseAppProvider>
464+
</copyA.FirebaseAppProvider>
465+
);
466+
467+
await waitFor(() => expect(getByTestId('dual-a')).toHaveTextContent('x'));
468+
await waitFor(() => expect(getByTestId('dual-b')).toHaveTextContent('y'));
469+
});
470+
471+
it('separates cache entries for the same document path in two databases on one app', async () => {
472+
const path = `${randomString()}/shared`;
473+
await setDoc(doc(db, path), { v: 'default' });
474+
await setDoc(doc(secondDb, path), { v: 'second' });
475+
476+
const DefaultDoc = () => {
477+
const { data } = useFirestoreDocData(doc(db, path));
478+
return <span data-testid="db-default">{String((data as { v?: string })?.v)}</span>;
479+
};
480+
const SecondDoc = () => {
481+
const { data } = useFirestoreDocData(doc(secondDb, path));
482+
return <span data-testid="db-second">{String((data as { v?: string })?.v)}</span>;
483+
};
484+
485+
const { getByTestId } = render(
486+
<Provider>
487+
<DefaultDoc />
488+
<SecondDoc />
489+
</Provider>
490+
);
491+
492+
await waitFor(() => expect(getByTestId('db-default')).toHaveTextContent('default'));
493+
await waitFor(() => expect(getByTestId('db-second')).toHaveTextContent('second'));
494+
});
495+
496+
it('separates cache entries for the same path in two databases across two module instances', async () => {
497+
// Both copies are freshly imported, so if the id table were module-local each would give its first database
498+
// id 0 and the two keys would collide.
499+
const path = `${randomString()}/shared`;
500+
await setDoc(doc(db, path), { v: 'default' });
501+
await setDoc(doc(thirdDb, path), { v: 'third' });
502+
503+
vi.resetModules();
504+
const copyA = await import('../src/index');
505+
vi.resetModules();
506+
const copyB = await import('../src/index');
507+
508+
const A = () => {
509+
const { data } = copyA.useFirestoreDocData(doc(db, path));
510+
return <span data-testid="copies-default">{String((data as { v?: string })?.v)}</span>;
511+
};
512+
const B = () => {
513+
const { data } = copyB.useFirestoreDocData(doc(thirdDb, path));
514+
return <span data-testid="copies-third">{String((data as { v?: string })?.v)}</span>;
515+
};
516+
517+
const { getByTestId } = render(
518+
<copyA.FirebaseAppProvider firebaseApp={app}>
519+
<A />
520+
<copyB.FirebaseAppProvider firebaseApp={app}>
521+
<B />
522+
</copyB.FirebaseAppProvider>
523+
</copyA.FirebaseAppProvider>
524+
);
525+
526+
await waitFor(() => expect(getByTestId('copies-default')).toHaveTextContent('default'));
527+
await waitFor(() => expect(getByTestId('copies-third')).toHaveTextContent('third'));
528+
});
355529
});
356530
});

0 commit comments

Comments
 (0)