Skip to content

Commit ce658ef

Browse files
committed
Update README for 0.5
1 parent 8c32459 commit ce658ef

File tree

2 files changed

+67
-77
lines changed

2 files changed

+67
-77
lines changed

packages/action-listener-middleware/README.md

Lines changed: 67 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,20 @@ const listenerMiddleware = createActionListenerMiddleware()
2929

3030
// Add one or more listener callbacks for specific actions. They may
3131
// contain any sync or async logic, similar to thunks.
32-
listenerMiddleware.addListener(todoAdded, (action, listenerApi) => {
32+
listenerMiddleware.addListener(todoAdded, async (action, listenerApi) => {
3333
// Run whatever additional side-effect-y logic you want here
34-
const { text } = action.payload
35-
console.log('Todo added: ', text)
34+
console.log('Todo added: ', action.payload.text)
3635

37-
if (text === 'Buy milk') {
38-
// Use the listener API methods to dispatch, get state, or unsubscribe the listener
36+
// Can cancel previous running instances
37+
listenerApi.cancelPrevious()
38+
39+
// Run async logic
40+
const data = await fetchData()
41+
42+
// Pause until action dispatched or state changed
43+
if (await listenerApi.condition(matchSomeAction)) {
44+
// Use the listener API methods to dispatch, get state,
45+
// unsubscribe the listener, or cancel previous
3946
listenerApi.dispatch(todoAdded('Buy pet food'))
4047
listenerApi.unsubscribe()
4148
}
@@ -228,58 +235,68 @@ The `listenerApi` object is the second argument to each listener callback. It co
228235

229236
- `unsubscribe: () => void`: will remove the listener from the middleware
230237
- `subscribe: () => void`: will re-subscribe the listener if it was previously removed, or no-op if currently subscribed
231-
- `cancelPrevious: () => void`: cancels any previously running instances of this same listener. (The cancelation will only have a meaningful effect if the previous instances are paused using one of the `job` APIs, `take`, or `condition` - see "Cancelation and Job Management" in the "Usage" section for more details)
238+
- `cancelPrevious: () => void`: cancels any previously running instances of this same listener. (The cancelation will only have a meaningful effect if the previous instances are paused using one of the cancelation-aware APIs like `take/cancel/pause/delay` - see "Cancelation and Task Management" in the "Usage" section for more details)
239+
- `signal: AbortSignal`: An [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) whose `aborted` property will be set to `true` if the listener execution is aborted or completed.
232240

233241
Dynamically unsubscribing and re-subscribing this listener allows for more complex async workflows, such as avoiding duplicate running instances by calling `listenerApi.unsubscribe()` at the start of a listener, or calling `listenerApi.cancelPrevious()` to ensure that only the most recent instance is allowed to complete.
234242

235243
#### Conditional Workflow Execution
236244

237245
- `take: (predicate: ListenerPredicate, timeout?: number) => Promise<[Action, State, State] | null>`: returns a promise that will resolve when the `predicate` returns `true`. The return value is the `[action, currentState, previousState]` combination that the predicate saw as arguments. If a `timeout` is provided and expires if a `timeout` is provided and expires first. the promise resolves to `null`.
238246
- `condition: (predicate: ListenerPredicate, timeout?: number) => Promise<boolean>`: Similar to `take`, but resolves to `true` if the predicate succeeds, and `false` if a `timeout` is provided and expires first. This allows async logic to pause and wait for some condition to occur before continuing. See "Writing Async Workflows" below for details on usage.
247+
- `delay: (timeoutMs: number) => Promise<void>`: returns a cancelation-aware promise that resolves after the timeout, or rejects if canceled before the expiration
248+
- `pause: (promise: Promise<T>) => Promise<T>`: accepts any promise, and returns a cancelation-aware promise that either resolves with the argument promise or rejects if canceled before the resolution
239249

240250
These methods provide the ability to write conditional logic based on future dispatched actions and state changes. Both also accept an optional `timeout` in milliseconds.
241251

242252
`take` resolves to a `[action, currentState, previousState]` tuple or `null` if it timed out, whereas `condition` resolves to `true` if it succeeded or `false` if timed out.
243253

244254
`take` is meant for "wait for an action and get its contents", while `condition` is meant for checks like `if (await condition(predicate))`.
245255

246-
Both these methods are cancelation-aware, and will throw a `JobCancelationException` if the listener instance is canceled while paused.
256+
Both these methods are cancelation-aware, and will throw a `TaskAbortError` if the listener instance is canceled while paused.
257+
258+
#### Child Tasks
247259

248-
#### Job API
260+
- `fork: (executor: (forkApi: ForkApi) => T | Promise<T>) => ForkedTask<T>`: Launches a "child task" that may be used to accomplish additional work. Accepts any sync or async function as its argument, and returns a `{result, cancel}` object that can be used to check the final status and return value of the child task, or cancel it while in-progress.
249261

250-
- `job: JobHandle`: a group of functions that allow manipulating the current running listener instance, including cancelation-aware delays, and launching "child Jobs" that can be used to run additional nested logic.
262+
Child tasks can be launched, and waited on to collect their return values. The provided `executor` function will be called with a `forkApi` object containing `{pause, delay, signal}`, allowing it to pause or check cancelation status. It can also make use of the `listenerApi` from the listener's scope.
251263

252-
The job implementation is based on https://github.com/ethossoftworks/job-ts . The `JobHandle` type includes:
264+
An example of this might be a listener that forks a child task containing an infinite loop that listens for events from a server. The parent then uses `listenerApi.condition()` to wait for a "stop" action, and cancels the child task.
265+
266+
The task and result types are:
253267

254268
```ts
255-
interface JobHandle {
256-
isActive: boolean
257-
isCompleted: boolean
258-
isCancelled: boolean
259-
childCount: number
260-
ensureActive(): void
261-
launch<R>(func: JobFunc<R>): Job<R>
262-
launchAndRun<R>(func: JobFunc<R>): Promise<Outcome<R>>
263-
pause<R>(func: Promise<R>): Promise<R>
264-
delay(milliseconds: number): Promise<void>
265-
cancel(reason?: JobCancellationException): void
266-
cancelChildren(
267-
reason?: JobCancellationException,
268-
skipChildren?: JobHandle[]
269-
): void
269+
export interface ForkedTaskAPI {
270+
pause<W>(waitFor: Promise<W>): Promise<W>
271+
delay(timeoutMs: number): Promise<void>
272+
signal: AbortSignal
270273
}
271-
```
272274

273-
`pause` and `delay` are both cancelation-aware. If the current listener is canceled, they will throw a `JobCancelationException` if the listener instance is canceled while paused.
275+
export type TaskResolved<T> = {
276+
readonly status: 'ok'
277+
readonly value: T
278+
}
274279

275-
Child jobs can be launched, and waited on to collect their return values.
280+
export type TaskRejected = {
281+
readonly status: 'rejected'
282+
readonly error: unknown
283+
}
276284

277-
Note that the jobs API relies on a functional-style async result abstraction called an `Outcome`, which wraps promise results.
285+
export type TaskCancelled = {
286+
readonly status: 'cancelled'
287+
readonly error: TaskAbortError
288+
}
278289

279-
This API will be documented more as the middleware implementation is finalized. For now, you can see the existing third-party library docs here:
290+
export type TaskResult<Value> =
291+
| TaskResolved<Value>
292+
| TaskRejected
293+
| TaskCancelled
280294

281-
- https://github.com/ethossoftworks/job-ts/blob/main/docs/api.md
282-
- https://github.com/ethossoftworks/outcome-ts#usage
295+
export interface ForkedTask<T> {
296+
result: Promise<TaskResult<T>>
297+
cancel(): void
298+
}
299+
```
283300

284301
## Usage Guide
285302

@@ -289,7 +306,7 @@ This middleware lets you run additional logic when some action is dispatched, as
289306

290307
This middleware is not intended to handle all possible use cases. Like thunks, it provides you with a basic set of primitives (including access to `dispatch` and `getState`), and gives you freedom to write any sync or async logic you want. This is both a strength (you can do anything!) and a weakness (you can do anything, with no guard rails!).
291308

292-
As of v0.4.0, the middleware does include several async workflow primitives that are sufficient to write equivalents to many Redux-Saga effects operators like `takeLatest`, `takeLeading`, and `debounce`.
309+
As of v0.5.0, the middleware does include several async workflow primitives that are sufficient to write equivalents to many Redux-Saga effects operators like `takeLatest`, `takeLeading`, and `debounce`.
293310

294311
### Standard Usage Patterns
295312

@@ -401,54 +418,29 @@ test('condition method resolves promise when there is a timeout', async () => {
401418
})
402419
```
403420
404-
### Cancelation and Job Management
405-
406-
As of 0.4.0, the middleware now uses a `Job` abstraction to help manage cancelation of existing listener instances. The `Job` implementation is based on https://github.com/ethossoftworks/job-ts .
407-
408-
Each running listener instance is wrapped in a `Job` that provides cancelation awareness. A running `Job` instance has a `JobHandle` object that can be used to control it:
409-
410-
```ts
411-
interface JobHandle {
412-
isActive: boolean
413-
isCompleted: boolean
414-
isCancelled: boolean
415-
childCount: number
416-
ensureActive(): void
417-
launch<R>(func: JobFunc<R>): Job<R>
418-
launchAndRun<R>(func: JobFunc<R>): Promise<Outcome<R>>
419-
pause<R>(func: Promise<R>): Promise<R>
420-
delay(milliseconds: number): Promise<void>
421-
cancel(reason?: JobCancellationException): void
422-
cancelChildren(
423-
reason?: JobCancellationException,
424-
skipChildren?: JobHandle[]
425-
): void
426-
}
427-
```
428-
429-
`listenerApi.job` exposes that `JobHandle` for the current listener instance so it can be accessed by the listener logic.
421+
### Cancelation and Task Management
430422
431-
Full documentation of `JobHandle` can currently be viewed at https://github.com/ethossoftworks/job-ts/blob/main/docs/api.md . Note that this API also uses a custom functional-style wrapper around async results called an `Outcome`: https://github.com/ethossoftworks/outcome-ts .
423+
As of 0.5.0, the middleware now supports cancelation of running listener instances, `take/condition`/pause/delay` functions, and "child tasks", with an implementation based on [`AbortController`](https://developer.mozilla.org/en-US/docs/Web/API/AbortController).
432424

433-
The `listenerApi.job.pause/delay()` functions provide a cancelation-aware way to have the current listener sleep. `pause()` accepts a promise, while `delay` accepts a timeout value. If the listener is canceled while waiting, a `JobCancelationException` will be thrown.
425+
The `listenerApi.pause/delay()` functions provide a cancelation-aware way to have the current listener sleep. `pause()` accepts a promise, while `delay` accepts a timeout value. If the listener is canceled while waiting, a `TaskAbortError` will be thrown. In addition, both `take` and `condition` support cancelation interruption as well.
434426

435-
This can also be used to launch "child jobs" that can do additional work. These can be waited on to collect their results. An example of this might look like:
427+
`listenerApi.fork()` can used to launch "child tasks" that can do additional work. These can be waited on to collect their results. An example of this might look like:
436428

437429
```ts
438430
middleware.addListener({
439431
actionCreator: increment,
440432
listener: async (action, listenerApi) => {
441-
// Spawn a child job and start it immediately
442-
const childJobPromise = listenerApi.job.launchAndRun(async (jobHandle) => {
433+
// Spawn a child task and start it immediately
434+
const task = listenerApi.fork(async (forkApi) => {
443435
// Artificially wait a bit inside the child
444-
await jobHandle.delay(5)
445-
// Complete the child by returning an Outcome-wrapped value
446-
return Outcome.ok(42)
436+
await forkApi.delay(5)
437+
// Complete the child by returning an Ovalue
438+
return 42
447439
})
448440
449-
const result = await childJobPromise
441+
const result = await task.result
450442
// Unwrap the child result in the listener
451-
if (result.isOk()) {
443+
if (result.status === 'ok') {
452444
console.log('Child succeeded: ', result.value)
453445
}
454446
},
@@ -457,7 +449,7 @@ middleware.addListener({
457449

458450
### Complex Async Workflows
459451

460-
The provided async workflow primitives (`cancelPrevious`, `unsuscribe`, `subscribe`, `take`, `condition`, `job.pause`, `job.delay`) can be used to implement many of the more complex async workflow capabilities found in the Redux-Saga library. This includes effects such as `throttle`, `debounce`, `takeLatest`, `takeLeading`, and `fork/join`. Some examples:
452+
The provided async workflow primitives (`cancelPrevious`, `unsuscribe`, `subscribe`, `take`, `condition`, `pause`, `delay`) can be used to implement many of the more complex async workflow capabilities found in the Redux-Saga library. This includes effects such as `throttle`, `debounce`, `takeLatest`, `takeLeading`, and `fork/join`. Some examples:
461453

462454
```js
463455
test('debounce / takeLatest', async () => {
@@ -474,7 +466,7 @@ test('debounce / takeLatest', async () => {
474466
listenerApi.cancelPrevious()
475467
476468
// Delay before starting actual work
477-
await listenerApi.job.delay(15)
469+
await listenerApi.delay(15)
478470
479471
// do work here
480472
},
@@ -515,18 +507,17 @@ test('canceled', async () => {
515507
if (increment.match(action)) {
516508
// Have this branch wait around to be canceled by the other
517509
try {
518-
await listenerApi.job.delay(10)
510+
await listenerApi.delay(10)
519511
} catch (err) {
520512
// Can check cancelation based on the exception and its reason
521-
if (err instanceof JobCancellationException) {
513+
if (err instanceof TaskAbortError) {
522514
canceledAndCaught = true
523515
}
524516
}
525517
} else if (incrementByAmount.match(action)) {
526518
// do a non-cancelation-aware wait
527-
await sleep(15)
528-
// Or can check based on `job.isCancelled`
529-
if (listenerApi.job.isCancelled) {
519+
await delay(15)
520+
if (listenerApi.signal.aborted) {
530521
canceledCheck = true
531522
}
532523
} else if (decrement.match(action)) {

packages/action-listener-middleware/src/tests/effectScenarios.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -343,7 +343,6 @@ describe('Saga-style Effects Scenarios', () => {
343343
} else if (incrementByAmount.match(action)) {
344344
// do a non-cancelation-aware wait
345345
await delay(15)
346-
// Or can check based on `job.isCancelled`
347346
if (listenerApi.signal.aborted) {
348347
canceledCheck = true
349348
}

0 commit comments

Comments
 (0)