From 01421bc634b941044e95c611f37eb87339486241 Mon Sep 17 00:00:00 2001 From: streamich Date: Sat, 7 Dec 2019 20:04:02 +0100 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20add=20useUnmountProm?= =?UTF-8?q?ise=20hook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/useUnmountPromise.ts | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 src/useUnmountPromise.ts diff --git a/src/useUnmountPromise.ts b/src/useUnmountPromise.ts new file mode 100644 index 0000000000..e63a437c12 --- /dev/null +++ b/src/useUnmountPromise.ts @@ -0,0 +1,33 @@ +import { useMemo, useRef } from 'react'; + +export type Race =

, E = any>(promise: P, onError?: (error: E) => void) => P; + +const useUnmountPromise = (): Race => { + const refUnmounted = useRef(false); + useRef(() => () => { + refUnmounted.current = true; + }); + + const wrapper = useMemo(() => { + const race =

, E>(promise: P, onError?: (error: E) => void) => { + const newPromise: P = new Promise((resolve, reject) => { + promise.then( + result => { + if (!refUnmounted.current) resolve(result); + }, + error => { + if (!refUnmounted.current) reject(error); + else if (onError) onError(error); + else console.error('useUnmountPromise', error); + } + ); + }) as P; + return newPromise; + }; + return race; + }, []); + + return wrapper; +}; + +export default useUnmountPromise; From 797e54c33b865ca633da2384d40723727d084b0e Mon Sep 17 00:00:00 2001 From: streamich Date: Sun, 8 Dec 2019 00:46:40 +0100 Subject: [PATCH 2/3] =?UTF-8?q?test:=20=F0=9F=92=8D=20add=20useUnmountProm?= =?UTF-8?q?ise()=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/useUnmountPromise.ts | 4 +- tests/useUnmountPromise.test.ts | 79 +++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 tests/useUnmountPromise.test.ts diff --git a/src/useUnmountPromise.ts b/src/useUnmountPromise.ts index e63a437c12..3c3d38010b 100644 --- a/src/useUnmountPromise.ts +++ b/src/useUnmountPromise.ts @@ -1,10 +1,10 @@ -import { useMemo, useRef } from 'react'; +import { useMemo, useRef, useEffect } from 'react'; export type Race =

