From 131646e7c36fd042f2f3870c8d87eb17439df276 Mon Sep 17 00:00:00 2001 From: Maarten Hus Date: Mon, 20 Jan 2020 10:52:04 +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 we no longer allow random extra properties that are unknown to `AsyncProps`. Instead only the new `context` of `AsyncProps` is passed. This means that the `[prop: string]: any` of `AsyncProps` is removed this makes TypeScript understand the props better. The other big change of this commit that `useAsync` no longer supports an overload. This means that the user can no longer do: ```ts const state = useAsync(loadPlayer, { context: { playerId: 1 } }) ``` But have to be more explicit: ```t const state = useAsync({ promiseFn: loadPlayer, context: { playerId: 1 } }) ``` These changes are of course a breaking change. Also now compiling TypeScript on `yarn test` this should prevent type errors from slipping in. Closes: #246 WIP: Trying to fix build asdf --- .gitignore | 2 + .travis.yml | 3 + docs/api/options.md | 16 +- docs/getting-started/upgrading.md | 201 ++++++++++++++++++ docs/getting-started/usage.md | 22 +- docs/guide/async-actions.md | 10 +- docs/guide/async-components.md | 10 +- docs/guide/optimistic-updates.md | 2 +- docs/guide/separating-view-logic.md | 8 +- docs/guide/server-side-rendering.md | 2 +- examples/basic-fetch/src/index.js | 4 +- examples/basic-hook/src/index.js | 2 +- examples/custom-instance/src/index.js | 4 +- examples/movie-app/src/App.js | 4 +- examples/with-abortcontroller/src/index.js | 2 +- examples/with-graphql/src/index.js | 2 +- .../with-nextjs/pages/examples/with-nextjs.js | 2 +- examples/with-react-native/App.js | 2 +- examples/with-suspense/src/index.js | 2 +- examples/with-typescript/package.json | 3 +- examples/with-typescript/src/App.tsx | 8 +- .../with-typescript/src/FetchHookExample.tsx | 2 +- package.json | 3 +- packages/react-async/src/Async.spec.js | 12 +- packages/react-async/src/Async.tsx | 36 ++-- packages/react-async/src/propTypes.ts | 1 + packages/react-async/src/reducer.ts | 6 +- packages/react-async/src/specs.js | 90 ++++---- packages/react-async/src/status.ts | 4 +- packages/react-async/src/types.ts | 23 +- packages/react-async/src/useAsync.spec.js | 29 +-- packages/react-async/src/useAsync.tsx | 69 +++--- 32 files changed, 406 insertions(+), 180 deletions(-) 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/.travis.yml b/.travis.yml index a3950cdf..d8f1c9b7 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,3 +8,6 @@ cache: script: yarn && yarn ci after_success: - bash <(curl -s https://codecov.io/bash) -e TRAVIS_NODE_VERSION +before_install: + - curl -o- -L https://yarnpkg.com/install.sh | bash -s -- --version 1.21.1 + - export PATH="$HOME/.yarn/bin:$PATH" diff --git a/docs/api/options.md b/docs/api/options.md index 6ca6ac61..e22223c9 100644 --- a/docs/api/options.md +++ b/docs/api/options.md @@ -5,6 +5,7 @@ These can be passed in an object to `useAsync(options)`, or as props to ` `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. -> Be aware that updating `promiseFn` will trigger it to cancel any pending promise and load the new promise. Passing an inline (arrow) function will cause it to change and reload on every render of the parent component. You can avoid this by defining the `promiseFn` value **outside** of the render method. If you need to pass variables to the `promiseFn`, pass them as additional props to ``, as `promiseFn` will be invoked with these props. Alternatively you can use `useCallback` or [memoize-one](https://github.com/alexreardon/memoize-one) to avoid unnecessary updates. +> Be aware that updating `promiseFn` will trigger it to cancel any pending promise and load the new promise. Passing an inline (arrow) function will cause it to change and reload on every render of the parent component. You can avoid this by defining the `promiseFn` value **outside** of the render method. If you need to pass variables to the `promiseFn`, pass them via the `context` props of ``, as `promiseFn` will be invoked with these props. Alternatively you can use `useCallback` or [memoize-one](https://github.com/alexreardon/memoize-one) to avoid unnecessary updates. ## `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 +133,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..7292022a 100644 --- a/docs/getting-started/upgrading.md +++ b/docs/getting-started/upgrading.md @@ -1,5 +1,206 @@ # Upgrading +## Upgrade to v11 + +### promiseFn and deferFn unification + +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`. Before you could pass any parameter +to `AsyncProps` and it would pass them to the `deferFn` and `promiseFn`, now you need to use +the `context` instead. + +For example before you could write: + +```jsx +useAsync({ promiseFn: loadPlayer, playerId: 1 }) +``` + +Now you must write: + +```jsx +useAsync({ promiseFn: loadPlayer, context: { playerId: 1 }}) +``` + +In the above example the context would be `{playerId: 1}`. + +This means that `promiseFn` now expects three parameters 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() +} + +// With hooks +const MyComponent = () => { + const state = useAsync({ promiseFn: loadPlayer, playerId: 1 }) +} + +// With the Async component + +``` + +In `11.0.0` you need to account for the three parameters: + +```jsx +import { useAsync } from "react-async" + +// Now it has three 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() +} + +// With hooks +const MyComponent = () => { + const state = useAsync({ promiseFn: loadPlayer, context: { playerId: 1 } }) +} + +// With the Async component + +``` + +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} + /> + )} + +) +``` + +### useAsync only accepts one prop + +Before in `10.0.0` you could call useAsync with multiple parameters, +the first argument would then be the `promiseFn` like this: + +```tsx +const state = useAsync(loadPlayer, { context: { playerId: 1 } }) +``` + +In `11.0.0` there is only one parameter. So the overload no longer works and you need to write this instead: + +```tsx +const state = useAsync({ promiseFn: loadPlayer, context: { playerId: 1 } }) +``` + +### WatchFn + +Another thing you need to be careful about is the `watchFn` you can no longer count on the fact that +unknown parameters are put into the `AsyncProps`. Before `< 10.0.0` you would write: + +```ts +useAsync({ + promiseFn, + count: 0, + watchFn: (props, prevProps) => props.count !== prevProps.count +}); +``` + +In `11.0.0` you need to use the `context` instead: + +```ts +useAsync({ + promiseFn, + context: { count: 0 }, + watchFn: (props, prevProps) => props.context.count !== prevProps.context.count +}); +``` + +## 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..98b6130d 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) @@ -37,7 +37,7 @@ Or using the shorthand version: ```jsx const MyComponent = () => { - const { data, error, isPending } = useAsync(loadPlayer, options) + const { data, error, isPending } = useAsync({ promiseFn: loadPlayer, context: { playerId: 1 } }) // ... } ``` @@ -85,14 +85,14 @@ 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() } const MyComponent = () => ( - + {({ data, error, isPending }) => { if (isPending) return "Loading..." if (error) return `Something went wrong: ${error.message}` @@ -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() @@ -128,7 +128,7 @@ const loadPlayer = async ({ playerId }, { signal }) => { const AsyncPlayer = createInstance({ promiseFn: loadPlayer }, "AsyncPlayer") const MyComponent = () => ( - + {player => `Hello ${player.name}`} ) @@ -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,14 +171,14 @@ 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() } const MyComponent = () => ( - + Loading... {data => ( diff --git a/docs/guide/async-actions.md b/docs/guide/async-actions.md index 3e96c7a1..2fa20a81 100644 --- a/docs/guide/async-actions.md +++ b/docs/guide/async-actions.md @@ -12,7 +12,7 @@ automatically invoked by React Async when rendering the component. Instead it wi import React, { useState } from "react" import { useAsync } from "react-async" -const subscribe = ([email], props, { signal }) => +const subscribe = ({email}, options, { signal }) => fetch("/newsletter", { method: "POST", body: JSON.stringify({ email }), signal }) const NewsletterForm = () => { @@ -21,7 +21,7 @@ const NewsletterForm = () => { const handleSubmit = event => { event.preventDefault() - run(email) + run({email}) } return ( @@ -36,11 +36,11 @@ const NewsletterForm = () => { } ``` -As you can see, the `deferFn` is invoked with 3 arguments: `args`, `props` and the AbortController. `args` is an array +As you can see, the `deferFn` is invoked with 3 arguments: `context`, `props` and the AbortController. `context` is an object representing the arguments that were passed to `run`. In this case we passed the `email`, so we can extract that from -the `args` array at the first index using [array destructuring] and pass it along to our `fetch` request. +the `context` prop using [object destructuring] and pass it along to our `fetch` request. -[array destructuring]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment#Array_destructuring +[object destructuring]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment#Object_destructuring ## Sending data with `useFetch` diff --git a/docs/guide/async-components.md b/docs/guide/async-components.md index e2b129ba..720ab9da 100644 --- a/docs/guide/async-components.md +++ b/docs/guide/async-components.md @@ -52,14 +52,14 @@ The above example, written with `useAsync`, would look like this: import React from "react" import { useAsync } from "react-async" -const fetchPerson = async ({ id }, { signal }) => { +const fetchPerson = async ({ id }, options, { signal }) => { const response = await fetch(`https://swapi.co/api/people/${id}/`, { signal }) if (!response.ok) throw new Error(response.status) return response.json() } const Person = ({ id }) => { - const { data, error } = useAsync({ promiseFn: fetchPerson, id }) + const { data, error } = useAsync({ promiseFn: fetchPerson, {context: id }}) if (error) return error.message if (data) return `Hi, my name is ${data.name}!` return null @@ -70,9 +70,9 @@ const App = () => { } ``` -Notice the incoming parameters to `fetchPerson`. The `promiseFn` will be invoked with a `props` object and an -`AbortController`. `props` are the options you passed to `useAsync`, which is why you can access the `id` property -using [object destructuring]. The `AbortController` is created by React Async to enable [abortable fetch], so the +Notice the incoming parameters to `fetchPerson`. The `promiseFn` will be invoked with a `context`, `props` object and an +`AbortController`. The `context` contains the `id` property which you can access +using [object destructuring]. `props` are the options you passed to `useAsync`. The `AbortController` is created by React Async to enable [abortable fetch], so the underlying request will be aborted when the promise is cancelled (e.g. when a new one starts or we leave the page). We have to pass its `AbortSignal` down to `fetch` in order to wire this up. diff --git a/docs/guide/optimistic-updates.md b/docs/guide/optimistic-updates.md index 0a92ba8c..cb031148 100644 --- a/docs/guide/optimistic-updates.md +++ b/docs/guide/optimistic-updates.md @@ -31,7 +31,7 @@ const AttendanceToggle = () => ( ) ``` -Here we have a switch to toggle attentance for an event. Clicking the toggle will most likely succeed, so we can predict +Here we have a switch to toggle attendance for an event. Clicking the toggle will most likely succeed, so we can predict the value it will have after completion (because we're just flipping a boolean). Notice that React Async accepts both a `promiseFn` and a `deferFn` at the same time. This allows you to combine data diff --git a/docs/guide/separating-view-logic.md b/docs/guide/separating-view-logic.md index cb828ac7..5b28701f 100644 --- a/docs/guide/separating-view-logic.md +++ b/docs/guide/separating-view-logic.md @@ -8,14 +8,14 @@ down the async state: import React from "react" import { useAsync } from "react-async" -const fetchPerson = async ({ id }, { signal }) => { +const fetchPerson = async ({ id }, option, { signal }) => { const response = await fetch(`https://swapi.co/api/people/${id}/`, { signal }) if (!response.ok) throw new Error(response.statusText) return response.json() } const Person = ({ id }) => { - const state = useAsync({ promiseFn: fetchPerson, id }) + const state = useAsync({ promiseFn: fetchPerson, { context: id }}) return children(state) } @@ -48,7 +48,7 @@ keywords in your JSX. import React from "react" import Async from "react-async" -const fetchPerson = async ({ id }, { signal }) => { +const fetchPerson = async ({ id }, options, { signal }) => { const response = await fetch(`https://swapi.co/api/people/${id}/`, { signal }) if (!response.ok) throw new Error(response.statusText) return response.json() @@ -56,7 +56,7 @@ const fetchPerson = async ({ id }, { signal }) => { const App = () => { return ( - + Loading... {error => } {data => } diff --git a/docs/guide/server-side-rendering.md b/docs/guide/server-side-rendering.md index 2daf6a82..e0939348 100644 --- a/docs/guide/server-side-rendering.md +++ b/docs/guide/server-side-rendering.md @@ -15,7 +15,7 @@ const fetchPerson = async ({ id }) => { } const Person = ({ id, person }) => ( - + Loading... {error => } {data => } diff --git a/examples/basic-fetch/src/index.js b/examples/basic-fetch/src/index.js index f36acd60..79111d1c 100644 --- a/examples/basic-fetch/src/index.js +++ b/examples/basic-fetch/src/index.js @@ -28,7 +28,7 @@ const UserDetails = ({ data }) => ( export const App = () => ( <> - + {({ data, error, isPending }) => { if (isPending) return if (error) return

