Skip to content

Commit 913a8fa

Browse files
authored
PropTypes and debugValue (#23)
This adds optional propType checking for components and `useDebugValue` for hooks.
1 parent a724a0d commit 913a8fa

File tree

7 files changed

+317
-241
lines changed

7 files changed

+317
-241
lines changed

README.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ error states, without assumptions about the shape of your data or the type of re
8080
- [Form submission](#form-submission)
8181
- [Optimistic updates](#optimistic-updates)
8282
- [Server-side rendering](#server-side-rendering)
83-
- [Who's using React Async](#whos-using-react-async)
83+
- [Who's using React Async?](#whos-using-react-async)
8484
- [Acknowledgements](#acknowledgements)
8585

8686
## Rationale
@@ -353,6 +353,7 @@ Callback function invoked when a promise rejects, receives rejection reason (err
353353
- `isLoading` Whether or not a Promise is currently pending.
354354
- `startedAt` When the current/last promise was started.
355355
- `finishedAt` When the last promise was resolved or rejected.
356+
- `counter` The number of times a promise was started.
356357
- `cancel` Cancel any pending promise.
357358
- `run` Invokes the `deferFn`.
358359
- `reload` Re-runs the promise when invoked, using the any previous arguments.
@@ -395,6 +396,12 @@ Tracks when the current/last promise was started.
395396
396397
Tracks when the last promise was resolved or rejected.
397398

399+
#### `counter`
400+
401+
> `number`
402+
403+
The number of times a promise was started.
404+
398405
#### `cancel`
399406

400407
> `function(): void`
@@ -637,7 +644,7 @@ render() {
637644
}
638645
```
639646

640-
## Who's using React Async
647+
## Who's using React Async?
641648

642649
<a href="https://xebia.com"><img src="https://user-images.githubusercontent.com/321738/52999660-a9949780-3426-11e9-9a7e-42b400f4ccbe.png" height="40" alt="Xebia" /></a> <a href="https://intergamma.nl"><img src="https://user-images.githubusercontent.com/321738/52999676-b5805980-3426-11e9-899e-6c9669176df4.png" height="40" alt="Intergamma" /></a>
643650

package.json

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,19 +28,22 @@
2828
"scripts": {
2929
"build": "rimraf lib && babel src -d lib --ignore '**/*spec.js'",
3030
"lint": "eslint src",
31-
"test": "jest src/spec.js --collectCoverageFrom=src/index.js",
31+
"test": "jest src/Async.spec.js --collectCoverageFrom=src/Async.js",
3232
"test:hook": "jest src/useAsync.spec.js --collectCoverageFrom=src/useAsync.js",
3333
"test:watch": "npm run test -- --watch",
3434
"test:compat": "npm run test:backwards && npm run test:forwards && npm run test:latest",
35-
"test:backwards": "npm i [email protected] [email protected] && npm test",
36-
"test:forwards": "npm i react@next react-dom@next && npm test && npm run test:hook",
37-
"test:latest": "npm i react@latest react-dom@latest && npm test && npm run test:hook",
35+
"test:backwards": "npm i [email protected] [email protected] --no-save && npm test",
36+
"test:forwards": "npm i react@next react-dom@next --no-save && npm test && npm run test:hook",
37+
"test:latest": "npm i react@latest react-dom@latest --no-save && npm test && npm run test:hook",
3838
"prepublishOnly": "npm run lint && npm run test:compat && npm run build"
3939
},
4040
"dependencies": {},
4141
"peerDependencies": {
4242
"react": ">=16.3.1"
4343
},
44+
"optionalDependencies": {
45+
"prop-types": ">=15.5.7"
46+
},
4447
"devDependencies": {
4548
"babel-cli": "6.26.0",
4649
"babel-eslint": "10.0.1",

src/Async.js

Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
import React from "react"
2+
3+
let PropTypes
4+
try {
5+
PropTypes = require("prop-types")
6+
} catch (e) {}
7+
8+
const isFunction = arg => typeof arg === "function"
9+
10+
/**
11+
* createInstance allows you to create instances of Async that are bound to a specific promise.
12+
* A unique instance also uses its own React context for better nesting capability.
13+
*/
14+
export const createInstance = (defaultProps = {}, displayName = "Async") => {
15+
const { Consumer, Provider } = React.createContext()
16+
17+
class Async extends React.Component {
18+
constructor(props) {
19+
super(props)
20+
21+
this.start = this.start.bind(this)
22+
this.load = this.load.bind(this)
23+
this.run = this.run.bind(this)
24+
this.cancel = this.cancel.bind(this)
25+
this.onResolve = this.onResolve.bind(this)
26+
this.onReject = this.onReject.bind(this)
27+
this.setData = this.setData.bind(this)
28+
this.setError = this.setError.bind(this)
29+
30+
const promiseFn = props.promiseFn || defaultProps.promiseFn
31+
const initialValue = props.initialValue || defaultProps.initialValue
32+
const initialError = initialValue instanceof Error ? initialValue : undefined
33+
const initialData = initialError ? undefined : initialValue
34+
35+
this.mounted = false
36+
this.counter = 0
37+
this.args = []
38+
this.abortController = { abort: () => {} }
39+
this.state = {
40+
initialValue,
41+
data: initialData,
42+
error: initialError,
43+
isLoading: !initialValue && isFunction(promiseFn),
44+
startedAt: undefined,
45+
finishedAt: initialValue ? new Date() : undefined,
46+
counter: this.counter,
47+
cancel: this.cancel,
48+
run: this.run,
49+
reload: () => {
50+
this.load()
51+
this.run(...this.args)
52+
},
53+
setData: this.setData,
54+
setError: this.setError,
55+
}
56+
}
57+
58+
componentDidMount() {
59+
this.mounted = true
60+
this.state.initialValue || this.load()
61+
}
62+
63+
componentDidUpdate(prevProps) {
64+
const { watch, watchFn = defaultProps.watchFn, promiseFn } = this.props
65+
if (watch !== prevProps.watch) this.load()
66+
if (watchFn && watchFn({ ...defaultProps, ...this.props }, { ...defaultProps, ...prevProps }))
67+
this.load()
68+
if (promiseFn !== prevProps.promiseFn) {
69+
if (promiseFn) this.load()
70+
else this.cancel()
71+
}
72+
}
73+
74+
componentWillUnmount() {
75+
this.cancel()
76+
this.mounted = false
77+
}
78+
79+
start() {
80+
if ("AbortController" in window) {
81+
this.abortController.abort()
82+
this.abortController = new window.AbortController()
83+
}
84+
this.counter++
85+
this.setState({
86+
isLoading: true,
87+
startedAt: new Date(),
88+
finishedAt: undefined,
89+
counter: this.counter,
90+
})
91+
}
92+
93+
load() {
94+
const promiseFn = this.props.promiseFn || defaultProps.promiseFn
95+
if (!promiseFn) return
96+
this.start()
97+
return promiseFn(this.props, this.abortController).then(
98+
this.onResolve(this.counter),
99+
this.onReject(this.counter)
100+
)
101+
}
102+
103+
run(...args) {
104+
const deferFn = this.props.deferFn || defaultProps.deferFn
105+
if (!deferFn) return
106+
this.args = args
107+
this.start()
108+
return deferFn(args, { ...defaultProps, ...this.props }, this.abortController).then(
109+
this.onResolve(this.counter),
110+
this.onReject(this.counter)
111+
)
112+
}
113+
114+
cancel() {
115+
this.counter++
116+
this.abortController.abort()
117+
this.setState({ isLoading: false, startedAt: undefined, counter: this.counter })
118+
}
119+
120+
onResolve(counter) {
121+
return data => {
122+
if (this.mounted && this.counter === counter) {
123+
const onResolve = this.props.onResolve || defaultProps.onResolve
124+
this.setData(data, () => onResolve && onResolve(data))
125+
}
126+
return data
127+
}
128+
}
129+
130+
onReject(counter) {
131+
return error => {
132+
if (this.mounted && this.counter === counter) {
133+
const onReject = this.props.onReject || defaultProps.onReject
134+
this.setError(error, () => onReject && onReject(error))
135+
}
136+
return error
137+
}
138+
}
139+
140+
setData(data, callback) {
141+
this.setState({ data, error: undefined, isLoading: false, finishedAt: new Date() }, callback)
142+
return data
143+
}
144+
145+
setError(error, callback) {
146+
this.setState({ error, isLoading: false, finishedAt: new Date() }, callback)
147+
return error
148+
}
149+
150+
render() {
151+
const { children } = this.props
152+
if (isFunction(children)) {
153+
return <Provider value={this.state}>{children(this.state)}</Provider>
154+
}
155+
if (children !== undefined && children !== null) {
156+
return <Provider value={this.state}>{children}</Provider>
157+
}
158+
return null
159+
}
160+
}
161+
162+
if (PropTypes) {
163+
Async.propTypes = {
164+
promiseFn: PropTypes.func,
165+
deferFn: PropTypes.func,
166+
watch: PropTypes.any,
167+
watchFn: PropTypes.func,
168+
initialValue: PropTypes.any,
169+
onResolve: PropTypes.func,
170+
onReject: PropTypes.func,
171+
}
172+
}
173+
174+
/**
175+
* Renders only when deferred promise is pending (not yet run).
176+
*
177+
* @prop {Function|Node} children Function (passing state) or React node
178+
* @prop {boolean} persist Show until we have data, even while loading or when an error occurred
179+
*/
180+
const Pending = ({ children, persist }) => (
181+
<Consumer>
182+
{state => {
183+
if (state.data !== undefined) return null
184+
if (!persist && state.isLoading) return null
185+
if (!persist && state.error !== undefined) return null
186+
return isFunction(children) ? children(state) : children || null
187+
}}
188+
</Consumer>
189+
)
190+
191+
if (PropTypes) {
192+
Pending.propTypes = {
193+
children: PropTypes.oneOfType([PropTypes.node, PropTypes.func]).isRequired,
194+
persist: PropTypes.bool,
195+
}
196+
}
197+
198+
/**
199+
* Renders only while loading.
200+
*
201+
* @prop {Function|Node} children Function (passing state) or React node
202+
* @prop {boolean} initial Show only on initial load (data is undefined)
203+
*/
204+
const Loading = ({ children, initial }) => (
205+
<Consumer>
206+
{state => {
207+
if (!state.isLoading) return null
208+
if (initial && state.data !== undefined) return null
209+
return isFunction(children) ? children(state) : children || null
210+
}}
211+
</Consumer>
212+
)
213+
214+
if (PropTypes) {
215+
Loading.propTypes = {
216+
children: PropTypes.oneOfType([PropTypes.node, PropTypes.func]).isRequired,
217+
initial: PropTypes.bool,
218+
}
219+
}
220+
221+
/**
222+
* Renders only when promise is resolved.
223+
*
224+
* @prop {Function|Node} children Function (passing data and state) or React node
225+
* @prop {boolean} persist Show old data while loading
226+
*/
227+
const Resolved = ({ children, persist }) => (
228+
<Consumer>
229+
{state => {
230+
if (state.data === undefined) return null
231+
if (!persist && state.isLoading) return null
232+
if (!persist && state.error !== undefined) return null
233+
return isFunction(children) ? children(state.data, state) : children || null
234+
}}
235+
</Consumer>
236+
)
237+
238+
if (PropTypes) {
239+
Resolved.propTypes = {
240+
children: PropTypes.oneOfType([PropTypes.node, PropTypes.func]).isRequired,
241+
persist: PropTypes.bool,
242+
}
243+
}
244+
245+
/**
246+
* Renders only when promise is rejected.
247+
*
248+
* @prop {Function|Node} children Function (passing error and state) or React node
249+
* @prop {boolean} persist Show old error while loading
250+
*/
251+
const Rejected = ({ children, persist }) => (
252+
<Consumer>
253+
{state => {
254+
if (state.error === undefined) return null
255+
if (state.isLoading && !persist) return null
256+
return isFunction(children) ? children(state.error, state) : children || null
257+
}}
258+
</Consumer>
259+
)
260+
261+
if (PropTypes) {
262+
Rejected.propTypes = {
263+
children: PropTypes.oneOfType([PropTypes.node, PropTypes.func]).isRequired,
264+
persist: PropTypes.bool,
265+
}
266+
}
267+
268+
Async.Pending = Pending
269+
Async.Loading = Loading
270+
Async.Resolved = Resolved
271+
Async.Rejected = Rejected
272+
273+
Async.displayName = displayName
274+
Async.Pending.displayName = `${displayName}.Pending`
275+
Async.Loading.displayName = `${displayName}.Loading`
276+
Async.Resolved.displayName = `${displayName}.Resolved`
277+
Async.Rejected.displayName = `${displayName}.Rejected`
278+
279+
return Async
280+
}
281+
282+
export default createInstance()

src/spec.js renamed to src/Async.spec.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import "jest-dom/extend-expect"
22
import React from "react"
33
import { render, fireEvent, cleanup, waitForElement } from "react-testing-library"
4-
import Async, { createInstance } from "./"
4+
import Async, { createInstance } from "./Async"
55

66
const abortCtrl = { abort: jest.fn() }
77
window.AbortController = jest.fn().mockImplementation(() => abortCtrl)

0 commit comments

Comments
 (0)