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/created-api/hooks.mdx b/docs/rtk-query/api/created-api/hooks.mdx index 7b8ff56f6d..b0d5b12325 100644 --- a/docs/rtk-query/api/created-api/hooks.mdx +++ b/docs/rtk-query/api/created-api/hooks.mdx @@ -335,7 +335,7 @@ type UseMutationResult = { data?: T // Returned result if present error?: unknown // Error result if present endpointName?: string // The name of the given endpoint for the mutation - fulfilledTimestamp?: number // Timestamp for when the mutation was completed + fulfilledTimeStamp?: number // Timestamp for when the mutation was completed // Derived request status booleans isUninitialized: boolean // Mutation has not been fired yet 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/packages/toolkit/package.json b/packages/toolkit/package.json index 2b765b1e6a..e3c3c8b6f3 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", 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/buildHooks.test.tsx b/packages/toolkit/src/query/tests/buildHooks.test.tsx index 5ec6b32c14..6810e75dd8 100644 --- a/packages/toolkit/src/query/tests/buildHooks.test.tsx +++ b/packages/toolkit/src/query/tests/buildHooks.test.tsx @@ -39,6 +39,7 @@ import type { MockInstance } from 'vitest' // the refetching behavior of components. let amount = 0 let nextItemId = 0 +let refetchCount = 0 interface Item { id: number @@ -87,6 +88,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 +137,12 @@ const api = createApi({ return true }, }), + queryWithDeepArg: build.query({ + query: ({ param: { nested } }) => nested, + serializeQueryArgs: ({ queryArgs }) => { + return queryArgs.param.nested + }, + }), }), }) @@ -377,6 +395,133 @@ 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) @@ -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', () => { @@ -2807,6 +3002,7 @@ describe('skip behavior', () => { }) expect(result.current).toEqual({ ...uninitialized, + isSuccess: true, currentData: undefined, data: { name: 'Timmy' }, }) @@ -2844,6 +3040,7 @@ describe('skip behavior', () => { }) expect(result.current).toEqual({ ...uninitialized, + isSuccess: true, currentData: undefined, data: { name: 'Timmy' }, }) @@ -2882,7 +3079,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/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/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/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/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/website/docusaurus.config.js b/website/docusaurus.config.js index c26183bb69..a7497cbdcf 100644 --- a/website/docusaurus.config.js +++ b/website/docusaurus.config.js @@ -71,6 +71,21 @@ module.exports = { colorMode: { respectPrefersColorScheme: true, }, + announcementBar: { + id: 'redux-dev-course', + content: ` + + Redux.dev - a new course by Mark Erikson + ui.dev - Learn more + + `, + backgroundColor: '#fafbfc', + textColor: '#091E42', + isCloseable: false, + }, navbar: { title: 'Redux Toolkit', logo: { diff --git a/website/src/css/custom.css b/website/src/css/custom.css index d3d0769ff3..1120329d0d 100644 --- a/website/src/css/custom.css +++ b/website/src/css/custom.css @@ -272,3 +272,15 @@ table.checkbox-table tbody td { color: var(--ifm-color-info); position: relative; } + +/* course callout on home page */ +.course-callout.home-mid { + max-width: 900px; + margin: 1rem auto 2rem; +} + +div[class*='announcementBar_'] { + /* Intentionally override the theme behavior, + so that the course banner image is effectively cropped*/ + z-index: calc(var(--ifm-z-index-fixed) -1) !important; +} \ No newline at end of file diff --git a/website/src/pages/index.js b/website/src/pages/index.js index f7b43f5343..b500ea3640 100644 --- a/website/src/pages/index.js +++ b/website/src/pages/index.js @@ -176,6 +176,14 @@ function Home() { )} +
+ + Redux.dev - a new course by Mark Erikson + ui.dev - Learn more + +
{otherLibraries && otherLibraries.length && (
diff --git a/website/static/img/course-callout-mid.svg b/website/static/img/course-callout-mid.svg new file mode 100644 index 0000000000..9273e4d921 --- /dev/null +++ b/website/static/img/course-callout-mid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/website/static/img/course-callout-narrow.svg b/website/static/img/course-callout-narrow.svg new file mode 100644 index 0000000000..4fe12ad40b --- /dev/null +++ b/website/static/img/course-callout-narrow.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/website/static/img/course-callout-wide.svg b/website/static/img/course-callout-wide.svg new file mode 100644 index 0000000000..111ed9c152 --- /dev/null +++ b/website/static/img/course-callout-wide.svg @@ -0,0 +1 @@ + \ No newline at end of file