diff --git a/.codesandbox/ci.json b/.codesandbox/ci.json index 96cdb9fde0..69146d63ba 100644 --- a/.codesandbox/ci.json +++ b/.codesandbox/ci.json @@ -12,13 +12,11 @@ "packages": [ "packages/toolkit", "packages/rtk-query-graphql-request-base-query", - "packages/action-listener-middleware", "packages/rtk-query-codegen-openapi" ], "publishDirectory": { "@reduxjs/toolkit": "packages/toolkit", "@rtk-query/graphql-request-base-query": "packages/rtk-query-graphql-request-base-query", - "@rtk-incubator/action-listener-middleware": "packages/action-listener-middleware", "@rtk-query/codegen-openapi": "packages/rtk-query-codegen-openapi" } } diff --git a/.github/workflows/listenerTests.yml b/.github/workflows/listenerTests.yml deleted file mode 100644 index d796f27296..0000000000 --- a/.github/workflows/listenerTests.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: CI -on: [push, pull_request] - -jobs: - build: - name: Test Listener Middleware on Node ${{ matrix.node }} - - runs-on: ubuntu-latest - strategy: - matrix: - node: ['14.x'] - - steps: - - name: Checkout repo - uses: actions/checkout@v2 - - - name: Use node ${{ matrix.node }} - uses: actions/setup-node@v2 - with: - node-version: ${{ matrix.node }} - cache: 'yarn' - - - name: Install deps - run: yarn install - - # The middleware apparently needs RTK built first for tests to compile (?!?) - - name: Build RTK - run: cd packages/toolkit && yarn build - - - name: Run action listener tests - run: cd packages/action-listener-middleware && yarn test diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b6180d8874..8586ae1757 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -96,7 +96,7 @@ jobs: fail-fast: false matrix: node: ['14.x'] - ts: ['4.1', '4.2', '4.3', '4.4', '4.5', 'next'] + ts: ['4.1', '4.2', '4.3', '4.4', '4.5', '4.6.1-rc', 'next'] steps: - name: Checkout repo uses: actions/checkout@v2 diff --git a/packages/action-listener-middleware/README.md b/docs/api/createListenerMiddleware.mdx similarity index 65% rename from packages/action-listener-middleware/README.md rename to docs/api/createListenerMiddleware.mdx index 17ae551670..879ae01161 100644 --- a/packages/action-listener-middleware/README.md +++ b/docs/api/createListenerMiddleware.mdx @@ -1,24 +1,30 @@ -# RTK Incubator - Action Listener Middleware +--- +id: createListenerMiddleware +title: createListenerMiddleware +sidebar_label: createListenerMiddleware +hide_title: true +--- -This package provides a callback-based Redux middleware that we plan to include in Redux Toolkit directly in the next feature release. We're publishing it as a standalone package to allow users to try it out separately and give us feedback on its API design. +  -This middleware lets you define "listener" entries containing "effect" callbacks that will run in response to specific actions being dispatched. It's intended to be a lightweight alternative to more widely used Redux async middleware like sagas and observables. While similar to thunks in level of complexity and concept, it can be used to replicate some common saga usage patterns. +# `createListenerMiddleware` -Conceptually, you can think of this as being similar to React's `useEffect` hook, except that it runs logic in response to Redux store updates instead of component props/state updates. +## Overview -## Installation +A Redux middleware that lets you define "listener" entries that contain an "effect" callback with additional logic, and a way to specify when that callback should run based on dispatched actions or state changes. -```bash -npm i @rtk-incubator/action-listener-middleware +It's intended to be a lightweight alternative to more widely used Redux async middleware like sagas and observables. While similar to thunks in level of complexity and concept, it can be used to replicate some common saga usage patterns. -yarn add @rtk-incubator/action-listener-middleware -``` +Conceptually, you can think of this as being similar to React's `useEffect` hook, except that it runs logic in response to Redux store updates instead of component props/state updates. + +Listener effect callbacks have access to `dispatch` and `getState`, similar to thunks. The listener also receives a set of async workflow functions like `take`, `condition`, `pause`, `fork`, and `unsubscribe`, which allow writing more complex async logic. + +Listeners can be defined statically by calling `listenerMiddleware.startListening()` during setup, or added and removed dynamically at runtime with special `dispatch(addListener())` and `dispatch(removeListener())` actions. ### Basic Usage ```js -import { configureStore } from '@reduxjs/toolkit' -import { createListenerMiddleware } from '@rtk-incubator/action-listener-middleware' +import { configureStore, createListenerMiddleware } from '@reduxjs/toolkit' import todosReducer, { todoAdded, @@ -46,7 +52,7 @@ listenerMiddleware.startListening({ // Pause until action dispatched or state changed if (await listenerApi.condition(matchSomeAction)) { // Use the listener API methods to dispatch, get state, - // unsubscribe the listener, or cancel previous + // unsubscribe the listener, start child tasks, and more listenerApi.dispatch(todoAdded('Buy pet food')) listenerApi.unsubscribe() } @@ -65,79 +71,80 @@ const store = configureStore({ }) ``` -## Motivation - -The Redux community has settled around three primary side effects libraries over time: +## `createListenerMiddleware` -- Thunks use basic functions passed to `dispatch`. They let users run arbitrary logic, including dispatching actions and getting state. These are mostly used for basic AJAX requests and logic that needs to read from state before dispatching actions -- Sagas use generator functions and a custom set of "effects" APIs, which are then executed by a middleware. Sagas let users write powerful async logic and workflows that can respond to any dispatched action, including "background thread"-type behavior like infinite loops and cancelation. -- Observables use RxJS observable operators. Observables form pipelines that do arbitrary processing similar to sagas, but with a more functional API style. - -All three of those have strengths and weaknesses: +Creates an instance of the middleware, which should then be added to the store via `configureStore`'s `middleware` parameter. -- Thunks are simple to use, but can only run imperative code and have no way to _respond_ to dispatched actions -- Sagas are extremely powerful, but require learning generator functions and the specifics of `redux-saga`'s effects API, and are overkill for many simpler use cases -- Observables are also powerful, but RxJS is its own complex API to learn and they can be hard to debug +```ts no-transpile +const createListenerMiddleware = (options?: CreateMiddlewareOptions) => + ListenerMiddlewareInstance -If you need to run some code in response to a specific action being dispatched, you _could_ write a custom middleware: +interface CreateListenerMiddlewareOptions { + extra?: ExtraArgument + onError?: ListenerErrorHandler +} -```js -const myMiddleware = (storeAPI) => (next) => (action) => { - if (action.type === 'some/specificAction') { - console.log('Do something useful here') - } +type ListenerErrorHandler = ( + error: unknown, + errorInfo: ListenerErrorInfo +) => void - return next(action) +interface ListenerErrorInfo { + raisedBy: 'effect' | 'predicate' } ``` -However, it would be nice to have a more structured API to help abstract this process. - -The `createListenerMiddleware` API provides that structure. - -For more background and debate over the use cases and API design, see the original discussion issue and PR: +### Middleware Options -- [RTK issue #237: Add an action listener middleware](https://github.com/reduxjs/redux-toolkit/issues/237) -- [RTK PR #547: yet another attempt at an action listener middleware](https://github.com/reduxjs/redux-toolkit/pull/547) -- [RTK discussion #1648: New experimental "action listener middleware" package available](https://github.com/reduxjs/redux-toolkit/discussions/1648) - -## API Reference - -`createListenerMiddleware` lets you add listeners by providing an "effect callback" containing additional logic, and a way to specify when that callback should run based on dispatched actions or state changes. - -The middleware then gives you access to `dispatch` and `getState` for use in your effect callback's logic, similar to thunks. The listener also receives a set of async workflow functions like `take`, `condition`, `pause`, `fork`, and `unsubscribe`, which allow writing more complex async logic. - -Listeners can be defined statically by calling `listenerMiddleware.startListening()` during setup, or added and removed dynamically at runtime with special `dispatch(addListener())` and `dispatch(removeListener())` actions. - -### `createListenerMiddleware: (options?: CreateMiddlewareOptions) => ListenerMiddlewareInstance` - -Creates an instance of the middleware, which should then be added to the store via `configureStore`'s `middleware` parameter. - -Current options are: +- `extra`: an optional "extra argument" that will be injected into the `listenerApi` parameter of each listener. Equivalent to [the "extra argument" in the Redux Thunk middleware](https://redux.js.org/usage/writing-logic-thunks#injecting-config-values-into-thunks) +- `onError`: an optional error handler that gets called with synchronous and async errors raised by `listener` and synchronous errors thrown by `predicate`. -- `extra`: an optional "extra argument" that will be injected into the `listenerApi` parameter of each listener. Equivalent to [the "extra argument" in the Redux Thunk middleware](https://redux.js.org/usage/writing-logic-thunks#injecting-config-values-into-thunks). +## Listener Middleware Instance + +The "listener middleware instance" returned from `createListenerMiddleware` is an object similar to the "slice" objects generated by `createSlice`. The instance object is _not_ the actual Redux middleware itself. Rather, it contains the middleware and some instance methods used to add and remove listener entries within the middleware. + +```ts no-transpile +interface ListenerMiddlewareInstance< + State = unknown, + Dispatch extends ThunkDispatch = ThunkDispatch< + State, + unknown, + AnyAction + >, + ExtraArgument = unknown +> { + middleware: ListenerMiddleware + startListening: (options: AddListenerOptions) => Unsubscribe + stopListening: (options: AddListenerOptions) => boolean + clearListeners: () => void +} +``` -- `onError`: an optional error handler that gets called with synchronous and async errors raised by `listener` and synchronous errors thrown by `predicate`. +### `middleware` -`createListenerMiddleware` returns an object (similar to how `createSlice` does), with the following fields: +The actual Redux middleware. Add this to the Redux store via [the `configureStore.middleware` option](./configureStore.mdx#middleware). -- `middleware`: the actual listener middleware instance. Add this to `configureStore()` -- `startListening`: adds a single listener entry to this specific middleware instance -- `stopListening`: removes a single listener entry from this specific middleware instance -- `clearListeners`: removes all listener entries from this specific middleware instance +Since the listener middleware can receive "add" and "remove" actions containing functions, this should normally be added as the first middleware in the chain so that it is before the serializability check middleware. -### `startListening(options: AddListenerOptions) : Unsubscribe` +```js +const store = configureStore({ + reducer: { + todos: todosReducer, + }, + // Add the listener middleware to the store. + // NOTE: Since this can receive actions with functions inside, + // it should go before the serializability check middleware + middleware: (getDefaultMiddleware) => + getDefaultMiddleware().prepend(listenerMiddleware.middleware), +}) +``` -Statically adds a new listener entry to the middleware. +### `startListening` -The available options are: +Adds a new listener entry to the middleware. Typically used to "statically" add new listeners during application setup. -```ts -type ListenerPredicate = ( - action: Action, - currentState?: State, - originalState?: State -) => boolean +```ts no-transpile +const startListening = (options: AddListenerOptions) => Unsubscribe interface AddListenerOptions { // Four options for deciding when the listener will run: @@ -157,21 +164,30 @@ interface AddListenerOptions { // The actual callback to run when the action is matched effect: (action: Action, listenerApi: ListenerApi) => void | Promise } + +type ListenerPredicate = ( + action: Action, + currentState?: State, + originalState?: State +) => boolean ``` -You must provide exactly _one_ of the four options for deciding when the listener will run: `type`, `actionCreator`, `matcher`, or `predicate`. Every time an action is dispatched, each listener will be checked to see if it should run based on the current action vs the comparison option provided. +**You must provide exactly _one_ of the four options for deciding when the listener will run: `type`, `actionCreator`, `matcher`, or `predicate`**. Every time an action is dispatched, each listener will be checked to see if it should run based on the current action vs the comparison option provided. These are all acceptable: -```ts +```js // 1) Action type string -startListening({ type: 'todos/todoAdded', listener }) +listenerMiddleware.startListening({ type: 'todos/todoAdded', listener }) // 2) RTK action creator -startListening({ actionCreator: todoAdded, listener }) +listenerMiddleware.startListening({ actionCreator: todoAdded, listener }) // 3) RTK matcher function -startListening({ matcher: isAnyOf(todoAdded, todoToggled), listener }) +listenerMiddleware.startListening({ + matcher: isAnyOf(todoAdded, todoToggled), + listener, +}) // 4) Listener predicate -startListening({ +listenerMiddleware.startListening({ predicate: (action, currentState, previousState) => { // return true when the listener should run }, @@ -181,7 +197,7 @@ startListening({ Note that the `predicate` option actually allows matching solely against state-related checks, such as "did `state.x` change" or "the current value of `state.x` matches some criteria", regardless of the actual action. -The ["matcher" utility functions included in RTK](https://redux-toolkit.js.org/api/matching-utilities) are acceptable as predicates. +The ["matcher" utility functions included in RTK](./matching-utilities.mdx) are acceptable as either the `matcher` or `predicate` option. The return value is a standard `unsubscribe()` callback that will remove this listener. If you try to add a listener entry but another entry with this exact function reference already exists, no new entry will be added, and the existing `unsubscribe` method will be returned. @@ -189,27 +205,39 @@ The `effect` callback will receive the current action as its first argument, as All listener predicates and callbacks are checked _after_ the root reducer has already processed the action and updated the state. The `listenerApi.getOriginalState()` method can be used to get the state value that existed before the action that triggered this listener was processed. -### `stopListening(options: AddListenerOptions): boolean` +### `stopListening` Removes a given listener. It accepts the same arguments as `startListening()`. It checks for an existing listener entry by comparing the function references of `listener` and the provided `actionCreator/matcher/predicate` function or `type` string. +```ts no-transpile +const stopListening = (options: AddListenerOptions) => boolean +``` + Returns `true` if the `options.effect` listener has been removed, or `false` if no subscription matching the input provided has been found. -```ts +```js // 1) Action type string -stopListening({ type: 'todos/todoAdded', listener }) +listenerMiddleware.stopListening({ type: 'todos/todoAdded', listener }) // 2) RTK action creator -stopListening({ actionCreator: todoAdded, listener }) +listenerMiddleware.stopListening({ actionCreator: todoAdded, listener }) // 3) RTK matcher function -stopListening({ matcher, listener }) +listenerMiddleware.stopListening({ matcher, listener }) // 4) Listener predicate -stopListening({ predicate, listener }) +listenerMiddleware.stopListening({ predicate, listener }) ``` -### `clearListeners(): void` +### `clearListeners` Removes all current listener entries. This is most likely useful for test scenarios where a single middleware or store instance might be used in multiple tests, as well as some app cleanup situations. +```ts no-transpile +const clearListeners = () => void; +``` + +## Action Creators + +In addition to adding and removing listeners by directly calling methods on the listener instance, you can dynamically add and remove listeners at runtime by dispatching special "add" and "remove" actions. These are exported from the main RTK package as standard RTK-generated action creators. + ### `addListener` A standard RTK action creator, imported from the package. Dispatching this action tells the middleware to dynamically add a new listener at runtime. It accepts exactly the same options as `startListening()` @@ -239,39 +267,36 @@ A standard RTK action creator, imported from the package. Dispatching this actio store.dispatch(removeAllListeners()) ``` -### `listenerApi` +## Listener API -The `listenerApi` object is the second argument to each listener callback. It contains several utility functions that may be called anywhere inside the listener's logic. These can be divided into several categories: +The `listenerApi` object is the second argument to each listener callback. It contains several utility functions that may be called anywhere inside the listener's logic. These can be divided into several categories. -#### Store Interaction Methods +### Store Interaction Methods - `dispatch: Dispatch`: the standard `store.dispatch` method - `getState: () => State`: the standard `store.getState` method - `getOriginalState: () => State`: returns the store state as it existed when the action was originally dispatched, _before_ the reducers ran. (**Note**: this method can only be called synchronously, during the initial dispatch call stack, to avoid memory leaks. Calling it asynchronously will throw an error.) +- `extra: unknown`: the "extra argument" that was provided as part of the middleware setup, if any `dispatch` and `getState` are exactly the same as in a thunk. `getOriginalState` can be used to compare the original state before the listener was started. -#### Middleware Options - -- `extra: unknown`: the "extra argument" that was provided as part of the middleware setup, if any - `extra` can be used to inject a value such as an API service layer into the middleware at creation time, and is accessible here. -#### Listener Subscription Management +### Listener Subscription Management -- `unsubscribe: () => void`: will remove the listener from the middleware -- `subscribe: () => void`: will re-subscribe the listener if it was previously removed, or no-op if currently subscribed -- `cancelActiveListeners: () => void`: cancels all other running instances of this same listener _except_ for the one that made this call. (The cancelation will only have a meaningful effect if the other 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) +- `unsubscribe: () => void`: removes the listener entry from the middleware, and prevent future instances of the listener from running. +- `subscribe: () => void`: will re-subscribe the listener entry if it was previously removed, or no-op if currently subscribed +- `cancelActiveListeners: () => void`: cancels all other running instances of this same listener _except_ for the one that made this call. (The cancellation will only have a meaningful effect if the other instances are paused using one of the cancellation-aware APIs like `take/cancel/pause/delay` - see "Cancelation and Task Management" in the "Usage" section for more details) - `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. 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.cancelActiveListeners()` to ensure that only the most recent instance is allowed to complete. -#### Conditional Workflow Execution +### Conditional Workflow Execution - `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 first, the promise resolves to `null`. - `condition: (predicate: ListenerPredicate, timeout?: number) => Promise`: 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. -- `delay: (timeoutMs: number) => Promise`: returns a cancelation-aware promise that resolves after the timeout, or rejects if canceled before the expiration -- `pause: (promise: Promise) => Promise`: accepts any promise, and returns a cancelation-aware promise that either resolves with the argument promise or rejects if canceled before the resolution +- `delay: (timeoutMs: number) => Promise`: returns a cancellation-aware promise that resolves after the timeout, or rejects if cancelled before the expiration +- `pause: (promise: Promise) => Promise`: accepts any promise, and returns a cancellation-aware promise that either resolves with the argument promise or rejects if cancelled before the resolution 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. @@ -279,20 +304,20 @@ These methods provide the ability to write conditional logic based on future dis `take` is meant for "wait for an action and get its contents", while `condition` is meant for checks like `if (await condition(predicate))`. -Both these methods are cancelation-aware, and will throw a `TaskAbortError` if the listener instance is canceled while paused. +Both these methods are cancellation-aware, and will throw a `TaskAbortError` if the listener instance is cancelled while paused. -#### Child Tasks +### Child Tasks - `fork: (executor: (forkApi: ForkApi) => T | Promise) => ForkedTask`: 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. -Child tasks can be launched, and waited on to collect their return values. The provided `executor` function will be called asynchronously 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. +Child tasks can be launched, and waited on to collect their return values. The provided `executor` function will be called asynchronously with a `forkApi` object containing `{pause, delay, signal}`, allowing it to pause or check cancellation status. It can also make use of the `listenerApi` from the listener's scope. 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. The task and result types are: -```ts -export interface ForkedTaskAPI { +```ts no-transpile +interface ForkedTaskAPI { pause(waitFor: Promise): Promise delay(timeoutMs: number): Promise signal: AbortSignal @@ -324,6 +349,28 @@ export interface ForkedTask { } ``` +## TypeScript Usage + +The middleware code is fully TS-typed. However, the `startListening` and `addListener` functions do not know what the store's `RootState` type looks like by default, so `getState()` will return `unknown`. + +To fix this, the middleware provides types for defining "pre-typed" versions of those methods, similar to the pattern used for defing pre-typed React-Redux hooks. We specifically recommend creating the middleware instance in a separate file from the actual `configureStore()` call: + +```ts no-transpile +// listenerMiddleware.ts +import { createListenerMiddleware, addListener } from '@reduxjs/toolkit' +import type { TypedStartListening, TypedAddListener } from '@reduxjs/toolkit' + +import type { RootState } from './store' + +export const listenerMiddleware = createListenerMiddleware() + +export const startAppListening = + listenerMiddleware.startListening as TypedStartListening +export const addAppListener = addListener as TypedAddListenern +``` + +Then import and use those pre-typed methods in your components. + ## Usage Guide ### Overall Purpose @@ -332,7 +379,7 @@ This middleware lets you run additional logic when some action is dispatched, as 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!). -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`. +The middleware includes several async workflow primitives that are sufficient to write equivalents to many Redux-Saga effects operators like `takeLatest`, `takeLeading`, and `debounce`. ### Standard Usage Patterns @@ -386,7 +433,9 @@ listenerMiddleware.startListening({ }) ``` -The `listenerApi.unsubscribe` method may be used at any time, and will remove the listener from handling any future actions. As an example, you could create a one-shot listener by unconditionally calling `unsubscribe()` in the body - it would run the first time the relevant action is seen, and then immediately stop and not handle any future actions. (The middleware actually uses this technique internally for the `take/condition` methods) +(That said, we would recommend use of RTK Query for any meaningful data fetching behavior - this is primarily an example of what you _could_ do in a listener.) + +The `listenerApi.unsubscribe` method may be used at any time, and will remove the listener from handling any future actions. As an example, you could create a one-shot listener by unconditionally calling `unsubscribe()` in the body - the effect callback would run the first time the relevant action is seen, then immediately unsubscribe and never run again. (The middleware actually uses this technique internally for the `take/condition` methods) ### Writing Async Workflows with Conditions @@ -398,7 +447,7 @@ Listeners can use the `condition` and `take` methods in `listenerApi` to wait un The signatures are: -```ts +```ts no-transpile type ConditionFunction = ( predicate: ListenerPredicate | (() => boolean), timeout?: number @@ -412,13 +461,13 @@ type TakeFunction = ( You can use `await condition(somePredicate)` as a way to pause execution of your listener callback until some criteria is met. -The `predicate` will be called before and after every action is processed, and should return `true` when the condition should resolve. (It is effectively a one-shot listener itself.) If a `timeout` number (in ms) is provided, the promise will resolve `true` if the `predicate` returns first, or `false` if the timeout expires. This allows you to write comparisons like `if (await condition(predicate))`. +The `predicate` will be called after every action is processed by the reducers, and should return `true` when the condition should resolve. (It is effectively a one-shot listener itself.) If a `timeout` number (in ms) is provided, the promise will resolve `true` if the `predicate` returns first, or `false` if the timeout expires. This allows you to write comparisons like `if (await condition(predicate, timeout))`. This should enable writing longer-running workflows with more complex async logic, such as [the "cancellable counter" example from Redux-Saga](https://github.com/redux-saga/redux-saga/blob/1ecb1bed867eeafc69757df8acf1024b438a79e0/examples/cancellable-counter/src/sagas/index.js). -An example of usage, from the test suite: +An example of `condition` usage, from the test suite: -```ts +```ts no-transpile test('condition method resolves promise when there is a timeout', async () => { let finalCount = 0 let listenerStarted = false @@ -461,15 +510,15 @@ test('condition method resolves promise when there is a timeout', async () => { }) ``` -### Cancelation and Task Management +### Cancellation and Task Management -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). +The listener middleware supports cancellation 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). -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. +The `listenerApi.pause/delay()` functions provide a cancellation-aware way to have the current listener sleep. `pause()` accepts a promise, while `delay` accepts a timeout value. If the listener is cancelled while waiting, a `TaskAbortError` will be thrown. In addition, both `take` and `condition` support cancellation interruption as well. `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: -```ts +```ts no-transpile listenerMiddleware.startListening({ actionCreator: increment, effect: async (action, listenerApi) => { @@ -477,13 +526,14 @@ listenerMiddleware.startListening({ const task = listenerApi.fork(async (forkApi) => { // Artificially wait a bit inside the child await forkApi.delay(5) - // Complete the child by returning an Ovalue + // Complete the child by returning a value return 42 }) const result = await task.result // Unwrap the child result in the listener if (result.status === 'ok') { + // Logs the `42` result value that was returned console.log('Child succeeded: ', result.value) } }, @@ -492,7 +542,7 @@ listenerMiddleware.startListening({ ### Complex Async Workflows -The provided async workflow primitives (`cancelActiveListeners`, `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: +The provided async workflow primitives (`cancelActiveListeners`, `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 from the test suite: ```js test('debounce / takeLatest', async () => { @@ -536,29 +586,29 @@ test('takeLeading', async () => { }) }) -test('canceled', async () => { - // canceled allows checking if the current task was canceled +test('cancelled', async () => { + // cancelled allows checking if the current task was cancelled // Ref: https://redux-saga.js.org/docs/api#cancelled let canceledAndCaught = false let canceledCheck = false - // Example of canceling prior instances conditionally and checking cancelation + // Example of canceling prior instances conditionally and checking cancellation listenerMiddleware.startListening({ matcher: isAnyOf(increment, decrement, incrementByAmount), effect: async (action, listenerApi) => { if (increment.match(action)) { - // Have this branch wait around to be canceled by the other + // Have this branch wait around to be cancelled by the other try { await listenerApi.delay(10) } catch (err) { - // Can check cancelation based on the exception and its reason + // Can check cancellation based on the exception and its reason if (err instanceof TaskAbortError) { canceledAndCaught = true } } } else if (incrementByAmount.match(action)) { - // do a non-cancelation-aware wait + // do a non-cancellation-aware wait await delay(15) if (listenerApi.signal.aborted) { canceledCheck = true @@ -571,34 +621,50 @@ test('canceled', async () => { }) ``` -### TypeScript Usage - -The code is fully typed. However, the `startListening` and `addListener` functions do not know what the store's `RootState` type looks like by default, so `getState()` will return `unknown`. +As a more practical example: [this saga-based "long polling" loop](https://gist.github.com/markerikson/5203e71a69fa9dff203c9e27c3d84154) repeatedly asks the server for a message and then processes each response. The child loop is started on demand when a "start polling" action is dispatched, and the loop is cancelled when a "stop polling" action is dispatched. -To fix this, the middleware provides types for defining "pre-typed" versions of those methods, similar to the pattern used for defing pre-typed React-Redux hooks: +That approach can be implemented via the listener middleware: -```ts -// listenerMiddleware.ts -import { - createListenerMiddleware, - addListener, -} from '@rtk-incubator/action-listener-middleware' -import type { - TypedStartListening, - TypedAddListener, -} from '@rtk-incubator/action-listener-middleware' +```ts no-transpile +// Track how many times each message was processed by the loop +const receivedMessages = { + a: 0, + b: 0, + c: 0, +} -import type { RootState } from './store' +const eventPollingStarted = createAction('serverPolling/started') +const eventPollingStopped = createAction('serverPolling/stopped') -export const listenerMiddleware = createListenerMiddleware() +listenerMiddleware.startListening({ + actionCreator: eventPollingStarted, + effect: async (action, listenerApi) => { + // Only allow one instance of this listener to run at a time + listenerApi.unsubscribe() + + // Start a child job that will infinitely loop receiving messages + const pollingTask = listenerApi.fork(async (forkApi) => { + try { + while (true) { + // Cancellation-aware pause for a new server message + const serverEvent = await forkApi.pause(pollForEvent()) + // Process the message. In this case, just count the times we've seen this message. + if (serverEvent.type in receivedMessages) { + receivedMessages[ + serverEvent.type as keyof typeof receivedMessages + ]++ + } + } + } catch (err) { + if (err instanceof TaskAbortError) { + // could do something here to track that the task was cancelled + } + } + }) -export const startAppListening = - listenerMiddleware.startListening as TypedStartListening -export const addAppListener = addListener as TypedAddListenern + // Wait for the "stop polling" action + await listenerApi.condition(eventPollingStopped.match) + pollingTask.cancel() + }, +}) ``` - -Then import and use those pre-typed versions in your components. - -## Feedback - -Please provide feedback in [RTK discussion #1648: "New experimental "action listener middleware" package"](https://github.com/reduxjs/redux-toolkit/discussions/1648). diff --git a/package.json b/package.json index 02a6bbaa97..3dfa2670b3 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "test": "yarn test:packages", "build:examples": "yarn workspaces foreach --include '@reduxjs/*' --include '@examples-query-react/*' --include '@examples-action-listener/*' -vtp run build", "build:docs": "yarn workspace website run build", - "build:packages": "yarn workspaces foreach --include '@reduxjs/*' --include '@rtk-query/*' --include '@rtk-incubator/*' --topological-dev run build", + "build:packages": "yarn workspaces foreach --include '@reduxjs/*' --include '@rtk-query/*' --topological-dev run build", "test:packages": "yarn workspaces foreach --include '@reduxjs/*' --include '@rtk-query/*' --include '@rtk-incubator/*' run test", "dev:docs": "yarn workspace website run start" } diff --git a/packages/action-listener-middleware/.gitignore b/packages/action-listener-middleware/.gitignore deleted file mode 100644 index f2de354d38..0000000000 --- a/packages/action-listener-middleware/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -dist -node_modules -yarn-error.log -coverage \ No newline at end of file diff --git a/packages/action-listener-middleware/LICENSE b/packages/action-listener-middleware/LICENSE deleted file mode 100644 index 573d9de13b..0000000000 --- a/packages/action-listener-middleware/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2021 Lenz Weber - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/packages/action-listener-middleware/cjs.json b/packages/action-listener-middleware/cjs.json deleted file mode 100644 index a3c15a7a63..0000000000 --- a/packages/action-listener-middleware/cjs.json +++ /dev/null @@ -1 +0,0 @@ -{ "type": "commonjs" } diff --git a/packages/action-listener-middleware/jest.config.js b/packages/action-listener-middleware/jest.config.js deleted file mode 100644 index 024aeff39f..0000000000 --- a/packages/action-listener-middleware/jest.config.js +++ /dev/null @@ -1,15 +0,0 @@ -module.exports = { - testMatch: ['/src/**/*.(spec|test).[jt]s?(x)'], - moduleNameMapper: { - '^@reduxjs/toolkit$': '/../toolkit/src/index.ts', // @remap-prod-remove-line - }, - preset: 'ts-jest', - globals: { - 'ts-jest': { - tsconfig: 'tsconfig.test.json', - diagnostics: { - ignoreCodes: [6133], - }, - }, - }, -} diff --git a/packages/action-listener-middleware/package.json b/packages/action-listener-middleware/package.json deleted file mode 100644 index a17900d43e..0000000000 --- a/packages/action-listener-middleware/package.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "name": "@rtk-incubator/action-listener-middleware", - "version": "0.8.0", - "author": { - "name": "Lenz Weber", - "email": "mail@phryneas.de", - "url": "https://phryneas.de/" - }, - "license": "MIT", - "source": "src/index.ts", - "module": "./dist/module/index.js", - "main": "./dist/cjs/index.js", - "types": "./dist/index.d.ts", - "scripts": { - "build": "rimraf dist && yarn build:esm && yarn build:module && yarn build:cjs", - "build:esm": "microbundle -o ./dist/esm/index.js -f modern", - "build:module": "microbundle -o ./dist/module/index.js -f esm --generate-types false", - "build:cjs": "microbundle -o ./dist/cjs/index.js -f cjs --generate-types false && cp ./cjs.json ./dist/cjs/package.json", - "dev": "microbundle watch", - "prepublishOnly": "yarn build", - "test": "jest --runInBand" - }, - "peerDependencies": { - "@reduxjs/toolkit": "^1.6.0" - }, - "devDependencies": { - "@reduxjs/toolkit": "^1.6.0", - "@types/jest": "^24.0.11", - "@types/node": "^10.14.4", - "jest": "^26.6.3", - "microbundle": "^0.13.3", - "rimraf": "^3.0.2", - "ts-jest": "^26.5.5", - "typescript": "^4.3.4" - }, - "publishConfig": { - "access": "public" - }, - "files": [ - "src", - "dist" - ], - "sideEffects": false -} diff --git a/packages/action-listener-middleware/tsconfig.base.json b/packages/action-listener-middleware/tsconfig.base.json deleted file mode 100644 index 81fd69009d..0000000000 --- a/packages/action-listener-middleware/tsconfig.base.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "compilerOptions": { - "target": "ESnext", - "module": "esnext", - "lib": ["dom", "esnext"], - "importHelpers": true, - // output .d.ts declaration files for consumers - "declaration": true, - // match output dir to input dir. e.g. dist/index instead of dist/src/index - "rootDir": "./src", - // stricter type-checking for stronger correctness. Recommended by TS - "strict": true, - // linter checks for common issues - "noImplicitReturns": true, - "noFallthroughCasesInSwitch": true, - // noUnused* overlap with @typescript-eslint/no-unused-vars, can disable if duplicative - "noUnusedLocals": false, - "noUnusedParameters": false, - // use Node's module resolution algorithm, instead of the legacy TS one - "moduleResolution": "node", - // transpile JSX to React.createElement - "jsx": "react", - // interop between ESM and CJS modules. Recommended by TS - "esModuleInterop": true, - // significant perf increase by skipping checking .d.ts files, particularly those in node_modules. Recommended by TS - "skipLibCheck": true, - // error out if import and file system have a casing mismatch. Recommended by TS - "forceConsistentCasingInFileNames": true, - "downlevelIteration": false, - "allowSyntheticDefaultImports": true, - "emitDeclarationOnly": true, - "baseUrl": ".", - "paths": {} - } -} diff --git a/packages/action-listener-middleware/tsconfig.json b/packages/action-listener-middleware/tsconfig.json deleted file mode 100644 index a01c9d4430..0000000000 --- a/packages/action-listener-middleware/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "./tsconfig.base.json", - "compilerOptions": { - "outDir": "dist" - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts*", "src/**/tests/*"] -} diff --git a/packages/action-listener-middleware/tsconfig.test.json b/packages/action-listener-middleware/tsconfig.test.json deleted file mode 100644 index 62e6ee81c1..0000000000 --- a/packages/action-listener-middleware/tsconfig.test.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "extends": "./tsconfig.base.json", - "compilerOptions": { - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, - "module": "esnext", - "moduleResolution": "node", - "emitDeclarationOnly": false, - "strict": true, - "noEmit": true, - "target": "es2018", - "jsx": "react", - "baseUrl": ".", - "skipLibCheck": true, - "noImplicitReturns": false - } -} diff --git a/packages/toolkit/src/createAction.ts b/packages/toolkit/src/createAction.ts index 3bfaadd19d..8bb88266ef 100644 --- a/packages/toolkit/src/createAction.ts +++ b/packages/toolkit/src/createAction.ts @@ -81,7 +81,7 @@ export type _ActionCreatorWithPreparedPayload< * * @inheritdoc {redux#ActionCreator} */ -interface BaseActionCreator { +export interface BaseActionCreator { type: T match: (action: Action) => action is PayloadAction } diff --git a/packages/toolkit/src/devtoolsExtension.ts b/packages/toolkit/src/devtoolsExtension.ts index 2d803a3bb8..acd01a6bcd 100644 --- a/packages/toolkit/src/devtoolsExtension.ts +++ b/packages/toolkit/src/devtoolsExtension.ts @@ -173,13 +173,17 @@ export interface EnhancerOptions { traceLimit?: number } +type Compose = typeof compose + +interface ComposeWithDevTools { + (options: EnhancerOptions): Compose + (...funcs: StoreEnhancer[]): StoreEnhancer +} + /** * @public */ -export const composeWithDevTools: { - (options: EnhancerOptions): typeof compose - (...funcs: Array>): StoreEnhancer -} = +export const composeWithDevTools: ComposeWithDevTools = typeof window !== 'undefined' && (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ ? (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ diff --git a/packages/toolkit/src/index.ts b/packages/toolkit/src/index.ts index 63a04c7c82..e4d6f5745c 100644 --- a/packages/toolkit/src/index.ts +++ b/packages/toolkit/src/index.ts @@ -150,3 +150,34 @@ export type { export { nanoid } from './nanoid' export { default as isPlainObject } from './isPlainObject' + +export type { + ListenerEffect, + ListenerMiddleware, + ListenerEffectAPI, + ListenerMiddlewareInstance, + CreateListenerMiddlewareOptions, + ListenerErrorHandler, + TypedStartListening, + TypedAddListener, + TypedStopListening, + TypedRemoveListener, + Unsubscribe, + ForkedTaskExecutor, + ForkedTask, + ForkedTaskAPI, + AsyncTaskExecutor, + SyncTaskExecutor, + TaskCancelled, + TaskRejected, + TaskResolved, + TaskResult, +} from './listenerMiddleware/index' + +export { + createListenerMiddleware, + addListener, + removeListener, + removeAllListeners, + TaskAbortError, +} from './listenerMiddleware/index' diff --git a/packages/action-listener-middleware/src/exceptions.ts b/packages/toolkit/src/listenerMiddleware/exceptions.ts similarity index 100% rename from packages/action-listener-middleware/src/exceptions.ts rename to packages/toolkit/src/listenerMiddleware/exceptions.ts diff --git a/packages/action-listener-middleware/src/index.ts b/packages/toolkit/src/listenerMiddleware/index.ts similarity index 98% rename from packages/action-listener-middleware/src/index.ts rename to packages/toolkit/src/listenerMiddleware/index.ts index 65541989b2..47c7024f79 100644 --- a/packages/action-listener-middleware/src/index.ts +++ b/packages/toolkit/src/listenerMiddleware/index.ts @@ -1,12 +1,7 @@ -import type { - Middleware, - Dispatch, - AnyAction, - Action, - ThunkDispatch, - MiddlewareAPI, -} from '@reduxjs/toolkit' -import { createAction, nanoid } from '@reduxjs/toolkit' +import type { Dispatch, AnyAction, MiddlewareAPI } from 'redux' +import type { ThunkDispatch } from 'redux-thunk' +import { createAction } from '../createAction' +import { nanoid } from '../nanoid' import type { ListenerMiddleware, @@ -14,8 +9,6 @@ import type { AddListenerOverloads, AnyListenerPredicate, CreateListenerMiddlewareOptions, - TypedActionCreator, - TypedStartListening, TypedAddListener, TypedCreateListenerEntry, FallbackAddListenerOptions, @@ -27,7 +20,6 @@ import type { ForkedTaskExecutor, ForkedTask, TypedRemoveListener, - TypedStopListening, TaskResult, } from './types' import { diff --git a/packages/action-listener-middleware/src/task.ts b/packages/toolkit/src/listenerMiddleware/task.ts similarity index 100% rename from packages/action-listener-middleware/src/task.ts rename to packages/toolkit/src/listenerMiddleware/task.ts diff --git a/packages/action-listener-middleware/src/tests/effectScenarios.test.ts b/packages/toolkit/src/listenerMiddleware/tests/effectScenarios.test.ts similarity index 100% rename from packages/action-listener-middleware/src/tests/effectScenarios.test.ts rename to packages/toolkit/src/listenerMiddleware/tests/effectScenarios.test.ts diff --git a/packages/action-listener-middleware/src/tests/fork.test.ts b/packages/toolkit/src/listenerMiddleware/tests/fork.test.ts similarity index 100% rename from packages/action-listener-middleware/src/tests/fork.test.ts rename to packages/toolkit/src/listenerMiddleware/tests/fork.test.ts diff --git a/packages/action-listener-middleware/src/tests/listenerMiddleware.test.ts b/packages/toolkit/src/listenerMiddleware/tests/listenerMiddleware.test.ts similarity index 100% rename from packages/action-listener-middleware/src/tests/listenerMiddleware.test.ts rename to packages/toolkit/src/listenerMiddleware/tests/listenerMiddleware.test.ts diff --git a/packages/action-listener-middleware/src/tests/useCases.test.ts b/packages/toolkit/src/listenerMiddleware/tests/useCases.test.ts similarity index 100% rename from packages/action-listener-middleware/src/tests/useCases.test.ts rename to packages/toolkit/src/listenerMiddleware/tests/useCases.test.ts diff --git a/packages/action-listener-middleware/src/types.ts b/packages/toolkit/src/listenerMiddleware/types.ts similarity index 69% rename from packages/action-listener-middleware/src/types.ts rename to packages/toolkit/src/listenerMiddleware/types.ts index d2d97e66e5..25fca90270 100644 --- a/packages/action-listener-middleware/src/types.ts +++ b/packages/toolkit/src/listenerMiddleware/types.ts @@ -1,12 +1,12 @@ +import type { PayloadAction, BaseActionCreator } from '../createAction' import type { - PayloadAction, - Middleware, - Dispatch, + Dispatch as ReduxDispatch, AnyAction, MiddlewareAPI, - Action, - ThunkDispatch, -} from '@reduxjs/toolkit' + Middleware, + Action as ReduxAction, +} from 'redux' +import type { ThunkDispatch } from 'redux-thunk' import type { TaskAbortError } from './exceptions' /** @@ -19,41 +19,38 @@ export type AbortSignalWithReason = AbortSignal & { reason?: T } * Types copied from RTK */ -export interface BaseActionCreator { - type: T - match(action: Action): action is PayloadAction -} - +/** @internal */ export interface TypedActionCreator { - (...args: any[]): Action + (...args: any[]): ReduxAction type: Type match: MatchFunction } +/** @internal */ export type AnyListenerPredicate = ( action: AnyAction, currentState: State, originalState: State ) => boolean +/** @public */ export type ListenerPredicate = ( action: AnyAction, currentState: State, originalState: State ) => action is Action +/** @public */ export interface ConditionFunction { (predicate: AnyListenerPredicate, timeout?: number): Promise (predicate: AnyListenerPredicate, timeout?: number): Promise (predicate: () => boolean, timeout?: number): Promise } +/** @internal */ export type MatchFunction = (v: any) => v is T -export interface HasMatchFunction { - match: MatchFunction -} - +/** @public */ export interface ForkedTaskAPI { /** * Returns a promise that resolves when `waitFor` resolves or @@ -74,36 +71,44 @@ export interface ForkedTaskAPI { signal: AbortSignal } +/** @public */ export interface AsyncTaskExecutor { (forkApi: ForkedTaskAPI): Promise } +/** @public */ export interface SyncTaskExecutor { (forkApi: ForkedTaskAPI): T } +/** @public */ export type ForkedTaskExecutor = AsyncTaskExecutor | SyncTaskExecutor +/** @public */ export type TaskResolved = { readonly status: 'ok' readonly value: T } +/** @public */ export type TaskRejected = { readonly status: 'rejected' readonly error: unknown } +/** @public */ export type TaskCancelled = { readonly status: 'cancelled' readonly error: TaskAbortError } +/** @public */ export type TaskResult = | TaskResolved | TaskRejected | TaskCancelled +/** @public */ export interface ForkedTask { /** * A promise that resolves when the task is either completed or cancelled or rejects @@ -126,14 +131,12 @@ export interface ForkedTask { cancel(): void } -/** - * @alpha - */ +/** @public */ export interface ListenerEffectAPI< - S, - D extends Dispatch, + State, + Dispatch extends ReduxDispatch, ExtraArgument = unknown -> extends MiddlewareAPI { +> extends MiddlewareAPI { /** * Returns the store state as it existed when the action was originally dispatched, _before_ the reducers ran. * @@ -158,11 +161,11 @@ export interface ListenerEffectAPI< * }) * ``` */ - getOriginalState: () => S + getOriginalState: () => State unsubscribe(): void subscribe(): void - condition: ConditionFunction - take: TakePattern + condition: ConditionFunction + take: TakePattern cancelActiveListeners: () => void /** * An abort signal whose `aborted` property is set to `true` @@ -190,23 +193,39 @@ export interface ListenerEffectAPI< extra: ExtraArgument } -/** - * @alpha - */ +/** @public */ export type ListenerEffect< - A extends AnyAction, - S, - D extends Dispatch, + Action extends AnyAction, + State, + Dispatch extends ReduxDispatch, ExtraArgument = unknown > = ( - action: A, - api: ListenerEffectAPI + action: Action, + api: ListenerEffectAPI ) => void | Promise +/** + * @public + * Additional infos regarding the error raised. + */ +export interface ListenerErrorInfo { + /** + * Which function has generated the exception. + */ + raisedBy: 'effect' | 'predicate' +} + +/** + * @public + * Gets notified with synchronous and asynchronous errors raised by `listeners` or `predicates`. + * @param error The thrown error. + * @param errorInfo Additional information regarding the thrown error. + */ export interface ListenerErrorHandler { - (error: unknown): void + (error: unknown, errorInfo: ListenerErrorInfo): void } +/** @public */ export interface CreateListenerMiddlewareOptions { extra?: ExtraArgument /** @@ -215,36 +234,41 @@ export interface CreateListenerMiddlewareOptions { onError?: ListenerErrorHandler } +/** @public */ export type ListenerMiddleware< - S = unknown, - // TODO Carry through the thunk extra arg somehow? - D extends ThunkDispatch = ThunkDispatch< - S, + State = unknown, + Dispatch extends ThunkDispatch = ThunkDispatch< + State, unknown, AnyAction >, ExtraArgument = unknown > = Middleware< { - (action: Action<'listenerMiddleware/add'>): Unsubscribe + (action: ReduxAction<'listenerMiddleware/add'>): Unsubscribe }, - S, - D + State, + Dispatch > +/** @public */ export interface ListenerMiddlewareInstance< - S = unknown, - // TODO Carry through the thunk extra arg somehow? - D extends ThunkDispatch = ThunkDispatch< - S, + State = unknown, + Dispatch extends ThunkDispatch = ThunkDispatch< + State, unknown, AnyAction >, ExtraArgument = unknown > { - middleware: ListenerMiddleware - startListening: AddListenerOverloads - stopListening: RemoveListenerOverloads + middleware: ListenerMiddleware + startListening: AddListenerOverloads< + Unsubscribe, + State, + Dispatch, + ExtraArgument + > + stopListening: RemoveListenerOverloads /** * Unsubscribes all listeners, cancels running listeners and tasks. */ @@ -255,6 +279,7 @@ export interface ListenerMiddlewareInstance< * API Function Overloads */ +/** @public */ export type TakePatternOutputWithoutTimeout< State, Predicate extends AnyListenerPredicate @@ -262,6 +287,7 @@ export type TakePatternOutputWithoutTimeout< ? Promise<[Action, State, State]> : Promise<[AnyAction, State, State]> +/** @public */ export type TakePatternOutputWithTimeout< State, Predicate extends AnyListenerPredicate @@ -269,6 +295,7 @@ export type TakePatternOutputWithTimeout< ? Promise<[Action, State, State] | null> : Promise<[AnyAction, State, State] | null> +/** @public */ export interface TakePattern { >( predicate: Predicate @@ -284,24 +311,25 @@ export interface TakePattern { } /** + * @public * The possible overloads and options for defining a listener. The return type of each function is specified as a generic arg, so the overloads can be reused for multiple different functions */ export interface AddListenerOverloads< Return, - S = unknown, - D extends Dispatch = ThunkDispatch, + State = unknown, + Dispatch extends ReduxDispatch = ThunkDispatch, ExtraArgument = unknown > { /** Accepts a "listener predicate" that is also a TS type predicate for the action*/ - >(options: { + >(options: { actionCreator?: never type?: never matcher?: never predicate: LP effect: ListenerEffect< ListenerPredicateGuardedActionType, - S, - D, + State, + Dispatch, ExtraArgument > }): Return @@ -312,7 +340,7 @@ export interface AddListenerOverloads< type?: never matcher?: never predicate?: never - effect: ListenerEffect, S, D, ExtraArgument> + effect: ListenerEffect, State, Dispatch, ExtraArgument> }): Return /** Accepts a specific action type string */ @@ -321,7 +349,7 @@ export interface AddListenerOverloads< type: T matcher?: never predicate?: never - effect: ListenerEffect, S, D, ExtraArgument> + effect: ListenerEffect, State, Dispatch, ExtraArgument> }): Return /** Accepts an RTK matcher function, such as `incrementByAmount.match` */ @@ -330,73 +358,108 @@ export interface AddListenerOverloads< type?: never matcher: M predicate?: never - effect: ListenerEffect, S, D, ExtraArgument> + effect: ListenerEffect, State, Dispatch, ExtraArgument> }): Return /** Accepts a "listener predicate" that just returns a boolean, no type assertion */ - >(options: { + >(options: { actionCreator?: never type?: never matcher?: never predicate: LP - effect: ListenerEffect + effect: ListenerEffect }): Return } +/** @public */ export type RemoveListenerOverloads< - S = unknown, - D extends Dispatch = ThunkDispatch -> = AddListenerOverloads + State = unknown, + Dispatch extends ReduxDispatch = ThunkDispatch +> = AddListenerOverloads +/** @public */ export interface RemoveListenerAction< - A extends AnyAction, - S, - D extends Dispatch + Action extends AnyAction, + State, + Dispatch extends ReduxDispatch > { type: 'listenerMiddleware/remove' payload: { type: string - listener: ListenerEffect + listener: ListenerEffect } } -/** A "pre-typed" version of `addListenerAction`, so the listener args are well-typed */ +/** + * @public + * A "pre-typed" version of `addListenerAction`, so the listener args are well-typed */ export type TypedAddListener< - S, - D extends Dispatch = ThunkDispatch, + State, + Dispatch extends ReduxDispatch = ThunkDispatch< + State, + unknown, + AnyAction + >, ExtraArgument = unknown, - Payload = ListenerEntry, + Payload = ListenerEntry, T extends string = 'listenerMiddleware/add' > = BaseActionCreator & - AddListenerOverloads, S, D, ExtraArgument> + AddListenerOverloads< + PayloadAction, + State, + Dispatch, + ExtraArgument + > -/** A "pre-typed" version of `removeListenerAction`, so the listener args are well-typed */ +/** + * @public + * A "pre-typed" version of `removeListenerAction`, so the listener args are well-typed */ export type TypedRemoveListener< - S, - D extends Dispatch = ThunkDispatch, - Payload = ListenerEntry, + State, + Dispatch extends ReduxDispatch = ThunkDispatch< + State, + unknown, + AnyAction + >, + Payload = ListenerEntry, T extends string = 'listenerMiddleware/remove' > = BaseActionCreator & - AddListenerOverloads, S, D> + AddListenerOverloads, State, Dispatch> -/** A "pre-typed" version of `middleware.startListening`, so the listener args are well-typed */ +/** + * @public + * A "pre-typed" version of `middleware.startListening`, so the listener args are well-typed */ export type TypedStartListening< - S, - D extends Dispatch = ThunkDispatch, + State, + Dispatch extends ReduxDispatch = ThunkDispatch< + State, + unknown, + AnyAction + >, ExtraArgument = unknown -> = AddListenerOverloads +> = AddListenerOverloads -/** A "pre-typed" version of `middleware.stopListening`, so the listener args are well-typed */ +/** @public + * A "pre-typed" version of `middleware.stopListening`, so the listener args are well-typed */ export type TypedStopListening< - S, - D extends Dispatch = ThunkDispatch -> = RemoveListenerOverloads + State, + Dispatch extends ReduxDispatch = ThunkDispatch< + State, + unknown, + AnyAction + > +> = RemoveListenerOverloads -/** A "pre-typed" version of `createListenerEntry`, so the listener args are well-typed */ +/** @public + * A "pre-typed" version of `createListenerEntry`, so the listener args are well-typed */ export type TypedCreateListenerEntry< - S, - D extends Dispatch = ThunkDispatch -> = AddListenerOverloads, S, D> + State, + Dispatch extends ReduxDispatch = ThunkDispatch< + State, + unknown, + AnyAction + > +> = AddListenerOverloads, State, Dispatch> /** * Internal Types @@ -404,23 +467,21 @@ export type TypedCreateListenerEntry< /** @internal An single listener entry */ export type ListenerEntry< - S = unknown, - D extends Dispatch = Dispatch + State = unknown, + Dispatch extends ReduxDispatch = ReduxDispatch > = { id: string - effect: ListenerEffect + effect: ListenerEffect unsubscribe: () => void pending: Set type?: string - predicate: ListenerPredicate + predicate: ListenerPredicate } -const declaredMiddlewareType: unique symbol = undefined as any -export type WithMiddlewareType> = { - [declaredMiddlewareType]: T -} - -// A shorthand form of the accepted args, solely so that `createListenerEntry` has validly-typed conditional logic when checking the options contents +/** + * @internal + * A shorthand form of the accepted args, solely so that `createListenerEntry` has validly-typed conditional logic when checking the options contents + */ export type FallbackAddListenerOptions = { actionCreator?: TypedActionCreator type?: string @@ -432,8 +493,10 @@ export type FallbackAddListenerOptions = { * Utility Types */ +/** @public */ export type Unsubscribe = () => void +/** @public */ export type GuardedType = T extends ( x: any, ...args: unknown[] @@ -441,36 +504,10 @@ export type GuardedType = T extends ( ? T : never +/** @public */ export type ListenerPredicateGuardedActionType = T extends ListenerPredicate< infer Action, any > ? Action : never - -/** - * Additional infos regarding the error raised. - */ -export interface ListenerErrorInfo { - /** - * Which function has generated the exception. - */ - raisedBy: 'effect' | 'predicate' -} - -/** - * Gets notified with synchronous and asynchronous errors raised by `listeners` or `predicates`. - * @param error The thrown error. - * @param errorInfo Additional information regarding the thrown error. - */ -export interface ListenerErrorHandler { - (error: unknown, errorInfo: ListenerErrorInfo): void -} - -export interface CreateListenerMiddlewareOptions { - extra?: ExtraArgument - /** - * Receives synchronous and asynchronous errors that are raised by `listener` and `listenerOption.predicate`. - */ - onError?: ListenerErrorHandler -} diff --git a/packages/action-listener-middleware/src/utils.ts b/packages/toolkit/src/listenerMiddleware/utils.ts similarity index 100% rename from packages/action-listener-middleware/src/utils.ts rename to packages/toolkit/src/listenerMiddleware/utils.ts diff --git a/packages/toolkit/src/query/tests/buildHooks.test.tsx b/packages/toolkit/src/query/tests/buildHooks.test.tsx index acfc6a99fa..0adf60eb10 100644 --- a/packages/toolkit/src/query/tests/buildHooks.test.tsx +++ b/packages/toolkit/src/query/tests/buildHooks.test.tsx @@ -963,15 +963,15 @@ describe('hooks tests', () => { const unwrappedErrorResult = screen.getByTestId('unwrappedError')?.textContent - errorResult && - unwrappedErrorResult && + if (errorResult && unwrappedErrorResult) { expect(JSON.parse(errorResult)).toMatchObject({ status: 500, data: null, - }) && + }) expect(JSON.parse(unwrappedErrorResult)).toMatchObject( JSON.parse(errorResult) ) + } }) expect(screen.getByTestId('result').textContent).toBe('') @@ -1015,14 +1015,14 @@ describe('hooks tests', () => { const unwrappedDataResult = screen.getByTestId('unwrappedResult')?.textContent - dataResult && - unwrappedDataResult && + if (dataResult && unwrappedDataResult) { expect(JSON.parse(dataResult)).toMatchObject({ name: 'Timmy', - }) && + }) expect(JSON.parse(unwrappedDataResult)).toMatchObject( JSON.parse(dataResult) ) + } }) expect(screen.getByTestId('error').textContent).toBe('') diff --git a/website/sidebars.json b/website/sidebars.json index 5b2e077863..402853dda0 100644 --- a/website/sidebars.json +++ b/website/sidebars.json @@ -40,7 +40,8 @@ "api/configureStore", "api/getDefaultMiddleware", "api/immutabilityMiddleware", - "api/serializabilityMiddleware" + "api/serializabilityMiddleware", + "api/createListenerMiddleware" ] }, { diff --git a/yarn.lock b/yarn.lock index 563ce0f517..2338f5283a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5513,22 +5513,14 @@ __metadata: languageName: node linkType: hard -"@rtk-incubator/action-listener-middleware@^0.8.0, @rtk-incubator/action-listener-middleware@workspace:packages/action-listener-middleware": - version: 0.0.0-use.local - resolution: "@rtk-incubator/action-listener-middleware@workspace:packages/action-listener-middleware" - dependencies: - "@reduxjs/toolkit": ^1.6.0 - "@types/jest": ^24.0.11 - "@types/node": ^10.14.4 - jest: ^26.6.3 - microbundle: ^0.13.3 - rimraf: ^3.0.2 - ts-jest: ^26.5.5 - typescript: ^4.3.4 +"@rtk-incubator/action-listener-middleware@npm:^0.8.0": + version: 0.8.0 + resolution: "@rtk-incubator/action-listener-middleware@npm:0.8.0" peerDependencies: "@reduxjs/toolkit": ^1.6.0 - languageName: unknown - linkType: soft + checksum: 2e9c0a235758bf2e7915c708ad641a7d3c25b0f7b0da787dca0fbdf73b0f530f3e9923f251232d1650c847e3447e96377ac592d5b167b7b63f216dccbb7c4d61 + languageName: node + linkType: hard "@rtk-query/codegen-openapi@workspace:packages/rtk-query-codegen-openapi": version: 0.0.0-use.local