From ef374bf36d6d519dd9030a6e7724e80bf0420f49 Mon Sep 17 00:00:00 2001 From: Maarten Hus Date: Mon, 20 Jan 2020 11:36:37 +0100 Subject: [PATCH] improvement: `deferFn` and `promiseFn` now have the same signature. The `promiseFn` and the `deferFn` have been unified. They now share the following signature: ```ts export type AsyncFn = ( context: C | undefined, props: AsyncProps, controller: AbortController ) => Promise ``` Before the `deferFn` and `promiseFn` had this signature: ```ts export type PromiseFn = (props: AsyncProps, controller: AbortController) => Promise export type DeferFn = ( args: any[], props: AsyncProps, controller: AbortController ) => Promise ``` The big change is the introduction of the `context` parameter. The idea behind this parameter is that it will contain the parameters which are not known to `AsyncOptions` for use in the `promiseFn` and `asyncFn`. Another goal of this commit is to make TypeScript more understanding of the `context` which `AsyncProps` implicitly carries around. Before this commit the `AsyncProps` accepted extra prop via `[prop: string]: any`. This breaks TypeScript's understanding of the divisions somewhat. This also led to missing types for `onCancel` and `suspense`, which have been added in this commit. To solve this all extra properties that are unknown to `AsyncProps` are put on the `AsyncProps`'s `context` property. This means that when using TypeScript you can now use `context` to safely extract the context. This does however mean that because `[prop: string]: any` is removed TypeScript users have a breaking change. Closes: #246 --- .gitignore | 2 + docs/api/options.md | 13 +- docs/getting-started/upgrading.md | 147 ++++++++++++++++++ docs/getting-started/usage.md | 14 +- examples/with-typescript/src/App.tsx | 8 +- .../with-typescript/src/FetchHookExample.tsx | 2 +- packages/react-async/src/Async.spec.js | 12 +- packages/react-async/src/Async.tsx | 38 +++-- packages/react-async/src/context.spec.js | 39 +++++ packages/react-async/src/context.ts | 23 +++ packages/react-async/src/propTypes.ts | 1 + packages/react-async/src/reducer.ts | 6 +- packages/react-async/src/specs.js | 55 +++++-- packages/react-async/src/status.ts | 4 +- packages/react-async/src/types.ts | 40 +++-- packages/react-async/src/useAsync.spec.js | 23 ++- packages/react-async/src/useAsync.tsx | 70 +++++---- 17 files changed, 404 insertions(+), 93 deletions(-) create mode 100644 packages/react-async/src/context.spec.js create mode 100644 packages/react-async/src/context.ts diff --git a/.gitignore b/.gitignore index 7bca893d..c207e024 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,5 @@ lerna-debug.log* # when working with contributors package-lock.json yarn.lock + +.vscode \ No newline at end of file diff --git a/docs/api/options.md b/docs/api/options.md index 6ca6ac61..aa17ca61 100644 --- a/docs/api/options.md +++ b/docs/api/options.md @@ -31,7 +31,7 @@ A Promise instance which has already started. It will simply add the necessary r ## `promiseFn` -> `function(props: Object, controller: AbortController): Promise` +> `function(context C, props: AsyncOptions, controller: AbortController): Promise` A function that returns a promise. It is automatically invoked in `componentDidMount` and `componentDidUpdate`. The function receives all component props \(or options\) and an AbortController instance as arguments. @@ -39,9 +39,9 @@ A function that returns a promise. It is automatically invoked in `componentDidM ## `deferFn` -> `function(args: any[], props: Object, controller: AbortController): Promise` +> `function(context: C, props: AsyncOptions, controller: AbortController): Promise` -A function that returns a promise. This is invoked only by manually calling `run(...args)`. Receives the same arguments as `promiseFn`, as well as any arguments to `run` which are passed through as an array. The `deferFn` is commonly used to send data to the server following a user action, such as submitting a form. You can use this in conjunction with `promiseFn` to fill the form with existing data, then updating it on submit with `deferFn`. +A function that returns a promise. This is invoked only by manually calling `run(param)`. Receives the same arguments as `promiseFn`. The `deferFn` is commonly used to send data to the server following a user action, such as submitting a form. You can use this in conjunction with `promiseFn` to fill the form with existing data, then updating it on submit with `deferFn`. > Be aware that when using both `promiseFn` and `deferFn`, the shape of their fulfilled value should match, because they both update the same `data`. @@ -132,3 +132,10 @@ Enables the use of `deferFn` if `true`, or enables the use of `promiseFn` if `fa > `boolean` Enables or disables JSON parsing of the response body. By default this is automatically enabled if the `Accept` header is set to `"application/json"`. + + +## `context` + +> `C | undefined` + +The argument which is passed as the first argument to the `promiseFn` and the `deferFn`. diff --git a/docs/getting-started/upgrading.md b/docs/getting-started/upgrading.md index e367db69..79ccdfe4 100644 --- a/docs/getting-started/upgrading.md +++ b/docs/getting-started/upgrading.md @@ -1,5 +1,152 @@ # Upgrading +## Upgrade to v11 + +The `promiseFn` and the `deferFn` have been unified. They now share the following signature: + +```ts +export type AsyncFn = ( + context: C | undefined, + props: AsyncProps, + controller: AbortController +) => Promise +``` + +Before the `deferFn` and `promiseFn` had this signature: + +```ts +export type PromiseFn = (props: AsyncProps, controller: AbortController) => Promise + +export type DeferFn = ( + args: any[], + props: AsyncProps, + controller: AbortController +) => Promise +``` + +The difference is the idea of having a `context`, the context will contain all parameters +to `AsyncProps` which are not native to the `AsyncProps`. For example: + +```jsx +useAsync({ promiseFn: loadPlayer, playerId: 1 }) +``` + +In the above example the context would be `{playerId: 1}`. + +This means that you know need to expect three parameter for the `promiseFn` instead of two. + +So before in `< 10.0.0` you would do this: + +```jsx +import { useAsync } from "react-async" + +// Here loadPlayer has only two arguments +const loadPlayer = async (options, controller) => { + const res = await fetch(`/api/players/${options.playerId}`, { signal: controller.signal }) + if (!res.ok) throw new Error(res.statusText) + return res.json() +} + +const MyComponent = () => { + const { data, error, isPending } = useAsync({ promiseFn: loadPlayer, playerId: 1 }) +} +``` + +In `11.0.0` you need to account for the three parameters: + +```jsx +import { useAsync } from "react-async" + +// With two arguments: +const loadPlayer = async (context, options, controller) => { + const res = await fetch(`/api/players/${context.playerId}`, { signal: controller.signal }) + if (!res.ok) throw new Error(res.statusText) + return res.json() +} + +const MyComponent = () => { + // You can either pass arguments by adding extra keys to the AsyncProps + const { data, error, isPending } = useAsync({ promiseFn: loadPlayer, playerId: 1 }) + + // Or you can explicitly define the context which is TypeScript friendly + const { data, error, isPending } = useAsync({ promiseFn: loadPlayer, context: { playerId: 1 } }) +} +``` + +For the `deferFn` this means no longer expecting an array of arguments but instead a singular argument. +The `run` now accepts only one argument which is a singular value. All other arguments to `run` but +the first will be ignored. + +So before in `< 10.0.0` you would do this: + +```jsx +import Async from "react-async" + +const getAttendance = () => + fetch("/attendance").then( + () => true, + () => false + ) +const updateAttendance = ([attend, userId]) => + fetch(`/attendance/${userId}`, { method: attend ? "POST" : "DELETE" }).then( + () => attend, + () => !attend + ) + +const userId = 42 + +const AttendanceToggle = () => ( + + {({ isPending, data: isAttending, run, setData }) => ( + { + run(!isAttending, userId) + }} + disabled={isPending} + /> + )} + +) +``` + +In `11.0.0` you need to account for for the parameters not being an array: + +```jsx +import Async from "react-async" + +const getAttendance = () => + fetch("/attendance").then( + () => true, + () => false + ) +const updateAttendance = ({ attend, userId }) => + fetch(`/attendance/${userId}`, { method: attend ? "POST" : "DELETE" }).then( + () => attend, + () => !attend + ) + +const userId = 42 + +const AttendanceToggle = () => ( + + {({ isPending, data: isAttending, run, setData }) => ( + { + run({ attend: isAttending, userId }) + }} + disabled={isPending} + /> + )} + +) +``` + +## Upgrade to v10 + +This is a major release due to the migration to TypeScript. While technically it shouldn't change anything, it might be a breaking change in certain situations. Theres also a bugfix for watchFn and a fix for legacy browsers. + ## Upgrade to v9 The rejection value for failed requests with `useFetch` was changed. Previously it was the Response object. Now it's an diff --git a/docs/getting-started/usage.md b/docs/getting-started/usage.md index b22e3e01..2d958b6d 100644 --- a/docs/getting-started/usage.md +++ b/docs/getting-started/usage.md @@ -10,14 +10,14 @@ The `useAsync` hook \(available [from React v16.8.0](https://reactjs.org/hooks)\ import { useAsync } from "react-async" // You can use async/await or any function that returns a Promise -const loadPlayer = async ({ playerId }, { signal }) => { +const loadPlayer = async ({ playerId }, options, { signal }) => { const res = await fetch(`/api/players/${playerId}`, { signal }) if (!res.ok) throw new Error(res.statusText) return res.json() } const MyComponent = () => { - const { data, error, isPending } = useAsync({ promiseFn: loadPlayer, playerId: 1 }) + const { data, error, isPending } = useAsync({ promiseFn: loadPlayer, context: { playerId: 1 } }) if (isPending) return "Loading..." if (error) return `Something went wrong: ${error.message}` if (data) @@ -85,7 +85,7 @@ The classic interface to React Async. Simply use `` directly in your JSX import Async from "react-async" // Your promiseFn receives all props from Async and an AbortController instance -const loadPlayer = async ({ playerId }, { signal }) => { +const loadPlayer = async ({ playerId }, options, { signal }) => { const res = await fetch(`/api/players/${playerId}`, { signal }) if (!res.ok) throw new Error(res.statusText) return res.json() @@ -118,7 +118,7 @@ You can also create your own component instances, allowing you to preconfigure t ```jsx import { createInstance } from "react-async" -const loadPlayer = async ({ playerId }, { signal }) => { +const loadPlayer = async ({ playerId }, options, { signal }) => { const res = await fetch(`/api/players/${playerId}`, { signal }) if (!res.ok) throw new Error(res.statusText) return res.json() @@ -141,12 +141,12 @@ Several [helper components](usage.md#helper-components) are available to improve ```jsx import { useAsync, IfPending, IfFulfilled, IfRejected } from "react-async" -const loadPlayer = async ({ playerId }, { signal }) => { +const loadPlayer = async ({ playerId }, options, { signal }) => { // ... } const MyComponent = () => { - const state = useAsync({ promiseFn: loadPlayer, playerId: 1 }) + const state = useAsync({ promiseFn: loadPlayer, context: { playerId: 1 } }) return ( <> Loading... @@ -171,7 +171,7 @@ Each of the helper components are also available as static properties of ` { +const loadPlayer = async ({ playerId }, options { signal }) => { const res = await fetch(`/api/players/${playerId}`, { signal }) if (!res.ok) throw new Error(res.statusText) return res.json() diff --git a/examples/with-typescript/src/App.tsx b/examples/with-typescript/src/App.tsx index 4789fb9c..182ce63b 100644 --- a/examples/with-typescript/src/App.tsx +++ b/examples/with-typescript/src/App.tsx @@ -5,13 +5,13 @@ import Async, { IfPending, IfRejected, IfFulfilled, - PromiseFn, + AsyncFn, } from "react-async" import DevTools from "react-async-devtools" import "./App.css" import { FetchHookExample } from "./FetchHookExample" -const loadFirstName: PromiseFn = ({ userId }) => +const loadFirstName: AsyncFn = ({ userId }) => fetch(`https://reqres.in/api/users/${userId}`) .then(res => (res.ok ? Promise.resolve(res) : Promise.reject(res))) .then(res => res.json()) @@ -20,7 +20,7 @@ const loadFirstName: PromiseFn = ({ userId }) => const CustomAsync = createInstance({ promiseFn: loadFirstName }) const UseAsync = () => { - const state = useAsync({ promiseFn: loadFirstName, userId: 1 }) + const state = useAsync({ promiseFn: loadFirstName, context: { userId: 1 } }) return ( <> Loading... @@ -47,7 +47,7 @@ class App extends Component { Promise.resolve("bar")}> {data => <>{data}} - + {data => <>{data}} diff --git a/examples/with-typescript/src/FetchHookExample.tsx b/examples/with-typescript/src/FetchHookExample.tsx index a0829994..9909d7d6 100644 --- a/examples/with-typescript/src/FetchHookExample.tsx +++ b/examples/with-typescript/src/FetchHookExample.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { useFetch } from "react-async" export function FetchHookExample() { - const result = useFetch<{ token: string }>("https://reqres.in/api/login", { + const result = useFetch<{ token: string }, {}>("https://reqres.in/api/login", { method: "POST", headers: { "Content-Type": "application/json", diff --git a/packages/react-async/src/Async.spec.js b/packages/react-async/src/Async.spec.js index cb00a41d..f00b151c 100644 --- a/packages/react-async/src/Async.spec.js +++ b/packages/react-async/src/Async.spec.js @@ -284,20 +284,20 @@ describe("createInstance", () => { let counter = 1 const { getByText } = render( - {({ run }) => } + {({ run }) => } ) const expectedProps = { deferFn, foo: "bar" } expect(deferFn).not.toHaveBeenCalled() fireEvent.click(getByText("run")) expect(deferFn).toHaveBeenCalledWith( - ["go", 1], + { type: "go", counter: 1 }, expect.objectContaining(expectedProps), abortCtrl ) fireEvent.click(getByText("run")) expect(deferFn).toHaveBeenCalledWith( - ["go", 2], + { type: "go", counter: 2 }, expect.objectContaining(expectedProps), abortCtrl ) @@ -312,7 +312,7 @@ describe("createInstance", () => { {({ run, reload }) => counter === 1 ? ( - + ) : ( ) @@ -323,13 +323,13 @@ describe("createInstance", () => { expect(deferFn).not.toHaveBeenCalled() fireEvent.click(getByText("run")) expect(deferFn).toHaveBeenCalledWith( - ["go", 1], + { type: "go", counter: 1 }, expect.objectContaining(expectedProps), abortCtrl ) fireEvent.click(getByText("reload")) expect(deferFn).toHaveBeenCalledWith( - ["go", 1], + { type: "go", counter: 1 }, expect.objectContaining(expectedProps), abortCtrl ) diff --git a/packages/react-async/src/Async.tsx b/packages/react-async/src/Async.tsx index 5d0559e7..c3f73d9a 100644 --- a/packages/react-async/src/Async.tsx +++ b/packages/react-async/src/Async.tsx @@ -21,6 +21,7 @@ import { AsyncAction, ReducerAsyncState, } from "./types" +import { createContext } from "./context" export interface InitialProps { children?: InitialChildren @@ -43,7 +44,7 @@ export interface SettledProps { persist?: boolean } -class Async extends React.Component, AsyncState> {} +class Async extends React.Component, AsyncState> {} type GenericAsync = typeof Async & { Initial(props: InitialProps): JSX.Element Pending(props: PendingProps): JSX.Element @@ -54,7 +55,7 @@ type GenericAsync = typeof Async & { Settled(props: SettledProps): JSX.Element } -export type AsyncConstructor = React.ComponentClass> & { +export type AsyncConstructor = React.ComponentClass> & { Initial: React.FC> Pending: React.FC> Loading: React.FC> @@ -68,10 +69,10 @@ export type AsyncConstructor = React.ComponentClass> & { * createInstance allows you to create instances of Async that are bound to a specific promise. * A unique instance also uses its own React context for better nesting capability. */ -export function createInstance( - defaultOptions: AsyncProps = {}, +export function createInstance( + defaultOptions: AsyncProps = {}, displayName = "Async" -): AsyncConstructor { +): AsyncConstructor { const { Consumer: UnguardedConsumer, Provider } = React.createContext | undefined>( undefined ) @@ -90,14 +91,18 @@ export function createInstance( ) } - type Props = AsyncProps + type Props = AsyncProps type State = AsyncState - type Constructor = AsyncConstructor + type Constructor = AsyncConstructor class Async extends React.Component { private mounted = false private counter = 0 - private args: any[] = [] + + // Accept that undefined is a perfect C + // @ts-ignore + private args: C = undefined + private promise?: Promise = neverSettle private abortController: AbortController = new MockAbortController() private debugLabel?: string @@ -120,12 +125,12 @@ export function createInstance( const initialValue = props.initialValue || defaultOptions.initialValue this.state = { - ...init({ initialValue, promise, promiseFn }), + ...init({ initialValue, promise, promiseFn }), cancel: this.cancel, run: this.run, reload: () => { this.load() - this.run(...this.args) + this.run(this.args) }, setData: this.setData, setError: this.setError, @@ -213,18 +218,23 @@ export function createInstance( .catch(this.onReject(this.counter)) } else if (promiseFn) { const props = { ...defaultOptions, ...this.props } - this.start(() => promiseFn(props, this.abortController)) + + const context = createContext(props) + + props.context = context + + this.start(() => promiseFn(context, props, this.abortController)) .then(this.onResolve(this.counter)) .catch(this.onReject(this.counter)) } } - run(...args: any[]) { + run(context: C) { const deferFn = this.props.deferFn || defaultOptions.deferFn if (deferFn) { - this.args = args + this.args = context const props = { ...defaultOptions, ...this.props } - return this.start(() => deferFn(args, props, this.abortController)).then( + return this.start(() => deferFn(context, props, this.abortController)).then( this.onResolve(this.counter), this.onReject(this.counter) ) diff --git a/packages/react-async/src/context.spec.js b/packages/react-async/src/context.spec.js new file mode 100644 index 00000000..7f515beb --- /dev/null +++ b/packages/react-async/src/context.spec.js @@ -0,0 +1,39 @@ +import { createContext } from "./context" + +import { KEYS_OF_FETCHOPTIONS } from "./types" + +describe("createContext", () => { + test("that `createContext` removes all known keys and copies the options", () => { + const options = { + a: 1, + b: 2, + } + + KEYS_OF_FETCHOPTIONS.forEach(k => { + // All keys but `context` should be removed by createContext + if (k !== "context") { + options[k] = k + } + }) + + const context = createContext(options) + + expect(context).toEqual({ a: 1, b: 2 }) + + expect(context).not.toBe(options) + }) + + test("that `createContext` uses `options.context` as default values but that props from the root take precedence", () => { + const options = { + a: 1, + b: 2, + context: { c: 3, b: 42 }, + } + + const context = createContext(options) + + expect(context).toEqual({ a: 1, b: 2, c: 3 }) + + expect(context).not.toBe(options) + }) +}) diff --git a/packages/react-async/src/context.ts b/packages/react-async/src/context.ts new file mode 100644 index 00000000..455698d8 --- /dev/null +++ b/packages/react-async/src/context.ts @@ -0,0 +1,23 @@ +import { AsyncOptions, KEYS_OF_FETCHOPTIONS } from "./types" + +/** + * Removes all "known "keys from fetchOptions leaving only the + * keys that are not defined in the type AsyncOptions. + * + * One special behavior is when it encounters a `context` object + * inside of the `options`. It will treat them as defaults for + * the context object. But they are overwritten by props from + * the options. + */ +export function createContext(options: AsyncOptions): C { + const context: AsyncOptions = { + ...options.context, + ...options, + } + + KEYS_OF_FETCHOPTIONS.forEach(k => { + delete context[k] + }) + + return context as C +} diff --git a/packages/react-async/src/propTypes.ts b/packages/react-async/src/propTypes.ts index b6d837fe..f062827e 100644 --- a/packages/react-async/src/propTypes.ts +++ b/packages/react-async/src/propTypes.ts @@ -45,6 +45,7 @@ export default PropTypes && { dispatcher: PropTypes.func, debugLabel: PropTypes.string, suspense: PropTypes.bool, + context: PropTypes.any, }, Initial: { children: childrenFn, diff --git a/packages/react-async/src/reducer.ts b/packages/react-async/src/reducer.ts index cd9448e5..396e9e88 100644 --- a/packages/react-async/src/reducer.ts +++ b/packages/react-async/src/reducer.ts @@ -1,6 +1,6 @@ import { getInitialStatus, getIdleStatus, getStatusProps, StatusTypes } from "./status" import { - PromiseFn, + AsyncFn, AsyncAction, AsyncPending, AsyncFulfilled, @@ -49,14 +49,14 @@ export enum ActionTypes { reject = "reject", } -export const init = ({ +export const init = ({ initialValue, promise, promiseFn, }: { initialValue?: Error | T promise?: Promise - promiseFn?: PromiseFn + promiseFn?: AsyncFn }) => ({ initialValue, diff --git a/packages/react-async/src/specs.js b/packages/react-async/src/specs.js index 14054789..b5e4a0ce 100644 --- a/packages/react-async/src/specs.js +++ b/packages/react-async/src/specs.js @@ -227,7 +227,11 @@ export const withPromiseFn = (Async, abortCtrl) => () => { test("invokes `promiseFn` with props", () => { const promiseFn = jest.fn().mockReturnValue(resolveTo()) render() - expect(promiseFn).toHaveBeenCalledWith({ promiseFn, anotherProp: "123" }, abortCtrl) + expect(promiseFn).toHaveBeenCalledWith( + { anotherProp: "123" }, + { promiseFn, anotherProp: "123", context: { anotherProp: "123" } }, + abortCtrl + ) }) test("sets `startedAt` when the promise starts", async () => { @@ -308,12 +312,14 @@ export const withPromiseFn = (Async, abortCtrl) => () => { ) expect(promiseFn).toHaveBeenCalledTimes(1) expect(promiseFn).toHaveBeenLastCalledWith( + { count: 0 }, expect.objectContaining({ count: 0 }), expect.any(Object) ) fireEvent.click(getByText("increment")) expect(promiseFn).toHaveBeenCalledTimes(2) expect(promiseFn).toHaveBeenLastCalledWith( + { count: 1 }, expect.objectContaining({ count: 1 }), expect.any(Object) ) @@ -322,6 +328,7 @@ export const withPromiseFn = (Async, abortCtrl) => () => { fireEvent.click(getByText("increment")) expect(promiseFn).toHaveBeenCalledTimes(3) expect(promiseFn).toHaveBeenLastCalledWith( + { count: 2 }, expect.objectContaining({ count: 2 }), expect.any(Object) ) @@ -351,12 +358,14 @@ export const withPromiseFn = (Async, abortCtrl) => () => { ) expect(promiseFn).toHaveBeenCalledTimes(1) expect(promiseFn).toHaveBeenLastCalledWith( + { count: 0 }, expect.objectContaining({ count: 0 }), expect.any(Object) ) fireEvent.click(getByText("increment")) expect(promiseFn).toHaveBeenCalledTimes(1) expect(promiseFn).toHaveBeenLastCalledWith( + { count: 0 }, expect.objectContaining({ count: 0 }), expect.any(Object) ) @@ -364,6 +373,7 @@ export const withPromiseFn = (Async, abortCtrl) => () => { fireEvent.click(getByText("increment")) expect(promiseFn).toHaveBeenCalledTimes(2) expect(promiseFn).toHaveBeenLastCalledWith( + { count: 2 }, expect.objectContaining({ count: 2 }), expect.any(Object) ) @@ -458,16 +468,24 @@ export const withDeferFn = (Async, abortCtrl) => () => { const { getByText } = render( {({ run }) => { - return + return }} ) const props = { deferFn, foo: "bar" } expect(deferFn).not.toHaveBeenCalled() fireEvent.click(getByText("run")) - expect(deferFn).toHaveBeenCalledWith(["go", 1], expect.objectContaining(props), abortCtrl) + expect(deferFn).toHaveBeenCalledWith( + { type: "go", counter: 1 }, + expect.objectContaining(props), + abortCtrl + ) fireEvent.click(getByText("run")) - expect(deferFn).toHaveBeenCalledWith(["go", 2], expect.objectContaining(props), abortCtrl) + expect(deferFn).toHaveBeenCalledWith( + { type: "go", counter: 2 }, + expect.objectContaining(props), + abortCtrl + ) }) test("always passes the latest props", async () => { @@ -482,21 +500,24 @@ export const withDeferFn = (Async, abortCtrl) => () => { )} ) + class Parent extends React.Component { constructor(props) { super(props) this.state = { count: 0 } } + render() { const inc = () => this.setState(state => ({ count: state.count + 1 })) return ( - <> +
{this.state.count && } - +
) } } + const { getByText, getByTestId } = render() fireEvent.click(getByText("inc")) expect(getByTestId("counter")).toHaveTextContent("1") @@ -504,7 +525,7 @@ export const withDeferFn = (Async, abortCtrl) => () => { expect(getByTestId("counter")).toHaveTextContent("2") fireEvent.click(getByText("run")) expect(deferFn).toHaveBeenCalledWith( - [2], + 2, expect.objectContaining({ count: 2, deferFn }), abortCtrl ) @@ -518,7 +539,7 @@ export const withDeferFn = (Async, abortCtrl) => () => { {({ run, reload }) => { return (
- +
) @@ -527,11 +548,23 @@ export const withDeferFn = (Async, abortCtrl) => () => { ) expect(deferFn).not.toHaveBeenCalled() fireEvent.click(getByText("run")) - expect(deferFn).toHaveBeenCalledWith(["go", 1], expect.objectContaining({ deferFn }), abortCtrl) + expect(deferFn).toHaveBeenCalledWith( + { type: "go", counter: 1 }, + expect.objectContaining({ deferFn }), + abortCtrl + ) fireEvent.click(getByText("run")) - expect(deferFn).toHaveBeenCalledWith(["go", 2], expect.objectContaining({ deferFn }), abortCtrl) + expect(deferFn).toHaveBeenCalledWith( + { type: "go", counter: 2 }, + expect.objectContaining({ deferFn }), + abortCtrl + ) fireEvent.click(getByText("reload")) - expect(deferFn).toHaveBeenCalledWith(["go", 2], expect.objectContaining({ deferFn }), abortCtrl) + expect(deferFn).toHaveBeenCalledWith( + { type: "go", counter: 2 }, + expect.objectContaining({ deferFn }), + abortCtrl + ) }) test("only accepts the last invocation of the promise", async () => { diff --git a/packages/react-async/src/status.ts b/packages/react-async/src/status.ts index 9db4d3f4..8b831482 100644 --- a/packages/react-async/src/status.ts +++ b/packages/react-async/src/status.ts @@ -1,4 +1,4 @@ -import { PromiseFn } from "./types" +import { AsyncFn } from "./types" export enum StatusTypes { initial = "initial", @@ -7,7 +7,7 @@ export enum StatusTypes { rejected = "rejected", } -export const getInitialStatus = (value?: T | Error, promise?: Promise | PromiseFn) => { +export const getInitialStatus = (value?: T | Error, promise?: Promise | AsyncFn) => { if (value instanceof Error) return StatusTypes.rejected if (value !== undefined) return StatusTypes.fulfilled if (promise) return StatusTypes.pending diff --git a/packages/react-async/src/types.ts b/packages/react-async/src/types.ts index 20cd5d84..f1b186e6 100644 --- a/packages/react-async/src/types.ts +++ b/packages/react-async/src/types.ts @@ -13,10 +13,9 @@ export type SettledChildren = | ((state: AsyncFulfilled | AsyncRejected) => React.ReactNode) | React.ReactNode -export type PromiseFn = (props: AsyncProps, controller: AbortController) => Promise -export type DeferFn = ( - args: any[], - props: AsyncProps, +export type AsyncFn = ( + context: C, + props: AsyncProps, controller: AbortController ) => Promise @@ -32,15 +31,16 @@ export type Fulfill = AbstractAction & { type: "fulfill"; payload: T } export type Reject = AbstractAction & { type: "reject"; payload: Error; error: true } export type AsyncAction = Start | Cancel | Fulfill | Reject -export interface AsyncOptions { +export interface AsyncOptions { promise?: Promise - promiseFn?: PromiseFn - deferFn?: DeferFn + promiseFn?: AsyncFn + deferFn?: AsyncFn watch?: any - watchFn?: (props: AsyncProps, prevProps: AsyncProps) => any + watchFn?: (props: AsyncProps, prevProps: AsyncProps) => any initialValue?: T onResolve?: (data: T) => void onReject?: (error: Error) => void + onCancel?: () => void reducer?: ( state: ReducerAsyncState, action: AsyncAction, @@ -49,13 +49,31 @@ export interface AsyncOptions { dispatcher?: ( action: AsyncAction, internalDispatch: (action: AsyncAction) => void, - props: AsyncProps + props: AsyncProps ) => void debugLabel?: string - [prop: string]: any + suspense?: boolean + context?: C } -export interface AsyncProps extends AsyncOptions { +export const KEYS_OF_FETCHOPTIONS: Array> = [ + "promise", + "promiseFn", + "deferFn", + "watch", + "watchFn", + "initialValue", + "onResolve", + "onReject", + "onCancel", + "reducer", + "dispatcher", + "debugLabel", + "suspense", + "context", +] + +export interface AsyncProps extends AsyncOptions { children?: AsyncChildren } diff --git a/packages/react-async/src/useAsync.spec.js b/packages/react-async/src/useAsync.spec.js index e6ae75a5..cb103b1b 100644 --- a/packages/react-async/src/useAsync.spec.js +++ b/packages/react-async/src/useAsync.spec.js @@ -63,6 +63,9 @@ describe("useAsync", () => { promiseFn, count, watch: count, + context: { + a: 1, + }, }) const { run } = useAsync({ deferFn, @@ -76,19 +79,31 @@ describe("useAsync", () => { ) } const { getByText } = render() - expect(promiseFn).toHaveBeenLastCalledWith(expect.objectContaining({ count: 0 }), abortCtrl) + expect(promiseFn).toHaveBeenLastCalledWith( + { a: 1, count: 0 }, + expect.objectContaining({ count: 0 }), + abortCtrl + ) fireEvent.click(getByText("inc")) await sleep(10) // resolve promiseFn - expect(promiseFn).toHaveBeenLastCalledWith(expect.objectContaining({ count: 1 }), abortCtrl) + expect(promiseFn).toHaveBeenLastCalledWith( + { a: 1, count: 1 }, + expect.objectContaining({ count: 1 }), + abortCtrl + ) fireEvent.click(getByText("run")) await sleep(10) // resolve deferFn - expect(promiseFn).toHaveBeenLastCalledWith(expect.objectContaining({ count: 1 }), abortCtrl) + expect(promiseFn).toHaveBeenLastCalledWith( + { a: 1, count: 1 }, + expect.objectContaining({ count: 1 }), + abortCtrl + ) }) test("calling run() will always use the latest onReject callback", async () => { const onReject1 = jest.fn() const onReject2 = jest.fn() - const deferFn = ([count]) => Promise.reject(count) + const deferFn = count => Promise.reject(count) function App() { const [count, setCount] = React.useState(0) const onReject = count === 0 ? onReject1 : onReject2 diff --git a/packages/react-async/src/useAsync.tsx b/packages/react-async/src/useAsync.tsx index b3eb22d9..06746a79 100644 --- a/packages/react-async/src/useAsync.tsx +++ b/packages/react-async/src/useAsync.tsx @@ -13,7 +13,7 @@ import { AsyncOptions, AsyncState, AbstractState, - PromiseFn, + AsyncFn, Meta, AsyncInitial, AsyncFulfilled, @@ -21,6 +21,8 @@ import { AsyncRejected, } from "./types" +import { createContext } from "./context" + /** * Due to https://github.com/microsoft/web-build-tools/issues/1050, we need * AbstractState imported in this file, even though it is only used implicitly. @@ -33,16 +35,19 @@ declare type ImportWorkaround = | AsyncPending | AsyncRejected -export interface FetchOptions extends AsyncOptions { +export interface FetchOptions extends AsyncOptions { defer?: boolean json?: boolean } -function useAsync(options: AsyncOptions): AsyncState -function useAsync(promiseFn: PromiseFn, options?: AsyncOptions): AsyncState +function useAsync(options: AsyncOptions): AsyncState +function useAsync(promiseFn: AsyncFn, options?: AsyncOptions): AsyncState -function useAsync(arg1: AsyncOptions | PromiseFn, arg2?: AsyncOptions): AsyncState { - const options: AsyncOptions = +function useAsync( + arg1: AsyncOptions | AsyncFn, + arg2?: AsyncOptions +): AsyncState { + const options: AsyncOptions = typeof arg1 === "function" ? { ...arg2, @@ -52,8 +57,8 @@ function useAsync(arg1: AsyncOptions | PromiseFn, arg2?: AsyncOptions(undefined) - const lastOptions = useRef>(options) + const lastArgs = useRef(undefined) + const lastOptions = useRef>(options) const lastPromise = useRef>(neverSettle) const abortController = useRef(new MockAbortController()) @@ -127,7 +132,7 @@ function useAsync(arg1: AsyncOptions | PromiseFn, arg2?: AsyncOptions { + execPromiseFn => { if ("AbortController" in globalScope) { abortController.current.abort() abortController.current = new globalScope.AbortController!() @@ -135,7 +140,7 @@ function useAsync(arg1: AsyncOptions | PromiseFn, arg2?: AsyncOptions { if (!isMounted.current) return - const executor = () => promiseFn().then(resolve, reject) + const executor = () => execPromiseFn().then(resolve, reject) dispatch({ type: ActionTypes.start, payload: executor, @@ -154,7 +159,13 @@ function useAsync(arg1: AsyncOptions | PromiseFn, arg2?: AsyncOptions promiseFn(lastOptions.current, abortController.current)) + start(() => { + const context = createContext(lastOptions.current) + + lastOptions.current.context = context + + return promiseFn(context, lastOptions.current, abortController.current) + }) .then(handleResolve(counter.current)) .catch(handleReject(counter.current)) } @@ -162,10 +173,11 @@ function useAsync(arg1: AsyncOptions | PromiseFn, arg2?: AsyncOptions { + (context: C) => { if (deferFn) { - lastArgs.current = args - start(() => deferFn(args, lastOptions.current, abortController.current)) + lastArgs.current = context + + start(() => deferFn(context, lastOptions.current, abortController.current)) .then(handleResolve(counter.current)) .catch(handleReject(counter.current)) } @@ -174,7 +186,7 @@ function useAsync(arg1: AsyncOptions | PromiseFn, arg2?: AsyncOptions { - lastArgs.current ? run(...lastArgs.current) : load() + lastArgs.current ? run(lastArgs.current) : load() }, [run, load]) const { onCancel } = options @@ -264,14 +276,11 @@ interface FetchRun extends Omit, "run"> { run(): void } -type FetchRunArgs = - | [(params?: OverrideParams) => OverrideParams] - | [OverrideParams] - | [React.SyntheticEvent] - | [Event] - | [] +type OverrideParamsFn = (params: OverrideParams) => OverrideParams + +type FetchRunArgs = OverrideParamsFn | OverrideParams | React.SyntheticEvent | Event -function isEvent(e: FetchRunArgs[0]): e is Event | React.SyntheticEvent { +function isEvent(e: FetchRunArgs): e is Event | React.SyntheticEvent { return typeof e === "object" && "preventDefault" in e } @@ -282,10 +291,10 @@ function isEvent(e: FetchRunArgs[0]): e is Event | React.SyntheticEvent { * @param {FetchOptions} options * @returns {AsyncState>} */ -function useAsyncFetch( +function useAsyncFetch( resource: RequestInfo, init: RequestInit, - { defer, json, ...options }: FetchOptions = {} + { defer, json, ...options }: FetchOptions = {} ): AsyncState> { const method = (resource as Request).method || (init && init.method) const headers: Headers & Record = @@ -296,20 +305,22 @@ function useAsyncFetch( globalScope.fetch(input, init).then(parseResponse(accept, json)) const isDefer = typeof defer === "boolean" ? defer : ["POST", "PUT", "PATCH", "DELETE"].indexOf(method!) !== -1 - const fn = isDefer ? "deferFn" : "promiseFn" + const identity = JSON.stringify({ resource, init, isDefer, }) + const promiseFn = useCallback( - (_: AsyncOptions, { signal }: AbortController) => { + (context: C, _: AsyncOptions, { signal }: AbortController) => { return doFetch(resource, { signal, ...init }) }, [identity] // eslint-disable-line react-hooks/exhaustive-deps ) + const deferFn = useCallback( - function([override]: FetchRunArgs, _: AsyncOptions, { signal }: AbortController) { + function(override: FetchRunArgs, _: AsyncOptions, { signal }: AbortController) { if (!override || isEvent(override)) { return doFetch(resource, { signal, ...init }) } @@ -322,11 +333,16 @@ function useAsyncFetch( }, [identity] // eslint-disable-line react-hooks/exhaustive-deps ) + + const fn = isDefer ? "deferFn" : "promiseFn" + const state = useAsync({ ...options, [fn]: isDefer ? deferFn : promiseFn, }) + useDebugValue(state, ({ counter, status }) => `[${counter}] ${status}`) + return state }