Skip to content

Commit

Permalink
Expose wrapper promise as a prop so it can be chained on (fixes #79) (#…
Browse files Browse the repository at this point in the history
…83)

* Expose wrapper promise as a prop so it can be chained on.

* Let run return void.

* Update propTypes.
  • Loading branch information
ghengeveld committed Aug 22, 2019
1 parent 400a981 commit 048d697
Show file tree
Hide file tree
Showing 8 changed files with 104 additions and 47 deletions.
21 changes: 15 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -547,9 +547,10 @@ is set to `"application/json"`.
- `isRejected` true when the last promise was rejected.
- `isSettled` true when the last promise was fulfilled or rejected (not initial or pending).
- `counter` The number of times a promise was started.
- `cancel` Cancel any pending promise.
- `promise` A reference to the internal wrapper promise, which can be chained on.
- `run` Invokes the `deferFn`.
- `reload` Re-runs the promise when invoked, using any previous arguments.
- `cancel` Cancel any pending promise.
- `setData` Sets `data` to the passed value, unsets `error` and cancels any pending promise.
- `setError` Sets `error` to the passed value and cancels any pending promise.

Expand Down Expand Up @@ -636,24 +637,32 @@ Alias: `isResolved`
The number of times a promise was started.

#### `cancel`
#### `promise`

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

#### `run`

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

#### `reload`

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

#### `cancel`

> `function(): void`
Cancels the currently pending promise by ignoring its result and calls `abort()` on the AbortController.

#### `setData`

