Skip to content

Commit

Permalink
Add experimental Suspense support (#153)
Browse files Browse the repository at this point in the history
* Add experimental Suspense support.

* Add PropType and type definition.

* Skip Suspense test when running against 16.3.

* Lock down all version ranges.

* Fix eslint config.

* Disable rules of hooks for examples.

* Attempt at fixing CircleCI memory issue.

* Update lockfile.

* Bump deps.

* Revert "Disable rules of hooks for examples."

This reverts commit d3d931a.
  • Loading branch information
ghengeveld committed Sep 30, 2019
1 parent 11f8996 commit 4cfaa0c
Show file tree
Hide file tree
Showing 15 changed files with 219 additions and 6 deletions.
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -441,6 +442,7 @@ These can be passed in an object to `useAsync()`, or as props to `<Async>` 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:

Expand Down Expand Up @@ -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 `<IfPending>`. Suspense differs in 2 main ways:

- `<Suspense>` 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 `<Suspense>` 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`
Expand Down
1 change: 1 addition & 0 deletions examples/with-suspense/.env
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
SKIP_PREFLIGHT_CHECK=true
7 changes: 7 additions & 0 deletions examples/with-suspense/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Basic fetch with Suspense

This demonstrates how Suspense can be used to render a fallback UI while loading.

<a href="https://react-async.async-library.now.sh/examples/with-suspense">
<img src="https://img.shields.io/badge/live-demo-blue.svg" alt="live demo">
</a>
42 changes: 42 additions & 0 deletions examples/with-suspense/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
Binary file added examples/with-suspense/public/favicon.ico
Binary file not shown.
13 changes: 13 additions & 0 deletions examples/with-suspense/public/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<meta name="theme-color" content="#000000" />
<title>React App</title>
</head>
<body>
<noscript> You need to enable JavaScript to run this app. </noscript>
<div id="root"></div>
</body>
</html>
29 changes: 29 additions & 0 deletions examples/with-suspense/src/index.css
Original file line number Diff line number Diff line change
@@ -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;
}
61 changes: 61 additions & 0 deletions examples/with-suspense/src/index.js
Original file line number Diff line number Diff line change
@@ -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 = () => (
<div className="user placeholder">
<div className="avatar" />
<div className="name">══════</div>
</div>
)

const UserDetails = ({ data }) => (
<div className="user">
<img className="avatar" src={data.avatar} alt="" />
<div className="name">
{data.first_name} {data.last_name}
</div>
</div>
)

const User = ({ userId }) => {
const state = useAsync({
suspense: true,
promiseFn: loadUser,
debugLabel: `User ${userId}`,
userId,
})
return (
<>
<IfFulfilled state={state}>{data => <UserDetails data={data} />}</IfFulfilled>
<IfRejected state={state}>{error => <p>{error.message}</p>}</IfRejected>
</>
)
}

export const App = () => (
<>
<DevTools />
<Suspense
fallback={
<>
<UserPlaceholder />
<UserPlaceholder />
</>
}
>
<User userId={1} />
<User userId={2} />
</Suspense>
</>
)

if (process.env.NODE_ENV !== "test") ReactDOM.render(<App />, document.getElementById("root"))
9 changes: 9 additions & 0 deletions examples/with-suspense/src/index.test.js
Original file line number Diff line number Diff line change
@@ -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(<App />, div)
ReactDOM.unmountComponentAtNode(div)
})
6 changes: 5 additions & 1 deletion packages/react-async/src/Async.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Provider value={this.state}>{children(this.state)}</Provider>
}
Expand Down
1 change: 1 addition & 0 deletions packages/react-async/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export interface AsyncOptions<T> {
props: AsyncProps<T>
) => void
debugLabel?: string
suspense?: boolean
[prop: string]: any
}

Expand Down
1 change: 1 addition & 0 deletions packages/react-async/src/propTypes.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export default PropTypes && {
reducer: PropTypes.func,
dispatcher: PropTypes.func,
debugLabel: PropTypes.string,
suspense: PropTypes.bool,
},
Initial: {
children: childrenFn.isRequired,
Expand Down
17 changes: 16 additions & 1 deletion packages/react-async/src/specs.js
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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(
<Suspense fallback={<div>fallback</div>}>
<Async suspense promiseFn={promiseFn}>
{({ data }) => data || null}
</Async>
</Suspense>
)
await findByText("fallback")
await findByText("done")
})
}

export const withPromise = Async => () => {
Expand Down
5 changes: 5 additions & 0 deletions packages/react-async/src/useAsync.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
15 changes: 11 additions & 4 deletions stories/index.stories.js
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -43,9 +43,16 @@ const App = () => {
return (
<>
<DevTools />
<Photo photoId={1} />
<Photo photoId={2} />
<Photo photoId={3} />
<div>
<Photo photoId={1} />
<Photo photoId={2} />
<Photo photoId={3} />
</div>
<Suspense fallback={<>Suspended...</>}>
<Photo suspense photoId={4} />
<Photo suspense photoId={5} />
<Photo suspense photoId={6} />
</Suspense>
</>
)
}
Expand Down

0 comments on commit 4cfaa0c

Please sign in to comment.