diff --git a/README.md b/README.md index 9a393af..1337044 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,28 @@ const { isLoading, error, result, callback } = useWorker( `isLoading` is set to true as soon as the callback is loaded and only returns to `false` when it ends or when an error happens. If an exception is thrown or a promise fails, `error` will be updated. +### Worker Load + +This is a worker that starts loading immediately and stores the result in a state. Useful for +loading data when you render a component: + +```ts +const { isLoading, error, data } = useWorkerLoad( + async () => { + return await getUserName(); + }, + 'no-name', // data's initial value +); +``` + +If the worker fails, the error is returned in the `error` state with a retry function: + +```ts +const { error } = useWorkerLoad(...); + +error?.value // the actual Error object +error?.retry() // calls the worker again +``` ### Did Mount diff --git a/package.json b/package.json index 2b38791..01b645a 100644 --- a/package.json +++ b/package.json @@ -42,10 +42,10 @@ "conventional-changelog-cli": "^2.0.25", "husky": "^3.0.5", "jest": "^24.9.0", - "prettier": "^1.18.2", + "prettier": "^1.19.1", "react-test-renderer": "^16.9.0", "ts-jest": "^24.1.0", "typedoc": "^0.15.0", - "typescript": "^3.6.3" + "typescript": "^3.7" } } diff --git a/src/__tests__/useWorkerLoad.ts b/src/__tests__/useWorkerLoad.ts new file mode 100644 index 0000000..f6364dd --- /dev/null +++ b/src/__tests__/useWorkerLoad.ts @@ -0,0 +1,334 @@ +import { renderHook, act } from '@testing-library/react-hooks'; + +import { useWorkerLoad } from '..'; + +let callbackFn: jest.Mock; +let initialProps: any; +const thrownError = new Error('error'); + +beforeEach(() => { + callbackFn = jest.fn(); + initialProps = { worker: callbackFn }; +}); + +const useHookHelper = ({ worker, initialValue }: any) => + useWorkerLoad(worker, initialValue); + +describe('initial values', () => { + it('starts without error', async () => { + const { + result: { + current: { error }, + }, + waitForNextUpdate, + } = renderHook(useHookHelper, { initialProps }); + + await waitForNextUpdate(); + + expect(error).toBeUndefined(); + }); + + it('starts without data', async () => { + const { + result: { + current: { data }, + }, + waitForNextUpdate, + } = renderHook(useHookHelper, { initialProps }); + + await waitForNextUpdate(); + + expect(data).toBeUndefined(); + }); + + it('starts with loading', async () => { + const { + result: { + current: { isLoading }, + }, + waitForNextUpdate, + } = renderHook(useHookHelper, { initialProps }); + + await waitForNextUpdate(); + + expect(isLoading).toBe(true); + }); + + it('starts with initial value', async () => { + const initialValue = 'foobar'; + const { + result: { + current: { data }, + }, + waitForNextUpdate, + } = renderHook(useHookHelper, { + initialProps: { + ...initialProps, + initialValue, + }, + }); + + await waitForNextUpdate(); + + expect(data).toBe(initialValue); + }); +}); + +it('stops loading when effect resolves', async () => { + const { result, waitForNextUpdate } = renderHook(useHookHelper, { + initialProps, + }); + + await waitForNextUpdate(); + + expect(result.current.isLoading).toEqual(false); +}); + +it('stops loading on failure and returns the error', async () => { + callbackFn.mockRejectedValue(thrownError); + const { result, waitForNextUpdate } = renderHook(useHookHelper, { + initialProps, + }); + + await waitForNextUpdate(); + + expect(result.current.error?.value).toBe(thrownError); + expect(result.current.error?.retry).not.toBeUndefined(); + expect(result.current.isLoading).toEqual(false); +}); + +it('updates data with returned value', async () => { + const val = 'foobar'; + callbackFn.mockResolvedValue(val); + const { result, waitForNextUpdate } = renderHook(useHookHelper, { + initialProps, + }); + + await waitForNextUpdate(); + + expect(result.current.data).toEqual(val); +}); + +it('can set error', async () => { + const { result, waitForNextUpdate } = renderHook(useHookHelper, { + initialProps, + }); + + await waitForNextUpdate(); + expect(result.current.error).toBeUndefined(); + act(() => { + result.current.setError(thrownError); + }); + + expect(result.current.error?.value).toEqual(thrownError); + expect(result.current.error?.retry).not.toBeUndefined(); +}); + +it('can set isLoading', async () => { + const { result, waitForNextUpdate } = renderHook(useHookHelper, { + initialProps, + }); + + await waitForNextUpdate(); + expect(result.current.isLoading).toEqual(false); + act(() => { + result.current.setIsLoading(true); + }); + + expect(result.current.isLoading).toEqual(true); +}); + +describe('retry', () => { + it('sets loading true', async () => { + callbackFn.mockRejectedValue(thrownError); + const { result, waitForNextUpdate } = renderHook(useHookHelper, { + initialProps, + }); + + await waitForNextUpdate(); + expect(result.current.isLoading).toBe(false); + + act(() => { + result.current.error!.retry(); + }); + + expect(result.current.isLoading).toBe(true); + + await waitForNextUpdate(); // stops errors from being logged + }); + + it('resets error after promise resolves', async () => { + callbackFn.mockRejectedValueOnce(thrownError); + + const { result, waitForNextUpdate } = renderHook(useHookHelper, { + initialProps, + }); + + await waitForNextUpdate(); + expect(result.current.error?.value).toBe(thrownError); + + await act(result.current.error!.retry); + + expect(result.current.error).toBeUndefined(); + }); + + it('updates data after retry on error', async () => { + const val = 'foobar'; + callbackFn.mockResolvedValue(val); + callbackFn.mockRejectedValueOnce(thrownError); + + const { result, waitForNextUpdate } = renderHook(useHookHelper, { + initialProps, + }); + + await waitForNextUpdate(); + expect(result.current.error?.value).toBe(thrownError); + expect(result.current.data).toBeUndefined(); + + await act(result.current.error!.retry); + + expect(result.current.data).toBe(val); + expect(result.current.error).toBeUndefined(); + }); +}); + +/// These tests won't fail their execution, but the type-checking instead. +describe('test types', () => { + type Assert = T extends Expected + ? Expected extends T + ? true + : never + : never; + + describe('`initialValue` is unset', () => { + it('infers `data` may be undefined when `worker` returns mandatory type', async () => { + const worker = async () => 'string'; + const { + result: { + current: { data }, + }, + waitForNextUpdate, + } = renderHook(() => useWorkerLoad(worker)); + await waitForNextUpdate(); + + const assert1: Assert, Promise> = true; + expect(assert1).toBe(true); + + const assert2: Assert = true; + expect(assert2).toBe(true); + }); + + it('infers `data` is undefined when `worker` returns undefined type', async () => { + const worker = async () => undefined; + const { + result: { + current: { data }, + }, + waitForNextUpdate, + } = renderHook(() => useWorkerLoad(worker)); + await waitForNextUpdate(); + + const assert1: Assert< + ReturnType, + Promise + > = true; + expect(assert1).toBe(true); + + const assert2: Assert = true; + expect(assert2).toBe(true); + }); + + it('forces `data` to be undefined even when type is explicitly set', async () => { + const worker = async () => undefined; + const { + result: { + current: { data }, + }, + waitForNextUpdate, + } = renderHook(() => useWorkerLoad(worker)); + await waitForNextUpdate(); + + const assert1: Assert< + ReturnType, + Promise + > = true; + expect(assert1).toBe(true); + + const assert2: Assert = true; + expect(assert2).toBe(true); + }); + }); + + describe('has `initialValue`', () => { + it('infers `data` from `initialValue', async () => { + const worker = async () => 0; + const { + result: { + current: { data }, + }, + waitForNextUpdate, + } = renderHook(() => useWorkerLoad(worker, -1)); + await waitForNextUpdate(); + + const assert1: Assert, Promise> = true; + expect(assert1).toBe(true); + + const assert2: Assert = true; + expect(assert2).toBe(true); + }); + + it("infers `data` may be undefined from `worker`'s return type", async () => { + const worker = async () => undefined; + const { + result: { + current: { data }, + }, + waitForNextUpdate, + } = renderHook(() => useWorkerLoad(worker, -1 as number)); + await waitForNextUpdate(); + + const assert1: Assert< + ReturnType, + Promise + > = true; + expect(assert1).toBe(true); + + const assert2: Assert = true; + expect(assert2).toBe(true); + }); + + it('infers `data` might be undefined from `initialValue`s type', async () => { + const worker = async () => 1; + const { + result: { + current: { data }, + }, + waitForNextUpdate, + } = renderHook(() => useWorkerLoad(worker, undefined)); + await waitForNextUpdate(); + + const assert1: Assert, Promise> = true; + expect(assert1).toBe(true); + + const assert2: Assert = true; + expect(assert2).toBe(true); + }); + + it('allows `data` to be undefined when type is explicitly set', async () => { + const worker = async () => 0; + const { + result: { + current: { data }, + }, + waitForNextUpdate, + } = renderHook(() => useWorkerLoad(worker, -1)); + await waitForNextUpdate(); + + const assert1: Assert, Promise> = true; + expect(assert1).toBe(true); + + const assert2: Assert = true; + expect(assert2).toBe(true); + }); + }); +}); diff --git a/src/index.tsx b/src/index.tsx index 51bb893..79e4303 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -7,6 +7,41 @@ import { MutableRefObject, } from 'react'; +/** + * The return of a [[useWorkerLoad]] call. + * + * See [[RetryWorkerError]] for errors. + * + * @typeparam Data The worker's return type + */ +interface WorkerLoad { + /** Indicates if the worker is running */ + isLoading: boolean; + + /** The worker's returned value stored in a state */ + data: Data; + + /** An optional object that contains any thrown errors, if any, and a retry function */ + error?: RetryWorkerError; + + /** Sets [[isLoading]] state manually */ + setIsLoading: (_: boolean) => void; + + /** Sets [[error]] state manually */ + setError: (_: Error | undefined) => void; +} + +/** + * Encapsulates [[useWorkerLoad]]'s errors and retry function + */ +interface RetryWorkerError { + /** The error thrown by the worker's call */ + value: Error; + + /** Calls the worker again */ + retry: () => Promise; +} + /** * Executes an asynchronous effect * @@ -52,6 +87,7 @@ export const useAsyncLayoutEffect = ( * @param dependencies The callback dependencies. * @typeparam TArgs The worker's arguments' types * @typeparam TRet The worker's return type + * @category Workers */ export const useWorker = ( worker: (...args: TArgs) => Promise, @@ -463,3 +499,61 @@ export const useLazyRef = ( return ref as MutableRefObject; }; + +/** + * Starts loading a worker immediately and handle loading, error and result states. + * + * See [[useWorker]] for more details. + * + * @param worker An asynchronous function that returns data, which is saved into a state + * @typeparam Data The worker's return type + * @category Workers + */ +export function useWorkerLoad( + worker: () => Promise, +): WorkerLoad; + +/** + * Starts loading a worker immediately and handle loading, error and result states. + * + * See [[useWorker]] for more details. + * + * @param worker An asynchronous function that returns data, which is saved into a state + * @param initialValue The data's initial value + * @typeparam Data The worker's return type + * @category Workers + */ +export function useWorkerLoad( + worker: () => Promise, + initialValue: Data, +): WorkerLoad; + +export function useWorkerLoad( + worker: () => Promise, + initialValue?: Data, +): WorkerLoad { + const [data, setData] = useState(initialValue); + const { + isLoading, + error, + setError, + setIsLoading, + callback, + } = useWorker(async () => { + setData(await worker()); + }, []); + + // start loading immediately + useDidMount(callback); + + return { + data, + isLoading, + setIsLoading, + setError, + error: error && { + value: error, + retry: callback, + }, + }; +} diff --git a/yarn.lock b/yarn.lock index bf0ee9e..123b0b5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3712,10 +3712,10 @@ prelude-ls@~1.1.2: resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" integrity sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ= -prettier@^1.18.2: - version "1.18.2" - resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.18.2.tgz#6823e7c5900017b4bd3acf46fe9ac4b4d7bda9ea" - integrity sha512-OeHeMc0JhFE9idD4ZdtNibzY0+TPHSpSSb9h8FqtP+YnoZZ1sl8Vc9b1sasjfymH3SonAF4QcA2+mzHPhMvIiw== +prettier@^1.19.1: + version "1.19.1" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.19.1.tgz#f7d7f5ff8a9cd872a7be4ca142095956a60797cb" + integrity sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew== pretty-format@^24.9.0: version "24.9.0" @@ -4746,10 +4746,10 @@ typescript@3.5.x: resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.5.3.tgz#c830f657f93f1ea846819e929092f5fe5983e977" integrity sha512-ACzBtm/PhXBDId6a6sDJfroT2pOWt/oOnk4/dElG5G33ZL776N3Y6/6bKZJBFpd+b05F3Ct9qDjMeJmRWtE2/g== -typescript@^3.6.3: - version "3.6.3" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.6.3.tgz#fea942fabb20f7e1ca7164ff626f1a9f3f70b4da" - integrity sha512-N7bceJL1CtRQ2RiG0AQME13ksR7DiuQh/QehubYcghzv20tnh+MQnQIuJddTmsbqYj+dztchykemz0zFzlvdQw== +typescript@^3.7: + version "3.7.2" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.7.2.tgz#27e489b95fa5909445e9fef5ee48d81697ad18fb" + integrity sha512-ml7V7JfiN2Xwvcer+XAf2csGO1bPBdRbFCkYBczNZggrBZ9c7G3riSUeJmqEU5uOtXNPMhE3n+R4FA/3YOAWOQ== uglify-js@^3.1.4: version "3.6.0"