From bcc3e0091d2fa784da2a9ec25bb9055deb26becc Mon Sep 17 00:00:00 2001 From: Matt Sutkowski Date: Sat, 27 Mar 2021 09:48:59 -0700 Subject: [PATCH] Add `utils.invalidateEntities` (#190) --- src/core/buildInitiate.ts | 2 +- src/core/buildMiddleware.ts | 24 ++++++++-- src/core/buildThunks.ts | 2 +- src/core/module.ts | 11 +++-- src/react-hooks/ApiProvider.tsx | 2 +- src/react-hooks/buildHooks.ts | 2 +- test/buildHooks.test.tsx | 3 +- test/buildMiddleware.test.tsx | 82 +++++++++++++++++++++++++++++++++ test/helpers.tsx | 26 +++++++++++ test/matchers.test.tsx | 35 ++++---------- test/optimisticUpdates.test.tsx | 7 +-- 11 files changed, 152 insertions(+), 44 deletions(-) create mode 100644 test/buildMiddleware.test.tsx diff --git a/src/core/buildInitiate.ts b/src/core/buildInitiate.ts index 5bd653e4..7d4e7124 100644 --- a/src/core/buildInitiate.ts +++ b/src/core/buildInitiate.ts @@ -81,7 +81,7 @@ export function buildInitiate({ serializeQueryArgs: InternalSerializeQueryArgs; queryThunk: AsyncThunk, {}>; mutationThunk: AsyncThunk, {}>; - api: Api; + api: Api; }) { const { unsubscribeQueryResult, unsubscribeMutationResult, updateSubscriptionOptions } = api.internalActions; return { buildInitiateQuery, buildInitiateMutation }; diff --git a/src/core/buildMiddleware.ts b/src/core/buildMiddleware.ts index c7441fd3..3f0cc96c 100644 --- a/src/core/buildMiddleware.ts +++ b/src/core/buildMiddleware.ts @@ -1,4 +1,4 @@ -import { AnyAction, AsyncThunk, Middleware, MiddlewareAPI, ThunkDispatch } from '@reduxjs/toolkit'; +import { AnyAction, AsyncThunk, createAction, Middleware, MiddlewareAPI, ThunkDispatch } from '@reduxjs/toolkit'; import { QueryCacheKey, QueryStatus, QuerySubState, QuerySubstateIdentifier, RootState, Subscribers } from './apiState'; import { Api, ApiContext } from '../apiTypes'; import { MutationThunkArg, QueryThunkArg, ThunkResult } from './buildThunks'; @@ -14,7 +14,11 @@ import { flatten } from '../utils'; type QueryStateMeta = Record; type TimeoutId = ReturnType; -export function buildMiddleware({ +export function buildMiddleware< + Definitions extends EndpointDefinitions, + ReducerPath extends string, + EntityTypes extends string +>({ reducerPath, context, context: { endpointDefinitions }, @@ -27,15 +31,21 @@ export function buildMiddleware; queryThunk: AsyncThunk, {}>; mutationThunk: AsyncThunk, {}>; - api: Api; + api: Api; assertEntityType: AssertEntityTypes; }) { type MWApi = MiddlewareAPI, RootState>; - const currentRemovalTimeouts: QueryStateMeta = {}; const { removeQueryResult, unsubscribeQueryResult, updateSubscriptionOptions } = api.internalActions; const currentPolls: QueryStateMeta<{ nextPollTimestamp: number; timeout?: TimeoutId; pollingInterval: number }> = {}; + + const actions = { + invalidateEntities: createAction>>( + `${reducerPath}/invalidateEntities` + ), + }; + const middleware: Middleware<{}, RootState, ThunkDispatch> = ( mwApi ) => (next) => (action) => { @@ -53,6 +63,10 @@ export function buildMiddleware, { status: QueryStatus.uninitialized }>, diff --git a/src/core/buildThunks.ts b/src/core/buildThunks.ts index 8f5c21f5..376bbc05 100644 --- a/src/core/buildThunks.ts +++ b/src/core/buildThunks.ts @@ -133,7 +133,7 @@ export function buildThunks< reducerPath: ReducerPath; context: ApiContext; serializeQueryArgs: InternalSerializeQueryArgs>; - api: Api; + api: Api; }) { type InternalQueryArgs = BaseQueryArg; type State = RootState; diff --git a/src/core/module.ts b/src/core/module.ts index d80d3ae4..52210591 100644 --- a/src/core/module.ts +++ b/src/core/module.ts @@ -2,7 +2,7 @@ * Note: this file should import all other files for type discovery and declaration merging */ import { buildThunks, PatchQueryResultThunk, UpdateQueryResultThunk } from './buildThunks'; -import { AnyAction, Middleware, Reducer, ThunkAction, ThunkDispatch } from '@reduxjs/toolkit'; +import { ActionCreatorWithPayload, AnyAction, Middleware, Reducer, ThunkAction, ThunkDispatch } from '@reduxjs/toolkit'; import { EndpointDefinitions, QueryArgFrom, @@ -11,6 +11,7 @@ import { AssertEntityTypes, isQueryDefinition, isMutationDefinition, + FullEntityDescription, } from '../endpointDefinitions'; import { CombinedState, QueryKeys, RootState } from './apiState'; import './buildSelectors'; @@ -55,6 +56,7 @@ declare module '../apiTypes' { ): ThunkAction; updateQueryResult: UpdateQueryResultThunk>; patchQueryResult: PatchQueryResultThunk>; + invalidateEntities: ActionCreatorWithPayload>, string>; }; // If you actually care about the return value, use useQuery usePrefetch>( @@ -87,7 +89,7 @@ export interface ApiEndpointMutation< Definitions extends EndpointDefinitions > {} -export type InternalActions = SliceActions & { +export type ListenerActions = { /** * Will cause the RTK Query middleware to trigger any refetchOnReconnect-related behavior * @link https://rtk-query-docs.netlify.app/api/setupListeners @@ -102,6 +104,8 @@ export type InternalActions = SliceActions & { onFocusLost: typeof onFocusLost; }; +export type InternalActions = SliceActions & ListenerActions; + export const coreModule = (): Module => ({ name: coreModuleName, init( @@ -168,7 +172,7 @@ export const coreModule = (): Module => ({ safeAssign(api.util, { patchQueryResult, updateQueryResult, prefetchThunk }); safeAssign(api.internalActions, sliceActions); - const { middleware } = buildMiddleware({ + const { middleware, actions: middlewareActions } = buildMiddleware({ reducerPath, context, queryThunk, @@ -176,6 +180,7 @@ export const coreModule = (): Module => ({ api, assertEntityType, }); + safeAssign(api.util, middlewareActions); safeAssign(api, { reducer: reducer as any, middleware }); diff --git a/src/react-hooks/ApiProvider.tsx b/src/react-hooks/ApiProvider.tsx index 66013ce2..0c5c70fe 100644 --- a/src/react-hooks/ApiProvider.tsx +++ b/src/react-hooks/ApiProvider.tsx @@ -10,7 +10,7 @@ import { Api } from '../apiTypes'; * conflict with each other - please use the traditional redux setup * in that case. */ -export function ApiProvider>(props: { +export function ApiProvider>(props: { children: any; api: A; setupListeners?: Parameters[1]; diff --git a/src/react-hooks/buildHooks.ts b/src/react-hooks/buildHooks.ts index cefa1c69..7b117936 100644 --- a/src/react-hooks/buildHooks.ts +++ b/src/react-hooks/buildHooks.ts @@ -148,7 +148,7 @@ export function buildHooks({ api, moduleOptions: { batch, useDispatch, useSelector }, }: { - api: Api; + api: Api; moduleOptions: Required; }) { return { buildQueryHooks, buildMutationHook, usePrefetch }; diff --git a/test/buildHooks.test.tsx b/test/buildHooks.test.tsx index 072c4413..68788673 100644 --- a/test/buildHooks.test.tsx +++ b/test/buildHooks.test.tsx @@ -509,7 +509,7 @@ describe('hooks tests', () => { }); }); - test('usePrefetch respects `ifOlderThan` when it evaluates to `true`', async () => { + test('usePrefetch respects ifOlderThan when it evaluates to true', async () => { const { usePrefetch } = api; const USER_ID = 47; @@ -517,7 +517,6 @@ describe('hooks tests', () => { // Load the initial query const { isFetching } = api.endpoints.getUser.useQuery(USER_ID); const prefetchUser = usePrefetch('getUser', { ifOlderThan: 0.2 }); - return (
{String(isFetching)}
diff --git a/test/buildMiddleware.test.tsx b/test/buildMiddleware.test.tsx new file mode 100644 index 00000000..fa9ac6e1 --- /dev/null +++ b/test/buildMiddleware.test.tsx @@ -0,0 +1,82 @@ +import { createApi } from '@rtk-incubator/rtk-query/react'; +import { actionsReducer, matchSequence, setupApiStore, waitMs } from './helpers'; + +const baseQuery = (args?: any) => ({ data: args }); +const api = createApi({ + baseQuery, + entityTypes: ['Banana', 'Bread'], + endpoints: (build) => ({ + getBanana: build.query({ + query(id) { + return { url: `banana/${id}` }; + }, + provides: ['Banana'], + }), + getBread: build.query({ + query(id) { + return { url: `bread/${id}` }; + }, + provides: ['Bread'], + }), + }), +}); +const { getBanana, getBread } = api.endpoints; + +const storeRef = setupApiStore(api, { + ...actionsReducer, +}); + +it('invalidates the specified entities', async () => { + await storeRef.store.dispatch(getBanana.initiate(1)); + matchSequence(storeRef.store.getState().actions, getBanana.matchPending, getBanana.matchFulfilled); + + await storeRef.store.dispatch(api.util.invalidateEntities(['Banana', 'Bread'])); + + // Slight pause to let the middleware run and such + await waitMs(20); + + const firstSequence = [ + getBanana.matchPending, + getBanana.matchFulfilled, + api.util.invalidateEntities.match, + getBanana.matchPending, + getBanana.matchFulfilled, + ]; + matchSequence(storeRef.store.getState().actions, ...firstSequence); + + await storeRef.store.dispatch(getBread.initiate(1)); + await storeRef.store.dispatch(api.util.invalidateEntities([{ type: 'Bread' }])); + + await waitMs(20); + + matchSequence( + storeRef.store.getState().actions, + ...firstSequence, + getBread.matchPending, + getBread.matchFulfilled, + api.util.invalidateEntities.match, + getBread.matchPending, + getBread.matchFulfilled + ); +}); + +describe.skip('TS only tests', () => { + it('should allow for an array of string EntityTypes', () => { + api.util.invalidateEntities(['Banana', 'Bread']); + }); + it('should allow for an array of full EntityTypes descriptions', () => { + api.util.invalidateEntities([{ type: 'Banana' }, { type: 'Bread', id: 1 }]); + }); + + it('should allow for a mix of full descriptions as well as plain strings', () => { + api.util.invalidateEntities(['Banana', { type: 'Bread', id: 1 }]); + }); + it('should error when using non-existing EntityTypes', () => { + // @ts-expect-error + api.util.invalidateEntities(['Missing Entity']); + }); + it('should error when using non-existing EntityTypes in the full format', () => { + // @ts-expect-error + api.util.invalidateEntities([{ type: 'Missing' }]); + }); +}); diff --git a/test/helpers.tsx b/test/helpers.tsx index 974c33f7..a6af32a1 100644 --- a/test/helpers.tsx +++ b/test/helpers.tsx @@ -42,6 +42,32 @@ export const hookWaitFor = async (cb: () => void, time = 2000) => { } }; +export function matchSequence(_actions: AnyAction[], ...matchers: Array<(arg: any) => boolean>) { + const actions = _actions.concat(); + actions.shift(); // remove INIT + expect(matchers.length).toBe(actions.length); + for (let i = 0; i < matchers.length; i++) { + expect(matchers[i](actions[i])).toBe(true); + } +} + +export function notMatchSequence(_actions: AnyAction[], ...matchers: Array boolean>>) { + const actions = _actions.concat(); + actions.shift(); // remove INIT + expect(matchers.length).toBe(actions.length); + for (let i = 0; i < matchers.length; i++) { + for (const matcher of matchers[i]) { + expect(matcher(actions[i])).not.toBe(true); + } + } +} + +export const actionsReducer = { + actions: (state: AnyAction[] = [], action: AnyAction) => { + return [...state, action]; + }, +}; + export function setupApiStore< A extends { reducerPath: any; reducer: Reducer; middleware: Middleware }, R extends Record> diff --git a/test/matchers.test.tsx b/test/matchers.test.tsx index 81556299..89693a37 100644 --- a/test/matchers.test.tsx +++ b/test/matchers.test.tsx @@ -1,7 +1,14 @@ -import { AnyAction, createSlice, SerializedError } from '@reduxjs/toolkit'; +import { createSlice, SerializedError } from '@reduxjs/toolkit'; import { createApi, fetchBaseQuery } from '@rtk-incubator/rtk-query/react'; import { renderHook, act } from '@testing-library/react-hooks'; -import { expectExactType, hookWaitFor, setupApiStore } from './helpers'; +import { + actionsReducer, + expectExactType, + hookWaitFor, + matchSequence, + notMatchSequence, + setupApiStore, +} from './helpers'; interface ResultType { result: 'complex'; @@ -28,31 +35,9 @@ const api = createApi({ }); const storeRef = setupApiStore(api, { - actions(state: AnyAction[] = [], action: AnyAction) { - return [...state, action]; - }, + ...actionsReducer, }); -function matchSequence(_actions: AnyAction[], ...matchers: Array<(arg: any) => boolean>) { - const actions = _actions.concat(); - actions.shift(); // remove INIT - expect(matchers.length).toBe(actions.length); - for (let i = 0; i < matchers.length; i++) { - expect(matchers[i](actions[i])).toBe(true); - } -} - -function notMatchSequence(_actions: AnyAction[], ...matchers: Array boolean>>) { - const actions = _actions.concat(); - actions.shift(); // remove INIT - expect(matchers.length).toBe(actions.length); - for (let i = 0; i < matchers.length; i++) { - for (const matcher of matchers[i]) { - expect(matcher(actions[i])).not.toBe(true); - } - } -} - const { mutationFail, mutationSuccess, mutationSuccess2, queryFail, querySuccess, querySuccess2 } = api.endpoints; const otherEndpointMatchers = [ diff --git a/test/optimisticUpdates.test.tsx b/test/optimisticUpdates.test.tsx index a8d716a0..80149eb2 100644 --- a/test/optimisticUpdates.test.tsx +++ b/test/optimisticUpdates.test.tsx @@ -1,7 +1,6 @@ -import { AnyAction } from '@reduxjs/toolkit'; import { createApi } from '@rtk-incubator/rtk-query/react'; import { renderHook, act } from '@testing-library/react-hooks'; -import { hookWaitFor, setupApiStore, waitMs } from './helpers'; +import { actionsReducer, hookWaitFor, setupApiStore, waitMs } from './helpers'; import { Patch } from 'immer'; interface Post { @@ -40,9 +39,7 @@ const api = createApi({ }); const storeRef = setupApiStore(api, { - actions(state: AnyAction[] = [], action: AnyAction) { - return [...state, action]; - }, + ...actionsReducer, }); describe('basic lifecycle', () => {