diff --git a/.eslintrc.js b/.eslintrc.js index 0184a7a46..5a71532bb 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -18,6 +18,12 @@ module.exports = { 'error', { prefer: 'type-imports', disallowTypeAnnotations: false }, ], + 'react-hooks/exhaustive-deps': [ + 'warn', + { + additionalHooks: '(usePossiblyImmediateEffect)', + }, + ], }, overrides: [ // { diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2efdf9f18..5cc462e99 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -96,7 +96,7 @@ jobs: fail-fast: false matrix: node: ['14.x'] - ts: ['3.9', '4.0', '4.1', '4.2', '4.3', 'next'] + ts: ['3.9', '4.0', '4.1', '4.2', '4.3', '4.4', '4.5', 'next'] steps: - name: Checkout repo uses: actions/checkout@v2 diff --git a/.gitignore b/.gitignore index 66b08f102..1a9e93821 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ temp/ .tmp-projections build/ .rts2* +coverage/ typesversions .cache diff --git a/docs/api/createAsyncThunk.mdx b/docs/api/createAsyncThunk.mdx index 19f048a03..f94531906 100644 --- a/docs/api/createAsyncThunk.mdx +++ b/docs/api/createAsyncThunk.mdx @@ -96,9 +96,9 @@ The logic in the `payloadCreator` function may use any of these values as needed An object with the following optional fields: -- `condition(arg, { getState, extra } ): boolean`: a callback that can be used to skip execution of the payload creator and all action dispatches, if desired. See [Canceling Before Execution](#canceling-before-execution) for a complete description. +- `condition(arg, { getState, extra } ): boolean | Promise`: a callback that can be used to skip execution of the payload creator and all action dispatches, if desired. See [Canceling Before Execution](#canceling-before-execution) for a complete description. - `dispatchConditionRejection`: if `condition()` returns `false`, the default behavior is that no actions will be dispatched at all. If you still want a "rejected" action to be dispatched when the thunk was canceled, set this flag to `true`. -- `idGenerator(): string`: a function to use when generating the `requestId` for the request sequence. Defaults to use [nanoid](./otherExports.mdx/#nanoid). +- `idGenerator(arg): string`: a function to use when generating the `requestId` for the request sequence. Defaults to use [nanoid](./otherExports.mdx/#nanoid), but you can implement your own ID generation logic. - `serializeError(error: unknown) => any` to replace the internal `miniSerializeError` method with your own serialization logic. - `getPendingMeta({ arg, requestId }, { getState, extra }): any`: a function to create an object that will be merged into the `pendingAction.meta` field. @@ -357,7 +357,7 @@ const updateUser = createAsyncThunk( ### Canceling Before Execution -If you need to cancel a thunk before the payload creator is called, you may provide a `condition` callback as an option after the payload creator. The callback will receive the thunk argument and an object with `{getState, extra}` as parameters, and use those to decide whether to continue or not. If the execution should be canceled, the `condition` callback should return a literal `false` value: +If you need to cancel a thunk before the payload creator is called, you may provide a `condition` callback as an option after the payload creator. The callback will receive the thunk argument and an object with `{getState, extra}` as parameters, and use those to decide whether to continue or not. If the execution should be canceled, the `condition` callback should return a literal `false` value or a promise that should resolve to `false`. If a promise is returned, the thunk waits for it to get fulfilled before dispatching the `pending` action, otherwise it proceeds with dispatching synchronously. ```js const fetchUserById = createAsyncThunk( diff --git a/docs/api/createReducer.mdx b/docs/api/createReducer.mdx index f19a846fd..d8e164d70 100644 --- a/docs/api/createReducer.mdx +++ b/docs/api/createReducer.mdx @@ -121,6 +121,21 @@ so we recommend the "builder callback" notation in most cases. [params](docblock://createReducer.ts?token=createReducer&overload=1) +### Returns + +The generated reducer function. + +The reducer will have a `getInitialState` function attached that will return the initial state when called. This may be useful for tests or usage with React's `useReducer` hook: + +```js +const counterReducer = createReducer(0, { + increment: (state, action) => state + action.payload, + decrement: (state, action) => state - action.payload, +}) + +console.log(counterReducer.getInitialState()) // 0 +``` + ### Example Usage [examples](docblock://createReducer.ts?token=createReducer&overload=1) diff --git a/docs/api/createSlice.mdx b/docs/api/createSlice.mdx index fe376fc33..7fc92a0ed 100644 --- a/docs/api/createSlice.mdx +++ b/docs/api/createSlice.mdx @@ -71,6 +71,8 @@ function createSlice({ The initial state value for this slice of state. +This may also be a "lazy initializer" function, which should return an initial state value when called. This will be used whenever the reducer is called with `undefined` as its state value, and is primarily useful for cases like reading initial state from `localStorage`. + ### `name` A string name for this slice of state. Generated action type constants will use this as a prefix. @@ -196,7 +198,8 @@ We recommend using the `builder callback` API as the default, especially if you name : string, reducer : ReducerFunction, actions : Record, - caseReducers: Record + caseReducers: Record. + getInitialState: () => State } ``` diff --git a/docs/package.json b/docs/package.json index 882422497..a3e5228c8 100644 --- a/docs/package.json +++ b/docs/package.json @@ -11,6 +11,8 @@ "graphql-request": "^3.4.0", "immutable": "^3.8.2", "nanoid": "^3.1.23", + "next-redux-wrapper": "^7.0.5", + "redux-persist": "^6.0.0", "rxjs": "^6.6.2" } } diff --git a/docs/rtk-query/api/createApi.mdx b/docs/rtk-query/api/createApi.mdx index 41f7e95ba..89f2fe535 100644 --- a/docs/rtk-query/api/createApi.mdx +++ b/docs/rtk-query/api/createApi.mdx @@ -56,6 +56,16 @@ export const { useGetPokemonByNameQuery } = pokemonApi ```ts no-transpile baseQuery(args: InternalQueryArgs, api: BaseQueryApi, extraOptions?: DefinitionExtraOptions): any; endpoints(build: EndpointBuilder): Definitions; + extractRehydrationInfo?: ( + action: AnyAction, + { + reducerPath, + }: { + reducerPath: ReducerPath + } + ) => + | undefined + | CombinedState tagTypes?: readonly TagTypes[]; reducerPath?: ReducerPath; serializeQueryArgs?: SerializeQueryArgs; @@ -144,7 +154,8 @@ export type QueryDefinition< /* transformResponse only available with `query`, not `queryFn` */ transformResponse?( baseQueryReturnValue: BaseQueryResult, - meta: BaseQueryMeta + meta: BaseQueryMeta, + arg: QueryArg ): ResultType | Promise extraOptions?: BaseQueryExtraOptions @@ -211,7 +222,8 @@ export type MutationDefinition< /* transformResponse only available with `query`, not `queryFn` */ transformResponse?( baseQueryReturnValue: BaseQueryResult, - meta: BaseQueryMeta + meta: BaseQueryMeta, + arg: QueryArg ): ResultType | Promise extraOptions?: BaseQueryExtraOptions @@ -289,6 +301,15 @@ export const { endpoints, reducerPath, reducer, middleware } = api // see `createApi` overview for _all exports_ ``` +### `extractRehydrationInfo` + +[summary](docblock://query/createApi.ts?token=CreateApiOptions.extractRehydrationInfo) + +[examples](docblock://query/createApi.ts?token=CreateApiOptions.extractRehydrationInfo) + +See also [Server Side Rendering](../usage/server-side-rendering.mdx) and +[Persistence and Rehydration](../usage/persistence-and-rehydration.mdx). + ### `tagTypes` [summary](docblock://query/createApi.ts?token=CreateApiOptions.tagTypes) @@ -406,7 +427,8 @@ In some cases, you may want to manipulate the data returned from a query before See also [Customizing query responses with `transformResponse`](../usage/customizing-queries.mdx#customizing-query-responses-with-transformresponse) ```ts title="Unpack a deeply nested collection" no-transpile -transformResponse: (response) => response.some.deeply.nested.collection +transformResponse: (response, meta, arg) => + response.some.deeply.nested.collection ``` ### `extraOptions` diff --git a/docs/rtk-query/api/created-api/cache-management-utils.mdx b/docs/rtk-query/api/created-api/api-slice-utils.mdx similarity index 80% rename from docs/rtk-query/api/created-api/cache-management-utils.mdx rename to docs/rtk-query/api/created-api/api-slice-utils.mdx index 190cf0553..888d916c6 100644 --- a/docs/rtk-query/api/created-api/cache-management-utils.mdx +++ b/docs/rtk-query/api/created-api/api-slice-utils.mdx @@ -1,15 +1,19 @@ --- -id: cache-management-utils -title: 'API Slices: Cache Management' -sidebar_label: Cache Management Utils +id: api-slice-utils +title: 'API Slices: Utilities' +sidebar_label: API Slice Utilities hide_title: true ---   -# API Slices: Cache Management Utilities +# API Slices: Utilities -The API slice object includes cache management utilities that are used for implementing [optimistic updates](../../usage/manual-cache-updates.mdx#optimistic-updates). These are included in a `util` field inside the slice object. +The API slice object includes various utilities that can be used for cache management, +such as implementing [optimistic updates](../../usage/manual-cache-updates.mdx#optimistic-updates), +as well implementing [server side rendering](../../usage/server-side-rendering.mdx). + +These are included in a `util` field inside the slice object. ### `updateQueryData` @@ -244,3 +248,54 @@ Note that [hooks](./hooks.mdx) also track state in local component state and mig ```ts no-transpile dispatch(api.util.resetApiState()) ``` + +## `getRunningOperationPromises` + +#### Signature + +```ts no-transpile +getRunningOperationPromises: () => Array> +``` + +#### Description + +A function that returns all promises for running queries and mutations. + +This is useful for SSR scenarios to await everything triggered in any way, including via hook calls, +or manually dispatching `initiate` actions. + +```ts no-transpile title="Awaiting all currently running queries & mutations example" +await Promise.all(api.util.getRunningOperationPromises()) +``` + +## `getRunningOperationPromise` + +#### Signature + +```ts no-transpile +getRunningOperationPromise: >( + endpointName: EndpointName, + args: QueryArgFrom +) => + | QueryActionCreatorResult + | undefined + +getRunningOperationPromise: >( + endpointName: EndpointName, + fixedCacheKeyOrRequestId: string +) => + | MutationActionCreatorResult + | undefined +``` + +#### Description + +A function that returns a single promise for a given endpoint name + argument combination, +if it is currently running. If it is not currently running, the function returns `undefined`. + +When used with mutation endpoints, it accepts a [fixed cache key](./hooks.mdx#signature-1) +or request ID rather than the argument. + +This is primarily added to add experimental support for suspense in the future. +It enables writing custom hooks that look up if RTK Query has already got a running promise +for a certain endpoint/argument combination, and retrieving that promise to `throw` it. diff --git a/docs/rtk-query/api/created-api/hooks.mdx b/docs/rtk-query/api/created-api/hooks.mdx index 0e589fd09..d46efff23 100644 --- a/docs/rtk-query/api/created-api/hooks.mdx +++ b/docs/rtk-query/api/created-api/hooks.mdx @@ -261,7 +261,8 @@ type UseQueryOptions = { type UseQueryResult = { // Base query state originalArgs?: unknown // Arguments passed to the query - data?: T // Returned result if present + data?: T // The latest returned result regardless of hook arg, if present + currentData?: T // The latest returned result for the current hook arg, if present error?: unknown // Error result if present requestId?: string // A string generated by RTK Query endpointName?: string // The name of the given endpoint for the query @@ -314,20 +315,22 @@ type UseMutation = ( type UseMutationStateOptions = { // A method to determine the contents of `UseMutationResult` selectFromResult?: (result: UseMutationStateDefaultResult) => any + // A string used to enable shared results across hook instances which have the same key + fixedCacheKey?: string } -type UseMutationTrigger = ( - arg: any -) => Promise<{ data: T } | { error: BaseQueryError | SerializedError }> & { +type UseMutationTrigger = (arg: any) => Promise< + { data: T } | { error: BaseQueryError | SerializedError } +> & { requestId: string // A string generated by RTK Query abort: () => void // A method to cancel the mutation promise unwrap: () => Promise // A method to unwrap the mutation call and provide the raw response/error - unsubscribe: () => void // A method to manually unsubscribe from the mutation call + reset: () => void // A method to manually unsubscribe from the mutation call and reset the result to the uninitialized state } type UseMutationResult = { // Base query state - originalArgs?: unknown // Arguments passed to the latest mutation call + originalArgs?: unknown // Arguments passed to the latest mutation call. Not available if using the `fixedCacheKey` option data?: T // Returned result if present error?: unknown // Error result if present endpointName?: string // The name of the given endpoint for the mutation @@ -339,6 +342,8 @@ type UseMutationResult = { isSuccess: boolean // Mutation has data from a successful call isError: boolean // Mutation is currently in an "error" state startedTimeStamp?: number // Timestamp for when the latest mutation was initiated + + reset: () => void // A method to manually unsubscribe from the mutation call and reset the result to the uninitialized state } ``` @@ -356,11 +361,16 @@ selectFromResult: () => ({}) - **Parameters** - - `options`: A set of options that control the subscription behavior of the hook + - `options`: A set of options that control the subscription behavior of the hook: + - `selectFromResult`: A callback that can be used to customize the mutation result returned as the second item in the tuple + - `fixedCacheKey`: An optional string used to enable shared results across hook instances - **Returns**: A tuple containing: - `trigger`: A function that triggers an update to the data based on the provided argument. The trigger function returns a promise with the properties shown above that may be used to handle the behavior of the promise - - `mutationState`: A query status object containing the current loading state and metadata about the request, or the values returned by the `selectFromResult` option where applicable + - `mutationState`: A query status object containing the current loading state and metadata about the request, or the values returned by the `selectFromResult` option where applicable. + Additionally, this object will contain + - a `reset` method to reset the hook back to it's original state and remove the current result from the cache + - an `originalArgs` property that contains the argument passed to the last call of the `trigger` function. #### Description @@ -388,7 +398,8 @@ type UseQueryStateOptions = { type UseQueryStateResult = { // Base query state originalArgs?: unknown // Arguments passed to the query - data?: T // Returned result if present + data?: T // The latest returned result regardless of hook arg, if present + currentData?: T // The latest returned result for the current hook arg, if present error?: unknown // Error result if present requestId?: string // A string generated by RTK Query endpointName?: string // The name of the given endpoint for the query @@ -459,9 +470,8 @@ type UseQuerySubscriptionResult = { ## `useLazyQuery` ```ts title="Accessing a useLazyQuery hook" no-transpile -const [trigger, result, lastPromiseInfo] = api.endpoints.getPosts.useLazyQuery( - options -) +const [trigger, result, lastPromiseInfo] = + api.endpoints.getPosts.useLazyQuery(options) // or const [trigger, result, lastPromiseInfo] = api.useLazyGetPostsQuery(options) ``` @@ -480,12 +490,24 @@ type UseLazyQueryOptions = { selectFromResult?: (result: UseQueryStateDefaultResult) => any } -type UseLazyQueryTrigger = (arg: any) => void +type UseLazyQueryTrigger = (arg: any) => Promise< + QueryResultSelectorResult +> & { + arg: unknown // Whatever argument was provided to the query + requestId: string // A string generated by RTK Query + subscriptionOptions: SubscriptionOptions // The values used for the query subscription + abort: () => void // A method to cancel the query promise + unwrap: () => Promise // A method to unwrap the query call and provide the raw response/error + unsubscribe: () => void // A method used to manually unsubscribe from the query results + refetch: () => void // A method used to re-run the query. In most cases when using a lazy query, you will never use this and should prefer to call the trigger again. + updateSubscriptionOptions: (options: SubscriptionOptions) () => void // A method used to update the subscription options (eg. pollingInterval) +} type UseQueryStateResult = { // Base query state originalArgs?: unknown // Arguments passed to the query - data?: T // Returned result if present + data?: T // The latest returned result regardless of trigger arg, if present + currentData?: T // The latest returned result for the trigger arg, if present error?: unknown // Error result if present requestId?: string // A string generated by RTK Query endpointName?: string // The name of the given endpoint for the query @@ -520,9 +542,8 @@ type UseLazyQueryLastPromiseInfo = { ## `useLazyQuerySubscription` ```ts title="Accessing a useLazyQuerySubscription hook" no-transpile -const [trigger, lastArg] = api.endpoints.getPosts.useLazyQuerySubscription( - options -) +const [trigger, lastArg] = + api.endpoints.getPosts.useLazyQuerySubscription(options) ``` #### Signature diff --git a/docs/rtk-query/api/created-api/overview.mdx b/docs/rtk-query/api/created-api/overview.mdx index 84be8e926..c6e5bd8b7 100644 --- a/docs/rtk-query/api/created-api/overview.mdx +++ b/docs/rtk-query/api/created-api/overview.mdx @@ -44,11 +44,29 @@ type Api = { injectEndpoints: (options: InjectEndpointsOptions) => UpdatedApi enhanceEndpoints: (options: EnhanceEndpointsOptions) => UpdatedApi - // Cache management utilities + // Utilities utils: { updateQueryData: UpdateQueryDataThunk patchQueryData: PatchQueryDataThunk prefetch: PrefetchThunk + invalidateTags: ActionCreatorWithPayload< + Array>, + string + > + resetApiState: SliceActions['resetApiState'] + getRunningOperationPromises: () => Array> + getRunningOperationPromise: >( + endpointName: EndpointName, + args: QueryArgFrom + ) => + | QueryActionCreatorResult + | undefined + getRunningOperationPromise: >( + endpointName: EndpointName, + fixedCacheKeyOrRequestId: string + ) => + | MutationActionCreatorResult + | undefined } // Internal actions @@ -95,6 +113,19 @@ Each API slice object has `injectEndpoints` and `enhanceEndpoints` functions to ::: +## API Slice Utilities + +The `util` field includes various utility functions that can be used to manage the cache, including +manually updating query cache data, triggering pre-fetching of data, manually invalidating tags, +and manually resetting the api state, as well as other utility functions that can be used in +various scenarios, including SSR. + +:::info API Reference + +- [API Slices: Utilities](./api-slice-utils.mdx) + +::: + ## Internal Actions The `internalActions` field contains a set of additional thunks that are used for internal behavior, such as managing updates based on focus. diff --git a/docs/rtk-query/api/fetchBaseQuery.mdx b/docs/rtk-query/api/fetchBaseQuery.mdx index 40ce01a44..6ce56b50c 100644 --- a/docs/rtk-query/api/fetchBaseQuery.mdx +++ b/docs/rtk-query/api/fetchBaseQuery.mdx @@ -13,7 +13,7 @@ description: 'RTK Query > API: fetchBaseQuery reference' This is a very small wrapper around [`fetch`](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) that aims to simplify requests. It is not a full-blown replacement for `axios`, `superagent`, or any other more heavy-weight library, but it will cover the large majority of your needs. -It takes all standard options from fetch's [`RequestInit`](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch) interface, as well as `baseUrl`, a `prepareHeaders` function, and an optional `fetch` function. +It takes all standard options from fetch's [`RequestInit`](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch) interface, as well as `baseUrl`, a `prepareHeaders` function, an optional `fetch` function, and a `paramsSerializer` function. - `baseUrl` _(required)_ - Typically a string like `https://api.your-really-great-app.com/v1/`. If you don't provide a `baseUrl`, it defaults to a relative path from where the request is being made. You should most likely _always_ specify this. @@ -25,6 +25,8 @@ It takes all standard options from fetch's [`RequestInit`](https://developer.moz ;(headers: Headers, api: { getState: () => unknown }) => Headers ``` +- `paramsSerializer` _(optional)_ + - A function that can be used to apply custom transformations to the data passed into [`params`](#setting-the-query-string). If you don't provide this, `params` will be given directly to `new URLSearchParms()`. With some API integrations, you may need to leverage this to use something like the [`query-string`](https://github.com/sindresorhus/query-string) library to support different array types. - `fetchFn` _(optional)_ - A fetch function that overrides the default on the window. Can be useful in SSR environments where you may need to leverage `isomorphic-fetch` or `cross-fetch`. @@ -154,7 +156,10 @@ By default, `fetchBaseQuery` assumes that every request you make will be `json`, ### Setting the query string -`fetchBaseQuery` provides a simple mechanism that converts an `object` to a serialized query string. If this doesn't suit your needs, you can always build your own querystring and set it in the `url`. +`fetchBaseQuery` provides a simple mechanism that converts an `object` to a serialized query string by passing the object to `new URLSearchParms()`. If this doesn't suit your needs, you have two options: + +1. Pass the `paramsSerializer` option to `fetchBaseQuery` to apply custom transformations +2. Build your own querystring and set it in the `url` ```ts no-transpile // omitted @@ -162,7 +167,9 @@ By default, `fetchBaseQuery` assumes that every request you make will be `json`, updateUser: builder.query({ query: (user: Record) => ({ url: `users`, - params: user // The user object is automatically converted and produces a request like /api/users?first_name=test&last_name=example + // Assuming no `paramsSerializer` is specified, the user object is automatically converted + // and produces a url like /api/users?first_name=test&last_name=example + params: user }), }), ``` diff --git a/docs/rtk-query/usage/customizing-queries.mdx b/docs/rtk-query/usage/customizing-queries.mdx index d7f69c465..8ae69f297 100644 --- a/docs/rtk-query/usage/customizing-queries.mdx +++ b/docs/rtk-query/usage/customizing-queries.mdx @@ -103,7 +103,11 @@ Individual endpoints on [`createApi`](../api/createApi.mdx) accept a [`transform By default, the payload from the server is returned directly. ```ts -function defaultTransformResponse(baseQueryReturnValue: unknown) { +function defaultTransformResponse( + baseQueryReturnValue: unknown, + meta: unknown, + arg: unknown +) { return baseQueryReturnValue } ``` @@ -111,13 +115,16 @@ function defaultTransformResponse(baseQueryReturnValue: unknown) { To change it, provide a function that looks like: ```ts title="Unpack a deeply nested collection" no-transpile -transformResponse: (response) => response.some.deeply.nested.collection +transformResponse: (response, meta, arg) => + response.some.deeply.nested.collection ``` -`transformResponse` is also called with the `meta` property returned from the `baseQuery`, which can be used while determining the transformed response. The value for `meta` is dependent on the `baseQuery` used. +`transformResponse` is called with the `meta` property returned from the `baseQuery` as it's second +argument, which can be used while determining the transformed response. The value for `meta` is +dependent on the `baseQuery` used. ```ts title="transformResponse meta example" no-transpile -transformResponse: (response: { sideA: Tracks; sideB: Tracks }, meta) => { +transformResponse: (response: { sideA: Tracks; sideB: Tracks }, meta, arg) => { if (meta?.coinFlip === 'heads') { return response.sideA } @@ -125,6 +132,19 @@ transformResponse: (response: { sideA: Tracks; sideB: Tracks }, meta) => { } ``` +`transformResponse` is called with the `arg` property provided to the endpoint as it's third +argument, which can be used while determining the transformed response. The value for `arg` is +dependent on the `endpoint` used, as well as the argument used when calling the query/mutation. + +```ts title="transformResponse arg example" no-transpile +transformResponse: (response: Posts, meta, arg) => { + return { + originalArg: arg, + data: response, + } +} +``` + While there is less need to store the response in a [normalized lookup table](https://redux.js.org/recipes/structuring-reducers/normalizing-state-shape) with RTK Query managing caching data, `transformResponse` can be leveraged to do so if desired. ```ts title="Normalize the response data" no-transpile diff --git a/docs/rtk-query/usage/error-handling.mdx b/docs/rtk-query/usage/error-handling.mdx index 157b0d2bc..6f36381b9 100644 --- a/docs/rtk-query/usage/error-handling.mdx +++ b/docs/rtk-query/usage/error-handling.mdx @@ -94,7 +94,7 @@ import { toast } from 'your-cool-library' export const rtkQueryErrorLogger: Middleware = (api: MiddlewareAPI) => ( next ) => (action) => { - // RTK Query uses `createAsyncThunk` from redux-toolkit under the hood, so we're able to utilize these use matchers! + // RTK Query uses `createAsyncThunk` from redux-toolkit under the hood, so we're able to utilize these matchers! if (isRejectedWithValue(action)) { console.warn('We got a rejected action!') toast.warn({ title: 'Async error!', message: action.error.data.message }) diff --git a/docs/rtk-query/usage/manual-cache-updates.mdx b/docs/rtk-query/usage/manual-cache-updates.mdx index 5f9664081..f3cee4fac 100644 --- a/docs/rtk-query/usage/manual-cache-updates.mdx +++ b/docs/rtk-query/usage/manual-cache-updates.mdx @@ -21,7 +21,7 @@ unless you encounter the need to do so. However, in some cases, you may want to update the cache manually. When you wish to update cache data that _already exists_ for query endpoints, you can do so using the -[`updateQueryData`](../api/created-api/cache-management-utils.mdx#updatequerydata) thunk action +[`updateQueryData`](../api/created-api/api-slice-utils.mdx#updatequerydata) thunk action available on the `util` object of your created API. Anywhere you have access to the `dispatch` method for the store instance, you can dispatch the diff --git a/docs/rtk-query/usage/mutations.mdx b/docs/rtk-query/usage/mutations.mdx index ed74ea04e..036920dca 100644 --- a/docs/rtk-query/usage/mutations.mdx +++ b/docs/rtk-query/usage/mutations.mdx @@ -50,7 +50,7 @@ const api = createApi({ body: patch, }), // Pick out data and prevent nested properties in a hook or selector - transformResponse: (response: { data: Post }) => response.data, + transformResponse: (response: { data: Post }, meta, arg) => response.data, invalidatesTags: ['Post'], // onQueryStarted is useful for optimistic updates // The 2nd parameter is the destructured `MutationLifecycleApi` @@ -109,6 +109,7 @@ Below are some of the most frequently used properties on the "mutation result" o - `isLoading` - When true, indicates that the mutation has been fired and is awaiting a response. - `isSuccess` - When true, indicates that the last mutation fired has data from a successful request. - `isError` - When true, indicates that the last mutation fired resulted in an error state. +- `reset` - A method to reset the hook back to it's original state and remove the current result from the cache :::note @@ -116,9 +117,63 @@ With RTK Query, a mutation does not contain a semantic distinction between 'load ::: +### Shared Mutation Results + +By default, separate instances of a `useMutation` hook are not inherently related to each other. +Triggering one instance will not affect the result for a separate instance. This applies regardless +of whether the hooks are called within the same component, or different components. + +```tsx no-transpile +export const ComponentOne = () => { + // Triggering `updatePostOne` will affect the result in this component, + // but not the result in `ComponentTwo`, and vice-versa + const [updatePost, result] = useUpdatePostMutation() + + return
...
+} + +export const ComponentTwo = () => { + const [updatePost, result] = useUpdatePostMutation() + + return
...
+} +``` + +RTK Query provides an option to share results across mutation hook instances using the +`fixedCacheKey` option. +Any `useMutation` hooks with the same `fixedCacheKey` string will share results between each other +when any of the trigger functions are called. This should be a unique string shared between each +mutation hook instance you wish to share results. + +```tsx no-transpile +export const ComponentOne = () => { + // Triggering `updatePostOne` will affect the result in both this component, + // but as well as the result in `ComponentTwo`, and vice-versa + const [updatePost, result] = useUpdatePostMutation({ + fixedCacheKey: 'shared-update-post', + }) + + return
...
+} + +export const ComponentTwo = () => { + const [updatePost, result] = useUpdatePostMutation({ + fixedCacheKey: 'shared-update-post', + }) + + return
...
+} +``` + +:::note + +When using `fixedCacheKey`, the `originalArgs` property is not able to be shared and will always be `undefined`. + +::: + ### Standard Mutation Example -This is a modified version of the complete example you can see at the bottom of the page to highlight the `updatePost` mutation. In this scenario, a post is fetched with `useQuery`, and then a `EditablePostName` component is rendered that allows us to edit the name of the post. +This is a modified version of the complete example you can see at the bottom of the page to highlight the `updatePost` mutation. In this scenario, a post is fetched with `useQuery`, and then an `EditablePostName` component is rendered that allows us to edit the name of the post. ```tsx title="src/features/posts/PostDetail.tsx" export const PostDetail = () => { diff --git a/docs/rtk-query/usage/persistence-and-rehydration.mdx b/docs/rtk-query/usage/persistence-and-rehydration.mdx new file mode 100644 index 000000000..8cfa3229f --- /dev/null +++ b/docs/rtk-query/usage/persistence-and-rehydration.mdx @@ -0,0 +1,56 @@ +--- +id: persistence-and-rehydration +title: Persistence and Rehydration +sidebar_label: Persistence and Rehydration +hide_title: true +description: 'RTK Query > Usage > Persistence and Rehydration' +--- + +  + +# Persistence and Rehydration + +RTK Query supports rehydration via the [`extractRehydrationInfo`](../api/createApi.mdx#extractrehydrationinfo) +option on [`createApi`](../api/createApi.mdx). This function is passed every dispatched action, +and where it returns a value other than `undefined`, that value is used to rehydrate the API state +for fulfilled & errored queries. + +See also [Server Side Rendering](./server-side-rendering.mdx). + +:::info + +Generally, persisting API slices is not recommended and instead, mechanisms like +[`Cache-Control` Headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control) +should be used in browsers to define cache behaviour. +Persisting and rehydrating an api slice might always leave the user with very stale data if the user +has not visited the page for some time. +Nonetheless, in environments like Native Apps, where there is no browser cache to take care of this, +persistance might still be a viable option. + +::: + +## Redux Persist + +API state rehydration can be used in conjunction with [Redux Persist](https://github.com/rt2zz/redux-persist) +by leveraging the `REHYDRATE` action type imported from `redux-persist`. This can be used out of the +box with the `autoMergeLevel1` or `autoMergeLevel2` [state reconcilers](https://github.com/rt2zz/redux-persist#state-reconciler) +when persisting the root reducer, or with the `autoMergeLevel1` reconciler when persisting just the api reducer. + +```ts title="redux-persist rehydration example" +import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react' +import { REHYDRATE } from 'redux-persist' + +export const api = createApi({ + baseQuery: fetchBaseQuery({ baseUrl: '/' }), + // highlight-start + extractRehydrationInfo(action, { reducerPath }) { + if (action.type === REHYDRATE) { + return action.payload[reducerPath] + } + }, + // highlight-end + endpoints: (build) => ({ + // omitted + }), +}) +``` diff --git a/docs/rtk-query/usage/queries.mdx b/docs/rtk-query/usage/queries.mdx index 0448cf667..2db3a7acd 100644 --- a/docs/rtk-query/usage/queries.mdx +++ b/docs/rtk-query/usage/queries.mdx @@ -57,7 +57,7 @@ const api = createApi({ // note: an optional `queryFn` may be used in place of `query` query: (id) => ({ url: `post/${id}` }), // Pick out data and prevent nested properties in a hook or selector - transformResponse: (response: { data: Post }) => response.data, + transformResponse: (response: { data: Post }, meta, arg) => response.data, providesTags: (result, error, id) => [{ type: 'Post', id }], // The 2nd parameter is the destructured `QueryLifecycleApi` async onQueryStarted( @@ -121,12 +121,6 @@ The query hooks expect two parameters: `(queryArg?, queryOptions?)`. The `queryArg` param will be passed through to the underlying `query` callback to generate the URL. -:::caution - -The `queryArg` param is handed to a `useEffect` dependency array internally. RTK Query tries to keep the argument stable by performing a `shallowEquals` diff on the value, however if you pass a deeper nested argument, you will need to keep the param stable yourself, e.g. with `useMemo`. - -::: - The `queryOptions` object accepts several additional parameters that can be used to control the behavior of the data fetching: - [skip](./conditional-fetching) - Allows a query to 'skip' running for that render. Defaults to `false` @@ -146,7 +140,8 @@ All `refetch`-related options will override the defaults you may have set in [cr The query hook returns an object containing properties such as the latest `data` for the query request, as well as status booleans for the current request lifecycle state. Below are some of the most frequently used properties. Refer to [`useQuery`](../api/created-api/hooks.mdx#usequery) for an extensive list of all returned properties. -- `data` - The returned result if present. +- `data` - The latest returned result regardless of hook arg, if present. +- `currentData` - The latest returned result for the current hook arg, if present. - `error` - The error result if present. - `isUninitialized` - When true, indicates that the query has not started yet. - `isLoading` - When true, indicates that the query is currently loading for the first time, and has no data yet. This will be `true` for the first request fired off, but _not_ for subsequent requests. @@ -163,7 +158,11 @@ Here is an example of a `PostDetail` component: ```tsx title="Example" export const PostDetail = ({ id }: { id: string }) => { - const { data: post, isFetching, isLoading } = useGetPostQuery(id, { + const { + data: post, + isFetching, + isLoading, + } = useGetPostQuery(id, { pollingInterval: 3000, refetchOnMountOrArgChange: true, skip: false, @@ -224,6 +223,45 @@ function App() { } ``` +While `data` is expected to used in the majority of situations, `currentData` is also provided, +which allows for a further level of granularity. For example, if you wanted to show data in the UI +as translucent to represent a re-fetching state, you can use `data` in combination with `isFetching` +to achieve this. However, if you also wish to _only_ show data corresponding to the current arg, +you can instead use `currentData` to achieve this. + +In the example below, if posts are being fetched for the first time, a loading skeleton will be +shown. If posts for the current user have previously been fetched, and are re-fetching (e.g. as a +result of a mutation), the UI will show the previous data, but will grey out the data. If the user +changes, it will instead show the skeleton again as opposed to greying out data for the previous user. + +```tsx title="Managing UI behavior with currentData" +import { Skeleton } from './Skeleton' +import { useGetPostsByUserQuery } from './api' + +function PostsList({ userName }: { userName: string }) { + const { currentData, isFetching, isError } = useGetPostsByUserQuery(userName) + + if (isError) return
An error has occurred!
+ + if (isFetching && !currentData) return + + return ( +
+ {currentData + ? currentData.map((post) => ( + + )) + : 'No data available'} +
+ ) +} +``` + ### Query Cache Keys When you perform a query, RTK Query automatically serializes the request parameters and creates an internal `queryCacheKey` for the request. Any future request that produces the same `queryCacheKey` will be de-duped against the original, and will share updates if a `refetch` is trigged on the query from any subscribed component. diff --git a/docs/rtk-query/usage/server-side-rendering.mdx b/docs/rtk-query/usage/server-side-rendering.mdx new file mode 100644 index 000000000..f0c5e0043 --- /dev/null +++ b/docs/rtk-query/usage/server-side-rendering.mdx @@ -0,0 +1,59 @@ +--- +id: server-side-rendering +title: Server Side Rendering +sidebar_label: Server Side Rendering +hide_title: true +description: 'RTK Query > Usage > Server Side Rendering' +--- + +  + +# Server Side Rendering + +## Server Side Rendering with Next.js + +RTK Query supports Server Side Rendering (SSR) with [Next.js](https://nextjs.org/) via +[rehydration](./persistence-and-rehydration.mdx) in combination with +[next-redux-wrapper](https://github.com/kirill-konshin/next-redux-wrapper). + +The workflow is as follows: + +- Set up `next-redux-wrapper` +- In `getStaticProps` or `getServerSideProps`: + - Pre-fetch all queries via the `initiate` actions, e.g. `store.dispatch(api.endpoints.getPokemonByName.initiate(name))` + - Wait for each query to finish using `await Promise.all(api.getRunningOperationPromises())` +- In your `createApi` call, configure rehydration using the `extractRehydrationInfo` option: + + [examples](docblock://query/createApi.ts?token=CreateApiOptions.extractRehydrationInfo) + +An example repo using `next.js` is available [here](https://github.com/phryneas/ssr-experiments/tree/main/nextjs-blog). + +:::tip +While memory leaks are not anticipated, once a render is sent to the client and the store is being +removed from memory, you may wish to also call `store.dispatch(api.util.resetApiState())` to +ensure that no rogue timers are left running. +::: + +:::tip +In order to avoid providing stale data with Static Site Generation (SSG), you may wish to set +[`refetchOnMountOrArgChange`](../api/createApi.mdx#refetchonmountorargchange) to a reasonable value +such as 900 (seconds) in order to allow data to be re-fetched when accessed if it has been that +long since the page was generated. +::: + +## Server Side Rendering elsewhere + +If you are not using `next.js`, and the example above cannot be adapted to your SSR framework, +an `unstable__` marked approach is available to support SSR scenarios where you need to execute +async code during render and not safely in an effect. +This is a similar approach to using [`getDataFromTree`](https://www.apollographql.com/docs/react/performance/server-side-rendering/#executing-queries-with-getdatafromtree) +with [Apollo](https://www.apollographql.com/docs/). + +The workflow is as follows: + +- Create a version of `createApi` that performs asynchronous work during render: + + [examples](docblock://query/react/module.ts?token=ReactHooksModuleOptions.unstable__sideEffectsInRender) + +- Use your custom `createApi` when calling `const api = createApi({...})` +- Wait for all queries to finish using `await Promise.all(api.getRunningOperationPromises())` before performing the next render cycle diff --git a/packages/toolkit/package.json b/packages/toolkit/package.json index 53699eb55..9812e2c87 100644 --- a/packages/toolkit/package.json +++ b/packages/toolkit/package.json @@ -1,6 +1,6 @@ { "name": "@reduxjs/toolkit", - "version": "1.6.0", + "version": "1.7.0-rc.0", "description": "The official, opinionated, batteries-included toolset for efficient Redux development", "author": "Mark Erikson ", "license": "MIT", @@ -37,6 +37,7 @@ "@types/json-stringify-safe": "^5.0.0", "@types/nanoid": "^2.1.0", "@types/node": "^10.14.4", + "@types/query-string": "^6.3.0", "@types/react": "^17.0.3", "@types/react-dom": "^17.0.3", "@types/react-redux": "^7.1.16", @@ -66,6 +67,7 @@ "msw": "^0.28.2", "node-fetch": "^2.6.1", "prettier": "^2.2.1", + "query-string": "^7.0.1", "rimraf": "^3.0.2", "rollup": "^2.47.0", "rollup-plugin-strip-code": "^0.2.6", @@ -98,14 +100,14 @@ "query" ], "dependencies": { - "immer": "^9.0.6", - "redux": "^4.1.0", - "redux-thunk": "^2.3.0", - "reselect": "^4.0.0" + "immer": "^9.0.7", + "redux": "^4.1.2", + "redux-thunk": "^2.4.1", + "reselect": "^4.1.5" }, "peerDependencies": { - "react": "^16.14.0 || ^17.0.0", - "react-redux": "^7.2.1" + "react": "^16.9.0 || ^17.0.0 || 18.0.0-beta", + "react-redux": "^7.2.1 || ^8.0.0-beta" }, "peerDependenciesMeta": { "react": { diff --git a/packages/toolkit/scripts/build.ts b/packages/toolkit/scripts/build.ts index 2f4927b59..d36a1d1cc 100644 --- a/packages/toolkit/scripts/build.ts +++ b/packages/toolkit/scripts/build.ts @@ -202,7 +202,7 @@ async function bundle(options: BuildOptions & EntryPointOptions) { const origin = chunk.text const sourcemap = extractInlineSourcemap(origin) const result = ts.transpileModule(removeInlineSourceMap(origin), { - fileName: chunk.path.replace(/.js$/, '.ts'), + fileName: chunk.path, compilerOptions: { sourceMap: true, module: @@ -253,7 +253,11 @@ async function bundle(options: BuildOptions & EntryPointOptions) { /** * since esbuild doesn't support umd, we use rollup to convert esm to umd */ -async function buildUMD(outputPath: string, prefix: string, globalName: string) { +async function buildUMD( + outputPath: string, + prefix: string, + globalName: string +) { for (let umdExtension of ['umd', 'umd.min']) { const input = path.join(outputPath, `${prefix}.${umdExtension}.js`) const instance = await rollup.rollup({ diff --git a/packages/toolkit/src/configureStore.ts b/packages/toolkit/src/configureStore.ts index 1a2bea1e2..86a9190ae 100644 --- a/packages/toolkit/src/configureStore.ts +++ b/packages/toolkit/src/configureStore.ts @@ -110,7 +110,7 @@ export interface EnhancedStore< * * @inheritdoc */ - dispatch: DispatchForMiddlewares & Dispatch + dispatch: Dispatch & DispatchForMiddlewares } /** diff --git a/packages/toolkit/src/createAsyncThunk.ts b/packages/toolkit/src/createAsyncThunk.ts index e3fafbe76..33d3e034d 100644 --- a/packages/toolkit/src/createAsyncThunk.ts +++ b/packages/toolkit/src/createAsyncThunk.ts @@ -286,7 +286,7 @@ export type AsyncThunkOptions< condition?( arg: ThunkArg, api: Pick, 'getState' | 'extra'> - ): boolean | undefined + ): MaybePromise /** * If `condition` returns `false`, the asyncThunk will be skipped. * This option allows you to control whether a `rejected` action with `meta.condition == false` @@ -303,7 +303,7 @@ export type AsyncThunkOptions< * * @default `nanoid` */ - idGenerator?: () => string + idGenerator?: (arg: ThunkArg) => string } & IsUnknown< GetPendingMeta, { @@ -415,6 +415,21 @@ export type AsyncThunk< typePrefix: string } +/** + * + * @param typePrefix + * @param payloadCreator + * @param options + * + * @public + */ +// separate signature without `AsyncThunkConfig` for better inference +export function createAsyncThunk( + typePrefix: string, + payloadCreator: AsyncThunkPayloadCreator, + options?: AsyncThunkOptions +): AsyncThunk + /** * * @param typePrefix @@ -425,8 +440,18 @@ export type AsyncThunk< */ export function createAsyncThunk< Returned, - ThunkArg = void, - ThunkApiConfig extends AsyncThunkConfig = {} + ThunkArg, + ThunkApiConfig extends AsyncThunkConfig +>( + typePrefix: string, + payloadCreator: AsyncThunkPayloadCreator, + options?: AsyncThunkOptions +): AsyncThunk + +export function createAsyncThunk< + Returned, + ThunkArg, + ThunkApiConfig extends AsyncThunkConfig >( typePrefix: string, payloadCreator: AsyncThunkPayloadCreator, @@ -531,7 +556,9 @@ If you want to use the AbortController to react to \`abort\` events, please cons arg: ThunkArg ): AsyncThunkAction { return (dispatch, getState, extra) => { - const requestId = (options?.idGenerator ?? nanoid)() + const requestId = options?.idGenerator + ? options.idGenerator(arg) + : nanoid() const abortController = new AC() let abortReason: string | undefined @@ -553,11 +580,11 @@ If you want to use the AbortController to react to \`abort\` events, please cons const promise = (async function () { let finalAction: ReturnType try { - if ( - options && - options.condition && - options.condition(arg, { getState, extra }) === false - ) { + let conditionResult = options?.condition?.(arg, { getState, extra }) + if (isThenable(conditionResult)) { + conditionResult = await conditionResult + } + if (conditionResult === false) { // eslint-disable-next-line no-throw-literal throw { name: 'ConditionError', @@ -678,3 +705,11 @@ export function unwrapResult( type WithStrictNullChecks = undefined extends boolean ? False : True + +function isThenable(value: any): value is PromiseLike { + return ( + value !== null && + typeof value === 'object' && + typeof value.then === 'function' + ) +} diff --git a/packages/toolkit/src/createReducer.ts b/packages/toolkit/src/createReducer.ts index 5662b11fb..b747cdc6d 100644 --- a/packages/toolkit/src/createReducer.ts +++ b/packages/toolkit/src/createReducer.ts @@ -66,6 +66,16 @@ export type CaseReducers = { [T in keyof AS]: AS[T] extends Action ? CaseReducer : void } +export type NotFunction = T extends Function ? never : T + +function isStateFunction(x: unknown): x is () => S { + return typeof x === 'function' +} + +export type ReducerWithInitialState> = Reducer & { + getInitialState: () => S +} + /** * A utility function that allows defining a reducer as a mapping from action * type to *case reducer* functions that handle these action types. The @@ -84,8 +94,8 @@ export type CaseReducers = { * That builder provides `addCase`, `addMatcher` and `addDefaultCase` functions that may be * called to define what actions this reducer will handle. * - * @param initialState - The initial state that should be used when the reducer is called the first time. - * @param builderCallback - A callback that receives a *builder* object to define + * @param initialState - `State | (() => State)`: The initial state that should be used when the reducer is called the first time. This may also be a "lazy initializer" function, which should return an initial state value when called. This will be used whenever the reducer is called with `undefined` as its state value, and is primarily useful for cases like reading initial state from `localStorage`. + * @param builderCallback - `(builder: Builder) => void` A callback that receives a *builder* object to define * case reducers via calls to `builder.addCase(actionCreatorOrType, reducer)`. * @example ```ts @@ -105,7 +115,7 @@ function isActionWithNumberPayload( return typeof action.payload === "number"; } -createReducer( +const reducer = createReducer( { counter: 0, sumOfNumberPayloads: 0, @@ -130,10 +140,10 @@ createReducer( ``` * @public */ -export function createReducer( - initialState: S, +export function createReducer>( + initialState: S | (() => S), builderCallback: (builder: ActionReducerMapBuilder) => void -): Reducer +): ReducerWithInitialState /** * A utility function that allows defining a reducer as a mapping from action @@ -151,7 +161,7 @@ export function createReducer( * This overload accepts an object where the keys are string action types, and the values * are case reducer functions to handle those action types. * - * @param initialState - The initial state that should be used when the reducer is called the first time. + * @param initialState - `State | (() => State)`: The initial state that should be used when the reducer is called the first time. This may also be a "lazy initializer" function, which should return an initial state value when called. This will be used whenever the reducer is called with `undefined` as its state value, and is primarily useful for cases like reading initial state from `localStorage`. * @param actionsMap - An object mapping from action types to _case reducers_, each of which handles one specific action type. * @param actionMatchers - An array of matcher definitions in the form `{matcher, reducer}`. * All matching reducers will be executed in order, independently if a case reducer matched or not. @@ -164,6 +174,14 @@ const counterReducer = createReducer(0, { increment: (state, action) => state + action.payload, decrement: (state, action) => state - action.payload }) + +// Alternately, use a "lazy initializer" to provide the initial state +// (works with either form of createReducer) +const initialState = () => 0 +const counterReducer = createReducer(initialState, { + increment: (state, action) => state + action.payload, + decrement: (state, action) => state - action.payload +}) ``` * Action creators that were generated using [`createAction`](./createAction) may be used directly as the keys here, using computed property syntax: @@ -180,31 +198,38 @@ const counterReducer = createReducer(0, { * @public */ export function createReducer< - S, + S extends NotFunction, CR extends CaseReducers = CaseReducers >( - initialState: S, + initialState: S | (() => S), actionsMap: CR, actionMatchers?: ActionMatcherDescriptionCollection, defaultCaseReducer?: CaseReducer -): Reducer +): ReducerWithInitialState -export function createReducer( - initialState: S, +export function createReducer>( + initialState: S | (() => S), mapOrBuilderCallback: | CaseReducers | ((builder: ActionReducerMapBuilder) => void), actionMatchers: ReadonlyActionMatcherDescriptionCollection = [], defaultCaseReducer?: CaseReducer -): Reducer { +): ReducerWithInitialState { let [actionsMap, finalActionMatchers, finalDefaultCaseReducer] = typeof mapOrBuilderCallback === 'function' ? executeReducerBuilderCallback(mapOrBuilderCallback) : [mapOrBuilderCallback, actionMatchers, defaultCaseReducer] - const frozenInitialState = createNextState(initialState, () => {}) + // Ensure the initial state gets frozen either way + let getInitialState: () => S + if (isStateFunction(initialState)) { + getInitialState = () => createNextState(initialState(), () => {}) + } else { + const frozenInitialState = createNextState(initialState, () => {}) + getInitialState = () => frozenInitialState + } - return function (state = frozenInitialState, action): S { + function reducer(state = getInitialState(), action: any): S { let caseReducers = [ actionsMap[action.type], ...finalActionMatchers @@ -257,4 +282,8 @@ export function createReducer( return previousState }, state) } + + reducer.getInitialState = getInitialState + + return reducer as ReducerWithInitialState } diff --git a/packages/toolkit/src/createSlice.ts b/packages/toolkit/src/createSlice.ts index b448e3485..5e120ae4c 100644 --- a/packages/toolkit/src/createSlice.ts +++ b/packages/toolkit/src/createSlice.ts @@ -1,4 +1,5 @@ -import type { Reducer } from 'redux' +import type { AnyAction, Reducer } from 'redux' +import { createNextState } from '.' import type { ActionCreatorWithoutPayload, PayloadAction, @@ -7,8 +8,12 @@ import type { _ActionCreatorWithPreparedPayload, } from './createAction' import { createAction } from './createAction' -import type { CaseReducer, CaseReducers } from './createReducer' -import { createReducer } from './createReducer' +import type { + CaseReducer, + CaseReducers, + ReducerWithInitialState, +} from './createReducer' +import { createReducer, NotFunction } from './createReducer' import type { ActionReducerMapBuilder } from './mapBuilders' import { executeReducerBuilderCallback } from './mapBuilders' import type { NoInfer } from './tsHelpers' @@ -53,6 +58,12 @@ export interface Slice< * This enables reuse and testing if they were defined inline when calling `createSlice`. */ caseReducers: SliceDefinedCaseReducers + + /** + * Provides access to the initial state value given to the slice. + * If a lazy state initializer was provided, it will be called and a fresh value returned. + */ + getInitialState: () => State } /** @@ -71,9 +82,9 @@ export interface CreateSliceOptions< name: Name /** - * The initial state to be returned by the slice reducer. + * The initial state that should be used when the reducer is called the first time. This may also be a "lazy initializer" function, which should return an initial state value when called. This will be used whenever the reducer is called with `undefined` as its state value, and is primarily useful for cases like reading initial state from `localStorage`. */ - initialState: State + initialState: State | (() => State) /** * A mapping from action types to action-type-specific *case reducer* @@ -247,19 +258,16 @@ export function createSlice< >( options: CreateSliceOptions ): Slice { - const { name, initialState } = options + const { name } = options if (!name) { throw new Error('`name` is a required option for createSlice') } + const initialState = + typeof options.initialState == 'function' + ? options.initialState + : createNextState(options.initialState, () => {}) + const reducers = options.reducers || {} - const [ - extraReducers = {}, - actionMatchers = [], - defaultCaseReducer = undefined, - ] = - typeof options.extraReducers === 'function' - ? executeReducerBuilderCallback(options.extraReducers) - : [options.extraReducers] const reducerNames = Object.keys(reducers) @@ -288,18 +296,40 @@ export function createSlice< : createAction(type) }) - const finalCaseReducers = { ...extraReducers, ...sliceCaseReducersByType } - const reducer = createReducer( - initialState, - finalCaseReducers as any, - actionMatchers, - defaultCaseReducer - ) + function buildReducer() { + const [ + extraReducers = {}, + actionMatchers = [], + defaultCaseReducer = undefined, + ] = + typeof options.extraReducers === 'function' + ? executeReducerBuilderCallback(options.extraReducers) + : [options.extraReducers] + + const finalCaseReducers = { ...extraReducers, ...sliceCaseReducersByType } + return createReducer( + initialState, + finalCaseReducers as any, + actionMatchers, + defaultCaseReducer + ) + } + + let _reducer: ReducerWithInitialState return { name, - reducer, + reducer(state, action) { + if (!_reducer) _reducer = buildReducer() + + return _reducer(state, action) + }, actions: actionCreators as any, caseReducers: sliceCaseReducersByName as any, + getInitialState() { + if (!_reducer) _reducer = buildReducer() + + return _reducer.getInitialState() + }, } } diff --git a/packages/toolkit/src/entities/state_selectors.ts b/packages/toolkit/src/entities/state_selectors.ts index c2078e9e0..46f59d3d9 100644 --- a/packages/toolkit/src/entities/state_selectors.ts +++ b/packages/toolkit/src/entities/state_selectors.ts @@ -1,3 +1,4 @@ +import type { Selector } from 'reselect' import { createDraftSafeSelector } from '../createDraftSafeSelector' import type { EntityState, @@ -11,21 +12,20 @@ export function createSelectorsFactory() { function getSelectors( selectState: (state: V) => EntityState ): EntitySelectors - function getSelectors( - selectState?: (state: any) => EntityState + function getSelectors( + selectState?: (state: V) => EntityState ): EntitySelectors { - const selectIds = (state: any) => state.ids + const selectIds = (state: EntityState) => state.ids const selectEntities = (state: EntityState) => state.entities const selectAll = createDraftSafeSelector( selectIds, selectEntities, - (ids: readonly T[], entities: Dictionary): any => - ids.map((id: any) => (entities as any)[id]) + (ids, entities): T[] => ids.map((id) => entities[id]!) ) - const selectId = (_: any, id: EntityId) => id + const selectId = (_: unknown, id: EntityId) => id const selectById = (entities: Dictionary, id: EntityId) => entities[id] @@ -46,7 +46,7 @@ export function createSelectorsFactory() { } const selectGlobalizedEntities = createDraftSafeSelector( - selectState, + selectState as Selector>, selectEntities ) diff --git a/packages/toolkit/src/isPlainObject.ts b/packages/toolkit/src/isPlainObject.ts index ecb582a86..06fb31aa0 100644 --- a/packages/toolkit/src/isPlainObject.ts +++ b/packages/toolkit/src/isPlainObject.ts @@ -11,10 +11,13 @@ export default function isPlainObject(value: unknown): value is object { if (typeof value !== 'object' || value === null) return false - let proto = value - while (Object.getPrototypeOf(proto) !== null) { - proto = Object.getPrototypeOf(proto) + let proto = Object.getPrototypeOf(value) + if (proto === null) return true + + let baseProto = proto + while (Object.getPrototypeOf(baseProto) !== null) { + baseProto = Object.getPrototypeOf(baseProto) } - return Object.getPrototypeOf(value) === proto + return proto === baseProto } diff --git a/packages/toolkit/src/query/apiTypes.ts b/packages/toolkit/src/query/apiTypes.ts index b390f1c8b..3dafe46b0 100644 --- a/packages/toolkit/src/query/apiTypes.ts +++ b/packages/toolkit/src/query/apiTypes.ts @@ -4,10 +4,16 @@ import type { EndpointDefinition, ReplaceTagTypes, } from './endpointDefinitions' -import type { UnionToIntersection, NoInfer } from './tsHelpers' +import type { + UnionToIntersection, + NoInfer, + WithRequiredProp, +} from './tsHelpers' import type { CoreModule } from './core/module' import type { CreateApiOptions } from './createApi' import type { BaseQueryFn } from './baseQueryTypes' +import type { CombinedState } from './core/apiState' +import type { AnyAction } from '@reduxjs/toolkit' export interface ApiModules< // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -31,8 +37,15 @@ export type Module = { TagTypes extends string >( api: Api, - options: Required< - CreateApiOptions + options: WithRequiredProp< + CreateApiOptions, + | 'reducerPath' + | 'serializeQueryArgs' + | 'keepUnusedDataFor' + | 'refetchOnMountOrArgChange' + | 'refetchOnFocus' + | 'refetchOnReconnect' + | 'tagTypes' >, context: ApiContext ): { @@ -47,6 +60,10 @@ export interface ApiContext { apiUid: string endpointDefinitions: Definitions batch(cb: () => void): void + extractRehydrationInfo: ( + action: AnyAction + ) => CombinedState | undefined + hasRehydrationInfo: (action: AnyAction) => boolean } export type Api< diff --git a/packages/toolkit/src/query/baseQueryTypes.ts b/packages/toolkit/src/query/baseQueryTypes.ts index 2b693b4bd..8916d94a2 100644 --- a/packages/toolkit/src/query/baseQueryTypes.ts +++ b/packages/toolkit/src/query/baseQueryTypes.ts @@ -6,6 +6,17 @@ export interface BaseQueryApi { dispatch: ThunkDispatch getState: () => unknown extra: unknown + endpoint: string + type: 'query' | 'mutation' + /** + * Only available for queries: indicates if a query has been forced, + * i.e. it would have been fetched even if there would already be a cache entry + * (this does not mean that there is already a cache entry though!) + * + * This can be used to for example add a `Cache-Control: no-cache` header for + * invalidated queries. + */ + forced?: boolean } export type QueryReturnValue = diff --git a/packages/toolkit/src/query/core/apiState.ts b/packages/toolkit/src/query/core/apiState.ts index 7206b8d96..dad155b3b 100644 --- a/packages/toolkit/src/query/core/apiState.ts +++ b/packages/toolkit/src/query/core/apiState.ts @@ -12,7 +12,15 @@ import type { Id, WithRequiredProp } from '../tsHelpers' export type QueryCacheKey = string & { _type: 'queryCacheKey' } export type QuerySubstateIdentifier = { queryCacheKey: QueryCacheKey } -export type MutationSubstateIdentifier = { requestId: string } +export type MutationSubstateIdentifier = + | { + requestId: string + fixedCacheKey?: string + } + | { + requestId?: string + fixedCacheKey: string + } export type RefetchConfigOptions = { refetchOnMountOrArgChange: boolean | number @@ -175,6 +183,7 @@ export type QuerySubState> = Id< > type BaseMutationSubState> = { + requestId: string data?: ResultTypeFrom error?: | SerializedError @@ -200,6 +209,7 @@ export type MutationSubState> = status: QueryStatus.rejected } & WithRequiredProp, 'error'>) | { + requestId?: undefined status: QueryStatus.uninitialized data?: undefined error?: undefined diff --git a/packages/toolkit/src/query/core/buildInitiate.ts b/packages/toolkit/src/query/core/buildInitiate.ts index 935109847..2ecc8eb37 100644 --- a/packages/toolkit/src/query/core/buildInitiate.ts +++ b/packages/toolkit/src/query/core/buildInitiate.ts @@ -5,24 +5,15 @@ import type { QueryArgFrom, ResultTypeFrom, } from '../endpointDefinitions' -import type { - QueryThunkArg, - MutationThunkArg, - QueryThunk, - MutationThunk, -} from './buildThunks' -import type { - AnyAction, - AsyncThunk, - ThunkAction, - SerializedError, -} from '@reduxjs/toolkit' -import { unwrapResult } from '@reduxjs/toolkit' -import type { QuerySubState, SubscriptionOptions, RootState } from './apiState' +import { DefinitionType } from '../endpointDefinitions' +import type { QueryThunk, MutationThunk } from './buildThunks' +import type { AnyAction, ThunkAction, SerializedError } from '@reduxjs/toolkit' +import type { SubscriptionOptions, RootState } from './apiState' import type { InternalSerializeQueryArgs } from '../defaultSerializeQueryArgs' -import type { Api } from '../apiTypes' +import type { Api, ApiContext } from '../apiTypes' import type { ApiEndpointQuery } from './module' import type { BaseQueryError } from '../baseQueryTypes' +import type { QueryResultSelectorResult } from './buildSelectors' declare module './module' { export interface ApiEndpointQuery< @@ -57,14 +48,16 @@ type StartQueryActionCreator< export type QueryActionCreatorResult< D extends QueryDefinition -> = Promise> & { +> = Promise> & { arg: QueryArgFrom requestId: string subscriptionOptions: SubscriptionOptions | undefined abort(): void + unwrap(): Promise> unsubscribe(): void refetch(): void updateSubscriptionOptions(options: SubscriptionOptions): void + queryCacheKey: string } type StartMutationActionCreator< @@ -79,6 +72,7 @@ type StartMutationActionCreator< * (defaults to `true`) */ track?: boolean + fixedCacheKey?: string } ) => ThunkAction, any, any, AnyAction> @@ -113,11 +107,13 @@ export type MutationActionCreatorResult< * Whether the mutation is being tracked in the store. */ track?: boolean + fixedCacheKey?: string } /** * A unique string generated for the request sequence */ requestId: string + /** * A method to cancel the mutation promise. Note that this is not intended to prevent the mutation * that was fired off from reaching the server, but only to assist in handling the response. @@ -177,6 +173,8 @@ export type MutationActionCreatorResult< * A method to manually unsubscribe from the mutation call, meaning it will be removed from cache after the usual caching grace period. The value returned by the hook will reset to `isUninitialized` afterwards. */ + reset(): void + /** @deprecated has been renamed to `reset` */ unsubscribe(): void } @@ -185,18 +183,58 @@ export function buildInitiate({ queryThunk, mutationThunk, api, + context, }: { serializeQueryArgs: InternalSerializeQueryArgs queryThunk: QueryThunk mutationThunk: MutationThunk api: Api + context: ApiContext }) { + const runningQueries: Record< + string, + QueryActionCreatorResult | undefined + > = {} + const runningMutations: Record< + string, + MutationActionCreatorResult | undefined + > = {} + const { unsubscribeQueryResult, - unsubscribeMutationResult, + removeMutationResult, updateSubscriptionOptions, } = api.internalActions - return { buildInitiateQuery, buildInitiateMutation } + return { + buildInitiateQuery, + buildInitiateMutation, + getRunningOperationPromises, + getRunningOperationPromise, + } + + function getRunningOperationPromise( + endpointName: string, + argOrRequestId: any + ): any { + const endpointDefinition = context.endpointDefinitions[endpointName] + if (endpointDefinition.type === DefinitionType.query) { + const queryCacheKey = serializeQueryArgs({ + queryArgs: argOrRequestId, + endpointDefinition, + endpointName, + }) + return runningQueries[queryCacheKey] + } else { + return runningMutations[argOrRequestId] + } + } + + function getRunningOperationPromises() { + return [ + ...Object.values(runningQueries), + ...Object.values(runningMutations), + ].filter((t: T | undefined): t is T => !!t) + } function middlewareWarning(getState: () => RootState<{}, string, string>) { if (process.env.NODE_ENV !== 'production') { @@ -228,6 +266,7 @@ Features like automatic cache collection, automatic refetching etc. will not be endpointName, }) const thunk = queryThunk({ + type: 'query', subscribe, forceRefetch, subscriptionOptions, @@ -237,9 +276,11 @@ Features like automatic cache collection, automatic refetching etc. will not be }) const thunkResult = dispatch(thunk) middlewareWarning(getState) + const { requestId, abort } = thunkResult - const statePromise = Object.assign( - thunkResult.then(() => + + const statePromise: QueryActionCreatorResult = Object.assign( + Promise.all([runningQueries[queryCacheKey], thunkResult]).then(() => (api.endpoints[endpointName] as ApiEndpointQuery).select( arg )(getState()) @@ -248,7 +289,17 @@ Features like automatic cache collection, automatic refetching etc. will not be arg, requestId, subscriptionOptions, + queryCacheKey, abort, + async unwrap() { + const result = await statePromise + + if (result.isError) { + throw result.error + } + + return result.data + }, refetch() { dispatch( queryAction(arg, { subscribe: false, forceRefetch: true }) @@ -276,38 +327,65 @@ Features like automatic cache collection, automatic refetching etc. will not be }, } ) + + if (!runningQueries[queryCacheKey]) { + runningQueries[queryCacheKey] = statePromise + statePromise.then(() => { + delete runningQueries[queryCacheKey] + }) + } + return statePromise } return queryAction } function buildInitiateMutation( - endpointName: string, - definition: MutationDefinition + endpointName: string ): StartMutationActionCreator { - return (arg, { track = true } = {}) => + return (arg, { track = true, fixedCacheKey } = {}) => (dispatch, getState) => { const thunk = mutationThunk({ + type: 'mutation', endpointName, originalArgs: arg, track, + fixedCacheKey, }) const thunkResult = dispatch(thunk) middlewareWarning(getState) - const { requestId, abort } = thunkResult + const { requestId, abort, unwrap } = thunkResult const returnValuePromise = thunkResult .unwrap() .then((data) => ({ data })) .catch((error) => ({ error })) - return Object.assign(returnValuePromise, { + + const reset = () => { + dispatch(removeMutationResult({ requestId, fixedCacheKey })) + } + + const ret = Object.assign(returnValuePromise, { arg: thunkResult.arg, requestId, abort, - unwrap: thunkResult.unwrap, - unsubscribe() { - if (track) dispatch(unsubscribeMutationResult({ requestId })) - }, + unwrap, + unsubscribe: reset, + reset, }) + + runningMutations[requestId] = ret + ret.then(() => { + delete runningMutations[requestId] + }) + if (fixedCacheKey) { + runningMutations[fixedCacheKey] = ret + ret.then(() => { + if (runningMutations[fixedCacheKey] === ret) + delete runningMutations[fixedCacheKey] + }) + } + + return ret } } } diff --git a/packages/toolkit/src/query/core/buildMiddleware/cacheCollection.ts b/packages/toolkit/src/query/core/buildMiddleware/cacheCollection.ts index a46622ace..128c0ebe1 100644 --- a/packages/toolkit/src/query/core/buildMiddleware/cacheCollection.ts +++ b/packages/toolkit/src/query/core/buildMiddleware/cacheCollection.ts @@ -1,6 +1,6 @@ import type { BaseQueryFn } from '../../baseQueryTypes' import type { QueryDefinition } from '../../endpointDefinitions' -import type { QueryCacheKey } from '../apiState' +import type { ConfigState, QueryCacheKey } from '../apiState' import { QuerySubstateIdentifier } from '../apiState' import type { QueryStateMeta, @@ -42,15 +42,11 @@ export const build: SubMiddlewareBuilder = ({ reducerPath, api, context }) => { const state = mwApi.getState()[reducerPath] const { queryCacheKey } = action.payload - const endpointDefinition = context.endpointDefinitions[ - state.queries[queryCacheKey]?.endpointName! - ] as QueryDefinition - handleUnsubscribe( queryCacheKey, + state.queries[queryCacheKey]?.endpointName, mwApi, - endpointDefinition?.keepUnusedDataFor ?? - state.config.keepUnusedDataFor + state.config ) } @@ -61,14 +57,37 @@ export const build: SubMiddlewareBuilder = ({ reducerPath, api, context }) => { } } + if (context.hasRehydrationInfo(action)) { + const state = mwApi.getState()[reducerPath] + const { queries } = context.extractRehydrationInfo(action)! + for (const [queryCacheKey, queryState] of Object.entries(queries)) { + // Gotcha: + // If rehydrating before the endpoint has been injected,the global `keepUnusedDataFor` + // will be used instead of the endpoint-specific one. + handleUnsubscribe( + queryCacheKey as QueryCacheKey, + queryState?.endpointName, + mwApi, + state.config + ) + } + } + return result } function handleUnsubscribe( queryCacheKey: QueryCacheKey, + endpointName: string | undefined, api: SubMiddlewareApi, - keepUnusedDataFor: number + config: ConfigState ) { + const endpointDefinition = context.endpointDefinitions[ + endpointName! + ] as QueryDefinition + const keepUnusedDataFor = + endpointDefinition?.keepUnusedDataFor ?? config.keepUnusedDataFor + const currentTimeout = currentRemovalTimeouts[queryCacheKey] if (currentTimeout) { clearTimeout(currentTimeout) diff --git a/packages/toolkit/src/query/core/buildMiddleware/cacheLifecycle.ts b/packages/toolkit/src/query/core/buildMiddleware/cacheLifecycle.ts index 52728a2d9..5fa44cac8 100644 --- a/packages/toolkit/src/query/core/buildMiddleware/cacheLifecycle.ts +++ b/packages/toolkit/src/query/core/buildMiddleware/cacheLifecycle.ts @@ -8,6 +8,7 @@ import type { MutationResultSelectorResult, QueryResultSelectorResult, } from '../buildSelectors' +import { getMutationCacheKey } from '../buildSlice' import type { PatchCollection, Recipe } from '../buildThunks' import type { PromiseWithKnownReason, @@ -235,7 +236,7 @@ export const build: SubMiddlewareBuilder = ({ } } else if ( api.internalActions.removeQueryResult.match(action) || - api.internalActions.unsubscribeMutationResult.match(action) + api.internalActions.removeMutationResult.match(action) ) { const lifecycle = lifecycleMap[cacheKey] if (lifecycle) { @@ -257,8 +258,8 @@ export const build: SubMiddlewareBuilder = ({ if (isMutationThunk(action)) return action.meta.requestId if (api.internalActions.removeQueryResult.match(action)) return action.payload.queryCacheKey - if (api.internalActions.unsubscribeMutationResult.match(action)) - return action.payload.requestId + if (api.internalActions.removeMutationResult.match(action)) + return getMutationCacheKey(action.payload) return '' } diff --git a/packages/toolkit/src/query/core/buildMiddleware/index.ts b/packages/toolkit/src/query/core/buildMiddleware/index.ts index 06494fc61..c7a9ed133 100644 --- a/packages/toolkit/src/query/core/buildMiddleware/index.ts +++ b/packages/toolkit/src/query/core/buildMiddleware/index.ts @@ -75,6 +75,7 @@ export function buildMiddleware< override: Partial = {} ) { return queryThunk({ + type: 'query', endpointName: querySubState.endpointName, originalArgs: querySubState.originalArgs, subscribe: false, diff --git a/packages/toolkit/src/query/core/buildMiddleware/invalidationByTags.ts b/packages/toolkit/src/query/core/buildMiddleware/invalidationByTags.ts index 734515648..b84bfb562 100644 --- a/packages/toolkit/src/query/core/buildMiddleware/invalidationByTags.ts +++ b/packages/toolkit/src/query/core/buildMiddleware/invalidationByTags.ts @@ -2,7 +2,6 @@ import { isAnyOf, isFulfilled, isRejectedWithValue } from '@reduxjs/toolkit' import type { FullTagDescription } from '../../endpointDefinitions' import { calculateProvidedBy } from '../../endpointDefinitions' -import { flatten } from '../../utils' import type { QueryCacheKey } from '../apiState' import { QueryStatus } from '../apiState' import { calculateProvidedByThunk } from '../buildThunks' @@ -59,39 +58,27 @@ export const build: SubMiddlewareBuilder = ({ function invalidateTags( tags: readonly FullTagDescription[], - api: SubMiddlewareApi + mwApi: SubMiddlewareApi ) { - const state = api.getState()[reducerPath] + const rootState = mwApi.getState() + const state = rootState[reducerPath] - const toInvalidate = new Set() - for (const tag of tags) { - const provided = state.provided[tag.type] - if (!provided) { - continue - } - - let invalidateSubscriptions = - (tag.id !== undefined - ? // id given: invalidate all queries that provide this type & id - provided[tag.id] - : // no id: invalidate all queries that provide this type - flatten(Object.values(provided))) ?? [] - - for (const invalidate of invalidateSubscriptions) { - toInvalidate.add(invalidate) - } - } + const toInvalidate = api.util.selectInvalidatedBy(rootState, tags) context.batch(() => { const valuesArray = Array.from(toInvalidate.values()) - for (const queryCacheKey of valuesArray) { + for (const { queryCacheKey } of valuesArray) { const querySubState = state.queries[queryCacheKey] const subscriptionSubState = state.subscriptions[queryCacheKey] if (querySubState && subscriptionSubState) { if (Object.keys(subscriptionSubState).length === 0) { - api.dispatch(removeQueryResult({ queryCacheKey })) + mwApi.dispatch( + removeQueryResult({ + queryCacheKey: queryCacheKey as QueryCacheKey, + }) + ) } else if (querySubState.status !== QueryStatus.uninitialized) { - api.dispatch(refetchQuery(querySubState, queryCacheKey)) + mwApi.dispatch(refetchQuery(querySubState, queryCacheKey)) } else { } } diff --git a/packages/toolkit/src/query/core/buildSelectors.ts b/packages/toolkit/src/query/core/buildSelectors.ts index af864ac88..a73e9be54 100644 --- a/packages/toolkit/src/query/core/buildSelectors.ts +++ b/packages/toolkit/src/query/core/buildSelectors.ts @@ -4,6 +4,7 @@ import type { QuerySubState, RootState as _RootState, RequestStatusFlags, + QueryCacheKey, } from './apiState' import { QueryStatus, getRequestStatusFlags } from './apiState' import type { @@ -13,8 +14,12 @@ import type { QueryArgFrom, TagTypesFrom, ReducerPathFrom, + TagDescription, } from '../endpointDefinitions' +import { expandTagDescription } from '../endpointDefinitions' import type { InternalSerializeQueryArgs } from '../defaultSerializeQueryArgs' +import { getMutationCacheKey } from './buildSlice' +import { flatten } from '../utils' export type SkipToken = typeof skipToken /** @@ -88,7 +93,10 @@ type MutationResultSelectorFactory< Definition extends MutationDefinition, RootState > = ( - requestId: string | SkipToken + requestId: + | string + | { requestId: string | undefined; fixedCacheKey: string | undefined } + | SkipToken ) => (state: RootState) => MutationResultSelectorResult export type MutationResultSelectorResult< @@ -121,7 +129,7 @@ export function buildSelectors< }) { type RootState = _RootState - return { buildQuerySelector, buildMutationSelector } + return { buildQuerySelector, buildMutationSelector, selectInvalidatedBy } function withRequestFlags( substate: T @@ -149,8 +157,8 @@ export function buildSelectors< function buildQuerySelector( endpointName: string, endpointDefinition: QueryDefinition - ): QueryResultSelectorFactory { - return (queryArgs) => { + ) { + return ((queryArgs: any) => { const selectQuerySubState = createSelector( selectInternalState, (internalState) => @@ -165,14 +173,17 @@ export function buildSelectors< ]) ?? defaultQuerySubState ) return createSelector(selectQuerySubState, withRequestFlags) - } + }) as QueryResultSelectorFactory } - function buildMutationSelector(): MutationResultSelectorFactory< - any, - RootState - > { - return (mutationId) => { + function buildMutationSelector() { + return ((id) => { + let mutationId: string | typeof skipToken + if (typeof id === 'object') { + mutationId = getMutationCacheKey(id) ?? skipToken + } else { + mutationId = id + } const selectMutationSubstate = createSelector( selectInternalState, (internalState) => @@ -181,6 +192,50 @@ export function buildSelectors< : internalState?.mutations?.[mutationId]) ?? defaultMutationSubState ) return createSelector(selectMutationSubstate, withRequestFlags) + }) as MutationResultSelectorFactory + } + + function selectInvalidatedBy( + state: RootState, + tags: ReadonlyArray> + ): Array<{ + endpointName: string + originalArgs: any + queryCacheKey: QueryCacheKey + }> { + const apiState = state[reducerPath] + const toInvalidate = new Set() + for (const tag of tags.map(expandTagDescription)) { + const provided = apiState.provided[tag.type] + if (!provided) { + continue + } + + let invalidateSubscriptions = + (tag.id !== undefined + ? // id given: invalidate all queries that provide this type & id + provided[tag.id] + : // no id: invalidate all queries that provide this type + flatten(Object.values(provided))) ?? [] + + for (const invalidate of invalidateSubscriptions) { + toInvalidate.add(invalidate) + } } + + return flatten( + Array.from(toInvalidate.values()).map((queryCacheKey) => { + const querySubState = apiState.queries[queryCacheKey] + return querySubState + ? [ + { + queryCacheKey, + endpointName: querySubState.endpointName!, + originalArgs: querySubState.originalArgs, + }, + ] + : [] + }) + ) } } diff --git a/packages/toolkit/src/query/core/buildSlice.ts b/packages/toolkit/src/query/core/buildSlice.ts index e0616521e..3c8fd81fc 100644 --- a/packages/toolkit/src/query/core/buildSlice.ts +++ b/packages/toolkit/src/query/core/buildSlice.ts @@ -1,4 +1,4 @@ -import type { AsyncThunk, PayloadAction } from '@reduxjs/toolkit' +import type { AnyAction, PayloadAction } from '@reduxjs/toolkit' import { combineReducers, createAction, @@ -6,12 +6,6 @@ import { isAnyOf, isFulfilled, isRejectedWithValue, - // Workaround for API-Extractor - AnyAction, - CombinedState, - Reducer, - ActionCreatorWithPayload, - ActionCreatorWithoutPayload, } from '@reduxjs/toolkit' import type { CombinedState as CombinedQueryState, @@ -28,13 +22,7 @@ import type { ConfigState, } from './apiState' import { QueryStatus } from './apiState' -import type { - MutationThunk, - MutationThunkArg, - QueryThunk, - QueryThunkArg, - ThunkResult, -} from './buildThunks' +import type { MutationThunk, QueryThunk } from './buildThunks' import { calculateProvidedByThunk } from './buildThunks' import type { AssertTagTypes, @@ -61,12 +49,33 @@ function updateQuerySubstateIfExists( } } +export function getMutationCacheKey( + id: + | MutationSubstateIdentifier + | { requestId: string; arg: { fixedCacheKey?: string | undefined } } +): string +export function getMutationCacheKey(id: { + fixedCacheKey?: string + requestId?: string +}): string | undefined + +export function getMutationCacheKey( + id: + | { fixedCacheKey?: string; requestId?: string } + | MutationSubstateIdentifier + | { requestId: string; arg: { fixedCacheKey?: string | undefined } } +): string | undefined { + return ('arg' in id ? id.arg.fixedCacheKey : id.fixedCacheKey) ?? id.requestId +} + function updateMutationSubstateIfExists( state: MutationState, - { requestId }: MutationSubstateIdentifier, + id: + | MutationSubstateIdentifier + | { requestId: string; arg: { fixedCacheKey?: string | undefined } }, update: (substate: MutationSubState) => void ) { - const substate = state[requestId] + const substate = state[getMutationCacheKey(id)] if (substate) { update(substate) } @@ -78,7 +87,12 @@ export function buildSlice({ reducerPath, queryThunk, mutationThunk, - context: { endpointDefinitions: definitions, apiUid }, + context: { + endpointDefinitions: definitions, + apiUid, + extractRehydrationInfo, + hasRehydrationInfo, + }, assertTagType, config, }: { @@ -130,7 +144,9 @@ export function buildSlice({ updateQuerySubstateIfExists(draft, arg.queryCacheKey, (substate) => { substate.status = QueryStatus.pending substate.requestId = meta.requestId - substate.originalArgs = arg.originalArgs + if (arg.originalArgs !== undefined) { + substate.originalArgs = arg.originalArgs + } substate.startedTimeStamp = meta.startedTimeStamp }) }) @@ -166,18 +182,31 @@ export function buildSlice({ ) } ) + .addMatcher(hasRehydrationInfo, (draft, action) => { + const { queries } = extractRehydrationInfo(action)! + for (const [key, entry] of Object.entries(queries)) { + if ( + // do not rehydrate entries that were currently in flight. + entry?.status === QueryStatus.fulfilled || + entry?.status === QueryStatus.rejected + ) { + draft[key] = entry + } + } + }) }, }) const mutationSlice = createSlice({ name: `${reducerPath}/mutations`, initialState: initialState as MutationState, reducers: { - unsubscribeMutationResult( + removeMutationResult( draft, - action: PayloadAction + { payload }: PayloadAction ) { - if (action.payload.requestId in draft) { - delete draft[action.payload.requestId] + const cacheKey = getMutationCacheKey(payload) + if (cacheKey in draft) { + delete draft[cacheKey] } }, }, @@ -185,39 +214,51 @@ export function buildSlice({ builder .addCase( mutationThunk.pending, - (draft, { meta: { arg, requestId, startedTimeStamp } }) => { + (draft, { meta, meta: { requestId, arg, startedTimeStamp } }) => { if (!arg.track) return - draft[requestId] = { + draft[getMutationCacheKey(meta)] = { + requestId, status: QueryStatus.pending, endpointName: arg.endpointName, startedTimeStamp, } } ) - .addCase( - mutationThunk.fulfilled, - (draft, { payload, meta, meta: { requestId } }) => { - if (!meta.arg.track) return + .addCase(mutationThunk.fulfilled, (draft, { payload, meta }) => { + if (!meta.arg.track) return - updateMutationSubstateIfExists(draft, { requestId }, (substate) => { - substate.status = QueryStatus.fulfilled - substate.data = payload - substate.fulfilledTimeStamp = meta.fulfilledTimeStamp - }) - } - ) - .addCase( - mutationThunk.rejected, - (draft, { payload, error, meta: { requestId, arg } }) => { - if (!arg.track) return + updateMutationSubstateIfExists(draft, meta, (substate) => { + if (substate.requestId !== meta.requestId) return + substate.status = QueryStatus.fulfilled + substate.data = payload + substate.fulfilledTimeStamp = meta.fulfilledTimeStamp + }) + }) + .addCase(mutationThunk.rejected, (draft, { payload, error, meta }) => { + if (!meta.arg.track) return + + updateMutationSubstateIfExists(draft, meta, (substate) => { + if (substate.requestId !== meta.requestId) return - updateMutationSubstateIfExists(draft, { requestId }, (substate) => { - substate.status = QueryStatus.rejected - substate.error = (payload ?? error) as any - }) + substate.status = QueryStatus.rejected + substate.error = (payload ?? error) as any + }) + }) + .addMatcher(hasRehydrationInfo, (draft, action) => { + const { mutations } = extractRehydrationInfo(action)! + for (const [key, entry] of Object.entries(mutations)) { + if ( + // do not rehydrate entries that were currently in flight. + (entry?.status === QueryStatus.fulfilled || + entry?.status === QueryStatus.rejected) && + // only rehydrate endpoints that were persisted using a `fixedCacheKey` + key !== entry?.requestId + ) { + draft[key] = entry + } } - ) + }) }, }) @@ -242,6 +283,23 @@ export function buildSlice({ } } ) + .addMatcher(hasRehydrationInfo, (draft, action) => { + const { provided } = extractRehydrationInfo(action)! + for (const [type, incomingTags] of Object.entries(provided)) { + for (const [id, cacheKeys] of Object.entries(incomingTags)) { + const subscribedQueries = ((draft[type] ??= {})[ + id || '__internal_without_id' + ] ??= []) + for (const queryCacheKey of cacheKeys) { + const alreadySubscribed = + subscribedQueries.includes(queryCacheKey) + if (!alreadySubscribed) { + subscribedQueries.push(queryCacheKey) + } + } + } + } + }) .addMatcher( isAnyOf(isFulfilled(queryThunk), isRejectedWithValue(queryThunk)), (draft, action) => { @@ -317,14 +375,17 @@ export function buildSlice({ .addCase( queryThunk.rejected, (draft, { meta: { condition, arg, requestId }, error, payload }) => { - const substate = draft[arg.queryCacheKey] // request was aborted due to condition (another query already running) - if (condition && arg.subscribe && substate) { + if (condition && arg.subscribe) { + const substate = (draft[arg.queryCacheKey] ??= {}) substate[requestId] = arg.subscriptionOptions ?? substate[requestId] ?? {} } } ) + // update the state to be a new object to be picked up as a "state change" + // by redux-persist's `autoMergeLevel2` + .addMatcher(hasRehydrationInfo, (draft) => ({ ...draft })) }, }) @@ -358,6 +419,9 @@ export function buildSlice({ .addCase(onFocusLost, (state) => { state.focused = false }) + // update the state to be a new object to be picked up as a "state change" + // by redux-persist's `autoMergeLevel2` + .addMatcher(hasRehydrationInfo, (draft) => ({ ...draft })) }, }) @@ -379,6 +443,8 @@ export function buildSlice({ ...querySlice.actions, ...subscriptionSlice.actions, ...mutationSlice.actions, + /** @deprecated has been renamed to `removeMutationResult` */ + unsubscribeMutationResult: mutationSlice.actions.removeMutationResult, resetApiState, } diff --git a/packages/toolkit/src/query/core/buildThunks.ts b/packages/toolkit/src/query/core/buildThunks.ts index 04254df8b..8c3ccbd1b 100644 --- a/packages/toolkit/src/query/core/buildThunks.ts +++ b/packages/toolkit/src/query/core/buildThunks.ts @@ -103,14 +103,17 @@ export interface Matchers< export interface QueryThunkArg extends QuerySubstateIdentifier, StartQueryActionCreatorOptions { + type: 'query' originalArgs: unknown endpointName: string } export interface MutationThunkArg { + type: 'mutation' originalArgs: unknown endpointName: string track?: boolean + fixedCacheKey?: string } export type ThunkResult = unknown @@ -264,14 +267,21 @@ export function buildThunks< const endpointDefinition = endpointDefinitions[arg.endpointName] try { - let transformResponse: (baseQueryReturnValue: any, meta: any) => any = - defaultTransformResponse + let transformResponse: ( + baseQueryReturnValue: any, + meta: any, + arg: any + ) => any = defaultTransformResponse let result: QueryReturnValue const baseQueryApi = { signal, dispatch, getState, extra, + endpoint: arg.endpointName, + type: arg.type, + forced: + arg.type === 'query' ? isForcedQuery(arg, getState()) : undefined, } if (endpointDefinition.query) { result = await baseQuery( @@ -292,10 +302,43 @@ export function buildThunks< baseQuery(arg, baseQueryApi, endpointDefinition.extraOptions as any) ) } + if ( + typeof process !== 'undefined' && + process.env.NODE_ENV === 'development' + ) { + const what = endpointDefinition.query ? '`baseQuery`' : '`queryFn`' + let err: undefined | string + if (!result) { + err = `${what} did not return anything.` + } else if (typeof result !== 'object') { + err = `${what} did not return an object.` + } else if (result.error && result.data) { + err = `${what} returned an object containing both \`error\` and \`result\`.` + } else if (result.error === undefined && result.data === undefined) { + err = `${what} returned an object containing neither a valid \`error\` and \`result\`. At least one of them should not be \`undefined\`` + } else { + for (const key of Object.keys(result)) { + if (key !== 'error' && key !== 'data' && key !== 'meta') { + err = `The object returned by ${what} has the unknown property ${key}.` + break + } + } + } + if (err) { + console.error( + `Error encountered handling the endpoint ${arg.endpointName}. + ${err} + It needs to return an object with either the shape \`{ data: }\` or \`{ error: }\` that may contain an optional \`meta\` property. + Object returned was:`, + result + ) + } + } + if (result.error) throw new HandledError(result.error, result.meta) return fulfillWithValue( - await transformResponse(result.data, result.meta), + await transformResponse(result.data, result.meta, arg.originalArgs), { fulfilledTimeStamp: Date.now(), baseQueryMeta: result.meta, @@ -321,6 +364,28 @@ In the case of an unhandled error, no tags will be "provided" or "invalidated".` } } + function isForcedQuery( + arg: QueryThunkArg, + state: RootState + ) { + const requestState = state[reducerPath]?.queries?.[arg.queryCacheKey] + const baseFetchOnMountOrArgChange = + state[reducerPath]?.config.refetchOnMountOrArgChange + + const fulfilledVal = requestState?.fulfilledTimeStamp + const refetchVal = + arg.forceRefetch ?? (arg.subscribe && baseFetchOnMountOrArgChange) + + if (refetchVal) { + // Return if its true or compare the dates because it must be a number + return ( + refetchVal === true || + (Number(new Date()) - Number(fulfilledVal)) / 1000 >= refetchVal + ) + } + return false + } + const queryThunk = createAsyncThunk< ThunkResult, QueryThunkArg, @@ -330,29 +395,20 @@ In the case of an unhandled error, no tags will be "provided" or "invalidated".` return { startedTimeStamp: Date.now() } }, condition(arg, { getState }) { - const state = getState()[reducerPath] - const requestState = state?.queries?.[arg.queryCacheKey] - const baseFetchOnMountOrArgChange = state.config.refetchOnMountOrArgChange - + const state = getState() + const requestState = state[reducerPath]?.queries?.[arg.queryCacheKey] const fulfilledVal = requestState?.fulfilledTimeStamp - const refetchVal = - arg.forceRefetch ?? (arg.subscribe && baseFetchOnMountOrArgChange) // Don't retry a request that's currently in-flight if (requestState?.status === 'pending') return false + // if this is forced, continue + if (isForcedQuery(arg, state)) return true + // Pull from the cache unless we explicitly force refetch or qualify based on time - if (fulfilledVal) { - if (refetchVal) { - // Return if its true or compare the dates because it must be a number - return ( - refetchVal === true || - (Number(new Date()) - Number(fulfilledVal)) / 1000 >= refetchVal - ) - } + if (fulfilledVal) // Value is cached and we didn't specify to refresh, skip it. return false - } return true }, diff --git a/packages/toolkit/src/query/core/module.ts b/packages/toolkit/src/query/core/module.ts index d6900a2d6..db7abfb06 100644 --- a/packages/toolkit/src/query/core/module.ts +++ b/packages/toolkit/src/query/core/module.ts @@ -17,15 +17,24 @@ import type { QueryDefinition, MutationDefinition, AssertTagTypes, - FullTagDescription, + TagDescription, } from '../endpointDefinitions' import { isQueryDefinition, isMutationDefinition } from '../endpointDefinitions' -import type { CombinedState, QueryKeys, RootState } from './apiState' +import type { + CombinedState, + QueryKeys, + MutationKeys, + RootState, +} from './apiState' import type { Api, Module } from '../apiTypes' import { onFocus, onFocusLost, onOnline, onOffline } from './setupListeners' import { buildSlice } from './buildSlice' import { buildMiddleware } from './buildMiddleware' import { buildSelectors } from './buildSelectors' +import type { + MutationActionCreatorResult, + QueryActionCreatorResult, +} from './buildInitiate' import { buildInitiate } from './buildInitiate' import { assertCast, safeAssign } from '../tsHelpers' import type { InternalSerializeQueryArgs } from '../defaultSerializeQueryArgs' @@ -124,6 +133,29 @@ declare module '../apiTypes' { * A collection of utility thunks for various situations. */ util: { + /** + * Returns all promises for running queries and mutations. + * Useful for SSR scenarios to await everything triggered in any way, + * including via hook calls, or manually dispatching `initiate` actions. + */ + getRunningOperationPromises: () => Array> + /** + * If a promise is running for a given endpoint name + argument combination, + * returns that promise. Otherwise, returns `undefined`. + * Can be used to await a specific query/mutation triggered in any way, + * including via hook calls, or manually dispatching `initiate` actions. + */ + getRunningOperationPromise>( + endpointName: EndpointName, + args: QueryArgFrom + ): QueryActionCreatorResult | undefined + getRunningOperationPromise< + EndpointName extends MutationKeys + >( + endpointName: EndpointName, + fixedCacheKeyOrRequestId: string + ): MutationActionCreatorResult | undefined + /** * A Redux thunk that can be used to manually trigger pre-fetching of data. * @@ -242,9 +274,18 @@ declare module '../apiTypes' { * ``` */ invalidateTags: ActionCreatorWithPayload< - Array>, + Array>, string > + + selectInvalidatedBy: ( + state: RootState, + tags: ReadonlyArray> + ) => Array<{ + endpointName: string + originalArgs: any + queryCacheKey: string + }> } /** * Endpoints based on the input endpoints provided to `createApi`, containing `select` and `action matchers`. @@ -431,16 +472,30 @@ export const coreModule = (): Module => ({ safeAssign(api, { reducer: reducer as any, middleware }) - const { buildQuerySelector, buildMutationSelector } = buildSelectors({ - serializeQueryArgs: serializeQueryArgs as any, - reducerPath, - }) + const { buildQuerySelector, buildMutationSelector, selectInvalidatedBy } = + buildSelectors({ + serializeQueryArgs: serializeQueryArgs as any, + reducerPath, + }) + + safeAssign(api.util, { selectInvalidatedBy }) - const { buildInitiateQuery, buildInitiateMutation } = buildInitiate({ + const { + buildInitiateQuery, + buildInitiateMutation, + getRunningOperationPromises, + getRunningOperationPromise, + } = buildInitiate({ queryThunk, mutationThunk, api, serializeQueryArgs: serializeQueryArgs as any, + context, + }) + + safeAssign(api.util, { + getRunningOperationPromises, + getRunningOperationPromise, }) return { @@ -468,7 +523,7 @@ export const coreModule = (): Module => ({ anyApi.endpoints[endpointName], { select: buildMutationSelector(), - initiate: buildInitiateMutation(endpointName, definition), + initiate: buildInitiateMutation(endpointName), }, buildMatchThunkActions(mutationThunk, endpointName) ) diff --git a/packages/toolkit/src/query/createApi.ts b/packages/toolkit/src/query/createApi.ts index cdf4d5420..d846cd1c2 100644 --- a/packages/toolkit/src/query/createApi.ts +++ b/packages/toolkit/src/query/createApi.ts @@ -1,4 +1,5 @@ import type { Api, ApiContext, Module, ModuleName } from './apiTypes' +import type { CombinedState } from './core/apiState' import type { BaseQueryArg, BaseQueryFn } from './baseQueryTypes' import type { SerializeQueryArgs } from './defaultSerializeQueryArgs' import { defaultSerializeQueryArgs } from './defaultSerializeQueryArgs' @@ -8,6 +9,9 @@ import type { } from './endpointDefinitions' import { DefinitionType } from './endpointDefinitions' import { nanoid } from '@reduxjs/toolkit' +import type { AnyAction } from '@reduxjs/toolkit' +import type { NoInfer } from './tsHelpers' +import { defaultMemoize } from 'reselect' export interface CreateApiOptions< BaseQuery extends BaseQueryFn, @@ -147,6 +151,46 @@ export interface CreateApiOptions< * Note: requires [`setupListeners`](./setupListeners) to have been called. */ refetchOnReconnect?: boolean + /** + * A function that is passed every dispatched action. If this returns something other than `undefined`, + * that return value will be used to rehydrate fulfilled & errored queries. + * + * @example + * + * ```ts + * // codeblock-meta title="next-redux-wrapper rehydration example" + * import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react' + * import { HYDRATE } from 'next-redux-wrapper' + * + * export const api = createApi({ + * baseQuery: fetchBaseQuery({ baseUrl: '/' }), + * // highlight-start + * extractRehydrationInfo(action, { reducerPath }) { + * if (action.type === HYDRATE) { + * return action.payload[reducerPath] + * } + * }, + * // highlight-end + * endpoints: (build) => ({ + * // omitted + * }), + * }) + * ``` + */ + extractRehydrationInfo?: ( + action: AnyAction, + { + reducerPath, + }: { + reducerPath: ReducerPath + } + ) => + | undefined + | CombinedState< + NoInfer, + NoInfer, + NoInfer + > } export type CreateApi = { @@ -186,6 +230,11 @@ export function buildCreateApi, ...Module[]]>( ...modules: Modules ): CreateApi { return function baseCreateApi(options) { + const extractRehydrationInfo = defaultMemoize((action: AnyAction) => + options.extractRehydrationInfo?.(action, { + reducerPath: (options.reducerPath ?? 'api') as any, + }) + ) const optionsWithDefaults = { reducerPath: 'api', serializeQueryArgs: defaultSerializeQueryArgs, @@ -194,6 +243,7 @@ export function buildCreateApi, ...Module[]]>( refetchOnFocus: false, refetchOnReconnect: false, ...options, + extractRehydrationInfo, tagTypes: [...(options.tagTypes || [])], } @@ -204,6 +254,10 @@ export function buildCreateApi, ...Module[]]>( fn() }, apiUid: nanoid(), + extractRehydrationInfo, + hasRehydrationInfo: defaultMemoize( + (action) => extractRehydrationInfo(action) != null + ), } const api = { diff --git a/packages/toolkit/src/query/endpointDefinitions.ts b/packages/toolkit/src/query/endpointDefinitions.ts index 7a6d306e2..dfaa2a081 100644 --- a/packages/toolkit/src/query/endpointDefinitions.ts +++ b/packages/toolkit/src/query/endpointDefinitions.ts @@ -60,7 +60,8 @@ interface EndpointDefinitionWithQuery< */ transformResponse?( baseQueryReturnValue: BaseQueryResult, - meta: BaseQueryMeta + meta: BaseQueryMeta, + arg: QueryArg ): ResultType | Promise } @@ -452,7 +453,7 @@ function isFunction(t: T): t is Extract { return typeof t === 'function' } -function expandTagDescription( +export function expandTagDescription( description: TagDescription ): FullTagDescription { return typeof description === 'string' ? { type: description } : description @@ -463,8 +464,9 @@ export type QueryArgFrom> = export type ResultTypeFrom> = D extends BaseEndpointDefinition ? RT : unknown -export type ReducerPathFrom> = - D extends EndpointDefinition ? RP : unknown +export type ReducerPathFrom< + D extends EndpointDefinition +> = D extends EndpointDefinition ? RP : unknown export type TagTypesFrom> = D extends EndpointDefinition ? RP : unknown diff --git a/packages/toolkit/src/query/fetchBaseQuery.ts b/packages/toolkit/src/query/fetchBaseQuery.ts index 81e33fce1..bd606b7cf 100644 --- a/packages/toolkit/src/query/fetchBaseQuery.ts +++ b/packages/toolkit/src/query/fetchBaseQuery.ts @@ -1,6 +1,6 @@ import { joinUrls } from './utils' import { isPlainObject } from '@reduxjs/toolkit' -import type { BaseQueryFn } from './baseQueryTypes' +import type { BaseQueryApi, BaseQueryFn } from './baseQueryTypes' import type { MaybePromise, Override } from './tsHelpers' export type ResponseHandler = @@ -54,7 +54,7 @@ const handleResponse = async ( if (responseHandler === 'json') { const text = await response.text() - return text.length ? JSON.parse(text) : undefined + return text.length ? JSON.parse(text) : null } } @@ -113,12 +113,13 @@ export type FetchBaseQueryArgs = { baseUrl?: string prepareHeaders?: ( headers: Headers, - api: { getState: () => unknown } + api: Pick ) => MaybePromise fetchFn?: ( input: RequestInfo, init?: RequestInit | undefined ) => Promise + paramsSerializer?: (params: Record) => string } & RequestInit export type FetchBaseQueryMeta = { request: Request; response?: Response } @@ -156,11 +157,14 @@ export type FetchBaseQueryMeta = { request: Request; response?: Response } * Accepts a custom `fetch` function if you do not want to use the default on the window. * Useful in SSR environments if you need to use a library such as `isomorphic-fetch` or `cross-fetch` * + * @param {(params: Record => string} paramsSerializer + * An optional function that can be used to stringify querystring parameters. */ export function fetchBaseQuery({ baseUrl, prepareHeaders = (x) => x, fetchFn = defaultFetchFn, + paramsSerializer, ...baseFetchOptions }: FetchBaseQueryArgs = {}): BaseQueryFn< string | FetchArgs, @@ -174,7 +178,7 @@ export function fetchBaseQuery({ 'Warning: `fetch` is not available. Please supply a custom `fetchFn` property to use `fetchBaseQuery` on SSR environments.' ) } - return async (arg, { signal, getState }) => { + return async (arg, { signal, getState, endpoint, forced, type }) => { let meta: FetchBaseQueryMeta | undefined let { url, @@ -196,7 +200,7 @@ export function fetchBaseQuery({ config.headers = await prepareHeaders( new Headers(stripUndefined(headers)), - { getState } + { getState, endpoint, forced, type } ) // Only set the content-type to json if appropriate. Will not be true for FormData, ArrayBuffer, Blob, etc. @@ -216,7 +220,9 @@ export function fetchBaseQuery({ if (params) { const divider = ~url.indexOf('?') ? '&' : '?' - const query = new URLSearchParams(stripUndefined(params)) + const query = paramsSerializer + ? paramsSerializer(params) + : new URLSearchParams(stripUndefined(params)) url += divider + query } diff --git a/packages/toolkit/src/query/react/buildHooks.ts b/packages/toolkit/src/query/react/buildHooks.ts index 4a33e9476..b3a852e69 100644 --- a/packages/toolkit/src/query/react/buildHooks.ts +++ b/packages/toolkit/src/query/react/buildHooks.ts @@ -1,5 +1,7 @@ import type { AnyAction, ThunkAction, ThunkDispatch } from '@reduxjs/toolkit' import { createSelector } from '@reduxjs/toolkit' +import type { Selector } from '@reduxjs/toolkit' +import type { DependencyList } from 'react' import { useCallback, useEffect, @@ -31,8 +33,9 @@ import type { QueryActionCreatorResult, MutationActionCreatorResult, } from '@reduxjs/toolkit/dist/query/core/buildInitiate' +import type { SerializeQueryArgs } from '@reduxjs/toolkit/dist/query/defaultSerializeQueryArgs' import { shallowEqual } from 'react-redux' -import type { Api } from '@reduxjs/toolkit/dist/query/apiTypes' +import type { Api, ApiContext } from '@reduxjs/toolkit/dist/query/apiTypes' import type { Id, NoInfer, @@ -45,9 +48,10 @@ import type { PrefetchOptions, } from '@reduxjs/toolkit/dist/query/core/module' import type { ReactHooksModuleOptions } from './module' -import { useShallowStableValue } from './useShallowStableValue' +import { useStableQueryArgs } from './useSerializedStableValue' import type { UninitializedValue } from './constants' import { UNINITIALIZED_VALUE } from './constants' +import { useShallowStableValue } from './useShallowStableValue' // Copy-pasted from React-Redux export const useIsomorphicLayoutEffect = @@ -199,8 +203,25 @@ export type LazyQueryTrigger> = { * * By default, this will start a new request even if there is already a value in the cache. * If you want to use the cache value and only start a request if there is no cache value, set the second argument to `true`. + * + * @remarks + * If you need to access the error or success payload immediately after a lazy query, you can chain .unwrap(). + * + * @example + * ```ts + * // codeblock-meta title="Using .unwrap with async await" + * try { + * const payload = await getUserById(1).unwrap(); + * console.log('fulfilled', payload) + * } catch (error) { + * console.error('rejected', error); + * } + * ``` */ - (arg: QueryArgFrom, preferCacheValue?: boolean): void + ( + arg: QueryArgFrom, + preferCacheValue?: boolean + ): QueryActionCreatorResult } /** @@ -218,7 +239,7 @@ export type UseLazyQuerySubscription< D extends QueryDefinition > = ( options?: SubscriptionOptions -) => [(arg: QueryArgFrom) => void, QueryArgFrom | UninitializedValue] +) => readonly [LazyQueryTrigger, QueryArgFrom | UninitializedValue] export type QueryStateSelector< R extends Record, @@ -319,6 +340,12 @@ export type UseQueryStateResult< type UseQueryStateBaseResult> = QuerySubState & { + /** + * Where `data` tries to hold data as much as possible, also re-using + * data from the last arguments passed into the hook, this property + * will always contain the received data from the query, for the current query arguments. + */ + currentData?: ResultTypeFrom /** * Query has not started yet. */ @@ -355,11 +382,21 @@ type UseQueryStateDefaultResult> = | { isLoading: true; isFetching: boolean; data: undefined } | ({ isSuccess: true - isFetching: boolean + isFetching: true error: undefined } & Required< Pick, 'data' | 'fulfilledTimeStamp'> >) + | ({ + isSuccess: true + isFetching: false + error: undefined + } & Required< + Pick< + UseQueryStateBaseResult, + 'data' | 'fulfilledTimeStamp' | 'currentData' + > + >) | ({ isError: true } & Required< Pick, 'error'> >) @@ -383,6 +420,7 @@ export type UseMutationStateOptions< R extends Record > = { selectFromResult?: MutationStateSelector + fixedCacheKey?: string } export type UseMutationStateResult< @@ -390,6 +428,11 @@ export type UseMutationStateResult< R > = NoInfer & { originalArgs?: QueryArgFrom + /** + * Resets the hook state to it's initial `uninitialized` state. + * This will also remove the last result from the cache. + */ + reset: () => void } /** @@ -406,40 +449,32 @@ export type UseMutation> = < R extends Record = MutationResultSelectorResult >( options?: UseMutationStateOptions -) => [ - (arg: QueryArgFrom) => MutationActionCreatorResult, - UseMutationStateResult -] +) => readonly [MutationTrigger, UseMutationStateResult] + +export type MutationTrigger> = + { + /** + * Triggers the mutation and returns a Promise. + * @remarks + * If you need to access the error or success payload immediately after a mutation, you can chain .unwrap(). + * + * @example + * ```ts + * // codeblock-meta title="Using .unwrap with async await" + * try { + * const payload = await addPost({ id: 1, name: 'Example' }).unwrap(); + * console.log('fulfilled', payload) + * } catch (error) { + * console.error('rejected', error); + * } + * ``` + */ + (arg: QueryArgFrom): MutationActionCreatorResult + } const defaultQueryStateSelector: QueryStateSelector = (x) => x const defaultMutationStateSelector: MutationStateSelector = (x) => x -const queryStatePreSelector = ( - currentState: QueryResultSelectorResult, - lastResult: UseQueryStateDefaultResult -): UseQueryStateDefaultResult => { - // data is the last known good request result we have tracked - or if none has been tracked yet the last good result for the current args - let data = currentState.isSuccess ? currentState.data : lastResult?.data - if (data === undefined) data = currentState.data - - const hasData = data !== undefined - - // isFetching = true any time a request is in flight - const isFetching = currentState.isLoading - // isLoading = true only when loading while no data is present yet (initial load with no data in the cache) - const isLoading = !hasData && isFetching - // isSuccess = true when data is present - const isSuccess = currentState.isSuccess || (isFetching && hasData) - - return { - ...currentState, - data, - isFetching, - isLoading, - isSuccess, - } as UseQueryStateDefaultResult -} - /** * Wrapper around `defaultQueryStateSelector` to be used in `useQuery`. * We want the initial render to already come back with @@ -477,13 +512,77 @@ type GenericPrefetchThunk = ( */ export function buildHooks({ api, - moduleOptions: { batch, useDispatch, useSelector, useStore }, + moduleOptions: { + batch, + useDispatch, + useSelector, + useStore, + unstable__sideEffectsInRender, + }, + serializeQueryArgs, + context, }: { api: Api moduleOptions: Required + serializeQueryArgs: SerializeQueryArgs + context: ApiContext }) { + const usePossiblyImmediateEffect: ( + effect: () => void | undefined, + deps?: DependencyList + ) => void = unstable__sideEffectsInRender ? (cb) => cb() : useEffect + return { buildQueryHooks, buildMutationHook, usePrefetch } + function queryStatePreSelector( + currentState: QueryResultSelectorResult, + lastResult: UseQueryStateDefaultResult | undefined, + queryArgs: any + ): UseQueryStateDefaultResult { + // if we had a last result and the current result is uninitialized, + // we might have called `api.util.resetApiState` + // in this case, reset the hook + if (lastResult?.endpointName && currentState.isUninitialized) { + const { endpointName } = lastResult + const endpointDefinition = context.endpointDefinitions[endpointName] + if ( + serializeQueryArgs({ + queryArgs: lastResult.originalArgs, + endpointDefinition, + endpointName, + }) === + serializeQueryArgs({ + queryArgs, + endpointDefinition, + endpointName, + }) + ) + lastResult = undefined + } + + // data is the last known good request result we have tracked - or if none has been tracked yet the last good result for the current args + let data = currentState.isSuccess ? currentState.data : lastResult?.data + if (data === undefined) data = currentState.data + + const hasData = data !== undefined + + // isFetching = true any time a request is in flight + const isFetching = currentState.isLoading + // isLoading = true only when loading while no data is present yet (initial load with no data in the cache) + const isLoading = !hasData && isFetching + // isSuccess = true when data is present + const isSuccess = currentState.isSuccess || (isFetching && hasData) + + return { + ...currentState, + data, + currentData: currentState.data, + isFetching, + isLoading, + isSuccess, + } as UseQueryStateDefaultResult + } + function usePrefetch>( endpointName: EndpointName, defaultOptions?: PrefetchOptions @@ -519,7 +618,12 @@ export function buildHooks({ Definitions > const dispatch = useDispatch>() - const stableArg = useShallowStableValue(skip ? skipToken : arg) + const stableArg = useStableQueryArgs( + skip ? skipToken : arg, + serializeQueryArgs, + context.endpointDefinitions[name], + name + ) const stableSubscriptionOptions = useShallowStableValue({ refetchOnReconnect, refetchOnFocus, @@ -528,8 +632,27 @@ export function buildHooks({ const promiseRef = useRef>() - useEffect(() => { + let { queryCacheKey, requestId } = promiseRef.current || {} + const subscriptionRemoved = useSelector( + (state: RootState) => + !!queryCacheKey && + !!requestId && + !state[api.reducerPath].subscriptions[queryCacheKey]?.[requestId] + ) + + usePossiblyImmediateEffect((): void | undefined => { + promiseRef.current = undefined + }, [subscriptionRemoved]) + + usePossiblyImmediateEffect((): void | undefined => { const lastPromise = promiseRef.current + if ( + typeof process !== 'undefined' && + process.env.NODE_ENV === 'removeMeOnCompilation' + ) { + // this is only present to enforce the rule of hooks to keep `isSubscribed` in the dependency array + console.log(subscriptionRemoved) + } if (stableArg === skipToken) { lastPromise?.unsubscribe() @@ -557,6 +680,7 @@ export function buildHooks({ refetchOnMountOrArgChange, stableArg, stableSubscriptionOptions, + subscriptionRemoved, ]) useEffect(() => { @@ -597,7 +721,7 @@ export function buildHooks({ pollingInterval, }) - useEffect(() => { + usePossiblyImmediateEffect(() => { const lastSubscriptionOptions = promiseRef.current?.subscriptionOptions if (stableSubscriptionOptions !== lastSubscriptionOptions) { @@ -608,23 +732,28 @@ export function buildHooks({ }, [stableSubscriptionOptions]) const subscriptionOptionsRef = useRef(stableSubscriptionOptions) - useEffect(() => { + usePossiblyImmediateEffect(() => { subscriptionOptionsRef.current = stableSubscriptionOptions }, [stableSubscriptionOptions]) const trigger = useCallback( function (arg: any, preferCacheValue = false) { + let promise: QueryActionCreatorResult + batch(() => { promiseRef.current?.unsubscribe() - promiseRef.current = dispatch( + promiseRef.current = promise = dispatch( initiate(arg, { subscriptionOptions: subscriptionOptionsRef.current, forceRefetch: !preferCacheValue, }) ) + setArg(arg) }) + + return promise! }, [dispatch, initiate] ) @@ -643,7 +772,7 @@ export function buildHooks({ } }, [arg, trigger]) - return useMemo(() => [trigger, arg], [trigger, arg]) + return useMemo(() => [trigger, arg] as const, [trigger, arg]) } const useQueryState: UseQueryState = ( @@ -654,20 +783,31 @@ export function buildHooks({ QueryDefinition, Definitions > - const stableArg = useShallowStableValue(skip ? skipToken : arg) + const stableArg = useStableQueryArgs( + skip ? skipToken : arg, + serializeQueryArgs, + context.endpointDefinitions[name], + name + ) + + type ApiRootState = Parameters>[0] const lastValue = useRef() - const selectDefaultResult = useMemo( + const selectDefaultResult: Selector = useMemo( () => createSelector( - [select(stableArg), (_: any, lastResult: any) => lastResult], + [ + select(stableArg), + (_: ApiRootState, lastResult: any) => lastResult, + (_: ApiRootState) => stableArg, + ], queryStatePreSelector ), [select, stableArg] ) - const querySelector = useMemo( + const querySelector: Selector = useMemo( () => createSelector([selectDefaultResult], selectFromResult), [selectDefaultResult, selectFromResult] ) @@ -725,57 +865,70 @@ export function buildHooks({ } function buildMutationHook(name: string): UseMutation { - return ({ selectFromResult = defaultMutationStateSelector } = {}) => { + return ({ + selectFromResult = defaultMutationStateSelector, + fixedCacheKey, + } = {}) => { const { select, initiate } = api.endpoints[name] as ApiEndpointMutation< MutationDefinition, Definitions > const dispatch = useDispatch>() - const [requestId, setRequestId] = useState() + const [promise, setPromise] = useState>() - const promiseRef = useRef>() - - useEffect(() => { - return () => { - promiseRef.current?.unsubscribe() - promiseRef.current = undefined - } - }, []) + useEffect( + () => () => { + if (!promise?.arg.fixedCacheKey) { + promise?.reset() + } + }, + [promise] + ) const triggerMutation = useCallback( function (arg) { - let promise: MutationActionCreatorResult - batch(() => { - promiseRef?.current?.unsubscribe() - promise = dispatch(initiate(arg)) - promiseRef.current = promise - setRequestId(promise.requestId) - }) - return promise! + const promise = dispatch(initiate(arg, { fixedCacheKey })) + setPromise(promise) + return promise }, - [dispatch, initiate] + [dispatch, initiate, fixedCacheKey] ) + const { requestId } = promise || {} const mutationSelector = useMemo( () => - createSelector([select(requestId || skipToken)], (subState) => - selectFromResult(subState) + createSelector( + [select({ fixedCacheKey, requestId: promise?.requestId })], + selectFromResult ), - [select, requestId, selectFromResult] + [select, promise, selectFromResult, fixedCacheKey] ) const currentState = useSelector(mutationSelector, shallowEqual) - const originalArgs = promiseRef.current?.arg.originalArgs + const originalArgs = + fixedCacheKey == null ? promise?.arg.originalArgs : undefined + const reset = useCallback(() => { + batch(() => { + if (promise) { + setPromise(undefined) + } + if (fixedCacheKey) { + dispatch( + api.internalActions.removeMutationResult({ + requestId, + fixedCacheKey, + }) + ) + } + }) + }, [dispatch, fixedCacheKey, promise, requestId]) const finalState = useMemo( - () => ({ - ...currentState, - originalArgs, - }), - [currentState, originalArgs] + () => ({ ...currentState, originalArgs, reset }), + [currentState, originalArgs, reset] ) return useMemo( - () => [triggerMutation, finalState], + () => [triggerMutation, finalState] as const, [triggerMutation, finalState] ) } diff --git a/packages/toolkit/src/query/react/module.ts b/packages/toolkit/src/query/react/module.ts index 89459f4f3..538ecdbd9 100644 --- a/packages/toolkit/src/query/react/module.ts +++ b/packages/toolkit/src/query/react/module.ts @@ -86,6 +86,25 @@ export interface ReactHooksModuleOptions { * The version of the `useStore` hook to be used */ useStore?: RR['useStore'] + /** + * Enables performing asynchronous tasks immediately within a render. + * + * @example + * + * ```ts + * import { + * buildCreateApi, + * coreModule, + * reactHooksModule + * } from '@reduxjs/toolkit/query/react' + * + * const createApi = buildCreateApi( + * coreModule(), + * reactHooksModule({ unstable__sideEffectsInRender: true }) + * ) + * ``` + */ + unstable__sideEffectsInRender?: boolean } /** @@ -107,9 +126,10 @@ export const reactHooksModule = ({ useDispatch = rrUseDispatch, useSelector = rrUseSelector, useStore = rrUseStore, + unstable__sideEffectsInRender = false, }: ReactHooksModuleOptions = {}): Module => ({ name: reactHooksModuleName, - init(api, options, context) { + init(api, { serializeQueryArgs }, context) { const anyApi = api as any as Api< any, Record, @@ -119,7 +139,15 @@ export const reactHooksModule = ({ > const { buildQueryHooks, buildMutationHook, usePrefetch } = buildHooks({ api, - moduleOptions: { batch, useDispatch, useSelector, useStore }, + moduleOptions: { + batch, + useDispatch, + useSelector, + useStore, + unstable__sideEffectsInRender, + }, + serializeQueryArgs, + context, }) safeAssign(anyApi, { usePrefetch }) safeAssign(context, { batch }) diff --git a/packages/toolkit/src/query/react/useSerializedStableValue.ts b/packages/toolkit/src/query/react/useSerializedStableValue.ts new file mode 100644 index 000000000..163f63eec --- /dev/null +++ b/packages/toolkit/src/query/react/useSerializedStableValue.ts @@ -0,0 +1,31 @@ +import { useEffect, useRef, useMemo } from 'react' +import type { SerializeQueryArgs } from '@reduxjs/toolkit/dist/query/defaultSerializeQueryArgs' +import type { EndpointDefinition } from '@reduxjs/toolkit/dist/query/endpointDefinitions' + +export function useStableQueryArgs( + queryArgs: T, + serialize: SerializeQueryArgs, + endpointDefinition: EndpointDefinition, + endpointName: string +) { + const incoming = useMemo( + () => ({ + queryArgs, + serialized: + typeof queryArgs == 'object' + ? serialize({ queryArgs, endpointDefinition, endpointName }) + : queryArgs, + }), + [queryArgs, serialize, endpointDefinition, endpointName] + ) + const cache = useRef(incoming) + useEffect(() => { + if (cache.current.serialized !== incoming.serialized) { + cache.current = incoming + } + }, [incoming]) + + return cache.current.serialized === incoming.serialized + ? cache.current.queryArgs + : queryArgs +} diff --git a/packages/toolkit/src/query/retry.ts b/packages/toolkit/src/query/retry.ts index 5fc3bf58c..9fa9c6f69 100644 --- a/packages/toolkit/src/query/retry.ts +++ b/packages/toolkit/src/query/retry.ts @@ -23,7 +23,7 @@ async function defaultBackoff(attempt: number = 0, maxRetries: number = 5) { ) } -interface StaggerOptions { +export interface RetryOptions { /** * How many times the query will be retried (default: 5) */ @@ -42,8 +42,8 @@ function fail(e: any): never { const retryWithBackoff: BaseQueryEnhancer< unknown, - StaggerOptions, - StaggerOptions | void + RetryOptions, + RetryOptions | void > = (baseQuery, defaultOptions) => async (args, api, extraOptions) => { const options = { maxRetries: 5, diff --git a/packages/toolkit/src/query/tests/apiProvider.test.tsx b/packages/toolkit/src/query/tests/apiProvider.test.tsx index 80ed29b36..3da38e157 100644 --- a/packages/toolkit/src/query/tests/apiProvider.test.tsx +++ b/packages/toolkit/src/query/tests/apiProvider.test.tsx @@ -6,7 +6,7 @@ import { waitMs } from './helpers' const api = createApi({ baseQuery: async (arg: any) => { await waitMs() - return { data: arg?.body ? arg.body : undefined } + return { data: arg?.body ? arg.body : null } }, endpoints: (build) => ({ getUser: build.query({ diff --git a/packages/toolkit/src/query/tests/buildHooks.test.tsx b/packages/toolkit/src/query/tests/buildHooks.test.tsx index 51b793188..463c436d3 100644 --- a/packages/toolkit/src/query/tests/buildHooks.test.tsx +++ b/packages/toolkit/src/query/tests/buildHooks.test.tsx @@ -10,6 +10,7 @@ import userEvent from '@testing-library/user-event' import { rest } from 'msw' import { actionsReducer, + ANY, expectExactType, expectType, setupApiStore, @@ -48,8 +49,17 @@ const api = createApi({ } }, endpoints: (build) => ({ - getUser: build.query({ - query: (arg) => arg, + getUser: build.query<{ name: string }, number>({ + query: () => ({ + body: { name: 'Timmy' }, + }), + }), + getUserAndForceError: build.query<{ name: string }, number>({ + query: () => ({ + body: { + forceError: true, + }, + }), }), getIncrementedAmount: build.query({ query: () => ({ @@ -494,18 +504,19 @@ describe('hooks tests', () => { let { unmount } = render(, { wrapper: storeRef.wrapper }) + expect(screen.getByTestId('isFetching').textContent).toBe('false') + // skipped queries do nothing by default, so we need to toggle that to get a cached result fireEvent.click(screen.getByText('change skip')) await waitFor(() => expect(screen.getByTestId('isFetching').textContent).toBe('true') ) - await waitFor(() => - expect(screen.getByTestId('isFetching').textContent).toBe('false') - ) - await waitFor(() => + + await waitFor(() => { expect(screen.getByTestId('amount').textContent).toBe('1') - ) + expect(screen.getByTestId('isFetching').textContent).toBe('false') + }) unmount() @@ -546,6 +557,52 @@ describe('hooks tests', () => { expect(screen.getByTestId('amount').textContent).toBe('2') ) }) + + describe('api.util.resetApiState resets hook', () => { + test('without `selectFromResult`', async () => { + const { result } = renderHook(() => api.endpoints.getUser.useQuery(5), { + wrapper: storeRef.wrapper, + }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + act(() => void storeRef.store.dispatch(api.util.resetApiState())) + + expect(result.current).toEqual( + expect.objectContaining({ + isError: false, + isFetching: true, + isLoading: true, + isSuccess: false, + isUninitialized: false, + refetch: expect.any(Function), + status: 'pending', + }) + ) + }) + test('with `selectFromResult`', async () => { + const selectFromResult = jest.fn((x) => x) + const { result } = renderHook( + () => api.endpoints.getUser.useQuery(5, { selectFromResult }), + { + wrapper: storeRef.wrapper, + } + ) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + selectFromResult.mockClear() + act(() => void storeRef.store.dispatch(api.util.resetApiState())) + + expect(selectFromResult).toHaveBeenNthCalledWith(1, { + isError: false, + isFetching: false, + isLoading: false, + isSuccess: false, + isUninitialized: true, + status: 'uninitialized', + }) + }) + }) }) describe('useLazyQuery', () => { @@ -773,6 +830,203 @@ describe('hooks tests', () => { .actions.filter(api.internalActions.unsubscribeQueryResult.match) ).toHaveLength(4) }) + + test('useLazyQuery hook callback returns various properties to handle the result', async () => { + function User() { + const [getUser] = api.endpoints.getUser.useLazyQuery() + const [{ successMsg, errMsg, isAborted }, setValues] = React.useState({ + successMsg: '', + errMsg: '', + isAborted: false, + }) + + const handleClick = (abort: boolean) => async () => { + const res = getUser(1) + + // no-op simply for clearer type assertions + res.then((result) => { + if (result.isSuccess) { + expectType<{ + data: { + name: string + } + }>(result) + } + if (result.isError) { + expectType<{ + error: { status: number; data: unknown } | SerializedError + }>(result) + } + }) + + expectType(res.arg) + expectType(res.requestId) + expectType<() => void>(res.abort) + expectType<() => Promise<{ name: string }>>(res.unwrap) + expectType<() => void>(res.unsubscribe) + expectType<(options: SubscriptionOptions) => void>( + res.updateSubscriptionOptions + ) + expectType<() => void>(res.refetch) + + // abort the query immediately to force an error + if (abort) res.abort() + res + .unwrap() + .then((result) => { + expectType<{ name: string }>(result) + setValues({ + successMsg: `Successfully fetched user ${result.name}`, + errMsg: '', + isAborted: false, + }) + }) + .catch((err) => { + setValues({ + successMsg: '', + errMsg: `An error has occurred fetching userId: ${res.arg}`, + isAborted: err.name === 'AbortError', + }) + }) + } + + return ( +
+ + +
{successMsg}
+
{errMsg}
+
{isAborted ? 'Request was aborted' : ''}
+
+ ) + } + + render(, { wrapper: storeRef.wrapper }) + expect(screen.queryByText(/An error has occurred/i)).toBeNull() + expect(screen.queryByText(/Successfully fetched user/i)).toBeNull() + expect(screen.queryByText('Request was aborted')).toBeNull() + + fireEvent.click( + screen.getByRole('button', { name: 'Fetch User and abort' }) + ) + await screen.findByText('An error has occurred fetching userId: 1') + expect(screen.queryByText(/Successfully fetched user/i)).toBeNull() + screen.getByText('Request was aborted') + + fireEvent.click( + screen.getByRole('button', { name: 'Fetch User successfully' }) + ) + await screen.findByText('Successfully fetched user Timmy') + expect(screen.queryByText(/An error has occurred/i)).toBeNull() + expect(screen.queryByText('Request was aborted')).toBeNull() + }) + + test('unwrapping the useLazyQuery trigger result does not throw on ConditionError and instead returns the aggregate error', async () => { + function User() { + const [getUser, { data, error }] = + api.endpoints.getUserAndForceError.useLazyQuery() + + const [unwrappedError, setUnwrappedError] = React.useState() + + const handleClick = async () => { + const res = getUser(1) + + try { + await res.unwrap() + } catch (error) { + setUnwrappedError(error) + } + } + + return ( +
+ +
{JSON.stringify(data)}
+
{JSON.stringify(error)}
+
+ {JSON.stringify(unwrappedError)} +
+
+ ) + } + + render(, { wrapper: storeRef.wrapper }) + + const fetchButton = screen.getByRole('button', { name: 'Fetch User' }) + fireEvent.click(fetchButton) + fireEvent.click(fetchButton) // This technically dispatches a ConditionError, but we don't want to see that here. We want the real error to resolve. + + await waitFor(() => { + const errorResult = screen.getByTestId('error')?.textContent + const unwrappedErrorResult = + screen.getByTestId('unwrappedError')?.textContent + + 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('') + }) + + test('useLazyQuery does not throw on ConditionError and instead returns the aggregate result', async () => { + function User() { + const [getUser, { data, error }] = api.endpoints.getUser.useLazyQuery() + + const [unwrappedResult, setUnwrappedResult] = React.useState< + undefined | { name: string } + >() + + const handleClick = async () => { + const res = getUser(1) + + const result = await res.unwrap() + setUnwrappedResult(result) + } + + return ( +
+ +
{JSON.stringify(data)}
+
{JSON.stringify(error)}
+
+ {JSON.stringify(unwrappedResult)} +
+
+ ) + } + + render(, { wrapper: storeRef.wrapper }) + + const fetchButton = screen.getByRole('button', { name: 'Fetch User' }) + fireEvent.click(fetchButton) + fireEvent.click(fetchButton) // This technically dispatches a ConditionError, but we don't want to see that here. We want the real result to resolve and ignore the error. + + await waitFor(() => { + const dataResult = screen.getByTestId('error')?.textContent + const unwrappedDataResult = + screen.getByTestId('unwrappedResult')?.textContent + + dataResult && + unwrappedDataResult && + expect(JSON.parse(dataResult)).toMatchObject({ + name: 'Timmy', + }) && + expect(JSON.parse(unwrappedDataResult)).toMatchObject( + JSON.parse(dataResult) + ) + }) + + expect(screen.getByTestId('error').textContent).toBe('') + }) }) describe('useMutation', () => { @@ -843,7 +1097,7 @@ describe('hooks tests', () => { // no-op simply for clearer type assertions res.then((result) => { - expectExactType< + expectType< | { error: { status: number; data: unknown } | SerializedError } @@ -863,6 +1117,7 @@ describe('hooks tests', () => { expectType(res.requestId) expectType<() => void>(res.abort) expectType<() => Promise<{ name: string }>>(res.unwrap) + expectType<() => void>(res.reset) expectType<() => void>(res.unsubscribe) // abort the mutation immediately to force an error @@ -905,6 +1160,63 @@ describe('hooks tests', () => { expect(screen.queryByText(/Successfully updated user/i)).toBeNull() screen.getByText('Request was aborted') }) + + test('useMutation return value contains originalArgs', async () => { + const { result } = renderHook(api.endpoints.updateUser.useMutation, { + wrapper: storeRef.wrapper, + }) + const arg = { name: 'Foo' } + + const firstRenderResult = result.current + expect(firstRenderResult[1].originalArgs).toBe(undefined) + act(() => void firstRenderResult[0](arg)) + const secondRenderResult = result.current + expect(firstRenderResult[1].originalArgs).toBe(undefined) + expect(secondRenderResult[1].originalArgs).toBe(arg) + }) + + test('`reset` sets state back to original state', async () => { + function User() { + const [updateUser, result] = api.endpoints.updateUser.useMutation() + return ( + <> + + {result.isUninitialized + ? 'isUninitialized' + : result.isSuccess + ? 'isSuccess' + : 'other'} + + {result.originalArgs?.name} + + + + ) + } + render(, { wrapper: storeRef.wrapper }) + + await screen.findByText(/isUninitialized/i) + expect(screen.queryByText('Yay')).toBeNull() + expect(Object.keys(storeRef.store.getState().api.mutations).length).toBe( + 0 + ) + + userEvent.click(screen.getByRole('button', { name: 'trigger' })) + + await screen.findByText(/isSuccess/i) + expect(screen.queryByText('Yay')).not.toBeNull() + expect(Object.keys(storeRef.store.getState().api.mutations).length).toBe( + 1 + ) + + userEvent.click(screen.getByRole('button', { name: 'reset' })) + + await screen.findByText(/isUninitialized/i) + expect(screen.queryByText('Yay')).toBeNull() + expect(Object.keys(storeRef.store.getState().api.mutations).length).toBe( + 0 + ) + }) }) describe('usePrefetch', () => { @@ -939,7 +1251,7 @@ describe('hooks tests', () => { expect( api.endpoints.getUser.select(USER_ID)(storeRef.store.getState() as any) ).toEqual({ - data: {}, + data: { name: 'Timmy' }, endpointName: 'getUser', error: undefined, fulfilledTimeStamp: expect.any(Number), @@ -960,7 +1272,7 @@ describe('hooks tests', () => { expect( api.endpoints.getUser.select(USER_ID)(storeRef.store.getState() as any) ).toEqual({ - data: {}, + data: { name: 'Timmy' }, endpointName: 'getUser', fulfilledTimeStamp: expect.any(Number), isError: false, @@ -1008,7 +1320,7 @@ describe('hooks tests', () => { expect( api.endpoints.getUser.select(USER_ID)(storeRef.store.getState() as any) ).toEqual({ - data: {}, + data: { name: 'Timmy' }, endpointName: 'getUser', fulfilledTimeStamp: expect.any(Number), isError: false, @@ -1026,7 +1338,7 @@ describe('hooks tests', () => { expect( api.endpoints.getUser.select(USER_ID)(storeRef.store.getState() as any) ).toEqual({ - data: {}, + data: { name: 'Timmy' }, endpointName: 'getUser', fulfilledTimeStamp: expect.any(Number), isError: false, @@ -1076,7 +1388,7 @@ describe('hooks tests', () => { expect( api.endpoints.getUser.select(USER_ID)(storeRef.store.getState() as any) ).toEqual({ - data: {}, + data: { name: 'Timmy' }, endpointName: 'getUser', fulfilledTimeStamp: expect.any(Number), isError: false, @@ -1096,7 +1408,7 @@ describe('hooks tests', () => { expect( api.endpoints.getUser.select(USER_ID)(storeRef.store.getState() as any) ).toEqual({ - data: {}, + data: { name: 'Timmy' }, endpointName: 'getUser', fulfilledTimeStamp: expect.any(Number), isError: false, @@ -1879,8 +2191,8 @@ describe('hooks with createApi defaults set', () => { api.internalActions.middlewareRegistered.match, increment.matchPending, increment.matchFulfilled, - api.internalActions.unsubscribeMutationResult.match, increment.matchPending, + api.internalActions.removeMutationResult.match, increment.matchFulfilled ) }) @@ -1965,19 +2277,6 @@ describe('hooks with createApi defaults set', () => { expect(getRenderCount()).toBe(5) }) - test('useMutation return value contains originalArgs', async () => { - const { result } = renderHook(api.endpoints.increment.useMutation, { - wrapper: storeRef.wrapper, - }) - - const firstRenderResult = result.current - expect(firstRenderResult[1].originalArgs).toBe(undefined) - firstRenderResult[0](5) - const secondRenderResult = result.current - expect(firstRenderResult[1].originalArgs).toBe(undefined) - expect(secondRenderResult[1].originalArgs).toBe(5) - }) - it('useMutation with selectFromResult option has a type error if the result is not an object', async () => { function Counter() { const [increment] = api.endpoints.increment.useMutation({ diff --git a/packages/toolkit/src/query/tests/buildSelector.test.ts b/packages/toolkit/src/query/tests/buildSelector.test.ts new file mode 100644 index 000000000..4ecb5106e --- /dev/null +++ b/packages/toolkit/src/query/tests/buildSelector.test.ts @@ -0,0 +1,54 @@ +import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react' + +import { createSelector, configureStore } from '@reduxjs/toolkit' +import { expectExactType } from './helpers' + +describe('buildSelector', () => { + test.skip('buildSelector typetest', () => { + interface Todo { + userId: number + id: number + title: string + completed: boolean + } + + type Todos = Array + + const exampleApi = createApi({ + reducerPath: 'api', + baseQuery: fetchBaseQuery({ + baseUrl: 'https://jsonplaceholder.typicode.com', + }), + endpoints: (build) => ({ + getTodos: build.query({ + query: () => '/todos', + }), + }), + }) + + const exampleQuerySelector = exampleApi.endpoints.getTodos.select('/') + + const todosSelector = createSelector( + [exampleQuerySelector], + (queryState) => { + return queryState?.data?.[0] ?? ({} as Todo) + } + ) + const firstTodoTitleSelector = createSelector( + [todosSelector], + (todo) => todo?.title + ) + + const store = configureStore({ + reducer: { + [exampleApi.reducerPath]: exampleApi.reducer, + }, + }) + + const todoTitle = firstTodoTitleSelector(store.getState()) + + // This only compiles if we carried the types through + const upperTitle = todoTitle.toUpperCase() + expectExactType(upperTitle) + }) +}) diff --git a/packages/toolkit/src/query/tests/buildThunks.test.tsx b/packages/toolkit/src/query/tests/buildThunks.test.tsx index 7ba0aaf35..c591738d0 100644 --- a/packages/toolkit/src/query/tests/buildThunks.test.tsx +++ b/packages/toolkit/src/query/tests/buildThunks.test.tsx @@ -74,7 +74,7 @@ test('passes the extraArgument property to the baseQueryApi', async () => { describe('re-triggering behavior on arg change', () => { const api = createApi({ - baseQuery: () => ({ data: undefined }), + baseQuery: () => ({ data: null }), endpoints: (build) => ({ getUser: build.query({ query: (obj) => obj, @@ -156,12 +156,14 @@ describe('re-triggering behavior on arg change', () => { } }) - test('re-trigger every time on deeper value changes', async () => { + test('re-triggers every time on deeper value changes', async () => { + const name = 'Tim' + const { result, rerender, waitForNextUpdate } = renderHook( (props) => getUser.useQuery(props), { wrapper: withProvider(store), - initialProps: { person: { name: 'Tim' } }, + initialProps: { person: { name } }, } ) @@ -171,7 +173,7 @@ describe('re-triggering behavior on arg change', () => { expect(spy).toHaveBeenCalledTimes(1) for (let x = 1; x < 3; x++) { - rerender({ person: { name: 'Tim' } }) + rerender({ person: { name: name + x } }) // @ts-ignore while (result.current.status === 'pending') { await waitForNextUpdate() diff --git a/packages/toolkit/src/query/tests/cleanup.test.tsx b/packages/toolkit/src/query/tests/cleanup.test.tsx index 1b96d7236..957a5bbe1 100644 --- a/packages/toolkit/src/query/tests/cleanup.test.tsx +++ b/packages/toolkit/src/query/tests/cleanup.test.tsx @@ -7,7 +7,7 @@ import { render, waitFor } from '@testing-library/react' import { setupApiStore } from './helpers' const api = createApi({ - baseQuery: () => ({ data: undefined }), + baseQuery: () => ({ data: null }), endpoints: (build) => ({ a: build.query({ query: () => '' }), b: build.query({ query: () => '' }), diff --git a/packages/toolkit/src/query/tests/createApi.test.ts b/packages/toolkit/src/query/tests/createApi.test.ts index 8ca61c32e..90c9acb77 100644 --- a/packages/toolkit/src/query/tests/createApi.test.ts +++ b/packages/toolkit/src/query/tests/createApi.test.ts @@ -136,7 +136,7 @@ describe('wrong tagTypes log errors', () => { }) beforeEach(() => { - baseQuery.mockResolvedValue({}) + baseQuery.mockResolvedValue({ data: 'foo' }) }) test.each<[keyof typeof api.endpoints, boolean?]>([ @@ -305,10 +305,14 @@ describe('endpoint definition typings', () => { describe('enhancing endpoint definitions', () => { const baseQuery = jest.fn((x: string) => ({ data: 'success' })) - const baseQueryApiMatcher = { + const commonBaseQueryApi = { dispatch: expect.any(Function), + endpoint: expect.any(String), + extra: undefined, + forced: expect.any(Boolean), getState: expect.any(Function), signal: expect.any(Object), + type: expect.any(String), } beforeEach(() => { baseQuery.mockClear() @@ -337,11 +341,56 @@ describe('endpoint definition typings', () => { storeRef.store.dispatch(api.endpoints.query2.initiate('in2')) storeRef.store.dispatch(api.endpoints.mutation1.initiate('in1')) storeRef.store.dispatch(api.endpoints.mutation2.initiate('in2')) + expect(baseQuery.mock.calls).toEqual([ - ['in1', baseQueryApiMatcher, undefined], - ['in2', baseQueryApiMatcher, undefined], - ['in1', baseQueryApiMatcher, undefined], - ['in2', baseQueryApiMatcher, undefined], + [ + 'in1', + { + dispatch: expect.any(Function), + endpoint: expect.any(String), + getState: expect.any(Function), + signal: expect.any(Object), + forced: expect.any(Boolean), + type: expect.any(String), + }, + undefined, + ], + [ + 'in2', + { + dispatch: expect.any(Function), + endpoint: expect.any(String), + getState: expect.any(Function), + signal: expect.any(Object), + forced: expect.any(Boolean), + type: expect.any(String), + }, + undefined, + ], + [ + 'in1', + { + dispatch: expect.any(Function), + endpoint: expect.any(String), + getState: expect.any(Function), + signal: expect.any(Object), + // forced: undefined, + type: expect.any(String), + }, + undefined, + ], + [ + 'in2', + { + dispatch: expect.any(Function), + endpoint: expect.any(String), + getState: expect.any(Function), + signal: expect.any(Object), + // forced: undefined, + type: expect.any(String), + }, + undefined, + ], ]) }) @@ -439,11 +488,12 @@ describe('endpoint definition typings', () => { storeRef.store.dispatch(api.endpoints.query2.initiate('in2')) storeRef.store.dispatch(api.endpoints.mutation1.initiate('in1')) storeRef.store.dispatch(api.endpoints.mutation2.initiate('in2')) + expect(baseQuery.mock.calls).toEqual([ - ['modified1', baseQueryApiMatcher, undefined], - ['modified2', baseQueryApiMatcher, undefined], - ['modified1', baseQueryApiMatcher, undefined], - ['modified2', baseQueryApiMatcher, undefined], + ['modified1', commonBaseQueryApi, undefined], + ['modified2', commonBaseQueryApi, undefined], + ['modified1', { ...commonBaseQueryApi, forced: undefined }, undefined], + ['modified2', { ...commonBaseQueryApi, forced: undefined }, undefined], ]) }) }) diff --git a/packages/toolkit/src/query/tests/errorHandling.test.tsx b/packages/toolkit/src/query/tests/errorHandling.test.tsx index 26343c2d1..6b5999817 100644 --- a/packages/toolkit/src/query/tests/errorHandling.test.tsx +++ b/packages/toolkit/src/query/tests/errorHandling.test.tsx @@ -11,6 +11,7 @@ import { server } from './mocks/server' import { fireEvent, render, waitFor, screen } from '@testing-library/react' import { useDispatch } from 'react-redux' import type { AnyAction, ThunkDispatch } from '@reduxjs/toolkit' +import type { BaseQueryApi } from '../baseQueryTypes' const baseQuery = fetchBaseQuery({ baseUrl: 'http://example.com' }) @@ -33,18 +34,20 @@ const failQueryOnce = rest.get('/query', (_, req, ctx) => ) describe('fetchBaseQuery', () => { + let commonBaseQueryApiArgs: BaseQueryApi = {} as any + beforeEach(() => { + commonBaseQueryApiArgs = { + signal: new AbortController().signal, + dispatch: storeRef.store.dispatch, + getState: storeRef.store.getState, + extra: undefined, + type: 'query', + endpoint: 'doesntmatterhere', + } + }) test('success', async () => { await expect( - baseQuery( - '/success', - { - signal: new AbortController().signal, - dispatch: storeRef.store.dispatch, - getState: storeRef.store.getState, - extra: undefined, - }, - {} - ) + baseQuery('/success', commonBaseQueryApiArgs, {}) ).resolves.toEqual({ data: { value: 'success' }, meta: { @@ -56,16 +59,7 @@ describe('fetchBaseQuery', () => { test('error', async () => { server.use(failQueryOnce) await expect( - baseQuery( - '/error', - { - signal: new AbortController().signal, - dispatch: storeRef.store.dispatch, - getState: storeRef.store.getState, - extra: undefined, - }, - {} - ) + baseQuery('/error', commonBaseQueryApiArgs, {}) ).resolves.toEqual({ error: { data: { value: 'error' }, diff --git a/packages/toolkit/src/query/tests/fetchBaseQuery.test.tsx b/packages/toolkit/src/query/tests/fetchBaseQuery.test.tsx index 201d6479a..9007a2dce 100644 --- a/packages/toolkit/src/query/tests/fetchBaseQuery.test.tsx +++ b/packages/toolkit/src/query/tests/fetchBaseQuery.test.tsx @@ -4,6 +4,8 @@ import { setupApiStore } from './helpers' import { server } from './mocks/server' import { default as crossFetch } from 'cross-fetch' import { rest } from 'msw' +import queryString from 'query-string' +import type { BaseQueryApi } from '../baseQueryTypes' const defaultHeaders: Record = { fake: 'header', @@ -70,19 +72,22 @@ const authSlice = createSlice({ const storeRef = setupApiStore(api, { auth: authSlice.reducer }) type RootState = ReturnType +let commonBaseQueryApi: BaseQueryApi = {} as any +beforeEach(() => { + commonBaseQueryApi = { + signal: new AbortController().signal, + dispatch: storeRef.store.dispatch, + getState: storeRef.store.getState, + extra: undefined, + type: 'query', + endpoint: 'doesntmatterhere', + } +}) + describe('fetchBaseQuery', () => { describe('basic functionality', () => { it('should return an object for a simple GET request when it is json data', async () => { - const req = baseQuery( - '/success', - { - signal: new AbortController().signal, - dispatch: storeRef.store.dispatch, - getState: storeRef.store.getState, - extra: undefined, - }, - {} - ) + const req = baseQuery('/success', commonBaseQueryApi, {}) expect(req).toBeInstanceOf(Promise) const res = await req expect(res).toBeInstanceOf(Object) @@ -90,35 +95,17 @@ describe('fetchBaseQuery', () => { }) it('should return undefined for a simple GET request when the response is empty', async () => { - const req = baseQuery( - '/empty', - { - signal: new AbortController().signal, - dispatch: storeRef.store.dispatch, - getState: storeRef.store.getState, - extra: undefined, - }, - {} - ) + const req = baseQuery('/empty', commonBaseQueryApi, {}) expect(req).toBeInstanceOf(Promise) const res = await req expect(res).toBeInstanceOf(Object) expect(res.meta?.request).toBeInstanceOf(Request) expect(res.meta?.response).toBeInstanceOf(Object) - expect(res.data).toBeUndefined() + expect(res.data).toBeNull() }) it('should return an error and status for error responses', async () => { - const req = baseQuery( - '/error', - { - signal: new AbortController().signal, - dispatch: storeRef.store.dispatch, - getState: storeRef.store.getState, - extra: undefined, - }, - {} - ) + const req = baseQuery('/error', commonBaseQueryApi, {}) expect(req).toBeInstanceOf(Promise) const res = await req expect(res).toBeInstanceOf(Object) @@ -133,16 +120,7 @@ describe('fetchBaseQuery', () => { it('should handle a connection loss semi-gracefully', async () => { fetchFn.mockRejectedValueOnce(new TypeError('Failed to fetch')) - const req = baseQuery( - '/success', - { - signal: new AbortController().signal, - dispatch: storeRef.store.dispatch, - getState: storeRef.store.getState, - extra: undefined, - }, - {} - ) + const req = baseQuery('/success', commonBaseQueryApi, {}) expect(req).toBeInstanceOf(Promise) const res = await req expect(res).toBeInstanceOf(Object) @@ -165,12 +143,7 @@ describe('fetchBaseQuery', () => { const req = baseQuery( { url: '/success', responseHandler: 'text' }, - { - signal: new AbortController().signal, - dispatch: storeRef.store.dispatch, - getState: storeRef.store.getState, - extra: undefined, - }, + commonBaseQueryApi, {} ) expect(req).toBeInstanceOf(Promise) @@ -188,16 +161,7 @@ describe('fetchBaseQuery', () => { ) ) - const req = baseQuery( - '/success', - { - signal: new AbortController().signal, - dispatch: storeRef.store.dispatch, - getState: storeRef.store.getState, - extra: undefined, - }, - {} - ) + const req = baseQuery('/success', commonBaseQueryApi, {}) expect(req).toBeInstanceOf(Promise) const res = await req expect(res).toBeInstanceOf(Object) @@ -220,12 +184,7 @@ describe('fetchBaseQuery', () => { const req = baseQuery( { url: '/error', responseHandler: 'text' }, - { - signal: new AbortController().signal, - dispatch: storeRef.store.dispatch, - getState: storeRef.store.getState, - extra: undefined, - }, + commonBaseQueryApi, {} ) expect(req).toBeInstanceOf(Promise) @@ -246,16 +205,7 @@ describe('fetchBaseQuery', () => { ) ) - const req = baseQuery( - '/error', - { - signal: new AbortController().signal, - dispatch: storeRef.store.dispatch, - getState: storeRef.store.getState, - extra: undefined, - }, - {} - ) + const req = baseQuery('/error', commonBaseQueryApi, {}) expect(req).toBeInstanceOf(Promise) const res = await req expect(res).toBeInstanceOf(Object) @@ -279,12 +229,7 @@ describe('fetchBaseQuery', () => { let request: any ;({ data: request } = await baseQuery( { url: '/echo', body: data, method: 'POST' }, - { - signal: new AbortController().signal, - dispatch: storeRef.store.dispatch, - getState: storeRef.store.getState, - extra: undefined, - }, + { ...commonBaseQueryApi, type: 'mutation' }, {} )) @@ -298,12 +243,7 @@ describe('fetchBaseQuery', () => { let request: any ;({ data: request } = await baseQuery( { url: '/echo', body: data, method: 'POST' }, - { - signal: new AbortController().signal, - dispatch: storeRef.store.dispatch, - getState: storeRef.store.getState, - extra: undefined, - }, + commonBaseQueryApi, {} )) @@ -324,12 +264,7 @@ describe('fetchBaseQuery', () => { method: 'POST', headers: { 'content-type': 'text/html' }, }, - { - signal: new AbortController().signal, - dispatch: storeRef.store.dispatch, - getState: storeRef.store.getState, - extra: undefined, - }, + commonBaseQueryApi, {} )) @@ -348,12 +283,7 @@ describe('fetchBaseQuery', () => { method: 'POST', headers: { 'content-type': 'text/html' }, }, - { - signal: new AbortController().signal, - dispatch: storeRef.store.dispatch, - getState: storeRef.store.getState, - extra: undefined, - }, + commonBaseQueryApi, {} )) @@ -367,12 +297,7 @@ describe('fetchBaseQuery', () => { let request: any ;({ data: request } = await baseQuery( { url: '/echo' }, - { - signal: new AbortController().signal, - dispatch: storeRef.store.dispatch, - getState: storeRef.store.getState, - extra: undefined, - }, + commonBaseQueryApi, {} )) @@ -385,12 +310,7 @@ describe('fetchBaseQuery', () => { let request: any ;({ data: request } = await baseQuery( { url: '/echo', params }, - { - signal: new AbortController().signal, - dispatch: storeRef.store.dispatch, - getState: storeRef.store.getState, - extra: undefined, - }, + commonBaseQueryApi, {} )) @@ -403,12 +323,7 @@ describe('fetchBaseQuery', () => { let request: any ;({ data: request } = await baseQuery( { url: '/echo?banana=pudding', params }, - { - signal: new AbortController().signal, - dispatch: storeRef.store.dispatch, - getState: storeRef.store.getState, - extra: undefined, - }, + commonBaseQueryApi, {} )) @@ -421,12 +336,7 @@ describe('fetchBaseQuery', () => { let request: any ;({ data: request } = await baseQuery( { url: '/echo', params }, - { - signal: new AbortController().signal, - dispatch: storeRef.store.dispatch, - getState: storeRef.store.getState, - extra: undefined, - }, + commonBaseQueryApi, {} )) @@ -439,17 +349,54 @@ describe('fetchBaseQuery', () => { let request: any ;({ data: request } = await baseQuery( { url: '/echo', params }, - { - signal: new AbortController().signal, - dispatch: storeRef.store.dispatch, - getState: storeRef.store.getState, - extra: undefined, - }, + commonBaseQueryApi, {} )) expect(request.url).toEqual(`${baseUrl}/echo?apple=fruit&randy=null`) }) + + it('should support a paramsSerializer', async () => { + const baseQuery = fetchBaseQuery({ + baseUrl, + fetchFn: fetchFn as any, + paramsSerializer: (params: Record) => + queryString.stringify(params, { arrayFormat: 'bracket' }), + }) + + const api = createApi({ + baseQuery, + endpoints(build) { + return { + query: build.query({ + query: () => ({ url: '/echo', headers: {} }), + }), + mutation: build.mutation({ + query: () => ({ + url: '/echo', + method: 'POST', + credentials: 'omit', + }), + }), + } + }, + }) + + const params = { + someArray: ['a', 'b', 'c'], + } + + let request: any + ;({ data: request } = await baseQuery( + { url: '/echo', params }, + commonBaseQueryApi, + {} + )) + + expect(request.url).toEqual( + `${baseUrl}/echo?someArray[]=a&someArray[]=b&someArray[]=c` + ) + }) }) describe('validateStatus', () => { @@ -461,12 +408,7 @@ describe('fetchBaseQuery', () => { validateStatus: (response, body) => response.status === 200 && body.success === false ? false : true, }, - { - signal: new AbortController().signal, - dispatch: storeRef.store.dispatch, - getState: storeRef.store.getState, - extra: undefined, - }, + commonBaseQueryApi, {} ) @@ -485,12 +427,7 @@ describe('fetchBaseQuery', () => { let request: any ;({ data: request } = await baseQuery( { url: '/echo' }, - { - signal: new AbortController().signal, - dispatch: storeRef.store.dispatch, - getState: storeRef.store.getState, - extra: undefined, - }, + commonBaseQueryApi, {} )) @@ -503,12 +440,7 @@ describe('fetchBaseQuery', () => { let request: any ;({ data: request } = await baseQuery( { url: '/echo', headers: { authorization: 'Bearer banana' } }, - { - signal: new AbortController().signal, - dispatch: storeRef.store.dispatch, - getState: storeRef.store.getState, - extra: undefined, - }, + commonBaseQueryApi, {} )) @@ -528,12 +460,7 @@ describe('fetchBaseQuery', () => { 'content-type': 'custom-content-type', }, }, - { - signal: new AbortController().signal, - dispatch: storeRef.store.dispatch, - getState: storeRef.store.getState, - extra: undefined, - }, + commonBaseQueryApi, {} )) @@ -550,12 +477,7 @@ describe('fetchBaseQuery', () => { let request: any ;({ data: request } = await baseQuery( { url: '/echo', headers: { fake, delete: '', delete2: '' } }, - { - signal: new AbortController().signal, - dispatch: storeRef.store.dispatch, - getState: storeRef.store.getState, - extra: undefined, - }, + commonBaseQueryApi, {} )) @@ -579,16 +501,7 @@ describe('fetchBaseQuery', () => { }) const doRequest = async () => - _baseQuery( - { url: '/echo' }, - { - signal: new AbortController().signal, - dispatch: storeRef.store.dispatch, - getState: storeRef.store.getState, - extra: undefined, - }, - {} - ) + _baseQuery({ url: '/echo' }, commonBaseQueryApi, {}) ;({ data: request } = await doRequest()) @@ -606,6 +519,8 @@ describe('fetchBaseQuery', () => { dispatch: storeRef.store.dispatch, getState: storeRef.store.getState, extra: undefined, + type: 'query', + endpoint: '', }, {} ) @@ -627,12 +542,7 @@ describe('fetchBaseQuery', () => { let request: any ;({ data: request } = await baseQuery( { url: '/echo', headers: undefined }, - { - signal: new AbortController().signal, - dispatch: storeRef.store.dispatch, - getState: storeRef.store.getState, - extra: undefined, - }, + commonBaseQueryApi, {} )) @@ -646,12 +556,7 @@ describe('fetchBaseQuery', () => { let request: any ;({ data: request } = await baseQuery( { url: '/echo', headers: { banana } }, - { - signal: new AbortController().signal, - dispatch: storeRef.store.dispatch, - getState: storeRef.store.getState, - extra: undefined, - }, + commonBaseQueryApi, {} )) @@ -666,12 +571,7 @@ describe('fetchBaseQuery', () => { let request: any ;({ data: request } = await baseQuery( { url: '/echo', headers: { banana } }, - { - signal: new AbortController().signal, - dispatch: storeRef.store.dispatch, - getState: storeRef.store.getState, - extra: undefined, - }, + commonBaseQueryApi, {} )) @@ -695,12 +595,7 @@ describe('fetchFn', () => { let request: any ;({ data: request } = await baseQuery( { url: '/echo', params }, - { - signal: new AbortController().signal, - dispatch: storeRef.store.dispatch, - getState: storeRef.store.getState, - extra: undefined, - }, + commonBaseQueryApi, {} )) @@ -721,16 +616,7 @@ describe('fetchFn', () => { const spiedFetch = jest.spyOn(window, 'fetch') spiedFetch.mockResolvedValueOnce(fakeResponse as any) - const { data } = await baseQuery( - { url: '/echo' }, - { - signal: new AbortController().signal, - dispatch: storeRef.store.dispatch, - getState: storeRef.store.getState, - extra: undefined, - }, - {} - ) + const { data } = await baseQuery({ url: '/echo' }, commonBaseQueryApi, {}) expect(data).toEqual({ url: 'mock-return-url' }) spiedFetch.mockClear() @@ -750,12 +636,7 @@ describe('FormData', () => { const res = await baseQuery( { url: '/echo', method: 'POST', body }, - { - signal: new AbortController().signal, - dispatch: storeRef.store.dispatch, - getState: storeRef.store.getState, - extra: undefined, - }, + commonBaseQueryApi, {} ) const request: any = res.data @@ -773,12 +654,7 @@ describe('still throws on completely unexpected errors', () => { throw error }, }, - { - signal: new AbortController().signal, - dispatch: storeRef.store.dispatch, - getState: storeRef.store.getState, - extra: undefined, - }, + commonBaseQueryApi, {} ) expect(req).toBeInstanceOf(Promise) diff --git a/packages/toolkit/src/query/tests/helpers.tsx b/packages/toolkit/src/query/tests/helpers.tsx index 008482257..c42b4f79e 100644 --- a/packages/toolkit/src/query/tests/helpers.tsx +++ b/packages/toolkit/src/query/tests/helpers.tsx @@ -17,6 +17,7 @@ import { createConsole, getLog, } from 'console-testing-library/pure' +import { cleanup } from '@testing-library/react' export const ANY = 0 as any @@ -213,6 +214,7 @@ export function setupApiStore< } }) afterEach(() => { + cleanup() if (!withoutListeners) { cleanupListeners() } diff --git a/packages/toolkit/src/query/tests/invalidation.test.tsx b/packages/toolkit/src/query/tests/invalidation.test.tsx index afcd052e0..b5257f868 100644 --- a/packages/toolkit/src/query/tests/invalidation.test.tsx +++ b/packages/toolkit/src/query/tests/invalidation.test.tsx @@ -1,10 +1,19 @@ import { createApi, fakeBaseQuery } from '@reduxjs/toolkit/query' import { setupApiStore, waitMs } from './helpers' -import type { ResultDescription } from '@reduxjs/toolkit/dist/query/endpointDefinitions' +import type { TagDescription } from '@reduxjs/toolkit/dist/query/endpointDefinitions' +import { waitFor } from '@testing-library/react' -const tagTypes = ['apple', 'pear', 'banana', 'tomato'] as const +const tagTypes = [ + 'apple', + 'pear', + 'banana', + 'tomato', + 'cat', + 'dog', + 'giraffe', +] as const type TagTypes = typeof tagTypes[number] -type Tags = ResultDescription +type Tags = TagDescription[] /** providesTags, invalidatesTags, shouldInvalidate */ const caseMatrix: [Tags, Tags, boolean][] = [ @@ -62,8 +71,9 @@ test.each(caseMatrix)( let queryCount = 0 const { store, + api, api: { - endpoints: { invalidating, providing }, + endpoints: { invalidating, providing, unrelated }, }, } = setupApiStore( createApi({ @@ -77,6 +87,12 @@ test.each(caseMatrix)( }, providesTags, }), + unrelated: build.query({ + queryFn() { + return { data: {} } + }, + providesTags: ['cat', 'dog', { type: 'giraffe', id: 8 }], + }), invalidating: build.mutation({ queryFn() { return { data: {} } @@ -88,7 +104,33 @@ test.each(caseMatrix)( ) store.dispatch(providing.initiate()) + store.dispatch(unrelated.initiate()) expect(queryCount).toBe(1) + await waitFor(() => { + expect(api.endpoints.providing.select()(store.getState()).status).toBe( + 'fulfilled' + ) + expect(api.endpoints.unrelated.select()(store.getState()).status).toBe( + 'fulfilled' + ) + }) + const toInvalidate = api.util.selectInvalidatedBy( + store.getState(), + invalidatesTags + ) + + if (shouldInvalidate) { + expect(toInvalidate).toEqual([ + { + queryCacheKey: 'providing(undefined)', + endpointName: 'providing', + originalArgs: undefined, + }, + ]) + } else { + expect(toInvalidate).toEqual([]) + } + store.dispatch(invalidating.initiate()) expect(queryCount).toBe(1) await waitMs(2) diff --git a/packages/toolkit/src/query/tests/optimisticUpdates.test.tsx b/packages/toolkit/src/query/tests/optimisticUpdates.test.tsx index e5f0dc243..961bfd77c 100644 --- a/packages/toolkit/src/query/tests/optimisticUpdates.test.tsx +++ b/packages/toolkit/src/query/tests/optimisticUpdates.test.tsx @@ -14,7 +14,7 @@ beforeEach(() => baseQuery.mockReset()) const api = createApi({ baseQuery: (...args: any[]) => { const result = baseQuery(...args) - if ('then' in result) + if (typeof result === 'object' && 'then' in result) return result .then((data: any) => ({ data, meta: 'meta' })) .catch((e: any) => ({ error: e })) @@ -131,11 +131,16 @@ describe('basic lifecycle', () => { describe('updateQueryData', () => { test('updates cache values, can apply inverse patch', async () => { - baseQuery.mockResolvedValueOnce({ - id: '3', - title: 'All about cheese.', - contents: 'TODO', - }) + baseQuery + .mockResolvedValueOnce({ + id: '3', + title: 'All about cheese.', + contents: 'TODO', + }) + // TODO I have no idea why the query is getting called multiple times, + // but passing an additional mocked value (_any_ value) + // seems to silence some annoying "got an undefined result" logging + .mockResolvedValueOnce(42) const { result } = renderHook(() => api.endpoints.post.useQuery('3'), { wrapper: storeRef.wrapper, }) @@ -172,7 +177,7 @@ describe('updateQueryData', () => { act(() => { storeRef.store.dispatch( - api.util.patchQueryResult('post', '3', returnValue.inversePatches) + api.util.patchQueryData('post', '3', returnValue.inversePatches) ) }) @@ -180,11 +185,14 @@ describe('updateQueryData', () => { }) test('does not update non-existing values', async () => { - baseQuery.mockResolvedValueOnce({ - id: '3', - title: 'All about cheese.', - contents: 'TODO', - }) + baseQuery + .mockImplementationOnce(async () => ({ + id: '3', + title: 'All about cheese.', + contents: 'TODO', + })) + .mockResolvedValueOnce(42) + const { result } = renderHook(() => api.endpoints.post.useQuery('3'), { wrapper: storeRef.wrapper, }) @@ -234,6 +242,7 @@ describe('full integration', () => { title: 'Meanwhile, this changed server-side.', contents: 'Delicious cheese!', }) + .mockResolvedValueOnce(42) const { result } = renderHook( () => ({ query: api.endpoints.post.useQuery('3'), @@ -283,6 +292,7 @@ describe('full integration', () => { title: 'Meanwhile, this changed server-side.', contents: 'TODO', }) + .mockResolvedValueOnce(42) const { result } = renderHook( () => ({ diff --git a/packages/toolkit/src/query/tests/unionTypes.test.ts b/packages/toolkit/src/query/tests/unionTypes.test.ts index 87616715e..1676df30c 100644 --- a/packages/toolkit/src/query/tests/unionTypes.test.ts +++ b/packages/toolkit/src/query/tests/unionTypes.test.ts @@ -115,6 +115,20 @@ describe.skip('TS only tests', () => { expectExactType(false as false)(result.isError) } + expectExactType('' as string | undefined)(result.currentData) + // @ts-expect-error + expectExactType('' as string)(result.currentData) + + if (result.isSuccess) { + if (!result.isFetching) { + expectExactType('' as string)(result.currentData) + } else { + expectExactType('' as string | undefined)(result.currentData) + // @ts-expect-error + expectExactType('' as string)(result.currentData) + } + } + // @ts-expect-error expectType(result) // is always one of those four @@ -483,6 +497,7 @@ describe.skip('TS only tests', () => { isLoading: true, isSuccess: false, isError: false, + reset: () => {}, })(result) }) diff --git a/packages/toolkit/src/query/tests/useMutation-fixedCacheKey.test.tsx b/packages/toolkit/src/query/tests/useMutation-fixedCacheKey.test.tsx new file mode 100644 index 000000000..c0c529023 --- /dev/null +++ b/packages/toolkit/src/query/tests/useMutation-fixedCacheKey.test.tsx @@ -0,0 +1,306 @@ +import { createApi } from '@reduxjs/toolkit/query/react' +import { setupApiStore, waitMs } from './helpers' +import React from 'react' +import { render, screen, getByTestId, waitFor } from '@testing-library/react' + +describe('fixedCacheKey', () => { + const api = createApi({ + async baseQuery(arg: string | Promise) { + return { data: await arg } + }, + endpoints: (build) => ({ + send: build.mutation>({ + query: (arg) => arg, + }), + }), + }) + const storeRef = setupApiStore(api) + + function Component({ + name, + fixedCacheKey, + value = name, + }: { + name: string + fixedCacheKey?: string + value?: string | Promise + }) { + const [trigger, result] = api.endpoints.send.useMutation({ fixedCacheKey }) + + return ( +
+
{result.status}
+
{result.data}
+
{String(result.originalArgs)}
+ + +
+ ) + } + + test('two mutations without `fixedCacheKey` do not influence each other', async () => { + render( + <> + + + , + { wrapper: storeRef.wrapper } + ) + const c1 = screen.getByTestId('C1') + const c2 = screen.getByTestId('C2') + expect(getByTestId(c1, 'status').textContent).toBe('uninitialized') + expect(getByTestId(c2, 'status').textContent).toBe('uninitialized') + + getByTestId(c1, 'trigger').click() + + await waitFor(() => + expect(getByTestId(c1, 'status').textContent).toBe('fulfilled') + ) + expect(getByTestId(c1, 'data').textContent).toBe('C1') + expect(getByTestId(c2, 'status').textContent).toBe('uninitialized') + }) + + test('two mutations with the same `fixedCacheKey` do influence each other', async () => { + render( + <> + + + , + { wrapper: storeRef.wrapper } + ) + const c1 = screen.getByTestId('C1') + const c2 = screen.getByTestId('C2') + expect(getByTestId(c1, 'status').textContent).toBe('uninitialized') + expect(getByTestId(c2, 'status').textContent).toBe('uninitialized') + + getByTestId(c1, 'trigger').click() + + await waitFor(() => + expect(getByTestId(c1, 'status').textContent).toBe('fulfilled') + ) + expect(getByTestId(c1, 'data').textContent).toBe('C1') + expect(getByTestId(c2, 'status').textContent).toBe('fulfilled') + expect(getByTestId(c2, 'data').textContent).toBe('C1') + + // test reset from the other component + getByTestId(c2, 'reset').click() + expect(getByTestId(c1, 'status').textContent).toBe('uninitialized') + expect(getByTestId(c1, 'data').textContent).toBe('') + expect(getByTestId(c2, 'status').textContent).toBe('uninitialized') + expect(getByTestId(c2, 'data').textContent).toBe('') + }) + + test('resetting from the component that triggered the mutation resets for each shared result', async () => { + render( + <> + + + + + , + { wrapper: storeRef.wrapper } + ) + const c1 = screen.getByTestId('C1') + const c2 = screen.getByTestId('C2') + const c3 = screen.getByTestId('C3') + const c4 = screen.getByTestId('C4') + expect(getByTestId(c1, 'status').textContent).toBe('uninitialized') + expect(getByTestId(c2, 'status').textContent).toBe('uninitialized') + expect(getByTestId(c3, 'status').textContent).toBe('uninitialized') + expect(getByTestId(c4, 'status').textContent).toBe('uninitialized') + + // trigger with a component using the first cache key + getByTestId(c1, 'trigger').click() + + await waitFor(() => + expect(getByTestId(c1, 'status').textContent).toBe('fulfilled') + ) + + // the components with the first cache key should be affected + expect(getByTestId(c1, 'data').textContent).toBe('C1') + expect(getByTestId(c2, 'status').textContent).toBe('fulfilled') + expect(getByTestId(c2, 'data').textContent).toBe('C1') + expect(getByTestId(c2, 'status').textContent).toBe('fulfilled') + + // the components with the second cache key should be unaffected + expect(getByTestId(c3, 'data').textContent).toBe('') + expect(getByTestId(c3, 'status').textContent).toBe('uninitialized') + expect(getByTestId(c4, 'data').textContent).toBe('') + expect(getByTestId(c4, 'status').textContent).toBe('uninitialized') + + // trigger with a component using the second cache key + getByTestId(c3, 'trigger').click() + + await waitFor(() => + expect(getByTestId(c3, 'status').textContent).toBe('fulfilled') + ) + + // the components with the first cache key should be unaffected + expect(getByTestId(c1, 'data').textContent).toBe('C1') + expect(getByTestId(c2, 'status').textContent).toBe('fulfilled') + expect(getByTestId(c2, 'data').textContent).toBe('C1') + expect(getByTestId(c2, 'status').textContent).toBe('fulfilled') + + // the component with the second cache key should be affected + expect(getByTestId(c3, 'data').textContent).toBe('C3') + expect(getByTestId(c3, 'status').textContent).toBe('fulfilled') + expect(getByTestId(c4, 'data').textContent).toBe('C3') + expect(getByTestId(c4, 'status').textContent).toBe('fulfilled') + + // test reset from the component that triggered the mutation for the first cache key + getByTestId(c1, 'reset').click() + + // the components with the first cache key should be affected + expect(getByTestId(c1, 'data').textContent).toBe('') + expect(getByTestId(c1, 'status').textContent).toBe('uninitialized') + expect(getByTestId(c2, 'data').textContent).toBe('') + expect(getByTestId(c2, 'status').textContent).toBe('uninitialized') + + // the components with the second cache key should be unaffected + expect(getByTestId(c3, 'data').textContent).toBe('C3') + expect(getByTestId(c3, 'status').textContent).toBe('fulfilled') + expect(getByTestId(c4, 'data').textContent).toBe('C3') + expect(getByTestId(c4, 'status').textContent).toBe('fulfilled') + }) + + test('two mutations with different `fixedCacheKey` do not influence each other', async () => { + render( + <> + + + , + { wrapper: storeRef.wrapper } + ) + const c1 = screen.getByTestId('C1') + const c2 = screen.getByTestId('C2') + expect(getByTestId(c1, 'status').textContent).toBe('uninitialized') + expect(getByTestId(c2, 'status').textContent).toBe('uninitialized') + + getByTestId(c1, 'trigger').click() + + await waitFor(() => + expect(getByTestId(c1, 'status').textContent).toBe('fulfilled') + ) + expect(getByTestId(c1, 'data').textContent).toBe('C1') + expect(getByTestId(c2, 'status').textContent).toBe('uninitialized') + }) + + test('unmounting and remounting keeps data intact', async () => { + const { rerender } = render(, { + wrapper: storeRef.wrapper, + }) + let c1 = screen.getByTestId('C1') + expect(getByTestId(c1, 'status').textContent).toBe('uninitialized') + + getByTestId(c1, 'trigger').click() + + await waitFor(() => + expect(getByTestId(c1, 'status').textContent).toBe('fulfilled') + ) + expect(getByTestId(c1, 'data').textContent).toBe('C1') + + rerender(
) + expect(screen.queryByTestId('C1')).toBe(null) + + rerender() + c1 = screen.getByTestId('C1') + expect(getByTestId(c1, 'status').textContent).toBe('fulfilled') + expect(getByTestId(c1, 'data').textContent).toBe('C1') + }) + + test('(limitation) mutations using `fixedCacheKey` do not return `originalArgs`', async () => { + render( + <> + + + , + { wrapper: storeRef.wrapper } + ) + const c1 = screen.getByTestId('C1') + const c2 = screen.getByTestId('C2') + expect(getByTestId(c1, 'status').textContent).toBe('uninitialized') + expect(getByTestId(c2, 'status').textContent).toBe('uninitialized') + + getByTestId(c1, 'trigger').click() + + await waitFor(() => + expect(getByTestId(c1, 'status').textContent).toBe('fulfilled') + ) + expect(getByTestId(c1, 'data').textContent).toBe('C1') + expect(getByTestId(c2, 'status').textContent).toBe('fulfilled') + expect(getByTestId(c2, 'data').textContent).toBe('C1') + }) + + test('a component without `fixedCacheKey` has `originalArgs`', async () => { + render(, { wrapper: storeRef.wrapper }) + let c1 = screen.getByTestId('C1') + expect(getByTestId(c1, 'status').textContent).toBe('uninitialized') + expect(getByTestId(c1, 'originalArgs').textContent).toBe('undefined') + + getByTestId(c1, 'trigger').click() + + expect(getByTestId(c1, 'originalArgs').textContent).toBe('C1') + }) + + test('a component with `fixedCacheKey` does never have `originalArgs`', async () => { + render(, { + wrapper: storeRef.wrapper, + }) + let c1 = screen.getByTestId('C1') + expect(getByTestId(c1, 'status').textContent).toBe('uninitialized') + expect(getByTestId(c1, 'originalArgs').textContent).toBe('undefined') + + getByTestId(c1, 'trigger').click() + + expect(getByTestId(c1, 'originalArgs').textContent).toBe('undefined') + }) + + test('using `fixedCacheKey` will always use the latest dispatched thunk, prevent races', async () => { + let resolve1: (str: string) => void, resolve2: (str: string) => void + const p1 = new Promise((resolve) => { + resolve1 = resolve + }) + const p2 = new Promise((resolve) => { + resolve2 = resolve + }) + render( + <> + + + , + { wrapper: storeRef.wrapper } + ) + const c1 = screen.getByTestId('C1') + const c2 = screen.getByTestId('C2') + expect(getByTestId(c1, 'status').textContent).toBe('uninitialized') + expect(getByTestId(c2, 'status').textContent).toBe('uninitialized') + + getByTestId(c1, 'trigger').click() + + expect(getByTestId(c1, 'status').textContent).toBe('pending') + expect(getByTestId(c1, 'data').textContent).toBe('') + + getByTestId(c2, 'trigger').click() + + expect(getByTestId(c1, 'status').textContent).toBe('pending') + expect(getByTestId(c1, 'data').textContent).toBe('') + + resolve1!('this should not show up any more') + + await waitMs() + + expect(getByTestId(c1, 'status').textContent).toBe('pending') + expect(getByTestId(c1, 'data').textContent).toBe('') + + resolve2!('this should be visible') + + await waitMs() + + expect(getByTestId(c1, 'status').textContent).toBe('fulfilled') + expect(getByTestId(c1, 'data').textContent).toBe('this should be visible') + }) +}) diff --git a/packages/toolkit/src/tests/configureStore.typetest.ts b/packages/toolkit/src/tests/configureStore.typetest.ts index d6d8d004d..a956c9cbf 100644 --- a/packages/toolkit/src/tests/configureStore.typetest.ts +++ b/packages/toolkit/src/tests/configureStore.typetest.ts @@ -2,7 +2,11 @@ import type { Dispatch, AnyAction, Middleware, Reducer, Store } from 'redux' import { applyMiddleware } from 'redux' import type { PayloadAction } from '@reduxjs/toolkit' -import { configureStore, getDefaultMiddleware } from '@reduxjs/toolkit' +import { + configureStore, + getDefaultMiddleware, + createSlice, +} from '@reduxjs/toolkit' import type { ThunkMiddleware, ThunkAction } from 'redux-thunk' import thunk, { ThunkDispatch } from 'redux-thunk' import { expectNotAny, expectType } from './helpers' @@ -187,6 +191,60 @@ const _anyMiddleware: any = () => () => () => {} store.dispatch(thunkA()) // @ts-expect-error store.dispatch(thunkB()) + + const res = store.dispatch((dispatch, getState) => { + return 42 + }) + + const action = store.dispatch({ type: 'foo' }) + } + /** + * Test: return type of thunks and actions is inferred correctly + */ + { + const slice = createSlice({ + name: 'counter', + initialState: { + value: 0, + }, + reducers: { + incrementByAmount: (state, action: PayloadAction) => { + state.value += action.payload + }, + }, + }) + + const store = configureStore({ + reducer: { + counter: slice.reducer, + }, + }) + + const action = slice.actions.incrementByAmount(2) + + const dispatchResult = store.dispatch(action) + expectType<{ type: string; payload: number }>(dispatchResult) + + const promiseResult = store.dispatch(async (dispatch) => { + return 42 + }) + + expectType>(promiseResult) + + const store2 = configureStore({ + reducer: { + counter: slice.reducer, + }, + middleware: (gDM) => + gDM({ + thunk: { + extraArgument: 42, + }, + }), + }) + + const dispatchResult2 = store2.dispatch(action) + expectType<{ type: string; payload: number }>(dispatchResult2) } /** * Test: removing the Thunk Middleware diff --git a/packages/toolkit/src/tests/createAsyncThunk.test.ts b/packages/toolkit/src/tests/createAsyncThunk.test.ts index d84dc8c62..6539d7323 100644 --- a/packages/toolkit/src/tests/createAsyncThunk.test.ts +++ b/packages/toolkit/src/tests/createAsyncThunk.test.ts @@ -595,6 +595,32 @@ describe('conditional skipping of asyncThunks', () => { ) }) + test('pending is dispatched synchronously if condition is synchronous', async () => { + const condition = () => true + const asyncThunk = createAsyncThunk('test', payloadCreator, { condition }) + const thunkCallPromise = asyncThunk(arg)(dispatch, getState, extra) + expect(dispatch).toHaveBeenCalledTimes(1) + await thunkCallPromise + expect(dispatch).toHaveBeenCalledTimes(2) + }) + + test('async condition', async () => { + const condition = () => Promise.resolve(false) + const asyncThunk = createAsyncThunk('test', payloadCreator, { condition }) + await asyncThunk(arg)(dispatch, getState, extra) + expect(dispatch).toHaveBeenCalledTimes(0) + }) + + test('async condition with rejected promise', async () => { + const condition = () => Promise.reject() + const asyncThunk = createAsyncThunk('test', payloadCreator, { condition }) + await asyncThunk(arg)(dispatch, getState, extra) + expect(dispatch).toHaveBeenCalledTimes(1) + expect(dispatch).toHaveBeenLastCalledWith( + expect.objectContaining({ type: 'test/rejected' }) + ) + }) + test('rejected action is not dispatched by default', async () => { const asyncThunk = createAsyncThunk('test', payloadCreator, { condition }) await asyncThunk(arg)(dispatch, getState, extra) @@ -644,38 +670,39 @@ describe('conditional skipping of asyncThunks', () => { }) ) }) +}) - test('serializeError implementation', async () => { - function serializeError() { - return 'serialized!' - } - const errorObject = 'something else!' +test('serializeError implementation', async () => { + function serializeError() { + return 'serialized!' + } + const errorObject = 'something else!' - const store = configureStore({ - reducer: (state = [], action) => [...state, action], - }) + const store = configureStore({ + reducer: (state = [], action) => [...state, action], + }) - const asyncThunk = createAsyncThunk< - unknown, - void, - { serializedErrorType: string } - >('test', () => Promise.reject(errorObject), { serializeError }) - const rejected = await store.dispatch(asyncThunk()) - if (!asyncThunk.rejected.match(rejected)) { - throw new Error() - } + const asyncThunk = createAsyncThunk< + unknown, + void, + { serializedErrorType: string } + >('test', () => Promise.reject(errorObject), { serializeError }) + const rejected = await store.dispatch(asyncThunk()) + if (!asyncThunk.rejected.match(rejected)) { + throw new Error() + } - const expectation = { - type: 'test/rejected', - payload: undefined, - error: 'serialized!', - meta: expect.any(Object), - } - expect(rejected).toEqual(expectation) - expect(store.getState()[2]).toEqual(expectation) - expect(rejected.error).not.toEqual(miniSerializeError(errorObject)) - }) + const expectation = { + type: 'test/rejected', + payload: undefined, + error: 'serialized!', + meta: expect.any(Object), + } + expect(rejected).toEqual(expectation) + expect(store.getState()[2]).toEqual(expectation) + expect(rejected.error).not.toEqual(miniSerializeError(errorObject)) }) + describe('unwrapResult', () => { const getState = jest.fn(() => ({})) const dispatch = jest.fn((x: any) => x) @@ -788,9 +815,29 @@ describe('idGenerator option', () => { expect.stringContaining('fake-fandom-id') ) }) + + test('idGenerator should be called with thunkArg', async () => { + const customIdGenerator = jest.fn((seed) => `fake-unique-random-id-${seed}`) + let generatedRequestId = '' + const asyncThunk = createAsyncThunk( + 'test', + async (args: any, { requestId }) => { + generatedRequestId = requestId + }, + { idGenerator: customIdGenerator } + ) + + const thunkArg = 1 + const expected = 'fake-unique-random-id-1' + const asyncThunkPromise = asyncThunk(thunkArg)(dispatch, getState, extra) + + expect(customIdGenerator).toHaveBeenCalledWith(thunkArg) + expect(asyncThunkPromise.requestId).toEqual(expected) + expect((await asyncThunkPromise).meta.requestId).toEqual(expected) + }) }) -test('`condition` will see state changes from a synchonously invoked asyncThunk', () => { +test('`condition` will see state changes from a synchronously invoked asyncThunk', () => { type State = ReturnType const onStart = jest.fn() const asyncThunk = createAsyncThunk< diff --git a/packages/toolkit/src/tests/createAsyncThunk.typetest.ts b/packages/toolkit/src/tests/createAsyncThunk.typetest.ts index 687639e22..98cfa93a1 100644 --- a/packages/toolkit/src/tests/createAsyncThunk.typetest.ts +++ b/packages/toolkit/src/tests/createAsyncThunk.typetest.ts @@ -7,7 +7,10 @@ import type { AxiosError } from 'axios' import apiRequest from 'axios' import type { IsAny, IsUnknown } from '@internal/tsHelpers' import { expectType } from './helpers' -import { AsyncThunkPayloadCreator } from '@internal/createAsyncThunk' +import type { + AsyncThunkFulfilledActionCreator, + AsyncThunkRejectedActionCreator, +} from '@internal/createAsyncThunk' const defaultDispatch = (() => {}) as ThunkDispatch<{}, any, AnyAction> const anyAction = { type: 'foo' } as AnyAction @@ -147,6 +150,34 @@ const anyAction = { type: 'foo' } as AnyAction }) })() +/** + * Should handle reject withvalue within a try catch block + * + * Note: + * this is a sample code taken from #1605 + * + */ +;(async () => { + type ResultType = { + text: string + } + const demoPromise = async (): Promise => + new Promise((resolve, _) => resolve({ text: '' })) + const thunk = createAsyncThunk('thunk', async (args, thunkAPI) => { + try { + const result = await demoPromise() + return result + } catch (error) { + return thunkAPI.rejectWithValue(error) + } + }) + createReducer({}, (builder) => + builder.addCase(thunk.fulfilled, (s, action) => { + expectType(action.payload) + }) + ) +})() + { interface Item { name: string @@ -394,6 +425,44 @@ const anyAction = { type: 'foo' } as AnyAction expectType>(thunk) } +// createAsyncThunk rejectWithValue without generics: Expect correct return type +{ + const asyncThunk = createAsyncThunk( + 'test', + (_: void, { rejectWithValue }) => { + try { + return Promise.resolve(true) + } catch (e) { + return rejectWithValue(e) + } + } + ) + + defaultDispatch(asyncThunk()) + .then((result) => { + if (asyncThunk.fulfilled.match(result)) { + expectType>>( + result + ) + expectType(result.payload) + // @ts-expect-error + expectType(result.error) + } else { + expectType>>( + result + ) + expectType(result.error) + expectType(result.payload) + } + + return result + }) + .then(unwrapResult) + .then((unwrapped) => { + expectType(unwrapped) + }) +} + { type Funky = { somethingElse: 'Funky!' } function funkySerializeError(err: any): Funky { @@ -433,10 +502,15 @@ const anyAction = { type: 'foo' } as AnyAction // @ts-expect-error const shouldFailNumWithoutArgs = createAsyncThunk('foo', () => {}, { idGenerator: returnsNumWithoutArgs }) - const returnsStrWithArgs = (foo: any) => 'foo' + const returnsStrWithNumberArg = (foo: number) => 'foo' // prettier-ignore // @ts-expect-error - const shouldFailStrArgs = createAsyncThunk('foo', () => {}, { idGenerator: returnsStrWithArgs }) + const shouldFailWrongArgs = createAsyncThunk('foo', (arg: string) => {}, { idGenerator: returnsStrWithNumberArg }) + + const returnsStrWithStringArg = (foo: string) => 'foo' + const shoulducceedCorrectArgs = createAsyncThunk('foo', (arg: string) => {}, { + idGenerator: returnsStrWithStringArg, + }) const returnsStrWithoutArgs = () => 'foo' const shouldSucceed = createAsyncThunk('foo', () => {}, { @@ -448,27 +522,49 @@ const anyAction = { type: 'foo' } as AnyAction { // return values createAsyncThunk<'ret', void, {}>('test', (_, api) => 'ret' as const) + createAsyncThunk<'ret', void, {}>('test', async (_, api) => 'ret' as const) createAsyncThunk<'ret', void, { fulfilledMeta: string }>('test', (_, api) => api.fulfillWithValue('ret' as const, '') ) + createAsyncThunk<'ret', void, { fulfilledMeta: string }>( + 'test', + async (_, api) => api.fulfillWithValue('ret' as const, '') + ) createAsyncThunk<'ret', void, { fulfilledMeta: string }>( 'test', // @ts-expect-error has to be a fulfilledWithValue call (_, api) => 'ret' as const ) + createAsyncThunk<'ret', void, { fulfilledMeta: string }>( + 'test', + // @ts-expect-error has to be a fulfilledWithValue call + async (_, api) => 'ret' as const + ) createAsyncThunk<'ret', void, { fulfilledMeta: string }>( 'test', // @ts-expect-error should only allow returning with 'test' (_, api) => api.fulfillWithValue(5, '') ) + createAsyncThunk<'ret', void, { fulfilledMeta: string }>( + 'test', // @ts-expect-error should only allow returning with 'test' + async (_, api) => api.fulfillWithValue(5, '') + ) // reject values createAsyncThunk<'ret', void, { rejectValue: string }>('test', (_, api) => api.rejectWithValue('ret') ) + createAsyncThunk<'ret', void, { rejectValue: string }>( + 'test', + async (_, api) => api.rejectWithValue('ret') + ) createAsyncThunk<'ret', void, { rejectValue: string; rejectedMeta: number }>( 'test', (_, api) => api.rejectWithValue('ret', 5) ) + createAsyncThunk<'ret', void, { rejectValue: string; rejectedMeta: number }>( + 'test', + async (_, api) => api.rejectWithValue('ret', 5) + ) createAsyncThunk<'ret', void, { rejectValue: string; rejectedMeta: number }>( 'test', (_, api) => api.rejectWithValue('ret', 5) @@ -478,9 +574,19 @@ const anyAction = { type: 'foo' } as AnyAction // @ts-expect-error wrong rejectedMeta type (_, api) => api.rejectWithValue('ret', '') ) + createAsyncThunk<'ret', void, { rejectValue: string; rejectedMeta: number }>( + 'test', + // @ts-expect-error wrong rejectedMeta type + async (_, api) => api.rejectWithValue('ret', '') + ) createAsyncThunk<'ret', void, { rejectValue: string; rejectedMeta: number }>( 'test', // @ts-expect-error wrong rejectValue type (_, api) => api.rejectWithValue(5, '') ) + createAsyncThunk<'ret', void, { rejectValue: string; rejectedMeta: number }>( + 'test', + // @ts-expect-error wrong rejectValue type + async (_, api) => api.rejectWithValue(5, '') + ) } diff --git a/packages/toolkit/src/tests/createReducer.test.ts b/packages/toolkit/src/tests/createReducer.test.ts index f9c2298a5..97985991e 100644 --- a/packages/toolkit/src/tests/createReducer.test.ts +++ b/packages/toolkit/src/tests/createReducer.test.ts @@ -98,8 +98,10 @@ describe('createReducer', () => { test('Freezes initial state', () => { const initialState = [{ text: 'Buy milk' }] const todosReducer = createReducer(initialState, {}) + const frozenInitialState = todosReducer(undefined, { type: 'dummy' }) - const mutateStateOutsideReducer = () => (initialState[0].text = 'edited') + const mutateStateOutsideReducer = () => + (frozenInitialState[0].text = 'edited') expect(mutateStateOutsideReducer).toThrowError( /Cannot assign to read only property/ ) @@ -132,6 +134,41 @@ describe('createReducer', () => { behavesLikeReducer(todosReducer) }) + describe('Accepts a lazy state init function to generate initial state', () => { + const addTodo: AddTodoReducer = (state, action) => { + const { newTodo } = action.payload + state.push({ ...newTodo, completed: false }) + } + + const toggleTodo: ToggleTodoReducer = (state, action) => { + const { index } = action.payload + const todo = state[index] + todo.completed = !todo.completed + } + + const lazyStateInit = () => [] as TodoState + + const todosReducer = createReducer(lazyStateInit, { + ADD_TODO: addTodo, + TOGGLE_TODO: toggleTodo, + }) + + behavesLikeReducer(todosReducer) + + it('Should only call the init function when `undefined` state is passed in', () => { + const spy = jest.fn().mockReturnValue(42) + + const dummyReducer = createReducer(spy, {}) + expect(spy).not.toHaveBeenCalled() + + dummyReducer(123, { type: 'dummy' }) + expect(spy).not.toHaveBeenCalled() + + const initialState = dummyReducer(undefined, { type: 'dummy' }) + expect(spy).toHaveBeenCalledTimes(1) + }) + }) + describe('given draft state from immer', () => { const addTodo: AddTodoReducer = (state, action) => { const { newTodo } = action.payload diff --git a/packages/toolkit/src/tests/createSlice.test.ts b/packages/toolkit/src/tests/createSlice.test.ts index 37492c34a..3aee1309d 100644 --- a/packages/toolkit/src/tests/createSlice.test.ts +++ b/packages/toolkit/src/tests/createSlice.test.ts @@ -63,6 +63,48 @@ describe('createSlice', () => { expect(caseReducers.increment).toBeTruthy() expect(typeof caseReducers.increment).toBe('function') }) + + it('getInitialState should return the state', () => { + const initialState = 42 + const slice = createSlice({ + name: 'counter', + initialState, + reducers: {}, + }) + + expect(slice.getInitialState()).toBe(initialState) + }) + }) + + describe('when initialState is a function', () => { + const initialState = () => ({ user: '' }) + + const { actions, reducer } = createSlice({ + reducers: { + setUserName: (state, action) => { + state.user = action.payload + }, + }, + initialState, + name: 'user', + }) + + it('should set the username', () => { + expect(reducer(undefined, actions.setUserName('eric'))).toEqual({ + user: 'eric', + }) + }) + + it('getInitialState should return the state', () => { + const initialState = () => 42 + const slice = createSlice({ + name: 'counter', + initialState, + reducers: {}, + }) + + expect(slice.getInitialState()).toBe(42) + }) }) describe('when mutating state object', () => { @@ -139,8 +181,8 @@ describe('createSlice', () => { }) test('prevents the same action type from being specified twice', () => { - expect(() => - createSlice({ + expect(() => { + const slice = createSlice({ name: 'counter', initialState: 0, reducers: {}, @@ -149,7 +191,8 @@ describe('createSlice', () => { .addCase('increment', (state) => state + 1) .addCase('increment', (state) => state + 1), }) - ).toThrowErrorMatchingInlineSnapshot( + slice.reducer(undefined, { type: 'unrelated' }) + }).toThrowErrorMatchingInlineSnapshot( `"addCase cannot be called with two reducers for the same action type"` ) }) @@ -227,4 +270,58 @@ describe('createSlice', () => { ) }) }) + + describe('circularity', () => { + test('extraReducers can reference each other circularly', () => { + const first = createSlice({ + name: 'first', + initialState: 'firstInitial', + reducers: { + something() { + return 'firstSomething' + }, + }, + extraReducers(builder) { + // eslint-disable-next-line @typescript-eslint/no-use-before-define + builder.addCase(second.actions.other, () => { + return 'firstOther' + }) + }, + }) + const second = createSlice({ + name: 'second', + initialState: 'secondInitial', + reducers: { + other() { + return 'secondOther' + }, + }, + extraReducers(builder) { + builder.addCase(first.actions.something, () => { + return 'secondSomething' + }) + }, + }) + + expect(first.reducer(undefined, { type: 'unrelated' })).toBe( + 'firstInitial' + ) + expect(first.reducer(undefined, first.actions.something())).toBe( + 'firstSomething' + ) + expect(first.reducer(undefined, second.actions.other())).toBe( + 'firstOther' + ) + + expect(second.reducer(undefined, { type: 'unrelated' })).toBe( + 'secondInitial' + ) + expect(second.reducer(undefined, first.actions.something())).toBe( + 'secondSomething' + ) + expect(second.reducer(undefined, second.actions.other())).toBe( + 'secondOther' + ) + }) + }) }) diff --git a/packages/toolkit/src/tests/isPlainObject.test.ts b/packages/toolkit/src/tests/isPlainObject.test.ts index 59b04e225..20a4d1839 100644 --- a/packages/toolkit/src/tests/isPlainObject.test.ts +++ b/packages/toolkit/src/tests/isPlainObject.test.ts @@ -20,5 +20,6 @@ describe('isPlainObject', () => { expect(isPlainObject(null)).toBe(false) expect(isPlainObject(undefined)).toBe(false) expect(isPlainObject({ x: 1, y: 2 })).toBe(true) + expect(isPlainObject(Object.create(null))).toBe(true) }) }) diff --git a/website/_redirects b/website/_redirects index f488937c1..a3f7a9dd7 100644 --- a/website/_redirects +++ b/website/_redirects @@ -11,3 +11,4 @@ # Relocated content /rtk-query/usage/optimistic-updates /rtk-query/usage/manual-cache-updates#optimistic-updates +/rtk-query/api/created-api/cache-management-utils /rtk-query/api/created-api/api-slice-utils diff --git a/website/sidebars.json b/website/sidebars.json index d15291f57..5b2e07786 100644 --- a/website/sidebars.json +++ b/website/sidebars.json @@ -93,6 +93,8 @@ "rtk-query/usage/streaming-updates", "rtk-query/usage/code-splitting", "rtk-query/usage/code-generation", + "rtk-query/usage/server-side-rendering", + "rtk-query/usage/persistence-and-rehydration", "rtk-query/usage/customizing-create-api", "rtk-query/usage/customizing-queries", "rtk-query/usage/usage-without-react-hooks", @@ -118,7 +120,7 @@ "rtk-query/api/created-api/redux-integration", "rtk-query/api/created-api/endpoints", "rtk-query/api/created-api/code-splitting", - "rtk-query/api/created-api/cache-management-utils", + "rtk-query/api/created-api/api-slice-utils", "rtk-query/api/created-api/hooks" ] } diff --git a/yarn.lock b/yarn.lock index 8728e0fc5..042e957da 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5314,7 +5314,27 @@ __metadata: languageName: node linkType: hard -"@reduxjs/toolkit@^1.6.0, @reduxjs/toolkit@^1.6.0-rc.1, @reduxjs/toolkit@workspace:packages/toolkit": +"@reduxjs/toolkit@npm:^1.6.0, @reduxjs/toolkit@npm:^1.6.0-rc.1": + version: 1.6.2 + resolution: "@reduxjs/toolkit@npm:1.6.2" + dependencies: + immer: ^9.0.6 + redux: ^4.1.0 + redux-thunk: ^2.3.0 + reselect: ^4.0.0 + peerDependencies: + react: ^16.14.0 || ^17.0.0 + react-redux: ^7.2.1 + peerDependenciesMeta: + react: + optional: true + react-redux: + optional: true + checksum: 895e30518dd1552c115c8a06091856f1f60394a971d214eba5c6d7fc2053c5bc2730bc172ba88aead25d240e99a25c94c3621159823bbc0d0109dc9365db456d + languageName: node + linkType: hard + +"@reduxjs/toolkit@workspace:packages/toolkit": version: 0.0.0-use.local resolution: "@reduxjs/toolkit@workspace:packages/toolkit" dependencies: @@ -5328,6 +5348,7 @@ __metadata: "@types/json-stringify-safe": ^5.0.0 "@types/nanoid": ^2.1.0 "@types/node": ^10.14.4 + "@types/query-string": ^6.3.0 "@types/react": ^17.0.3 "@types/react-dom": ^17.0.3 "@types/react-redux": ^7.1.16 @@ -5349,7 +5370,7 @@ __metadata: eslint-plugin-react: ^7.23.2 eslint-plugin-react-hooks: ^4.2.0 fs-extra: ^9.1.0 - immer: ^9.0.6 + immer: ^9.0.7 invariant: ^2.2.4 jest: ^26.6.3 json-stringify-safe: ^5.0.1 @@ -5358,9 +5379,10 @@ __metadata: msw: ^0.28.2 node-fetch: ^2.6.1 prettier: ^2.2.1 - redux: ^4.1.0 - redux-thunk: ^2.3.0 - reselect: ^4.0.0 + query-string: ^7.0.1 + redux: ^4.1.2 + redux-thunk: ^2.4.1 + reselect: ^4.1.5 rimraf: ^3.0.2 rollup: ^2.47.0 rollup-plugin-strip-code: ^0.2.6 @@ -5372,8 +5394,8 @@ __metadata: typescript: ~4.2.4 yargs: ^15.3.1 peerDependencies: - react: ^16.14.0 || ^17.0.0 - react-redux: ^7.2.1 + react: ^16.9.0 || ^17.0.0 || 18.0.0-beta + react-redux: ^7.2.1 || ^8.0.0-beta peerDependenciesMeta: react: optional: true @@ -6497,6 +6519,15 @@ __metadata: languageName: node linkType: hard +"@types/query-string@npm:^6.3.0": + version: 6.3.0 + resolution: "@types/query-string@npm:6.3.0" + dependencies: + query-string: "*" + checksum: 7d507aea24e650548bc8a164ae695deb1eaf7a566fdaeb53ec3f89605dbd2c5201daf9b76c31843bc8accc01e866df8b2a06557514ad8014c3af99c664cd0b5c + languageName: node + linkType: hard + "@types/react-dom@npm:17.0.0": version: 17.0.0 resolution: "@types/react-dom@npm:17.0.0" @@ -11242,6 +11273,8 @@ __metadata: graphql-request: ^3.4.0 immutable: ^3.8.2 nanoid: ^3.1.23 + next-redux-wrapper: ^7.0.5 + redux-persist: ^6.0.0 rxjs: ^6.6.2 languageName: unknown linkType: soft @@ -12978,6 +13011,13 @@ __metadata: languageName: node linkType: hard +"filter-obj@npm:^1.1.0": + version: 1.1.0 + resolution: "filter-obj@npm:1.1.0" + checksum: cf2104a7c45ff48e7f505b78a3991c8f7f30f28bd8106ef582721f321f1c6277f7751aacd5d83026cb079d9d5091082f588d14a72e7c5d720ece79118fa61e10 + languageName: node + linkType: hard + "finalhandler@npm:~1.1.2": version: 1.1.2 resolution: "finalhandler@npm:1.1.2" @@ -14585,6 +14625,13 @@ fsevents@^1.2.7: languageName: node linkType: hard +"immer@npm:^9.0.7": + version: 9.0.7 + resolution: "immer@npm:9.0.7" + checksum: 3655ad64bf5ab5adf2854f7d2a9ad543f2cd995fcd169b6f10294f41fdb2cbcbd44d8beaa3e01b3c0b6149001190e57f6ab2cd735e6a929780b7462f2e973c9b + languageName: node + linkType: hard + "immutable@npm:^3.8.2": version: 3.8.2 resolution: "immutable@npm:3.8.2" @@ -18122,6 +18169,17 @@ fsevents@^1.2.7: languageName: node linkType: hard +"next-redux-wrapper@npm:^7.0.5": + version: 7.0.5 + resolution: "next-redux-wrapper@npm:7.0.5" + peerDependencies: + next: ">=10.0.3" + react: "*" + react-redux: "*" + checksum: 064e3d562954a13128f94fec6420c802f819f763db8157bd57108e2549ef8597db72e77f3e53a36cfb3c661a3d370227578ec86d1aeca1b3ebfa9009db680c69 + languageName: node + linkType: hard + "next-tick@npm:~1.0.0": version: 1.0.0 resolution: "next-tick@npm:1.0.0" @@ -21165,6 +21223,18 @@ fsevents@^1.2.7: languageName: node linkType: hard +"query-string@npm:*, query-string@npm:^7.0.1": + version: 7.0.1 + resolution: "query-string@npm:7.0.1" + dependencies: + decode-uri-component: ^0.2.0 + filter-obj: ^1.1.0 + split-on-first: ^1.0.0 + strict-uri-encode: ^2.0.0 + checksum: 2eb990c0eaa80998d074aac2ad5bcc7f21fa2e53a7d129d19883abe724a2eedb987ca81b731755307431914b0f958767bfe7c5f7433d0974a1650b5d313e5618 + languageName: node + linkType: hard + "query-string@npm:^4.1.0": version: 4.3.4 resolution: "query-string@npm:4.3.4" @@ -21943,19 +22013,39 @@ fsevents@^1.2.7: languageName: node linkType: hard +"redux-persist@npm:^6.0.0": + version: 6.0.0 + resolution: "redux-persist@npm:6.0.0" + peerDependencies: + redux: ">4.0.0" + checksum: edaf10dbf17351ce8058d0802357adae8665b3a1ff39371834e37838ddbe1a79cccbc717b8ba54acb5307651ccf51d0f7dc1cbc8dbae0726ff952d11ef61c6b8 + languageName: node + linkType: hard + "redux-thunk@npm:^2.3.0": - version: 2.3.0 - resolution: "redux-thunk@npm:2.3.0" - checksum: d13f442ffc91249b534bf14884c33feff582894be2562169637dc9d4d70aec6423bfe6d66f88c46ac027ac1c0cd07d6c2dd4a61cf7695b8e43491de679df9bcf + version: 2.4.0 + resolution: "redux-thunk@npm:2.4.0" + peerDependencies: + redux: ^4 + checksum: 250cd88087bb4614052a5175fd6bd4c70b6a4479c357af8628b3a1d5f75d5b0a6c01645acc3257d3ed147a949708dd748a50b00402d548c3331038ed4f296edc languageName: node linkType: hard -"redux@npm:^4.0.0, redux@npm:^4.1.0": - version: 4.1.0 - resolution: "redux@npm:4.1.0" +"redux-thunk@npm:^2.4.1": + version: 2.4.1 + resolution: "redux-thunk@npm:2.4.1" + peerDependencies: + redux: ^4 + checksum: af5abb425fb9dccda02e5f387d6f3003997f62d906542a3d35fc9420088f550dc1a018bdc246c7d23ee852b4d4ab8b5c64c5be426e45a328d791c4586a3c6b6e + languageName: node + linkType: hard + +"redux@npm:^4.0.0, redux@npm:^4.1.0, redux@npm:^4.1.2": + version: 4.1.2 + resolution: "redux@npm:4.1.2" dependencies: "@babel/runtime": ^7.9.2 - checksum: 322d5f4b49cbbdb3f64f04e9279cabbdea9a698024b530dc98563eb598b6bd55ff8a715208e3ee09db9802a2f426c991c78906b1c6491ebb52e7310e55ee5cdf + checksum: 6a839cee5bd580c5298d968e9e2302150e961318253819bcd97f9d945a5a409559eacddf6026f4118bb68b681c593d90e8a2c5bbf278f014aff9bf0d2d8fa084 languageName: node linkType: hard @@ -22346,9 +22436,16 @@ fsevents@^1.2.7: linkType: hard "reselect@npm:^4.0.0": - version: 4.0.0 - resolution: "reselect@npm:4.0.0" - checksum: ac7dfc9ef2cdb42b6fc87a856f3ce904c2e4363a2bc1e6fb7eea5f78902a6f506e4388e6509752984877c6dbfe501100c076671d334799eb5a1bfe9936cb2c12 + version: 4.1.2 + resolution: "reselect@npm:4.1.2" + checksum: 5a702af37e7fa5e58e8b0787b8a1668df2eff527f1eb2cb2bd81416db6947064a922b41f28a522016df6e5e2dbc5f8588ff0749dcf3c06daae0e0cc3baffec99 + languageName: node + linkType: hard + +"reselect@npm:^4.1.5": + version: 4.1.5 + resolution: "reselect@npm:4.1.5" + checksum: 54c13c1e795b2ea70cba8384138aebe78adda00cbea303cc94b64da0a70d74c896cc9a03115ae38b8bff990e7a60dcd6452ab68cbec01b0b38c1afda70714cf0 languageName: node linkType: hard @@ -23850,6 +23947,13 @@ resolve@~1.19.0: languageName: node linkType: hard +"split-on-first@npm:^1.0.0": + version: 1.1.0 + resolution: "split-on-first@npm:1.1.0" + checksum: 16ff85b54ddcf17f9147210a4022529b343edbcbea4ce977c8f30e38408b8d6e0f25f92cd35b86a524d4797f455e29ab89eb8db787f3c10708e0b47ebf528d30 + languageName: node + linkType: hard + "split-string@npm:^3.0.1, split-string@npm:^3.0.2": version: 3.1.0 resolution: "split-string@npm:3.1.0" @@ -24028,6 +24132,13 @@ resolve@~1.19.0: languageName: node linkType: hard +"strict-uri-encode@npm:^2.0.0": + version: 2.0.0 + resolution: "strict-uri-encode@npm:2.0.0" + checksum: eaac4cf978b6fbd480f1092cab8b233c9b949bcabfc9b598dd79a758f7243c28765ef7639c876fa72940dac687181b35486ea01ff7df3e65ce3848c64822c581 + languageName: node + linkType: hard + "string-argv@npm:~0.3.1": version: 0.3.1 resolution: "string-argv@npm:0.3.1"