From ab418cc6ece9bcb26f8a0b066686984178501678 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli Date: Tue, 28 May 2019 17:30:59 -0400 Subject: [PATCH 1/9] add tests, and fix the initial state issue --- src/__tests__/useAsync.test.tsx | 56 +++++++++++++++++++++++++++++++++ src/useAsync.ts | 4 +-- src/useAsyncFn.ts | 9 ++++-- 3 files changed, 64 insertions(+), 5 deletions(-) create mode 100644 src/__tests__/useAsync.test.tsx diff --git a/src/__tests__/useAsync.test.tsx b/src/__tests__/useAsync.test.tsx new file mode 100644 index 00000000..81b33ec4 --- /dev/null +++ b/src/__tests__/useAsync.test.tsx @@ -0,0 +1,56 @@ +import { cleanup, renderHook } from 'react-hooks-testing-library'; +import useAsync from '../useAsync'; + +afterEach(cleanup); + +function wait(ms: number) { + return new Promise((resolve, reject) => { + setTimeout(resolve, ms); + }); +} + +describe('useAsync', () => { + it('should be defined', () => { + expect(useAsync).toBeDefined(); + }); + + describe('a success', () => { + const hook = renderHook(props => useAsync(props), { + initialProps: () => + new Promise((resolve, reject) => { + setTimeout(() => resolve('yay'), 50); + }), + }); + + it('initially starts loading', () => { + expect(hook.result.current.loading).toEqual(true); + }); + + it('resolves', async () => { + await wait(60); + expect(hook.result.current.loading).toEqual(false); + expect(hook.result.current.value).toEqual('yay'); + expect(hook.result.current.error).toEqual(undefined); + }); + }); + + describe('an error', () => { + const hook = renderHook(props => useAsync(props), { + initialProps: () => + new Promise((resolve, reject) => { + setTimeout(() => reject('yay'), 50); + }), + }); + + it('initially starts loading', () => { + expect(hook.result.current.loading).toEqual(true); + }); + + it('resolves', async () => { + await wait(60); + expect(hook.result.current.loading).toEqual(false); + expect(hook.result.current.error).toEqual('yay'); + expect(hook.result.current.value).toEqual(undefined); + }); + }); +}); diff --git a/src/useAsync.ts b/src/useAsync.ts index 3b3e1b63..2c8bb767 100644 --- a/src/useAsync.ts +++ b/src/useAsync.ts @@ -18,8 +18,8 @@ export type AsyncState = value: T; }; -const useAsync = (fn: () => Promise, deps: DependencyList = []) => { - const [state, callback] = useAsyncFn(fn, deps); +const useAsync = (fn: Fn, deps: DependencyList = []) => { + const [state, callback] = useAsyncFn(fn, deps); useEffect(() => { callback(); diff --git a/src/useAsyncFn.ts b/src/useAsyncFn.ts index b5dd7c5c..beb6a699 100644 --- a/src/useAsyncFn.ts +++ b/src/useAsyncFn.ts @@ -18,9 +18,12 @@ export type AsyncState = value: T; }; -const useAsyncFn = (fn: (...args: any[]) => Promise, deps: DependencyList = []): [AsyncState, () => void] => { - const [state, set] = useState>({ - loading: false, +const useAsyncFn = ( + fn: Fn, + deps: DependencyList = [] +): [AsyncState, () => void] => { + const [state, set] = useState>({ + loading: true, }); const mounted = useRefMounted(); From 3f9cc47975926342260599cd9c488aa2b0f8e423 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli Date: Wed, 29 May 2019 10:08:12 -0400 Subject: [PATCH 2/9] add some tests --- babel.config.js | 15 +++- package.json | 6 +- src/__tests__/useAsync.test.tsx | 140 ++++++++++++++++++++++++-------- src/useAsync.ts | 13 +-- src/useAsyncFn.ts | 14 ++-- yarn.lock | 14 ---- 6 files changed, 141 insertions(+), 61 deletions(-) diff --git a/babel.config.js b/babel.config.js index 4bea0c59..67616420 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,3 +1,14 @@ module.exports = { - presets: ['@babel/preset-env', '@babel/preset-react', "@babel/preset-typescript"], -}; \ No newline at end of file + presets: [ + [ + "@babel/preset-env", + { + targets: { + node: "current" + } + } + ], + "@babel/preset-react", + "@babel/preset-typescript" + ] +}; diff --git a/package.json b/package.json index 59fca7e4..0d6897db 100644 --- a/package.json +++ b/package.json @@ -128,5 +128,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 index 81b33ec4..f70402ed 100644 --- a/src/__tests__/useAsync.test.tsx +++ b/src/__tests__/useAsync.test.tsx @@ -1,5 +1,6 @@ +import { useCallback } from 'react'; import { cleanup, renderHook } from 'react-hooks-testing-library'; -import useAsync from '../useAsync'; +import useAsync, { AsyncState } from '../useAsync'; afterEach(cleanup); @@ -9,48 +10,123 @@ function wait(ms: number) { }); } +interface AsyncProps { + fn: (...args: Args) => Promise; + args?: Args; +} + +// 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', () => { - const hook = renderHook(props => useAsync(props), { - initialProps: () => - new Promise((resolve, reject) => { - setTimeout(() => resolve('yay'), 50); - }), + // describe("a success", () => { + // const hook = renderHook(props => useAsync(props), { + // initialProps: () => + // new Promise((resolve, reject) => { + // let wait = setTimeout(() => { + // clearTimeout(wait); + // resolve("yay"); + // }, 50); + // }) + // }); + + // it("initially starts loading", () => { + // expect(hook.result.current.loading).toEqual(true); + // }); + + // it("resolves", async () => { + // await hook.waitForNextUpdate(); + // expect(hook.result.current.loading).toBeFalsy(); + // expect(hook.result.current.value).toEqual("yay"); + // expect(hook.result.current.error).toEqual(undefined); + // }); + // }); + + // describe("an error", () => { + // const hook = renderHook(({ fn }) => useAsync(fn), { + // initialProps: () => ({ + // fn: new Promise((resolve, reject) => { + // let wait = setTimeout(() => { + // clearTimeout(wait); + // reject("yay"); + // }, 0); + // }) + // }) + // }); + + // it("initially starts loading", () => { + // expect(hook.result.current.loading).toBeTruthy(); + // }); + + // it("resolves", async () => { + // expect.assertions(3); + + // await hook.waitForNextUpdate(); + // expect(hook.result.current.loading).toBeFalsy(); + // expect(hook.result.current.error).toEqual("yay"); + // expect(hook.result.current.value).toEqual(undefined); + // }); + // }); + + describe('re-evaluates when dependecies change', () => { + describe('the fn is a dependency', () => { + const hook = renderHook(({ fn }) => useAsync(fn, []), { + initialProps: { + fn: async () => { + return 'value'; + }, + }, + }); + + it('renders the first value', () => { + expect(hook.result.current.value).toEqual('value'); + }); + + it('renders a different value when deps change', async () => { + expect.assertions(1); + + hook.rerender({ fn: async () => 'new value' }); + await hook.waitForNextUpdate(); + + expect(hook.result.current.value).toEqual('new value'); + }); }); - it('initially starts loading', () => { - expect(hook.result.current.loading).toEqual(true); - }); + describe('the additional dependencies list changes', () => { + let callCount = 0; + const staticFunction = async counter => { + callCount++; + return `counter is ${counter} and callCount is ${callCount}`; + }; + const hook = renderHook( + ({ fn, counter }) => { + const callback = useCallback(() => fn(counter), [counter]); + return useAsync(callback, [callback]); + }, + { + initialProps: { + counter: 0, + fn: staticFunction, + }, + } + ); - it('resolves', async () => { - await wait(60); - expect(hook.result.current.loading).toEqual(false); - expect(hook.result.current.value).toEqual('yay'); - expect(hook.result.current.error).toEqual(undefined); - }); - }); + it('initial renders the first passed pargs', () => { + expect(hook.result.current.value).toEqual('counter is 0 and callCount is 1'); + }); - describe('an error', () => { - const hook = renderHook(props => useAsync(props), { - initialProps: () => - new Promise((resolve, reject) => { - setTimeout(() => reject('yay'), 50); - }), - }); + it('renders a different value when deps change', async () => { + expect.assertions(1); - it('initially starts loading', () => { - expect(hook.result.current.loading).toEqual(true); - }); + hook.rerender({ fn: staticFunction, counter: 1 }); + await hook.waitForNextUpdate(); - it('resolves', async () => { - await wait(60); - expect(hook.result.current.loading).toEqual(false); - expect(hook.result.current.error).toEqual('yay'); - expect(hook.result.current.value).toEqual(undefined); + expect(hook.result.current.value).toEqual('counter is 1 and callCount is 2'); + }); }); }); }); diff --git a/src/useAsync.ts b/src/useAsync.ts index 2c8bb767..3a042060 100644 --- a/src/useAsync.ts +++ b/src/useAsync.ts @@ -18,14 +18,17 @@ export type AsyncState = value: T; }; -const useAsync = (fn: Fn, deps: DependencyList = []) => { - const [state, callback] = useAsyncFn(fn, deps); +export default function useAsync< + Result, + T = any, + Args extends T[] = any[], + Fn extends Function = (...args: Args) => Promise +>(fn: Fn, deps: DependencyList = []) { + const [state, callback] = useAsyncFn(fn, deps); useEffect(() => { callback(); }, [callback]); return state; -}; - -export default useAsync; +} diff --git a/src/useAsyncFn.ts b/src/useAsyncFn.ts index beb6a699..e3e2257e 100644 --- a/src/useAsyncFn.ts +++ b/src/useAsyncFn.ts @@ -18,10 +18,12 @@ export type AsyncState = value: T; }; -const useAsyncFn = ( - fn: Fn, - deps: DependencyList = [] -): [AsyncState, () => void] => { +export default function useAsyncFn< + Result, + T = any, + Args extends T[] = any[], + Fn extends Function = (...args: Args) => Promise +>(fn: Fn, deps: DependencyList = []): [AsyncState, () => void] { const [state, set] = useState>({ loading: true, }); @@ -46,6 +48,4 @@ const useAsyncFn = ( }, deps); return [state, callback]; -}; - -export default useAsyncFn; +} diff --git a/yarn.lock b/yarn.lock index 39b9993d..62290207 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10186,7 +10186,6 @@ npm@^6.8.0: cmd-shim "~2.0.2" columnify "~1.5.4" config-chain "^1.1.12" - debuglog "*" detect-indent "~5.0.0" detect-newline "^2.1.0" dezalgo "~1.0.3" @@ -10201,7 +10200,6 @@ npm@^6.8.0: has-unicode "~2.0.1" hosted-git-info "^2.7.1" iferr "^1.0.2" - imurmurhash "*" inflight "~1.0.6" inherits "~2.0.3" ini "^1.3.5" @@ -10211,22 +10209,12 @@ npm@^6.8.0: lazy-property "~1.0.0" libcipm "^3.0.3" libnpm "^2.0.1" - libnpmaccess "*" libnpmhook "^5.0.2" - libnpmorg "*" - libnpmsearch "*" - libnpmteam "*" libnpx "^10.2.0" lock-verify "^2.1.0" lockfile "^1.0.4" - lodash._baseindexof "*" lodash._baseuniq "~4.6.0" - lodash._bindcallback "*" - lodash._cacheindexof "*" - lodash._createcache "*" - lodash._getnative "*" lodash.clonedeep "~4.5.0" - lodash.restparam "*" lodash.union "~4.6.0" lodash.uniq "~4.5.0" lodash.without "~4.4.0" @@ -10245,7 +10233,6 @@ npm@^6.8.0: npm-package-arg "^6.1.0" npm-packlist "^1.4.1" npm-pick-manifest "^2.2.3" - npm-profile "*" npm-registry-fetch "^3.9.0" npm-user-validate "~1.0.0" npmlog "~4.1.2" @@ -10264,7 +10251,6 @@ npm@^6.8.0: read-package-json "^2.0.13" read-package-tree "^5.2.2" readable-stream "^3.1.1" - readdir-scoped-modules "*" request "^2.88.0" retry "^0.12.0" rimraf "^2.6.3" From 85db1dd9403be1121c7d134e85851645d7f91eac Mon Sep 17 00:00:00 2001 From: NullVoxPopuli Date: Wed, 29 May 2019 10:22:04 -0400 Subject: [PATCH 3/9] tests done for useAsync --- src/__tests__/useAsync.test.tsx | 142 ++++++++++++++++++-------------- 1 file changed, 78 insertions(+), 64 deletions(-) diff --git a/src/__tests__/useAsync.test.tsx b/src/__tests__/useAsync.test.tsx index f70402ed..687294a8 100644 --- a/src/__tests__/useAsync.test.tsx +++ b/src/__tests__/useAsync.test.tsx @@ -1,6 +1,6 @@ -import { useCallback } from 'react'; -import { cleanup, renderHook } from 'react-hooks-testing-library'; -import useAsync, { AsyncState } from '../useAsync'; +import { useCallback } from "react"; +import { cleanup, renderHook } from "react-hooks-testing-library"; +import useAsync, { AsyncState } from "../useAsync"; afterEach(cleanup); @@ -18,85 +18,95 @@ interface AsyncProps { // 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', () => { +describe("useAsync", () => { + it("should be defined", () => { expect(useAsync).toBeDefined(); }); - // describe("a success", () => { - // const hook = renderHook(props => useAsync(props), { - // initialProps: () => - // new Promise((resolve, reject) => { - // let wait = setTimeout(() => { - // clearTimeout(wait); - // resolve("yay"); - // }, 50); - // }) - // }); + describe("a success", () => { + const hook = renderHook(({ fn }) => useAsync(fn), { + initialProps: { + fn: async () => { + return new Promise((resolve, reject) => { + let wait = setTimeout(() => { + clearTimeout(wait); + resolve("yay"); + }, 0); + }); + } + } + }); - // it("initially starts loading", () => { - // expect(hook.result.current.loading).toEqual(true); - // }); + it("initially starts loading", () => { + expect(hook.result.current.loading).toEqual(true); + }); - // it("resolves", async () => { - // await hook.waitForNextUpdate(); - // expect(hook.result.current.loading).toBeFalsy(); - // expect(hook.result.current.value).toEqual("yay"); - // expect(hook.result.current.error).toEqual(undefined); - // }); - // }); + it("resolves", async () => { + expect.assertions(3); - // describe("an error", () => { - // const hook = renderHook(({ fn }) => useAsync(fn), { - // initialProps: () => ({ - // fn: new Promise((resolve, reject) => { - // let wait = setTimeout(() => { - // clearTimeout(wait); - // reject("yay"); - // }, 0); - // }) - // }) - // }); + hook.rerender(); + await hook.waitForNextUpdate(); + expect(hook.result.current.loading).toBeFalsy(); + expect(hook.result.current.value).toEqual("yay"); + expect(hook.result.current.error).toEqual(undefined); + }); + }); - // it("initially starts loading", () => { - // expect(hook.result.current.loading).toBeTruthy(); - // }); + describe("an error", () => { + const hook = renderHook(({ fn }) => useAsync(fn), { + initialProps: { + fn: async () => { + return new Promise((resolve, reject) => { + let wait = setTimeout(() => { + clearTimeout(wait); + reject("yay"); + }, 0); + }); + } + } + }); - // it("resolves", async () => { - // expect.assertions(3); + it("initially starts loading", () => { + expect(hook.result.current.loading).toBeTruthy(); + }); - // await hook.waitForNextUpdate(); - // expect(hook.result.current.loading).toBeFalsy(); - // expect(hook.result.current.error).toEqual("yay"); - // expect(hook.result.current.value).toEqual(undefined); - // }); - // }); + it("resolves", async () => { + expect.assertions(3); - describe('re-evaluates when dependecies change', () => { - describe('the fn is a dependency', () => { + hook.rerender(); + await hook.waitForNextUpdate(); + + expect(hook.result.current.loading).toBeFalsy(); + expect(hook.result.current.error).toEqual("yay"); + expect(hook.result.current.value).toEqual(undefined); + }); + }); + + describe("re-evaluates when dependecies change", () => { + describe("the fn is a dependency", () => { const hook = renderHook(({ fn }) => useAsync(fn, []), { initialProps: { fn: async () => { - return 'value'; - }, - }, + return "value"; + } + } }); - it('renders the first value', () => { - expect(hook.result.current.value).toEqual('value'); + it("renders the first value", () => { + expect(hook.result.current.value).toEqual("value"); }); - it('renders a different value when deps change', async () => { + it("renders a different value when deps change", async () => { expect.assertions(1); - hook.rerender({ fn: async () => 'new value' }); + hook.rerender({ fn: async () => "new value" }); await hook.waitForNextUpdate(); - expect(hook.result.current.value).toEqual('new value'); + expect(hook.result.current.value).toEqual("new value"); }); }); - describe('the additional dependencies list changes', () => { + describe("the additional dependencies list changes", () => { let callCount = 0; const staticFunction = async counter => { callCount++; @@ -110,22 +120,26 @@ describe('useAsync', () => { { initialProps: { counter: 0, - fn: staticFunction, - }, + fn: staticFunction + } } ); - it('initial renders the first passed pargs', () => { - expect(hook.result.current.value).toEqual('counter is 0 and callCount is 1'); + 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 () => { + 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'); + expect(hook.result.current.value).toEqual( + "counter is 1 and callCount is 2" + ); }); }); }); From cabb9d667652461c7a2bcbf3ad29dda800f1be05 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli Date: Wed, 29 May 2019 13:04:11 -0400 Subject: [PATCH 4/9] tests pass, now to fix types --- src/__tests__/useAsync.test.tsx | 13 +----- src/__tests__/useAsyncFn.test.tsx | 68 +++++++++++++++++++++++++++++++ src/useAsync.ts | 2 +- src/useAsyncFn.ts | 18 ++++---- 4 files changed, 80 insertions(+), 21 deletions(-) create mode 100644 src/__tests__/useAsyncFn.test.tsx diff --git a/src/__tests__/useAsync.test.tsx b/src/__tests__/useAsync.test.tsx index 687294a8..9e98fa72 100644 --- a/src/__tests__/useAsync.test.tsx +++ b/src/__tests__/useAsync.test.tsx @@ -1,20 +1,9 @@ import { useCallback } from "react"; import { cleanup, renderHook } from "react-hooks-testing-library"; -import useAsync, { AsyncState } from "../useAsync"; +import useAsync from "../useAsync"; afterEach(cleanup); -function wait(ms: number) { - return new Promise((resolve, reject) => { - setTimeout(resolve, ms); - }); -} - -interface AsyncProps { - fn: (...args: Args) => Promise; - args?: Args; -} - // NOTE: these tests cause console errors. // maybe we should test in a real environment instead // of a fake one? diff --git a/src/__tests__/useAsyncFn.test.tsx b/src/__tests__/useAsyncFn.test.tsx new file mode 100644 index 00000000..8236c148 --- /dev/null +++ b/src/__tests__/useAsyncFn.test.tsx @@ -0,0 +1,68 @@ +// 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("args can be passed to the function", () => { + let hook; + let callCount = 0; + const adder = async (a: number, b: number): Promise => { + callCount += 1; + 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, _callback] = 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 3a042060..380dab55 100644 --- a/src/useAsync.ts +++ b/src/useAsync.ts @@ -24,7 +24,7 @@ export default function useAsync< Args extends T[] = any[], Fn extends Function = (...args: Args) => Promise >(fn: Fn, deps: DependencyList = []) { - const [state, callback] = useAsyncFn(fn, deps); + const [state, callback] = useAsyncFn(fn, deps, { loading: false }); useEffect(() => { callback(); diff --git a/src/useAsyncFn.ts b/src/useAsyncFn.ts index e3e2257e..bac18017 100644 --- a/src/useAsyncFn.ts +++ b/src/useAsyncFn.ts @@ -1,5 +1,5 @@ -import { DependencyList, useCallback, useState } from 'react'; -import useRefMounted from './useRefMounted'; +import { DependencyList, useCallback, useState } from "react"; +import useRefMounted from "./useRefMounted"; export type AsyncState = | { @@ -23,17 +23,19 @@ export default function useAsyncFn< T = any, Args extends T[] = any[], Fn extends Function = (...args: Args) => Promise ->(fn: Fn, deps: DependencyList = []): [AsyncState, () => void] { - const [state, set] = useState>({ - loading: true, - }); +>( + fn: Fn, + 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 }); From 30209518d5e69a9b4ad4d8712700c491ef7c3c06 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli Date: Wed, 29 May 2019 14:11:42 -0400 Subject: [PATCH 5/9] finished --- src/__tests__/useAsync.test.tsx | 80 +++++++++++++++---------------- src/__tests__/useAsyncFn.test.tsx | 45 ++++++++--------- src/useAsync.ts | 11 +++-- src/useAsyncFn.ts | 13 +++-- tslint.json | 34 +++++++------ 5 files changed, 87 insertions(+), 96 deletions(-) diff --git a/src/__tests__/useAsync.test.tsx b/src/__tests__/useAsync.test.tsx index 9e98fa72..fda35628 100644 --- a/src/__tests__/useAsync.test.tsx +++ b/src/__tests__/useAsync.test.tsx @@ -1,101 +1,101 @@ -import { useCallback } from "react"; -import { cleanup, renderHook } from "react-hooks-testing-library"; -import useAsync from "../useAsync"; +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", () => { +describe('useAsync', () => { + it('should be defined', () => { expect(useAsync).toBeDefined(); }); - describe("a success", () => { + describe('a success', () => { const hook = renderHook(({ fn }) => useAsync(fn), { initialProps: { fn: async () => { return new Promise((resolve, reject) => { - let wait = setTimeout(() => { + const wait = setTimeout(() => { clearTimeout(wait); - resolve("yay"); + resolve('yay'); }, 0); }); - } - } + }, + }, }); - it("initially starts loading", () => { + it('initially starts loading', () => { expect(hook.result.current.loading).toEqual(true); }); - it("resolves", async () => { + it('resolves', async () => { expect.assertions(3); hook.rerender(); await hook.waitForNextUpdate(); expect(hook.result.current.loading).toBeFalsy(); - expect(hook.result.current.value).toEqual("yay"); + expect(hook.result.current.value).toEqual('yay'); expect(hook.result.current.error).toEqual(undefined); }); }); - describe("an error", () => { + describe('an error', () => { const hook = renderHook(({ fn }) => useAsync(fn), { initialProps: { fn: async () => { return new Promise((resolve, reject) => { - let wait = setTimeout(() => { + const wait = setTimeout(() => { clearTimeout(wait); - reject("yay"); + reject('yay'); }, 0); }); - } - } + }, + }, }); - it("initially starts loading", () => { + it('initially starts loading', () => { expect(hook.result.current.loading).toBeTruthy(); }); - it("resolves", async () => { + it('resolves', async () => { expect.assertions(3); hook.rerender(); await hook.waitForNextUpdate(); expect(hook.result.current.loading).toBeFalsy(); - expect(hook.result.current.error).toEqual("yay"); + expect(hook.result.current.error).toEqual('yay'); expect(hook.result.current.value).toEqual(undefined); }); }); - describe("re-evaluates when dependecies change", () => { - describe("the fn is a dependency", () => { + describe('re-evaluates when dependecies change', () => { + describe('the fn is a dependency', () => { const hook = renderHook(({ fn }) => useAsync(fn, []), { initialProps: { fn: async () => { - return "value"; - } - } + return 'value'; + }, + }, }); - it("renders the first value", () => { - expect(hook.result.current.value).toEqual("value"); + it('renders the first value', () => { + expect(hook.result.current.value).toEqual('value'); }); - it("renders a different value when deps change", async () => { + it('renders a different value when deps change', async () => { expect.assertions(1); - hook.rerender({ fn: async () => "new value" }); + hook.rerender({ fn: async () => 'new value' }); await hook.waitForNextUpdate(); - expect(hook.result.current.value).toEqual("new value"); + expect(hook.result.current.value).toEqual('new value'); }); }); - describe("the additional dependencies list changes", () => { + describe('the additional dependencies list changes', () => { let callCount = 0; const staticFunction = async counter => { callCount++; @@ -109,26 +109,22 @@ describe("useAsync", () => { { initialProps: { counter: 0, - fn: staticFunction - } + fn: staticFunction, + }, } ); - it("initial renders the first passed pargs", () => { - expect(hook.result.current.value).toEqual( - "counter is 0 and callCount is 1" - ); + 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 () => { + 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" - ); + 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 index 8236c148..6a3d5fc0 100644 --- a/src/__tests__/useAsyncFn.test.tsx +++ b/src/__tests__/useAsyncFn.test.tsx @@ -5,19 +5,19 @@ // does not automatically invoke the function // and it can take arguments. -import { cleanup, renderHook } from "react-hooks-testing-library"; -import useAsyncFn, { AsyncState } from "../useAsyncFn"; +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", () => { +describe('useAsyncFn', () => { + it('should be defined', () => { expect(useAsyncFn).toBeDefined(); }); - describe("args can be passed to the function", () => { + describe('args can be passed to the function', () => { let hook; let callCount = 0; const adder = async (a: number, b: number): Promise => { @@ -27,36 +27,33 @@ describe("useAsyncFn", () => { beforeEach(() => { // NOTE: renderHook isn't good at inferring array types - hook = renderHook<{ fn: AdderFn }, [AsyncState, AdderFn]>( - ({ fn }) => useAsyncFn(fn), - { - initialProps: { - fn: adder - } - } - ); + hook = renderHook<{ fn: AdderFn }, [AsyncState, AdderFn]>(({ fn }) => useAsyncFn(fn), { + initialProps: { + fn: adder, + }, + }); }); - // it("initially does not have a value", () => { - // const [state, _callback] = hook.result.current; + 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); - // }); + 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 () => { + describe('when invoked', () => { + it('resolves a value derived from args', async () => { expect.assertions(4); - const [_s, callback] = hook.result.current; + const [s, callback] = hook.result.current; callback(2, 7); hook.rerender({ fn: adder }); await hook.waitForNextUpdate(); - const [state, _c] = hook.result.current; + const [state, c] = hook.result.current; expect(callCount).toEqual(1); expect(state.loading).toEqual(false); diff --git a/src/useAsync.ts b/src/useAsync.ts index 380dab55..a5afad8a 100644 --- a/src/useAsync.ts +++ b/src/useAsync.ts @@ -19,12 +19,13 @@ export type AsyncState = }; export default function useAsync< - Result, - T = any, - Args extends T[] = any[], - Fn extends Function = (...args: Args) => Promise + Result = any, + Args extends any[] = any[], + Fn extends Function = (...args: Args | []) => Promise >(fn: Fn, deps: DependencyList = []) { - const [state, callback] = useAsyncFn(fn, deps, { loading: false }); + const [state, callback] = useAsyncFn(fn, deps, { + loading: false, + }); useEffect(() => { callback(); diff --git a/src/useAsyncFn.ts b/src/useAsyncFn.ts index bac18017..d7e16ba2 100644 --- a/src/useAsyncFn.ts +++ b/src/useAsyncFn.ts @@ -1,5 +1,5 @@ -import { DependencyList, useCallback, useState } from "react"; -import useRefMounted from "./useRefMounted"; +import { DependencyList, useCallback, useState } from 'react'; +import useRefMounted from './useRefMounted'; export type AsyncState = | { @@ -19,10 +19,9 @@ export type AsyncState = }; export default function useAsyncFn< - Result, - T = any, - Args extends T[] = any[], - Fn extends Function = (...args: Args) => Promise + Result = any, + Args extends any[] = any[], + Fn extends Function = (...args: Args | []) => Promise >( fn: Fn, deps: DependencyList = [], @@ -32,7 +31,7 @@ export default function useAsyncFn< const mounted = useRefMounted(); - const callback = useCallback((...args: Args) => { + const callback = useCallback((...args: Args | []) => { set({ loading: true }); return fn(...args).then( diff --git a/tslint.json b/tslint.json index 5e8f3ec0..357cc2c6 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"] +} From 1b59040e6e928b2f0c3e935ac0a4c2f887cff200 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli Date: Wed, 29 May 2019 14:48:11 -0400 Subject: [PATCH 6/9] unify test style --- src/__tests__/useAsync.test.tsx | 132 ++++++++++++++++++++---------- src/__tests__/useAsyncFn.test.tsx | 2 +- 2 files changed, 89 insertions(+), 45 deletions(-) diff --git a/src/__tests__/useAsync.test.tsx b/src/__tests__/useAsync.test.tsx index fda35628..3764e42a 100644 --- a/src/__tests__/useAsync.test.tsx +++ b/src/__tests__/useAsync.test.tsx @@ -13,17 +13,27 @@ describe('useAsync', () => { }); describe('a success', () => { - const hook = renderHook(({ fn }) => useAsync(fn), { - initialProps: { - fn: async () => { - return new Promise((resolve, reject) => { - const wait = setTimeout(() => { - clearTimeout(wait); - resolve('yay'); - }, 0); - }); + 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', () => { @@ -31,10 +41,12 @@ describe('useAsync', () => { }); it('resolves', async () => { - expect.assertions(3); + expect.assertions(4); - hook.rerender(); + 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); @@ -42,17 +54,27 @@ describe('useAsync', () => { }); describe('an error', () => { - const hook = renderHook(({ fn }) => useAsync(fn), { - initialProps: { - fn: async () => { - return new Promise((resolve, reject) => { - const wait = setTimeout(() => { - clearTimeout(wait); - reject('yay'); - }, 0); - }); + 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', () => { @@ -60,25 +82,38 @@ describe('useAsync', () => { }); it('resolves', async () => { - expect.assertions(3); + expect.assertions(4); - hook.rerender(); + 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 dependecies change', () => { + describe('re-evaluates when dependencies change', () => { describe('the fn is a dependency', () => { - const hook = renderHook(({ fn }) => useAsync(fn, []), { - initialProps: { - fn: async () => { - return 'value'; - }, - }, + 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', () => { @@ -86,33 +121,42 @@ describe('useAsync', () => { }); it('renders a different value when deps change', async () => { - expect.assertions(1); + expect.assertions(3); - hook.rerender({ fn: async () => 'new value' }); + 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}`; }; - const hook = renderHook( - ({ fn, counter }) => { - const callback = useCallback(() => fn(counter), [counter]); - return useAsync(callback, [callback]); - }, - { - initialProps: { - counter: 0, - fn: staticFunction, + + 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'); diff --git a/src/__tests__/useAsyncFn.test.tsx b/src/__tests__/useAsyncFn.test.tsx index 6a3d5fc0..9cc30c60 100644 --- a/src/__tests__/useAsyncFn.test.tsx +++ b/src/__tests__/useAsyncFn.test.tsx @@ -21,7 +21,7 @@ describe('useAsyncFn', () => { let hook; let callCount = 0; const adder = async (a: number, b: number): Promise => { - callCount += 1; + callCount++; return a + b; }; From 4f8b1b9897d84440d79105c093eb1a8960cdf7c7 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli Date: Wed, 29 May 2019 14:59:00 -0400 Subject: [PATCH 7/9] wrong default. whoops --- src/useAsync.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/useAsync.ts b/src/useAsync.ts index a5afad8a..815b213c 100644 --- a/src/useAsync.ts +++ b/src/useAsync.ts @@ -24,7 +24,7 @@ export default function useAsync< Fn extends Function = (...args: Args | []) => Promise >(fn: Fn, deps: DependencyList = []) { const [state, callback] = useAsyncFn(fn, deps, { - loading: false, + loading: true, }); useEffect(() => { From facedc10aa3b4eb53331133608c2ace1c105b375 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli Date: Thu, 30 May 2019 13:41:12 -0400 Subject: [PATCH 8/9] simplify types --- src/useAsync.ts | 11 +++++------ src/useAsyncFn.ts | 12 ++++++------ 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/src/useAsync.ts b/src/useAsync.ts index 815b213c..5a86804f 100644 --- a/src/useAsync.ts +++ b/src/useAsync.ts @@ -18,12 +18,11 @@ export type AsyncState = value: T; }; -export default function useAsync< - Result = any, - Args extends any[] = any[], - Fn extends Function = (...args: Args | []) => Promise ->(fn: Fn, 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, }); diff --git a/src/useAsyncFn.ts b/src/useAsyncFn.ts index d7e16ba2..1165e1d4 100644 --- a/src/useAsyncFn.ts +++ b/src/useAsyncFn.ts @@ -18,12 +18,8 @@ export type AsyncState = value: T; }; -export default function useAsyncFn< - Result = any, - Args extends any[] = any[], - Fn extends Function = (...args: Args | []) => Promise ->( - fn: Fn, +export default function useAsyncFn( + fn: (...args: Args | []) => Promise, deps: DependencyList = [], initialState: AsyncState = { loading: false } ): [AsyncState, (...args: Args | []) => Promise] { @@ -39,11 +35,15 @@ export default function useAsyncFn< if (mounted.current) { set({ value, loading: false }); } + + return value; }, error => { if (mounted.current) { set({ error, loading: false }); } + + return error; } ); }, deps); From a880724b04207f4684a45bb88a7f6154120cecbf Mon Sep 17 00:00:00 2001 From: NullVoxPopuli Date: Thu, 30 May 2019 13:46:54 -0400 Subject: [PATCH 9/9] add test proving that we can capture and await the callback result --- src/__tests__/useAsyncFn.test.tsx | 33 +++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/__tests__/useAsyncFn.test.tsx b/src/__tests__/useAsyncFn.test.tsx index 9cc30c60..8e3826b5 100644 --- a/src/__tests__/useAsyncFn.test.tsx +++ b/src/__tests__/useAsyncFn.test.tsx @@ -17,6 +17,39 @@ describe('useAsyncFn', () => { 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;