> `function(data: any, callback?: () => void): any`
Expand Down
25 changes: 12 additions & 13 deletions packages/react-async/src/Async.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export const createInstance = (defaultProps = {}, displayName = "Async") => {
this.mounted = false
this.counter = 0
this.args = []
this.promise = undefined
this.abortController = { abort: () => {} }
this.state = {
...init({ initialValue, promise, promiseFn }),
Expand Down Expand Up @@ -96,6 +97,7 @@ export const createInstance = (defaultProps = {}, displayName = "Async") => {
getMeta(meta) {
return {
counter: this.counter,
promise: this.promise,
debugLabel: this.debugLabel,
...meta,
}
Expand All @@ -107,28 +109,25 @@ export const createInstance = (defaultProps = {}, displayName = "Async") => {
this.abortController = new globalScope.AbortController()
}
this.counter++
return new Promise((resolve, reject) => {
return (this.promise = new Promise((resolve, reject) => {
if (!this.mounted) return
const executor = () => promiseFn().then(resolve, reject)
this.dispatch({ type: actionTypes.start, payload: executor, meta: this.getMeta() })
})
}))
}

load() {
const promise = this.props.promise
if (promise) {
return this.start(() => promise).then(
this.onResolve(this.counter),
this.onReject(this.counter)
)
}
const promiseFn = this.props.promiseFn || defaultProps.promiseFn
if (promiseFn) {
if (promise) {
this.start(() => promise)
.then(this.onResolve(this.counter))
.catch(this.onReject(this.counter))
} else if (promiseFn) {
const props = { ...defaultProps, ...this.props }
return this.start(() => promiseFn(props, this.abortController)).then(
this.onResolve(this.counter),
this.onReject(this.counter)
)
this.start(() => promiseFn(props, this.abortController))
.then(this.onResolve(this.counter))
.catch(this.onReject(this.counter))
}
}

Expand Down
3 changes: 2 additions & 1 deletion packages/react-async/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,9 @@ export interface AsyncProps<T> extends AsyncOptions<T> {
interface AbstractState<T> {
initialValue?: T | Error
counter: number
promise: Promise<T>
cancel: () => void
run: (...args: any[]) => Promise<T>
run: (...args: any[]) => void
reload: () => void
setData: (data: T, callback?: () => void) => T
setError: (error: Error, callback?: () => void) => Error
Expand Down
3 changes: 2 additions & 1 deletion packages/react-async/src/propTypes.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,10 @@ const stateObject =
isRejected: PropTypes.bool,
isSettled: PropTypes.bool,
counter: PropTypes.number,
cancel: PropTypes.func,
promise: PropTypes.instanceOf(Promise),
run: PropTypes.func,
reload: PropTypes.func,
cancel: PropTypes.func,
setData: PropTypes.func,
setError: PropTypes.func,
})
Expand Down
5 changes: 5 additions & 0 deletions packages/react-async/src/reducer.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export const init = ({ initialValue, promise, promiseFn }) => ({
finishedAt: initialValue ? new Date() : undefined,
...getStatusProps(getInitialStatus(initialValue, promise || promiseFn)),
counter: 0,
promise: undefined,
})

export const reducer = (state, { type, payload, meta }) => {
Expand All @@ -27,6 +28,7 @@ export const reducer = (state, { type, payload, meta }) => {
finishedAt: undefined,
...getStatusProps(statusTypes.pending),
counter: meta.counter,
promise: meta.promise,
}
case actionTypes.cancel:
return {
Expand All @@ -35,6 +37,7 @@ export const reducer = (state, { type, payload, meta }) => {
finishedAt: undefined,
...getStatusProps(getIdleStatus(state.error || state.data)),
counter: meta.counter,
promise: meta.promise,
}
case actionTypes.fulfill:
return {
Expand All @@ -44,6 +47,7 @@ export const reducer = (state, { type, payload, meta }) => {
error: undefined,
finishedAt: new Date(),
...getStatusProps(statusTypes.fulfilled),
promise: meta.promise,
}
case actionTypes.reject:
return {
Expand All @@ -52,6 +56,7 @@ export const reducer = (state, { type, payload, meta }) => {
value: payload,
finishedAt: new Date(),
...getStatusProps(statusTypes.rejected),
promise: meta.promise,
}
default:
return state
Expand Down
37 changes: 35 additions & 2 deletions packages/react-async/src/specs.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export const rejectTo = rejectIn(0)
export const sleep = ms => resolveIn(ms)()

export const common = Async => () => {
test("passes `data`, `error`, metadata and methods as render props", async () => {
test("passes `data`, `error`, `promise`, metadata and methods as render props", async () => {
render(
<Async>
{renderProps => {
Expand All @@ -29,9 +29,10 @@ export const common = Async => () => {
expect(renderProps).toHaveProperty("isRejected")
expect(renderProps).toHaveProperty("isSettled")
expect(renderProps).toHaveProperty("counter")
expect(renderProps).toHaveProperty("cancel")
expect(renderProps).toHaveProperty("promise")
expect(renderProps).toHaveProperty("run")
expect(renderProps).toHaveProperty("reload")
expect(renderProps).toHaveProperty("cancel")
expect(renderProps).toHaveProperty("setData")
expect(renderProps).toHaveProperty("setError")
return null
Expand Down Expand Up @@ -167,6 +168,38 @@ export const withPromise = Async => () => {
await findByText("init")
await findByText("done")
})

test("exposes the wrapper promise", async () => {
const onFulfilled = jest.fn()
const onRejected = jest.fn()
const { findByText } = render(
<Async promise={resolveTo("done")}>
{({ data, promise }) => {
promise && promise.then(onFulfilled, onRejected)
return data || null
}}
</Async>
)
await findByText("done")
expect(onFulfilled).toHaveBeenCalledWith("done")
expect(onRejected).not.toHaveBeenCalled()
})

test("the wrapper promise rejects on error", async () => {
const onFulfilled = jest.fn()
const onRejected = jest.fn()
const { findByText } = render(
<Async promise={rejectTo("err")}>
{({ error, promise }) => {
promise && promise.then(onFulfilled, onRejected)
return error ? error.message : null
}}
</Async>
)
await findByText("err")
expect(onFulfilled).not.toHaveBeenCalled()
expect(onRejected).toHaveBeenCalledWith(new Error("err"))
})
}

export const withPromiseFn = (Async, abortCtrl) => () => {
Expand Down
44 changes: 22 additions & 22 deletions packages/react-async/src/useAsync.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const useAsync = (arg1, arg2) => {
const isMounted = useRef(true)
const lastArgs = useRef(undefined)
const lastOptions = useRef(undefined)
const lastPromise = useRef(undefined)
const abortController = useRef({ abort: noop })

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

const { debugLabel } = options
const getMeta = useCallback(meta => ({ counter: counter.current, debugLabel, ...meta }), [
debugLabel,
])
const getMeta = useCallback(
meta => ({ counter: counter.current, promise: lastPromise.current, debugLabel, ...meta }),
[debugLabel]
)

const setData = useCallback(
(data, callback = noop) => {
Expand Down Expand Up @@ -72,29 +74,26 @@ const useAsync = (arg1, arg2) => {
abortController.current = new globalScope.AbortController()
}
counter.current++
return new Promise((resolve, reject) => {
return (lastPromise.current = new Promise((resolve, reject) => {
if (!isMounted.current) return
const executor = () => promiseFn().then(resolve, reject)
dispatch({ type: actionTypes.start, payload: executor, meta: getMeta() })
})
}))
},
[dispatch, getMeta]
)

const { promise, promiseFn, initialValue } = options
const load = useCallback(() => {
if (promise) {
return start(() => promise).then(
handleResolve(counter.current),
handleReject(counter.current)
)
}
const isPreInitialized = initialValue && counter.current === 0
if (promiseFn && !isPreInitialized) {
return start(() => promiseFn(lastOptions.current, abortController.current)).then(
handleResolve(counter.current),
handleReject(counter.current)
)
if (promise) {
start(() => promise)
.then(handleResolve(counter.current))
.catch(handleReject(counter.current))
} else if (promiseFn && !isPreInitialized) {
start(() => promiseFn(lastOptions.current, abortController.current))
.then(handleResolve(counter.current))
.catch(handleReject(counter.current))
}
}, [start, promise, promiseFn, initialValue, handleResolve, handleReject])

Expand All @@ -103,17 +102,16 @@ const useAsync = (arg1, arg2) => {
(...args) => {
if (deferFn) {
lastArgs.current = args
return start(() => deferFn(args, lastOptions.current, abortController.current)).then(
handleResolve(counter.current),
handleReject(counter.current)
)
start(() => deferFn(args, lastOptions.current, abortController.current))
.then(handleResolve(counter.current))
.catch(handleReject(counter.current))
}
},
[start, deferFn, handleResolve, handleReject]
)

const reload = useCallback(() => {
return lastArgs.current ? run(...lastArgs.current) : load()
lastArgs.current ? run(...lastArgs.current) : load()
}, [run, load])

const { onCancel } = options
Expand All @@ -130,7 +128,9 @@ const useAsync = (arg1, arg2) => {
useEffect(() => {
if (watchFn && lastOptions.current && watchFn(options, lastOptions.current)) load()
})
useEffect(() => (lastOptions.current = options) && undefined)
useEffect(() => {
lastOptions.current = options
})
useEffect(() => {
if (counter.current) cancel()
if (promise || promiseFn) load()
Expand Down
13 changes: 11 additions & 2 deletions packages/react-async/src/useAsync.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,16 @@ describe("useAsync", () => {
const [count, setCount] = React.useState(0)
const onReject = count === 0 ? onReject1 : onReject2
const { run } = useAsync({ deferFn, onReject })
return <button onClick={() => run(count) && setCount(1)}>run</button>
return (
<button
onClick={() => {
run(count)
setCount(1)
}}
>
run
</button>
)
}
const { getByText } = render(<App />)
fireEvent.click(getByText("run"))
Expand All @@ -110,7 +119,7 @@ test("does not return a new `run` function on every render", async () => {
const DeleteScheduleForm = () => {
const [value, setValue] = React.useState()
const { run } = useAsync({ deferFn })
React.useEffect(() => value && run() && undefined, [value, run])
React.useEffect(() => value && run(), [value, run])
return <button onClick={() => setValue(true)}>run</button>
}
const component = <DeleteScheduleForm />
Expand Down

0 comments on commit 048d697

Please sign in to comment.