From 66ff32ad7bf5ff15e7b5abaaaca3abbf62cae31c Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Mon, 3 Mar 2025 13:03:56 -0500 Subject: [PATCH 1/8] Fix assorted infinite query types (#4869) * Fix infinite query matcher types * Fix infinite query providesTags types * Fix infinite query subscription/state hooks * Fix internal providedBy type issue --- docs/rtk-query/usage/infinite-queries.mdx | 2 +- .../toolkit/src/query/core/buildThunks.ts | 38 ++++++++++++++----- .../toolkit/src/query/endpointDefinitions.ts | 2 +- .../toolkit/src/query/react/buildHooks.ts | 4 +- .../src/query/tests/infiniteQueries.test-d.ts | 37 ++++++++++++++++++ 5 files changed, 69 insertions(+), 14 deletions(-) diff --git a/docs/rtk-query/usage/infinite-queries.mdx b/docs/rtk-query/usage/infinite-queries.mdx index 0d0ec7e9c5..c43dd02572 100644 --- a/docs/rtk-query/usage/infinite-queries.mdx +++ b/docs/rtk-query/usage/infinite-queries.mdx @@ -63,7 +63,7 @@ This structure allows flexibility in how your UI chooses to render the data (sho Infinite query endpoints are defined by returning an object inside the `endpoints` section of `createApi`, and defining the fields using the `build.infiniteQuery()` method. They are an extension of standard query endpoints - you can specify [the same options as standard queries](./queries.mdx#defining-query-endpoints) (providing either `query` or `queryFn`, customizing with `transformResponse`, lifecycles with `onCacheEntryAdded` and `onQueryStarted`, defining tags, etc). However, they also require an additional `infiniteQueryOptions` field to specify the infinite query behavior. -With TypeScript, you must supply 3 generic arguments: `build.infiniteQuery`, where `ResultType` is the contents of a single page, `QueryArg` is the type passed in as the cache key, and `PageParam` is the value that will be passed to `query/queryFn` to make the rest. If there is no argument, use `void` for the arg type instead. +With TypeScript, you must supply 3 generic arguments: `build.infiniteQuery`, where `ResultType` is the contents of a single page, `QueryArg` is the type passed in as the cache key, and `PageParam` is the value used to request a specific page. If there is no argument, use `void` for the arg type instead. ### `infiniteQueryOptions` diff --git a/packages/toolkit/src/query/core/buildThunks.ts b/packages/toolkit/src/query/core/buildThunks.ts index 37f6606c7c..81ca5d38a7 100644 --- a/packages/toolkit/src/query/core/buildThunks.ts +++ b/packages/toolkit/src/query/core/buildThunks.ts @@ -26,6 +26,7 @@ import type { PageParamFrom, QueryArgFrom, QueryDefinition, + ResultDescription, ResultTypeFrom, } from '../endpointDefinitions' import { @@ -71,14 +72,14 @@ export type BuildThunksApiEndpointQuery< export type BuildThunksApiEndpointInfiniteQuery< Definition extends InfiniteQueryDefinition, -> = Matchers +> = Matchers, Definition> export type BuildThunksApiEndpointMutation< Definition extends MutationDefinition, > = Matchers type EndpointThunk< - Thunk extends QueryThunk | MutationThunk, + Thunk extends QueryThunk | MutationThunk | InfiniteQueryThunk, Definition extends EndpointDefinition, > = Definition extends EndpointDefinition< @@ -94,27 +95,41 @@ type EndpointThunk< ATConfig & { rejectValue: BaseQueryError } > : never - : never + : Definition extends InfiniteQueryDefinition< + infer QueryArg, + infer PageParam, + infer BaseQueryFn, + any, + infer ResultType + > + ? Thunk extends AsyncThunk + ? AsyncThunk< + InfiniteData, + ATArg & { originalArgs: QueryArg }, + ATConfig & { rejectValue: BaseQueryError } + > + : never + : never export type PendingAction< - Thunk extends QueryThunk | MutationThunk, + Thunk extends QueryThunk | MutationThunk | InfiniteQueryThunk, Definition extends EndpointDefinition, > = ReturnType['pending']> export type FulfilledAction< - Thunk extends QueryThunk | MutationThunk, + Thunk extends QueryThunk | MutationThunk | InfiniteQueryThunk, Definition extends EndpointDefinition, > = ReturnType['fulfilled']> export type RejectedAction< - Thunk extends QueryThunk | MutationThunk, + Thunk extends QueryThunk | MutationThunk | InfiniteQueryThunk, Definition extends EndpointDefinition, > = ReturnType['rejected']> export type Matcher = (value: any) => value is M export interface Matchers< - Thunk extends QueryThunk | MutationThunk, + Thunk extends QueryThunk | MutationThunk | InfiniteQueryThunk, Definition extends EndpointDefinition, > { matchPending: Matcher> @@ -645,7 +660,6 @@ export function buildThunks< isForcedQueryNeedingRefetch || !cachedData ? blankData : cachedData ) as InfiniteData - // If the thunk specified a direction and we do have at least one page, // fetch the next or previous page if ('direction' in arg && arg.direction && existingData.pages.length) { @@ -960,14 +974,18 @@ export function getPreviousPageParam( export function calculateProvidedByThunk( action: UnwrapPromise< - ReturnType> | ReturnType> + | ReturnType> + | ReturnType> + | ReturnType>> >, type: 'providesTags' | 'invalidatesTags', endpointDefinitions: EndpointDefinitions, assertTagType: AssertTagTypes, ) { return calculateProvidedBy( - endpointDefinitions[action.meta.arg.endpointName][type], + endpointDefinitions[action.meta.arg.endpointName][ + type + ] as ResultDescription, isFulfilled(action) ? action.payload : undefined, isRejectedWithValue(action) ? action.payload : undefined, action.meta.arg.originalArgs, diff --git a/packages/toolkit/src/query/endpointDefinitions.ts b/packages/toolkit/src/query/endpointDefinitions.ts index 44e46e3519..d69b976d21 100644 --- a/packages/toolkit/src/query/endpointDefinitions.ts +++ b/packages/toolkit/src/query/endpointDefinitions.ts @@ -595,7 +595,7 @@ export interface InfiniteQueryExtraOptions< providesTags?: ResultDescription< TagTypes, - ResultType, + InfiniteData, QueryArg, BaseQueryError, BaseQueryMeta diff --git a/packages/toolkit/src/query/react/buildHooks.ts b/packages/toolkit/src/query/react/buildHooks.ts index 1f08eab47b..4c8ca51bef 100644 --- a/packages/toolkit/src/query/react/buildHooks.ts +++ b/packages/toolkit/src/query/react/buildHooks.ts @@ -940,7 +940,7 @@ export type UseInfiniteQuery< export type UseInfiniteQueryState< D extends InfiniteQueryDefinition, > = = UseInfiniteQueryStateDefaultResult>( - arg: QueryArgFrom | SkipToken, + arg: InfiniteQueryArgFrom | SkipToken, options?: UseInfiniteQueryStateOptions, ) => UseInfiniteQueryStateResult @@ -977,7 +977,7 @@ export type TypedUseInfiniteQueryState< export type UseInfiniteQuerySubscription< D extends InfiniteQueryDefinition, > = ( - arg: QueryArgFrom | SkipToken, + arg: InfiniteQueryArgFrom | SkipToken, options?: UseInfiniteQuerySubscriptionOptions, ) => UseInfiniteQuerySubscriptionResult diff --git a/packages/toolkit/src/query/tests/infiniteQueries.test-d.ts b/packages/toolkit/src/query/tests/infiniteQueries.test-d.ts index e305b5e788..2ada1aef09 100644 --- a/packages/toolkit/src/query/tests/infiniteQueries.test-d.ts +++ b/packages/toolkit/src/query/tests/infiniteQueries.test-d.ts @@ -5,6 +5,7 @@ import { QueryStatus, } from '@reduxjs/toolkit/query/react' import { setupApiStore } from '../../tests/utils/helpers' +import { createSlice } from '@internal/createSlice' describe('Infinite queries', () => { test('Basic infinite query behavior', async () => { @@ -54,6 +55,12 @@ describe('Infinite queries', () => { InfiniteData >() }, + providesTags: (result) => { + expectTypeOf(result).toEqualTypeOf< + InfiniteData | undefined + >() + return [] + }, }), }), }) @@ -68,6 +75,36 @@ describe('Infinite queries', () => { expectTypeOf(pokemonApi.useGetInfinitePokemonInfiniteQuery).toBeFunction() + expectTypeOf(pokemonApi.endpoints.getInfinitePokemon.useInfiniteQuery) + .parameter(0) + .toEqualTypeOf() + + expectTypeOf(pokemonApi.endpoints.getInfinitePokemon.useInfiniteQueryState) + .parameter(0) + .toEqualTypeOf() + + expectTypeOf( + pokemonApi.endpoints.getInfinitePokemon.useInfiniteQuerySubscription, + ) + .parameter(0) + .toEqualTypeOf() + + const slice = createSlice({ + name: 'pokemon', + initialState: {} as { data: Pokemon[] }, + reducers: {}, + extraReducers: (builder) => { + builder.addMatcher( + pokemonApi.endpoints.getInfinitePokemon.matchFulfilled, + (state, action) => { + expectTypeOf(action.payload).toEqualTypeOf< + InfiniteData + >() + }, + ) + }, + }) + const res = storeRef.store.dispatch( pokemonApi.endpoints.getInfinitePokemon.initiate('fire', {}), ) From be33129029107e654d945dac966712cc8777f4be Mon Sep 17 00:00:00 2001 From: Arya Emami Date: Tue, 4 Mar 2025 21:23:52 -0600 Subject: [PATCH 2/8] Improve handling of environment variables in tests --- .../toolkit/src/query/tests/createApi.test.ts | 4 ++ .../src/query/tests/infiniteQueries.test.ts | 38 ++++--------------- packages/toolkit/vitest.config.mts | 1 + 3 files changed, 12 insertions(+), 31 deletions(-) diff --git a/packages/toolkit/src/query/tests/createApi.test.ts b/packages/toolkit/src/query/tests/createApi.test.ts index 9a4107f3c1..a9898e74a7 100644 --- a/packages/toolkit/src/query/tests/createApi.test.ts +++ b/packages/toolkit/src/query/tests/createApi.test.ts @@ -159,6 +159,8 @@ describe('wrong tagTypes log errors', () => { ['invalidateWrongTypeWithId', true], ['invalidateWrongTypeWithIdAndCallback', true], ])(`endpoint %s should log an error? %s`, async (endpoint, shouldError) => { + vi.stubEnv('NODE_ENV', 'development') + // @ts-ignore store.dispatch(api.endpoints[endpoint].initiate()) let result: { status: string } @@ -402,6 +404,8 @@ describe('endpoint definition typings', () => { }) test('warn on wrong tagType', async () => { + vi.stubEnv('NODE_ENV', 'development') + const storeRef = setupApiStore(api, undefined, { withoutTestLifecycles: true, }) diff --git a/packages/toolkit/src/query/tests/infiniteQueries.test.ts b/packages/toolkit/src/query/tests/infiniteQueries.test.ts index dd4ca4e4ac..26126cfa5f 100644 --- a/packages/toolkit/src/query/tests/infiniteQueries.test.ts +++ b/packages/toolkit/src/query/tests/infiniteQueries.test.ts @@ -1,33 +1,13 @@ -import { configureStore, isAllOf } from '@reduxjs/toolkit' -import { - act, - fireEvent, - render, - renderHook, - screen, - waitFor, -} from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { HttpResponse, delay, http } from 'msw' -import util from 'util' -import type { - InfiniteQueryActionCreatorResult, - QueryCacheKey, -} from '@reduxjs/toolkit/query/react' +import { server } from '@internal/query/tests/mocks/server' +import type { InfiniteQueryActionCreatorResult } from '@reduxjs/toolkit/query/react' import { QueryStatus, createApi, - fetchBaseQuery, fakeBaseQuery, - skipToken, + fetchBaseQuery, } from '@reduxjs/toolkit/query/react' -import { - actionsReducer, - setupApiStore, - withProvider, -} from '../../tests/utils/helpers' -import type { BaseQueryApi } from '../baseQueryTypes' -import { server } from '@internal/query/tests/mocks/server' +import { HttpResponse, delay, http } from 'msw' +import { actionsReducer, setupApiStore } from '../../tests/utils/helpers' import type { InfiniteQueryResultFlags } from '../core/buildSelectors' describe('Infinite queries', () => { @@ -171,12 +151,6 @@ describe('Infinite queries', () => { hitCounter = 0 queryCounter = 0 - - process.env.NODE_ENV = 'development' - }) - - afterEach(() => { - process.env.NODE_ENV = 'test' }) type InfiniteQueryResult = Awaited> @@ -387,6 +361,8 @@ describe('Infinite queries', () => { }) test('validates maxPages during createApi call', async () => { + vi.stubEnv('NODE_ENV', 'development') + const createApiWithMaxPages = ( maxPages: number, getPreviousPageParam: (() => number) | undefined, diff --git a/packages/toolkit/vitest.config.mts b/packages/toolkit/vitest.config.mts index 6327d7e388..41a7534dc5 100644 --- a/packages/toolkit/vitest.config.mts +++ b/packages/toolkit/vitest.config.mts @@ -23,5 +23,6 @@ export default defineConfig({ setupFiles: ['./vitest.setup.ts'], include: ['./src/**/*.(spec|test).[jt]s?(x)'], server: { deps: { inline: ['redux', '@reduxjs/toolkit'] } }, + unstubEnvs: true, }, }) From 72ac07d62ba48fa5b42ef13ec0f24cff85e45a3f Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Fri, 7 Mar 2025 14:22:12 -0500 Subject: [PATCH 3/8] Add providesTags handling for upsertQueryEntries (#4872) * Add providesTags handling for upsertQueryEntries * Fix flakiness in infinite query polling test --- packages/toolkit/src/query/core/buildSlice.ts | 67 +++++++++++++------ .../src/query/tests/infiniteQueries.test.ts | 6 +- .../query/tests/optimisticUpserts.test.tsx | 26 ++++--- 3 files changed, 66 insertions(+), 33 deletions(-) diff --git a/packages/toolkit/src/query/core/buildSlice.ts b/packages/toolkit/src/query/core/buildSlice.ts index 59174d4850..3dd824f5b8 100644 --- a/packages/toolkit/src/query/core/buildSlice.ts +++ b/packages/toolkit/src/query/core/buildSlice.ts @@ -1,4 +1,4 @@ -import type { Action, PayloadAction, UnknownAction } from '@reduxjs/toolkit' +import type { PayloadAction } from '@reduxjs/toolkit' import { combineReducers, createAction, @@ -23,7 +23,6 @@ import type { QueryCacheKey, SubscriptionState, ConfigState, - QueryKeys, InfiniteQuerySubState, InfiniteQueryDirection, } from './apiState' @@ -36,18 +35,14 @@ import type { MutationThunk, QueryThunk, QueryThunkArg, - RejectedAction, } from './buildThunks' import { calculateProvidedByThunk } from './buildThunks' import { isInfiniteQueryDefinition, type AssertTagTypes, - type DefinitionType, type EndpointDefinitions, type FullTagDescription, - type QueryArgFrom, type QueryDefinition, - type ResultTypeFrom, } from '../endpointDefinitions' import type { Patch } from 'immer' import { isDraft } from 'immer' @@ -61,6 +56,7 @@ import { import type { ApiContext } from '../apiTypes' import { isUpsertQuery } from './buildInitiate' import type { InternalSerializeQueryArgs } from '../defaultSerializeQueryArgs' +import type { UnwrapPromise } from '../tsHelpers' /** * A typesafe single entry to be upserted into the cache @@ -279,6 +275,7 @@ export function buildSlice({ substate.fulfilledTimeStamp = meta.fulfilledTimeStamp }) } + const querySlice = createSlice({ name: `${reducerPath}/queries`, initialState: initialState as QueryState, @@ -486,6 +483,11 @@ export function buildSlice({ }, }) + type CalculateProvidedByAction = UnwrapPromise< + | ReturnType> + | ReturnType>> + > + const invalidationSlice = createSlice({ name: `${reducerPath}/invalidation`, initialState: initialState as InvalidationState, @@ -562,26 +564,51 @@ export function buildSlice({ .addMatcher( isAnyOf(isFulfilled(queryThunk), isRejectedWithValue(queryThunk)), (draft, action) => { - const providedTags = calculateProvidedByThunk( - action, - 'providesTags', - definitions, - assertTagType, - ) - const { queryCacheKey } = action.meta.arg + writeProvidedTagsForQuery(draft, action) + }, + ) + .addMatcher( + querySlice.actions.cacheEntriesUpserted.match, + (draft, action) => { + for (const { queryDescription: arg, value } of action.payload) { + const action: CalculateProvidedByAction = { + type: 'UNKNOWN', + payload: value, + meta: { + requestStatus: 'fulfilled', + requestId: 'UNKNOWN', + arg, + }, + } - invalidationSlice.caseReducers.updateProvidedBy( - draft, - invalidationSlice.actions.updateProvidedBy({ - queryCacheKey, - providedTags, - }), - ) + writeProvidedTagsForQuery(draft, action) + } }, ) }, }) + function writeProvidedTagsForQuery( + draft: InvalidationState, + action: CalculateProvidedByAction, + ) { + const providedTags = calculateProvidedByThunk( + action, + 'providesTags', + definitions, + assertTagType, + ) + const { queryCacheKey } = action.meta.arg + + invalidationSlice.caseReducers.updateProvidedBy( + draft, + invalidationSlice.actions.updateProvidedBy({ + queryCacheKey, + providedTags, + }), + ) + } + // Dummy slice to generate actions const subscriptionSlice = createSlice({ name: `${reducerPath}/subscriptions`, diff --git a/packages/toolkit/src/query/tests/infiniteQueries.test.ts b/packages/toolkit/src/query/tests/infiniteQueries.test.ts index 26126cfa5f..e8f50ea41e 100644 --- a/packages/toolkit/src/query/tests/infiniteQueries.test.ts +++ b/packages/toolkit/src/query/tests/infiniteQueries.test.ts @@ -567,10 +567,10 @@ describe('Infinite queries', () => { ]) thirdPromise.updateSubscriptionOptions({ - pollingInterval: 10, + pollingInterval: 50, }) - await delay(5) + await delay(25) let entry = countersApi.endpoints.counters.select('item')( storeRef.store.getState(), @@ -582,7 +582,7 @@ describe('Infinite queries', () => { { page: 5, hitCounter: 3 }, ]) - await delay(10) + await delay(50) entry = countersApi.endpoints.counters.select('item')( storeRef.store.getState(), diff --git a/packages/toolkit/src/query/tests/optimisticUpserts.test.tsx b/packages/toolkit/src/query/tests/optimisticUpserts.test.tsx index d7099c1c03..946103c5b3 100644 --- a/packages/toolkit/src/query/tests/optimisticUpserts.test.tsx +++ b/packages/toolkit/src/query/tests/optimisticUpserts.test.tsx @@ -81,7 +81,12 @@ const api = createApi({ }), postWithSideEffect: build.query({ query: (id) => `post/${id}`, - providesTags: ['Post'], + providesTags: (result) => { + if (result) { + return [{ type: 'Post', id: result.id } as const] + } + return [] + }, async onCacheEntryAdded(arg, api) { // Verify that lifecycle promise resolution works const res = await api.cacheDataLoaded @@ -419,15 +424,16 @@ describe('upsertQueryEntries', () => { expect(api.endpoints.getPosts.select()(state).data).toBe(posts) - expect(api.endpoints.postWithSideEffect.select('1')(state).data).toBe( - posts[0], - ) - expect(api.endpoints.postWithSideEffect.select('2')(state).data).toBe( - posts[1], - ) - expect(api.endpoints.postWithSideEffect.select('3')(state).data).toBe( - posts[2], - ) + for (const post of posts) { + expect(api.endpoints.postWithSideEffect.select(post.id)(state).data).toBe( + post, + ) + + // Should have added tags + expect(state.api.provided.Post[post.id]).toEqual([ + `postWithSideEffect("${post.id}")`, + ]) + } }) test('Triggers cache lifecycles and side effects', async () => { From a59f90c1b660af917ddf6aea9765856efd535e49 Mon Sep 17 00:00:00 2001 From: Alex Motoc Date: Fri, 7 Mar 2025 19:22:43 +0000 Subject: [PATCH 4/8] add infinite query type support for selectCachedArgsForQuery (#4880) --- packages/toolkit/src/query/core/apiState.ts | 4 ++-- .../toolkit/src/query/core/buildSelectors.ts | 13 +++++++++--- packages/toolkit/src/query/core/module.ts | 5 +++-- .../toolkit/src/query/endpointDefinitions.ts | 9 ++++++++ .../src/query/tests/buildSelector.test-d.ts | 21 +++++++++++++++++++ 5 files changed, 45 insertions(+), 7 deletions(-) diff --git a/packages/toolkit/src/query/core/apiState.ts b/packages/toolkit/src/query/core/apiState.ts index 9a3f6800f0..e86d5b72c8 100644 --- a/packages/toolkit/src/query/core/apiState.ts +++ b/packages/toolkit/src/query/core/apiState.ts @@ -6,7 +6,7 @@ import type { InfiniteQueryDefinition, MutationDefinition, PageParamFrom, - QueryArgFrom, + QueryArgFromAnyQuery, QueryDefinition, ResultTypeFrom, } from '../endpointDefinitions' @@ -190,7 +190,7 @@ type BaseQuerySubState< /** * The argument originally passed into the hook or `initiate` action call */ - originalArgs: QueryArgFrom + originalArgs: QueryArgFromAnyQuery /** * A unique ID associated with the request */ diff --git a/packages/toolkit/src/query/core/buildSelectors.ts b/packages/toolkit/src/query/core/buildSelectors.ts index 3daca29696..fbdc07f02d 100644 --- a/packages/toolkit/src/query/core/buildSelectors.ts +++ b/packages/toolkit/src/query/core/buildSelectors.ts @@ -6,6 +6,7 @@ import type { InfiniteQueryDefinition, MutationDefinition, QueryArgFrom, + QueryArgFromAnyQuery, QueryDefinition, ReducerPathFrom, TagDescription, @@ -29,7 +30,11 @@ import { QueryStatus, getRequestStatusFlags } from './apiState' import { getMutationCacheKey } from './buildSlice' import type { createSelector as _createSelector } from './rtkImports' import { createNextState } from './rtkImports' -import { getNextPageParam, getPreviousPageParam } from './buildThunks' +import { + type AllQueryKeys, + getNextPageParam, + getPreviousPageParam, +} from './buildThunks' export type SkipToken = typeof skipToken /** @@ -372,10 +377,12 @@ export function buildSelectors< ) } - function selectCachedArgsForQuery>( + function selectCachedArgsForQuery< + QueryName extends AllQueryKeys, + >( state: RootState, queryName: QueryName, - ): Array> { + ): Array> { return Object.values(selectQueries(state) as QueryState) .filter( ( diff --git a/packages/toolkit/src/query/core/module.ts b/packages/toolkit/src/query/core/module.ts index 7ccf49ade6..63a1e8479f 100644 --- a/packages/toolkit/src/query/core/module.ts +++ b/packages/toolkit/src/query/core/module.ts @@ -19,6 +19,7 @@ import type { InfiniteQueryDefinition, MutationDefinition, QueryArgFrom, + QueryArgFromAnyQuery, QueryDefinition, TagDescription, } from '../endpointDefinitions' @@ -389,10 +390,10 @@ export interface ApiModules< * * Can be used for mutations that want to do optimistic updates instead of invalidating a set of tags, but don't know exactly what they need to update. */ - selectCachedArgsForQuery: >( + selectCachedArgsForQuery: >( state: RootState, queryName: QueryName, - ) => Array> + ) => Array> } /** * Endpoints based on the input endpoints provided to `createApi`, containing `select` and `action matchers`. diff --git a/packages/toolkit/src/query/endpointDefinitions.ts b/packages/toolkit/src/query/endpointDefinitions.ts index d69b976d21..9765ccf30d 100644 --- a/packages/toolkit/src/query/endpointDefinitions.ts +++ b/packages/toolkit/src/query/endpointDefinitions.ts @@ -1059,6 +1059,15 @@ export type InfiniteQueryArgFrom< D extends BaseEndpointDefinition, > = D extends InfiniteQueryDefinition ? QA : never +export type QueryArgFromAnyQuery< + D extends BaseEndpointDefinition, +> = + D extends InfiniteQueryDefinition + ? InfiniteQueryArgFrom + : D extends QueryDefinition + ? QueryArgFrom + : never + export type ResultTypeFrom> = D extends BaseEndpointDefinition ? RT : unknown diff --git a/packages/toolkit/src/query/tests/buildSelector.test-d.ts b/packages/toolkit/src/query/tests/buildSelector.test-d.ts index 15bf0c6be2..2f117b661a 100644 --- a/packages/toolkit/src/query/tests/buildSelector.test-d.ts +++ b/packages/toolkit/src/query/tests/buildSelector.test-d.ts @@ -73,6 +73,21 @@ describe('type tests', () => { getTodos: build.query({ query: () => '/todos', }), + getInfiniteTodos: build.infiniteQuery({ + infiniteQueryOptions: { + initialPageParam: 0, + maxPages: 3, + getNextPageParam: ( + lastPage, + allPages, + lastPageParam, + allPageParams, + ) => lastPageParam + 1, + }, + query({ pageParam }) { + return `/todos?page=${pageParam}` + }, + }), }), }) @@ -86,5 +101,11 @@ describe('type tests', () => { expectTypeOf( exampleApi.util.selectCachedArgsForQuery(store.getState(), 'getTodos'), ).toEqualTypeOf() + expectTypeOf( + exampleApi.util.selectCachedArgsForQuery( + store.getState(), + 'getInfiniteTodos', + ), + ).toEqualTypeOf() }) }) From 642d79503ed3af7b55938757f6b7ec79282074f6 Mon Sep 17 00:00:00 2001 From: Ben Durrant Date: Fri, 7 Mar 2025 19:24:35 +0000 Subject: [PATCH 5/8] add more Typed wrappers and make sure they're all exported (#4866) --- .../toolkit/src/query/react/buildHooks.ts | 150 +++++++++++++++++- packages/toolkit/src/query/react/index.ts | 9 ++ 2 files changed, 157 insertions(+), 2 deletions(-) diff --git a/packages/toolkit/src/query/react/buildHooks.ts b/packages/toolkit/src/query/react/buildHooks.ts index 4c8ca51bef..913267a409 100644 --- a/packages/toolkit/src/query/react/buildHooks.ts +++ b/packages/toolkit/src/query/react/buildHooks.ts @@ -793,6 +793,22 @@ export type LazyInfiniteQueryTrigger< ): InfiniteQueryActionCreatorResult } +export type TypedLazyInfiniteQueryTrigger< + ResultType, + QueryArg, + PageParam, + BaseQuery extends BaseQueryFn, +> = LazyInfiniteQueryTrigger< + InfiniteQueryDefinition< + QueryArg, + PageParam, + BaseQuery, + string, + ResultType, + string + > +> + interface UseInfiniteQuerySubscriptionOptions< D extends InfiniteQueryDefinition, > extends SubscriptionOptions { @@ -890,6 +906,36 @@ export type InfiniteQueryStateSelector< D extends InfiniteQueryDefinition, > = (state: UseInfiniteQueryStateDefaultResult) => R +export type TypedInfiniteQueryStateSelector< + ResultType, + QueryArg, + PageParam, + BaseQuery extends BaseQueryFn, + SelectedResult extends Record< + string, + any + > = UseInfiniteQueryStateDefaultResult< + InfiniteQueryDefinition< + QueryArg, + PageParam, + BaseQuery, + string, + ResultType, + string + > + >, +> = InfiniteQueryStateSelector< + SelectedResult, + InfiniteQueryDefinition< + QueryArg, + PageParam, + BaseQuery, + string, + ResultType, + string + > +> + /** * A React hook that automatically triggers fetches of data from an endpoint, 'subscribes' the component to the cached data, and reads the request status and cached data from the Redux store. The component will re-render as the loading status changes and the data becomes available. Additionally, it will cache multiple "pages" worth of responses within a single cache entry, and allows fetching more pages forwards and backwards from the current cached pages. * @@ -927,6 +973,22 @@ export type UseInfiniteQuery< 'fetchNextPage' | 'fetchPreviousPage' > +export type TypedUseInfiniteQuery< + ResultType, + QueryArg, + PageParam, + BaseQuery extends BaseQueryFn, +> = UseInfiniteQuery< + InfiniteQueryDefinition< + QueryArg, + PageParam, + BaseQuery, + string, + ResultType, + string + > +> + /** * A React hook that reads the request status and cached data from the Redux store. The component will re-render as the loading status changes and the data becomes available. * @@ -987,6 +1049,33 @@ export type UseInfiniteQueryHookResult< > = UseInfiniteQueryStateResult & Pick, 'refetch'> +export type TypedUseInfiniteQueryHookResult< + ResultType, + QueryArg, + PageParam, + BaseQuery extends BaseQueryFn, + R extends Record = UseInfiniteQueryStateDefaultResult< + InfiniteQueryDefinition< + QueryArg, + PageParam, + BaseQuery, + string, + ResultType, + string + > + >, +> = UseInfiniteQueryHookResult< + InfiniteQueryDefinition< + QueryArg, + PageParam, + BaseQuery, + string, + ResultType, + string + >, + R +> + export type UseInfiniteQueryStateOptions< D extends InfiniteQueryDefinition, R extends Record, @@ -1057,11 +1146,68 @@ export type UseInfiniteQueryStateOptions< selectFromResult?: InfiniteQueryStateSelector } +export type TypedUseInfiniteQueryStateOptions< + ResultType, + QueryArg, + PageParam, + BaseQuery extends BaseQueryFn, + SelectedResult extends Record< + string, + any + > = UseInfiniteQueryStateDefaultResult< + InfiniteQueryDefinition< + QueryArg, + PageParam, + BaseQuery, + string, + ResultType, + string + > + >, +> = UseInfiniteQueryStateOptions< + InfiniteQueryDefinition< + QueryArg, + PageParam, + BaseQuery, + string, + ResultType, + string + >, + SelectedResult +> + export type UseInfiniteQueryStateResult< - _ extends InfiniteQueryDefinition, - R, + D extends InfiniteQueryDefinition, + R = UseInfiniteQueryStateDefaultResult, > = TSHelpersNoInfer +export type TypedUseInfiniteQueryStateResult< + ResultType, + QueryArg, + PageParam, + BaseQuery extends BaseQueryFn, + R = UseInfiniteQueryStateDefaultResult< + InfiniteQueryDefinition< + QueryArg, + PageParam, + BaseQuery, + string, + ResultType, + string + > + >, +> = UseInfiniteQueryStateResult< + InfiniteQueryDefinition< + QueryArg, + PageParam, + BaseQuery, + string, + ResultType, + string + >, + R +> + type UseInfiniteQueryStateBaseResult< D extends InfiniteQueryDefinition, > = InfiniteQuerySubState & { diff --git a/packages/toolkit/src/query/react/index.ts b/packages/toolkit/src/query/react/index.ts index e9fda233ae..7b96482c26 100644 --- a/packages/toolkit/src/query/react/index.ts +++ b/packages/toolkit/src/query/react/index.ts @@ -29,6 +29,15 @@ export type { TypedUseLazyQuerySubscription, TypedUseQueryStateOptions, TypedUseLazyQueryStateResult, + TypedUseInfiniteQuery, + TypedUseInfiniteQueryHookResult, + TypedUseInfiniteQueryStateResult, + TypedUseInfiniteQuerySubscriptionResult, + TypedUseInfiniteQueryStateOptions, + TypedInfiniteQueryStateSelector, + TypedUseInfiniteQuerySubscription, + TypedUseInfiniteQueryState, + TypedLazyInfiniteQueryTrigger, } from './buildHooks' export { UNINITIALIZED_VALUE } from './constants' export { createApi, reactHooksModule, reactHooksModuleName } From 4e358215f68b21e7f7b1dce5ec573d96f054907a Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Fri, 7 Mar 2025 15:40:22 -0500 Subject: [PATCH 6/8] Fix infinite query type portability issues (#4881) * Fix infinite query type portability issues * Add infinite queries to type portability examples --- .../bundler/src/app/services/posts.ts | 25 +++++++++++++++++ .../src/features/posts/PostsManager.tsx | 2 ++ .../nodenext-cjs/src/app/services/posts.ts | 27 +++++++++++++++++++ .../src/features/posts/PostsManager.tsx | 2 ++ .../nodenext-esm/src/app/services/posts.ts | 25 +++++++++++++++++ .../src/features/posts/PostsManager.tsx | 2 ++ packages/toolkit/src/query/core/apiState.ts | 2 +- .../toolkit/src/query/endpointDefinitions.ts | 4 +-- .../toolkit/src/query/react/buildHooks.ts | 4 +-- 9 files changed, 88 insertions(+), 5 deletions(-) diff --git a/examples/type-portability/bundler/src/app/services/posts.ts b/examples/type-portability/bundler/src/app/services/posts.ts index b57c44dea4..1626dd2816 100644 --- a/examples/type-portability/bundler/src/app/services/posts.ts +++ b/examples/type-portability/bundler/src/app/services/posts.ts @@ -73,6 +73,16 @@ export const postsApi = apiSlice.injectEndpoints({ getErrorProne: build.query<{ success: boolean }, void>({ query: () => 'error-prone', }), + getInfinitePosts: build.infiniteQuery({ + queryFn: ({ pageParam = 0 }) => ({ + data: [], + }), + infiniteQueryOptions: { + initialPageParam: 0, + getNextPageParam: (lastPage) => + lastPage.length === 0 ? undefined : lastPage[lastPage.length - 1].id, + }, + }), }), }) @@ -87,6 +97,7 @@ export const { useLazyGetErrorProneQuery, useLazyGetPostQuery, useLazyGetPostsQuery, + useGetInfinitePostsInfiniteQuery, endpoints, enhanceEndpoints, injectEndpoints, @@ -106,6 +117,7 @@ export const { getPosts, login, updatePost, + getInfinitePosts, } = endpoints export const { @@ -182,6 +194,19 @@ export const { useMutation: _____useMutation, } = updatePost +export const { + Types: ______Types, + initiate: ______initiate, + matchFulfilled: ______matchFulfilled, + matchPending: ______matchPending, + matchRejected: ______matchRejected, + name: ______name, + select: ______select, + useInfiniteQueryState: ______useQueryState, + useInfiniteQuery: ______useQuery, + useInfiniteQuerySubscription: ______useQuerySubscription, +} = getInfinitePosts + export const { internal_getRTKQSubscriptions, middlewareRegistered, diff --git a/examples/type-portability/bundler/src/features/posts/PostsManager.tsx b/examples/type-portability/bundler/src/features/posts/PostsManager.tsx index 7474bee286..15c7438b76 100644 --- a/examples/type-portability/bundler/src/features/posts/PostsManager.tsx +++ b/examples/type-portability/bundler/src/features/posts/PostsManager.tsx @@ -8,6 +8,7 @@ import { useGetErrorProneQuery, useGetPostsQuery, useLoginMutation, + useGetInfinitePostsInfiniteQuery, } from '../../app/services/posts' import { logout, selectIsAuthenticated } from '../auth/authSlice' import { PostDetail } from './PostDetail' @@ -70,6 +71,7 @@ export const PostListItem = ({ export const PostList = () => { const { data: posts, isLoading } = useGetPostsQuery() + useGetInfinitePostsInfiniteQuery() const navigate = useNavigate() if (isLoading) { diff --git a/examples/type-portability/nodenext-cjs/src/app/services/posts.ts b/examples/type-portability/nodenext-cjs/src/app/services/posts.ts index 2523a7e7ee..4874386b43 100644 --- a/examples/type-portability/nodenext-cjs/src/app/services/posts.ts +++ b/examples/type-portability/nodenext-cjs/src/app/services/posts.ts @@ -76,6 +76,18 @@ namespace postsModule { getErrorProne: build.query<{ success: boolean }, void>({ query: () => 'error-prone', }), + getInfinitePosts: build.infiniteQuery({ + queryFn: ({ pageParam = 0 }) => ({ + data: [], + }), + infiniteQueryOptions: { + initialPageParam: 0, + getNextPageParam: (lastPage) => + lastPage.length === 0 + ? undefined + : lastPage[lastPage.length - 1].id, + }, + }), }), }) @@ -90,6 +102,7 @@ namespace postsModule { useLazyGetErrorProneQuery, useLazyGetPostQuery, useLazyGetPostsQuery, + useGetInfinitePostsInfiniteQuery, endpoints, enhanceEndpoints, injectEndpoints, @@ -109,6 +122,7 @@ namespace postsModule { getPosts, login, updatePost, + getInfinitePosts, } = endpoints export const { @@ -185,6 +199,19 @@ namespace postsModule { useMutation: _____useMutation, } = updatePost + export const { + Types: ______Types, + initiate: ______initiate, + matchFulfilled: ______matchFulfilled, + matchPending: ______matchPending, + matchRejected: ______matchRejected, + name: ______name, + select: ______select, + useInfiniteQueryState: ______useQueryState, + useInfiniteQuery: ______useQuery, + useInfiniteQuerySubscription: ______useQuerySubscription, + } = getInfinitePosts + export const { internal_getRTKQSubscriptions, middlewareRegistered, diff --git a/examples/type-portability/nodenext-cjs/src/features/posts/PostsManager.tsx b/examples/type-portability/nodenext-cjs/src/features/posts/PostsManager.tsx index 831c0b220f..f75c7bb74c 100644 --- a/examples/type-portability/nodenext-cjs/src/features/posts/PostsManager.tsx +++ b/examples/type-portability/nodenext-cjs/src/features/posts/PostsManager.tsx @@ -18,6 +18,7 @@ import useAddPostMutation = postsModule.useAddPostMutation import useGetErrorProneQuery = postsModule.useGetErrorProneQuery import useGetPostsQuery = postsModule.useGetPostsQuery import useLoginMutation = postsModule.useLoginMutation +import useGetInfinitePostsInfiniteQuery = postsModule.useGetInfinitePostsInfiniteQuery import logout = authSliceModule.logout import selectIsAuthenticated = authSliceModule.selectIsAuthenticated @@ -81,6 +82,7 @@ const PostListItem = ({ const PostList = () => { const { data: posts, isLoading } = useGetPostsQuery() + useGetInfinitePostsInfiniteQuery() const navigate = useNavigate() if (isLoading) { diff --git a/examples/type-portability/nodenext-esm/src/app/services/posts.ts b/examples/type-portability/nodenext-esm/src/app/services/posts.ts index 18fa7a8df7..488507c296 100644 --- a/examples/type-portability/nodenext-esm/src/app/services/posts.ts +++ b/examples/type-portability/nodenext-esm/src/app/services/posts.ts @@ -73,6 +73,16 @@ export const postsApi = apiSlice.injectEndpoints({ getErrorProne: build.query<{ success: boolean }, void>({ query: () => 'error-prone', }), + getInfinitePosts: build.infiniteQuery({ + queryFn: ({ pageParam = 0 }) => ({ + data: [], + }), + infiniteQueryOptions: { + initialPageParam: 0, + getNextPageParam: (lastPage) => + lastPage.length === 0 ? undefined : lastPage[lastPage.length - 1].id, + }, + }), }), }) @@ -87,6 +97,7 @@ export const { useLazyGetErrorProneQuery, useLazyGetPostQuery, useLazyGetPostsQuery, + useGetInfinitePostsInfiniteQuery, endpoints, enhanceEndpoints, injectEndpoints, @@ -106,6 +117,7 @@ export const { getPosts, login, updatePost, + getInfinitePosts, } = endpoints export const { @@ -182,6 +194,19 @@ export const { useMutation: _____useMutation, } = updatePost +export const { + Types: ______Types, + initiate: ______initiate, + matchFulfilled: ______matchFulfilled, + matchPending: ______matchPending, + matchRejected: ______matchRejected, + name: ______name, + select: ______select, + useInfiniteQueryState: ______useQueryState, + useInfiniteQuery: ______useQuery, + useInfiniteQuerySubscription: ______useQuerySubscription, +} = getInfinitePosts + export const { internal_getRTKQSubscriptions, middlewareRegistered, diff --git a/examples/type-portability/nodenext-esm/src/features/posts/PostsManager.tsx b/examples/type-portability/nodenext-esm/src/features/posts/PostsManager.tsx index 3bd5d973c8..c43fec1236 100644 --- a/examples/type-portability/nodenext-esm/src/features/posts/PostsManager.tsx +++ b/examples/type-portability/nodenext-esm/src/features/posts/PostsManager.tsx @@ -6,6 +6,7 @@ import type { Post } from '../../app/services/posts.js' import { useAddPostMutation, useGetErrorProneQuery, + useGetInfinitePostsInfiniteQuery, useGetPostsQuery, useLoginMutation, } from '../../app/services/posts.js' @@ -70,6 +71,7 @@ export const PostListItem = ({ export const PostList = () => { const { data: posts, isLoading } = useGetPostsQuery() + useGetInfinitePostsInfiniteQuery() const navigate = useNavigate() if (isLoading) { diff --git a/packages/toolkit/src/query/core/apiState.ts b/packages/toolkit/src/query/core/apiState.ts index e86d5b72c8..d73584e11e 100644 --- a/packages/toolkit/src/query/core/apiState.ts +++ b/packages/toolkit/src/query/core/apiState.ts @@ -60,7 +60,7 @@ export type InfiniteQueryConfigOptions = { maxPages?: number } -export interface InfiniteData { +export type InfiniteData = { pages: Array pageParams: Array } diff --git a/packages/toolkit/src/query/endpointDefinitions.ts b/packages/toolkit/src/query/endpointDefinitions.ts index 9765ccf30d..d0ced56637 100644 --- a/packages/toolkit/src/query/endpointDefinitions.ts +++ b/packages/toolkit/src/query/endpointDefinitions.ts @@ -544,14 +544,14 @@ export type QueryDefinition< > = BaseEndpointDefinition & QueryExtraOptions -export interface InfiniteQueryTypes< +export type InfiniteQueryTypes< QueryArg, PageParam, BaseQuery extends BaseQueryFn, TagTypes extends string, ResultType, ReducerPath extends string = string, -> extends BaseEndpointTypes { +> = BaseEndpointTypes & { /** * The endpoint definition type. To be used with some internal generic types. * @example diff --git a/packages/toolkit/src/query/react/buildHooks.ts b/packages/toolkit/src/query/react/buildHooks.ts index 913267a409..5930281d27 100644 --- a/packages/toolkit/src/query/react/buildHooks.ts +++ b/packages/toolkit/src/query/react/buildHooks.ts @@ -809,9 +809,9 @@ export type TypedLazyInfiniteQueryTrigger< > > -interface UseInfiniteQuerySubscriptionOptions< +export type UseInfiniteQuerySubscriptionOptions< D extends InfiniteQueryDefinition, -> extends SubscriptionOptions { +> = SubscriptionOptions & { /** * Prevents a query from automatically running. * From 690869c3e44c69f23a70fb1a88c5fccde3ce5d1d Mon Sep 17 00:00:00 2001 From: Ben Durrant Date: Fri, 7 Mar 2025 20:40:35 +0000 Subject: [PATCH 7/8] support passing an external abortsignal to createAsyncThunk (#4860) * support passing an external abortsignal to createAsyncThunk * make our own promiseWithResolvers * jsdoc * add once option * added docs * added no-transpile --- docs/api/createAsyncThunk.mdx | 12 ++++ packages/toolkit/src/createAsyncThunk.ts | 44 +++++++++++++- .../src/tests/createAsyncThunk.test-d.ts | 18 ++++-- .../src/tests/createAsyncThunk.test.ts | 58 ++++++++++++++++--- packages/toolkit/src/utils.ts | 14 +++++ 5 files changed, 129 insertions(+), 17 deletions(-) diff --git a/docs/api/createAsyncThunk.mdx b/docs/api/createAsyncThunk.mdx index 40e805028d..872d146655 100644 --- a/docs/api/createAsyncThunk.mdx +++ b/docs/api/createAsyncThunk.mdx @@ -133,6 +133,18 @@ When dispatched, the thunk will: - if the promise failed and was not handled with `rejectWithValue`, dispatch the `rejected` action with a serialized version of the error value as `action.error` - Return a fulfilled promise containing the final dispatched action (either the `fulfilled` or `rejected` action object) +## Thunk Dispatch Options + +The returned thunk action creator accepts an optional second argument with the following options: + +- `signal`: an optional [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) that will be tracked by the internal abort signal (see [Canceling While Running](#canceling-while-running)) + +```ts no-transpile +const externalController = new AbortController() +dispatch(fetchUserById(123, { signal: externalController.signal })) +externalController.abort() +``` + ## Promise Lifecycle Actions `createAsyncThunk` will generate three Redux action creators using [`createAction`](./createAction.mdx): `pending`, `fulfilled`, and `rejected`. Each lifecycle action creator will be attached to the returned thunk action creator so that your reducer logic can reference the action types and respond to the actions when dispatched. Each action object will contain the current unique `requestId` and `arg` values under `action.meta`. diff --git a/packages/toolkit/src/createAsyncThunk.ts b/packages/toolkit/src/createAsyncThunk.ts index 733003e6d4..b6d51759fe 100644 --- a/packages/toolkit/src/createAsyncThunk.ts +++ b/packages/toolkit/src/createAsyncThunk.ts @@ -246,6 +246,16 @@ export type AsyncThunkAction< unwrap: () => Promise } +/** + * Config provided when calling the async thunk action creator. + */ +export interface AsyncThunkDispatchConfig { + /** + * An external `AbortSignal` that will be tracked by the internal `AbortSignal`. + */ + signal?: AbortSignal +} + type AsyncThunkActionCreator< Returned, ThunkArg, @@ -253,29 +263,42 @@ type AsyncThunkActionCreator< > = IsAny< ThunkArg, // any handling - (arg: ThunkArg) => AsyncThunkAction, + ( + arg: ThunkArg, + config?: AsyncThunkDispatchConfig, + ) => AsyncThunkAction, // unknown handling unknown extends ThunkArg - ? (arg: ThunkArg) => AsyncThunkAction // argument not specified or specified as void or undefined + ? ( + arg: ThunkArg, + config?: AsyncThunkDispatchConfig, + ) => AsyncThunkAction // argument not specified or specified as void or undefined : [ThunkArg] extends [void] | [undefined] - ? () => AsyncThunkAction // argument contains void + ? ( + arg?: undefined, + config?: AsyncThunkDispatchConfig, + ) => AsyncThunkAction // argument contains void : [void] extends [ThunkArg] // make optional ? ( arg?: ThunkArg, + config?: AsyncThunkDispatchConfig, ) => AsyncThunkAction // argument contains undefined : [undefined] extends [ThunkArg] ? WithStrictNullChecks< // with strict nullChecks: make optional ( arg?: ThunkArg, + config?: AsyncThunkDispatchConfig, ) => AsyncThunkAction, // without strict null checks this will match everything, so don't make it optional ( arg: ThunkArg, + config?: AsyncThunkDispatchConfig, ) => AsyncThunkAction > // default case: normal argument : ( arg: ThunkArg, + config?: AsyncThunkDispatchConfig, ) => AsyncThunkAction > @@ -492,6 +515,8 @@ type CreateAsyncThunk = > } +const externalAbortMessage = 'External signal was aborted' + export const createAsyncThunk = /* @__PURE__ */ (() => { function createAsyncThunk< Returned, @@ -575,6 +600,7 @@ export const createAsyncThunk = /* @__PURE__ */ (() => { function actionCreator( arg: ThunkArg, + { signal }: AsyncThunkDispatchConfig = {}, ): AsyncThunkAction> { return (dispatch, getState, extra) => { const requestId = options?.idGenerator @@ -590,6 +616,18 @@ export const createAsyncThunk = /* @__PURE__ */ (() => { abortController.abort() } + if (signal) { + if (signal.aborted) { + abort(externalAbortMessage) + } else { + signal.addEventListener( + 'abort', + () => abort(externalAbortMessage), + { once: true }, + ) + } + } + const promise = (async function () { let finalAction: ReturnType try { diff --git a/packages/toolkit/src/tests/createAsyncThunk.test-d.ts b/packages/toolkit/src/tests/createAsyncThunk.test-d.ts index a6978d4838..19163f50a0 100644 --- a/packages/toolkit/src/tests/createAsyncThunk.test-d.ts +++ b/packages/toolkit/src/tests/createAsyncThunk.test-d.ts @@ -15,6 +15,7 @@ import { import type { TSVersion } from '@phryneas/ts-version' import type { AxiosError } from 'axios' import apiRequest from 'axios' +import type { AsyncThunkDispatchConfig } from '@internal/createAsyncThunk' const defaultDispatch = (() => {}) as ThunkDispatch<{}, any, UnknownAction> const unknownAction = { type: 'foo' } as UnknownAction @@ -269,7 +270,9 @@ describe('type tests', () => { expectTypeOf(asyncThunk).toMatchTypeOf<() => any>() - expectTypeOf(asyncThunk).parameters.toEqualTypeOf<[]>() + expectTypeOf(asyncThunk).parameters.toEqualTypeOf< + [undefined?, AsyncThunkDispatchConfig?] + >() expectTypeOf(asyncThunk).returns.toBeFunction() }) @@ -279,7 +282,9 @@ describe('type tests', () => { expectTypeOf(asyncThunk).toMatchTypeOf<() => any>() - expectTypeOf(asyncThunk).parameters.toEqualTypeOf<[]>() + expectTypeOf(asyncThunk).parameters.toEqualTypeOf< + [undefined?, AsyncThunkDispatchConfig?] + >() }) test('one argument, specified as void: asyncThunk has no argument', () => { @@ -388,13 +393,14 @@ describe('type tests', () => { expectTypeOf(asyncThunk).toBeCallableWith() - // @ts-expect-error cannot be called with an argument, even if the argument is `undefined` expectTypeOf(asyncThunk).toBeCallableWith(undefined) // cannot be called with an argument expectTypeOf(asyncThunk).parameter(0).not.toBeAny() - expectTypeOf(asyncThunk).parameters.toEqualTypeOf<[]>() + expectTypeOf(asyncThunk).parameters.toEqualTypeOf< + [undefined?, AsyncThunkDispatchConfig?] + >() }) test('two arguments, first specified as void: asyncThunk has no argument', () => { @@ -409,7 +415,9 @@ describe('type tests', () => { // cannot be called with an argument expectTypeOf(asyncThunk).parameter(0).not.toBeAny() - expectTypeOf(asyncThunk).parameters.toEqualTypeOf<[]>() + expectTypeOf(asyncThunk).parameters.toEqualTypeOf< + [undefined?, AsyncThunkDispatchConfig?] + >() }) test('two arguments, first specified as number|undefined: asyncThunk has optional number argument', () => { diff --git a/packages/toolkit/src/tests/createAsyncThunk.test.ts b/packages/toolkit/src/tests/createAsyncThunk.test.ts index ef67002899..3dcda30098 100644 --- a/packages/toolkit/src/tests/createAsyncThunk.test.ts +++ b/packages/toolkit/src/tests/createAsyncThunk.test.ts @@ -1,5 +1,5 @@ import { noop } from '@internal/listenerMiddleware/utils' -import { delay } from '@internal/utils' +import { delay, promiseWithResolvers } from '@internal/utils' import type { CreateAsyncThunkFunction, UnknownAction } from '@reduxjs/toolkit' import { configureStore, @@ -879,17 +879,18 @@ test('`condition` will see state changes from a synchronously invoked asyncThunk expect(onStart).toHaveBeenCalledTimes(2) }) +const getNewStore = () => + configureStore({ + reducer(actions: UnknownAction[] = [], action) { + return [...actions, action] + }, + }) + describe('meta', () => { - const getNewStore = () => - configureStore({ - reducer(actions = [], action) { - return [...actions, action] - }, - }) - const store = getNewStore() + let store = getNewStore() beforeEach(() => { - const store = getNewStore() + store = getNewStore() }) test('pendingMeta', () => { @@ -1003,3 +1004,42 @@ describe('meta', () => { expect(result.error).toEqual('serialized!') }) }) + +describe('dispatch config', () => { + let store = getNewStore() + + beforeEach(() => { + store = getNewStore() + }) + test('accepts external signal', async () => { + const asyncThunk = createAsyncThunk('test', async (_: void, { signal }) => { + signal.throwIfAborted() + const { promise, reject } = promiseWithResolvers() + signal.addEventListener('abort', () => reject(signal.reason)) + return promise + }) + + const abortController = new AbortController() + const promise = store.dispatch( + asyncThunk(undefined, { signal: abortController.signal }), + ) + abortController.abort() + await expect(promise.unwrap()).rejects.toThrow( + 'External signal was aborted', + ) + }) + test('handles already aborted external signal', async () => { + const asyncThunk = createAsyncThunk('test', async (_: void, { signal }) => { + signal.throwIfAborted() + const { promise, reject } = promiseWithResolvers() + signal.addEventListener('abort', () => reject(signal.reason)) + return promise + }) + + const signal = AbortSignal.abort() + const promise = store.dispatch(asyncThunk(undefined, { signal })) + await expect(promise.unwrap()).rejects.toThrow( + 'Aborted due to condition callback returning false.', + ) + }) +}) diff --git a/packages/toolkit/src/utils.ts b/packages/toolkit/src/utils.ts index 6607f4b339..1b7955a2e9 100644 --- a/packages/toolkit/src/utils.ts +++ b/packages/toolkit/src/utils.ts @@ -109,3 +109,17 @@ export function getOrInsertComputed( return map.set(key, compute(key)).get(key) as V } + +export function promiseWithResolvers(): { + promise: Promise + resolve: (value: T | PromiseLike) => void + reject: (reason?: any) => void +} { + let resolve: any + let reject: any + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} From ac26766267f663d7d8251008a2e5f8b3166396ff Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Fri, 7 Mar 2025 15:48:37 -0500 Subject: [PATCH 8/8] Release 2.6.1 --- packages/toolkit/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/toolkit/package.json b/packages/toolkit/package.json index 81a5a74b17..b92ae7f8ec 100644 --- a/packages/toolkit/package.json +++ b/packages/toolkit/package.json @@ -1,6 +1,6 @@ { "name": "@reduxjs/toolkit", - "version": "2.6.0", + "version": "2.6.1", "description": "The official, opinionated, batteries-included toolset for efficient Redux development", "author": "Mark Erikson ", "license": "MIT",