Skip to content

Commit 121f5e1

Browse files
authored
Refactor on the core observable engine (#210)
Refactoring `useObservable` and `preloadObservable` to be more suspense friendly. The core idea here is that our "observable" should expose a promise that resolves on first emission (which can be thrown for suspense), a synchronous means of getting the latest value (to pass into `useState`), and a shared subscription for use thereafter (for `useEffect`). As a matter of optimization they will keep their cache around for only a present amount of time (30 seconds by default) unless they are subscribed to (`useEffect`); in which case the cache will remain until the component is unloaded. This should be enough time to prevent suspense from thrashing. To encapsulate this behavior this we've implemented our own `Subject`. Other changes in this PR: * Ensure that the observable cache has to be keyed very specifically taking into account the firebase app, options, etc. * Make sure no cache keys overlap on tests & different exports * We're using more typescript in the tests, build them as part of the main build step since jest wasn't playing nice * There's still some meh types kicking around here, should take as an AI * Errors are thrown again until the cache timeout elapses, put down a TODO for now * `FirebaseAppProvider` now throws if the provided config does not match the existing instance options, rather than simply returning the default * `FirebaseAppProvider` now takes an `appName` property allowing you to have more than one app
1 parent e2f1b1f commit 121f5e1

File tree

21 files changed

+511
-257
lines changed

21 files changed

+511
-257
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ node_modules/
1010

1111
reactfire/docs/reactfire-metadata.json
1212
reactfire/firestore-debug.log
13+
reactfire/firebase-debug.log
1314
reactfire/pub/**
1415
pub
1516
yarn-error.log
1617
reactfire/database-debug.log
18+
reactfire/reactfire-*.tgz

reactfire/.npmignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
cjs/index.esm-*
2+
**/*.test.d.ts
3+
**/*.test.js

reactfire/auth/auth.test.tsx

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ class MockAuth {
1515
this.subscriber = null;
1616
}
1717

18+
app = {
19+
name: '[DEFAULT]'
20+
};
21+
1822
notifySubscriber() {
1923
if (this.subscriber) {
2024
this.subscriber.next(this.user);
@@ -39,16 +43,16 @@ const mockFirebase = {
3943
};
4044

4145
const Provider = ({ children }) => (
42-
<FirebaseAppProvider firebaseApp={mockFirebase}>
46+
<FirebaseAppProvider firebaseApp={(mockFirebase as any) as firebase.app.App}>
4347
{children}
4448
</FirebaseAppProvider>
4549
);
4650

47-
const Component = ({ children }) => (
51+
const Component = (props?: { children?: any }) => (
4852
<Provider>
4953
<React.Suspense fallback={'loading'}>
5054
<AuthCheck fallback={<h1 data-testid="signed-out">not signed in</h1>}>
51-
{children || <h1 data-testid="signed-in">signed in</h1>}
55+
{props?.children || <h1 data-testid="signed-in">signed in</h1>}
5256
</AuthCheck>
5357
</React.Suspense>
5458
</Provider>
@@ -57,12 +61,14 @@ const Component = ({ children }) => (
5761
describe('AuthCheck', () => {
5862
beforeEach(() => {
5963
// clear the signed in user
60-
mockFirebase.auth().updateUser(null);
64+
act(() => mockFirebase.auth().updateUser(null));
6165
});
6266

6367
afterEach(() => {
64-
cleanup();
65-
jest.clearAllMocks();
68+
act(() => {
69+
cleanup();
70+
jest.clearAllMocks();
71+
});
6672
});
6773

6874
it('can find firebase Auth from Context', () => {
@@ -95,7 +101,7 @@ describe('AuthCheck', () => {
95101
});
96102

97103
it('renders children if a user is logged in', async () => {
98-
mockFirebase.auth().updateUser({ uid: 'testuser' });
104+
act(() => mockFirebase.auth().updateUser({ uid: 'testuser' }));
99105
const { getByTestId } = render(<Component />);
100106

101107
await wait(() => expect(getByTestId('signed-in')).toBeInTheDocument());

reactfire/auth/index.tsx

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,9 @@ export function preloadUser(firebaseApp: firebase.app.App) {
1414
return preloadAuth(firebaseApp).then(auth => {
1515
const result = preloadObservable(
1616
user(auth() as firebase.auth.Auth),
17-
'auth: user'
17+
`auth:user:${firebaseApp.name}`
1818
);
19-
return result.request.promise;
19+
return result.toPromise();
2020
});
2121
}
2222

@@ -31,18 +31,8 @@ export function useUser<T = unknown>(
3131
options?: ReactFireOptions<T>
3232
): User | T {
3333
auth = auth || useAuth()();
34-
35-
let currentUser = undefined;
36-
37-
if (options && options.startWithValue !== undefined) {
38-
currentUser = options.startWithValue;
39-
} else if (auth.currentUser) {
40-
// if auth.currentUser is undefined or null, we won't use it
41-
// because null can mean "not signed in" OR "still loading"
42-
currentUser = auth.currentUser;
43-
}
44-
45-
return useObservable(user(auth), 'auth: user', currentUser);
34+
const currentUser = auth.currentUser || options?.startWithValue;
35+
return useObservable(user(auth), `auth:user:${auth.app.name}`, currentUser);
4636
}
4737

4838
export function useIdTokenResult(user: User, forceRefresh: boolean = false) {
@@ -52,7 +42,10 @@ export function useIdTokenResult(user: User, forceRefresh: boolean = false) {
5242

5343
const idToken$ = from(user.getIdTokenResult(forceRefresh));
5444

55-
return useObservable<any>(idToken$, `${user.uid}-claims`);
45+
return useObservable<any>(
46+
idToken$,
47+
`auth:idTokenResult:${user.uid}:forceRefresh=${forceRefresh}`
48+
);
5649
}
5750

5851
export interface AuthCheckProps {

reactfire/database/index.tsx

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export function useDatabaseObject<T = unknown>(
2222
): QueryChange | T {
2323
return useObservable(
2424
object(ref),
25-
`RTDB Doc: ${ref.toString()}`,
25+
`database:object:${ref.toString()}`,
2626
options ? options.startWithValue : undefined
2727
);
2828
}
@@ -54,9 +54,10 @@ export function useDatabaseObjectData<T>(
5454
ref: database.Reference,
5555
options?: ReactFireOptions<T>
5656
): T {
57+
const idField = checkIdField(options);
5758
return useObservable(
58-
objectVal(ref, checkIdField(options)),
59-
`RTDB DocData: ${ref.toString()}`,
59+
objectVal(ref, idField),
60+
`database:objectVal:${ref.toString()}:idField=${idField}`,
6061
checkStartWithValue(options)
6162
);
6263
}
@@ -78,7 +79,7 @@ export function useDatabaseList<T = { [key: string]: unknown }>(
7879
ref: database.Reference | database.Query,
7980
options?: ReactFireOptions<T[]>
8081
): QueryChange[] | T[] {
81-
const hash = `RTDB List: ${ref.toString()}|${(ref as _QueryWithId).queryIdentifier()}`;
82+
const hash = `database:list:${ref.toString()}|${(ref as _QueryWithId).queryIdentifier()}`;
8283

8384
return useObservable(
8485
list(ref),
@@ -91,9 +92,10 @@ export function useDatabaseListData<T = { [key: string]: unknown }>(
9192
ref: database.Reference | database.Query,
9293
options?: ReactFireOptions<T[]>
9394
): T[] {
95+
const idField = checkIdField(options);
9496
return useObservable(
95-
listVal(ref, checkIdField(options)),
96-
`RTDB ListData: ${ref.toString()}`,
97+
listVal(ref, idField),
98+
`database:listVal:${ref.toString()}|${(ref as _QueryWithId).queryIdentifier()}:idField=${idField}`,
9799
checkStartWithValue(options)
98100
);
99101
}

reactfire/firebaseApp/firebaseApp.test.tsx

Lines changed: 59 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,21 +8,21 @@ import { FirebaseAppProvider } from './index';
88

99
afterEach(cleanup);
1010

11+
const DEFAULT_APP_CONFIG = { appId: '12345' };
12+
1113
describe('FirebaseAppProvider', () => {
1214
it('calls firebase.initializeApp with the provided config', () => {
13-
const config = { appId: '12345' };
14-
1515
const spy = jest.spyOn(firebase, 'initializeApp');
1616

17-
render(<FirebaseAppProvider firebaseConfig={config} />);
18-
expect(spy).toBeCalledWith(config);
17+
render(<FirebaseAppProvider firebaseConfig={DEFAULT_APP_CONFIG} />);
18+
expect(spy).toBeCalledWith(DEFAULT_APP_CONFIG, undefined);
1919

2020
spy.mockRestore();
2121
});
2222

2323
it('does not call firebase.initializeApp if the firebaseApp is provided', () => {
2424
const spy = jest.spyOn(firebase, 'initializeApp');
25-
const app = {};
25+
const app: firebase.app.App = {} as any;
2626
render(<FirebaseAppProvider firebaseApp={app} />);
2727
expect(spy).not.toBeCalled();
2828

@@ -32,7 +32,7 @@ describe('FirebaseAppProvider', () => {
3232
it('initializes fireperf if specified', async () => {
3333
const mockPerf = jest.fn();
3434
firebase['performance' as any] = mockPerf;
35-
const app = { performance: mockPerf };
35+
const app: firebase.app.App = { performance: mockPerf } as any;
3636

3737
render(<FirebaseAppProvider firebaseApp={app} initPerformance />);
3838

@@ -42,7 +42,7 @@ describe('FirebaseAppProvider', () => {
4242
it('does not initialize fireperf if not specified', async () => {
4343
const mockPerf = jest.fn();
4444
firebase['performance' as any] = mockPerf;
45-
const app = { performance: mockPerf };
45+
const app: firebase.app.App = { performance: mockPerf } as any;
4646

4747
render(<FirebaseAppProvider firebaseApp={app} />);
4848

@@ -52,7 +52,7 @@ describe('FirebaseAppProvider', () => {
5252

5353
describe('useFirebaseApp', () => {
5454
it('finds firebase from Context', () => {
55-
const firebaseApp = { a: 1 };
55+
const firebaseApp: firebase.app.App = { a: 1 } as any;
5656

5757
const wrapper = ({ children }) => (
5858
<FirebaseAppProvider firebaseApp={firebaseApp}>
@@ -65,6 +65,57 @@ describe('useFirebaseApp', () => {
6565
expect(result.current).toBe(firebaseApp);
6666
});
6767

68+
it('can initialize more than one firebase app', () => {
69+
const config = { a: 1 };
70+
71+
const initializeApp = jest.spyOn(firebase, 'initializeApp');
72+
73+
const wrapper = ({ children }) => (
74+
<div>
75+
<FirebaseAppProvider firebaseConfig={DEFAULT_APP_CONFIG}>
76+
{children}
77+
</FirebaseAppProvider>
78+
<FirebaseAppProvider firebaseConfig={config} appName="app-2">
79+
appA
80+
</FirebaseAppProvider>
81+
</div>
82+
);
83+
84+
const { result } = renderHook(() => useFirebaseApp(), { wrapper });
85+
86+
expect(initializeApp).toBeCalledWith(config, 'app-2');
87+
initializeApp.mockRestore();
88+
89+
expect(result.error).toBeUndefined();
90+
});
91+
92+
it('will throw if configs dont match, and same name', () => {
93+
const config = { a: 1 };
94+
95+
const initializeApp = jest.spyOn(firebase, 'initializeApp');
96+
97+
const wrapper = ({ children }) => (
98+
<div>
99+
<FirebaseAppProvider firebaseConfig={DEFAULT_APP_CONFIG}>
100+
{children}
101+
</FirebaseAppProvider>
102+
<FirebaseAppProvider firebaseConfig={config}>appA</FirebaseAppProvider>
103+
</div>
104+
);
105+
106+
try {
107+
const { result } = renderHook(() => useFirebaseApp(), { wrapper });
108+
fail('expected a throw');
109+
} catch (e) {
110+
expect(e).toEqual(
111+
'Does not match the options already provided to the default firebase app instance, give this new instance a different appName.'
112+
);
113+
}
114+
115+
expect(initializeApp).not.toBeCalled();
116+
initializeApp.mockRestore();
117+
});
118+
68119
it('throws an error if Firebase is not in context', () => {
69120
const { result } = renderHook(() => useFirebaseApp());
70121

reactfire/firebaseApp/index.tsx

Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,34 +5,54 @@ export * from './sdk';
55

66
type FirebaseAppContextValue = firebase.app.App;
77

8+
// INVESTIGATE I don't like magic strings, can we have export this in js-sdk?
9+
const DEFAULT_APP_NAME = '[DEFAULT]';
10+
811
const FirebaseAppContext = React.createContext<
912
FirebaseAppContextValue | undefined
1013
>(undefined);
1114

12-
export function FirebaseAppProvider(props) {
13-
const { firebaseConfig, initPerformance } = props;
14-
let { firebaseApp } = props;
15+
type Props = {
16+
initPerformance?: boolean;
17+
firebaseApp?: firebase.app.App;
18+
firebaseConfig?: Object;
19+
appName?: string;
20+
};
21+
22+
const shallowEq = (a: Object, b: Object) =>
23+
a == b ||
24+
[...Object.keys(a), ...Object.keys(b)].every(key => a[key] == b[key]);
1525

16-
firebaseApp =
17-
firebaseApp ||
26+
export function FirebaseAppProvider(props: Props & { [key: string]: unknown }) {
27+
const { firebaseConfig, appName, initPerformance = false } = props;
28+
const firebaseApp: firebase.app.App =
29+
props.firebaseApp ||
1830
React.useMemo(() => {
19-
if (!firebase.apps.length) {
20-
firebase.initializeApp(firebaseConfig);
31+
const existingApp = firebase.apps.find(
32+
app => app.name == (appName || DEFAULT_APP_NAME)
33+
);
34+
if (existingApp) {
35+
if (shallowEq(existingApp.options, firebaseConfig)) {
36+
return existingApp;
37+
} else {
38+
throw `Does not match the options already provided to the ${appName ||
39+
'default'} firebase app instance, give this new instance a different appName.`;
40+
}
41+
} else {
42+
return firebase.initializeApp(firebaseConfig, appName);
2143
}
22-
23-
return firebase;
24-
}, [firebaseConfig]);
44+
}, [firebaseConfig, appName]);
2545

2646
React.useMemo(() => {
27-
if (initPerformance === true && !!firebase.apps.length) {
28-
if (!firebase.performance) {
47+
if (initPerformance === true) {
48+
if (firebaseApp.performance) {
49+
// initialize Performance Monitoring
50+
firebaseApp.performance();
51+
} else {
2952
throw new Error(
3053
'firebase.performance not found. Did you forget to import it?'
3154
);
3255
}
33-
34-
// initialize Performance Monitoring
35-
firebase.performance();
3656
}
3757
}, [initPerformance, firebaseApp]);
3858

0 commit comments

Comments
 (0)