diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5486079d76..d18385c584 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -111,7 +111,7 @@ jobs: fail-fast: false matrix: node: ['20.x'] - ts: ['4.7', '4.8', '4.9', '5.0', '5.1', '5.2', '5.3', '5.4', '5.5'] + ts: ['5.0', '5.1', '5.2', '5.3', '5.4', '5.5'] steps: - name: Checkout repo uses: actions/checkout@v4 diff --git a/docs/rtk-query/api/fetchBaseQuery.mdx b/docs/rtk-query/api/fetchBaseQuery.mdx index 456ab0dc18..8f88b444ce 100644 --- a/docs/rtk-query/api/fetchBaseQuery.mdx +++ b/docs/rtk-query/api/fetchBaseQuery.mdx @@ -83,14 +83,60 @@ type FetchBaseQueryResult = Promise< meta?: { request: Request; response: Response } } | { - error: { - status: number - data: any - } + error: FetchBaseQueryError data?: undefined meta?: { request: Request; response: Response } } > + +type FetchBaseQueryError = + | { + /** + * * `number`: + * HTTP status code + */ + status: number + data: unknown + } + | { + /** + * * `"FETCH_ERROR"`: + * An error that occurred during execution of `fetch` or the `fetchFn` callback option + **/ + status: 'FETCH_ERROR' + data?: undefined + error: string + } + | { + /** + * * `"PARSING_ERROR"`: + * An error happened during parsing. + * Most likely a non-JSON-response was returned with the default `responseHandler` "JSON", + * or an error occurred while executing a custom `responseHandler`. + **/ + status: 'PARSING_ERROR' + originalStatus: number + data: string + error: string + } + | { + /** + * * `"TIMEOUT_ERROR"`: + * Request timed out + **/ + status: 'TIMEOUT_ERROR' + data?: undefined + error: string + } + | { + /** + * * `"CUSTOM_ERROR"`: + * A custom error type that you can return from your `queryFn` where another error might not make sense. + **/ + status: 'CUSTOM_ERROR' + data?: unknown + error: string + } ``` ## Parameters diff --git a/docs/rtk-query/usage/customizing-queries.mdx b/docs/rtk-query/usage/customizing-queries.mdx index 092f990687..d3f716df0d 100644 --- a/docs/rtk-query/usage/customizing-queries.mdx +++ b/docs/rtk-query/usage/customizing-queries.mdx @@ -636,7 +636,7 @@ const staggeredBaseQueryWithBailOut = retry( // bail out of re-tries immediately if unauthorized, // because we know successive re-retries would be redundant if (result.error?.status === 401) { - retry.fail(result.error) + retry.fail(result.error, result.meta) } return result diff --git a/examples/query/react/basic/package.json b/examples/query/react/basic/package.json index 709c27b6c8..fc44afac89 100644 --- a/examples/query/react/basic/package.json +++ b/examples/query/react/basic/package.json @@ -13,7 +13,8 @@ "react-scripts": "5.0.1" }, "devDependencies": { - "@testing-library/react": "^13.3.0", + "@testing-library/dom": "^10.4.0", + "@testing-library/react": "^16.0.1", "@types/jest": "^26.0.23", "@types/react": "^18.0.5", "@types/react-dom": "^18.0.5", diff --git a/examples/query/react/kitchen-sink/package.json b/examples/query/react/kitchen-sink/package.json index 2e1675c6fc..c0e5794127 100644 --- a/examples/query/react/kitchen-sink/package.json +++ b/examples/query/react/kitchen-sink/package.json @@ -15,8 +15,9 @@ "react-scripts": "5.0.1" }, "devDependencies": { + "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^5.11.5", - "@testing-library/react": "^13.3.0", + "@testing-library/react": "^16.0.1", "@types/jest": "^26.0.23", "@types/node": "^14.14.6", "@types/react": "^18.0.5", diff --git a/packages/toolkit/package.json b/packages/toolkit/package.json index 2b765b1e6a..dee00cc37a 100644 --- a/packages/toolkit/package.json +++ b/packages/toolkit/package.json @@ -1,6 +1,6 @@ { "name": "@reduxjs/toolkit", - "version": "2.3.0", + "version": "2.4.0", "description": "The official, opinionated, batteries-included toolset for efficient Redux development", "author": "Mark Erikson ", "license": "MIT", @@ -56,8 +56,9 @@ "@phryneas/ts-version": "^1.0.2", "@size-limit/file": "^11.0.1", "@size-limit/webpack": "^11.0.1", - "@testing-library/react": "^13.3.0", - "@testing-library/user-event": "^13.1.5", + "@testing-library/dom": "^10.4.0", + "@testing-library/react": "^16.0.1", + "@testing-library/user-event": "^14.5.2", "@types/babel__core": "^7.20.5", "@types/babel__helper-module-imports": "^7.18.3", "@types/json-stringify-safe": "^5.0.0", diff --git a/packages/toolkit/src/autoBatchEnhancer.ts b/packages/toolkit/src/autoBatchEnhancer.ts index 7479d49ef5..ab2d58d614 100644 --- a/packages/toolkit/src/autoBatchEnhancer.ts +++ b/packages/toolkit/src/autoBatchEnhancer.ts @@ -15,13 +15,6 @@ const createQueueWithTimer = (timeout: number) => { } } -// requestAnimationFrame won't exist in SSR environments. -// Fall back to a vague approximation just to keep from erroring. -const rAF = - typeof window !== 'undefined' && window.requestAnimationFrame - ? window.requestAnimationFrame - : createQueueWithTimer(10) - export type AutoBatchOptions = | { type: 'tick' } | { type: 'timer'; timeout: number } @@ -66,7 +59,10 @@ export const autoBatchEnhancer = options.type === 'tick' ? queueMicrotask : options.type === 'raf' - ? rAF + ? // requestAnimationFrame won't exist in SSR environments. Fall back to a vague approximation just to keep from erroring. + typeof window !== 'undefined' && window.requestAnimationFrame + ? window.requestAnimationFrame + : createQueueWithTimer(10) : options.type === 'callback' ? options.queueNotification : createQueueWithTimer(options.timeout) diff --git a/packages/toolkit/src/combineSlices.ts b/packages/toolkit/src/combineSlices.ts index 26ff173e7d..a5353af937 100644 --- a/packages/toolkit/src/combineSlices.ts +++ b/packages/toolkit/src/combineSlices.ts @@ -8,7 +8,7 @@ import type { UnionToIntersection, WithOptionalProp, } from './tsHelpers' -import { emplace } from './utils' +import { getOrInsertComputed } from './utils' type SliceLike = { reducerPath: ReducerPath @@ -324,8 +324,10 @@ const createStateProxy = ( state: State, reducerMap: Partial>, ) => - emplace(stateProxyMap, state, { - insert: () => + getOrInsertComputed( + stateProxyMap, + state, + () => new Proxy(state, { get: (target, prop, receiver) => { if (prop === ORIGINAL_STATE) return target @@ -350,7 +352,7 @@ const createStateProxy = ( return result }, }), - }) as State + ) as State const original = (state: any) => { if (!isStateProxy(state)) { diff --git a/packages/toolkit/src/createAsyncThunk.ts b/packages/toolkit/src/createAsyncThunk.ts index 4534165869..733003e6d4 100644 --- a/packages/toolkit/src/createAsyncThunk.ts +++ b/packages/toolkit/src/createAsyncThunk.ts @@ -437,7 +437,9 @@ export type OverrideThunkApiConfigs = Id< NewConfig & Omit > -type CreateAsyncThunk = { +export type CreateAsyncThunkFunction< + CurriedThunkApiConfig extends AsyncThunkConfig, +> = { /** * * @param typePrefix @@ -481,12 +483,15 @@ type CreateAsyncThunk = { ThunkArg, OverrideThunkApiConfigs > - - withTypes(): CreateAsyncThunk< - OverrideThunkApiConfigs - > } +type CreateAsyncThunk = + CreateAsyncThunkFunction & { + withTypes(): CreateAsyncThunk< + OverrideThunkApiConfigs + > + } + export const createAsyncThunk = /* @__PURE__ */ (() => { function createAsyncThunk< Returned, diff --git a/packages/toolkit/src/createSlice.ts b/packages/toolkit/src/createSlice.ts index e3d2c25c56..1d4f3e3712 100644 --- a/packages/toolkit/src/createSlice.ts +++ b/packages/toolkit/src/createSlice.ts @@ -26,7 +26,7 @@ import { createReducer } from './createReducer' import type { ActionReducerMapBuilder, TypedActionCreator } from './mapBuilders' import { executeReducerBuilderCallback } from './mapBuilders' import type { Id, TypeGuard } from './tsHelpers' -import { emplace } from './utils' +import { getOrInsertComputed } from './utils' const asyncThunkSymbol = /* @__PURE__ */ Symbol.for( 'rtk-slice-createasyncthunk', @@ -769,25 +769,25 @@ export function buildCreateSlice({ creators }: BuildCreateSliceConfig = {}) { function getSelectors( selectState: (rootState: any) => State = selectSelf, ) { - const selectorCache = emplace(injectedSelectorCache, injected, { - insert: () => new WeakMap(), - }) - - return emplace(selectorCache, selectState, { - insert: () => { - const map: Record> = {} - for (const [name, selector] of Object.entries( - options.selectors ?? {}, - )) { - map[name] = wrapSelector( - selector, - selectState, - getInitialState, - injected, - ) - } - return map - }, + const selectorCache = getOrInsertComputed( + injectedSelectorCache, + injected, + () => new WeakMap(), + ) + + return getOrInsertComputed(selectorCache, selectState, () => { + const map: Record> = {} + for (const [name, selector] of Object.entries( + options.selectors ?? {}, + )) { + map[name] = wrapSelector( + selector, + selectState, + getInitialState, + injected, + ) + } + return map }) as any } return { diff --git a/packages/toolkit/src/dynamicMiddleware/index.ts b/packages/toolkit/src/dynamicMiddleware/index.ts index ed151b2979..8e61d6769b 100644 --- a/packages/toolkit/src/dynamicMiddleware/index.ts +++ b/packages/toolkit/src/dynamicMiddleware/index.ts @@ -3,7 +3,7 @@ import { compose } from 'redux' import { createAction } from '../createAction' import { isAllOf } from '../matchers' import { nanoid } from '../nanoid' -import { emplace, find } from '../utils' +import { getOrInsertComputed } from '../utils' import type { AddMiddleware, DynamicMiddleware, @@ -23,7 +23,6 @@ const createMiddlewareEntry = < >( middleware: Middleware, ): MiddlewareEntry => ({ - id: nanoid(), middleware, applied: new Map(), }) @@ -38,7 +37,10 @@ export const createDynamicMiddleware = < DispatchType extends Dispatch = Dispatch, >(): DynamicMiddlewareInstance => { const instanceId = nanoid() - const middlewareMap = new Map>() + const middlewareMap = new Map< + Middleware, + MiddlewareEntry + >() const withMiddleware = Object.assign( createAction( @@ -58,14 +60,7 @@ export const createDynamicMiddleware = < ...middlewares: Middleware[] ) { middlewares.forEach((middleware) => { - let entry = find( - Array.from(middlewareMap.values()), - (entry) => entry.middleware === middleware, - ) - if (!entry) { - entry = createMiddlewareEntry(middleware) - } - middlewareMap.set(entry.id, entry) + getOrInsertComputed(middlewareMap, middleware, createMiddlewareEntry) }) }, { withTypes: () => addMiddleware }, @@ -73,7 +68,7 @@ export const createDynamicMiddleware = < const getFinalMiddleware: Middleware<{}, State, DispatchType> = (api) => { const appliedMiddleware = Array.from(middlewareMap.values()).map((entry) => - emplace(entry.applied, api, { insert: () => entry.middleware(api) }), + getOrInsertComputed(entry.applied, api, entry.middleware), ) return compose(...appliedMiddleware) } diff --git a/packages/toolkit/src/dynamicMiddleware/types.ts b/packages/toolkit/src/dynamicMiddleware/types.ts index ee8c37a21b..989c7ffcc0 100644 --- a/packages/toolkit/src/dynamicMiddleware/types.ts +++ b/packages/toolkit/src/dynamicMiddleware/types.ts @@ -59,7 +59,6 @@ export type MiddlewareEntry< State = unknown, DispatchType extends Dispatch = Dispatch, > = { - id: string middleware: Middleware applied: Map< MiddlewareAPI, diff --git a/packages/toolkit/src/index.ts b/packages/toolkit/src/index.ts index 1883310dc2..65291ab83b 100644 --- a/packages/toolkit/src/index.ts +++ b/packages/toolkit/src/index.ts @@ -133,6 +133,7 @@ export type { GetState, GetThunkAPI, SerializedError, + CreateAsyncThunkFunction, } from './createAsyncThunk' export { diff --git a/packages/toolkit/src/listenerMiddleware/index.ts b/packages/toolkit/src/listenerMiddleware/index.ts index efa2912ad3..cfefa17e09 100644 --- a/packages/toolkit/src/listenerMiddleware/index.ts +++ b/packages/toolkit/src/listenerMiddleware/index.ts @@ -4,7 +4,6 @@ import type { ThunkDispatch } from 'redux-thunk' import { createAction } from '../createAction' import { nanoid } from '../nanoid' -import { find } from '../utils' import { TaskAbortError, listenerCancelled, @@ -221,9 +220,8 @@ export const createListenerEntry: TypedCreateListenerEntry = (options: FallbackAddListenerOptions) => { const { type, predicate, effect } = getListenerEntryPropsFrom(options) - const id = nanoid() const entry: ListenerEntry = { - id, + id: nanoid(), effect, type, predicate, @@ -238,6 +236,22 @@ export const createListenerEntry: TypedCreateListenerEntry = { withTypes: () => createListenerEntry }, ) as unknown as TypedCreateListenerEntry +const findListenerEntry = ( + listenerMap: Map, + options: FallbackAddListenerOptions, +) => { + const { type, effect, predicate } = getListenerEntryPropsFrom(options) + + return Array.from(listenerMap.values()).find((entry) => { + const matchPredicateOrType = + typeof type === 'string' + ? entry.type === type + : entry.predicate === predicate + + return matchPredicateOrType && entry.effect === effect + }) +} + const cancelActiveListeners = ( entry: ListenerEntry>, ) => { @@ -330,7 +344,7 @@ export const createListenerMiddleware = < assertFunction(onError, 'onError') const insertEntry = (entry: ListenerEntry) => { - entry.unsubscribe = () => listenerMap.delete(entry!.id) + entry.unsubscribe = () => listenerMap.delete(entry.id) listenerMap.set(entry.id, entry) return (cancelOptions?: UnsubscribeListenerOptions) => { @@ -342,14 +356,9 @@ export const createListenerMiddleware = < } const startListening = ((options: FallbackAddListenerOptions) => { - let entry = find( - Array.from(listenerMap.values()), - (existingEntry) => existingEntry.effect === options.effect, - ) - - if (!entry) { - entry = createListenerEntry(options as any) - } + const entry = + findListenerEntry(listenerMap, options) ?? + createListenerEntry(options as any) return insertEntry(entry) }) as AddListenerOverloads @@ -361,16 +370,7 @@ export const createListenerMiddleware = < const stopListening = ( options: FallbackAddListenerOptions & UnsubscribeListenerOptions, ): boolean => { - const { type, effect, predicate } = getListenerEntryPropsFrom(options) - - const entry = find(Array.from(listenerMap.values()), (entry) => { - const matchPredicateOrType = - typeof type === 'string' - ? entry.type === type - : entry.predicate === predicate - - return matchPredicateOrType && entry.effect === effect - }) + const entry = findListenerEntry(listenerMap, options) if (entry) { entry.unsubscribe() diff --git a/packages/toolkit/src/listenerMiddleware/tests/listenerMiddleware.test.ts b/packages/toolkit/src/listenerMiddleware/tests/listenerMiddleware.test.ts index 56939af639..ad657508e2 100644 --- a/packages/toolkit/src/listenerMiddleware/tests/listenerMiddleware.test.ts +++ b/packages/toolkit/src/listenerMiddleware/tests/listenerMiddleware.test.ts @@ -117,6 +117,7 @@ describe('createListenerMiddleware', () => { const testAction1 = createAction('testAction1') type TestAction1 = ReturnType const testAction2 = createAction('testAction2') + type TestAction2 = ReturnType const testAction3 = createAction('testAction3') beforeAll(() => { @@ -339,6 +340,27 @@ describe('createListenerMiddleware', () => { ]) }) + test('subscribing with the same effect but different predicate is allowed', () => { + const effect = vi.fn((_: TestAction1 | TestAction2) => {}) + + startListening({ + actionCreator: testAction1, + effect, + }) + startListening({ + actionCreator: testAction2, + effect, + }) + + store.dispatch(testAction1('a')) + store.dispatch(testAction2('b')) + + expect(effect.mock.calls).toEqual([ + [testAction1('a'), middlewareApi], + [testAction2('b'), middlewareApi], + ]) + }) + test('unsubscribing via callback', () => { const effect = vi.fn((_: TestAction1) => {}) diff --git a/packages/toolkit/src/listenerMiddleware/types.ts b/packages/toolkit/src/listenerMiddleware/types.ts index b5980e1085..7e6f6c2783 100644 --- a/packages/toolkit/src/listenerMiddleware/types.ts +++ b/packages/toolkit/src/listenerMiddleware/types.ts @@ -578,9 +578,13 @@ export type TypedAddListener< OverrideStateType, unknown, UnknownAction - >, - OverrideExtraArgument = unknown, - >() => TypedAddListener + >, + OverrideExtraArgument = unknown, + >() => TypedAddListener< + OverrideStateType, + OverrideDispatchType, + OverrideExtraArgument + > } /** @@ -641,7 +645,11 @@ export type TypedRemoveListener< UnknownAction >, OverrideExtraArgument = unknown, - >() => TypedRemoveListener + >() => TypedRemoveListener< + OverrideStateType, + OverrideDispatchType, + OverrideExtraArgument + > } /** @@ -701,7 +709,11 @@ export type TypedStartListening< UnknownAction >, OverrideExtraArgument = unknown, - >() => TypedStartListening + >() => TypedStartListening< + OverrideStateType, + OverrideDispatchType, + OverrideExtraArgument + > } /** @@ -756,7 +768,11 @@ export type TypedStopListening< UnknownAction >, OverrideExtraArgument = unknown, - >() => TypedStopListening + >() => TypedStopListening< + OverrideStateType, + OverrideDispatchType, + OverrideExtraArgument + > } /** @@ -813,7 +829,11 @@ export type TypedCreateListenerEntry< UnknownAction >, OverrideExtraArgument = unknown, - >() => TypedStopListening + >() => TypedStopListening< + OverrideStateType, + OverrideDispatchType, + OverrideExtraArgument + > } /** diff --git a/packages/toolkit/src/query/core/buildInitiate.ts b/packages/toolkit/src/query/core/buildInitiate.ts index ff2ab45456..ca2d6854e0 100644 --- a/packages/toolkit/src/query/core/buildInitiate.ts +++ b/packages/toolkit/src/query/core/buildInitiate.ts @@ -16,7 +16,7 @@ import type { QueryDefinition, ResultTypeFrom, } from '../endpointDefinitions' -import { countObjectKeys, isNotNullish } from '../utils' +import { countObjectKeys, getOrInsert, isNotNullish } from '../utils' import type { SubscriptionOptions } from './apiState' import type { QueryResultSelectorResult } from './buildSelectors' import type { MutationThunk, QueryThunk, QueryThunkArg } from './buildThunks' @@ -391,9 +391,8 @@ You must add the middleware for RTK-Query to function correctly!`, ) if (!runningQuery && !skippedSynchronously && !forceQueryFn) { - const running = runningQueries.get(dispatch) || {} + const running = getOrInsert(runningQueries, dispatch, {}) running[queryCacheKey] = statePromise - runningQueries.set(dispatch, running) statePromise.then(() => { delete running[queryCacheKey] diff --git a/packages/toolkit/src/query/core/buildMiddleware/index.ts b/packages/toolkit/src/query/core/buildMiddleware/index.ts index 17092d82fd..1f55b5ef22 100644 --- a/packages/toolkit/src/query/core/buildMiddleware/index.ts +++ b/packages/toolkit/src/query/core/buildMiddleware/index.ts @@ -35,6 +35,8 @@ export type { MutationLifecycleApi, QueryLifecycleApi, ReferenceQueryLifecycle, + TypedMutationOnQueryStarted, + TypedQueryOnQueryStarted, } from './queryLifecycle' export type { SubscriptionSelectors } from './types' @@ -48,7 +50,7 @@ export function buildMiddleware< const actions = { invalidateTags: createAction< - Array> + Array | null | undefined> >(`${reducerPath}/invalidateTags`), } @@ -148,7 +150,12 @@ export function buildMiddleware< { status: QueryStatus.uninitialized } >, ) { - return (input.api.endpoints[querySubState.endpointName] as ApiEndpointQuery).initiate(querySubState.originalArgs as any, { + return ( + input.api.endpoints[querySubState.endpointName] as ApiEndpointQuery< + any, + any + > + ).initiate(querySubState.originalArgs as any, { subscribe: false, forceRefetch: true, }) diff --git a/packages/toolkit/src/query/core/buildMiddleware/queryLifecycle.ts b/packages/toolkit/src/query/core/buildMiddleware/queryLifecycle.ts index 8b899d9202..7e80d8c96e 100644 --- a/packages/toolkit/src/query/core/buildMiddleware/queryLifecycle.ts +++ b/packages/toolkit/src/query/core/buildMiddleware/queryLifecycle.ts @@ -111,8 +111,13 @@ export type QueryLifecycleQueryExtraOptions< * ``` */ onQueryStarted?( - arg: QueryArg, - api: QueryLifecycleApi, + queryArgument: QueryArg, + queryLifeCycleApi: QueryLifecycleApi< + QueryArg, + BaseQuery, + ResultType, + ReducerPath + >, ): Promise | void } @@ -171,8 +176,13 @@ export type QueryLifecycleMutationExtraOptions< * ``` */ onQueryStarted?( - arg: QueryArg, - api: MutationLifecycleApi, + queryArgument: QueryArg, + mutationLifeCycleApi: MutationLifecycleApi< + QueryArg, + BaseQuery, + ResultType, + ReducerPath + >, ): Promise | void } @@ -192,6 +202,212 @@ export type MutationLifecycleApi< > = MutationBaseLifecycleApi & QueryLifecyclePromises +/** + * Provides a way to define a strongly-typed version of + * {@linkcode QueryLifecycleQueryExtraOptions.onQueryStarted | onQueryStarted} + * for a specific query. + * + * @example + * #### __Create and reuse a strongly-typed `onQueryStarted` function__ + * + * ```ts + * import type { TypedQueryOnQueryStarted } from '@reduxjs/toolkit/query' + * import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query' + * + * type Post = { + * id: number + * title: string + * userId: number + * } + * + * type PostsApiResponse = { + * posts: Post[] + * total: number + * skip: number + * limit: number + * } + * + * type QueryArgument = number | undefined + * + * type BaseQueryFunction = ReturnType + * + * const baseApiSlice = createApi({ + * baseQuery: fetchBaseQuery({ baseUrl: 'https://dummyjson.com' }), + * reducerPath: 'postsApi', + * tagTypes: ['Posts'], + * endpoints: (builder) => ({ + * getPosts: builder.query({ + * query: () => `/posts`, + * }), + * + * getPostById: builder.query({ + * query: (postId) => `/posts/${postId}`, + * }), + * }), + * }) + * + * const updatePostOnFulfilled: TypedQueryOnQueryStarted< + * PostsApiResponse, + * QueryArgument, + * BaseQueryFunction, + * 'postsApi' + * > = async (queryArgument, { dispatch, queryFulfilled }) => { + * const result = await queryFulfilled + * + * const { posts } = result.data + * + * // Pre-fill the individual post entries with the results + * // from the list endpoint query + * dispatch( + * baseApiSlice.util.upsertQueryEntries( + * posts.map((post) => ({ + * endpointName: 'getPostById', + * arg: post.id, + * value: post, + * })), + * ), + * ) + * } + * + * export const extendedApiSlice = baseApiSlice.injectEndpoints({ + * endpoints: (builder) => ({ + * getPostsByUserId: builder.query({ + * query: (userId) => `/posts/user/${userId}`, + * + * onQueryStarted: updatePostOnFulfilled, + * }), + * }), + * }) + * ``` + * + * @template ResultType - The type of the result `data` returned by the query. + * @template QueryArgumentType - The type of the argument passed into the query. + * @template BaseQueryFunctionType - The type of the base query function being used. + * @template ReducerPath - The type representing the `reducerPath` for the API slice. + * + * @since 2.4.0 + * @public + */ +export type TypedQueryOnQueryStarted< + ResultType, + QueryArgumentType, + BaseQueryFunctionType extends BaseQueryFn, + ReducerPath extends string = string, +> = QueryLifecycleQueryExtraOptions< + ResultType, + QueryArgumentType, + BaseQueryFunctionType, + ReducerPath +>['onQueryStarted'] + +/** + * Provides a way to define a strongly-typed version of + * {@linkcode QueryLifecycleMutationExtraOptions.onQueryStarted | onQueryStarted} + * for a specific mutation. + * + * @example + * #### __Create and reuse a strongly-typed `onQueryStarted` function__ + * + * ```ts + * import type { TypedMutationOnQueryStarted } from '@reduxjs/toolkit/query' + * import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query' + * + * type Post = { + * id: number + * title: string + * userId: number + * } + * + * type PostsApiResponse = { + * posts: Post[] + * total: number + * skip: number + * limit: number + * } + * + * type QueryArgument = Pick & Partial + * + * type BaseQueryFunction = ReturnType + * + * const baseApiSlice = createApi({ + * baseQuery: fetchBaseQuery({ baseUrl: 'https://dummyjson.com' }), + * reducerPath: 'postsApi', + * tagTypes: ['Posts'], + * endpoints: (builder) => ({ + * getPosts: builder.query({ + * query: () => `/posts`, + * }), + * + * getPostById: builder.query({ + * query: (postId) => `/posts/${postId}`, + * }), + * }), + * }) + * + * const updatePostOnFulfilled: TypedMutationOnQueryStarted< + * Post, + * QueryArgument, + * BaseQueryFunction, + * 'postsApi' + * > = async ({ id, ...patch }, { dispatch, queryFulfilled }) => { + * const patchCollection = dispatch( + * baseApiSlice.util.updateQueryData('getPostById', id, (draftPost) => { + * Object.assign(draftPost, patch) + * }), + * ) + * + * try { + * await queryFulfilled + * } catch { + * patchCollection.undo() + * } + * } + * + * export const extendedApiSlice = baseApiSlice.injectEndpoints({ + * endpoints: (builder) => ({ + * addPost: builder.mutation>({ + * query: (body) => ({ + * url: `posts/add`, + * method: 'POST', + * body, + * }), + * + * onQueryStarted: updatePostOnFulfilled, + * }), + * + * updatePost: builder.mutation({ + * query: ({ id, ...patch }) => ({ + * url: `post/${id}`, + * method: 'PATCH', + * body: patch, + * }), + * + * onQueryStarted: updatePostOnFulfilled, + * }), + * }), + * }) + * ``` + * + * @template ResultType - The type of the result `data` returned by the query. + * @template QueryArgumentType - The type of the argument passed into the query. + * @template BaseQueryFunctionType - The type of the base query function being used. + * @template ReducerPath - The type representing the `reducerPath` for the API slice. + * + * @since 2.4.0 + * @public + */ +export type TypedMutationOnQueryStarted< + ResultType, + QueryArgumentType, + BaseQueryFunctionType extends BaseQueryFn, + ReducerPath extends string = string, +> = QueryLifecycleMutationExtraOptions< + ResultType, + QueryArgumentType, + BaseQueryFunctionType, + ReducerPath +>['onQueryStarted'] + export const buildQueryLifecycleHandler: InternalHandlerBuilder = ({ api, context, diff --git a/packages/toolkit/src/query/core/buildSelectors.ts b/packages/toolkit/src/query/core/buildSelectors.ts index 83fa9c0103..c20db23950 100644 --- a/packages/toolkit/src/query/core/buildSelectors.ts +++ b/packages/toolkit/src/query/core/buildSelectors.ts @@ -9,7 +9,7 @@ import type { TagTypesFrom, } from '../endpointDefinitions' import { expandTagDescription } from '../endpointDefinitions' -import { flatten } from '../utils' +import { flatten, isNotNullish } from '../utils' import type { MutationSubState, QueryCacheKey, @@ -168,6 +168,9 @@ export function buildSelectors< endpointDefinition: QueryDefinition, ) { return ((queryArgs: any) => { + if (queryArgs === skipToken) { + return createSelector(selectSkippedQuery, withRequestFlags) + } const serializedArgs = serializeQueryArgs({ queryArgs, endpointDefinition, @@ -176,10 +179,8 @@ export function buildSelectors< const selectQuerySubstate = (state: RootState) => selectInternalState(state)?.queries?.[serializedArgs] ?? defaultQuerySubState - const finalSelectQuerySubState = - queryArgs === skipToken ? selectSkippedQuery : selectQuerySubstate - return createSelector(finalSelectQuerySubState, withRequestFlags) + return createSelector(selectQuerySubstate, withRequestFlags) }) as QueryResultSelectorFactory } @@ -205,7 +206,7 @@ export function buildSelectors< function selectInvalidatedBy( state: RootState, - tags: ReadonlyArray>, + tags: ReadonlyArray | null | undefined>, ): Array<{ endpointName: string originalArgs: any @@ -213,7 +214,7 @@ export function buildSelectors< }> { const apiState = state[reducerPath] const toInvalidate = new Set() - for (const tag of tags.map(expandTagDescription)) { + for (const tag of tags.filter(isNotNullish).map(expandTagDescription)) { const provided = apiState.provided[tag.type] if (!provided) { continue diff --git a/packages/toolkit/src/query/core/index.ts b/packages/toolkit/src/query/core/index.ts index a970f20d08..a79ac4d9bd 100644 --- a/packages/toolkit/src/query/core/index.ts +++ b/packages/toolkit/src/query/core/index.ts @@ -24,7 +24,9 @@ export type { QueryCacheLifecycleApi, QueryLifecycleApi, SubscriptionSelectors, -} from './buildMiddleware' + TypedMutationOnQueryStarted, + TypedQueryOnQueryStarted, +} from './buildMiddleware/index' export { skipToken } from './buildSelectors' export type { MutationResultSelectorResult, diff --git a/packages/toolkit/src/query/core/module.ts b/packages/toolkit/src/query/core/module.ts index 074aa08eb8..6bb43bc411 100644 --- a/packages/toolkit/src/query/core/module.ts +++ b/packages/toolkit/src/query/core/module.ts @@ -350,7 +350,7 @@ export interface ApiModules< * ``` */ invalidateTags: ActionCreatorWithPayload< - Array>, + Array | null | undefined>, string > @@ -361,7 +361,7 @@ export interface ApiModules< */ selectInvalidatedBy: ( state: RootState, - tags: ReadonlyArray>, + tags: ReadonlyArray | null | undefined>, ) => Array<{ endpointName: string originalArgs: any diff --git a/packages/toolkit/src/query/endpointDefinitions.ts b/packages/toolkit/src/query/endpointDefinitions.ts index 38e5343475..8f7955468c 100644 --- a/packages/toolkit/src/query/endpointDefinitions.ts +++ b/packages/toolkit/src/query/endpointDefinitions.ts @@ -29,6 +29,7 @@ import type { OmitFromUnion, UnwrapPromise, } from './tsHelpers' +import { isNotNullish } from './utils' const resultType = /* @__PURE__ */ Symbol() const baseQuery = /* @__PURE__ */ Symbol() @@ -224,7 +225,7 @@ export type GetResultDescriptionFn< error: ErrorType | undefined, arg: QueryArg, meta: MetaType, -) => ReadonlyArray> +) => ReadonlyArray | undefined | null> export type FullTagDescription = { type: TagType @@ -242,7 +243,7 @@ export type ResultDescription< ErrorType, MetaType, > = - | ReadonlyArray> + | ReadonlyArray | undefined | null> | GetResultDescriptionFn type QueryTypes< @@ -778,6 +779,7 @@ export function calculateProvidedBy( queryArg, meta as MetaType, ) + .filter(isNotNullish) .map(expandTagDescription) .map(assertTagTypes) } diff --git a/packages/toolkit/src/query/index.ts b/packages/toolkit/src/query/index.ts index 630b0afe65..a7d9114145 100644 --- a/packages/toolkit/src/query/index.ts +++ b/packages/toolkit/src/query/index.ts @@ -68,7 +68,11 @@ export type { CreateApi, CreateApiOptions } from './createApi' export { buildCreateApi } from './createApi' export { _NEVER, fakeBaseQuery } from './fakeBaseQuery' export { copyWithStructuralSharing } from './utils/copyWithStructuralSharing' -export { createApi, coreModule, coreModuleName } from './core' +export { createApi, coreModule, coreModuleName } from './core/index' +export type { + TypedMutationOnQueryStarted, + TypedQueryOnQueryStarted, +} from './core/index' export type { ApiEndpointMutation, ApiEndpointQuery, diff --git a/packages/toolkit/src/query/react/buildHooks.ts b/packages/toolkit/src/query/react/buildHooks.ts index 20b659b18b..daec42efe2 100644 --- a/packages/toolkit/src/query/react/buildHooks.ts +++ b/packages/toolkit/src/query/react/buildHooks.ts @@ -18,6 +18,7 @@ import type { PrefetchOptions, QueryActionCreatorResult, QueryArgFrom, + QueryCacheKey, QueryDefinition, QueryKeys, QueryResultSelectorResult, @@ -255,7 +256,7 @@ export type UseLazyQuery> = < options?: SubscriptionOptions & Omit, 'skip'>, ) => [ LazyQueryTrigger, - UseQueryStateResult, + UseLazyQueryStateResult, UseLazyQueryLastPromiseInfo, ] @@ -267,6 +268,33 @@ export type TypedUseLazyQuery< QueryDefinition > +export type UseLazyQueryStateResult< + D extends QueryDefinition, + R = UseQueryStateDefaultResult, +> = UseQueryStateResult & { + /** + * Resets the hook state to its initial `uninitialized` state. + * This will also remove the last result from the cache. + */ + reset: () => void +} + +/** + * Helper type to manually type the result + * of the `useLazyQuery` hook in userland code. + */ +export type TypedUseLazyQueryStateResult< + ResultType, + QueryArg, + BaseQuery extends BaseQueryFn, + R = UseQueryStateDefaultResult< + QueryDefinition + >, +> = UseLazyQueryStateResult< + QueryDefinition, + R +> + export type LazyQueryTrigger> = { /** * Triggers a lazy query. @@ -317,7 +345,11 @@ export type UseLazyQuerySubscription< D extends QueryDefinition, > = ( options?: SubscriptionOptions, -) => readonly [LazyQueryTrigger, QueryArgFrom | UninitializedValue] +) => readonly [ + LazyQueryTrigger, + QueryArgFrom | UninitializedValue, + { reset: () => void }, +] export type TypedUseLazyQuerySubscription< ResultType, @@ -421,7 +453,7 @@ export type QueryStateSelector< * @template BaseQueryFunctionType - The type of the base query function being used. * @template SelectedResultType - The type of the selected result returned by the __`selectFromResult`__ function. * - * @since 2.7.9 + * @since 2.3.0 * @public */ export type TypedQueryStateSelector< @@ -604,7 +636,7 @@ export type UseQueryStateOptions< * @template BaseQuery - The type of the base query function being used. * @template SelectedResult - The type of the selected result returned by the __`selectFromResult`__ function. * - * @since 2.7.8 + * @since 2.2.8 * @public */ export type TypedUseQueryStateOptions< @@ -895,13 +927,20 @@ export function buildHooks({ // 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 = (!lastResult || lastResult.isLoading || lastResult.isUninitialized) && !hasData && isFetching - // isSuccess = true when data is present - const isSuccess = currentState.isSuccess || (isFetching && hasData) + + // isSuccess = true when data is present and we're not refetching after an error. + // That includes cases where the _current_ item is either actively + // fetching or about to fetch due to an uninitialized entry. + const isSuccess = + currentState.isSuccess || + (hasData && + ((isFetching && !lastResult?.isError) || currentState.isUninitialized)) return { ...currentState, @@ -1162,6 +1201,16 @@ export function buildHooks({ [dispatch, initiate], ) + const reset = useCallback(() => { + if (promiseRef.current?.queryCacheKey) { + dispatch( + api.internalActions.removeQueryResult({ + queryCacheKey: promiseRef.current?.queryCacheKey as QueryCacheKey, + }), + ) + } + }, [dispatch]) + /* cleanup on unmount */ useEffect(() => { return () => { @@ -1176,7 +1225,10 @@ export function buildHooks({ } }, [arg, trigger]) - return useMemo(() => [trigger, arg] as const, [trigger, arg]) + return useMemo( + () => [trigger, arg, { reset }] as const, + [trigger, arg, reset], + ) } const useQueryState: UseQueryState = ( @@ -1249,7 +1301,7 @@ export function buildHooks({ useQuerySubscription, useLazyQuerySubscription, useLazyQuery(options) { - const [trigger, arg] = useLazyQuerySubscription(options) + const [trigger, arg, { reset }] = useLazyQuerySubscription(options) const queryStateResults = useQueryState(arg, { ...options, skip: arg === UNINITIALIZED_VALUE, @@ -1257,8 +1309,8 @@ export function buildHooks({ const info = useMemo(() => ({ lastArg: arg }), [arg]) return useMemo( - () => [trigger, queryStateResults, info], - [trigger, queryStateResults, info], + () => [trigger, { ...queryStateResults, reset }, info], + [trigger, queryStateResults, reset, info], ) }, useQuery(arg, options) { diff --git a/packages/toolkit/src/query/react/index.ts b/packages/toolkit/src/query/react/index.ts index 9816a915a0..e9fda233ae 100644 --- a/packages/toolkit/src/query/react/index.ts +++ b/packages/toolkit/src/query/react/index.ts @@ -28,6 +28,7 @@ export type { TypedUseQuerySubscription, TypedUseLazyQuerySubscription, TypedUseQueryStateOptions, + TypedUseLazyQueryStateResult, } from './buildHooks' export { UNINITIALIZED_VALUE } from './constants' export { createApi, reactHooksModule, reactHooksModuleName } diff --git a/packages/toolkit/src/query/retry.ts b/packages/toolkit/src/query/retry.ts index 05ab05dd01..3947dd4acd 100644 --- a/packages/toolkit/src/query/retry.ts +++ b/packages/toolkit/src/query/retry.ts @@ -5,6 +5,7 @@ import type { BaseQueryError, BaseQueryExtraOptions, BaseQueryFn, + BaseQueryMeta, } from './baseQueryTypes' import type { FetchBaseQueryError } from './fetchBaseQuery' import { HandledError } from './HandledError' @@ -64,8 +65,11 @@ export type RetryOptions = { } ) -function fail(e: any): never { - throw Object.assign(new HandledError({ error: e }), { +function fail( + error: BaseQueryError, + meta?: BaseQueryMeta, +): never { + throw Object.assign(new HandledError({ error, meta }), { throwImmediately: true, }) } diff --git a/packages/toolkit/src/query/tests/apiProvider.test.tsx b/packages/toolkit/src/query/tests/apiProvider.test.tsx index f6a4cea10a..4fdf9de462 100644 --- a/packages/toolkit/src/query/tests/apiProvider.test.tsx +++ b/packages/toolkit/src/query/tests/apiProvider.test.tsx @@ -32,6 +32,10 @@ const api = createApi({ }), }) +afterEach(() => { + vi.resetAllMocks() +}) + describe('ApiProvider', () => { test('ApiProvider allows a user to make queries without a traditional Redux setup', async () => { function User() { @@ -72,6 +76,8 @@ describe('ApiProvider', () => { expect(getByTestId('isFetching').textContent).toBe('false') }) test('ApiProvider throws if nested inside a Redux context', () => { + // Intentionally swallow the "unhandled error" message + vi.spyOn(console, 'error').mockImplementation(() => {}) expect(() => render( null })}> diff --git a/packages/toolkit/src/query/tests/buildHooks.test.tsx b/packages/toolkit/src/query/tests/buildHooks.test.tsx index 5ec6b32c14..50ff561148 100644 --- a/packages/toolkit/src/query/tests/buildHooks.test.tsx +++ b/packages/toolkit/src/query/tests/buildHooks.test.tsx @@ -6,6 +6,7 @@ import { actionsReducer, setupApiStore, useRenderCounter, + waitForFakeTimer, waitMs, withProvider, } from '@internal/tests/utils/helpers' @@ -39,6 +40,7 @@ import type { MockInstance } from 'vitest' // the refetching behavior of components. let amount = 0 let nextItemId = 0 +let refetchCount = 0 interface Item { id: number @@ -46,7 +48,7 @@ interface Item { const api = createApi({ baseQuery: async (arg: any) => { - await waitMs(150) + await waitForFakeTimer(150) if (arg?.body && 'amount' in arg.body) { amount += 1 } @@ -87,6 +89,17 @@ const api = createApi({ }, }), }), + getUserWithRefetchError: build.query<{ name: string }, number>({ + queryFn: async (id) => { + refetchCount += 1 + + if (refetchCount > 1) { + return { error: true } as any + } + + return { data: { name: 'Timmy' } } + }, + }), getIncrementedAmount: build.query<{ amount: number }, void>({ query: () => ({ url: '', @@ -125,6 +138,12 @@ const api = createApi({ return true }, }), + queryWithDeepArg: build.query({ + query: ({ param: { nested } }) => nested, + serializeQueryArgs: ({ queryArgs }) => { + return queryArgs.param.nested + }, + }), }), }) @@ -377,6 +396,132 @@ describe('hooks tests', () => { expect(fetchingHist).toEqual([true, false, true, false]) }) + test('`isSuccess` does not jump back false on subsequent queries', async () => { + type LoadingState = { + id: number + isFetching: boolean + isSuccess: boolean + } + const loadingHistory: LoadingState[] = [] + + function User({ id }: { id: number }) { + const queryRes = api.endpoints.getUser.useQuery(id) + + useEffect(() => { + const { isFetching, isSuccess } = queryRes + loadingHistory.push({ id, isFetching, isSuccess }) + }, [id, queryRes]) + return ( +
+ {queryRes.status === QueryStatus.fulfilled && id} +
+ ) + } + + let { rerender } = render(, { wrapper: storeRef.wrapper }) + + await waitFor(() => + expect(screen.getByTestId('status').textContent).toBe('1'), + ) + rerender() + + await waitFor(() => + expect(screen.getByTestId('status').textContent).toBe('2'), + ) + + expect(loadingHistory).toEqual([ + // Initial render(s) + { id: 1, isFetching: true, isSuccess: false }, + { id: 1, isFetching: true, isSuccess: false }, + // Data returned + { id: 1, isFetching: false, isSuccess: true }, + // ID changed, there's an uninitialized cache entry. + // IMPORTANT: `isSuccess` should not be false here. + // We have valid data already for the old item. + { id: 2, isFetching: true, isSuccess: true }, + { id: 2, isFetching: true, isSuccess: true }, + { id: 2, isFetching: false, isSuccess: true }, + ]) + }) + + test('isSuccess stays consistent if there is an error while refetching', async () => { + type LoadingState = { + id: number + isFetching: boolean + isSuccess: boolean + isError: boolean + } + const loadingHistory: LoadingState[] = [] + + function Component({ id = 1 }) { + const queryRes = api.endpoints.getUserWithRefetchError.useQuery(id) + const { refetch, data, status } = queryRes + + useEffect(() => { + const { isFetching, isSuccess, isError } = queryRes + loadingHistory.push({ id, isFetching, isSuccess, isError }) + }, [id, queryRes]) + + return ( +
+ +
{data?.name}
+
{status}
+
+ ) + } + + render(, { wrapper: storeRef.wrapper }) + + await waitFor(() => + expect(screen.getByTestId('name').textContent).toBe('Timmy'), + ) + + fireEvent.click(screen.getByText('refetch')) + + await waitFor(() => + expect(screen.getByTestId('status').textContent).toBe('pending'), + ) + + await waitFor(() => + expect(screen.getByTestId('status').textContent).toBe('rejected'), + ) + + fireEvent.click(screen.getByText('refetch')) + + await waitFor(() => + expect(screen.getByTestId('status').textContent).toBe('pending'), + ) + + await waitFor(() => + expect(screen.getByTestId('status').textContent).toBe('rejected'), + ) + + expect(loadingHistory).toEqual([ + // Initial renders + { id: 1, isFetching: true, isSuccess: false, isError: false }, + { id: 1, isFetching: true, isSuccess: false, isError: false }, + // Data is returned + { id: 1, isFetching: false, isSuccess: true, isError: false }, + // Started first refetch + { id: 1, isFetching: true, isSuccess: true, isError: false }, + // First refetch errored + { id: 1, isFetching: false, isSuccess: false, isError: true }, + // Started second refetch + // IMPORTANT We expect `isSuccess` to still be false, + // despite having started the refetch again. + { id: 1, isFetching: true, isSuccess: false, isError: false }, + // Second refetch errored + { id: 1, isFetching: false, isSuccess: false, isError: true }, + ]) + }) + test('useQuery hook respects refetchOnMountOrArgChange: true', async () => { let data, isLoading, isFetching function User() { @@ -667,6 +812,14 @@ describe('hooks tests', () => { await screen.findByText('ID: 3') }) + test(`useQuery shouldn't call args serialization if request skipped`, async () => { + expect(() => + renderHook(() => api.endpoints.queryWithDeepArg.useQuery(skipToken), { + wrapper: storeRef.wrapper, + }), + ).not.toThrow() + }) + test(`useQuery gracefully handles bigint types`, async () => { function ItemList() { const [pageNumber, setPageNumber] = useState(0) @@ -762,7 +915,7 @@ describe('hooks tests', () => { resPromise = refetch() }) expect(resPromise).toBeInstanceOf(Promise) - const res = await resPromise + const res = await act(() => resPromise) expect(res.data!.amount).toBeGreaterThan(originalAmount) }) @@ -942,15 +1095,15 @@ describe('hooks tests', () => { // Allow at least three state effects to hit. // Trying to see if any [true, false, true] occurs. await act(async () => { - await waitMs(1) + await waitForFakeTimer(150) }) await act(async () => { - await waitMs(1) + await waitForFakeTimer(150) }) await act(async () => { - await waitMs(1) + await waitForFakeTimer(150) }) // Find if at any time the isLoading state has reverted @@ -1386,10 +1539,8 @@ describe('hooks tests', () => { test('useLazyQuery trigger promise returns the correctly updated data', async () => { const LazyUnwrapUseEffect = () => { - const [ - triggerGetIncrementedAmount, - { isFetching, isSuccess, isError, error, data }, - ] = api.endpoints.getIncrementedAmount.useLazyQuery() + const [triggerGetIncrementedAmount, { isFetching, isSuccess, data }] = + api.endpoints.getIncrementedAmount.useLazyQuery() type AmountData = { amount: number } | undefined @@ -1478,6 +1629,50 @@ describe('hooks tests', () => { expect(screen.getByText('Unwrap data: 2')).toBeTruthy() }) }) + + test('`reset` sets state back to original state', async () => { + function User() { + const [getUser, { isSuccess, isUninitialized, reset }, _lastInfo] = + api.endpoints.getUser.useLazyQuery() + + const handleFetchClick = async () => { + await getUser(1).unwrap() + } + + return ( +
+ + {isUninitialized + ? 'isUninitialized' + : isSuccess + ? 'isSuccess' + : 'other'} + + + +
+ ) + } + + render(, { wrapper: storeRef.wrapper }) + + await screen.findByText(/isUninitialized/i) + expect(countObjectKeys(storeRef.store.getState().api.queries)).toBe(0) + + userEvent.click(screen.getByRole('button', { name: 'Fetch User' })) + + await screen.findByText(/isSuccess/i) + expect(countObjectKeys(storeRef.store.getState().api.queries)).toBe(1) + + userEvent.click( + screen.getByRole('button', { + name: 'Reset', + }), + ) + + await screen.findByText(/isUninitialized/i) + expect(countObjectKeys(storeRef.store.getState().api.queries)).toBe(0) + }) }) describe('useMutation', () => { @@ -1669,7 +1864,8 @@ describe('hooks tests', () => { expect(screen.getByTestId('isFetching').textContent).toBe('false'), ) - userEvent.hover(screen.getByTestId('highPriority')) + await userEvent.hover(screen.getByTestId('highPriority')) + expect( api.endpoints.getUser.select(USER_ID)(storeRef.store.getState() as any), ).toEqual({ @@ -1806,7 +2002,7 @@ describe('hooks tests', () => { await waitMs(400) // This should run the query being that we're past the threshold - userEvent.hover(screen.getByTestId('lowPriority')) + await userEvent.hover(screen.getByTestId('lowPriority')) expect( api.endpoints.getUser.select(USER_ID)(storeRef.store.getState() as any), ).toEqual({ @@ -1906,7 +2102,7 @@ describe('hooks tests', () => { render(, { wrapper: storeRef.wrapper }) - userEvent.hover(screen.getByTestId('lowPriority')) + await userEvent.hover(screen.getByTestId('lowPriority')) expect( api.endpoints.getUser.select(USER_ID)(storeRef.store.getState() as any), @@ -2798,6 +2994,11 @@ describe('skip behavior', () => { await act(async () => { rerender([1]) }) + + await act(async () => { + await waitForFakeTimer(150) + }) + expect(result.current).toMatchObject({ status: QueryStatus.fulfilled }) await waitMs(1) expect(getSubscriptionCount('getUser(1)')).toBe(1) @@ -2807,6 +3008,7 @@ describe('skip behavior', () => { }) expect(result.current).toEqual({ ...uninitialized, + isSuccess: true, currentData: undefined, data: { name: 'Timmy' }, }) @@ -2834,6 +3036,11 @@ describe('skip behavior', () => { await act(async () => { rerender([1]) }) + + await act(async () => { + await waitForFakeTimer(150) + }) + expect(result.current).toMatchObject({ status: QueryStatus.fulfilled }) await waitMs(1) expect(getSubscriptionCount('getUser(1)')).toBe(1) @@ -2844,6 +3051,7 @@ describe('skip behavior', () => { }) expect(result.current).toEqual({ ...uninitialized, + isSuccess: true, currentData: undefined, data: { name: 'Timmy' }, }) @@ -2862,7 +3070,7 @@ describe('skip behavior', () => { ) await act(async () => { - await waitMs(1) + await waitForFakeTimer(150) }) // Normal fulfilled result, with both `data` and `currentData` @@ -2882,7 +3090,7 @@ describe('skip behavior', () => { // even though it's skipped. `currentData` is undefined, since that matches the current arg. expect(result.current).toMatchObject({ status: QueryStatus.uninitialized, - isSuccess: false, + isSuccess: true, data: { name: 'Timmy' }, currentData: undefined, }) diff --git a/packages/toolkit/src/query/tests/buildMiddleware.test.tsx b/packages/toolkit/src/query/tests/buildMiddleware.test.tsx index 0e52cb0cd1..0a03396302 100644 --- a/packages/toolkit/src/query/tests/buildMiddleware.test.tsx +++ b/packages/toolkit/src/query/tests/buildMiddleware.test.tsx @@ -1,6 +1,6 @@ import { createApi } from '@reduxjs/toolkit/query' -import { actionsReducer, setupApiStore } from '../../tests/utils/helpers' import { delay } from 'msw' +import { actionsReducer, setupApiStore } from '../../tests/utils/helpers' const baseQuery = (args?: any) => ({ data: args }) const api = createApi({ @@ -25,9 +25,15 @@ const api = createApi({ }, providesTags: ['Bread'], }), + invalidateFruit: build.mutation({ + query: (fruit?: 'Banana' | 'Bread' | null) => ({ url: `invalidate/fruit/${fruit || ''}` }), + invalidatesTags(result, error, arg) { + return [arg] + } + }) }), }) -const { getBanana, getBread } = api.endpoints +const { getBanana, getBread, invalidateFruit } = api.endpoints const storeRef = setupApiStore(api, { ...actionsReducer, @@ -70,3 +76,61 @@ it('invalidates the specified tags', async () => { getBread.matchFulfilled, ) }) + +it('invalidates tags correctly when null or undefined are provided as tags', async() =>{ + await storeRef.store.dispatch(getBanana.initiate(1)) + await storeRef.store.dispatch(api.util.invalidateTags([undefined, null, 'Banana'])) + + // Slight pause to let the middleware run and such + await delay(20) + + const apiActions = [ + api.internalActions.middlewareRegistered.match, + getBanana.matchPending, + getBanana.matchFulfilled, + api.util.invalidateTags.match, + getBanana.matchPending, + getBanana.matchFulfilled, + ] + + expect(storeRef.store.getState().actions).toMatchSequence(...apiActions) +}) + + +it.each([ + { tags: [undefined, null, 'Bread'] as Parameters['0'] }, + { tags: [undefined, null], }, { tags: [] }] +)('does not invalidate with tags=$tags if no query matches', async ({ tags }) => { + await storeRef.store.dispatch(getBanana.initiate(1)) + await storeRef.store.dispatch(api.util.invalidateTags(tags)) + + // Slight pause to let the middleware run and such + await delay(20) + + const apiActions = [ + api.internalActions.middlewareRegistered.match, + getBanana.matchPending, + getBanana.matchFulfilled, + api.util.invalidateTags.match, + ] + + expect(storeRef.store.getState().actions).toMatchSequence(...apiActions) +}) + +it.each([{ mutationArg: 'Bread' as "Bread" | null | undefined }, { mutationArg: undefined }, { mutationArg: null }])('does not invalidate queries when a mutation with tags=[$mutationArg] runs and does not match anything', async ({ mutationArg }) => { + await storeRef.store.dispatch(getBanana.initiate(1)) + await storeRef.store.dispatch(invalidateFruit.initiate(mutationArg)) + + // Slight pause to let the middleware run and such + await delay(20) + + const apiActions = [ + api.internalActions.middlewareRegistered.match, + getBanana.matchPending, + getBanana.matchFulfilled, + invalidateFruit.matchPending, + invalidateFruit.matchFulfilled, + ] + + expect(storeRef.store.getState().actions).toMatchSequence(...apiActions) +}) \ No newline at end of file diff --git a/packages/toolkit/src/query/tests/cleanup.test.tsx b/packages/toolkit/src/query/tests/cleanup.test.tsx index fff0d4f7ad..49b634d7f4 100644 --- a/packages/toolkit/src/query/tests/cleanup.test.tsx +++ b/packages/toolkit/src/query/tests/cleanup.test.tsx @@ -43,7 +43,7 @@ function UsingAB() { } beforeAll(() => { - vi.useFakeTimers() + vi.useFakeTimers({ shouldAdvanceTime: true }) }) test('data stays in store when component stays rendered', async () => { diff --git a/packages/toolkit/src/query/tests/createApi.test-d.ts b/packages/toolkit/src/query/tests/createApi.test-d.ts index b33ced1f6e..2b1dc64d9e 100644 --- a/packages/toolkit/src/query/tests/createApi.test-d.ts +++ b/packages/toolkit/src/query/tests/createApi.test-d.ts @@ -39,7 +39,7 @@ describe('type tests', () => { expectTypeOf(api.util.invalidateTags) .parameter(0) - .toEqualTypeOf[]>() + .toEqualTypeOf<(null | undefined | TagDescription)[]>() }) describe('endpoint definition typings', () => { diff --git a/packages/toolkit/src/query/tests/fetchBaseQuery.test.tsx b/packages/toolkit/src/query/tests/fetchBaseQuery.test.tsx index 3b70753318..845a1c3592 100644 --- a/packages/toolkit/src/query/tests/fetchBaseQuery.test.tsx +++ b/packages/toolkit/src/query/tests/fetchBaseQuery.test.tsx @@ -862,7 +862,7 @@ describe('fetchBaseQuery', () => { prepare.apply(undefined, arguments as unknown as any[]) }, }) - baseQuery('http://example.com', commonBaseQueryApi, { + baseQuery('https://example.com', commonBaseQueryApi, { foo: 'baz', bar: 5, }) diff --git a/packages/toolkit/src/query/tests/invalidation.test.tsx b/packages/toolkit/src/query/tests/invalidation.test.tsx index 09b71cdee2..03a7638663 100644 --- a/packages/toolkit/src/query/tests/invalidation.test.tsx +++ b/packages/toolkit/src/query/tests/invalidation.test.tsx @@ -14,10 +14,10 @@ const tagTypes = [ 'giraffe', ] as const type TagTypes = (typeof tagTypes)[number] -type Tags = TagDescription[] - +type ProvidedTags = TagDescription[] +type InvalidatesTags = (ProvidedTags[number] | null | undefined)[] /** providesTags, invalidatesTags, shouldInvalidate */ -const caseMatrix: [Tags, Tags, boolean][] = [ +const caseMatrix: [ProvidedTags, InvalidatesTags, boolean][] = [ // ***************************** // basic invalidation behavior // ***************************** @@ -39,7 +39,11 @@ const caseMatrix: [Tags, Tags, boolean][] = [ // type + id invalidates type + id [[{ type: 'apple', id: 1 }], [{ type: 'apple', id: 1 }], true], [[{ type: 'apple', id: 1 }], [{ type: 'apple', id: 2 }], false], - + // null and undefined + [['apple'], [null], false], + [['apple'], [undefined], false], + [['apple'], [null, 'apple'], true], + [['apple'], [undefined, 'apple'], true], // ***************************** // test multiple values in array // ***************************** diff --git a/packages/toolkit/src/query/tests/mocks/handlers.ts b/packages/toolkit/src/query/tests/mocks/handlers.ts index 3fe044fdf0..6f2146e175 100644 --- a/packages/toolkit/src/query/tests/mocks/handlers.ts +++ b/packages/toolkit/src/query/tests/mocks/handlers.ts @@ -12,6 +12,12 @@ export const posts: Record = { } export const handlers = [ + http.get( + 'https://example.com', + async ({ request, params, cookies, requestId }) => { + HttpResponse.json({ value: 'success' }) + }, + ), http.get( 'https://example.com/echo', async ({ request, params, cookies, requestId }) => { diff --git a/packages/toolkit/src/query/tests/optimisticUpserts.test.tsx b/packages/toolkit/src/query/tests/optimisticUpserts.test.tsx index 600c433aab..ac6c8ee473 100644 --- a/packages/toolkit/src/query/tests/optimisticUpserts.test.tsx +++ b/packages/toolkit/src/query/tests/optimisticUpserts.test.tsx @@ -399,27 +399,34 @@ describe('upsertQueryEntries', () => { storeRef.store.dispatch(entriesAction) // Tricky timing. The cache data promises will be resolved - // in microtasks, so we just need any async delay here. - await delay(1) + // in microtasks. We need to wait for them. Best to do this + // in a loop just to avoid a hardcoded delay, but also this + // needs to complete before `keepUnusedDataFor` expires them. + await waitFor( + () => { + const state = storeRef.store.getState() + + // onCacheEntryAdded should have run for each post, + // including cache data being resolved + for (const post of posts) { + const matchingSideEffectAction = state.actions.find( + (action) => + postAddedAction.match(action) && action.payload === post.id, + ) + expect(matchingSideEffectAction).toBeTruthy() + } - const state = storeRef.store.getState() + const selectedData = + api.endpoints.postWithSideEffect.select('1')(state).data - // onCacheEntryAdded should have run for each post, - // including cache data being resolved - for (const post of posts) { - const matchingSideEffectAction = state.actions.find( - (action) => postAddedAction.match(action) && action.payload === post.id, - ) - expect(matchingSideEffectAction).toBeTruthy() - } - - expect(api.endpoints.postWithSideEffect.select('1')(state).data).toBe( - posts[0], + expect(selectedData).toBe(posts[0]) + }, + { timeout: 50, interval: 5 }, ) // The cache data should be removed after the keepUnusedDataFor time, // so wait longer than that - await delay(20) + await delay(100) const stateAfter = storeRef.store.getState() diff --git a/packages/toolkit/src/query/tests/queryLifecycle.test-d.tsx b/packages/toolkit/src/query/tests/queryLifecycle.test-d.tsx index 983de884f4..82e71d202b 100644 --- a/packages/toolkit/src/query/tests/queryLifecycle.test-d.tsx +++ b/packages/toolkit/src/query/tests/queryLifecycle.test-d.tsx @@ -1,6 +1,11 @@ +import type { PatchCollection, Recipe } from '@internal/query/core/buildThunks' +import type { ThunkDispatch, UnknownAction } from '@reduxjs/toolkit' import type { FetchBaseQueryError, FetchBaseQueryMeta, + RootState, + TypedMutationOnQueryStarted, + TypedQueryOnQueryStarted, } from '@reduxjs/toolkit/query' import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query' @@ -148,4 +153,219 @@ describe('type tests', () => { }), }) }) + + describe('typed `onQueryStarted` function', () => { + test('TypedQueryOnQueryStarted creates a pre-typed version of onQueryStarted', () => { + type Post = { + id: number + title: string + userId: number + } + + type PostsApiResponse = { + posts: Post[] + total: number + skip: number + limit: number + } + + type QueryArgument = number | undefined + + type BaseQueryFunction = ReturnType + + const baseApiSlice = createApi({ + baseQuery: fetchBaseQuery({ baseUrl: 'https://dummyjson.com' }), + reducerPath: 'postsApi', + tagTypes: ['Posts'], + endpoints: (builder) => ({ + getPosts: builder.query({ + query: () => `/posts`, + }), + + getPostById: builder.query({ + query: (postId) => `/posts/${postId}`, + }), + }), + }) + + const updatePostOnFulfilled: TypedQueryOnQueryStarted< + PostsApiResponse, + QueryArgument, + BaseQueryFunction, + 'postsApi' + > = async (queryArgument, queryLifeCycleApi) => { + const { + dispatch, + extra, + getCacheEntry, + getState, + queryFulfilled, + requestId, + updateCachedData, + } = queryLifeCycleApi + + expectTypeOf(queryArgument).toEqualTypeOf() + + expectTypeOf(dispatch).toEqualTypeOf< + ThunkDispatch + >() + + expectTypeOf(extra).toBeUnknown() + + expectTypeOf(getState).toEqualTypeOf< + () => RootState + >() + + expectTypeOf(requestId).toBeString() + + expectTypeOf(getCacheEntry).toBeFunction() + + expectTypeOf(updateCachedData).toEqualTypeOf< + (updateRecipe: Recipe) => PatchCollection + >() + + expectTypeOf(queryFulfilled).resolves.toEqualTypeOf<{ + data: PostsApiResponse + meta: FetchBaseQueryMeta | undefined + }>() + + const result = await queryFulfilled + + const { posts } = result.data + + dispatch( + baseApiSlice.util.upsertQueryEntries( + posts.map((post) => ({ + // Without `as const` this will result in a TS error in TS 4.7. + endpointName: 'getPostById' as const, + arg: post.id, + value: post, + })), + ), + ) + } + + const extendedApiSlice = baseApiSlice.injectEndpoints({ + endpoints: (builder) => ({ + getPostsByUserId: builder.query({ + query: (userId) => `/posts/user/${userId}`, + + onQueryStarted: updatePostOnFulfilled, + }), + }), + }) + }) + + test('TypedMutationOnQueryStarted creates a pre-typed version of onQueryStarted', () => { + type Post = { + id: number + title: string + userId: number + } + + type PostsApiResponse = { + posts: Post[] + total: number + skip: number + limit: number + } + + type QueryArgument = Pick & Partial + + type BaseQueryFunction = ReturnType + + const baseApiSlice = createApi({ + baseQuery: fetchBaseQuery({ baseUrl: 'https://dummyjson.com' }), + reducerPath: 'postsApi', + tagTypes: ['Posts'], + endpoints: (builder) => ({ + getPosts: builder.query({ + query: () => `/posts`, + }), + + getPostById: builder.query({ + query: (postId) => `/posts/${postId}`, + }), + }), + }) + + const updatePostOnFulfilled: TypedMutationOnQueryStarted< + Post, + QueryArgument, + BaseQueryFunction, + 'postsApi' + > = async (queryArgument, mutationLifeCycleApi) => { + const { id, ...patch } = queryArgument + const { + dispatch, + extra, + getCacheEntry, + getState, + queryFulfilled, + requestId, + } = mutationLifeCycleApi + + const patchCollection = dispatch( + baseApiSlice.util.updateQueryData('getPostById', id, (draftPost) => { + Object.assign(draftPost, patch) + }), + ) + + expectTypeOf(queryFulfilled).resolves.toEqualTypeOf<{ + data: Post + meta: FetchBaseQueryMeta | undefined + }>() + + expectTypeOf(queryArgument).toEqualTypeOf() + + expectTypeOf(dispatch).toEqualTypeOf< + ThunkDispatch + >() + + expectTypeOf(extra).toBeUnknown() + + expectTypeOf(getState).toEqualTypeOf< + () => RootState + >() + + expectTypeOf(requestId).toBeString() + + expectTypeOf(getCacheEntry).toBeFunction() + + expectTypeOf(mutationLifeCycleApi).not.toHaveProperty( + 'updateCachedData', + ) + + try { + await queryFulfilled + } catch { + patchCollection.undo() + } + } + + const extendedApiSlice = baseApiSlice.injectEndpoints({ + endpoints: (builder) => ({ + addPost: builder.mutation>({ + query: (body) => ({ + url: `posts/add`, + method: 'POST', + body, + }), + + onQueryStarted: updatePostOnFulfilled, + }), + + updatePost: builder.mutation({ + query: ({ id, ...patch }) => ({ + url: `post/${id}`, + method: 'PATCH', + body: patch, + }), + + onQueryStarted: updatePostOnFulfilled, + }), + }), + }) + }) + }) }) diff --git a/packages/toolkit/src/query/tests/queryLifecycle.test.tsx b/packages/toolkit/src/query/tests/queryLifecycle.test.tsx index b0396e9b0c..22a1c1aefd 100644 --- a/packages/toolkit/src/query/tests/queryLifecycle.test.tsx +++ b/packages/toolkit/src/query/tests/queryLifecycle.test.tsx @@ -360,7 +360,7 @@ test('query: updateCachedData', async () => { }, ) { // calling `updateCachedData` when there is no data yet should not do anything - // but if there is a cache value it will be updated & overwritten by the next succesful result + // but if there is a cache value it will be updated & overwritten by the next successful result updateCachedData((draft) => { draft.value += '.' }) diff --git a/packages/toolkit/src/query/tests/refetchingBehaviors.test.tsx b/packages/toolkit/src/query/tests/refetchingBehaviors.test.tsx index be27d08d9f..96e41b9e10 100644 --- a/packages/toolkit/src/query/tests/refetchingBehaviors.test.tsx +++ b/packages/toolkit/src/query/tests/refetchingBehaviors.test.tsx @@ -71,12 +71,12 @@ describe('refetchOnFocus tests', () => { expect(screen.getByTestId('amount').textContent).toBe('1'), ) + fireEvent.focus(window) + await act(async () => { - fireEvent.focus(window) + await delay(150) }) - await delay(150) - await waitFor(() => expect(screen.getByTestId('amount').textContent).toBe('2'), ) @@ -111,9 +111,7 @@ describe('refetchOnFocus tests', () => { expect(screen.getByTestId('amount').textContent).toBe('1'), ) - act(() => { - fireEvent.focus(window) - }) + fireEvent.focus(window) await delay(150) @@ -165,9 +163,7 @@ describe('refetchOnFocus tests', () => { expect(screen.getByTestId('amount').textContent).toBe('1'), ) - act(() => { - fireEvent.focus(window) - }) + fireEvent.focus(window) expect(screen.getByTestId('isLoading').textContent).toBe('false') await waitFor(() => expect(screen.getByTestId('isFetching').textContent).toBe('true'), @@ -213,9 +209,7 @@ describe('refetchOnFocus tests', () => { expect(getIncrementedAmountState()).not.toBeUndefined() - await act(async () => { - fireEvent.focus(window) - }) + fireEvent.focus(window) await delay(1) expect(getIncrementedAmountState()).toBeUndefined() diff --git a/packages/toolkit/src/query/tests/retry.test-d.ts b/packages/toolkit/src/query/tests/retry.test-d.ts index 26a9eb210f..ef8aee5f7f 100644 --- a/packages/toolkit/src/query/tests/retry.test-d.ts +++ b/packages/toolkit/src/query/tests/retry.test-d.ts @@ -1,4 +1,9 @@ -import type { RetryOptions } from '@internal/query/retry' +import { retry, type RetryOptions } from '@internal/query/retry' +import { + fetchBaseQuery, + type FetchBaseQueryError, + type FetchBaseQueryMeta, +} from '@internal/query/fetchBaseQuery' describe('type tests', () => { test('RetryOptions only accepts one of maxRetries or retryCondition', () => { @@ -14,6 +19,28 @@ describe('type tests', () => { retryCondition: () => false, }).not.toMatchTypeOf() }) -}) + test('fail can be pretyped to only accept correct error and meta', () => { + expectTypeOf(retry.fail).parameter(0).toEqualTypeOf() + expectTypeOf(retry.fail).parameter(1).toEqualTypeOf<{} | undefined>() + expectTypeOf(retry.fail).toBeCallableWith('Literally anything', {}) + + const myBaseQuery = fetchBaseQuery() + const typedFail = retry.fail + + expectTypeOf(typedFail).parameter(0).toMatchTypeOf() + expectTypeOf(typedFail) + .parameter(1) + .toMatchTypeOf() -export {} + expectTypeOf(typedFail).toBeCallableWith( + { + status: 401, + data: 'Unauthorized', + }, + { request: new Request('http://localhost') }, + ) + + expectTypeOf(typedFail).parameter(0).not.toMatchTypeOf() + expectTypeOf(typedFail).parameter(1).not.toMatchTypeOf<{}>() + }) +}) diff --git a/packages/toolkit/src/query/tests/unionTypes.test-d.ts b/packages/toolkit/src/query/tests/unionTypes.test-d.ts index 6426556428..819a150d56 100644 --- a/packages/toolkit/src/query/tests/unionTypes.test-d.ts +++ b/packages/toolkit/src/query/tests/unionTypes.test-d.ts @@ -9,6 +9,7 @@ import type { TypedUseQueryStateResult, TypedUseQuerySubscriptionResult, TypedLazyQueryTrigger, + TypedUseLazyQueryStateResult, TypedUseLazyQuery, TypedUseLazyQuerySubscription, TypedUseMutation, @@ -820,7 +821,7 @@ describe('"Typed" helper types', () => { >().toMatchTypeOf(trigger) expectTypeOf< - TypedUseQueryHookResult + TypedUseLazyQueryStateResult >().toMatchTypeOf(result) }) @@ -834,7 +835,12 @@ describe('"Typed" helper types', () => { >().toMatchTypeOf(trigger) expectTypeOf< - TypedUseQueryHookResult + TypedUseLazyQueryStateResult< + string, + void, + typeof baseQuery, + { x: boolean } + > >().toMatchTypeOf(result) }) diff --git a/packages/toolkit/src/query/utils/getOrInsert.ts b/packages/toolkit/src/query/utils/getOrInsert.ts new file mode 100644 index 0000000000..124da032ea --- /dev/null +++ b/packages/toolkit/src/query/utils/getOrInsert.ts @@ -0,0 +1,15 @@ +export function getOrInsert( + map: WeakMap, + key: K, + value: V, +): V +export function getOrInsert(map: Map, key: K, value: V): V +export function getOrInsert( + map: Map | WeakMap, + key: K, + value: V, +): V { + if (map.has(key)) return map.get(key) as V + + return map.set(key, value).get(key) as V +} diff --git a/packages/toolkit/src/query/utils/index.ts b/packages/toolkit/src/query/utils/index.ts index 0eb7c62ce9..916b32fd60 100644 --- a/packages/toolkit/src/query/utils/index.ts +++ b/packages/toolkit/src/query/utils/index.ts @@ -8,3 +8,4 @@ export * from './isNotNullish' export * from './isOnline' export * from './isValidUrl' export * from './joinUrls' +export * from './getOrInsert' diff --git a/packages/toolkit/src/tests/autoBatchEnhancer.test.ts b/packages/toolkit/src/tests/autoBatchEnhancer.test.ts index 870d2c3106..e1b820c908 100644 --- a/packages/toolkit/src/tests/autoBatchEnhancer.test.ts +++ b/packages/toolkit/src/tests/autoBatchEnhancer.test.ts @@ -125,3 +125,84 @@ describe.each(cases)('autoBatchEnhancer: %j', (autoBatchOptions) => { expect(subscriptionNotifications).toBe(3) }) }) + +describe.each(cases)( + 'autoBatchEnhancer with fake timers: %j', + (autoBatchOptions) => { + beforeAll(() => { + vitest.useFakeTimers({ + toFake: ['setTimeout', 'queueMicrotask', 'requestAnimationFrame'], + }) + }) + afterAll(() => { + vitest.useRealTimers() + }) + beforeEach(() => { + subscriptionNotifications = 0 + store = makeStore(autoBatchOptions) + + store.subscribe(() => { + subscriptionNotifications++ + }) + }) + test('Does not alter normal subscription notification behavior', () => { + store.dispatch(decrementUnbatched()) + expect(subscriptionNotifications).toBe(1) + store.dispatch(decrementUnbatched()) + expect(subscriptionNotifications).toBe(2) + store.dispatch(decrementUnbatched()) + expect(subscriptionNotifications).toBe(3) + store.dispatch(decrementUnbatched()) + + vitest.runAllTimers() + + expect(subscriptionNotifications).toBe(4) + }) + + test('Only notifies once if several batched actions are dispatched in a row', () => { + store.dispatch(incrementBatched()) + expect(subscriptionNotifications).toBe(0) + store.dispatch(incrementBatched()) + expect(subscriptionNotifications).toBe(0) + store.dispatch(incrementBatched()) + expect(subscriptionNotifications).toBe(0) + store.dispatch(incrementBatched()) + + vitest.runAllTimers() + + expect(subscriptionNotifications).toBe(1) + }) + + test('Notifies immediately if a non-batched action is dispatched', () => { + store.dispatch(incrementBatched()) + expect(subscriptionNotifications).toBe(0) + store.dispatch(incrementBatched()) + expect(subscriptionNotifications).toBe(0) + store.dispatch(decrementUnbatched()) + expect(subscriptionNotifications).toBe(1) + store.dispatch(incrementBatched()) + + vitest.runAllTimers() + + expect(subscriptionNotifications).toBe(2) + }) + + test('Does not notify at end of tick if last action was normal priority', () => { + store.dispatch(incrementBatched()) + expect(subscriptionNotifications).toBe(0) + store.dispatch(incrementBatched()) + expect(subscriptionNotifications).toBe(0) + store.dispatch(decrementUnbatched()) + expect(subscriptionNotifications).toBe(1) + store.dispatch(incrementBatched()) + store.dispatch(decrementUnbatched()) + expect(subscriptionNotifications).toBe(2) + store.dispatch(decrementUnbatched()) + expect(subscriptionNotifications).toBe(3) + + vitest.runAllTimers() + + expect(subscriptionNotifications).toBe(3) + }) + }, +) diff --git a/packages/toolkit/src/tests/createAsyncThunk.test.ts b/packages/toolkit/src/tests/createAsyncThunk.test.ts index 971ee3682a..d36937d9d6 100644 --- a/packages/toolkit/src/tests/createAsyncThunk.test.ts +++ b/packages/toolkit/src/tests/createAsyncThunk.test.ts @@ -1,4 +1,4 @@ -import type { UnknownAction } from '@reduxjs/toolkit' +import type { CreateAsyncThunkFunction, UnknownAction } from '@reduxjs/toolkit' import { configureStore, createAsyncThunk, @@ -990,4 +990,25 @@ describe('meta', () => { expect(thunk.settled).toEqual(expectFunction) expect(thunk.fulfilled.type).toBe('a/fulfilled') }) + test('createAsyncThunkWrapper using CreateAsyncThunkFunction', async () => { + const customSerializeError = () => 'serialized!' + const createAppAsyncThunk: CreateAsyncThunkFunction<{ + serializedErrorType: ReturnType + }> = (prefix: string, payloadCreator: any, options: any) => + createAsyncThunk(prefix, payloadCreator, { + ...options, + serializeError: customSerializeError, + }) as any + + const asyncThunk = createAppAsyncThunk('test', async () => { + throw new Error('Panic!') + }) + + const promise = store.dispatch(asyncThunk()) + const result = await promise + if (!asyncThunk.rejected.match(result)) { + throw new Error('should have thrown') + } + expect(result.error).toEqual('serialized!') + }) }) diff --git a/packages/toolkit/src/utils.ts b/packages/toolkit/src/utils.ts index 1f8445bb0f..6607f4b339 100644 --- a/packages/toolkit/src/utils.ts +++ b/packages/toolkit/src/utils.ts @@ -26,19 +26,6 @@ export function delay(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)) } -export function find( - iterable: Iterable, - comparator: (item: T) => boolean, -): T | undefined { - for (const entry of iterable) { - if (comparator(entry)) { - return entry - } - } - - return undefined -} - export class Tuple = []> extends Array< Items[number] > { @@ -87,81 +74,38 @@ export function freezeDraftable(val: T) { return isDraftable(val) ? createNextState(val, () => {}) : val } -interface WeakMapEmplaceHandler { - /** - * Will be called to get value, if no value is currently in map. - */ - insert?(key: K, map: WeakMap): V - /** - * Will be called to update a value, if one exists already. - */ - update?(previous: V, key: K, map: WeakMap): V -} +export function getOrInsert( + map: WeakMap, + key: K, + value: V, +): V +export function getOrInsert(map: Map, key: K, value: V): V +export function getOrInsert( + map: Map | WeakMap, + key: K, + value: V, +): V { + if (map.has(key)) return map.get(key) as V -interface MapEmplaceHandler { - /** - * Will be called to get value, if no value is currently in map. - */ - insert?(key: K, map: Map): V - /** - * Will be called to update a value, if one exists already. - */ - update?(previous: V, key: K, map: Map): V + return map.set(key, value).get(key) as V } -export function emplace( - map: Map, +export function getOrInsertComputed( + map: WeakMap, key: K, - handler: MapEmplaceHandler, + compute: (key: K) => V, ): V -export function emplace( - map: WeakMap, +export function getOrInsertComputed( + map: Map, key: K, - handler: WeakMapEmplaceHandler, + compute: (key: K) => V, ): V -/** - * Allow inserting a new value, or updating an existing one - * @throws if called for a key with no current value and no `insert` handler is provided - * @returns current value in map (after insertion/updating) - * ```ts - * // return current value if already in map, otherwise initialise to 0 and return that - * const num = emplace(map, key, { - * insert: () => 0 - * }) - * - * // increase current value by one if already in map, otherwise initialise to 0 - * const num = emplace(map, key, { - * update: (n) => n + 1, - * insert: () => 0, - * }) - * - * // only update if value's already in the map - and increase it by one - * if (map.has(key)) { - * const num = emplace(map, key, { - * update: (n) => n + 1, - * }) - * } - * ``` - * - * @remarks - * Based on https://github.com/tc39/proposal-upsert currently in Stage 2 - maybe in a few years we'll be able to replace this with direct method calls - */ -export function emplace( - map: WeakMap, +export function getOrInsertComputed( + map: Map | WeakMap, key: K, - handler: WeakMapEmplaceHandler, + compute: (key: K) => V, ): V { - if (map.has(key)) { - let value = map.get(key) as V - if (handler.update) { - value = handler.update(value, key, map) - map.set(key, value) - } - return value - } - if (!handler.insert) - throw new Error('No insert provided for key not already in map') - const inserted = handler.insert(key, map) - map.set(key, inserted) - return inserted + if (map.has(key)) return map.get(key) as V + + return map.set(key, compute(key)).get(key) as V } diff --git a/yarn.lock b/yarn.lock index 71280a400a..54408ff2d6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4870,7 +4870,8 @@ __metadata: resolution: "@examples-query-react/basic@workspace:examples/query/react/basic" dependencies: "@reduxjs/toolkit": "npm:^1.6.0-rc.1" - "@testing-library/react": "npm:^13.3.0" + "@testing-library/dom": "npm:^10.4.0" + "@testing-library/react": "npm:^16.0.1" "@types/jest": "npm:^26.0.23" "@types/react": "npm:^18.0.5" "@types/react-dom": "npm:^18.0.5" @@ -4987,8 +4988,9 @@ __metadata: resolution: "@examples-query-react/kitchen-sink@workspace:examples/query/react/kitchen-sink" dependencies: "@reduxjs/toolkit": "npm:1.8.1" + "@testing-library/dom": "npm:^10.4.0" "@testing-library/jest-dom": "npm:^5.11.5" - "@testing-library/react": "npm:^13.3.0" + "@testing-library/react": "npm:^16.0.1" "@types/jest": "npm:^26.0.23" "@types/node": "npm:^14.14.6" "@types/react": "npm:^18.0.5" @@ -6955,8 +6957,9 @@ __metadata: "@phryneas/ts-version": "npm:^1.0.2" "@size-limit/file": "npm:^11.0.1" "@size-limit/webpack": "npm:^11.0.1" - "@testing-library/react": "npm:^13.3.0" - "@testing-library/user-event": "npm:^13.1.5" + "@testing-library/dom": "npm:^10.4.0" + "@testing-library/react": "npm:^16.0.1" + "@testing-library/user-event": "npm:^14.5.2" "@types/babel__core": "npm:^7.20.5" "@types/babel__helper-module-imports": "npm:^7.18.3" "@types/json-stringify-safe": "npm:^5.0.0" @@ -7882,19 +7885,19 @@ __metadata: languageName: node linkType: hard -"@testing-library/dom@npm:^8.5.0": - version: 8.13.0 - resolution: "@testing-library/dom@npm:8.13.0" +"@testing-library/dom@npm:^10.4.0": + version: 10.4.0 + resolution: "@testing-library/dom@npm:10.4.0" dependencies: "@babel/code-frame": "npm:^7.10.4" "@babel/runtime": "npm:^7.12.5" - "@types/aria-query": "npm:^4.2.0" - aria-query: "npm:^5.0.0" + "@types/aria-query": "npm:^5.0.1" + aria-query: "npm:5.3.0" chalk: "npm:^4.1.0" dom-accessibility-api: "npm:^0.5.9" - lz-string: "npm:^1.4.4" + lz-string: "npm:^1.5.0" pretty-format: "npm:^27.0.2" - checksum: 10/e91bf4261b8f8c191f62fd44548d39fe8dab7d12f9ecbfdec8cc0060b4030e837f4626b0256a795cc91db9ba20fc9b97005247d951b21449751af26dbb8c7191 + checksum: 10/05825ee9a15b88cbdae12c137db7111c34069ed3c7a1bd03b6696cb1b37b29f6f2d2de581ebf03033e7df1ab7ebf08399310293f440a4845d95c02c0a9ecc899 languageName: node linkType: hard @@ -7961,20 +7964,6 @@ __metadata: languageName: node linkType: hard -"@testing-library/react@npm:^13.3.0": - version: 13.3.0 - resolution: "@testing-library/react@npm:13.3.0" - dependencies: - "@babel/runtime": "npm:^7.12.5" - "@testing-library/dom": "npm:^8.5.0" - "@types/react-dom": "npm:^18.0.0" - peerDependencies: - react: ^18.0.0 - react-dom: ^18.0.0 - checksum: 10/f72ff8e5f5268788ea2249bc0d8dceeca91f9fbad23fd718403e7c9518ea7b9f0053d96db7c3b2d20fc51b487b37ac6c5d55e7f732cadf786c8fa763ce1efec6 - languageName: node - linkType: hard - "@testing-library/react@npm:^14.1.2": version: 14.1.2 resolution: "@testing-library/react@npm:14.1.2" @@ -7989,14 +7978,23 @@ __metadata: languageName: node linkType: hard -"@testing-library/user-event@npm:^13.1.5": - version: 13.5.0 - resolution: "@testing-library/user-event@npm:13.5.0" +"@testing-library/react@npm:^16.0.1": + version: 16.0.1 + resolution: "@testing-library/react@npm:16.0.1" dependencies: "@babel/runtime": "npm:^7.12.5" peerDependencies: - "@testing-library/dom": ">=7.21.4" - checksum: 10/e2bdf2b2375d1399a4fd45195705ab0be05f5e3e8220ecbfc26cba98985e45cb505e4e11ed4d909a7052ce90d1e83751cfe21bb1b70fa82d2a2c268370432d8b + "@testing-library/dom": ^10.0.0 + "@types/react": ^18.0.0 + "@types/react-dom": ^18.0.0 + react: ^18.0.0 + react-dom: ^18.0.0 + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10/904b48881cf5bd208e25899e168f5c99c78ed6d77389544838d9d861a038d2c5c5385863ee9a367436770cbf7d21c5e05a991b9e24a33806e9ac985df2448185 languageName: node linkType: hard @@ -8065,13 +8063,6 @@ __metadata: languageName: node linkType: hard -"@types/aria-query@npm:^4.2.0": - version: 4.2.1 - resolution: "@types/aria-query@npm:4.2.1" - checksum: 10/04f60d7a3938ce1f33810f4cccde95e1fa45d2bb5ab1b7e93a5c331c072a59a9b3b559479b56c59af0170d40d97698e139310fef94904cff44db7151db8b9e15 - languageName: node - linkType: hard - "@types/aria-query@npm:^5.0.1": version: 5.0.4 resolution: "@types/aria-query@npm:5.0.4" @@ -10467,6 +10458,15 @@ __metadata: languageName: node linkType: hard +"aria-query@npm:5.3.0": + version: 5.3.0 + resolution: "aria-query@npm:5.3.0" + dependencies: + dequal: "npm:^2.0.3" + checksum: 10/c3e1ed127cc6886fea4732e97dd6d3c3938e64180803acfb9df8955517c4943760746ffaf4020ce8f7ffaa7556a3b5f85c3769a1f5ca74a1288e02d042f9ae4e + languageName: node + linkType: hard + "aria-query@npm:^4.2.2": version: 4.2.2 resolution: "aria-query@npm:4.2.2" @@ -13633,6 +13633,13 @@ __metadata: languageName: node linkType: hard +"dequal@npm:^2.0.3": + version: 2.0.3 + resolution: "dequal@npm:2.0.3" + checksum: 10/6ff05a7561f33603df87c45e389c9ac0a95e3c056be3da1a0c4702149e3a7f6fe5ffbb294478687ba51a9e95f3a60e8b6b9005993acd79c292c7d15f71964b6b + languageName: node + linkType: hard + "destroy@npm:1.2.0": version: 1.2.0 resolution: "destroy@npm:1.2.0" @@ -13820,13 +13827,20 @@ __metadata: languageName: node linkType: hard -"dom-accessibility-api@npm:^0.5.6, dom-accessibility-api@npm:^0.5.9": +"dom-accessibility-api@npm:^0.5.6": version: 0.5.14 resolution: "dom-accessibility-api@npm:0.5.14" checksum: 10/19d7a7de931fcc7d9d67c270341220c6bda97124c3b1444b2bea6e8c6c3964ee09c339e3e69be5b830e3fcb60258d43e6377039974b69c5cec2f75db0114ac59 languageName: node linkType: hard +"dom-accessibility-api@npm:^0.5.9": + version: 0.5.16 + resolution: "dom-accessibility-api@npm:0.5.16" + checksum: 10/377b4a7f9eae0a5d72e1068c369c99e0e4ca17fdfd5219f3abd32a73a590749a267475a59d7b03a891f9b673c27429133a818c44b2e47e32fec024b34274e2ca + languageName: node + linkType: hard + "dom-accessibility-api@npm:^0.6.3": version: 0.6.3 resolution: "dom-accessibility-api@npm:0.6.3" @@ -20047,15 +20061,6 @@ __metadata: languageName: node linkType: hard -"lz-string@npm:^1.4.4": - version: 1.4.4 - resolution: "lz-string@npm:1.4.4" - bin: - lz-string: bin/bin.js - checksum: 10/da3abc3c15b3f91ab0fba0fe8ea3bb53d3c758d5c50d88d97b759e52d9b5224f8b05edc0e6423bfd448e6bcbe30f79236b7f2e6e7f8a321be62ae77b88092581 - languageName: node - linkType: hard - "lz-string@npm:^1.5.0": version: 1.5.0 resolution: "lz-string@npm:1.5.0"