Skip to content

Commit 803159c

Browse files
authored
Make sure useUser works predictably in AuthCheck (#156)
* Make sure useUser works predictably in AuthCheck * add a test for useObservable too
1 parent 99dac6c commit 803159c

File tree

5 files changed

+185
-42
lines changed

5 files changed

+185
-42
lines changed

reactfire/auth/auth.test.tsx

Lines changed: 98 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,41 @@
1-
import { cleanup, render } from '@testing-library/react';
2-
import { auth } from 'firebase/app';
1+
import { cleanup, render, waitForElement, wait } from '@testing-library/react';
2+
import { auth, User } from 'firebase/app';
33
import '@testing-library/jest-dom/extend-expect';
44
import * as React from 'react';
5-
import { AuthCheck } from '.';
5+
import { AuthCheck, useUser } from '.';
66
import { FirebaseAppProvider } from '..';
7+
import { Observable, Observer } from 'rxjs';
8+
import { act } from 'react-dom/test-utils';
79

8-
const mockAuth = jest.fn(() => {
9-
return {
10-
onIdTokenChanged: jest.fn()
11-
};
12-
});
10+
class MockAuth {
11+
user: Object | null;
12+
subscriber: Observer<any> | null;
13+
constructor() {
14+
this.user = null;
15+
this.subscriber = null;
16+
}
17+
18+
notifySubscriber() {
19+
if (this.subscriber) {
20+
this.subscriber.next(this.user);
21+
}
22+
}
23+
24+
onIdTokenChanged(s) {
25+
this.subscriber = s;
26+
this.notifySubscriber();
27+
}
28+
29+
updateUser(u: Object) {
30+
this.user = u;
31+
this.notifySubscriber();
32+
}
33+
}
34+
35+
const mockAuth = new MockAuth();
1336

1437
const mockFirebase = {
15-
auth: mockAuth
38+
auth: () => mockAuth
1639
};
1740

1841
const Provider = ({ children }) => (
@@ -21,22 +44,29 @@ const Provider = ({ children }) => (
2144
</FirebaseAppProvider>
2245
);
2346

47+
const Component = ({ children }) => (
48+
<Provider>
49+
<React.Suspense fallback={'loading'}>
50+
<AuthCheck fallback={<h1 data-testid="signed-out">not signed in</h1>}>
51+
{children || <h1 data-testid="signed-in">signed in</h1>}
52+
</AuthCheck>
53+
</React.Suspense>
54+
</Provider>
55+
);
56+
2457
describe('AuthCheck', () => {
58+
beforeEach(() => {
59+
// clear the signed in user
60+
mockFirebase.auth().updateUser(null);
61+
});
62+
2563
afterEach(() => {
2664
cleanup();
2765
jest.clearAllMocks();
2866
});
2967

3068
it('can find firebase Auth from Context', () => {
31-
expect(() =>
32-
render(
33-
<Provider>
34-
<React.Suspense fallback={'loading'}>
35-
<AuthCheck fallback={'loading'}>{'children'}</AuthCheck>
36-
</React.Suspense>
37-
</Provider>
38-
)
39-
).not.toThrow();
69+
expect(() => render(<Component />)).not.toThrow();
4070
});
4171

4272
it('can use firebase Auth from props', () => {
@@ -54,14 +84,62 @@ describe('AuthCheck', () => {
5484
).not.toThrow();
5585
});
5686

57-
test.todo('renders the fallback if a user is not signed in');
87+
it('renders the fallback if a user is not signed in', async () => {
88+
const { getByTestId } = render(<Component />);
89+
90+
await wait(() => expect(getByTestId('signed-out')).toBeInTheDocument());
91+
92+
act(() => mockFirebase.auth().updateUser({ uid: 'testuser' }));
93+
94+
await wait(() => expect(getByTestId('signed-in')).toBeInTheDocument());
95+
});
96+
97+
it('renders children if a user is logged in', async () => {
98+
mockFirebase.auth().updateUser({ uid: 'testuser' });
99+
const { getByTestId } = render(<Component />);
100+
101+
await wait(() => expect(getByTestId('signed-in')).toBeInTheDocument());
102+
});
103+
104+
it('can switch between logged in and logged out', async () => {
105+
const { getByTestId } = render(<Component />);
106+
107+
await wait(() => expect(getByTestId('signed-out')).toBeInTheDocument());
108+
109+
act(() => mockFirebase.auth().updateUser({ uid: 'testuser' }));
110+
111+
await wait(() => expect(getByTestId('signed-in')).toBeInTheDocument());
58112

59-
test.todo('renders children if a user is logged in');
113+
act(() => mockFirebase.auth().updateUser(null));
114+
115+
await wait(() => expect(getByTestId('signed-out')).toBeInTheDocument());
116+
});
60117

61118
test.todo('checks requiredClaims');
62119
});
63120

64121
describe('useUser', () => {
122+
it('always returns a user if inside an <AuthCheck> component', () => {
123+
const UserDetails = () => {
124+
const user = useUser();
125+
126+
expect(user).not.toBeNull();
127+
expect(user).toBeDefined();
128+
129+
return <h1>Hello</h1>;
130+
};
131+
132+
render(
133+
<>
134+
<Component>
135+
<UserDetails />
136+
</Component>
137+
</>
138+
);
139+
140+
act(() => mockFirebase.auth().updateUser({ uid: 'testuser' }));
141+
});
142+
65143
test.todo('can find firebase.auth() from Context');
66144

67145
test.todo('throws an error if firebase.auth() is not available');

reactfire/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
"@firebase/testing": "^0.11.4",
3939
"@testing-library/jest-dom": "^4.1.1",
4040
"@testing-library/react": "^9.3.0",
41-
"@testing-library/react-hooks": "^2.0.3",
41+
"@testing-library/react-hooks": "^3.1.0",
4242
"@types/jest": "^24.0.11",
4343
"babel-jest": "^24.7.1",
4444
"firebase-tools": "^7.1.0",

reactfire/useObservable/index.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,22 @@ export function useObservable(
3131
observableId: string,
3232
startWithValue?: any
3333
) {
34+
const request = requestCache.getRequest(observable$, observableId);
35+
3436
const initialValue =
35-
startWithValue || suspendUntilFirst(observable$, observableId);
37+
request.value ||
38+
startWithValue ||
39+
suspendUntilFirst(observable$, observableId);
3640

3741
const [latestValue, setValue] = React.useState(initialValue);
3842

3943
React.useEffect(() => {
4044
const subscription = observable$.pipe(startWith(initialValue)).subscribe(
4145
newVal => {
46+
// update the value in requestCache
47+
request.setValue(newVal);
48+
49+
// update state
4250
setValue(newVal);
4351
},
4452
error => {

reactfire/useObservable/useObservable.test.tsx

Lines changed: 73 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
1-
import { useObservable } from '.';
2-
import { renderHook, act } from '@testing-library/react-hooks';
3-
import { of, Subject, Observable, observable } from 'rxjs';
4-
import { delay } from 'rxjs/operators';
5-
import { render, waitForElement, cleanup } from '@testing-library/react';
6-
import { ReactFireOptions } from '..';
7-
import * as React from 'react';
81
import '@testing-library/jest-dom/extend-expect';
2+
import { act, cleanup, render, waitForElement } from '@testing-library/react';
3+
import { act as actOnHook, renderHook } from '@testing-library/react-hooks';
4+
import * as React from 'react';
5+
import { of, Subject } from 'rxjs';
6+
import { useObservable } from '.';
97

108
describe('useObservable', () => {
119
afterEach(cleanup);
@@ -32,20 +30,33 @@ describe('useObservable', () => {
3230
expect(result.current).toEqual(startVal);
3331

3432
// prove that it actually does emit the value from the observable too
35-
act(() => observable$.next(observableVal));
33+
actOnHook(() => observable$.next(observableVal));
3634
expect(result.current).toEqual(observableVal);
3735
});
3836

39-
it('ignores provided initial value if the observable is ready right away', () => {
37+
it('returns the provided startWithValue first even if the observable is ready right away', () => {
38+
// This behavior is a consequense of how observables work. There is
39+
// not a synchronous way to ask an observable if it has a value to emit.
40+
4041
const startVal = 'howdy';
4142
const observableVal = "y'all";
4243
const observable$ = of(observableVal);
44+
let hasReturnedStartWithValue = false;
4345

44-
const { result, waitForNextUpdate } = renderHook(() =>
45-
useObservable(observable$, 'test', startVal)
46-
);
46+
const Component = () => {
47+
const val = useObservable(observable$, 'test', startVal);
4748

48-
expect(result.current).toEqual(observableVal);
49+
if (hasReturnedStartWithValue) {
50+
expect(val).toEqual(observableVal);
51+
} else {
52+
expect(val).toEqual(startVal);
53+
hasReturnedStartWithValue = true;
54+
}
55+
56+
return <h1>Hello</h1>;
57+
};
58+
59+
render(<Component />);
4960
});
5061

5162
it('works with Suspense', async () => {
@@ -73,7 +84,7 @@ describe('useObservable', () => {
7384
expect(getByTestId(fallbackComponentId)).toBeInTheDocument();
7485
expect(queryByTestId(actualComponentId)).toBeNull();
7586

76-
act(() => observable$.next(observableFinalVal));
87+
actOnHook(() => observable$.next(observableFinalVal));
7788
await waitForElement(() => getByTestId(actualComponentId));
7889

7990
// make sure Suspense correctly renders its child after the observable emits a value
@@ -87,7 +98,6 @@ describe('useObservable', () => {
8798
it('emits new values as the observable changes', async () => {
8899
const startVal = 'start';
89100
const values = ['a', 'b', 'c'];
90-
const observableSecondValue = 'b';
91101
const observable$ = new Subject();
92102

93103
const { result } = renderHook(() =>
@@ -97,8 +107,55 @@ describe('useObservable', () => {
97107
expect(result.current).toEqual(startVal);
98108

99109
values.forEach(value => {
100-
act(() => observable$.next(value));
110+
actOnHook(() => observable$.next(value));
101111
expect(result.current).toEqual(value);
102112
});
103113
});
114+
115+
it('returns the most recent value of an observable to all subscribers of an observableId', async () => {
116+
const values = ['a', 'b', 'c'];
117+
const observable$ = new Subject();
118+
const observableId = 'my-observable-id';
119+
const firstComponentId = 'first';
120+
const secondComponentId = 'second';
121+
122+
const ObservableConsumer = props => {
123+
const val = useObservable(observable$, observableId);
124+
125+
return <h1 {...props}>{val}</h1>;
126+
};
127+
128+
const Component = ({ renderSecondComponent }) => {
129+
return (
130+
<React.Suspense fallback="loading">
131+
<ObservableConsumer data-testid={firstComponentId} />
132+
{renderSecondComponent ? (
133+
<ObservableConsumer data-testid={secondComponentId} />
134+
) : null}
135+
</React.Suspense>
136+
);
137+
};
138+
139+
const { getByTestId, rerender } = render(
140+
<Component renderSecondComponent={false} />
141+
);
142+
143+
// emit one value to the first component (second one isn't rendered yet)
144+
act(() => observable$.next(values[0]));
145+
const comp = await waitForElement(() => getByTestId(firstComponentId));
146+
expect(comp).toHaveTextContent(values[0]);
147+
148+
// emit a second value to the first component (second one still isn't rendered)
149+
act(() => observable$.next(values[1]));
150+
expect(comp).toHaveTextContent(values[1]);
151+
152+
// keep the original component around, but now render the second one.
153+
// they both use the same observableId
154+
rerender(<Component renderSecondComponent={true} />);
155+
156+
// the second component should start by receiving the latest value
157+
// since the first component has already been subscribed
158+
const comp2 = await waitForElement(() => getByTestId(secondComponentId));
159+
expect(comp2).toHaveTextContent(values[1]);
160+
});
104161
});

yarn.lock

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1885,10 +1885,10 @@
18851885
pretty-format "^24.0.0"
18861886
redent "^3.0.0"
18871887

1888-
"@testing-library/react-hooks@^2.0.3":
1889-
version "2.0.3"
1890-
resolved "https://registry.yarnpkg.com/@testing-library/react-hooks/-/react-hooks-2.0.3.tgz#305a6c76facb5fa1d185792b9eb11b1ca1b63fb7"
1891-
integrity sha512-adm+7b1gcysGka8VuYq/ObBrIBJTT9QmCEIqPpuxozWFfVDgxSbzBGc44ia/WYLGVt2dqFIOc6/DmAmu/pa0gQ==
1888+
"@testing-library/react-hooks@^3.1.0":
1889+
version "3.1.0"
1890+
resolved "https://registry.yarnpkg.com/@testing-library/react-hooks/-/react-hooks-3.1.0.tgz#f186c4f3b32db153d30d646faacb043ef4089807"
1891+
integrity sha512-mwFDHXCQiyr0tQkYU4VkcwlCzR5YQ5k1/TCrL3hPslCM5MvS6pBhbl2z4UnCMV4DOyiUUXIvoMAf5kHT/hibTg==
18921892
dependencies:
18931893
"@babel/runtime" "^7.5.4"
18941894
"@types/testing-library__react-hooks" "^2.0.0"

0 commit comments

Comments
 (0)