Skip to content

Commit 048d697

Browse files
authored
Expose wrapper promise as a prop so it can be chained on (fixes #79) (#83)
* Expose wrapper promise as a prop so it can be chained on. * Let run return void. * Update propTypes.
1 parent 400a981 commit 048d697

File tree

8 files changed

+104
-47
lines changed

8 files changed

+104
-47
lines changed

README.md

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -547,9 +547,10 @@ is set to `"application/json"`.
547547
- `isRejected` true when the last promise was rejected.
548548
- `isSettled` true when the last promise was fulfilled or rejected (not initial or pending).
549549
- `counter` The number of times a promise was started.
550-
- `cancel` Cancel any pending promise.
550+
- `promise` A reference to the internal wrapper promise, which can be chained on.
551551
- `run` Invokes the `deferFn`.
552552
- `reload` Re-runs the promise when invoked, using any previous arguments.
553+
- `cancel` Cancel any pending promise.
553554
- `setData` Sets `data` to the passed value, unsets `error` and cancels any pending promise.
554555
- `setError` Sets `error` to the passed value and cancels any pending promise.
555556

@@ -636,24 +637,32 @@ Alias: `isResolved`
636637
637638
The number of times a promise was started.
638639

639-
#### `cancel`
640+
#### `promise`
640641

641-
> `function(): void`
642+
> `Promise`
642643
643-
Cancels the currently pending promise by ignoring its result and calls `abort()` on the AbortController.
644+
A reference to the internal wrapper promise created when starting a new promise (either automatically or by invoking
645+
`run` / `reload`). It fulfills or rejects along with the provided `promise` / `promiseFn` / `deferFn`. Useful as a
646+
chainable alternative to the `onResolve` / `onReject` callbacks.
644647

645648
#### `run`
646649

647-
> `function(...args: any[]): Promise`
650+
> `function(...args: any[]): void`
648651
649-
Runs the `deferFn`, passing any arguments provided as an array. The returned Promise always **fulfills** to `data` or `error`, it never rejects.
652+
Runs the `deferFn`, passing any arguments provided as an array.
650653

651654
#### `reload`
652655

653656
> `function(): void`
654657
655658
Re-runs the promise when invoked, using the previous arguments.
656659

660+
#### `cancel`
661+
662+
> `function(): void`
663+
664+
Cancels the currently pending promise by ignoring its result and calls `abort()` on the AbortController.
665+
657666
#### `setData`
658667

659668
> `function(data: any, callback?: () => void): any`

packages/react-async/src/Async.js

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export const createInstance = (defaultProps = {}, displayName = "Async") => {
3232
this.mounted = false
3333
this.counter = 0
3434
this.args = []
35+
this.promise = undefined
3536
this.abortController = { abort: () => {} }
3637
this.state = {
3738
...init({ initialValue, promise, promiseFn }),
@@ -96,6 +97,7 @@ export const createInstance = (defaultProps = {}, displayName = "Async") => {
9697
getMeta(meta) {
9798
return {
9899
counter: this.counter,
100+
promise: this.promise,
99101
debugLabel: this.debugLabel,
100102
...meta,
101103
}
@@ -107,28 +109,25 @@ export const createInstance = (defaultProps = {}, displayName = "Async") => {
107109
this.abortController = new globalScope.AbortController()
108110
}
109111
this.counter++
110-
return new Promise((resolve, reject) => {
112+
return (this.promise = new Promise((resolve, reject) => {
111113
if (!this.mounted) return
112114
const executor = () => promiseFn().then(resolve, reject)
113115
this.dispatch({ type: actionTypes.start, payload: executor, meta: this.getMeta() })
114-
})
116+
}))
115117
}
116118

117119
load() {
118120
const promise = this.props.promise
119-
if (promise) {
120-
return this.start(() => promise).then(
121-
this.onResolve(this.counter),
122-
this.onReject(this.counter)
123-
)
124-
}
125121
const promiseFn = this.props.promiseFn || defaultProps.promiseFn
126-
if (promiseFn) {
122+
if (promise) {
123+
this.start(() => promise)
124+
.then(this.onResolve(this.counter))
125+
.catch(this.onReject(this.counter))
126+
} else if (promiseFn) {
127127
const props = { ...defaultProps, ...this.props }
128-
return this.start(() => promiseFn(props, this.abortController)).then(
129-
this.onResolve(this.counter),
130-
this.onReject(this.counter)
131-
)
128+
this.start(() => promiseFn(props, this.abortController))
129+
.then(this.onResolve(this.counter))
130+
.catch(this.onReject(this.counter))
132131
}
133132
}
134133

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,9 @@ export interface AsyncProps<T> extends AsyncOptions<T> {
6060
interface AbstractState<T> {
6161
initialValue?: T | Error
6262
counter: number
63+
promise: Promise<T>
6364
cancel: () => void
64-
run: (...args: any[]) => Promise<T>
65+
run: (...args: any[]) => void
6566
reload: () => void
6667
setData: (data: T, callback?: () => void) => T
6768
setError: (error: Error, callback?: () => void) => Error

packages/react-async/src/propTypes.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,10 @@ const stateObject =
2222
isRejected: PropTypes.bool,
2323
isSettled: PropTypes.bool,
2424
counter: PropTypes.number,
25-
cancel: PropTypes.func,
25+
promise: PropTypes.instanceOf(Promise),
2626
run: PropTypes.func,
2727
reload: PropTypes.func,
28+
cancel: PropTypes.func,
2829
setData: PropTypes.func,
2930
setError: PropTypes.func,
3031
})

packages/react-async/src/reducer.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export const init = ({ initialValue, promise, promiseFn }) => ({
1616
finishedAt: initialValue ? new Date() : undefined,
1717
...getStatusProps(getInitialStatus(initialValue, promise || promiseFn)),
1818
counter: 0,
19+
promise: undefined,
1920
})
2021

2122
export const reducer = (state, { type, payload, meta }) => {
@@ -27,6 +28,7 @@ export const reducer = (state, { type, payload, meta }) => {
2728
finishedAt: undefined,
2829
...getStatusProps(statusTypes.pending),
2930
counter: meta.counter,
31+
promise: meta.promise,
3032
}
3133
case actionTypes.cancel:
3234
return {
@@ -35,6 +37,7 @@ export const reducer = (state, { type, payload, meta }) => {
3537
finishedAt: undefined,
3638
...getStatusProps(getIdleStatus(state.error || state.data)),
3739
counter: meta.counter,
40+
promise: meta.promise,
3841
}
3942
case actionTypes.fulfill:
4043
return {
@@ -44,6 +47,7 @@ export const reducer = (state, { type, payload, meta }) => {
4447
error: undefined,
4548
finishedAt: new Date(),
4649
...getStatusProps(statusTypes.fulfilled),
50+
promise: meta.promise,
4751
}
4852
case actionTypes.reject:
4953
return {
@@ -52,6 +56,7 @@ export const reducer = (state, { type, payload, meta }) => {
5256
value: payload,
5357
finishedAt: new Date(),
5458
...getStatusProps(statusTypes.rejected),
59+
promise: meta.promise,
5560
}
5661
default:
5762
return state

packages/react-async/src/specs.js

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export const rejectTo = rejectIn(0)
1313
export const sleep = ms => resolveIn(ms)()
1414

1515
export const common = Async => () => {
16-
test("passes `data`, `error`, metadata and methods as render props", async () => {
16+
test("passes `data`, `error`, `promise`, metadata and methods as render props", async () => {
1717
render(
1818
<Async>
1919
{renderProps => {
@@ -29,9 +29,10 @@ export const common = Async => () => {
2929
expect(renderProps).toHaveProperty("isRejected")
3030
expect(renderProps).toHaveProperty("isSettled")
3131
expect(renderProps).toHaveProperty("counter")
32-
expect(renderProps).toHaveProperty("cancel")
32+
expect(renderProps).toHaveProperty("promise")
3333
expect(renderProps).toHaveProperty("run")
3434
expect(renderProps).toHaveProperty("reload")
35+
expect(renderProps).toHaveProperty("cancel")
3536
expect(renderProps).toHaveProperty("setData")
3637
expect(renderProps).toHaveProperty("setError")
3738
return null
@@ -167,6 +168,38 @@ export const withPromise = Async => () => {
167168
await findByText("init")
168169
await findByText("done")
169170
})
171+
172+
test("exposes the wrapper promise", async () => {
173+
const onFulfilled = jest.fn()
174+
const onRejected = jest.fn()
175+
const { findByText } = render(
176+
<Async promise={resolveTo("done")}>
177+
{({ data, promise }) => {
178+
promise && promise.then(onFulfilled, onRejected)
179+
return data || null
180+
}}
181+
</Async>
182+
)
183+
await findByText("done")
184+
expect(onFulfilled).toHaveBeenCalledWith("done")
185+
expect(onRejected).not.toHaveBeenCalled()
186+
})
187+
188+
test("the wrapper promise rejects on error", async () => {
189+
const onFulfilled = jest.fn()
190+
const onRejected = jest.fn()
191+
const { findByText } = render(
192+
<Async promise={rejectTo("err")}>
193+
{({ error, promise }) => {
194+
promise && promise.then(onFulfilled, onRejected)
195+
return error ? error.message : null
196+
}}
197+
</Async>
198+
)
199+
await findByText("err")
200+
expect(onFulfilled).not.toHaveBeenCalled()
201+
expect(onRejected).toHaveBeenCalledWith(new Error("err"))
202+
})
170203
}
171204

172205
export const withPromiseFn = (Async, abortCtrl) => () => {

packages/react-async/src/useAsync.js

Lines changed: 22 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const useAsync = (arg1, arg2) => {
1212
const isMounted = useRef(true)
1313
const lastArgs = useRef(undefined)
1414
const lastOptions = useRef(undefined)
15+
const lastPromise = useRef(undefined)
1516
const abortController = useRef({ abort: noop })
1617

1718
const { devToolsDispatcher } = globalScope.__REACT_ASYNC__
@@ -29,9 +30,10 @@ const useAsync = (arg1, arg2) => {
2930
)
3031

3132
const { debugLabel } = options
32-
const getMeta = useCallback(meta => ({ counter: counter.current, debugLabel, ...meta }), [
33-
debugLabel,
34-
])
33+
const getMeta = useCallback(
34+
meta => ({ counter: counter.current, promise: lastPromise.current, debugLabel, ...meta }),
35+
[debugLabel]
36+
)
3537

3638
const setData = useCallback(
3739
(data, callback = noop) => {
@@ -72,29 +74,26 @@ const useAsync = (arg1, arg2) => {
7274
abortController.current = new globalScope.AbortController()
7375
}
7476
counter.current++
75-
return new Promise((resolve, reject) => {
77+
return (lastPromise.current = new Promise((resolve, reject) => {
7678
if (!isMounted.current) return
7779
const executor = () => promiseFn().then(resolve, reject)
7880
dispatch({ type: actionTypes.start, payload: executor, meta: getMeta() })
79-
})
81+
}))
8082
},
8183
[dispatch, getMeta]
8284
)
8385

8486
const { promise, promiseFn, initialValue } = options
8587
const load = useCallback(() => {
86-
if (promise) {
87-
return start(() => promise).then(
88-
handleResolve(counter.current),
89-
handleReject(counter.current)
90-
)
91-
}
9288
const isPreInitialized = initialValue && counter.current === 0
93-
if (promiseFn && !isPreInitialized) {
94-
return start(() => promiseFn(lastOptions.current, abortController.current)).then(
95-
handleResolve(counter.current),
96-
handleReject(counter.current)
97-
)
89+
if (promise) {
90+
start(() => promise)
91+
.then(handleResolve(counter.current))
92+
.catch(handleReject(counter.current))
93+
} else if (promiseFn && !isPreInitialized) {
94+
start(() => promiseFn(lastOptions.current, abortController.current))
95+
.then(handleResolve(counter.current))
96+
.catch(handleReject(counter.current))
9897
}
9998
}, [start, promise, promiseFn, initialValue, handleResolve, handleReject])
10099

@@ -103,17 +102,16 @@ const useAsync = (arg1, arg2) => {
103102
(...args) => {
104103
if (deferFn) {
105104
lastArgs.current = args
106-
return start(() => deferFn(args, lastOptions.current, abortController.current)).then(
107-
handleResolve(counter.current),
108-
handleReject(counter.current)
109-
)
105+
start(() => deferFn(args, lastOptions.current, abortController.current))
106+
.then(handleResolve(counter.current))
107+
.catch(handleReject(counter.current))
110108
}
111109
},
112110
[start, deferFn, handleResolve, handleReject]
113111
)
114112

115113
const reload = useCallback(() => {
116-
return lastArgs.current ? run(...lastArgs.current) : load()
114+
lastArgs.current ? run(...lastArgs.current) : load()
117115
}, [run, load])
118116

119117
const { onCancel } = options
@@ -130,7 +128,9 @@ const useAsync = (arg1, arg2) => {
130128
useEffect(() => {
131129
if (watchFn && lastOptions.current && watchFn(options, lastOptions.current)) load()
132130
})
133-
useEffect(() => (lastOptions.current = options) && undefined)
131+
useEffect(() => {
132+
lastOptions.current = options
133+
})
134134
useEffect(() => {
135135
if (counter.current) cancel()
136136
if (promise || promiseFn) load()

packages/react-async/src/useAsync.spec.js

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,16 @@ describe("useAsync", () => {
9393
const [count, setCount] = React.useState(0)
9494
const onReject = count === 0 ? onReject1 : onReject2
9595
const { run } = useAsync({ deferFn, onReject })
96-
return <button onClick={() => run(count) && setCount(1)}>run</button>
96+
return (
97+
<button
98+
onClick={() => {
99+
run(count)
100+
setCount(1)
101+
}}
102+
>
103+
run
104+
</button>
105+
)
97106
}
98107
const { getByText } = render(<App />)
99108
fireEvent.click(getByText("run"))
@@ -110,7 +119,7 @@ test("does not return a new `run` function on every render", async () => {
110119
const DeleteScheduleForm = () => {
111120
const [value, setValue] = React.useState()
112121
const { run } = useAsync({ deferFn })
113-
React.useEffect(() => value && run() && undefined, [value, run])
122+
React.useEffect(() => value && run(), [value, run])
114123
return <button onClick={() => setValue(true)}>run</button>
115124
}
116125
const component = <DeleteScheduleForm />

0 commit comments

Comments
 (0)