Skip to content

Commit 774bdb2

Browse files
authored
Merge pull request #5822 from marmelab/fix-WithPermissions-race-condition
Fix race condition in WithPermissions
2 parents 94aff58 + 29aefbc commit 774bdb2

File tree

2 files changed

+116
-5
lines changed

2 files changed

+116
-5
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import * as React from 'react';
2+
import { render, act, cleanup } from '@testing-library/react';
3+
import expect from 'expect';
4+
import usePermissionsOptimized from './usePermissionsOptimized';
5+
import AuthContext from './AuthContext';
6+
7+
describe('usePermissionsOptimized', () => {
8+
afterEach(cleanup);
9+
10+
const CallPermissionsOnMount = ({ number, authParams }: any) => {
11+
const { permissions } = usePermissionsOptimized(authParams);
12+
return (
13+
<div>
14+
permissions {number}: {permissions}
15+
</div>
16+
);
17+
};
18+
19+
it('returns undefined on mount', () => {
20+
const getPermissions = jest.fn(() => Promise.resolve('admin'));
21+
const { queryByText } = render(
22+
<AuthContext.Provider value={{ getPermissions } as any}>
23+
<div>
24+
<CallPermissionsOnMount authParams={{ test: 1 }} />
25+
</div>
26+
</AuthContext.Provider>
27+
);
28+
expect(queryByText('permissions :')).not.toBeNull();
29+
expect(queryByText('permissions : admin')).toBeNull();
30+
});
31+
32+
it('returns permissions from authProvider after resolve', async () => {
33+
const getPermissions = jest.fn(() => Promise.resolve('admin'));
34+
const { queryByText } = render(
35+
<AuthContext.Provider value={{ getPermissions } as any}>
36+
<div>
37+
<CallPermissionsOnMount authParams={{ test: 2 }} />
38+
</div>
39+
</AuthContext.Provider>
40+
);
41+
await act(async () => await new Promise(r => setTimeout(r)));
42+
expect(queryByText('permissions :')).toBeNull();
43+
expect(queryByText('permissions : admin')).not.toBeNull();
44+
});
45+
46+
it('does not rerender once the permissions have already been fetched', async () => {
47+
let renders = 0;
48+
const ComponentToTest = () => {
49+
const { permissions } = usePermissionsOptimized({ test: 3 });
50+
renders++;
51+
return <div>{permissions}</div>;
52+
};
53+
const getPermissions = jest.fn(() => Promise.resolve('admin'));
54+
55+
// first usage
56+
const { queryByText } = render(
57+
<AuthContext.Provider value={{ getPermissions } as any}>
58+
<ComponentToTest />
59+
</AuthContext.Provider>
60+
);
61+
expect(renders).toBe(1); // renders on mount
62+
expect(getPermissions).toBeCalledTimes(1);
63+
expect(queryByText('admin')).toBeNull();
64+
await act(async () => await new Promise(r => setTimeout(r)));
65+
expect(renders).toBe(2); // rerenders when the getPermissions returns
66+
expect(queryByText('admin')).not.toBeNull();
67+
68+
// second usage
69+
cleanup();
70+
renders = 0;
71+
const { queryByText: queryByText2 } = render(
72+
<AuthContext.Provider value={{ getPermissions } as any}>
73+
<ComponentToTest />
74+
</AuthContext.Provider>
75+
);
76+
expect(renders).toBe(1); // renders on mount
77+
expect(getPermissions).toBeCalledTimes(2);
78+
expect(queryByText2('admin')).not.toBeNull(); // answer from the cache
79+
await act(async () => await new Promise(r => setTimeout(r)));
80+
expect(renders).toBe(1); // does not rerender when the getPermissions returns the same permissions
81+
});
82+
83+
it('can be called by two independent components', async () => {
84+
const getPermissions = jest.fn(() => Promise.resolve('admin'));
85+
const { queryByText } = render(
86+
<AuthContext.Provider value={{ getPermissions } as any}>
87+
<div>
88+
<CallPermissionsOnMount
89+
number={1}
90+
authParams={{ test: 4 }}
91+
/>
92+
<CallPermissionsOnMount
93+
number={2}
94+
authParams={{ test: 4 }}
95+
/>
96+
</div>
97+
</AuthContext.Provider>
98+
);
99+
expect(queryByText('permissions 1:')).not.toBeNull();
100+
expect(queryByText('permissions 2:')).not.toBeNull();
101+
expect(queryByText('permissions 1: admin')).toBeNull();
102+
expect(queryByText('permissions 2: admin')).toBeNull();
103+
expect(getPermissions).toBeCalledTimes(2);
104+
await act(async () => await new Promise(r => setTimeout(r)));
105+
expect(queryByText('permissions 1:')).toBeNull();
106+
expect(queryByText('permissions 2:')).toBeNull();
107+
expect(queryByText('permissions 1: admin')).not.toBeNull();
108+
expect(queryByText('permissions 2: admin')).not.toBeNull();
109+
expect(getPermissions).toBeCalledTimes(2);
110+
});
111+
});

packages/ra-core/src/auth/usePermissionsOptimized.ts

+5-5
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ const emptyParams = {};
1313

1414
// keep a cache of already fetched permissions to initialize state for new
1515
// components and avoid a useless rerender if the permissions haven't changed
16-
const alreadyFetchedPermissions = { '{}': [] };
16+
const alreadyFetchedPermissions = { '{}': undefined };
1717

1818
/**
1919
* Hook for getting user permissions without the loading state.
@@ -53,15 +53,15 @@ const alreadyFetchedPermissions = { '{}': [] };
5353
* };
5454
*/
5555
const usePermissionsOptimized = (params = emptyParams) => {
56+
const key = JSON.stringify(params);
5657
const [state, setState] = useSafeSetState<State>({
57-
permissions: alreadyFetchedPermissions[JSON.stringify(params)],
58+
permissions: alreadyFetchedPermissions[key],
5859
});
5960
const getPermissions = useGetPermissions();
6061
useEffect(() => {
6162
getPermissions(params)
6263
.then(permissions => {
63-
const key = JSON.stringify(params);
64-
if (!isEqual(permissions, alreadyFetchedPermissions[key])) {
64+
if (!isEqual(permissions, state.permissions)) {
6565
alreadyFetchedPermissions[key] = permissions;
6666
setState({ permissions });
6767
}
@@ -71,7 +71,7 @@ const usePermissionsOptimized = (params = emptyParams) => {
7171
error,
7272
});
7373
});
74-
}, [getPermissions, params, setState]);
74+
}, [getPermissions, key]); // eslint-disable-line react-hooks/exhaustive-deps
7575

7676
return state;
7777
};

0 commit comments

Comments
 (0)