Skip to content

Commit e6248bd

Browse files
authored
chore(react-core): ensure useDataState returns value of last dispatch (#6382)
* Create nasty-lemons-agree.md
1 parent 4a4d8d6 commit e6248bd

File tree

15 files changed

+273
-58
lines changed

15 files changed

+273
-58
lines changed

.changeset/nasty-lemons-agree.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@aws-amplify/ui-react-core": patch
3+
"@aws-amplify/ui-react-storage": patch
4+
---
5+
6+
chore(react-core): ensure useDataState returns value of last dispatch

packages/react-core/src/hooks/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
export {
2-
default as useDataState,
2+
useDataState,
33
AsyncDataAction,
44
DataAction,
55
DataState,
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { renderHook } from '@testing-library/react';
2+
3+
import useDataState from '../useDataState.native';
4+
5+
it('throws the expected error when called with a type not mapped to an action handler', () => {
6+
// turn off console.error logging for unhappy path test case
7+
jest.spyOn(console, 'error').mockImplementation(() => {});
8+
9+
expect(() => renderHook(() => useDataState())).toThrow(
10+
new Error('useDataState is not implemented for React Native')
11+
);
12+
});

packages/react-core/src/hooks/__tests__/useDataState.spec.ts renamed to packages/react-core/src/hooks/useDataState/__tests__/useDataState.spec.ts

Lines changed: 109 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,30 @@
11
import { act, renderHook, waitFor } from '@testing-library/react';
2+
23
import useDataState from '../useDataState';
34

45
const asyncAction = jest.fn((_prev: string, next: string) =>
56
Promise.resolve(next)
67
);
78
const syncAction = jest.fn((_prev: string, next: string) => next);
89

9-
const errorMessage = 'Unhappy!';
10+
const sleepyAction = jest.fn(
11+
(
12+
_: string,
13+
{ timeout, fail }: { fail?: boolean; timeout: number }
14+
): Promise<string> =>
15+
new Promise((resolve, reject) =>
16+
setTimeout(
17+
() =>
18+
fail
19+
? reject(new Error(timeout.toString()))
20+
: resolve(timeout.toString()),
21+
timeout
22+
)
23+
)
24+
);
25+
26+
const error = new Error('Unhappy!');
27+
const errorMessage = error.message;
1028
const unhappyAction = jest.fn((_, isUnhappy: boolean) =>
1129
isUnhappy ? Promise.reject(new Error(errorMessage)) : Promise.resolve()
1230
);
@@ -15,6 +33,13 @@ const initData = 'initial-data';
1533
const nextData = 'next-data';
1634

1735
describe('useDataState', () => {
36+
beforeAll(() => {
37+
let id = 0;
38+
Object.defineProperty(globalThis, 'crypto', {
39+
value: { randomUUID: () => ++id },
40+
});
41+
});
42+
1843
beforeEach(() => {
1944
jest.clearAllMocks();
2045
});
@@ -112,7 +137,7 @@ describe('useDataState', () => {
112137
});
113138

114139
expect(onError).toHaveBeenCalledTimes(1);
115-
expect(onError).toHaveBeenCalledWith(errorMessage);
140+
expect(onError).toHaveBeenCalledWith(error);
116141
});
117142

118143
it('handles an error and resets error state on the next call to handleAction', async () => {
@@ -157,5 +182,86 @@ describe('useDataState', () => {
157182
});
158183
});
159184

160-
it.todo('only returns the value of the last call to handleAction');
185+
it('only returns the value of the last dispatch in the happy path', async () => {
186+
jest.useFakeTimers();
187+
188+
const defaultValue = '';
189+
const timeoutOne = 2000;
190+
const timeoutTwo = 1000;
191+
const expectedResult = timeoutTwo.toString();
192+
193+
const { result } = renderHook(() =>
194+
useDataState(sleepyAction, defaultValue)
195+
);
196+
197+
const [initState, dispatch] = result.current;
198+
199+
act(() => {
200+
dispatch({ timeout: timeoutOne });
201+
});
202+
203+
expect(initState.data).toBe(defaultValue);
204+
205+
expect(sleepyAction).toHaveBeenCalledTimes(1);
206+
207+
act(() => {
208+
dispatch({ timeout: timeoutTwo });
209+
});
210+
211+
expect(sleepyAction).toHaveBeenCalledTimes(2);
212+
213+
jest.runAllTimers();
214+
215+
await waitFor(() => {
216+
const [resolvedState] = result.current;
217+
218+
// assert both calls have completed
219+
expect(sleepyAction.mock.results.length).toBe(2);
220+
221+
expect(resolvedState.data).toBe(expectedResult);
222+
expect(resolvedState.isLoading).toBe(false);
223+
expect(resolvedState.hasError).toBe(false);
224+
});
225+
});
226+
227+
it('only returns the value of the last dispatch in the unhappy path', async () => {
228+
jest.useFakeTimers();
229+
230+
const defaultValue = '';
231+
const timeoutOne = 2000;
232+
const timeoutTwo = 1000;
233+
const expectedResult = timeoutTwo.toString();
234+
235+
const { result } = renderHook(() =>
236+
useDataState(sleepyAction, defaultValue)
237+
);
238+
239+
const [initState, dispatch] = result.current;
240+
241+
act(() => {
242+
dispatch({ timeout: timeoutOne, fail: true });
243+
});
244+
245+
expect(initState.data).toBe(defaultValue);
246+
247+
expect(sleepyAction).toHaveBeenCalledTimes(1);
248+
249+
act(() => {
250+
dispatch({ timeout: timeoutTwo, fail: true });
251+
});
252+
253+
jest.runAllTimers();
254+
255+
await waitFor(() => {
256+
const [resolvedState] = result.current;
257+
258+
// assert both calls have completed
259+
expect(sleepyAction.mock.results.length).toBe(2);
260+
261+
expect(resolvedState.data).toBe(defaultValue);
262+
expect(resolvedState.message).toBe(expectedResult);
263+
expect(resolvedState.hasError).toBe(true);
264+
expect(resolvedState.isLoading).toBe(false);
265+
});
266+
});
161267
});
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { default as useDataState } from './useDataState';
2+
export { AsyncDataAction, DataAction, DataState } from './types';
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
export interface DataState<T> {
2+
data: T;
3+
hasError: boolean;
4+
isLoading: boolean;
5+
message: string | undefined;
6+
}
7+
8+
export type DataAction<T = any, K = any> = (prevData: T, input: K) => T;
9+
10+
export type AsyncDataAction<T = any, K = any> = (
11+
prevData: T,
12+
input: K
13+
) => Promise<T>;
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default function useDataState(): void {
2+
throw new Error('useDataState is not implemented for React Native');
3+
}

