diff --git a/README.md b/README.md index f7776f45..2a08c50b 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,7 @@ React Async has no direct relation to Concurrent React. They are conceptually cl 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 @@ -441,6 +442,7 @@ These can be passed in an object to `useAsync()`, or as props to `` and c - `reducer` State reducer to control internal state updates. - `dispatcher` Action dispatcher to control internal action dispatching. - `debugLabel` Unique label used in DevTools. +- `suspense` Enable **experimental** Suspense integration. `useFetch` additionally takes these options: @@ -557,6 +559,22 @@ dispatcher at some point. A unique label to describe this React Async instance, used in React DevTools (through `useDebugValue`) and React Async DevTools. +#### `suspense` + +> `boolean` + +Enables **experimental** Suspense integration. This will make React Async throw a promise while loading, so you can use +Suspense to render a fallback UI, instead of using ``. Suspense differs in 2 main ways: + +- `` should be an ancestor of your Async component, instead of a descendant. It can be anywhere up in the + component hierarchy. +- You can have a single `` wrap multiple Async components, in which case it will render the fallback UI until + all promises are settled. + +> Note that the way Suspense is integrated right now may change. Until Suspense for data fetching is officially +> released, we may make breaking changes to its integration in React Async in a minor or patch release. Among other +> things, we'll probably add a cache of sorts. + #### `defer` > `boolean` diff --git a/examples/with-suspense/.env b/examples/with-suspense/.env new file mode 100644 index 00000000..7d910f14 --- /dev/null +++ b/examples/with-suspense/.env @@ -0,0 +1 @@ +SKIP_PREFLIGHT_CHECK=true \ No newline at end of file diff --git a/examples/with-suspense/README.md b/examples/with-suspense/README.md new file mode 100644 index 00000000..547ff5b8 --- /dev/null +++ b/examples/with-suspense/README.md @@ -0,0 +1,7 @@ +# Basic fetch with Suspense + +This demonstrates how Suspense can be used to render a fallback UI while loading. + + + live demo + diff --git a/examples/with-suspense/package.json b/examples/with-suspense/package.json new file mode 100644 index 00000000..a5201b1e --- /dev/null +++ b/examples/with-suspense/package.json @@ -0,0 +1,42 @@ +{ + "name": "with-suspense-example", + "version": "8.0.0", + "private": true, + "homepage": "https://react-async.async-library.now.sh/examples/with-suspense", + "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": { + "react": "16.10.1", + "react-async": "8.0.0", + "react-async-devtools": "8.0.0", + "react-dom": "16.10.1", + "react-scripts": "3.1.2" + }, + "devDependencies": { + "relative-deps": "0.1.2" + }, + "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-suspense/public/favicon.ico b/examples/with-suspense/public/favicon.ico new file mode 100644 index 00000000..a11777cc Binary files /dev/null and b/examples/with-suspense/public/favicon.ico differ diff --git a/examples/with-suspense/public/index.html b/examples/with-suspense/public/index.html new file mode 100644 index 00000000..b8317902 --- /dev/null +++ b/examples/with-suspense/public/index.html @@ -0,0 +1,13 @@ + + + + + + + React App + + + +
+ + diff --git a/examples/with-suspense/src/index.css b/examples/with-suspense/src/index.css new file mode 100644 index 00000000..6ddc1f2c --- /dev/null +++ b/examples/with-suspense/src/index.css @@ -0,0 +1,29 @@ +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; +} + +.user { + display: inline-block; + margin: 20px; + text-align: center; +} + +.avatar { + background: #eee; + border-radius: 64px; + width: 128px; + height: 128px; +} + +.name { + margin-top: 10px; +} + +.placeholder { + opacity: 0.5; +} diff --git a/examples/with-suspense/src/index.js b/examples/with-suspense/src/index.js new file mode 100644 index 00000000..b5550997 --- /dev/null +++ b/examples/with-suspense/src/index.js @@ -0,0 +1,61 @@ +import React, { Suspense } from "react" +import { useAsync, IfFulfilled, IfRejected } from "react-async" +import ReactDOM from "react-dom" +import DevTools from "react-async-devtools" +import "./index.css" + +const loadUser = ({ userId }) => + fetch(`https://reqres.in/api/users/${userId}`) + .then(res => (res.ok ? res : Promise.reject(res))) + .then(res => res.json()) + .then(({ data }) => data) + +const UserPlaceholder = () => ( +
+
+
══════
+
+) + +const UserDetails = ({ data }) => ( +
+ +
+ {data.first_name} {data.last_name} +
+
+) + +const User = ({ userId }) => { + const state = useAsync({ + suspense: true, + promiseFn: loadUser, + debugLabel: `User ${userId}`, + userId, + }) + return ( + <> + {data => } + {error =>

{error.message}

}
+ + ) +} + +export const App = () => ( + <> + + + + + + } + > + + + + +) + +if (process.env.NODE_ENV !== "test") ReactDOM.render(, document.getElementById("root")) diff --git a/examples/with-suspense/src/index.test.js b/examples/with-suspense/src/index.test.js new file mode 100644 index 00000000..2920612e --- /dev/null +++ b/examples/with-suspense/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/packages/react-async/src/Async.js b/packages/react-async/src/Async.js index 86dea2e9..314cc0ff 100644 --- a/packages/react-async/src/Async.js +++ b/packages/react-async/src/Async.js @@ -193,7 +193,11 @@ export const createInstance = (defaultProps = {}, displayName = "Async") => { } render() { - const { children } = this.props + const { children, suspense } = this.props + if (suspense && this.state.isPending && this.promise !== neverSettle) { + // Rely on Suspense to handle the loading state + throw this.promise + } if (typeof children === "function") { return {children(this.state)} } diff --git a/packages/react-async/src/index.d.ts b/packages/react-async/src/index.d.ts index 49297abf..ecbe75cb 100644 --- a/packages/react-async/src/index.d.ts +++ b/packages/react-async/src/index.d.ts @@ -50,6 +50,7 @@ export interface AsyncOptions { props: AsyncProps ) => void debugLabel?: string + suspense?: boolean [prop: string]: any } diff --git a/packages/react-async/src/propTypes.js b/packages/react-async/src/propTypes.js index d8fe293a..a44a8fe8 100644 --- a/packages/react-async/src/propTypes.js +++ b/packages/react-async/src/propTypes.js @@ -44,6 +44,7 @@ export default PropTypes && { reducer: PropTypes.func, dispatcher: PropTypes.func, debugLabel: PropTypes.string, + suspense: PropTypes.bool, }, Initial: { children: childrenFn.isRequired, diff --git a/packages/react-async/src/specs.js b/packages/react-async/src/specs.js index ddb90cb8..dac4fa74 100644 --- a/packages/react-async/src/specs.js +++ b/packages/react-async/src/specs.js @@ -2,7 +2,7 @@ /* eslint-disable react/prop-types */ import "@testing-library/jest-dom/extend-expect" -import React from "react" +import React, { Suspense } from "react" import { render, fireEvent } from "@testing-library/react" export const resolveIn = ms => value => new Promise(resolve => setTimeout(resolve, ms, value)) @@ -65,6 +65,21 @@ export const common = Async => () => { await findByText("done") expect(onCancel).not.toHaveBeenCalled() }) + + // Skip when testing for backwards-compatibility with React 16.3 + const testSuspense = Suspense ? test : test.skip + testSuspense("supports Suspense", async () => { + const promiseFn = () => resolveIn(150)("done") + const { findByText } = render( + fallback
}> + + {({ data }) => data || null} + +
+ ) + await findByText("fallback") + await findByText("done") + }) } export const withPromise = Async => () => { diff --git a/packages/react-async/src/useAsync.js b/packages/react-async/src/useAsync.js index 6f0cb44a..b268db18 100644 --- a/packages/react-async/src/useAsync.js +++ b/packages/react-async/src/useAsync.js @@ -147,6 +147,11 @@ const useAsync = (arg1, arg2) => { 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, diff --git a/stories/index.stories.js b/stories/index.stories.js index 585ee74f..373d57cb 100644 --- a/stories/index.stories.js +++ b/stories/index.stories.js @@ -1,4 +1,4 @@ -import React from "react" +import React, { Suspense } from "react" import { storiesOf } from "@storybook/react" import { useAsync } from "../packages/react-async/src" @@ -43,9 +43,16 @@ const App = () => { return ( <> - - - +
+ + + +
+ Suspended...}> + + + + ) }