diff --git a/babel.config.js b/babel.config.js index 99e6aa2e82..86be860b75 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,11 +1,22 @@ module.exports = { - presets: ['@babel/preset-env', '@babel/preset-react', "@babel/preset-typescript"], - env: { - test: { - plugins: ['dynamic-import-node'] - }, - production: { - plugins: ['@babel/plugin-syntax-dynamic-import'] - } + presets: [ + [ + "@babel/preset-env", + { + targets: { + node: "current" + } + } + ], + "@babel/preset-react", + "@babel/preset-typescript" + ], + env: { + test: { + plugins: ['dynamic-import-node'] + }, + production: { + plugins: ['@babel/plugin-syntax-dynamic-import'] } + } }; diff --git a/package.json b/package.json index 7b4c047ab4..4aab99ac29 100644 --- a/package.json +++ b/package.json @@ -131,5 +131,9 @@ "tslint --fix -t verbose", "git add" ] + }, + "volta": { + "node": "10.16.0", + "yarn": "1.16.0" } -} +} \ No newline at end of file diff --git a/src/__tests__/useAsync.test.tsx b/src/__tests__/useAsync.test.tsx new file mode 100644 index 0000000000..3764e42ace --- /dev/null +++ b/src/__tests__/useAsync.test.tsx @@ -0,0 +1,175 @@ +import { useCallback } from 'react'; +import { cleanup, renderHook } from 'react-hooks-testing-library'; +import useAsync from '../useAsync'; + +afterEach(cleanup); + +// NOTE: these tests cause console errors. +// maybe we should test in a real environment instead +// of a fake one? +describe('useAsync', () => { + it('should be defined', () => { + expect(useAsync).toBeDefined(); + }); + + describe('a success', () => { + let hook; + let callCount = 0; + + const resolver = async () => { + return new Promise((resolve, reject) => { + callCount++; + + const wait = setTimeout(() => { + clearTimeout(wait); + resolve('yay'); + }, 0); + }); + }; + + beforeEach(() => { + callCount = 0; + hook = renderHook(({ fn }) => useAsync(fn, [fn]), { + initialProps: { + fn: resolver, + }, + }); + }); + + it('initially starts loading', () => { + expect(hook.result.current.loading).toEqual(true); + }); + + it('resolves', async () => { + expect.assertions(4); + + hook.rerender({ fn: resolver }); + await hook.waitForNextUpdate(); + + expect(callCount).toEqual(1); + expect(hook.result.current.loading).toBeFalsy(); + expect(hook.result.current.value).toEqual('yay'); + expect(hook.result.current.error).toEqual(undefined); + }); + }); + + describe('an error', () => { + let hook; + let callCount = 0; + + const rejection = async () => { + return new Promise((resolve, reject) => { + callCount++; + + const wait = setTimeout(() => { + clearTimeout(wait); + reject('yay'); + }, 0); + }); + }; + + beforeEach(() => { + callCount = 0; + hook = renderHook(({ fn }) => useAsync(fn, [fn]), { + initialProps: { + fn: rejection, + }, + }); + }); + + it('initially starts loading', () => { + expect(hook.result.current.loading).toBeTruthy(); + }); + + it('resolves', async () => { + expect.assertions(4); + + hook.rerender({ fn: rejection }); + await hook.waitForNextUpdate(); + + expect(callCount).toEqual(1); + expect(hook.result.current.loading).toBeFalsy(); + expect(hook.result.current.error).toEqual('yay'); + expect(hook.result.current.value).toEqual(undefined); + }); + }); + + describe('re-evaluates when dependencies change', () => { + describe('the fn is a dependency', () => { + let hook; + let callCount = 0; + + const initialFn = async () => { + callCount++; + return 'value'; + }; + + const differentFn = async () => { + callCount++; + return 'new value'; + }; + + beforeEach(() => { + callCount = 0; + hook = renderHook(({ fn }) => useAsync(fn, [fn]), { + initialProps: { fn: initialFn }, + }); + }); + + it('renders the first value', () => { + expect(hook.result.current.value).toEqual('value'); + }); + + it('renders a different value when deps change', async () => { + expect.assertions(3); + + expect(callCount).toEqual(1); + + hook.rerender({ fn: differentFn }); // change the fn to initiate new request + await hook.waitForNextUpdate(); + + expect(callCount).toEqual(2); + expect(hook.result.current.value).toEqual('new value'); + }); + }); + + describe('the additional dependencies list changes', () => { + let callCount = 0; + let hook; + + const staticFunction = async counter => { + callCount++; + return `counter is ${counter} and callCount is ${callCount}`; + }; + + beforeEach(() => { + callCount = 0; + hook = renderHook( + ({ fn, counter }) => { + const callback = useCallback(() => fn(counter), [counter]); + return useAsync(callback, [callback]); + }, + { + initialProps: { + counter: 0, + fn: staticFunction, + }, + } + ); + }); + + it('initial renders the first passed pargs', () => { + expect(hook.result.current.value).toEqual('counter is 0 and callCount is 1'); + }); + + it('renders a different value when deps change', async () => { + expect.assertions(1); + + hook.rerender({ fn: staticFunction, counter: 1 }); + await hook.waitForNextUpdate(); + + expect(hook.result.current.value).toEqual('counter is 1 and callCount is 2'); + }); + }); + }); +}); diff --git a/src/__tests__/useAsyncFn.test.tsx b/src/__tests__/useAsyncFn.test.tsx new file mode 100644 index 0000000000..8e3826b588 --- /dev/null +++ b/src/__tests__/useAsyncFn.test.tsx @@ -0,0 +1,98 @@ +// NOTE: most behavior that useAsyncFn provides +// is covered be the useAsync tests. +// +// The main difference is that useAsyncFn +// does not automatically invoke the function +// and it can take arguments. + +import { cleanup, renderHook } from 'react-hooks-testing-library'; +import useAsyncFn, { AsyncState } from '../useAsyncFn'; + +afterEach(cleanup); + +type AdderFn = (a: number, b: number) => Promise; + +describe('useAsyncFn', () => { + it('should be defined', () => { + expect(useAsyncFn).toBeDefined(); + }); + + describe('the callback can be awaited and return the value', () => { + let hook; + let callCount = 0; + const adder = async (a: number, b: number): Promise => { + callCount++; + return a + b; + }; + + beforeEach(() => { + // NOTE: renderHook isn't good at inferring array types + hook = renderHook<{ fn: AdderFn }, [AsyncState, AdderFn]>(({ fn }) => useAsyncFn(fn), { + initialProps: { + fn: adder, + }, + }); + }); + + it('awaits the result', async () => { + expect.assertions(3); + + const [s, callback] = hook.result.current; + + const result = await callback(5, 7); + + expect(result).toEqual(12); + + const [state] = hook.result.current; + + expect(state.value).toEqual(12); + expect(result).toEqual(state.value); + }); + }); + + describe('args can be passed to the function', () => { + let hook; + let callCount = 0; + const adder = async (a: number, b: number): Promise => { + callCount++; + return a + b; + }; + + beforeEach(() => { + // NOTE: renderHook isn't good at inferring array types + hook = renderHook<{ fn: AdderFn }, [AsyncState, AdderFn]>(({ fn }) => useAsyncFn(fn), { + initialProps: { + fn: adder, + }, + }); + }); + + it('initially does not have a value', () => { + const [state] = hook.result.current; + + expect(state.value).toEqual(undefined); + expect(state.loading).toEqual(false); + expect(state.error).toEqual(undefined); + expect(callCount).toEqual(0); + }); + + describe('when invoked', () => { + it('resolves a value derived from args', async () => { + expect.assertions(4); + + const [s, callback] = hook.result.current; + + callback(2, 7); + hook.rerender({ fn: adder }); + await hook.waitForNextUpdate(); + + const [state, c] = hook.result.current; + + expect(callCount).toEqual(1); + expect(state.loading).toEqual(false); + expect(state.error).toEqual(undefined); + expect(state.value).toEqual(9); + }); + }); + }); +}); diff --git a/src/useAsync.ts b/src/useAsync.ts index 3b3e1b635c..5a86804fbc 100644 --- a/src/useAsync.ts +++ b/src/useAsync.ts @@ -18,14 +18,17 @@ export type AsyncState = value: T; }; -const useAsync = (fn: () => Promise, deps: DependencyList = []) => { - const [state, callback] = useAsyncFn(fn, deps); +export default function useAsync( + fn: (...args: Args | []) => Promise, + deps: DependencyList = [] +) { + const [state, callback] = useAsyncFn(fn, deps, { + loading: true, + }); useEffect(() => { callback(); }, [callback]); return state; -}; - -export default useAsync; +} diff --git a/src/useAsyncFn.ts b/src/useAsyncFn.ts index b5dd7c5c38..1165e1d491 100644 --- a/src/useAsyncFn.ts +++ b/src/useAsyncFn.ts @@ -18,31 +18,35 @@ export type AsyncState = value: T; }; -const useAsyncFn = (fn: (...args: any[]) => Promise, deps: DependencyList = []): [AsyncState, () => void] => { - const [state, set] = useState>({ - loading: false, - }); +export default function useAsyncFn( + fn: (...args: Args | []) => Promise, + deps: DependencyList = [], + initialState: AsyncState = { loading: false } +): [AsyncState, (...args: Args | []) => Promise] { + const [state, set] = useState>(initialState); const mounted = useRefMounted(); - const callback = useCallback((...args) => { + const callback = useCallback((...args: Args | []) => { set({ loading: true }); - fn(...args).then( + return fn(...args).then( value => { if (mounted.current) { set({ value, loading: false }); } + + return value; }, error => { if (mounted.current) { set({ error, loading: false }); } + + return error; } ); }, deps); return [state, callback]; -}; - -export default useAsyncFn; +} diff --git a/tslint.json b/tslint.json index 5e8f3ec0b8..357cc2c674 100644 --- a/tslint.json +++ b/tslint.json @@ -1,18 +1,17 @@ { - "defaultSeverity": "error", - "extends": [ - "tslint:recommended", - "tslint-react", - "tslint-eslint-rules", - "tslint-config-prettier" - ], - "linterOptions": { - "exclude": [ - "node_modules/**" - ] - }, - "jsRules": {}, - "rules": { + "defaultSeverity": "error", + "extends": [ + "tslint:recommended", + "tslint-react", + "tslint-eslint-rules", + "tslint-config-prettier" + ], + "linterOptions": { + "exclude": ["node_modules/**"] + }, + "jsRules": {}, + "rules": { + "ban-types": false, "interface-name": [true, "never-prefix"], "no-console": false, "max-classes-per-file": false, @@ -32,7 +31,6 @@ "tabWidth": 2 } ] - }, - "rulesDirectory": ["tslint-plugin-prettier"] - } - \ No newline at end of file + }, + "rulesDirectory": ["tslint-plugin-prettier"] +}