{error.message}

@@ -37,7 +37,7 @@ export const App = () => ( }}
- + diff --git a/examples/basic-hook/src/index.js b/examples/basic-hook/src/index.js index 6cafd7fe..062e7d71 100644 --- a/examples/basic-hook/src/index.js +++ b/examples/basic-hook/src/index.js @@ -27,7 +27,7 @@ const UserDetails = ({ data }) => ( ) const User = ({ userId }) => { - const state = useAsync({ promiseFn: loadUser, debugLabel: `User ${userId}`, userId }) + const state = useAsync({ promiseFn: loadUser, debugLabel: `User ${userId}`, context: { userId } }) return ( <> diff --git a/examples/custom-instance/src/index.js b/examples/custom-instance/src/index.js index 9cb04ecb..ca4a3cc2 100644 --- a/examples/custom-instance/src/index.js +++ b/examples/custom-instance/src/index.js @@ -30,7 +30,7 @@ const UserDetails = ({ data }) => ( export const App = () => ( <> - + {({ data, error, isPending }) => { if (isPending) return if (error) return

{error.message}

@@ -38,7 +38,7 @@ export const App = () => ( return null }}
- + diff --git a/examples/movie-app/src/App.js b/examples/movie-app/src/App.js index 018bcf58..01a92d2e 100755 --- a/examples/movie-app/src/App.js +++ b/examples/movie-app/src/App.js @@ -81,7 +81,7 @@ const Details = ({ onBack, id }) => ( @@ -113,7 +113,7 @@ const Details = ({ onBack, id }) => ( diff --git a/examples/with-abortcontroller/src/index.js b/examples/with-abortcontroller/src/index.js index 9339d07b..382e3999 100644 --- a/examples/with-abortcontroller/src/index.js +++ b/examples/with-abortcontroller/src/index.js @@ -4,7 +4,7 @@ import DevTools from "react-async-devtools" import ReactDOM from "react-dom" import "./index.css" -const download = (args, props, controller) => +const download = (context, props, controller) => fetch(`https://reqres.in/api/users/1?delay=3`, { signal: controller.signal }) .then(res => (res.ok ? res : Promise.reject(res))) .then(res => res.json()) diff --git a/examples/with-graphql/src/index.js b/examples/with-graphql/src/index.js index 62f8e136..be809a4e 100644 --- a/examples/with-graphql/src/index.js +++ b/examples/with-graphql/src/index.js @@ -38,7 +38,7 @@ const MovieDetails = ({ data }) => ( ) const Movie = ({ slug }) => { - const state = useAsync({ promiseFn: loadMovie, debugLabel: slug, slug }) + const state = useAsync({ promiseFn: loadMovie, debugLabel: slug, context: { slug } }) return ( <> diff --git a/examples/with-nextjs/pages/examples/with-nextjs.js b/examples/with-nextjs/pages/examples/with-nextjs.js index 0d120617..4a95322f 100644 --- a/examples/with-nextjs/pages/examples/with-nextjs.js +++ b/examples/with-nextjs/pages/examples/with-nextjs.js @@ -27,7 +27,7 @@ class Hello extends React.Component { diff --git a/examples/with-react-native/App.js b/examples/with-react-native/App.js index d552ed37..7268987f 100644 --- a/examples/with-react-native/App.js +++ b/examples/with-react-native/App.js @@ -11,7 +11,7 @@ const loadUser = ({ userId }) => export default function App() { return ( - + Loading... diff --git a/examples/with-suspense/src/index.js b/examples/with-suspense/src/index.js index b5550997..6a80dd5c 100644 --- a/examples/with-suspense/src/index.js +++ b/examples/with-suspense/src/index.js @@ -31,7 +31,7 @@ const User = ({ userId }) => { suspense: true, promiseFn: loadUser, debugLabel: `User ${userId}`, - userId, + context: { userId } }) return ( <> diff --git a/examples/with-typescript/package.json b/examples/with-typescript/package.json index af4b56ce..86a6ccda 100644 --- a/examples/with-typescript/package.json +++ b/examples/with-typescript/package.json @@ -10,7 +10,8 @@ "pretest": "relative-deps", "start": "react-scripts start", "build": "react-scripts build", - "test": "react-scripts test", + "test": "yarn test:typescript && react-scripts test", + "test:typescript": "tsc --version && tsc --noEmit", "now-build": "SKIP_PREFLIGHT_CHECK=true react-scripts build" }, "dependencies": { 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/package.json b/package.json index 43548edf..72a45289 100644 --- a/package.json +++ b/package.json @@ -26,8 +26,9 @@ "test:compat": "yarn test:backwards && yarn test:forwards && yarn test:latest", "test:examples": "CI=1 lerna run --scope '*-example' test -- --passWithNoTests --watchAll=false", "test:chromatic": "chromatic --app-code iiua39bmt0j --build-script-name build:storybook --exit-zero-on-changes", + "test:typescript": "tsc --version && tsc --noEmit --project packages/react-async", "resolutions:fix-react": "jq '.resolutions.react = .devDependencies.react|.resolutions.\"react-dom\"=.devDependencies.react' package.json > package.json.new && mv package.json.new package.json && yarn install", - "ci": "yarn lint && yarn test:compat && yarn test:examples", + "ci": "yarn lint && yarn test:typescript && yarn test && yarn test:compat && yarn test:examples", "build:packages": "lerna run --scope 'react-async*' build", "build:examples": "lerna run --scope '*-example' build", "build:storybook": "build-storybook -o storybook", 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..e4d29241 100644 --- a/packages/react-async/src/Async.tsx +++ b/packages/react-async/src/Async.tsx @@ -43,7 +43,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 +54,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 +68,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 +90,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 +124,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 +217,22 @@ export function createInstance( .catch(this.onReject(this.counter)) } else if (promiseFn) { const props = { ...defaultOptions, ...this.props } - this.start(() => promiseFn(props, this.abortController)) + + // Cast to allow undefined + const context = props.context as C + + 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/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..5d18164b 100644 --- a/packages/react-async/src/specs.js +++ b/packages/react-async/src/specs.js @@ -226,8 +226,12 @@ export const withPromiseFn = (Async, abortCtrl) => () => { test("invokes `promiseFn` with props", () => { const promiseFn = jest.fn().mockReturnValue(resolveTo()) - render() - expect(promiseFn).toHaveBeenCalledWith({ promiseFn, anotherProp: "123" }, abortCtrl) + render() + expect(promiseFn).toHaveBeenCalledWith( + { anotherProp: "123" }, + { promiseFn, context: { anotherProp: "123" } }, + abortCtrl + ) }) test("sets `startedAt` when the promise starts", async () => { @@ -304,27 +308,18 @@ export const withPromiseFn = (Async, abortCtrl) => () => { } const promiseFn = jest.fn().mockReturnValue(resolveTo()) const { getByText } = render( - {count => } + {count => } ) expect(promiseFn).toHaveBeenCalledTimes(1) - expect(promiseFn).toHaveBeenLastCalledWith( - expect.objectContaining({ count: 0 }), - expect.any(Object) - ) + expect(promiseFn).toHaveBeenLastCalledWith(0, expect.any(Object), expect.any(Object)) fireEvent.click(getByText("increment")) expect(promiseFn).toHaveBeenCalledTimes(2) - expect(promiseFn).toHaveBeenLastCalledWith( - expect.objectContaining({ count: 1 }), - expect.any(Object) - ) + expect(promiseFn).toHaveBeenLastCalledWith(1, expect.any(Object), expect.any(Object)) expect(abortCtrl.abort).toHaveBeenCalled() abortCtrl.abort.mockClear() fireEvent.click(getByText("increment")) expect(promiseFn).toHaveBeenCalledTimes(3) - expect(promiseFn).toHaveBeenLastCalledWith( - expect.objectContaining({ count: 2 }), - expect.any(Object) - ) + expect(promiseFn).toHaveBeenLastCalledWith(2, expect.any(Object), expect.any(Object)) expect(abortCtrl.abort).toHaveBeenCalled() }) @@ -345,28 +340,22 @@ export const withPromiseFn = (Async, abortCtrl) => () => { } } const promiseFn = jest.fn().mockReturnValue(resolveTo()) - const watchFn = ({ count }, prevProps) => count !== prevProps.count && count === 2 + const watchFn = (props, prevProps) => + props.context.count !== prevProps.context.count && props.context.count === 2 const { getByText } = render( - {count => } + + {count => } + ) expect(promiseFn).toHaveBeenCalledTimes(1) - expect(promiseFn).toHaveBeenLastCalledWith( - expect.objectContaining({ count: 0 }), - expect.any(Object) - ) + expect(promiseFn).toHaveBeenLastCalledWith({ count: 0 }, expect.any(Object), expect.any(Object)) fireEvent.click(getByText("increment")) expect(promiseFn).toHaveBeenCalledTimes(1) - expect(promiseFn).toHaveBeenLastCalledWith( - expect.objectContaining({ count: 0 }), - expect.any(Object) - ) + expect(promiseFn).toHaveBeenLastCalledWith({ count: 0 }, expect.any(Object), expect.any(Object)) expect(abortCtrl.abort).not.toHaveBeenCalled() fireEvent.click(getByText("increment")) expect(promiseFn).toHaveBeenCalledTimes(2) - expect(promiseFn).toHaveBeenLastCalledWith( - expect.objectContaining({ count: 2 }), - expect.any(Object) - ) + expect(promiseFn).toHaveBeenLastCalledWith({ count: 2 }, expect.any(Object), expect.any(Object)) expect(abortCtrl.abort).toHaveBeenCalled() }) @@ -458,16 +447,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 +479,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 +504,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 +518,7 @@ export const withDeferFn = (Async, abortCtrl) => () => { {({ run, reload }) => { return (
- +
) @@ -527,11 +527,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..4e779d49 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,14 @@ 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 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..a74dd459 100644 --- a/packages/react-async/src/useAsync.spec.js +++ b/packages/react-async/src/useAsync.spec.js @@ -39,30 +39,19 @@ describe("useAsync", () => { describe("with `reducer`", withReducer(Async)) describe("with `dispatcher`", withDispatcher(Async)) - test("accepts [promiseFn, options] shorthand, with the former taking precedence", async () => { - const promiseFn1 = () => resolveTo("done") - const promiseFn2 = () => resolveTo("nope") - const Async = ({ children, ...props }) => children(useAsync(promiseFn1, props)) - const onResolve = jest.fn() - const component = ( - - {({ data }) => data || null} - - ) - const { findByText } = render(component) - await findByText("done") - expect(onResolve).toHaveBeenCalledWith("done") - }) - test("calling run() will always use the latest onResolve callback", async () => { const promiseFn = jest.fn(() => resolveTo()) const deferFn = () => resolveTo() function App() { const [count, setCount] = React.useState(0) + const { reload } = useAsync({ promiseFn, - count, watch: count, + context: { + a: 1, + count, + }, }) const { run } = useAsync({ deferFn, @@ -76,19 +65,19 @@ describe("useAsync", () => { ) } const { getByText } = render() - expect(promiseFn).toHaveBeenLastCalledWith(expect.objectContaining({ count: 0 }), abortCtrl) + expect(promiseFn).toHaveBeenLastCalledWith({ a: 1, count: 0 }, expect.any(Object), 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.any(Object), 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.any(Object), 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..5ef69ae2 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, @@ -33,27 +33,16 @@ 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(arg1: AsyncOptions | PromiseFn, arg2?: AsyncOptions): AsyncState { - const options: AsyncOptions = - typeof arg1 === "function" - ? { - ...arg2, - promiseFn: arg1, - } - : arg1 - +function useAsync(options: AsyncOptions): AsyncState { const counter = useRef(0) const isMounted = useRef(true) - const lastArgs = useRef(undefined) - const lastOptions = useRef>(options) + const lastArgs = useRef(undefined) + const lastOptions = useRef>(options) const lastPromise = useRef>(neverSettle) const abortController = useRef(new MockAbortController()) @@ -127,7 +116,7 @@ function useAsync(arg1: AsyncOptions | PromiseFn, arg2?: AsyncOptions { + execPromiseFn => { if ("AbortController" in globalScope) { abortController.current.abort() abortController.current = new globalScope.AbortController!() @@ -135,7 +124,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 +143,12 @@ function useAsync(arg1: AsyncOptions | PromiseFn, arg2?: AsyncOptions promiseFn(lastOptions.current, abortController.current)) + start(() => { + // Cast to allow undefined + const context = lastOptions.current.context as C + + return promiseFn(context, lastOptions.current, abortController.current) + }) .then(handleResolve(counter.current)) .catch(handleReject(counter.current)) } @@ -162,10 +156,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 +169,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 +259,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 +274,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 +288,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 +316,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 }