packages/react-core/src/hooks/useDataState.ts renamed to packages/react-core/src/hooks/useDataState/useDataState.ts

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,7 @@
1-
import { isFunction } from '@aws-amplify/ui';
21
import React from 'react';
2+
import { isFunction } from '@aws-amplify/ui';
33

4-
export interface DataState<T> {
5-
data: T;
6-
hasError: boolean;
7-
isLoading: boolean;
8-
message: string | undefined;
9-
}
10-
11-
export type DataAction<T = any, K = any> = (prevData: T, input: K) => T;
12-
13-
export type AsyncDataAction<T = any, K = any> = (
14-
prevData: T,
15-
input: K
16-
) => Promise<T>;
4+
import { AsyncDataAction, DataAction, DataState } from './types';
175

186
// default state
197
const INITIAL_STATE = { hasError: false, isLoading: false, message: undefined };
@@ -27,12 +15,15 @@ const resolveMaybeAsync = async <T>(
2715
return awaited;
2816
};
2917

18+
/**
19+
* @internal may be updated in future versions
20+
*/
3021
export default function useDataState<T, K>(
3122
action: DataAction<T, K> | AsyncDataAction<T, K>,
3223
initialData: T,
3324
options?: {
3425
onSuccess?: (data: T) => void;
35-
onError?: (message: string) => void;
26+
onError?: (error: Error) => void;
3627
}
3728
): [state: DataState<T>, handleAction: (input: K) => void] {
3829
const [dataState, setDataState] = React.useState<DataState<T>>(() => ({
@@ -41,22 +32,33 @@ export default function useDataState<T, K>(
4132
}));
4233

4334
const prevData = React.useRef(initialData);
35+
const pendingId = React.useRef<string | undefined>();
4436

4537
const { onSuccess, onError } = options ?? {};
4638

4739
const handleAction: (input: K) => void = React.useCallback(
4840
(input) => {
41+
const id = crypto.randomUUID();
42+
pendingId.current = id;
43+
4944
setDataState(({ data }) => ({ ...LOADING_STATE, data }));
5045

5146
resolveMaybeAsync(action(prevData.current, input))
5247
.then((data: T) => {
53-
if (isFunction(onSuccess)) onSuccess(data);
48+
if (pendingId.current !== id) return;
5449

5550
prevData.current = data;
51+
52+
if (isFunction(onSuccess)) onSuccess(data);
53+
5654
setDataState({ ...INITIAL_STATE, data });
5755
})
58-
.catch(({ message }: Error) => {
59-
if (isFunction(onError)) onError(message);
56+
.catch((error: Error) => {
57+
if (pendingId.current !== id) return;
58+
59+
if (isFunction(onError)) onError(error);
60+
61+
const { message } = error;
6062

6163
setDataState(({ data }) => ({ ...ERROR_STATE, data, message }));
6264
});

packages/react-storage/jest.config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ const config: Config = {
88
'!<rootDir>/**/(index|version).(ts|tsx)',
99
// do not collect from top level styles directory
1010
'!<rootDir>/src/styles/*.ts',
11+
// do not collect coverage of test utils
12+
'!<rootDir>/src/**/__testUtils__/*.(ts|tsx)',
1113
],
1214
coverageThreshold: {
1315
global: {

packages/react-storage/src/components/StorageBrowser/StorageBrowserAmplify.tsx

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,10 @@ import { createAmplifyAuthAdapter } from './adapters';
66

77
export interface StorageBrowserProps extends StorageBrowserPropsBase {}
88

9-
export const StorageBrowser = ({
10-
views,
11-
displayText,
12-
}: StorageBrowserProps): React.JSX.Element => {
13-
const { StorageBrowser } = React.useRef(
9+
export function StorageBrowser(props: StorageBrowserProps): React.JSX.Element {
10+
const { StorageBrowser: StorageBrowserComponent } = React.useRef(
1411
createStorageBrowser({ config: createAmplifyAuthAdapter() })
1512
).current;
1613

17-
return <StorageBrowser views={views} displayText={displayText} />;
18-
};
14+
return <StorageBrowserComponent {...props} />;
15+
}

0 commit comments

Comments
 (0)