diff --git a/.all-contributorsrc b/.all-contributorsrc index 32276e75..734929b8 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -212,6 +212,25 @@ "contributions": [ "ideas" ] + }, + { + "login": "AlixWang", + "name": "AlixWang", + "avatar_url": "https://avatars0.githubusercontent.com/u/5417459?v=4", + "profile": "https://github.com/AlixWang", + "contributions": [ + "doc" + ] + }, + { + "login": "salolivares", + "name": "Sal Olivares", + "avatar_url": "https://avatars0.githubusercontent.com/u/1812749?v=4", + "profile": "http://salolivares.com", + "contributions": [ + "code", + "bug" + ] } ], "contributorsPerLine": 7, diff --git a/.circleci/config.yml b/.circleci/config.yml index 83010cee..09c824be 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -18,7 +18,7 @@ jobs: - restore_cache: name: Restore root dependencies from cache keys: - - root-dependencies-v1-{{ checksum "yarn.lock" }} + - root-dependencies-v1-{{ checksum "package.json" }} - run: name: Install dependencies command: yarn install @@ -27,7 +27,7 @@ jobs: command: yarn bootstrap - save_cache: name: Cache root dependencies - key: root-dependencies-v1-{{ checksum "yarn.lock" }} + key: root-dependencies-v1-{{ checksum "package.json" }} paths: - ~/.cache/yarn - run: yarn build:packages @@ -35,7 +35,6 @@ jobs: root: . paths: - node_modules - - examples - packages lint: <<: *defaults @@ -64,28 +63,6 @@ jobs: - attach_workspace: at: . - run: bash <(curl -s https://codecov.io/bash) - examples: - <<: *defaults - steps: - - checkout - - attach_workspace: - at: . - - run: - name: Bootstrap - command: yarn bootstrap - - run: - name: Build examples - command: yarn build:examples - - run: - name: Test examples - command: yarn test:examples --maxWorkers=2 - - run: - name: Deploy examples - command: | - if [ "${CIRCLE_BRANCH}" == "master" ]; then - yarn deploy:examples - fi - no_output_timeout: 30m chromatic: <<: *defaults steps: @@ -108,9 +85,6 @@ workflows: - coverage: requires: - test - - examples: - requires: - - build - chromatic: requires: - build diff --git a/.eslintrc b/.eslintrc index 70843243..02a3ac38 100644 --- a/.eslintrc +++ b/.eslintrc @@ -20,5 +20,17 @@ "react": { "version": "detect" } - } -} + }, + "overrides": [{ + "files": "packages/**/*.{ts,tsx}", + "parser": "@typescript-eslint/parser", + "plugins": ["@typescript-eslint"], + "parserOptions": { + "ecmaVersion": 6, + "sourceType": "module", + "ecmaFeatures": { + "modules": true + } + } + }] +} \ No newline at end of file diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml new file mode 100644 index 00000000..a0a9b5bf --- /dev/null +++ b/.github/workflows/examples.yml @@ -0,0 +1,39 @@ +name: Build, test and deploy examples + +on: [push] + +jobs: + build: + name: Build on node ${{ matrix.node_version }} and ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest] + node: [10] + steps: + - uses: actions/setup-node@v1 + with: + node-version: "10.x" + - uses: actions/checkout@v1 + - name: Cache node modules + id: cache-modules + uses: actions/cache@v1 + with: + path: node_modules + key: ${{ runner.OS }}-build-${{ hashFiles('**/package.json') }} + restore-keys: | + ${{ runner.OS }}-build-${{ env.cache-name }}- + ${{ runner.OS }}-build- + ${{ runner.OS }}- + - name: Install + if: steps.cache-modules.outputs.cache-hit != 'true' + run: yarn install + - name: Bootstrap + run: yarn bootstrap + - name: Build examples + run: yarn build:examples + - name: Test examples + run: yarn test:examples --maxWorkers=2 + - name: Deploy examples + if: github.ref == 'master' + run: yarn deploy:examples diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..449691b7 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +save-exact=true \ No newline at end of file diff --git a/.storybook/webpack.config.js b/.storybook/webpack.config.js index 3976b3d1..3f6fe527 100644 --- a/.storybook/webpack.config.js +++ b/.storybook/webpack.config.js @@ -1,4 +1,12 @@ module.exports = async ({ config }) => { delete config.module.rules[0].include + config.module.rules.push({ + test: /\.(ts|tsx)$/, + loader: require.resolve('babel-loader'), + options: { + presets: [['react-app', { flow: false, typescript: true }]], + }, + }); + config.resolve.extensions.push('.ts', '.tsx'); return config } diff --git a/.travis.yml b/.travis.yml index 06c60f3b..a3950cdf 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,6 +5,6 @@ cache: yarn: true directories: - node_modules -script: yarn ci +script: yarn && yarn ci after_success: - bash <(curl -s https://codecov.io/bash) -e TRAVIS_NODE_VERSION diff --git a/README.md b/README.md index 530aca3b..0426860a 100644 --- a/README.md +++ b/README.md @@ -17,25 +17,12 @@ montly downloads - minified size + minzipped size license
- - issues - - - pull requests - - - releases - - - contributors - -
circleci status @@ -52,10 +39,6 @@ DeepScan grade
- dependencies - devDependencies - peerDependencies -
@@ -74,6 +57,7 @@ Use it with `fetch`, Axios or other data fetching libraries, even GraphQL. - Zero dependencies - Works with promises, async/await and the Fetch API +- Now with experimental Suspense support - Choose between Render Props, Context-based helper components or the `useAsync` and `useFetch` hooks - Debug and develop every part of the loading sequence with the React Async DevTools - Provides convenient `isPending`, `startedAt`, `finishedAt`, et al metadata @@ -83,7 +67,7 @@ Use it with `fetch`, Axios or other data fetching libraries, even GraphQL. - Supports [abortable fetch] by providing an AbortController - Supports optimistic updates using `setData` - Supports server-side rendering through `initialValue` -- Comes with type definitions for TypeScript +- Written in TypeScript, ships with type definitions - Works well in React Native too! [abortable fetch]: https://developers.google.com/web/updates/2017/09/abortable-fetch @@ -111,6 +95,22 @@ Use it with `fetch`, Axios or other data fetching libraries, even GraphQL. - [State properties](https://docs.react-async.com/api/state) - [Helper components](https://docs.react-async.com/api/helpers) +## Guide + +- [Async components](https://docs.react-async.com/guide/async-components) +- [Separating view and logic](https://docs.react-async.com/guide/separating-view-logic) +- [Async actions](https://docs.react-async.com/guide/async-actions) +- [Optimistic updates](https://docs.react-async.com/guide/optimistic-updates) +- [Server-side rendering](https://docs.react-async.com/guide/server-side-rendering) + +## Contributing + +- [Introduction](https://docs.react-async.com/contributing/introduction) +- [Setting up](https://docs.react-async.com/contributing/setting-up) +- [Development](https://docs.react-async.com/contributing/development) +- [Testing](https://docs.react-async.com/contributing/testing) +- [Releasing](https://docs.react-async.com/contributing/releasing) + # Contributors Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): @@ -147,9 +147,19 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d Munir Ahmed Elsangedy
Munir Ahmed Elsangedy

🤔 + AlixWang
AlixWang

📖 + Sal Olivares
Sal Olivares

💻 🐛 This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! + +## Similar projects + +- [@slorber/react-async-hook](https://github.com/slorber/react-async-hook) +- [@dai-shi/react-hooks-async](https://github.com/dai-shi/react-hooks-async) +- [@cristovao-trevisan/async-resource](https://github.com/cristovao-trevisan/async-resource) +- [@ilyalesik/react-fetch-hook](https://github.com/ilyalesik/react-fetch-hook) +- [@marcin-piela/react-fetching-library](https://github.com/marcin-piela/react-fetching-library) diff --git a/babel.config.js b/babel.config.js index 49a48f94..4e9750dd 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,11 +1,11 @@ module.exports = { presets: ["@babel/preset-react"], - plugins: ["@babel/plugin-proposal-object-rest-spread"], + plugins: ["@babel/plugin-proposal-object-rest-spread", "@babel/plugin-proposal-class-properties"], env: { test: { - presets: ["@babel/preset-env", "@babel/preset-react"], - plugins: ["@babel/plugin-transform-runtime"], + presets: ["@babel/preset-env", "@babel/preset-react", "@babel/preset-typescript"], + plugins: ["@babel/plugin-transform-runtime", "@babel/plugin-proposal-class-properties"], }, }, } diff --git a/docs/_summary.md b/docs/_summary.md index 08efbe34..199c2ae4 100644 --- a/docs/_summary.md +++ b/docs/_summary.md @@ -4,14 +4,30 @@ ## Getting started -- [Installation](installation.md) -- [Upgrading](upgrading.md) -- [Usage](usage.md) -- [DevTools](devtools.md) +- [Installation](getting-started/installation.md) +- [Upgrading](getting-started/upgrading.md) +- [Usage](getting-started/usage.md) +- [DevTools](getting-started/devtools.md) ## API -- [Interfaces](interfaces.md) -- [Configuration options](options.md) -- [State properties](state.md) -- [Helper components](helpers.md) +- [Interfaces](api/interfaces.md) +- [Configuration options](api/options.md) +- [State properties](api/state.md) +- [Helper components](api/helpers.md) + +## Guide + +- [Async components](guide/async-components.md) +- [Separating view and logic](guide/separating-view-logic.md) +- [Async actions](guide/async-actions.md) +- [Optimistic updates](guide/optimistic-updates.md) +- [Server-side rendering](guide/server-side-rendering.md) + +## Contributing + +- [Introduction](contributing/introduction.md) +- [Setting up](contributing/setting-up.md) +- [Development](contributing/development.md) +- [Testing](contributing/testing.md) +- [Releasing](contributing/releasing.md) diff --git a/docs/helpers.md b/docs/api/helpers.md similarity index 100% rename from docs/helpers.md rename to docs/api/helpers.md diff --git a/docs/interfaces.md b/docs/api/interfaces.md similarity index 95% rename from docs/interfaces.md rename to docs/api/interfaces.md index dd6601f5..5ae00ffe 100644 --- a/docs/interfaces.md +++ b/docs/api/interfaces.md @@ -2,7 +2,8 @@ React Async provides several ways to use it. The classic interface is through the `` component, which is backwards compatible to React v16.3. More recent React applications will be using hooks, of which two are provided: -`useAsync` and `useFetch`. Functionally, `` and `useAsync` are equivalent. `useFetch` is a special type of `useAsync` which is tied to the native `fetch` API. +`useAsync` and `useFetch`. Functionally, `` and `useAsync` are equivalent. `useFetch` is a special version of +`useAsync` which is tied to the native `fetch` API. React Async accepts a wide range of [configuration options](options.md) and returns a set of [state props](state.md). The way you use these differs slightly between the `useAsync` and `useFetch` hooks, and the `` component. diff --git a/docs/options.md b/docs/api/options.md similarity index 100% rename from docs/options.md rename to docs/api/options.md diff --git a/docs/state.md b/docs/api/state.md similarity index 100% rename from docs/state.md rename to docs/api/state.md diff --git a/docs/contributing/development.md b/docs/contributing/development.md new file mode 100644 index 00000000..8e2da052 --- /dev/null +++ b/docs/contributing/development.md @@ -0,0 +1,45 @@ +# Development + +React Async is a library without visual parts. Only the DevTools have a user interface you can spin up in a browser. +Therefore the development workflow for the core library might be different from what you're used to. Generally, we use a +TDD approach: + +- Write a unit test for the new feature or bug you want to fix. Sometimes you can just extend an existing test. +- Fix the test by implementing the feature or bugfix. Now all tests should pass. +- Optionally refactor the code for performance, readability and style. Probably this will come up during PR review. + +We use the GitHub pull request workflow. In practice this means your workflow looks like this: + +- Fork the repo (or pull the latest upstream) under your own account. +- Make your changes, commit and push them. We don't enforce any commit message format. +- Open a pull request on the main repository against the `next` branch. Make sure to follow the template. +- We'll review your PR and will probably ask for some changes. +- Once ready, we'll merge your PR. +- Your changes will be in the next release. + +## Working with Storybook + +We use Storybook as a development environment for the DevTools. Spin it up using: + +```sh +yarn start:storybook +``` + +This should open up Storybook in a browser at http://localhost:6006/ +Run it side-by-side with `yarn test --watch` during development. See [Testing](#testing). + +## Working with the examples + +In the `examples` folder, you will find sample React applications that use React Async in various ways with various other libraries. Please add a new example when introducing a major new feature. Make sure to add it to `now.json` so it is automatically deployed when merged to `master`. + +To run sample examples on your local environments + +```sh +yarn build:examples +yarn test:examples +yarn start:examples +``` + +## Resolving issues + +Sometimes your dependencies might end up in a weird state, causing random issues, especially when working with the examples. In this case it often helps to run `yarn clean -y && yarn bootstrap`. This will delete `node_modules` from all packages/examples and do a clean install. diff --git a/docs/contributing/introduction.md b/docs/contributing/introduction.md new file mode 100644 index 00000000..d3303808 --- /dev/null +++ b/docs/contributing/introduction.md @@ -0,0 +1,92 @@ +# Contributing to React Async + +Thanks for your interest in improving React Async! Contributions of any kind are welcome. Please refer to this guide before opening an issue or pull request. + +This repo relies on Yarn workspaces, so you should [install](https://yarnpkg.com/en/docs/install) and use `yarn@1.3.2` or higher as the package manager for this project. + +## Development guide + +Please have the **_latest_** stable versions of the following on your machine + +- node +- yarn + +### Initial setup + +To start working on React Async, clone the repo and bootstrap the project: + +```sh +git clone https://github.com/async-library/react-async.git +cd react-async +yarn && yarn bootstrap && yarn test +``` + +Note that all work is done against the `next` branch, we only merge to `master` when doing a release. + +### Working with Storybook + +We use Storybook as a development environment, particularly for the DevTools. Spin it up using: + +```sh +yarn start:storybook +``` + +This should open up Storybook in a browser at http://localhost:6006/ +Run it side-by-side with `yarn test --watch` during development. See [Testing](#testing). + +### Linting + +Use `yarn lint` to verify your code style before committing. It's highly recommended to install the Prettier and ESLint plugins for your IDE. Travis CI will fail your build on lint errors. Configure VS Code with the following settings: + +```plaintext +"eslint.autoFixOnSave": true, +"eslint.packageManager": "yarn", +"eslint.options": { + "cache": true, + "cacheLocation": ".cache/eslint", + "extensions": [".js", ".jsx", ".mjs", ".json", ".ts", ".tsx"] +}, +"eslint.validate": [ + "javascript", + "javascriptreact", + {"language": "typescript", "autoFix": true }, + {"language": "typescriptreact", "autoFix": true } +], +"eslint.alwaysShowStatus": true +``` + +This should enable auto-fix for all source files, and give linting warnings and errors within your editor. + +### Testing + +Use the following command to test all packages in watch mode. Refer to the [Jest CLI options](https://jestjs.io/docs/en/cli#options) for details. + +```sh +yarn test:watch +``` + +In general, this is sufficient during development. Travis CI will apply a more rigorous set of tests. + +#### Testing for compatibility + +```sh +yarn test:compat +``` + +This runs all tests using various versions of `react` and `react-dom`, to check for compatibility with older/newer versions of React. This is what CircleCI and Travis run. + +### Working with the examples + +In the `examples` folder, you will find sample React applications that use React Async in various ways with various other libraries. Please add a new example when introducing a major new feature. Make sure to add it to `now.json` so it is automatically deployed when merged to `master`. + +To run sample examples on your local environments + +```sh +yarn build:examples +yarn test:examples +yarn start:examples +``` + +### Resolving issues + +Sometimes your dependencies might end up in a weird state, causing random issues, especially when working with the examples. In this case it often helps to run `yarn clean -y && yarn bootstrap`. This will delete `node_modules` from all packages/examples and do a clean install. diff --git a/docs/contributing/releasing.md b/docs/contributing/releasing.md new file mode 100644 index 00000000..d5b325ae --- /dev/null +++ b/docs/contributing/releasing.md @@ -0,0 +1,26 @@ +# Releasing + +All ongoing development is done on the `next` branch. When preparing for a release, we'll create a `release` branch +which will eventually be merged into `master`. This way, what's on `master` is always what's published on `npm`. + +Release management is currently a manual process, to be performed by core team members only. Here's the process: + +1. Create a `release` branch, usually based on `next`. +2. Open a pull request for `release` -> `master` +3. Write the release notes in the PR description. +4. Decide on the version number, taking care to follow semver. Do a pre-release before doing the actual release. +5. Run `yarn bump` to increment the version number and commit it as "Release vX.X.X" (using the correct version number). +6. Tag the release commit with `git tag vX.X.X` (using the correct version number). +7. Push the release commit AND tag: `git push --follow-tags` +8. Publish each package (in `./packages`) to npm using the script below. +9. Create a new release on GitHub and copy the release notes there. + +``` +yarn build:packages +cd packages/react-async +npm publish pkg +cd ../react-async-devtools +npm publish pkg +``` + +Take care to publish the `pkg` directory! diff --git a/docs/contributing/setting-up.md b/docs/contributing/setting-up.md new file mode 100644 index 00000000..196c0cba --- /dev/null +++ b/docs/contributing/setting-up.md @@ -0,0 +1,61 @@ +# Setting up your development environment + +## Prerequisites + +In order to develop React Async on your local machine, you'll need `git`, `node` and `yarn`. + +### Git + +To clone the repository, commit your changes and push them upstream, you'll need to have `git` [installed][install git]. + +[install git]: https://www.atlassian.com/git/tutorials/install-git + +### Node.js + +As a JavaScript project, we rely heavily on Node.js. It's recommended to use a version manager such as [fnm] for Mac / +Linux or [nvm-windows] for Windows to install the latest Node.js with. + +[fnm]: https://github.com/Schniz/fnm +[nvm-windows]: https://github.com/coreybutler/nvm-windows + +### Yarn + +This repo relies on Yarn workspaces, so you should [install][install yarn] and use `yarn@1.3.2` or higher as the package +manager for this project. + +[install yarn]: https://yarnpkg.com/en/docs/install + +## Project setup + +To start working on React Async, clone the repository and bootstrap the project by running the following commands +one-by-one: + +```sh +git clone https://github.com/async-library/react-async.git +cd react-async +yarn install +yarn bootstrap +yarn test +``` + +This should install all dependencies, build and link the react-async and react-async-devtools packages to the examples, +and finally run the unit tests. In the end it should succeed with a message (numbers may change): + +``` +Test Suites: 6 passed, 6 total +Tests: 136 passed, 136 total +``` + +> Note that all work is done against the `next` branch, we only merge to `master` when doing a release. + +## Editor setup + +We recommend using [Visual Studio Code](https://code.visualstudio.com/) with the following extensions: + +- [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) +- [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) +- [DeepScan](https://marketplace.visualstudio.com/items?itemName=DeepScan.vscode-deepscan) +- [Oceanic Plus](https://marketplace.visualstudio.com/items?itemName=marcoms.oceanic-plus) + +Make sure to enable `editor.formatOnSave`, so Prettier will automatically apply the right code style. For the full +immersive experience you can also install and use the [Overpass Mono](https://overpassfont.org/) font. diff --git a/docs/contributing/testing.md b/docs/contributing/testing.md new file mode 100644 index 00000000..67f6c834 --- /dev/null +++ b/docs/contributing/testing.md @@ -0,0 +1,36 @@ +# Testing + +Use the following command to test all packages in watch mode. Refer to the [Jest CLI options][jest options] for details. + +[jest options]: https://jestjs.io/docs/en/cli#options + +```sh +yarn test:watch +``` + +In general, this is sufficient during development. CircleCI and Travis will eventually apply a more rigorous set of +tests against your pull request, including the ones below. + +## Testing the examples + +Because React Async is only a piece in a bigger puzzle, testing for integration with other libraries is very important. +You can run the tests for all examples against your local changes with the following command: + +```sh +yarn test:examples +``` + +If you want to add integration tests for compatibility with another library, please add an example for it. + +## Testing for compatibility + +```sh +yarn test:compat +``` + +This runs all tests using various versions of `react` and `react-dom`, to check for compatibility with older/newer +versions of React. This is what CircleCI and Travis run. + +## Linting + +Use `yarn lint` to verify your code style before committing. It's highly recommended to install the Prettier and ESLint plugins for your IDE. CircleCI and Travis will fail your build on lint errors. diff --git a/docs/devtools.md b/docs/getting-started/devtools.md similarity index 82% rename from docs/devtools.md rename to docs/getting-started/devtools.md index 226a97df..af01f89d 100644 --- a/docs/devtools.md +++ b/docs/getting-started/devtools.md @@ -1,6 +1,7 @@ # DevTools -React Async comes with a separate DevTools package which helps you Debug and develop your asynchronous application states. You can install it from npm: +React Async comes with a separate DevTools package which helps you Debug and develop your asynchronous application +states. You can install it from npm: ```text npm install --save react-async-devtools diff --git a/docs/installation.md b/docs/getting-started/installation.md similarity index 66% rename from docs/installation.md rename to docs/getting-started/installation.md index eea0d1e0..1f87cdcb 100644 --- a/docs/installation.md +++ b/docs/getting-started/installation.md @@ -12,4 +12,5 @@ Or if you're using Yarn: yarn add react-async ``` -> This package requires `react` as a peer dependency. Please make sure to install that as well. If you want to use the `useAsync` hook, you'll need `react@16.8.0` or later. +> This package requires `react` as a peer dependency. Please make sure to install that as well. If you want to use the +> `useAsync` hook, you'll need `react@16.8.0` or later. diff --git a/docs/upgrading.md b/docs/getting-started/upgrading.md similarity index 100% rename from docs/upgrading.md rename to docs/getting-started/upgrading.md diff --git a/docs/usage.md b/docs/getting-started/usage.md similarity index 100% rename from docs/usage.md rename to docs/getting-started/usage.md diff --git a/docs/guide/async-actions.md b/docs/guide/async-actions.md new file mode 100644 index 00000000..3e96c7a1 --- /dev/null +++ b/docs/guide/async-actions.md @@ -0,0 +1,76 @@ +# Async actions + +Fetching data for display alone isn't sufficient for most applications. You'll often also want to submit data back to +the server, or handle other types of asynchronous actions. To enable this, React Async has the concept of a +[`deferFn`](../api/options.md#deferfn). + +Like `promiseFn`, a `deferFn` is a function that returns a Promise. The difference is that `deferFn` will not be +automatically invoked by React Async when rendering the component. Instead it will have to be triggered by calling the +[`run`](../api/state.md#run) function provided by React Async. + +```jsx +import React, { useState } from "react" +import { useAsync } from "react-async" + +const subscribe = ([email], props, { signal }) => + fetch("/newsletter", { method: "POST", body: JSON.stringify({ email }), signal }) + +const NewsletterForm = () => { + const { isPending, error, run } = useAsync({ deferFn: subscribe }) + const [email, setEmail] = useState("") + + const handleSubmit = event => { + event.preventDefault() + run(email) + } + + return ( +
+ setEmail(event.target.value)} /> + + {error &&

{error.message}

} +
+ ) +} +``` + +As you can see, the `deferFn` is invoked with 3 arguments: `args`, `props` and the AbortController. `args` is an array +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. + +[array destructuring]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment#Array_destructuring + +## Sending data with `useFetch` + +The above example can be simplified when we rely on [`useFetch`](../api/interfaces.md#usefetch-hook) instead of +constructing the request manually. + +```jsx +import React, { useState } from "react" +import { useFetch } from "react-async" + +const NewsletterForm = () => { + const { isPending, error, run } = useFetch("/newsletter", { method: "POST" }) + const [email, setEmail] = useState("") + + const handleSubmit = event => { + event.preventDefault() + run({ body: JSON.stringify({ email }) }) + } + + return ( +
+ setEmail(event.target.value)} /> + + {error &&

{error.message}

} +
+ ) +} +``` + +The [`run`](../api/state.md#run) function for `useFetch` is a little special because it allows you to override the +request's resource and other params. This way you can pass in the body, add dynamic headers or override the URL. diff --git a/docs/guide/async-components.md b/docs/guide/async-components.md new file mode 100644 index 00000000..e2b129ba --- /dev/null +++ b/docs/guide/async-components.md @@ -0,0 +1,81 @@ +# Async components + +The most common use case for React Async is data fetching. In single-page applications it's very common to dynamically +load some data from a backend. React Async makes it incredibly easy to set this up, without having to worry about the +details. + +The mental model of React Async is component-first. Rather than loading data high up in your application and passing it +down to a component for display, you perform the data loading at the component level. Such a component is called an +async component. An async component can render its state in a meaningful way like any other component, or be logic-only. +In that case it doesn't render any UI but instead passes its state down to its children. Such separation of concerns is +good practice. + +## Creating an async component with `useFetch` + +The easiest way to create an async component for data fetching is through the +[`useFetch` hook](../api/interfaces.md#usefetch-hook): + +```jsx +import React from "react" +import { useFetch } from "react-async" + +const Person = ({ id }) => { + const { data, error } = useFetch(`https://swapi.co/api/people/${id}/`, { + headers: { accept: "application/json" }, + }) + if (error) return error.message + if (data) return `Hi, my name is ${data.name}!` + return null +} + +const App = () => { + return +} +``` + +## More flexibility with `useAsync` + +For most data fetching needs, `useFetch` is sufficient. However, sometimes you may want to take full control, for +example if you want to combine multiple requests. In this case you can use the +[`useAsync` hook](../api/interfaces.md#useasync-hook). + +The core concept of `useAsync` (and React Async in general), is the [`promiseFn`](../api/options.md#promisefn): a +function that returns a `Promise`. It's the fundamental concept for modelling asynchronous operations. It enables React +Async to take control over scheduling, the Promise lifecycle and things like (re)starting an operation on user action or +other changes. We've deliberately chosen the `Promise` as our primitive, because it's natively supported and has various +utility methods like `Promise.all`. That's also why you'll find our terminology closely follows the Promise [states and +fates]. + +The above example, written with `useAsync`, would look like this: + +```jsx +import React from "react" +import { useAsync } from "react-async" + +const fetchPerson = async ({ id }, { 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 }) + if (error) return error.message + if (data) return `Hi, my name is ${data.name}!` + return null +} + +const App = () => { + return +} +``` + +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 +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. + +[states and fates]: https://github.com/domenic/promises-unwrapping/blob/master/docs/states-and-fates.md +[object destructuring]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment#Object_destructuring +[abortable fetch]: https://developers.google.com/web/updates/2017/09/abortable-fetch diff --git a/docs/guide/optimistic-updates.md b/docs/guide/optimistic-updates.md new file mode 100644 index 00000000..0a92ba8c --- /dev/null +++ b/docs/guide/optimistic-updates.md @@ -0,0 +1,40 @@ +# Optimistic updates + +A powerful pattern to improve your app's perceived performance is optimistic updates. When building an async action, you +might be able to predict the outcome of the operation. If so, you can implement optimistic updates by proactively +setting the `data` to the predicted value, when starting the async action. Once the action completes, it will update +`data` to the actual value, probably the same value as predicted. + +The following example uses both `promiseFn` and `deferFn` along with [`setData`](../api/state.md#setdata) to implement +optimistic updates. + +```jsx +import Async from "react-async" + +const getAttendance = () => fetch("/attendance").then(() => true, () => false) +const updateAttendance = ([attend]) => + fetch("/attendance", { method: attend ? "POST" : "DELETE" }).then(() => attend, () => !attend) + +const AttendanceToggle = () => ( + + {({ isPending, data: isAttending, run, setData }) => ( + { + setData(!isAttending) + run(!isAttending) + }} + disabled={isPending} + /> + )} + +) +``` + +Here we have a switch to toggle attentance 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 +fetching with performing actions. A typical example of where this is useful is with forms, where you first want to +populate the fields with current values from the database, and send the new values back when submitting the form. Do +note that `promiseFn` and `deferFn` operate on the same `data`, so they should both resolve to a similar kind of value. diff --git a/docs/guide/separating-view-logic.md b/docs/guide/separating-view-logic.md new file mode 100644 index 00000000..cb828ac7 --- /dev/null +++ b/docs/guide/separating-view-logic.md @@ -0,0 +1,75 @@ +# Separating view and logic + +It's generally good practice to separate view components from logic components. Async components should preferably be +logic-only. That means they don't render anything by themselves. Instead you can use the [render props] pattern to pass +down the async state: + +```jsx +import React from "react" +import { useAsync } from "react-async" + +const fetchPerson = async ({ id }, { 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 }) + return children(state) +} + +const App = () => { + return ( + + {({ isPending, data, error }) => { + if (isPending) return "Loading..." + if (error) return + if (data) return + return null + }} + + ) +} +``` + +> `ErrorMessage` and `Greeting` would be separate view components defined elsewhere. + +[render props]: https://reactjs.org/docs/render-props.html + +## Cleaning up the JSX + +You'll notice the render props pattern is very powerful, but can also lead to code that's hard to read and understand. +To make your JSX more declarative and less cluttered, you can use the [``](../api/interfaces.md#async-component) +component and its [state helpers](../api/helpers.md). These take away the need for `if/else` statements and `return` +keywords in your JSX. + +```jsx +import React from "react" +import Async from "react-async" + +const fetchPerson = async ({ id }, { signal }) => { + const response = await fetch(`https://swapi.co/api/people/${id}/`, { signal }) + if (!response.ok) throw new Error(response.statusText) + return response.json() +} + +const App = () => { + return ( + + Loading... + {error => } + {data => } + + ) +} +``` + +You should know that these helper components do not have to be direct children of the `` component. Because they +are automatically wired up using [Context], they can be placed anywhere down the component tree, so long as they are +descendants. You can also use helpers of the same type, multiple times. + +Stand-alone versions of `` and the like are also available. However, these must be wired up manually by +passing the `state` prop and are therefore only really useful when combined with one of the async hooks. + +[context]: https://reactjs.org/docs/context.html diff --git a/docs/guide/server-side-rendering.md b/docs/guide/server-side-rendering.md new file mode 100644 index 00000000..2daf6a82 --- /dev/null +++ b/docs/guide/server-side-rendering.md @@ -0,0 +1,33 @@ +# Server-side rendering + +There's a good chance you're using React with Server-side rendering (SSR), as many applications require this to be +successful. If you happen to be using Next.js, it's really easy to integrate React Async. The crux is in setting a +[`initialValue`](../api/options.md#initialvalue), which is fetched server-side for initial page loads and passed along +through rehydration. + +```jsx +import fetch from "isomorphic-unfetch" + +const fetchPerson = async ({ id }) => { + const response = await fetch(`https://swapi.co/api/people/${id}/`) + if (!response.ok) throw new Error(response.status) + return response.json() +} + +const Person = ({ id, person }) => ( + + Loading... + {error => } + {data => } + +) + +Person.getInitialProps = async ({ req }) => { + const id = req.params.id + const person = await fetchPerson({ id }) + return { id, person } +} +``` + +If React Async is provided an `initialValue`, it will not invoke the `promiseFn` on mount. Instead it will use the +`initialValue` to immediately set `data` or `error`, and render accordingly. diff --git a/docs/introduction.md b/docs/introduction.md index 695a4e37..7b3e2879 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -1,17 +1,32 @@ # Introduction -React Async is a utility belt for declarative promise resolution and data fetching. It makes it easy to handle asynchronous UI states, without assumptions about the shape of your data or the type of request. React Async consists of a React component and several hooks. You can use it with `fetch`, Axios or other data fetching libraries, even GraphQL. +React Async is a utility belt for declarative promise resolution and data fetching. It makes it easy to handle +asynchronous UI states, without assumptions about the shape of your data or the type of request. React Async consists of +a React component and several hooks. You can use it with `fetch`, Axios or other data fetching libraries, even GraphQL. ## Rationale -React Async is different in that it tries to resolve data as close as possible to where it will be used, while using declarative syntax, using just JSX and native promises. This is in contrast to systems like Redux where you would configure any data fetching or updates on a higher \(application global\) level, using a special construct \(actions/reducers\). +React Async is different in that it tries to resolve data as close as possible to where it will be used, while using +declarative syntax, using just JSX and native promises. This is in contrast to systems like Redux where you would +configure any data fetching or updates on a higher (application global) level, using a special construct +(actions/reducers). -React Async works well even in larger applications with multiple or nested data dependencies. It encourages loading data on-demand and in parallel at component level instead of in bulk at the route/page level. It's entirely decoupled from your routes, so it works well in complex applications that have a dynamic routing model or don't use routes at all. +React Async works well even in larger applications with multiple or nested data dependencies. It encourages loading data +on-demand and in parallel at component level instead of in bulk at the route/page level. It's entirely decoupled from +your routes, so it works well in complex applications that have a dynamic routing model or don't use routes at all. React Async is promise-based, so you can resolve anything you want, not just `fetch` requests. ## Concurrent React and Suspense -The React team is currently working on a large rewrite called [Concurrent React](https://github.com/sw-yx/fresh-concurrent-react/blob/master/Intro.md#introduction-what-is-concurrent-react), previously known as "Async React". Part of this rewrite is Suspense, which is a generic way for components to suspend rendering while they load data from a cache. It can render a fallback UI while loading data, much like ``. +The React team is currently working on a large rewrite called [Concurrent React], previously known as "Async React". +Part of this rewrite is Suspense, which is a generic way for components to suspend rendering while they load data from a +cache. It can render a fallback UI while loading data, much like ``. -React Async has no direct relation to Concurrent React. They are conceptually close, but not the same. React Async is meant to make dealing with asynchronous business logic easier. Concurrent React will make those features have less impact on performance and usability. When Suspense lands, React Async will make full use of Suspense features. In fact, you can already **start using React Async right now**, and in a later update, you'll **get Suspense features for free**. In fact, React Async already has experimental support for Suspense, by passing the `suspense` option. +React Async has no direct relation to Concurrent React. They are conceptually close, but not the same. React Async is +meant to make dealing with asynchronous business logic easier. Concurrent React will make those features have less +impact on performance and usability. When Suspense lands, React Async will make full use of Suspense features. In fact, +you can already **start using React Async right now**, and in a later update, you'll **get Suspense features for free**. +In fact, React Async already has experimental support for Suspense, by passing the `suspense` option. + +[concurrent react]: https://github.com/sw-yx/fresh-concurrent-react/blob/master/Intro.md#introduction-what-is-concurrent-react diff --git a/examples/basic-fetch/package.json b/examples/basic-fetch/package.json index 4eaff85a..36dad833 100644 --- a/examples/basic-fetch/package.json +++ b/examples/basic-fetch/package.json @@ -1,6 +1,6 @@ { "name": "basic-fetch-example", - "version": "9.0.0", + "version": "10.0.0-alpha.0", "private": true, "homepage": "https://react-async.async-library.now.sh/examples/basic-fetch", "scripts": { @@ -14,14 +14,14 @@ "now-build": "SKIP_PREFLIGHT_CHECK=true react-scripts build" }, "dependencies": { - "react": "16.10.2", - "react-async": "^9.0.0", - "react-async-devtools": "^9.0.0", - "react-dom": "16.10.2", + "react": "16.11.0", + "react-async": "^10.0.0-alpha.0", + "react-async-devtools": "^10.0.0-alpha.0", + "react-dom": "16.11.0", "react-scripts": "3.2.0" }, "devDependencies": { - "relative-deps": "0.1.2" + "relative-deps": "0.2.0" }, "relativeDependencies": { "react-async": "../../packages/react-async/pkg", diff --git a/examples/basic-hook/package.json b/examples/basic-hook/package.json index 02e9ee0b..400111a5 100644 --- a/examples/basic-hook/package.json +++ b/examples/basic-hook/package.json @@ -1,6 +1,6 @@ { "name": "basic-hook-example", - "version": "9.0.0", + "version": "10.0.0-alpha.0", "private": true, "homepage": "https://react-async.async-library.now.sh/examples/basic-hook", "scripts": { @@ -14,14 +14,14 @@ "now-build": "SKIP_PREFLIGHT_CHECK=true react-scripts build" }, "dependencies": { - "react": "16.10.2", - "react-async": "^9.0.0", - "react-async-devtools": "^9.0.0", - "react-dom": "16.10.2", + "react": "16.11.0", + "react-async": "^10.0.0-alpha.0", + "react-async-devtools": "^10.0.0-alpha.0", + "react-dom": "16.11.0", "react-scripts": "3.2.0" }, "devDependencies": { - "relative-deps": "0.1.2" + "relative-deps": "0.2.0" }, "relativeDependencies": { "react-async": "../../packages/react-async/pkg", diff --git a/examples/custom-instance/package.json b/examples/custom-instance/package.json index 09606bf4..6c16d51b 100644 --- a/examples/custom-instance/package.json +++ b/examples/custom-instance/package.json @@ -1,6 +1,6 @@ { "name": "custom-instance-example", - "version": "9.0.0", + "version": "10.0.0-alpha.0", "private": true, "homepage": "https://react-async.async-library.now.sh/examples/custom-instance", "scripts": { @@ -14,14 +14,14 @@ "now-build": "SKIP_PREFLIGHT_CHECK=true react-scripts build" }, "dependencies": { - "react": "16.10.2", - "react-async": "^9.0.0", - "react-async-devtools": "^9.0.0", - "react-dom": "16.10.2", + "react": "16.11.0", + "react-async": "^10.0.0-alpha.0", + "react-async-devtools": "^10.0.0-alpha.0", + "react-dom": "16.11.0", "react-scripts": "3.2.0" }, "devDependencies": { - "relative-deps": "0.1.2" + "relative-deps": "0.2.0" }, "relativeDependencies": { "react-async": "../../packages/react-async/pkg", diff --git a/examples/movie-app/package.json b/examples/movie-app/package.json index 4481c126..0b68cbfc 100644 --- a/examples/movie-app/package.json +++ b/examples/movie-app/package.json @@ -1,6 +1,6 @@ { "name": "movie-app-example", - "version": "9.0.0", + "version": "10.0.0-alpha.0", "private": true, "homepage": "https://react-async.async-library.now.sh/examples/movie-app", "scripts": { @@ -14,14 +14,14 @@ "now-build": "SKIP_PREFLIGHT_CHECK=true react-scripts build" }, "dependencies": { - "react": "16.10.2", - "react-async": "^9.0.0", - "react-async-devtools": "^9.0.0", - "react-dom": "16.10.2", + "react": "16.11.0", + "react-async": "^10.0.0-alpha.0", + "react-async-devtools": "^10.0.0-alpha.0", + "react-dom": "16.11.0", "react-scripts": "3.2.0" }, "devDependencies": { - "relative-deps": "0.1.2" + "relative-deps": "0.2.0" }, "relativeDependencies": { "react-async": "../../packages/react-async/pkg", diff --git a/examples/with-abortcontroller/package.json b/examples/with-abortcontroller/package.json index 0ea0793c..9b2a128e 100644 --- a/examples/with-abortcontroller/package.json +++ b/examples/with-abortcontroller/package.json @@ -1,6 +1,6 @@ { "name": "with-abortcontroller-example", - "version": "9.0.0", + "version": "10.0.0-alpha.0", "private": true, "homepage": "https://react-async.async-library.now.sh/examples/with-abortcontroller", "scripts": { @@ -14,14 +14,14 @@ "now-build": "SKIP_PREFLIGHT_CHECK=true react-scripts build" }, "dependencies": { - "react": "16.10.2", - "react-async": "^9.0.0", - "react-async-devtools": "^9.0.0", - "react-dom": "16.10.2", + "react": "16.11.0", + "react-async": "^10.0.0-alpha.0", + "react-async-devtools": "^10.0.0-alpha.0", + "react-dom": "16.11.0", "react-scripts": "3.2.0" }, "devDependencies": { - "relative-deps": "0.1.2" + "relative-deps": "0.2.0" }, "relativeDependencies": { "react-async": "../../packages/react-async/pkg", diff --git a/examples/with-graphql/.env b/examples/with-graphql/.env new file mode 100644 index 00000000..7d910f14 --- /dev/null +++ b/examples/with-graphql/.env @@ -0,0 +1 @@ +SKIP_PREFLIGHT_CHECK=true \ No newline at end of file diff --git a/examples/with-graphql/README.md b/examples/with-graphql/README.md new file mode 100644 index 00000000..1fdc7d4e --- /dev/null +++ b/examples/with-graphql/README.md @@ -0,0 +1,7 @@ +# GraphQL with useAsync + +This demonstrates how to use the `useAsync` hook with GraphQL. + + + live demo + diff --git a/examples/with-graphql/package.json b/examples/with-graphql/package.json new file mode 100644 index 00000000..5880908a --- /dev/null +++ b/examples/with-graphql/package.json @@ -0,0 +1,43 @@ +{ + "name": "with-graphql-example", + "version": "10.0.0-alpha.0", + "private": true, + "homepage": "https://react-async.async-library.now.sh/examples/with-graphql", + "scripts": { + "postinstall": "relative-deps", + "prestart": "relative-deps", + "prebuild": "relative-deps", + "pretest": "relative-deps", + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test", + "now-build": "SKIP_PREFLIGHT_CHECK=true react-scripts build" + }, + "dependencies": { + "graphql-request": "1.8.2", + "react": "16.11.0", + "react-async": "^10.0.0-alpha.0", + "react-async-devtools": "^10.0.0-alpha.0", + "react-dom": "16.11.0", + "react-scripts": "3.2.0" + }, + "devDependencies": { + "relative-deps": "0.2.0" + }, + "relativeDependencies": { + "react-async": "../../packages/react-async/pkg", + "react-async-devtools": "../../packages/react-async-devtools/pkg" + }, + "eslintConfig": { + "extends": "react-app" + }, + "browserslist": [ + ">0.2%", + "not dead", + "not ie <= 11", + "not op_mini all" + ], + "engines": { + "node": ">=8" + } +} diff --git a/examples/with-graphql/public/favicon.ico b/examples/with-graphql/public/favicon.ico new file mode 100644 index 00000000..a11777cc Binary files /dev/null and b/examples/with-graphql/public/favicon.ico differ diff --git a/examples/with-graphql/public/index.html b/examples/with-graphql/public/index.html new file mode 100644 index 00000000..b8317902 --- /dev/null +++ b/examples/with-graphql/public/index.html @@ -0,0 +1,13 @@ + + + + + + + React App + + + +
+ + diff --git a/examples/with-graphql/src/index.css b/examples/with-graphql/src/index.css new file mode 100644 index 00000000..a1f38b9e --- /dev/null +++ b/examples/with-graphql/src/index.css @@ -0,0 +1,19 @@ +body { + margin: 20px; + padding: 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", + "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.movie { + margin-bottom: 40px; + line-height: 1.5em; +} +.movie dt { + font-weight: bold; +} +.movie dd { + margin-left: 10px; +} diff --git a/examples/with-graphql/src/index.js b/examples/with-graphql/src/index.js new file mode 100644 index 00000000..62f8e136 --- /dev/null +++ b/examples/with-graphql/src/index.js @@ -0,0 +1,62 @@ +import React from "react" +import { useAsync, IfPending, IfFulfilled, IfRejected } from "react-async" +import ReactDOM from "react-dom" +import DevTools from "react-async-devtools" +import { request } from "graphql-request" +import "./index.css" + +const query = /* GraphQL */ ` + query getMovie($slug: String!) { + Movie(slug: $slug) { + title + releaseDate + actors { + id + name + } + } + } +` + +const loadMovie = async variables => { + const { Movie } = await request("https://api.graph.cool/simple/v1/movies", query, variables) + return Movie +} + +const MovieDetails = ({ data }) => ( +
+

{data.title}

+
+
Released
+
{data.releaseDate.substr(0, 10)}
+
Featuring
+ {data.actors.map(actor => ( +
{actor.name}
+ ))} +
+
+) + +const Movie = ({ slug }) => { + const state = useAsync({ promiseFn: loadMovie, debugLabel: slug, slug }) + return ( + <> + +

Loading...

+
+ {data => } + {error =>

{error.message}

}
+ + ) +} + +export const App = () => ( + <> + + + + + +) + +if (process.env.NODE_ENV !== "test") ReactDOM.render(, document.getElementById("root")) diff --git a/examples/with-graphql/src/index.test.js b/examples/with-graphql/src/index.test.js new file mode 100644 index 00000000..2920612e --- /dev/null +++ b/examples/with-graphql/src/index.test.js @@ -0,0 +1,9 @@ +import React from "react" +import ReactDOM from "react-dom" +import { App } from "./" + +it("renders without crashing", () => { + const div = document.createElement("div") + ReactDOM.render(, div) + ReactDOM.unmountComponentAtNode(div) +}) diff --git a/examples/with-nextjs/package.json b/examples/with-nextjs/package.json index baa44d7b..a97c4d8b 100644 --- a/examples/with-nextjs/package.json +++ b/examples/with-nextjs/package.json @@ -1,6 +1,6 @@ { "name": "with-nextjs-example", - "version": "9.0.0", + "version": "10.0.0-alpha.0", "private": true, "main": "index.js", "scripts": { @@ -16,14 +16,14 @@ }, "dependencies": { "isomorphic-fetch": "2.2.1", - "next": "9.0.8", - "react": "16.10.2", - "react-async": "^9.0.0", - "react-async-devtools": "^9.0.0", - "react-dom": "16.10.2" + "next": "9.1.3", + "react": "16.11.0", + "react-async": "^10.0.0-alpha.0", + "react-async-devtools": "^10.0.0-alpha.0", + "react-dom": "16.11.0" }, "devDependencies": { - "relative-deps": "0.1.2" + "relative-deps": "0.2.0" }, "relativeDependencies": { "react-async": "../../packages/react-async/pkg", diff --git a/examples/with-react-native/package-lock.json b/examples/with-react-native/package-lock.json index ee2c4be5..8312f650 100644 --- a/examples/with-react-native/package-lock.json +++ b/examples/with-react-native/package-lock.json @@ -7968,5 +7968,6 @@ "camelcase": "^4.1.0" } } - } + }, + "version": "10.0.0-alpha.0" } diff --git a/examples/with-react-native/package.json b/examples/with-react-native/package.json index 0a791313..8dc4ca35 100644 --- a/examples/with-react-native/package.json +++ b/examples/with-react-native/package.json @@ -1,6 +1,6 @@ { "name": "with-react-native-example", - "version": "9.0.0", + "version": "10.0.0-alpha.0", "private": true, "main": "node_modules/expo/AppEntry.js", "scripts": { @@ -15,16 +15,16 @@ "web": "expo start --web" }, "dependencies": { - "expo": "35.0.0", - "react": "16.10.2", - "react-async": "^9.0.0", - "react-dom": "16.10.2", + "expo": "35.0.1", + "react": "16.11.0", + "react-async": "^10.0.0-alpha.0", + "react-dom": "16.11.0", "react-native": "https://github.com/expo/react-native/archive/sdk-33.0.0.tar.gz", "react-native-web": "0.11.7" }, "devDependencies": { - "babel-preset-expo": "7.0.0", - "relative-deps": "0.1.2" + "babel-preset-expo": "7.1.0", + "relative-deps": "0.2.0" }, "relativeDependencies": { "react-async": "../../packages/react-async/pkg" diff --git a/examples/with-react-router/package.json b/examples/with-react-router/package.json index 09130dff..90760514 100644 --- a/examples/with-react-router/package.json +++ b/examples/with-react-router/package.json @@ -1,6 +1,6 @@ { "name": "with-react-router-example", - "version": "9.0.0", + "version": "10.0.0-alpha.0", "private": true, "main": "index.js", "scripts": { @@ -11,17 +11,17 @@ "build": "parcel build index.html" }, "dependencies": { - "react": "16.10.2", - "react-async": "^9.0.0", - "react-async-devtools": "^9.0.0", - "react-dom": "16.10.2", + "react": "16.11.0", + "react-async": "^10.0.0-alpha.0", + "react-async-devtools": "^10.0.0-alpha.0", + "react-dom": "16.11.0", "react-router-dom": "5.1.2" }, "devDependencies": { - "@babel/core": "7.6.2", - "@babel/preset-react": "7.0.0", - "parcel-bundler": "1.12.3", - "relative-deps": "0.1.2" + "@babel/core": "7.7.2", + "@babel/preset-react": "7.7.0", + "parcel-bundler": "1.12.4", + "relative-deps": "0.2.0" }, "relativeDependencies": { "react-async": "../../packages/react-async/pkg", diff --git a/examples/with-suspense/package.json b/examples/with-suspense/package.json index 561bace0..0bd54c9d 100644 --- a/examples/with-suspense/package.json +++ b/examples/with-suspense/package.json @@ -1,6 +1,6 @@ { "name": "with-suspense-example", - "version": "9.0.0", + "version": "10.0.0-alpha.0", "private": true, "homepage": "https://react-async.async-library.now.sh/examples/with-suspense", "scripts": { @@ -14,14 +14,14 @@ "now-build": "SKIP_PREFLIGHT_CHECK=true react-scripts build" }, "dependencies": { - "react": "16.10.2", - "react-async": "^9.0.0", - "react-async-devtools": "^9.0.0", - "react-dom": "16.10.2", + "react": "16.11.0", + "react-async": "^10.0.0-alpha.0", + "react-async-devtools": "^10.0.0-alpha.0", + "react-dom": "16.11.0", "react-scripts": "3.2.0" }, "devDependencies": { - "relative-deps": "0.1.2" + "relative-deps": "0.2.0" }, "relativeDependencies": { "react-async": "../../packages/react-async/pkg", diff --git a/examples/with-typescript/package.json b/examples/with-typescript/package.json index 85b93b31..c310b307 100644 --- a/examples/with-typescript/package.json +++ b/examples/with-typescript/package.json @@ -1,6 +1,6 @@ { "name": "with-typescript-example", - "version": "9.0.0", + "version": "10.0.0-alpha.0", "private": true, "homepage": "https://react-async.async-library.now.sh/examples/with-typescript", "scripts": { @@ -14,19 +14,19 @@ "now-build": "SKIP_PREFLIGHT_CHECK=true react-scripts build" }, "dependencies": { - "@types/node": "12.7.11", - "@types/react": "16.9.5", - "@types/react-dom": "16.9.1", - "react": "16.10.2", - "react-async": "^9.0.0", - "react-async-devtools": "^9.0.0", - "react-dom": "16.10.2", + "@types/node": "12.12.7", + "@types/react": "16.9.11", + "@types/react-dom": "16.9.4", + "react": "16.11.0", + "react-async": "^10.0.0-alpha.0", + "react-async-devtools": "^10.0.0-alpha.0", + "react-dom": "16.11.0", "react-scripts": "3.2.0", - "typescript": "3.6.3" + "typescript": "3.7.2" }, "devDependencies": { - "@types/jest": "24.0.18", - "relative-deps": "0.1.2" + "@types/jest": "24.0.22", + "relative-deps": "0.2.0" }, "relativeDependencies": { "react-async": "../../packages/react-async/pkg", diff --git a/jest.config.js b/jest.config.js index ef3484d5..b5124c80 100644 --- a/jest.config.js +++ b/jest.config.js @@ -17,7 +17,9 @@ module.exports = { coverageDirectory: "/coverage", verbose: true, bail: true, - transform: { "^.+\\.js$": "babel-jest" }, + transform: { + "^.+\\.[tj]sx?$": "babel-jest", + }, projects: ["/packages/*"], setupFiles: ["/jest.setup.js"], testPathIgnorePatterns: ["/node_modules/", "/pkg/"], diff --git a/lerna.json b/lerna.json index 179c5e4b..8d24444b 100644 --- a/lerna.json +++ b/lerna.json @@ -10,5 +10,5 @@ ] }, "useWorkspaces": true, - "version": "9.0.0" + "version": "10.0.0-alpha.0" } diff --git a/now.json b/now.json index 4cb6a47b..89ebebae 100644 --- a/now.json +++ b/now.json @@ -28,6 +28,11 @@ "use": "@now/static-build", "config": { "distDir": "build" } }, + { + "src": "examples/with-graphql/package.json", + "use": "@now/static-build", + "config": { "distDir": "build" } + }, { "src": "examples/with-typescript/package.json", "use": "@now/static-build", diff --git a/package.json b/package.json index dd67436c..bce91fd2 100644 --- a/package.json +++ b/package.json @@ -15,17 +15,18 @@ "start": "run-p start:*", "start:examples": "now dev", "start:storybook": "start-storybook -p 6006", - "lint": "eslint packages/*/src/*.js", + "lint": "eslint packages/*/src/*.{js,ts,tsx}", "test": "jest packages/*/src/*.spec.js", "test:watch": "yarn test -- --watch", "test:devtools": "jest react-async-devtools/src", "test:components": "jest src/Async.spec.js --collectCoverageFrom=src/Async.js", - "test:backwards": "yarn add -D -W react@16.3.1 react-dom@16.3.1 && yarn test:components", - "test:forwards": "yarn add -D -W react@next react-dom@next && yarn test", - "test:latest": "yarn add -D -W react@latest react-dom@latest && yarn test", + "test:backwards": "yarn add -D -W react@16.3.1 react-dom@16.3.1 && yarn resolutions:fix-react && yarn test:components", + "test:forwards": "yarn add -D -W react@next react-dom@next && yarn resolutions:fix-react && yarn test", + "test:latest": "yarn add -D -W react@latest react-dom@latest && yarn resolutions:fix-react && yarn test", "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", + "test:chromatic": "chromatic --app-code iiua39bmt0j --build-script-name build:storybook --exit-zero-on-changes", + "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", "build:packages": "lerna run --scope 'react-async*' build", "build:examples": "lerna run --scope '*-example' build", @@ -35,39 +36,51 @@ "postbump": "yarn build:packages" }, "devDependencies": { - "@babel/core": "7.6.2", + "@babel/core": "7.7.2", + "@babel/plugin-proposal-class-properties": "7.7.0", "@babel/plugin-proposal-object-rest-spread": "7.6.2", "@babel/plugin-transform-runtime": "7.6.2", - "@babel/preset-env": "7.6.2", - "@babel/preset-react": "7.0.0", + "@babel/preset-env": "7.7.1", + "@babel/preset-react": "7.7.0", + "@babel/preset-typescript": "7.7.2", "@pika/pack": "0.5.0", - "@pika/plugin-build-node": "0.6.1", - "@pika/plugin-build-types": "0.6.1", - "@pika/plugin-build-web": "0.6.1", - "@pika/plugin-standard-pkg": "0.6.1", - "@storybook/react": "5.2.1", - "@testing-library/jest-dom": "4.1.0", - "@testing-library/react": "9.3.0", + "@pika/plugin-build-node": "0.7.1", + "@pika/plugin-build-types": "0.7.1", + "@pika/plugin-build-umd": "0.7.1", + "@pika/plugin-build-web": "0.7.1", + "@pika/plugin-bundle-types": "0.7.1", + "@pika/plugin-standard-pkg": "0.7.1", + "@pika/plugin-ts-standard-pkg": "0.7.1", + "@storybook/react": "5.2.6", + "@testing-library/jest-dom": "4.2.4", + "@testing-library/react": "9.3.2", + "@typescript-eslint/eslint-plugin": "2.8.0", + "@typescript-eslint/parser": "2.8.0", "babel-eslint": "10.0.3", "babel-jest": "24.9.0", "babel-loader": "8.0.6", "copyfiles": "2.1.1", - "eslint": "6.5.1", - "eslint-config-prettier": "6.4.0", - "eslint-plugin-jest": "22.17.0", + "eslint": "6.6.0", + "eslint-config-prettier": "6.7.0", + "eslint-plugin-jest": "23.0.4", "eslint-plugin-prettier": "3.1.1", "eslint-plugin-promise": "4.2.1", "eslint-plugin-react": "7.16.0", - "eslint-plugin-react-hooks": "2.1.2", + "eslint-plugin-react-hooks": "2.3.0", "jest": "24.9.0", - "lerna": "3.16.4", - "now": "16.3.1", + "lerna": "3.19.0", + "node-jq": "1.11.0", + "now": "16.6.0", "npm-run-all": "4.1.5", - "prettier": "1.18.2", + "prettier": "1.19.1", "prop-types": "15.7.2", - "react": "16.10.2", - "react-async": "8.0.0", - "react-dom": "16.10.2", - "storybook-chromatic": "3.0.0" + "react": "16.12.0", + "react-async": "10.0.0-alpha.0", + "react-dom": "16.12.0", + "storybook-chromatic": "3.1.0", + "typescript": "3.7.2" + }, + "resolutions": { + "@types/react": "16.9.13" } } diff --git a/packages/react-async-devtools/package.json b/packages/react-async-devtools/package.json index e9f27823..941a63b1 100644 --- a/packages/react-async-devtools/package.json +++ b/packages/react-async-devtools/package.json @@ -1,6 +1,6 @@ { "name": "react-async-devtools", - "version": "9.0.0", + "version": "10.0.0-alpha.0", "description": "DevTools for React Async", "keywords": [ "react", @@ -9,7 +9,7 @@ ], "author": "Gert Hengeveld ", "license": "ISC", - "homepage": "https://async-library.com", + "homepage": "https://react-async.com/", "repository": { "type": "git", "url": "https://github.com/async-library/react-async.git", diff --git a/packages/react-async-devtools/src/index.js b/packages/react-async-devtools/src/index.js index a5b7ebee..0989c94f 100644 --- a/packages/react-async-devtools/src/index.js +++ b/packages/react-async-devtools/src/index.js @@ -1,5 +1,5 @@ import React from "react" -import { actionTypes, reducer, globalScope } from "react-async" +import { ActionTypes, reducer, globalScope } from "react-async" import { Root, Range, Checkbox, Label, Small, Ol, Li, Button } from "./components" @@ -17,14 +17,14 @@ globalScope.__REACT_ASYNC__.devToolsDispatcher = (action, dispatch) => { state.update(action) } switch (action.type) { - case actionTypes.start: + case ActionTypes.start: if (state.intercept) { dispatch({ ...action, payload: undefined }) state.update(action, run) } else run() break - case actionTypes.fulfill: - case actionTypes.reject: + case ActionTypes.fulfill: + case ActionTypes.reject: setTimeout(run, state.latency * 1000) break default: diff --git a/packages/react-async/package.json b/packages/react-async/package.json index ba0ea478..74bf50d4 100644 --- a/packages/react-async/package.json +++ b/packages/react-async/package.json @@ -1,6 +1,6 @@ { "name": "react-async", - "version": "9.0.0", + "version": "10.0.0-alpha.0", "description": "React component for declarative promise resolution and data fetching", "keywords": [ "react", @@ -11,7 +11,7 @@ ], "author": "Gert Hengeveld ", "license": "ISC", - "homepage": "https://async-library.com", + "homepage": "https://react-async.com/", "repository": { "type": "git", "url": "https://github.com/async-library/react-async.git", @@ -29,22 +29,26 @@ "@pika/pack": { "pipeline": [ [ - "@pika/plugin-standard-pkg", - { - "exclude": [ - "specs.js", - "*.spec.js" - ] - } + "@pika/plugin-ts-standard-pkg" ], [ "@pika/plugin-build-node" ], [ - "@pika/plugin-build-web" + "@pika/plugin-build-web", + { + "entrypoint": [ + "module", + "unpkg", + "jsdelivr" + ] + } + ], + [ + "@pika/plugin-build-umd" ], [ - "@pika/plugin-build-types" + "@pika/plugin-bundle-types" ] ] } diff --git a/packages/react-async/src/Async.spec.js b/packages/react-async/src/Async.spec.js index a666888b..cb00a41d 100644 --- a/packages/react-async/src/Async.spec.js +++ b/packages/react-async/src/Async.spec.js @@ -38,6 +38,7 @@ describe("Async", () => { {value => { one = value + return null }}
@@ -47,6 +48,7 @@ describe("Async", () => { {value => { two = value + return null }}
@@ -55,6 +57,36 @@ describe("Async", () => { }) }) +describe("rendering context consumers without provider should throw an error", () => { + for (const Component of [ + Async.Initial, + Async.Pending, + Async.Fulfilled, + Async.Rejected, + Async.Settled, + ]) { + test("does not throw an error when rendered within ", () => { + expect(() => + render( + + {() => null} + + ) + ).not.toThrowError() + }) + test("does throw an error when not rendered within ", () => { + // Prevent the thrown error from showing up in test output by mocking console.error. + jest.spyOn(console, "error") + global.console.error.mockImplementation(() => {}) + + expect(() => render({() => null})).toThrowError() + + // Restore the original console.error so other tests will still print errors that occur. + global.console.error.mockRestore() + }) + } +}) + describe("Async.Fulfilled", () => { test("renders only after the promise is resolved", async () => { const promiseFn = () => resolveTo("ok") diff --git a/packages/react-async/src/Async.js b/packages/react-async/src/Async.tsx similarity index 54% rename from packages/react-async/src/Async.js rename to packages/react-async/src/Async.tsx index 0a123706..340822c6 100644 --- a/packages/react-async/src/Async.js +++ b/packages/react-async/src/Async.tsx @@ -1,25 +1,109 @@ import React from "react" -import globalScope from "./globalScope" +import globalScope, { MockAbortController } from "./globalScope" import { IfInitial, IfPending, IfFulfilled, IfRejected, IfSettled } from "./helpers" import propTypes from "./propTypes" import { neverSettle, - actionTypes, + ActionTypes, init, dispatchMiddleware, reducer as asyncReducer, } from "./reducer" +import { + AsyncProps, + AsyncState, + InitialChildren, + PendingChildren, + FulfilledChildren, + SettledChildren, + RejectedChildren, + AsyncAction, + ReducerAsyncState, +} from "./types" + +interface InitialProps { + children?: InitialChildren + persist?: boolean +} +interface PendingProps { + children?: PendingChildren + initial?: boolean +} +interface FulfilledProps { + children?: FulfilledChildren + persist?: boolean +} +interface RejectedProps { + children?: RejectedChildren + persist?: boolean +} +interface SettledProps { + children?: SettledChildren + persist?: boolean +} + +class Async extends React.Component, AsyncState> {} +type GenericAsync = typeof Async & { + Initial(props: InitialProps): JSX.Element + Pending(props: PendingProps): JSX.Element + Loading(props: PendingProps): JSX.Element + Fulfilled(props: FulfilledProps): JSX.Element + Resolved(props: FulfilledProps): JSX.Element + Rejected(props: RejectedProps): JSX.Element + Settled(props: SettledProps): JSX.Element +} + +type AsyncConstructor = React.ComponentClass> & { + Initial: React.FC> + Pending: React.FC> + Loading: React.FC> + Fulfilled: React.FC> + Resolved: React.FC> + Rejected: React.FC> + Settled: React.FC> +} /** * 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 const createInstance = (defaultOptions = {}, displayName = "Async") => { - const { Consumer, Provider } = React.createContext() +export function createInstance( + defaultOptions: AsyncProps = {}, + displayName = "Async" +): AsyncConstructor { + const { Consumer: UnguardedConsumer, Provider } = React.createContext | undefined>( + undefined + ) + function Consumer({ children }: { children: (value: AsyncState) => React.ReactNode }) { + return ( + + {value => { + if (!value) { + throw new Error( + "this component should only be used within an associated component!" + ) + } + return children(value) + }} + + ) + } - class Async extends React.Component { - constructor(props) { + type Props = AsyncProps + type State = AsyncState + type Constructor = AsyncConstructor + + class Async extends React.Component { + private mounted = false + private counter = 0 + private args: any[] = [] + private promise?: Promise = neverSettle + private abortController: AbortController = new MockAbortController() + private debugLabel?: string + private dispatch: (action: AsyncAction, ...args: any[]) => void + + constructor(props: Props) { super(props) this.start = this.start.bind(this) @@ -35,13 +119,8 @@ export const createInstance = (defaultOptions = {}, displayName = "Async") => { const promiseFn = props.promiseFn || defaultOptions.promiseFn const initialValue = props.initialValue || defaultOptions.initialValue - this.mounted = false - this.counter = 0 - this.args = [] - this.promise = neverSettle - this.abortController = { abort: () => {} } this.state = { - ...init({ initialValue, promise, promiseFn }), + ...init({ initialValue, promise, promiseFn }), cancel: this.cancel, run: this.run, reload: () => { @@ -56,10 +135,13 @@ export const createInstance = (defaultOptions = {}, displayName = "Async") => { const { devToolsDispatcher } = globalScope.__REACT_ASYNC__ const _reducer = props.reducer || defaultOptions.reducer const _dispatcher = props.dispatcher || defaultOptions.dispatcher || devToolsDispatcher - const reducer = _reducer + const reducer: ( + state: ReducerAsyncState, + action: AsyncAction + ) => ReducerAsyncState = _reducer ? (state, action) => _reducer(state, action, asyncReducer) : asyncReducer - const dispatch = dispatchMiddleware((action, callback) => { + const dispatch = dispatchMiddleware((action, callback) => { this.setState(state => reducer(state, action), callback) }) this.dispatch = _dispatcher ? action => _dispatcher(action, dispatch, props) : dispatch @@ -72,7 +154,7 @@ export const createInstance = (defaultOptions = {}, displayName = "Async") => { } } - componentDidUpdate(prevProps) { + componentDidUpdate(prevProps: Props) { const { watch, watchFn = defaultOptions.watchFn, promise, promiseFn } = this.props if (watch !== prevProps.watch) { if (this.counter) this.cancel() @@ -100,7 +182,7 @@ export const createInstance = (defaultOptions = {}, displayName = "Async") => { this.mounted = false } - getMeta(meta) { + getMeta(meta?: M) { return { counter: this.counter, promise: this.promise, @@ -109,16 +191,16 @@ export const createInstance = (defaultOptions = {}, displayName = "Async") => { } } - start(promiseFn) { + start(promiseFn: () => Promise) { if ("AbortController" in globalScope) { this.abortController.abort() - this.abortController = new globalScope.AbortController() + this.abortController = new globalScope.AbortController!() } this.counter++ return (this.promise = new Promise((resolve, reject) => { if (!this.mounted) return const executor = () => promiseFn().then(resolve, reject) - this.dispatch({ type: actionTypes.start, payload: executor, meta: this.getMeta() }) + this.dispatch({ type: ActionTypes.start, payload: executor, meta: this.getMeta() }) })) } @@ -137,7 +219,7 @@ export const createInstance = (defaultOptions = {}, displayName = "Async") => { } } - run(...args) { + run(...args: any[]) { const deferFn = this.props.deferFn || defaultOptions.deferFn if (deferFn) { this.args = args @@ -154,11 +236,11 @@ export const createInstance = (defaultOptions = {}, displayName = "Async") => { onCancel && onCancel() this.counter++ this.abortController.abort() - this.mounted && this.dispatch({ type: actionTypes.cancel, meta: this.getMeta() }) + this.mounted && this.dispatch({ type: ActionTypes.cancel, meta: this.getMeta() }) } - onResolve(counter) { - return data => { + onResolve(counter: Number) { + return (data: T) => { if (this.counter === counter) { const onResolve = this.props.onResolve || defaultOptions.onResolve this.setData(data, () => onResolve && onResolve(data)) @@ -167,8 +249,8 @@ export const createInstance = (defaultOptions = {}, displayName = "Async") => { } } - onReject(counter) { - return error => { + onReject(counter: Number) { + return (error: Error) => { if (this.counter === counter) { const onReject = this.props.onReject || defaultOptions.onReject this.setError(error, () => onReject && onReject(error)) @@ -177,16 +259,16 @@ export const createInstance = (defaultOptions = {}, displayName = "Async") => { } } - setData(data, callback) { + setData(data: T, callback?: () => void) { this.mounted && - this.dispatch({ type: actionTypes.fulfill, payload: data, meta: this.getMeta() }, callback) + this.dispatch({ type: ActionTypes.fulfill, payload: data, meta: this.getMeta() }, callback) return data } - setError(error, callback) { + setError(error: Error, callback?: () => void) { this.mounted && this.dispatch( - { type: actionTypes.reject, payload: error, error: true, meta: this.getMeta() }, + { type: ActionTypes.reject, payload: error, error: true, meta: this.getMeta() }, callback ) return error @@ -199,7 +281,8 @@ export const createInstance = (defaultOptions = {}, displayName = "Async") => { throw this.promise } if (typeof children === "function") { - return {children(this.state)} + const render = children as (state: State) => React.ReactNode + return {render(this.state)} } if (children !== undefined && children !== null) { return {children} @@ -208,13 +291,23 @@ export const createInstance = (defaultOptions = {}, displayName = "Async") => { } } - if (propTypes) Async.propTypes = propTypes.Async + if (propTypes) (Async as React.ComponentClass).propTypes = propTypes.Async - const AsyncInitial = props => {st => } - const AsyncPending = props => {st => } - const AsyncFulfilled = props => {st => } - const AsyncRejected = props => {st => } - const AsyncSettled = props => {st => } + const AsyncInitial: Constructor["Initial"] = props => ( + {st => } + ) + const AsyncPending: Constructor["Pending"] = props => ( + {st => } + ) + const AsyncFulfilled: Constructor["Fulfilled"] = props => ( + {st => } + ) + const AsyncRejected: Constructor["Rejected"] = props => ( + {st => } + ) + const AsyncSettled: Constructor["Settled"] = props => ( + {st => } + ) AsyncInitial.displayName = `${displayName}.Initial` AsyncPending.displayName = `${displayName}.Pending` @@ -222,16 +315,16 @@ export const createInstance = (defaultOptions = {}, displayName = "Async") => { AsyncRejected.displayName = `${displayName}.Rejected` AsyncSettled.displayName = `${displayName}.Settled` - Async.displayName = displayName - Async.Initial = AsyncInitial - Async.Pending = AsyncPending - Async.Loading = AsyncPending // alias - Async.Fulfilled = AsyncFulfilled - Async.Resolved = AsyncFulfilled // alias - Async.Rejected = AsyncRejected - Async.Settled = AsyncSettled - - return Async + return Object.assign(Async, { + displayName: displayName, + Initial: AsyncInitial, + Pending: AsyncPending, + Loading: AsyncPending, // alias + Fulfilled: AsyncFulfilled, + Resolved: AsyncFulfilled, // alias + Rejected: AsyncRejected, + Settled: AsyncSettled, + }) } -export default createInstance() +export default createInstance() as GenericAsync diff --git a/packages/react-async/src/globalScope.js b/packages/react-async/src/globalScope.ts similarity index 72% rename from packages/react-async/src/globalScope.js rename to packages/react-async/src/globalScope.ts index 62316c15..8fd3ffbd 100644 --- a/packages/react-async/src/globalScope.js +++ b/packages/react-async/src/globalScope.ts @@ -1,5 +1,11 @@ /* istanbul ignore file */ +declare type GlobalScope = { + __REACT_ASYNC__: any + AbortController?: typeof AbortController + fetch: typeof fetch +} + /** * Universal global scope object. In the browser this is `self`, in Node.js and React Native it's `global`. * This file is excluded from coverage reporting because these globals are environment-specific so we can't test them all. @@ -9,11 +15,17 @@ const globalScope = (() => { if (typeof global === "object" && global.global === global) return global if (typeof global === "object" && global.GLOBAL === global) return global return {} // fallback that relies on imported modules to be singletons -})() +})() as GlobalScope /** * Globally available object used to connect the DevTools to all React Async instances. */ globalScope.__REACT_ASYNC__ = globalScope.__REACT_ASYNC__ || {} +export const noop = () => {} +export class MockAbortController implements AbortController { + public abort = noop + readonly signal = {} as AbortSignal +} + export default globalScope diff --git a/packages/react-async/src/helpers.js b/packages/react-async/src/helpers.js deleted file mode 100644 index 09232f99..00000000 --- a/packages/react-async/src/helpers.js +++ /dev/null @@ -1,63 +0,0 @@ -import propTypes from "./propTypes" - -const nullify = children => (children === undefined ? null : children) -const renderFn = (children, ...args) => - nullify(typeof children === "function" ? children(...args) : children) - -/** - * Renders only when no promise has started or completed yet. - * - * @prop {Function|Node} children Function (passing state) or React node - * @prop {Object} state React Async state object - * @prop {boolean} persist Show until we have data, even while pending (loading) or when an error occurred - */ -export const IfInitial = ({ children, persist, state = {} }) => - state.isInitial || (persist && !state.data) ? renderFn(children, state) : null - -/** - * Renders only while pending (promise is loading). - * - * @prop {Function|Node} children Function (passing state) or React node - * @prop {Object} state React Async state object - * @prop {boolean} initial Show only on initial load (data is undefined) - */ -export const IfPending = ({ children, initial, state = {} }) => - state.isPending && (!initial || !state.value) ? renderFn(children, state) : null - -/** - * Renders only when promise is resolved. - * - * @prop {Function|Node} children Function (passing data and state) or React node - * @prop {Object} state React Async state object - * @prop {boolean} persist Show old data while pending (promise is loading) - */ -export const IfFulfilled = ({ children, persist, state = {} }) => - state.isFulfilled || (persist && state.data) ? renderFn(children, state.data, state) : null - -/** - * Renders only when promise is rejected. - * - * @prop {Function|Node} children Function (passing error and state) or React node - * @prop {Object} state React Async state object - * @prop {boolean} persist Show old error while pending (promise is loading) - */ -export const IfRejected = ({ children, persist, state = {} }) => - state.isRejected || (persist && state.error) ? renderFn(children, state.error, state) : null - -/** - * Renders only when promise is fulfilled or rejected. - * - * @prop {Function|Node} children Function (passing state) or React node - * @prop {Object} state React Async state object - * @prop {boolean} persist Show old data or error while pending (promise is loading) - */ -export const IfSettled = ({ children, persist, state = {} }) => - state.isSettled || (persist && state.value) ? renderFn(children, state) : null - -if (propTypes) { - IfInitial.propTypes = propTypes.Initial - IfPending.propTypes = propTypes.Pending - IfFulfilled.propTypes = propTypes.Fulfilled - IfRejected.propTypes = propTypes.Rejected - IfSettled.propTypes = propTypes.Settled -} diff --git a/packages/react-async/src/helpers.spec.js b/packages/react-async/src/helpers.spec.js index 24cff478..e1c66d70 100644 --- a/packages/react-async/src/helpers.spec.js +++ b/packages/react-async/src/helpers.spec.js @@ -2,7 +2,7 @@ import "@testing-library/jest-dom/extend-expect" import React from "react" import { render, fireEvent, cleanup } from "@testing-library/react" import Async, { IfInitial, IfPending, IfFulfilled, IfRejected, IfSettled } from "./index" -import { resolveIn, resolveTo, rejectTo } from "./specs" +import { resolveIn, resolveTo, rejectTo, sleep } from "./specs" afterEach(cleanup) @@ -84,6 +84,15 @@ describe("IfFulfilled", () => { await findByText("outer inner") expect(queryByText("outer inner")).toBeInTheDocument() }) + test("renders nothing if missing state", () => { + const { queryByText } = render(Test) + expect(queryByText("Test")).not.toBeInTheDocument() + }) + test("renders without children", async () => { + const promiseFn = () => resolveTo("ok") + render({state => }) + await sleep(0) + }) }) describe("IfPending", () => { @@ -103,6 +112,10 @@ describe("IfPending", () => { await findByText("done") expect(queryByText("pending")).toBeNull() }) + test("renders nothing if missing state", () => { + const { queryByText } = render(Test) + expect(queryByText("Test")).not.toBeInTheDocument() + }) }) describe("IfInitial", () => { @@ -128,6 +141,10 @@ describe("IfInitial", () => { await findByText("done") expect(queryByText("pending")).toBeNull() }) + test("renders nothing if missing state", () => { + const { queryByText } = render(Test) + expect(queryByText("Test")).not.toBeInTheDocument() + }) }) describe("IfRejected", () => { @@ -142,6 +159,10 @@ describe("IfRejected", () => { await findByText("err") expect(queryByText("err")).toBeInTheDocument() }) + test("renders nothing if missing state", () => { + const { queryByText } = render(Test) + expect(queryByText("Test")).not.toBeInTheDocument() + }) }) describe("IfSettled", () => { @@ -191,4 +212,8 @@ describe("IfSettled", () => { fireEvent.click(getByText("reload")) await findByText("loading") }) + test("renders nothing if missing state", () => { + const { queryByText } = render(Test) + expect(queryByText("Test")).not.toBeInTheDocument() + }) }) diff --git a/packages/react-async/src/helpers.tsx b/packages/react-async/src/helpers.tsx new file mode 100644 index 00000000..9b3d06a6 --- /dev/null +++ b/packages/react-async/src/helpers.tsx @@ -0,0 +1,136 @@ +import React from "react" +import propTypes from "./propTypes" + +import { + InitialChildren, + PendingChildren, + FulfilledChildren, + RejectedChildren, + SettledChildren, + AsyncState, + AbstractState, + AsyncInitial, + AsyncFulfilled, + AsyncPending, + AsyncRejected, +} from "./types" + +/** + * 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. + * This _uses_ AbstractState so it is not accidentally removed by someone. + */ +declare type ImportWorkaround = + | AbstractState + | AsyncInitial + | AsyncFulfilled + | AsyncPending + | AsyncRejected + +type ChildrenFn = (...args: any[]) => React.ReactNode +const renderFn = (children: React.ReactNode | ChildrenFn, ...args: any[]) => { + if (typeof children === "function") { + const render = children as ChildrenFn + return render(...args) + } + return children +} + +/** + * Renders only when no promise has started or completed yet. + * + * @prop {Function|Node} children Function (passing state) or React node + * @prop {Object} state React Async state object + * @prop {boolean} persist Show until we have data, even while pending (loading) or when an error occurred + */ +export const IfInitial = ({ + children, + persist, + state = {} as any, +}: { + children?: InitialChildren + persist?: boolean + state: AsyncState +}) => <>{state.isInitial || (persist && !state.data) ? renderFn(children, state) : null} + +/** + * Renders only while pending (promise is loading). + * + * @prop {Function|Node} children Function (passing state) or React node + * @prop {Object} state React Async state object + * @prop {boolean} initial Show only on initial load (data is undefined) + */ +export const IfPending = ({ + children, + initial, + state = {} as any, +}: { + children?: PendingChildren + initial?: boolean + state: AsyncState +}) => <>{state.isPending && (!initial || !state.value) ? renderFn(children, state) : null} + +/** + * Renders only when promise is resolved. + * + * @prop {Function|Node} children Function (passing data and state) or React node + * @prop {Object} state React Async state object + * @prop {boolean} persist Show old data while pending (promise is loading) + */ +export const IfFulfilled = ({ + children, + persist, + state = {} as any, +}: { + children?: FulfilledChildren + persist?: boolean + state: AsyncState +}) => ( + <>{state.isFulfilled || (persist && state.data) ? renderFn(children, state.data, state) : null} +) + +/** + * Renders only when promise is rejected. + * + * @prop {Function|Node} children Function (passing error and state) or React node + * @prop {Object} state React Async state object + * @prop {boolean} persist Show old error while pending (promise is loading) + */ +export const IfRejected = ({ + children, + persist, + state = {} as any, +}: { + children?: RejectedChildren + persist?: boolean + state: AsyncState +}) => ( + <> + {state.isRejected || (persist && state.error) ? renderFn(children, state.error, state) : null} + +) + +/** + * Renders only when promise is fulfilled or rejected. + * + * @prop {Function|Node} children Function (passing state) or React node + * @prop {Object} state React Async state object + * @prop {boolean} persist Show old data or error while pending (promise is loading) + */ +export const IfSettled = ({ + children, + persist, + state = {} as any, +}: { + children?: SettledChildren + persist?: boolean + state: AsyncState +}) => <>{state.isSettled || (persist && state.value) ? renderFn(children, state) : null} + +if (propTypes) { + IfInitial.propTypes = propTypes.Initial + IfPending.propTypes = propTypes.Pending + IfFulfilled.propTypes = propTypes.Fulfilled + IfRejected.propTypes = propTypes.Rejected + IfSettled.propTypes = propTypes.Settled +} diff --git a/packages/react-async/src/index.d.ts b/packages/react-async/src/index.d.ts deleted file mode 100644 index 3e649c05..00000000 --- a/packages/react-async/src/index.d.ts +++ /dev/null @@ -1,244 +0,0 @@ -import * as React from "react" - -export type AsyncChildren = ((state: AsyncState) => React.ReactNode) | React.ReactNode -export type InitialChildren = ((state: AsyncInitial) => React.ReactNode) | React.ReactNode -export type PendingChildren = ((state: AsyncPending) => React.ReactNode) | React.ReactNode -export type FulfilledChildren = - | ((data: T, state: AsyncFulfilled) => React.ReactNode) - | React.ReactNode -export type RejectedChildren = - | ((error: Error, state: AsyncRejected) => React.ReactNode) - | React.ReactNode -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, - controller: AbortController -) => Promise - -interface AbstractAction { - type: string - meta: { counter: number; [meta: string]: any } -} -export type Start = AbstractAction & { type: "start"; payload: () => Promise } -export type Cancel = AbstractAction & { type: "cancel" } -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 { - promise?: Promise - promiseFn?: PromiseFn - deferFn?: DeferFn - watch?: any - watchFn?: (props: AsyncProps, prevProps: AsyncProps) => any - initialValue?: T - onResolve?: (data: T) => void - onReject?: (error: Error) => void - reducer?: ( - state: AsyncState, - action: AsyncAction, - internalReducer: (state: AsyncState, action: AsyncAction) => AsyncState - ) => AsyncState - dispatcher?: ( - action: AsyncAction, - internalDispatch: (action: AsyncAction) => void, - props: AsyncProps - ) => void - debugLabel?: string - suspense?: boolean - [prop: string]: any -} - -export interface AsyncProps extends AsyncOptions { - children?: AsyncChildren -} - -interface AbstractState { - initialValue?: T | Error - counter: number - promise: Promise - run: (...args: any[]) => void - reload: () => void - cancel: () => void - setData: (data: T, callback?: () => void) => T - setError: (error: Error, callback?: () => void) => Error -} - -export type AsyncInitial = AbstractState & { - initialValue?: undefined - data: undefined - error: undefined - value: undefined - startedAt: undefined - finishedAt: undefined - status: "initial" - isInitial: false - isPending: false - isLoading: false - isFulfilled: false - isResolved: false - isRejected: false - isSettled: false -} -export type AsyncPending = AbstractState & { - data: T | undefined - error: Error | undefined - value: T | Error | undefined - startedAt: Date - finishedAt: undefined - status: "pending" - isInitial: false - isPending: true - isLoading: true - isFulfilled: false - isResolved: false - isRejected: false - isSettled: false -} -export type AsyncFulfilled = AbstractState & { - data: T - error: undefined - value: T - startedAt: Date - finishedAt: Date - status: "fulfilled" - isInitial: false - isPending: false - isLoading: false - isFulfilled: true - isResolved: true - isRejected: false - isSettled: true -} -export type AsyncRejected = AbstractState & { - data: T | undefined - error: Error - value: Error - startedAt: Date - finishedAt: Date - status: "rejected" - isInitial: false - isPending: false - isLoading: false - isFulfilled: false - isResolved: false - isRejected: true - isSettled: true -} -export type AsyncState = AsyncInitial | AsyncPending | AsyncFulfilled | AsyncRejected - -export class Async extends React.Component, AsyncState> {} - -export namespace Async { - export function Initial(props: { - children?: InitialChildren - persist?: boolean - }): JSX.Element - export function Pending(props: { - children?: PendingChildren - initial?: boolean - }): JSX.Element - export function Loading(props: { - children?: PendingChildren - initial?: boolean - }): JSX.Element - export function Fulfilled(props: { - children?: FulfilledChildren - persist?: boolean - }): JSX.Element - export function Resolved(props: { - children?: FulfilledChildren - persist?: boolean - }): JSX.Element - export function Rejected(props: { - children?: RejectedChildren - persist?: boolean - }): JSX.Element - export function Settled(props: { - children?: SettledChildren - persist?: boolean - }): JSX.Element -} - -export function createInstance( - defaultOptions?: AsyncProps, - displayName?: string -): (new () => Async) & { - Initial(props: { children?: InitialChildren; persist?: boolean }): JSX.Element - Pending(props: { children?: PendingChildren; initial?: boolean }): JSX.Element - Loading(props: { children?: PendingChildren; initial?: boolean }): JSX.Element - Fulfilled(props: { children?: FulfilledChildren; persist?: boolean }): JSX.Element - Resolved(props: { children?: FulfilledChildren; persist?: boolean }): JSX.Element - Rejected(props: { children?: RejectedChildren; persist?: boolean }): JSX.Element - Settled(props: { children?: SettledChildren; persist?: boolean }): JSX.Element -} - -export function IfInitial(props: { - children?: InitialChildren - persist?: boolean - state: AsyncState -}): JSX.Element -export function IfPending(props: { - children?: PendingChildren - initial?: boolean - state: AsyncState -}): JSX.Element -export function IfFulfilled(props: { - children?: FulfilledChildren - persist?: boolean - state: AsyncState -}): JSX.Element -export function IfRejected(props: { - children?: RejectedChildren - persist?: boolean - state: AsyncState -}): JSX.Element -export function IfSettled(props: { - children?: SettledChildren - persist?: boolean - state: AsyncState -}): JSX.Element - -export function useAsync( - arg1: AsyncOptions | PromiseFn, - arg2?: AsyncOptions -): AsyncState - -export interface FetchOptions extends AsyncOptions { - defer?: boolean - json?: boolean -} - -export function useFetch( - input: RequestInfo, - init?: RequestInit, - options?: FetchOptions -): AsyncInitialWithout<"run", T> & FetchRun - -// unfortunately, we cannot just omit K from AsyncInitial as that would unbox the Discriminated Union -type AsyncInitialWithout, T> = - | Omit, K> - | Omit, K> - | Omit, K> - | Omit, K> - -type OverrideParams = { resource?: RequestInfo } & Partial - -type FetchRun = { - run(overrideParams: (params?: OverrideParams) => OverrideParams): void - run(overrideParams: OverrideParams): void - run(ignoredEvent: React.SyntheticEvent): void - run(ignoredEvent: Event): void - run(): void -} - -export class FetchError extends Error { - response: Response -} - -export default Async diff --git a/packages/react-async/src/index.js b/packages/react-async/src/index.ts similarity index 59% rename from packages/react-async/src/index.js rename to packages/react-async/src/index.ts index e9409c85..e35a81b0 100644 --- a/packages/react-async/src/index.js +++ b/packages/react-async/src/index.ts @@ -1,8 +1,9 @@ import Async from "./Async" export { default as Async, createInstance } from "./Async" -export { default as useAsync, useFetch, FetchError } from "./useAsync" +export * from "./types" +export { default as useAsync, useFetch, FetchOptions, FetchError } from "./useAsync" export default Async -export { statusTypes } from "./status" +export { StatusTypes } from "./status" export { default as globalScope } from "./globalScope" export * from "./helpers" export * from "./reducer" diff --git a/packages/react-async/src/propTypes.js b/packages/react-async/src/propTypes.ts similarity index 90% rename from packages/react-async/src/propTypes.js rename to packages/react-async/src/propTypes.ts index a44a8fe8..b6d837fe 100644 --- a/packages/react-async/src/propTypes.js +++ b/packages/react-async/src/propTypes.ts @@ -47,27 +47,27 @@ export default PropTypes && { suspense: PropTypes.bool, }, Initial: { - children: childrenFn.isRequired, + children: childrenFn, state: stateObject.isRequired, persist: PropTypes.bool, }, Pending: { - children: childrenFn.isRequired, + children: childrenFn, state: stateObject.isRequired, initial: PropTypes.bool, }, Fulfilled: { - children: childrenFn.isRequired, + children: childrenFn, state: stateObject.isRequired, persist: PropTypes.bool, }, Rejected: { - children: childrenFn.isRequired, + children: childrenFn, state: stateObject.isRequired, persist: PropTypes.bool, }, Settled: { - children: childrenFn.isRequired, + children: childrenFn, state: stateObject.isRequired, persist: PropTypes.bool, }, diff --git a/packages/react-async/src/reducer.js b/packages/react-async/src/reducer.js deleted file mode 100644 index 3587bcdf..00000000 --- a/packages/react-async/src/reducer.js +++ /dev/null @@ -1,94 +0,0 @@ -import { getInitialStatus, getIdleStatus, getStatusProps, statusTypes } from "./status" - -// This exists to make sure we don't hold any references to user-provided functions -class NeverSettle extends Promise { - constructor() { - super(() => {}, () => {}) - /* istanbul ignore next */ - if (Object.setPrototypeOf) { - // Not available in IE 10, but can be polyfilled - Object.setPrototypeOf(this, NeverSettle.prototype) - } - } - finally() { - return this - } - catch() { - return this - } - then() { - return this - } -} - -export const neverSettle = new NeverSettle() - -export const actionTypes = { - start: "start", - cancel: "cancel", - fulfill: "fulfill", - reject: "reject", -} - -export const init = ({ initialValue, promise, promiseFn }) => ({ - initialValue, - data: initialValue instanceof Error ? undefined : initialValue, - error: initialValue instanceof Error ? initialValue : undefined, - value: initialValue, - startedAt: promise || promiseFn ? new Date() : undefined, - finishedAt: initialValue ? new Date() : undefined, - ...getStatusProps(getInitialStatus(initialValue, promise || promiseFn)), - counter: 0, - promise: neverSettle, -}) - -export const reducer = (state, { type, payload, meta }) => { - switch (type) { - case actionTypes.start: - return { - ...state, - startedAt: new Date(), - finishedAt: undefined, - ...getStatusProps(statusTypes.pending), - counter: meta.counter, - promise: meta.promise, - } - case actionTypes.cancel: - return { - ...state, - startedAt: undefined, - finishedAt: undefined, - ...getStatusProps(getIdleStatus(state.error || state.data)), - counter: meta.counter, - promise: meta.promise, - } - case actionTypes.fulfill: - return { - ...state, - data: payload, - value: payload, - error: undefined, - finishedAt: new Date(), - ...getStatusProps(statusTypes.fulfilled), - promise: meta.promise, - } - case actionTypes.reject: - return { - ...state, - error: payload, - value: payload, - finishedAt: new Date(), - ...getStatusProps(statusTypes.rejected), - promise: meta.promise, - } - default: - return state - } -} - -export const dispatchMiddleware = dispatch => (action, ...args) => { - dispatch(action, ...args) - if (action.type === actionTypes.start && typeof action.payload === "function") { - action.payload() - } -} diff --git a/packages/react-async/src/reducer.ts b/packages/react-async/src/reducer.ts new file mode 100644 index 00000000..cd9448e5 --- /dev/null +++ b/packages/react-async/src/reducer.ts @@ -0,0 +1,127 @@ +import { getInitialStatus, getIdleStatus, getStatusProps, StatusTypes } from "./status" +import { + PromiseFn, + AsyncAction, + AsyncPending, + AsyncFulfilled, + AsyncRejected, + AsyncInitial, + AbstractState, + ReducerAsyncState, + ReducerBaseState, +} from "./types" + +/** + * 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. + * This _uses_ AbstractState so it is not accidentally removed by someone. + */ +declare type ImportWorkaround = AbstractState + +// This exists to make sure we don't hold any references to user-provided functions +// The way NeverSettle extends from Promise is complicated, but can't be done differently because Babel doesn't support +// extending built-in classes. See https://babeljs.io/docs/en/caveats/#classes +const NeverSettle = (function() {} as unknown) as { new (): Promise } +/* istanbul ignore next */ +if (Object.setPrototypeOf) { + Object.setPrototypeOf(NeverSettle, Promise) +} else { + ;(NeverSettle as any).__proto__ = Promise +} +NeverSettle.prototype = Object.assign(Object.create(Promise.prototype), { + finally() { + return this + }, + catch() { + return this + }, + then() { + return this + }, +}) + +export const neverSettle = new NeverSettle() + +export enum ActionTypes { + start = "start", + cancel = "cancel", + fulfill = "fulfill", + reject = "reject", +} + +export const init = ({ + initialValue, + promise, + promiseFn, +}: { + initialValue?: Error | T + promise?: Promise + promiseFn?: PromiseFn +}) => + ({ + initialValue, + data: initialValue instanceof Error ? undefined : initialValue, + error: initialValue instanceof Error ? initialValue : undefined, + value: initialValue, + startedAt: promise || promiseFn ? new Date() : undefined, + finishedAt: initialValue ? new Date() : undefined, + ...getStatusProps(getInitialStatus(initialValue, promise || promiseFn)), + counter: 0, + promise: neverSettle, + } as ReducerAsyncState) + +export const reducer = (state: ReducerAsyncState, action: AsyncAction) => { + switch (action.type) { + case ActionTypes.start: + return { + ...state, + startedAt: new Date(), + finishedAt: undefined, + ...getStatusProps(StatusTypes.pending), + counter: action.meta.counter, + promise: action.meta.promise, + } as AsyncPending> + case ActionTypes.cancel: + return { + ...state, + startedAt: undefined, + finishedAt: undefined, + ...getStatusProps(getIdleStatus(state.error || state.data)), + counter: action.meta.counter, + promise: action.meta.promise, + } as + | AsyncInitial> + | AsyncFulfilled> + | AsyncRejected> + case ActionTypes.fulfill: + return { + ...state, + data: action.payload, + value: action.payload, + error: undefined, + finishedAt: new Date(), + ...getStatusProps(StatusTypes.fulfilled), + promise: action.meta.promise, + } as AsyncFulfilled> + case ActionTypes.reject: + return { + ...state, + error: action.payload, + value: action.payload, + finishedAt: new Date(), + ...getStatusProps(StatusTypes.rejected), + promise: action.meta.promise, + } as AsyncRejected> + default: + return state + } +} + +export const dispatchMiddleware = ( + dispatch: (action: AsyncAction, ...args: any[]) => void +) => (action: AsyncAction, ...args: unknown[]) => { + dispatch(action, ...args) + if (action.type === ActionTypes.start && typeof action.payload === "function") { + action.payload() + } +} diff --git a/packages/react-async/src/specs.js b/packages/react-async/src/specs.js index dac4fa74..14054789 100644 --- a/packages/react-async/src/specs.js +++ b/packages/react-async/src/specs.js @@ -286,7 +286,7 @@ export const withPromiseFn = (Async, abortCtrl) => () => { expect(abortCtrl.abort).toHaveBeenCalledTimes(1) }) - test("re-runs the promise when the value of `watch` changes", () => { + test("re-runs the promise with new props when the value of `watch` changes", () => { class Counter extends React.Component { constructor(props) { super(props) @@ -304,19 +304,31 @@ 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) + ) fireEvent.click(getByText("increment")) expect(promiseFn).toHaveBeenCalledTimes(2) + expect(promiseFn).toHaveBeenLastCalledWith( + expect.objectContaining({ count: 1 }), + 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(abortCtrl.abort).toHaveBeenCalled() }) - test("re-runs the promise when `watchFn` returns truthy", () => { + test("re-runs the promise with new props when `watchFn` returns truthy", () => { class Counter extends React.Component { constructor(props) { super(props) @@ -338,11 +350,23 @@ export const withPromiseFn = (Async, abortCtrl) => () => { {count => } ) expect(promiseFn).toHaveBeenCalledTimes(1) + expect(promiseFn).toHaveBeenLastCalledWith( + expect.objectContaining({ count: 0 }), + expect.any(Object) + ) fireEvent.click(getByText("increment")) expect(promiseFn).toHaveBeenCalledTimes(1) + expect(promiseFn).toHaveBeenLastCalledWith( + expect.objectContaining({ count: 0 }), + 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(abortCtrl.abort).toHaveBeenCalled() }) diff --git a/packages/react-async/src/status.js b/packages/react-async/src/status.js deleted file mode 100644 index 0af8fd52..00000000 --- a/packages/react-async/src/status.js +++ /dev/null @@ -1,30 +0,0 @@ -export const statusTypes = { - initial: "initial", - pending: "pending", - fulfilled: "fulfilled", - rejected: "rejected", -} - -export const getInitialStatus = (value, promise) => { - if (value instanceof Error) return statusTypes.rejected - if (value !== undefined) return statusTypes.fulfilled - if (promise) return statusTypes.pending - return statusTypes.initial -} - -export const getIdleStatus = value => { - if (value instanceof Error) return statusTypes.rejected - if (value !== undefined) return statusTypes.fulfilled - return statusTypes.initial -} - -export const getStatusProps = status => ({ - status, - isInitial: status === statusTypes.initial, - isPending: status === statusTypes.pending, - isLoading: status === statusTypes.pending, // alias - isFulfilled: status === statusTypes.fulfilled, - isResolved: status === statusTypes.fulfilled, // alias - isRejected: status === statusTypes.rejected, - isSettled: status === statusTypes.fulfilled || status === statusTypes.rejected, -}) diff --git a/packages/react-async/src/status.spec.js b/packages/react-async/src/status.spec.js index 16d9c7b1..f2f6fe6a 100644 --- a/packages/react-async/src/status.spec.js +++ b/packages/react-async/src/status.spec.js @@ -2,31 +2,31 @@ import "@testing-library/jest-dom/extend-expect" -import { getInitialStatus, getIdleStatus, statusTypes } from "./status" +import { getInitialStatus, getIdleStatus, StatusTypes } from "./status" describe("getInitialStatus", () => { test("returns 'initial' when given an undefined value", () => { - expect(getInitialStatus(undefined)).toEqual(statusTypes.initial) + expect(getInitialStatus(undefined)).toEqual(StatusTypes.initial) }) test("returns 'pending' when given only a promise", () => { - expect(getInitialStatus(undefined, Promise.resolve("foo"))).toEqual(statusTypes.pending) + expect(getInitialStatus(undefined, Promise.resolve("foo"))).toEqual(StatusTypes.pending) }) test("returns 'rejected' when given an Error value", () => { - expect(getInitialStatus(new Error("oops"))).toEqual(statusTypes.rejected) + expect(getInitialStatus(new Error("oops"))).toEqual(StatusTypes.rejected) }) test("returns 'fulfilled' when given any other value", () => { - expect(getInitialStatus(null)).toEqual(statusTypes.fulfilled) + expect(getInitialStatus(null)).toEqual(StatusTypes.fulfilled) }) }) describe("getIdleStatus", () => { test("returns 'initial' when given an undefined value", () => { - expect(getIdleStatus(undefined)).toEqual(statusTypes.initial) + expect(getIdleStatus(undefined)).toEqual(StatusTypes.initial) }) test("returns 'rejected' when given an Error value", () => { - expect(getIdleStatus(new Error("oops"))).toEqual(statusTypes.rejected) + expect(getIdleStatus(new Error("oops"))).toEqual(StatusTypes.rejected) }) test("returns 'fulfilled' when given any other value", () => { - expect(getIdleStatus(null)).toEqual(statusTypes.fulfilled) + expect(getIdleStatus(null)).toEqual(StatusTypes.fulfilled) }) }) diff --git a/packages/react-async/src/status.ts b/packages/react-async/src/status.ts new file mode 100644 index 00000000..9db4d3f4 --- /dev/null +++ b/packages/react-async/src/status.ts @@ -0,0 +1,32 @@ +import { PromiseFn } from "./types" + +export enum StatusTypes { + initial = "initial", + pending = "pending", + fulfilled = "fulfilled", + rejected = "rejected", +} + +export const getInitialStatus = (value?: T | Error, promise?: Promise | PromiseFn) => { + if (value instanceof Error) return StatusTypes.rejected + if (value !== undefined) return StatusTypes.fulfilled + if (promise) return StatusTypes.pending + return StatusTypes.initial +} + +export const getIdleStatus = (value?: T | Error) => { + if (value instanceof Error) return StatusTypes.rejected + if (value !== undefined) return StatusTypes.fulfilled + return StatusTypes.initial +} + +export const getStatusProps = (status: StatusTypes) => ({ + status, + isInitial: status === StatusTypes.initial, + isPending: status === StatusTypes.pending, + isLoading: status === StatusTypes.pending, // alias + isFulfilled: status === StatusTypes.fulfilled, + isResolved: status === StatusTypes.fulfilled, // alias + isRejected: status === StatusTypes.rejected, + isSettled: status === StatusTypes.fulfilled || status === StatusTypes.rejected, +}) diff --git a/packages/react-async/src/types.ts b/packages/react-async/src/types.ts new file mode 100644 index 00000000..20cd5d84 --- /dev/null +++ b/packages/react-async/src/types.ts @@ -0,0 +1,147 @@ +import React from "react" + +export type AsyncChildren = ((state: AsyncState) => React.ReactNode) | React.ReactNode +export type InitialChildren = ((state: AsyncInitial) => React.ReactNode) | React.ReactNode +export type PendingChildren = ((state: AsyncPending) => React.ReactNode) | React.ReactNode +export type FulfilledChildren = + | ((data: T, state: AsyncFulfilled) => React.ReactNode) + | React.ReactNode +export type RejectedChildren = + | ((error: Error, state: AsyncRejected) => React.ReactNode) + | React.ReactNode +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, + controller: AbortController +) => Promise + +export interface AbstractAction { + type: string + meta: { counter: number; [meta: string]: any } +} +export type Meta = AbstractAction["meta"] + +export type Start = AbstractAction & { type: "start"; payload: () => Promise } +export type Cancel = AbstractAction & { type: "cancel" } +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 { + promise?: Promise + promiseFn?: PromiseFn + deferFn?: DeferFn + watch?: any + watchFn?: (props: AsyncProps, prevProps: AsyncProps) => any + initialValue?: T + onResolve?: (data: T) => void + onReject?: (error: Error) => void + reducer?: ( + state: ReducerAsyncState, + action: AsyncAction, + internalReducer: (state: ReducerAsyncState, action: AsyncAction) => ReducerAsyncState + ) => AsyncState + dispatcher?: ( + action: AsyncAction, + internalDispatch: (action: AsyncAction) => void, + props: AsyncProps + ) => void + debugLabel?: string + [prop: string]: any +} + +export interface AsyncProps extends AsyncOptions { + children?: AsyncChildren +} + +export interface AbstractState { + initialValue?: T | Error + counter: number + promise: Promise + run: (...args: any[]) => void + reload: () => void + cancel: () => void + setData: (data: T, callback?: () => void) => T + setError: (error: Error, callback?: () => void) => Error +} + +export type AsyncInitial> = S & { + initialValue?: undefined + data: undefined + error: undefined + value: undefined + startedAt: undefined + finishedAt: undefined + status: "initial" + isInitial: false + isPending: false + isLoading: false + isFulfilled: false + isResolved: false + isRejected: false + isSettled: false +} +export type AsyncPending> = S & { + data: T | undefined + error: Error | undefined + value: T | Error | undefined + startedAt: Date + finishedAt: undefined + status: "pending" + isInitial: false + isPending: true + isLoading: true + isFulfilled: false + isResolved: false + isRejected: false + isSettled: false +} +export type AsyncFulfilled> = S & { + data: T + error: undefined + value: T + startedAt: Date + finishedAt: Date + status: "fulfilled" + isInitial: false + isPending: false + isLoading: false + isFulfilled: true + isResolved: true + isRejected: false + isSettled: true +} +export type AsyncRejected> = S & { + data: T | undefined + error: Error + value: Error + startedAt: Date + finishedAt: Date + status: "rejected" + isInitial: false + isPending: false + isLoading: false + isFulfilled: false + isResolved: false + isRejected: true + isSettled: true +} + +type BaseAsyncState = + | AsyncInitial + | AsyncPending + | AsyncFulfilled + | AsyncRejected + +export type ReducerBaseState = Omit< + AbstractState, + "run" | "reload" | "cancel" | "setData" | "setError" +> +export type ReducerAsyncState = BaseAsyncState> + +export type AsyncState = AbstractState> = BaseAsyncState diff --git a/packages/react-async/src/useAsync.js b/packages/react-async/src/useAsync.js deleted file mode 100644 index a43c31c5..00000000 --- a/packages/react-async/src/useAsync.js +++ /dev/null @@ -1,227 +0,0 @@ -import { useCallback, useDebugValue, useEffect, useMemo, useRef, useReducer } from "react" - -import globalScope from "./globalScope" -import { - neverSettle, - actionTypes, - init, - dispatchMiddleware, - reducer as asyncReducer, -} from "./reducer" - -const noop = () => {} - -const useAsync = (arg1, arg2) => { - const options = typeof arg1 === "function" ? { ...arg2, promiseFn: arg1 } : arg1 - - const counter = useRef(0) - const isMounted = useRef(true) - const lastArgs = useRef(undefined) - const lastOptions = useRef(undefined) - const lastPromise = useRef(neverSettle) - const abortController = useRef({ abort: noop }) - - const { devToolsDispatcher } = globalScope.__REACT_ASYNC__ - const { reducer, dispatcher = devToolsDispatcher } = options - const [state, _dispatch] = useReducer( - reducer ? (state, action) => reducer(state, action, asyncReducer) : asyncReducer, - options, - init - ) - const dispatch = useCallback( - dispatcher - ? action => dispatcher(action, dispatchMiddleware(_dispatch), lastOptions.current) - : dispatchMiddleware(_dispatch), - [dispatcher] - ) - - const { debugLabel } = options - const getMeta = useCallback( - meta => ({ counter: counter.current, promise: lastPromise.current, debugLabel, ...meta }), - [debugLabel] - ) - - const setData = useCallback( - (data, callback = noop) => { - if (isMounted.current) { - dispatch({ type: actionTypes.fulfill, payload: data, meta: getMeta() }) - callback() - } - return data - }, - [dispatch, getMeta] - ) - - const setError = useCallback( - (error, callback = noop) => { - if (isMounted.current) { - dispatch({ type: actionTypes.reject, payload: error, error: true, meta: getMeta() }) - callback() - } - return error - }, - [dispatch, getMeta] - ) - - const { onResolve, onReject } = options - const handleResolve = useCallback( - count => data => count === counter.current && setData(data, () => onResolve && onResolve(data)), - [setData, onResolve] - ) - const handleReject = useCallback( - count => err => count === counter.current && setError(err, () => onReject && onReject(err)), - [setError, onReject] - ) - - const start = useCallback( - promiseFn => { - if ("AbortController" in globalScope) { - abortController.current.abort() - abortController.current = new globalScope.AbortController() - } - counter.current++ - return (lastPromise.current = new Promise((resolve, reject) => { - if (!isMounted.current) return - const executor = () => promiseFn().then(resolve, reject) - dispatch({ type: actionTypes.start, payload: executor, meta: getMeta() }) - })) - }, - [dispatch, getMeta] - ) - - const { promise, promiseFn, initialValue } = options - const load = useCallback(() => { - const isPreInitialized = initialValue && counter.current === 0 - if (promise) { - start(() => promise) - .then(handleResolve(counter.current)) - .catch(handleReject(counter.current)) - } else if (promiseFn && !isPreInitialized) { - start(() => promiseFn(lastOptions.current, abortController.current)) - .then(handleResolve(counter.current)) - .catch(handleReject(counter.current)) - } - }, [start, promise, promiseFn, initialValue, handleResolve, handleReject]) - - const { deferFn } = options - const run = useCallback( - (...args) => { - if (deferFn) { - lastArgs.current = args - start(() => deferFn(args, lastOptions.current, abortController.current)) - .then(handleResolve(counter.current)) - .catch(handleReject(counter.current)) - } - }, - [start, deferFn, handleResolve, handleReject] - ) - - const reload = useCallback(() => { - lastArgs.current ? run(...lastArgs.current) : load() - }, [run, load]) - - const { onCancel } = options - const cancel = useCallback(() => { - onCancel && onCancel() - counter.current++ - abortController.current.abort() - isMounted.current && dispatch({ type: actionTypes.cancel, meta: getMeta() }) - }, [onCancel, dispatch, getMeta]) - - /* These effects should only be triggered on changes to specific props */ - /* eslint-disable react-hooks/exhaustive-deps */ - const { watch, watchFn } = options - useEffect(() => { - if (watchFn && lastOptions.current && watchFn(options, lastOptions.current)) load() - }) - useEffect(() => { - lastOptions.current = options - }, [options]) - useEffect(() => { - if (counter.current) cancel() - if (promise || promiseFn) load() - }, [promise, promiseFn, watch]) - useEffect(() => () => (isMounted.current = false), []) - useEffect(() => () => cancel(), []) - /* eslint-enable react-hooks/exhaustive-deps */ - - useDebugValue(state, ({ status }) => `[${counter.current}] ${status}`) - - if (options.suspense && state.isPending && lastPromise.current !== neverSettle) { - // Rely on Suspense to handle the loading state - throw lastPromise.current - } - - return useMemo( - () => ({ - ...state, - run, - reload, - cancel, - setData, - setError, - }), - [state, run, reload, cancel, setData, setError] - ) -} - -export class FetchError extends Error { - constructor(response) { - super(`${response.status} ${response.statusText}`) - /* istanbul ignore next */ - if (Object.setPrototypeOf) { - // Not available in IE 10, but can be polyfilled - Object.setPrototypeOf(this, FetchError.prototype) - } - this.response = response - } -} - -const parseResponse = (accept, json) => res => { - if (!res.ok) return Promise.reject(new FetchError(res)) - if (typeof json === "boolean") return json ? res.json() : res - return accept === "application/json" ? res.json() : res -} - -const useAsyncFetch = (resource, init, { defer, json, ...options } = {}) => { - const method = resource.method || (init && init.method) - const headers = resource.headers || (init && init.headers) || {} - const accept = headers["Accept"] || headers["accept"] || (headers.get && headers.get("accept")) - const doFetch = (resource, init) => - globalScope.fetch(resource, 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 state = useAsync({ - ...options, - [fn]: useCallback( - (arg1, arg2, arg3) => { - const [override, signal] = isDefer ? [arg1[0], arg3.signal] : [undefined, arg2.signal] - const isEvent = typeof override === "object" && "preventDefault" in override - if (!override || isEvent) { - return doFetch(resource, { signal, ...init }) - } - if (typeof override === "function") { - const { resource: runResource, ...runInit } = override({ resource, signal, ...init }) - return doFetch(runResource || resource, { signal, ...runInit }) - } - const { resource: runResource, ...runInit } = override - return doFetch(runResource || resource, { signal, ...init, ...runInit }) - }, - [identity] // eslint-disable-line react-hooks/exhaustive-deps - ), - }) - useDebugValue(state, ({ counter, status }) => `[${counter}] ${status}`) - return state -} - -/* istanbul ignore next */ -const unsupported = () => { - throw new Error( - "useAsync requires React v16.8 or up. Upgrade your React version or use the component instead." - ) -} - -export default useEffect ? useAsync : unsupported -export const useFetch = useEffect ? useAsyncFetch : unsupported diff --git a/packages/react-async/src/useAsync.tsx b/packages/react-async/src/useAsync.tsx new file mode 100644 index 00000000..b3eb22d9 --- /dev/null +++ b/packages/react-async/src/useAsync.tsx @@ -0,0 +1,340 @@ +import React, { useCallback, useDebugValue, useEffect, useMemo, useRef, useReducer } from "react" + +import globalScope, { MockAbortController, noop } from "./globalScope" +import { + neverSettle, + ActionTypes, + init, + dispatchMiddleware, + reducer as asyncReducer, +} from "./reducer" + +import { + AsyncOptions, + AsyncState, + AbstractState, + PromiseFn, + Meta, + AsyncInitial, + AsyncFulfilled, + AsyncPending, + AsyncRejected, +} from "./types" + +/** + * 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. + * This _uses_ AbstractState so it is not accidentally removed by someone. + */ +declare type ImportWorkaround = + | AbstractState + | AsyncInitial + | AsyncFulfilled + | AsyncPending + | AsyncRejected + +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 + + const counter = useRef(0) + const isMounted = useRef(true) + const lastArgs = useRef(undefined) + const lastOptions = useRef>(options) + const lastPromise = useRef>(neverSettle) + const abortController = useRef(new MockAbortController()) + + const { devToolsDispatcher } = globalScope.__REACT_ASYNC__ + const { reducer, dispatcher = devToolsDispatcher } = options + const [state, _dispatch] = useReducer( + reducer ? (state, action) => reducer(state, action, asyncReducer) : asyncReducer, + options, + init + ) + const dispatch = useCallback( + dispatcher + ? action => dispatcher(action, dispatchMiddleware(_dispatch), lastOptions.current) + : dispatchMiddleware(_dispatch), + [dispatcher] + ) + + const { debugLabel } = options + const getMeta: (meta?: M) => M = useCallback( + (meta?) => + ({ + counter: counter.current, + promise: lastPromise.current, + debugLabel, + ...meta, + } as any), + [debugLabel] + ) + + const setData = useCallback( + (data, callback = noop) => { + if (isMounted.current) { + dispatch({ + type: ActionTypes.fulfill, + payload: data, + meta: getMeta(), + }) + callback() + } + return data + }, + [dispatch, getMeta] + ) + + const setError = useCallback( + (error, callback = noop) => { + if (isMounted.current) { + dispatch({ + type: ActionTypes.reject, + payload: error, + error: true, + meta: getMeta(), + }) + callback() + } + return error + }, + [dispatch, getMeta] + ) + + const { onResolve, onReject } = options + const handleResolve = useCallback( + count => (data: T) => + count === counter.current && setData(data, () => onResolve && onResolve(data)), + [setData, onResolve] + ) + const handleReject = useCallback( + count => (err: Error) => + count === counter.current && setError(err, () => onReject && onReject(err)), + [setError, onReject] + ) + + const start = useCallback( + promiseFn => { + if ("AbortController" in globalScope) { + abortController.current.abort() + abortController.current = new globalScope.AbortController!() + } + counter.current++ + return (lastPromise.current = new Promise((resolve, reject) => { + if (!isMounted.current) return + const executor = () => promiseFn().then(resolve, reject) + dispatch({ + type: ActionTypes.start, + payload: executor, + meta: getMeta(), + }) + })) + }, + [dispatch, getMeta] + ) + + const { promise, promiseFn, initialValue } = options + const load = useCallback(() => { + const isPreInitialized = initialValue && counter.current === 0 + if (promise) { + start(() => promise) + .then(handleResolve(counter.current)) + .catch(handleReject(counter.current)) + } else if (promiseFn && !isPreInitialized) { + start(() => promiseFn(lastOptions.current, abortController.current)) + .then(handleResolve(counter.current)) + .catch(handleReject(counter.current)) + } + }, [start, promise, promiseFn, initialValue, handleResolve, handleReject]) + + const { deferFn } = options + const run = useCallback( + (...args) => { + if (deferFn) { + lastArgs.current = args + start(() => deferFn(args, lastOptions.current, abortController.current)) + .then(handleResolve(counter.current)) + .catch(handleReject(counter.current)) + } + }, + [start, deferFn, handleResolve, handleReject] + ) + + const reload = useCallback(() => { + lastArgs.current ? run(...lastArgs.current) : load() + }, [run, load]) + + const { onCancel } = options + const cancel = useCallback(() => { + onCancel && onCancel() + counter.current++ + abortController.current.abort() + isMounted.current && + dispatch({ + type: ActionTypes.cancel, + meta: getMeta(), + }) + }, [onCancel, dispatch, getMeta]) + + /* These effects should only be triggered on changes to specific props */ + /* eslint-disable react-hooks/exhaustive-deps */ + const { watch, watchFn } = options + useEffect(() => { + if (watchFn && lastOptions.current && watchFn(options, lastOptions.current)) { + lastOptions.current = options + load() + } + }) + useEffect(() => { + lastOptions.current = options + }, [options]) + useEffect(() => { + if (counter.current) cancel() + if (promise || promiseFn) load() + }, [promise, promiseFn, watch]) + useEffect( + () => () => { + isMounted.current = false + }, + [] + ) + useEffect(() => () => cancel(), []) + /* eslint-enable react-hooks/exhaustive-deps */ + + useDebugValue(state, ({ status }) => `[${counter.current}] ${status}`) + + if (options.suspense && state.isPending && lastPromise.current !== neverSettle) { + // Rely on Suspense to handle the loading state + throw lastPromise.current + } + + return useMemo( + () => + ({ + ...state, + run, + reload, + cancel, + setData, + setError, + } as AsyncState), + [state, run, reload, cancel, setData, setError] + ) +} + +export class FetchError extends Error { + constructor(public response: Response) { + super(`${response.status} ${response.statusText}`) + /* istanbul ignore next */ + if (Object.setPrototypeOf) { + // Not available in IE 10, but can be polyfilled + Object.setPrototypeOf(this, FetchError.prototype) + } + } +} + +const parseResponse = (accept: undefined | string, json: undefined | boolean) => ( + res: Response +) => { + if (!res.ok) return Promise.reject(new FetchError(res)) + if (typeof json === "boolean") return json ? res.json() : res + return accept === "application/json" ? res.json() : res +} + +type OverrideParams = { resource?: RequestInfo } & Partial + +interface FetchRun extends Omit, "run"> { + run(overrideParams: (params?: OverrideParams) => OverrideParams): void + run(overrideParams: OverrideParams): void + run(ignoredEvent: React.SyntheticEvent): void + run(ignoredEvent: Event): void + run(): void +} + +type FetchRunArgs = + | [(params?: OverrideParams) => OverrideParams] + | [OverrideParams] + | [React.SyntheticEvent] + | [Event] + | [] + +function isEvent(e: FetchRunArgs[0]): e is Event | React.SyntheticEvent { + return typeof e === "object" && "preventDefault" in e +} + +/** + * + * @param {RequestInfo} resource + * @param {RequestInit} init + * @param {FetchOptions} options + * @returns {AsyncState>} + */ +function useAsyncFetch( + resource: RequestInfo, + init: RequestInit, + { defer, json, ...options }: FetchOptions = {} +): AsyncState> { + const method = (resource as Request).method || (init && init.method) + const headers: Headers & Record = + (resource as Request).headers || (init && init.headers) || {} + const accept: string | undefined = + headers["Accept"] || headers["accept"] || (headers.get && headers.get("accept")) + const doFetch = (input: RequestInfo, init: RequestInit) => + 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) => { + return doFetch(resource, { signal, ...init }) + }, + [identity] // eslint-disable-line react-hooks/exhaustive-deps + ) + const deferFn = useCallback( + function([override]: FetchRunArgs, _: AsyncOptions, { signal }: AbortController) { + if (!override || isEvent(override)) { + return doFetch(resource, { signal, ...init }) + } + if (typeof override === "function") { + const { resource: runResource, ...runInit } = override({ resource, signal, ...init }) + return doFetch(runResource || resource, { signal, ...runInit }) + } + const { resource: runResource, ...runInit } = override + return doFetch(runResource || resource, { signal, ...init, ...runInit }) + }, + [identity] // eslint-disable-line react-hooks/exhaustive-deps + ) + const state = useAsync({ + ...options, + [fn]: isDefer ? deferFn : promiseFn, + }) + useDebugValue(state, ({ counter, status }) => `[${counter}] ${status}`) + return state +} + +const unsupported = () => { + throw new Error( + "useAsync requires React v16.8 or up. Upgrade your React version or use the component instead." + ) +} + +export default useEffect ? useAsync : unsupported +export const useFetch = useEffect ? useAsyncFetch : unsupported diff --git a/packages/react-async/tsconfig.json b/packages/react-async/tsconfig.json new file mode 100644 index 00000000..f261ce40 --- /dev/null +++ b/packages/react-async/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "es2019", + "module": "esnext", + "allowJs": false, + "checkJs": false, + "jsx": "react", + "declaration": true, + "skipLibCheck": true, + "strict": true, + "esModuleInterop": true + }, + "include": ["src"] +} diff --git a/renovate.json b/renovate.json index f45d8f11..4e971384 100644 --- a/renovate.json +++ b/renovate.json @@ -1,5 +1,8 @@ { - "extends": [ - "config:base" - ] + "extends": ["config:base"], + "automerge": true, + "automergeType": "branch", + "major": { + "automerge": false + } } diff --git a/stories/.eslintrc b/stories/.eslintrc new file mode 100644 index 00000000..fe5eb603 --- /dev/null +++ b/stories/.eslintrc @@ -0,0 +1,23 @@ +{ + "extends": [ + "plugin:prettier/recommended", + "plugin:promise/recommended", + "plugin:react/recommended" + ], + "parser": "babel-eslint", + "parserOptions": { + "ecmaFeatures": { + "jsx": true + } + }, + "plugins": ["jest", "promise", "react", "react-hooks"], + "rules": { + "react/prop-types": "off", + "react-hooks/rules-of-hooks": "error" + }, + "settings": { + "react": { + "version": "detect" + } + } +}