From 731dae0ee862de95cef5c694be0c2d1dcdcc6d08 Mon Sep 17 00:00:00 2001 From: Jason Quense Date: Tue, 17 Sep 2019 13:12:00 -0400 Subject: [PATCH 1/2] feat: add useAsyncSetState --- .babelrc | 5 +++- src/useAsyncSetState.ts | 51 ++++++++++++++++++++++++++++++++++ src/useMergeState.ts | 27 ++++++++++-------- test/useAsyncSetState.test.tsx | 41 +++++++++++++++++++++++++++ 4 files changed, 111 insertions(+), 13 deletions(-) create mode 100644 src/useAsyncSetState.ts create mode 100644 test/useAsyncSetState.test.tsx diff --git a/.babelrc b/.babelrc index 5a9c0e9..5ccf53a 100644 --- a/.babelrc +++ b/.babelrc @@ -1,5 +1,5 @@ { - "presets": [["@4c"], "@babel/typescript"], + "presets": [["@4c", {}], "@babel/typescript"], "env": { "esm": { "presets": [ @@ -10,6 +10,9 @@ } ] ] + }, + "test": { + "presets": [["@4c", { "development": true, "debug": true }]] } } } diff --git a/src/useAsyncSetState.ts b/src/useAsyncSetState.ts new file mode 100644 index 0000000..6793193 --- /dev/null +++ b/src/useAsyncSetState.ts @@ -0,0 +1,51 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react' + +type Updater = (state: TState) => TState + +export type AsyncSetState = ( + stateUpdate: React.SetStateAction, +) => Promise + +export const useAsyncSetState = ( + initialState: TState, +): [TState, AsyncSetState] => { + const [state, setState] = useState(initialState) + const resolvers = useRef<((state: TState) => void)[]>([]) + + useEffect(() => { + resolvers.current.forEach(resolve => resolve(state)) + resolvers.current = [] + }, [state]) + + const setStateAsync = useCallback( + (update: Updater | TState) => { + return new Promise((resolve, reject) => { + setState(stateBefore => { + try { + let nextState: TState + if (typeof update === 'function') { + // @ts-ignore This isn't a Function + nextState = update(stateBefore) + } else { + nextState = update + } + + // If state does not change, we must resolve the promise because + // react won't re-render and effect will not resolve + if (nextState === stateBefore) { + resolve(nextState) + } else { + resolvers.current.push(resolve) + } + return nextState + } catch (e) { + reject(e) + throw e + } + }) + }) + }, + [setState], + ) + return [state, setStateAsync] +} diff --git a/src/useMergeState.ts b/src/useMergeState.ts index decd2d6..0224a28 100644 --- a/src/useMergeState.ts +++ b/src/useMergeState.ts @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useCallback, useState } from 'react' type Updater = (state: TState) => Partial | null @@ -29,17 +29,20 @@ export default function useMergeState( ): [TState, MergeStateSetter] { const [state, setState] = useState(initialState) - const updater = (update: Updater | Partial | null) => { - if (update === null) return - if (typeof update === 'function') { - setState(state => { - const nextState = update(state) - return nextState == null ? state : { ...state, ...nextState } - }) - } else { - setState(state => ({ ...state, ...update })) - } - } + const updater = useCallback( + (update: Updater | Partial | null) => { + if (update === null) return + if (typeof update === 'function') { + setState(state => { + const nextState = update(state) + return nextState == null ? state : { ...state, ...nextState } + }) + } else { + setState(state => ({ ...state, ...update })) + } + }, + [setState], + ) return [state, updater] } diff --git a/test/useAsyncSetState.test.tsx b/test/useAsyncSetState.test.tsx new file mode 100644 index 0000000..c25299d --- /dev/null +++ b/test/useAsyncSetState.test.tsx @@ -0,0 +1,41 @@ +import { mount } from 'enzyme' +import React from 'react' +import { act } from 'react-dom/test-utils' +import { useAsyncSetState } from '../src/useAsyncSetState' + +describe('useAsyncSetState', () => { + it('should increment counter', async () => { + let asyncState + + type TestState = { + counter: number + label: string + } + + function Wrapper() { + asyncState = useAsyncSetState({ label: 'hey', counter: 0 }) + return null + } + + mount() + + const incrementAsync = async () => { + await act(() => + asyncState[1]({ + ...asyncState[0], + counter: asyncState[0].counter + 1, + }), + ) + } + + expect(asyncState[0].counter).toEqual(0) + await incrementAsync() + expect(asyncState[0].counter).toEqual(1) + await incrementAsync() + expect(asyncState[0].counter).toEqual(2) + await incrementAsync() + expect(asyncState[0].counter).toEqual(3) + + expect(asyncState[0].label).toEqual('hey') + }) +}) From bc52e7148e56d0a3e698c2ec40a20b9bfcb357f0 Mon Sep 17 00:00:00 2001 From: Jason Quense Date: Fri, 20 Sep 2019 09:17:09 -0400 Subject: [PATCH 2/2] clean up async state --- .babelrc | 4 +- package.json | 2 +- src/useAnimationFrame.ts | 4 +- src/{useAsyncSetState.ts => useStateAsync.ts} | 40 +++++-- test/setup.js | 34 +++++- test/useAsyncSetState.test.tsx | 41 ------- test/useStateAsync.test.tsx | 109 ++++++++++++++++++ 7 files changed, 177 insertions(+), 57 deletions(-) rename src/{useAsyncSetState.ts => useStateAsync.ts} (52%) delete mode 100644 test/useAsyncSetState.test.tsx create mode 100644 test/useStateAsync.test.tsx diff --git a/.babelrc b/.babelrc index 5ccf53a..31e852b 100644 --- a/.babelrc +++ b/.babelrc @@ -1,5 +1,5 @@ { - "presets": [["@4c", {}], "@babel/typescript"], + "presets": ["@4c", "@babel/typescript"], "env": { "esm": { "presets": [ @@ -12,7 +12,7 @@ ] }, "test": { - "presets": [["@4c", { "development": true, "debug": true }]] + "presets": [["@4c", { "development": true }]] } } } diff --git a/package.json b/package.json index 8b26994..a278bf0 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ }, "jest": { "preset": "@4c", - "setupFiles": [ + "setupFilesAfterEnv": [ "./test/setup.js" ] }, diff --git a/src/useAnimationFrame.ts b/src/useAnimationFrame.ts index fd22015..47b0be9 100644 --- a/src/useAnimationFrame.ts +++ b/src/useAnimationFrame.ts @@ -1,7 +1,7 @@ import { useRef } from 'react' -import useWillUnmount from './useWillUnmount' import useMounted from './useMounted' import useStableMemo from './useStableMemo' +import useWillUnmount from './useWillUnmount' export interface UseAnimationFrameReturn { cancel(): void @@ -22,7 +22,7 @@ export interface UseAnimationFrameReturn { * Returns a controller object for requesting and cancelling an animation freame that is properly cleaned up * once the component unmounts. New requests cancel and replace existing ones. * - * ```tsx + * ```ts * const [style, setStyle] = useState({}); * const animationFrame = useAnimationFrame(); * diff --git a/src/useAsyncSetState.ts b/src/useStateAsync.ts similarity index 52% rename from src/useAsyncSetState.ts rename to src/useStateAsync.ts index 6793193..a63ce1e 100644 --- a/src/useAsyncSetState.ts +++ b/src/useStateAsync.ts @@ -6,33 +6,51 @@ export type AsyncSetState = ( stateUpdate: React.SetStateAction, ) => Promise -export const useAsyncSetState = ( - initialState: TState, -): [TState, AsyncSetState] => { +/** + * A hook that mirrors `useState` in function and API, expect that setState + * calls return a promise that resolves after the state has been set (in an effect). + * + * This is _similar_ to the second callback in classy setState calls, but fires later. + * + * ```ts + * const [counter, setState] = useStateAsync(1); + * + * const handleIncrement = async () => { + * await setState(2); + * doWorkRequiringCurrentState() + * } + * ``` + * + * @param initialState initialize with some state value same as `useState` + */ +function useStateAsync( + initialState: TState | (() => TState), +): [TState, AsyncSetState] { const [state, setState] = useState(initialState) const resolvers = useRef<((state: TState) => void)[]>([]) useEffect(() => { resolvers.current.forEach(resolve => resolve(state)) - resolvers.current = [] + resolvers.current.length = 0 }, [state]) const setStateAsync = useCallback( (update: Updater | TState) => { return new Promise((resolve, reject) => { - setState(stateBefore => { + setState(prevState => { try { let nextState: TState - if (typeof update === 'function') { - // @ts-ignore This isn't a Function - nextState = update(stateBefore) + // ugly instanceof for typescript + if (update instanceof Function) { + nextState = update(prevState) } else { nextState = update } // If state does not change, we must resolve the promise because - // react won't re-render and effect will not resolve - if (nextState === stateBefore) { + // react won't re-render and effect will not resolve. If there are already + // resolvers queued, then it should be safe to assume an update will happen + if (!resolvers.current.length && Object.is(nextState, prevState)) { resolve(nextState) } else { resolvers.current.push(resolve) @@ -49,3 +67,5 @@ export const useAsyncSetState = ( ) return [state, setStateAsync] } + +export default useStateAsync diff --git a/test/setup.js b/test/setup.js index c8dd3f5..94ef01e 100644 --- a/test/setup.js +++ b/test/setup.js @@ -1,5 +1,5 @@ -import Adapter from 'enzyme-adapter-react-16' import Enzyme from 'enzyme' +import Adapter from 'enzyme-adapter-react-16' import matchMediaPolyfill from 'mq-polyfill' Enzyme.configure({ adapter: new Adapter() }) @@ -22,3 +22,35 @@ if (typeof window !== 'undefined') { }).dispatchEvent(new this.Event('resize')) } } + +let expectedErrors = 0 +let actualErrors = 0 +function onError(e) { + if (expectedErrors) { + e.preventDefault() + } + actualErrors += 1 +} + +expect.errors = num => { + expectedErrors = num +} + +beforeEach(() => { + expectedErrors = 0 + actualErrors = 0 + if (typeof window !== 'undefined') { + window.addEventListener('error', onError) + } +}) + +afterEach(() => { + if (typeof window !== 'undefined') { + window.removeEventListener('error', onError) + } + if (expectedErrors) { + expect(actualErrors).toBe(expectedErrors) + } + + expectedErrors = 0 +}) diff --git a/test/useAsyncSetState.test.tsx b/test/useAsyncSetState.test.tsx deleted file mode 100644 index c25299d..0000000 --- a/test/useAsyncSetState.test.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { mount } from 'enzyme' -import React from 'react' -import { act } from 'react-dom/test-utils' -import { useAsyncSetState } from '../src/useAsyncSetState' - -describe('useAsyncSetState', () => { - it('should increment counter', async () => { - let asyncState - - type TestState = { - counter: number - label: string - } - - function Wrapper() { - asyncState = useAsyncSetState({ label: 'hey', counter: 0 }) - return null - } - - mount() - - const incrementAsync = async () => { - await act(() => - asyncState[1]({ - ...asyncState[0], - counter: asyncState[0].counter + 1, - }), - ) - } - - expect(asyncState[0].counter).toEqual(0) - await incrementAsync() - expect(asyncState[0].counter).toEqual(1) - await incrementAsync() - expect(asyncState[0].counter).toEqual(2) - await incrementAsync() - expect(asyncState[0].counter).toEqual(3) - - expect(asyncState[0].label).toEqual('hey') - }) -}) diff --git a/test/useStateAsync.test.tsx b/test/useStateAsync.test.tsx new file mode 100644 index 0000000..8212605 --- /dev/null +++ b/test/useStateAsync.test.tsx @@ -0,0 +1,109 @@ +import { mount } from 'enzyme' +import React from 'react' +import { act } from 'react-dom/test-utils' +import useStateAsync, { AsyncSetState } from '../src/useStateAsync' + +describe('useStateAsync', () => { + it('should increment counter', async () => { + let asyncState: [number, AsyncSetState] + + function Wrapper() { + asyncState = useStateAsync(0) + return null + } + + mount() + + expect.assertions(4) + + const incrementAsync = async () => { + await act(() => asyncState[1](prev => prev + 1)) + } + + expect(asyncState![0]).toEqual(0) + + await incrementAsync() + expect(asyncState![0]).toEqual(1) + + await incrementAsync() + expect(asyncState![0]).toEqual(2) + + await incrementAsync() + expect(asyncState![0]).toEqual(3) + }) + + it('should reject on error', async () => { + let asyncState: [number, AsyncSetState] + + function Wrapper() { + asyncState = useStateAsync(1) + return null + } + class CatchError extends React.Component { + static getDerivedStateFromError() {} + componentDidCatch() {} + render() { + return this.props.children + } + } + + mount( + + + , + ) + + // @ts-ignore + expect.errors(1) + + await act(async () => { + const p = asyncState[1](() => { + throw new Error('yo') + }) + return expect(p).rejects.toThrow('yo') + }) + }) + + it('should resolve even if no update happens', async () => { + let asyncState: [number, AsyncSetState] + + function Wrapper() { + asyncState = useStateAsync(1) + return null + } + + mount() + + expect.assertions(3) + + expect(asyncState![0]).toEqual(1) + + await act(() => expect(asyncState[1](1)).resolves.toEqual(1)) + + expect(asyncState![0]).toEqual(1) + }) + + it('should resolve after update if already pending', async () => { + let asyncState: [number, AsyncSetState] + + function Wrapper() { + asyncState = useStateAsync(0) + return null + } + + mount() + + expect.assertions(5) + + expect(asyncState![0]).toEqual(0) + + const setAndAssert = async (n: number) => + expect(asyncState[1](n)).resolves.toEqual(2) + + await act(() => + Promise.all([setAndAssert(1), setAndAssert(1), setAndAssert(2)]), + ) + + expect(asyncState![0]).toEqual(2) + }) +})