, E = any>(promise: P, onError?: (error: E) => void) => P; const useUnmountPromise = (): Race => { const refUnmounted = useRef(false); - useRef(() => () => { + useEffect(() => () => { refUnmounted.current = true; }); diff --git a/tests/useUnmountPromise.test.ts b/tests/useUnmountPromise.test.ts new file mode 100644 index 0000000000..2f186e3823 --- /dev/null +++ b/tests/useUnmountPromise.test.ts @@ -0,0 +1,79 @@ +import { renderHook } from '@testing-library/react-hooks'; +import useUnmountPromise from '../src/useUnmountPromise'; + +describe('useUnmountPromise', () => { + it('should be defined', () => { + expect(useUnmountPromise).toBeDefined(); + }); + + it('should return a function', () => { + const hook = renderHook(() => useUnmountPromise()); + + expect(typeof hook.result.current).toBe('function'); + }); + + it('when component is mounted function should resolve with wrapped promises', async () => { + const hook = renderHook(() => useUnmountPromise()); + + const mounted = hook.result.current; + const res = await mounted(new Promise(r => setTimeout(() => r(25), 10))); + + expect(res).toBe(25); + }); + + it('when component is unmounted promise never resolves', async () => { + const hook = renderHook(() => useUnmountPromise()); + + const mounted = hook.result.current; + const promise = mounted(new Promise(r => setTimeout(() => r(25), 10))); + + hook.unmount(); + + const res = await Promise.race([promise, new Promise(r => setTimeout(() => r('UNMOUNTED'), 20))]); + expect(res).toBe('UNMOUNTED'); + }); + + it('when component is mounted function should resolve with wrapped promises - 2', async () => { + const hook = renderHook(() => useUnmountPromise()); + + const mounted = hook.result.current; + const promise = mounted(new Promise(r => setTimeout(() => r(25), 10))); + + // hook.unmount(); + + const res = await Promise.race([promise, new Promise(r => setTimeout(() => r('UNMOUNTED'), 20))]); + expect(res).toBe(25); + }); + + describe('when promise throws', () => { + describe('when component is mounted', () => { + it('onError callback is not called', async () => { + const hook = renderHook(() => useUnmountPromise()); + + const mounted = hook.result.current; + const onError = jest.fn(); + try { + await mounted(new Promise((r, reject) => setTimeout(() => reject(r), 10)), onError); + } catch {} + + expect(onError).toHaveBeenCalledTimes(0); + }); + }); + + describe('when component is un-mounted', () => { + it('onError callback is called', async () => { + const hook = renderHook(() => useUnmountPromise()); + + const mounted = hook.result.current; + const onError = jest.fn(); + const promise = mounted(new Promise((r, reject) => setTimeout(() => reject(r), 10)), onError); + + hook.unmount(); + await Promise.race([promise, new Promise(r => setTimeout(r, 20))]); + + expect(onError).toHaveBeenCalledTimes(1); + expect(typeof onError.mock.calls[0][0]).toBe('function'); + }); + }); + }); +}); From 28ef34b4c7c8e9580a85d83d3dab11fde3be2db3 Mon Sep 17 00:00:00 2001 From: streamich Date: Sun, 8 Dec 2019 00:54:58 +0100 Subject: [PATCH 3/3] =?UTF-8?q?docs:=20=E2=9C=8F=EF=B8=8F=20add=20useUnmou?= =?UTF-8?q?ntedPromise=20reference?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- docs/useUnmountPromise.md | 30 ++++++++++++++++++++++++++++++ src/index.ts | 1 + 3 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 docs/useUnmountPromise.md diff --git a/README.md b/README.md index 6f370fb078..127ccab04c 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ - [`useMountedState`](./docs/useMountedState.md) — track if component is mounted. - [`usePromise`](./docs/usePromise.md) — resolves promise only while component is mounted. - [`useLogger`](./docs/useLogger.md) — logs in console as component goes through life-cycles. - - [`useMount`](./docs/useMount.md) — calls `mount` callbacks. + - [`useMount`](./docs/useMount.md) and [`useUnmountPromise`](./docs/useUnmountPromise.md) — tracks if component is mounted. - [`useUnmount`](./docs/useUnmount.md) — calls `unmount` callbacks. - [`useUpdateEffect`](./docs/useUpdateEffect.md) — run an `effect` only on updates. - [`useIsomorphicLayoutEffect`](./docs/useIsomorphicLayoutEffect.md) — `useLayoutEffect` that does not show warning when server-side rendering. diff --git a/docs/useUnmountPromise.md b/docs/useUnmountPromise.md new file mode 100644 index 0000000000..5a129cb936 --- /dev/null +++ b/docs/useUnmountPromise.md @@ -0,0 +1,30 @@ +# `useUnmountPromise` + +A life-cycle hook that provides a higher order promise that does not resolve if component un-mounts. + + +## Usage + +```ts +import useUnmountPromise from 'react-use/lib/useUnmountPromise'; + +const Demo = () => { + const mounted = useUnmountPromise(); + useEffect(async () => { + await mounted(someFunction()); // Will not resolve if component un-mounts. + }); +}; +``` + + +## Reference + +```ts +const mounted = useUnmountPromise(); + +mounted(promise); +mounted(promise, onError); +``` + +- `onError` — if promise rejects after the component is unmounted, `onError` + callback is called with the error. diff --git a/src/index.ts b/src/index.ts index 2559d82d1f..61ef03e3a3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -85,6 +85,7 @@ export { default as useTitle } from './useTitle'; export { default as useToggle } from './useToggle'; export { default as useTween } from './useTween'; export { default as useUnmount } from './useUnmount'; +export { default as useUnmountPromise } from './useUnmountPromise'; export { default as useUpdate } from './useUpdate'; export { default as useUpdateEffect } from './useUpdateEffect'; export { default as useUpsert } from './useUpsert';