Skip to content

Commit 4cfaa0c

Browse files
authored
Add experimental Suspense support (#153)
* 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.
1 parent 11f8996 commit 4cfaa0c

File tree

15 files changed

+219
-6
lines changed

15 files changed

+219
-6
lines changed

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ React Async has no direct relation to Concurrent React. They are conceptually cl
138138
meant to make dealing with asynchronous business logic easier. Concurrent React will make those features have less
139139
impact on performance and usability. When Suspense lands, React Async will make full use of Suspense features. In fact,
140140
you can already **start using React Async right now**, and in a later update, you'll **get Suspense features for free**.
141+
In fact, React Async already has experimental support for Suspense, by passing the `suspense` option.
141142

142143
[concurrent react]: https://github.com/sw-yx/fresh-concurrent-react/blob/master/Intro.md#introduction-what-is-concurrent-react
143144

@@ -441,6 +442,7 @@ These can be passed in an object to `useAsync()`, or as props to `<Async>` and c
441442
- `reducer` State reducer to control internal state updates.
442443
- `dispatcher` Action dispatcher to control internal action dispatching.
443444
- `debugLabel` Unique label used in DevTools.
445+
- `suspense` Enable **experimental** Suspense integration.
444446

445447
`useFetch` additionally takes these options:
446448

@@ -557,6 +559,22 @@ dispatcher at some point.
557559
A unique label to describe this React Async instance, used in React DevTools (through `useDebugValue`) and React Async
558560
DevTools.
559561

562+
#### `suspense`
563+
564+
> `boolean`
565+
566+
Enables **experimental** Suspense integration. This will make React Async throw a promise while loading, so you can use
567+
Suspense to render a fallback UI, instead of using `<IfPending>`. Suspense differs in 2 main ways:
568+
569+
- `<Suspense>` should be an ancestor of your Async component, instead of a descendant. It can be anywhere up in the
570+
component hierarchy.
571+
- You can have a single `<Suspense>` wrap multiple Async components, in which case it will render the fallback UI until
572+
all promises are settled.
573+
574+
> Note that the way Suspense is integrated right now may change. Until Suspense for data fetching is officially
575+
> released, we may make breaking changes to its integration in React Async in a minor or patch release. Among other
576+
> things, we'll probably add a cache of sorts.
577+
560578
#### `defer`
561579

562580
> `boolean`

examples/with-suspense/.env

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
SKIP_PREFLIGHT_CHECK=true

examples/with-suspense/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Basic fetch with Suspense
2+
3+
This demonstrates how Suspense can be used to render a fallback UI while loading.
4+
5+
<a href="https://react-async.async-library.now.sh/examples/with-suspense">
6+
<img src="https://img.shields.io/badge/live-demo-blue.svg" alt="live demo">
7+
</a>

examples/with-suspense/package.json

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
{
2+
"name": "with-suspense-example",
3+
"version": "8.0.0",
4+
"private": true,
5+
"homepage": "https://react-async.async-library.now.sh/examples/with-suspense",
6+
"scripts": {
7+
"postinstall": "relative-deps",
8+
"prestart": "relative-deps",
9+
"prebuild": "relative-deps",
10+
"pretest": "relative-deps",
11+
"start": "react-scripts start",
12+
"build": "react-scripts build",
13+
"test": "react-scripts test",
14+
"now-build": "SKIP_PREFLIGHT_CHECK=true react-scripts build"
15+
},
16+
"dependencies": {
17+
"react": "16.10.1",
18+
"react-async": "8.0.0",
19+
"react-async-devtools": "8.0.0",
20+
"react-dom": "16.10.1",
21+
"react-scripts": "3.1.2"
22+
},
23+
"devDependencies": {
24+
"relative-deps": "0.1.2"
25+
},
26+
"relativeDependencies": {
27+
"react-async": "../../packages/react-async/pkg",
28+
"react-async-devtools": "../../packages/react-async-devtools/pkg"
29+
},
30+
"eslintConfig": {
31+
"extends": "react-app"
32+
},
33+
"browserslist": [
34+
">0.2%",
35+
"not dead",
36+
"not ie <= 11",
37+
"not op_mini all"
38+
],
39+
"engines": {
40+
"node": ">=8"
41+
}
42+
}
3.78 KB
Binary file not shown.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
6+
<meta name="theme-color" content="#000000" />
7+
<title>React App</title>
8+
</head>
9+
<body>
10+
<noscript> You need to enable JavaScript to run this app. </noscript>
11+
<div id="root"></div>
12+
</body>
13+
</html>

examples/with-suspense/src/index.css

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
body {
2+
margin: 20px;
3+
padding: 0;
4+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu",
5+
"Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
6+
-webkit-font-smoothing: antialiased;
7+
-moz-osx-font-smoothing: grayscale;
8+
}
9+
10+
.user {
11+
display: inline-block;
12+
margin: 20px;
13+
text-align: center;
14+
}
15+
16+
.avatar {
17+
background: #eee;
18+
border-radius: 64px;
19+
width: 128px;
20+
height: 128px;
21+
}
22+
23+
.name {
24+
margin-top: 10px;
25+
}
26+
27+
.placeholder {
28+
opacity: 0.5;
29+
}

examples/with-suspense/src/index.js

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import React, { Suspense } from "react"
2+
import { useAsync, IfFulfilled, IfRejected } from "react-async"
3+
import ReactDOM from "react-dom"
4+
import DevTools from "react-async-devtools"
5+
import "./index.css"
6+
7+
const loadUser = ({ userId }) =>
8+
fetch(`https://reqres.in/api/users/${userId}`)
9+
.then(res => (res.ok ? res : Promise.reject(res)))
10+
.then(res => res.json())
11+
.then(({ data }) => data)
12+
13+
const UserPlaceholder = () => (
14+
<div className="user placeholder">
15+
<div className="avatar" />
16+
<div className="name">══════</div>
17+
</div>
18+
)
19+
20+
const UserDetails = ({ data }) => (
21+
<div className="user">
22+
<img className="avatar" src={data.avatar} alt="" />
23+
<div className="name">
24+
{data.first_name} {data.last_name}
25+
</div>
26+
</div>
27+
)
28+
29+
const User = ({ userId }) => {
30+
const state = useAsync({
31+
suspense: true,
32+
promiseFn: loadUser,
33+
debugLabel: `User ${userId}`,
34+
userId,
35+
})
36+
return (
37+
<>
38+
<IfFulfilled state={state}>{data => <UserDetails data={data} />}</IfFulfilled>
39+
<IfRejected state={state}>{error => <p>{error.message}</p>}</IfRejected>
40+
</>
41+
)
42+
}
43+
44+
export const App = () => (
45+
<>
46+
<DevTools />
47+
<Suspense
48+
fallback={
49+
<>
50+
<UserPlaceholder />
51+
<UserPlaceholder />
52+
</>
53+
}
54+
>
55+
<User userId={1} />
56+
<User userId={2} />
57+
</Suspense>
58+
</>
59+
)
60+
61+
if (process.env.NODE_ENV !== "test") ReactDOM.render(<App />, document.getElementById("root"))
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import React from "react"
2+
import ReactDOM from "react-dom"
3+
import { App } from "./"
4+
5+
it("renders without crashing", () => {
6+
const div = document.createElement("div")
7+
ReactDOM.render(<App />, div)
8+
ReactDOM.unmountComponentAtNode(div)
9+
})

packages/react-async/src/Async.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,11 @@ export const createInstance = (defaultProps = {}, displayName = "Async") => {
193193
}
194194

195195
render() {
196-
const { children } = this.props
196+
const { children, suspense } = this.props
197+
if (suspense && this.state.isPending && this.promise !== neverSettle) {
198+
// Rely on Suspense to handle the loading state
199+
throw this.promise
200+
}
197201
if (typeof children === "function") {
198202
return <Provider value={this.state}>{children(this.state)}</Provider>
199203
}

packages/react-async/src/index.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export interface AsyncOptions<T> {
5050
props: AsyncProps<T>
5151
) => void
5252
debugLabel?: string
53+
suspense?: boolean
5354
[prop: string]: any
5455
}
5556

packages/react-async/src/propTypes.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export default PropTypes && {
4444
reducer: PropTypes.func,
4545
dispatcher: PropTypes.func,
4646
debugLabel: PropTypes.string,
47+
suspense: PropTypes.bool,
4748
},
4849
Initial: {
4950
children: childrenFn.isRequired,

packages/react-async/src/specs.js

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
/* eslint-disable react/prop-types */
33

44
import "@testing-library/jest-dom/extend-expect"
5-
import React from "react"
5+
import React, { Suspense } from "react"
66
import { render, fireEvent } from "@testing-library/react"
77

88
export const resolveIn = ms => value => new Promise(resolve => setTimeout(resolve, ms, value))
@@ -65,6 +65,21 @@ export const common = Async => () => {
6565
await findByText("done")
6666
expect(onCancel).not.toHaveBeenCalled()
6767
})
68+
69+
// Skip when testing for backwards-compatibility with React 16.3
70+
const testSuspense = Suspense ? test : test.skip
71+
testSuspense("supports Suspense", async () => {
72+
const promiseFn = () => resolveIn(150)("done")
73+
const { findByText } = render(
74+
<Suspense fallback={<div>fallback</div>}>
75+
<Async suspense promiseFn={promiseFn}>
76+
{({ data }) => data || null}
77+
</Async>
78+
</Suspense>
79+
)
80+
await findByText("fallback")
81+
await findByText("done")
82+
})
6883
}
6984

