Skip to content

Expose wrapper promise as a prop so it can be chained on (fixes #79) #83

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Aug 22, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -12,7 +12,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 @@ -28,9 +28,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 @@ -166,6 +167,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