7085
export const withPromise = Async => () => {

packages/react-async/src/useAsync.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,11 @@ const useAsync = (arg1, arg2) => {
147147

148148
useDebugValue(state, ({ status }) => `[${counter.current}] ${status}`)
149149

150+
if (options.suspense && state.isPending && lastPromise.current !== neverSettle) {
151+
// Rely on Suspense to handle the loading state
152+
throw lastPromise.current
153+
}
154+
150155
return useMemo(
151156
() => ({
152157
...state,

stories/index.stories.js

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React from "react"
1+
import React, { Suspense } from "react"
22
import { storiesOf } from "@storybook/react"
33

44
import { useAsync } from "../packages/react-async/src"
@@ -43,9 +43,16 @@ const App = () => {
4343
return (
4444
<>
4545
<DevTools />
46-
<Photo photoId={1} />
47-
<Photo photoId={2} />
48-
<Photo photoId={3} />
46+
<div>
47+
<Photo photoId={1} />
48+
<Photo photoId={2} />
49+
<Photo photoId={3} />
50+
</div>
51+
<Suspense fallback={<>Suspended...</>}>
52+
<Photo suspense photoId={4} />
53+
<Photo suspense photoId={5} />
54+
<Photo suspense photoId={6} />
55+
</Suspense>
4956
</>
5057
)
5158
}

0 commit comments

Comments
 (0)