Skip to content

Commit a18661d

Browse files
msutkowskiphryneas
andauthored
Fix return types for matchers (#70)
* Fix return types for matchers * use action matchers, retype matched types, tests * extract error types for thunk matchers Co-authored-by: Lenz Weber <[email protected]>
1 parent 5d0a71f commit a18661d

File tree

5 files changed

+95
-51
lines changed

5 files changed

+95
-51
lines changed

examples/react/src/app/services/posts.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { createApi, fetchBaseQuery, retry } from '@rtk-incubator/rtk-query';
2-
import { setCredentials } from 'src/features/auth/authSlice';
32
import { RootState } from '../store';
43

54
export interface Post {
@@ -30,19 +29,20 @@ const baseQuery = fetchBaseQuery({
3029
},
3130
});
3231

33-
const staggeredBaseQuery = retry(baseQuery, { maxRetries: 6 });
32+
const baseQueryWithRetry = retry(baseQuery, { maxRetries: 6 });
3433

3534
export const postApi = createApi({
3635
reducerPath: 'postsApi', // We only specify this because there are many services. This would not be common in most applications
37-
baseQuery: staggeredBaseQuery,
36+
baseQuery: baseQueryWithRetry,
3837
entityTypes: ['Posts'],
3938
endpoints: (build) => ({
4039
login: build.mutation<{ token: string; user: User }, any>({
4140
query: (credentials: any) => ({ url: 'login', method: 'POST', body: credentials }),
42-
onSuccess: (arg, mutationApi, result) => {
43-
const { token, user } = result;
44-
// Set our user and token in the store for future requests
45-
mutationApi.dispatch(setCredentials({ token, user }));
41+
extraOptions: {
42+
backoff: () => {
43+
// We intentionally error once on login, and this breaks out of retrying. The next login attempt will succeed.
44+
retry.fail({ fake: 'error' });
45+
},
4646
},
4747
}),
4848
getPosts: build.query<PostsResponse, void>({
Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
2-
import { User } from '../../app/services/posts';
1+
import { createSlice } from '@reduxjs/toolkit';
2+
import { postApi, User } from '../../app/services/posts';
33
import { RootState } from '../../app/store';
44

55
const initialState = {
@@ -12,16 +12,25 @@ const slice = createSlice({
1212
name: 'auth',
1313
initialState,
1414
reducers: {
15-
setCredentials: (state, { payload }: PayloadAction<{ token: string; user: User }>) => {
16-
state.user = payload.user;
17-
state.token = payload.token;
18-
state.isAuthenticated = true;
19-
},
2015
logout: () => initialState,
2116
},
17+
extraReducers: (builder) => {
18+
builder
19+
.addMatcher(postApi.endpoints.login.matchPending, (state, action) => {
20+
console.log('pending', action);
21+
})
22+
.addMatcher(postApi.endpoints.login.matchFulfilled, (state, action) => {
23+
console.log('fulfilled', action);
24+
state.user = action.payload.result.user;
25+
state.token = action.payload.result.token;
26+
})
27+
.addMatcher(postApi.endpoints.login.matchRejected, (state, action) => {
28+
console.log('rejected', action);
29+
});
30+
},
2231
});
2332

24-
export const { setCredentials, logout } = slice.actions;
33+
export const { logout } = slice.actions;
2534
export default slice.reducer;
2635

2736
export const selectIsAuthenticated = (state: RootState) => state.auth.isAuthenticated;

examples/react/src/mocks/handlers.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,10 @@ export const handlers = [
5151
}),
5252

5353
rest.post('/login', (req, res, ctx) => {
54-
return res(ctx.json({ token }));
54+
return res.once(ctx.json({ message: 'i fail once' }), ctx.status(500));
55+
}),
56+
rest.post('/login', (req, res, ctx) => {
57+
return res(ctx.json({ token, user: { first_name: 'Test', last_name: 'User' } }));
5558
}),
5659

5760
rest.get('/posts', (req, res, ctx) => {

src/buildThunks.ts

Lines changed: 27 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { InternalSerializeQueryArgs } from '.';
2-
import { Api, ApiEndpointQuery, BaseQueryFn, BaseQueryArg } from './apiTypes';
2+
import { Api, ApiEndpointQuery, BaseQueryFn, BaseQueryArg, BaseQueryError } from './apiTypes';
33
import { InternalRootState, QueryKeys, QueryStatus, QuerySubstateIdentifier } from './apiState';
44
import { StartQueryActionCreatorOptions } from './buildActionMaps';
55
import {
@@ -11,12 +11,11 @@ import {
1111
QueryDefinition,
1212
ResultTypeFrom,
1313
} from './endpointDefinitions';
14-
import { Draft } from '@reduxjs/toolkit';
14+
import { Draft, isAllOf, isFulfilled, isPending, isRejected } from '@reduxjs/toolkit';
1515
import { Patch, isDraftable, produceWithPatches, enablePatches } from 'immer';
1616
import { AnyAction, createAsyncThunk, ThunkAction, ThunkDispatch, AsyncThunk } from '@reduxjs/toolkit';
1717

1818
import { PrefetchOptions } from './buildHooks';
19-
import { Id } from './tsHelpers';
2019

2120
declare module './apiTypes' {
2221
export interface ApiEndpointQuery<
@@ -30,37 +29,33 @@ declare module './apiTypes' {
3029
> extends Matchers<MutationThunk, Definition> {}
3130
}
3231

33-
export type PendingAction<
32+
type EndpointThunk<
3433
Thunk extends AsyncThunk<any, any, any>,
3534
Definition extends EndpointDefinition<any, any, any, any>
36-
> = Definition extends EndpointDefinition<infer QueryArg, any, any, infer ResultType>
37-
? OverrideActionProps<ReturnType<Thunk['pending']>, QueryArg>
35+
> = Definition extends EndpointDefinition<infer QueryArg, infer BaseQueryFn, any, infer ResultType>
36+
? Thunk extends AsyncThunk<infer ATResult, infer ATArg, infer ATConfig>
37+
? AsyncThunk<
38+
ATResult & { result: ResultType },
39+
ATArg & { originalArgs: QueryArg },
40+
ATConfig & { rejectValue: BaseQueryError<BaseQueryFn> }
41+
>
42+
: never
3843
: never;
3944

45+
export type PendingAction<
46+
Thunk extends AsyncThunk<any, any, any>,
47+
Definition extends EndpointDefinition<any, any, any, any>
48+
> = ReturnType<EndpointThunk<Thunk, Definition>['pending']>;
49+
4050
export type FulfilledAction<
4151
Thunk extends AsyncThunk<any, any, any>,
4252
Definition extends EndpointDefinition<any, any, any, any>
43-
> = Definition extends EndpointDefinition<infer QueryArg, any, any, infer ResultType>
44-
? OverrideActionProps<ReturnType<Thunk['fulfilled']>, QueryArg, ResultType>
45-
: never;
53+
> = ReturnType<EndpointThunk<Thunk, Definition>['fulfilled']>;
4654

4755
export type RejectedAction<
4856
Thunk extends AsyncThunk<any, any, any>,
4957
Definition extends EndpointDefinition<any, any, any, any>
50-
> = Definition extends EndpointDefinition<infer QueryArg, any, any, infer ResultType>
51-
? OverrideActionProps<ReturnType<Thunk['rejected']>, QueryArg>
52-
: never;
53-
54-
type OverrideActionProps<
55-
A extends { payload?: any; meta: { arg: { originalArgs: any } } },
56-
QueryArg,
57-
Payload = void
58-
> = Id<
59-
Omit<A, 'payload' | 'meta'> & {
60-
payload: [void] extends [Payload] ? A['payload'] : Payload;
61-
meta: Id<Omit<A['meta'], 'arg'> & { arg: Id<Omit<A['meta']['arg'], 'originalArgs'> & { originalArgs: QueryArg }> }>;
62-
}
63-
>;
58+
> = ReturnType<EndpointThunk<Thunk, Definition>['rejected']>;
6459

6560
export type Matcher<M> = (value: any) => value is M;
6661

@@ -292,17 +287,18 @@ export function buildThunks<
292287
}
293288
};
294289

290+
function matchesEndpoint(endpoint: string) {
291+
return (action: any): action is AnyAction => action?.meta?.arg?.endpoint === endpoint;
292+
}
293+
295294
function buildMatchThunkActions<
296295
Thunk extends AsyncThunk<any, QueryThunkArg<any>, any> | AsyncThunk<any, MutationThunkArg<any>, any>
297-
>(thunk: Thunk, endpoint: string): Matchers<Thunk, any> {
296+
>(thunk: Thunk, endpoint: string) {
298297
return {
299-
matchPending: (action): action is PendingAction<Thunk, any> =>
300-
thunk.pending.match(action) && action.meta.arg.endpoint === endpoint,
301-
matchFulfilled: (action): action is FulfilledAction<Thunk, any> =>
302-
thunk.fulfilled.match(action) && action.meta.arg.endpoint === endpoint,
303-
matchRejected: (action): action is RejectedAction<Thunk, any> =>
304-
thunk.rejected.match(action) && action.meta.arg.endpoint === endpoint,
305-
};
298+
matchPending: isAllOf(isPending(thunk), matchesEndpoint(endpoint)),
299+
matchFulfilled: isAllOf(isFulfilled(thunk), matchesEndpoint(endpoint)),
300+
matchRejected: isAllOf(isRejected(thunk), matchesEndpoint(endpoint)),
301+
} as Matchers<Thunk, any>;
306302
}
307303

308304
return { queryThunk, mutationThunk, prefetchThunk, updateQueryResult, patchQueryResult, buildMatchThunkActions };

test/matchers.test.tsx

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,23 @@
1-
import { AnyAction } from '@reduxjs/toolkit';
1+
import { AnyAction, createSlice, SerializedError } from '@reduxjs/toolkit';
22
import { createApi, fetchBaseQuery } from '@rtk-incubator/rtk-query';
33
import { renderHook, act } from '@testing-library/react-hooks';
4-
import { hookWaitFor, setupApiStore } from './helpers';
4+
import { expectExactType, hookWaitFor, setupApiStore } from './helpers';
5+
6+
interface ResultType {
7+
result: 'complex';
8+
}
9+
10+
interface ArgType {
11+
foo: 'bar';
12+
count: 3;
13+
}
514

615
const baseQuery = fetchBaseQuery({ baseUrl: 'http://example.com' });
716
const api = createApi({
817
baseQuery,
918
endpoints(build) {
1019
return {
11-
querySuccess: build.query({ query: () => '/success' }),
20+
querySuccess: build.query<ResultType, ArgType>({ query: () => '/success' }),
1221
querySuccess2: build.query({ query: () => '/success' }),
1322
queryFail: build.query({ query: () => '/fail' }),
1423
mutationSuccess: build.mutation({ query: () => ({ url: '/success', method: 'POST' }) }),
@@ -57,7 +66,7 @@ const otherEndpointMatchers = [
5766

5867
test('matches query pending & fulfilled actions for the own endpoint', async () => {
5968
const endpoint = querySuccess;
60-
const { result } = renderHook(() => endpoint.useQuery({}), { wrapper: storeRef.wrapper });
69+
const { result } = renderHook(() => endpoint.useQuery({} as any), { wrapper: storeRef.wrapper });
6170
await hookWaitFor(() => expect(result.current.isLoading).toBeFalsy());
6271

6372
matchSequence(storeRef.store.getState().actions, endpoint.matchPending, endpoint.matchFulfilled);
@@ -105,3 +114,30 @@ test('matches mutation pending & rejected actions for the own endpoint', async (
105114
[endpoint.matchPending, endpoint.matchFulfilled, ...otherEndpointMatchers]
106115
);
107116
});
117+
118+
test('inferred types', () => {
119+
createSlice({
120+
name: 'auth',
121+
initialState: {},
122+
reducers: {},
123+
extraReducers: (builder) => {
124+
builder
125+
.addMatcher(api.endpoints.querySuccess.matchPending, (state, action) => {
126+
expectExactType(undefined)(action.payload);
127+
// @ts-expect-error
128+
console.log(action.error);
129+
expectExactType({} as ArgType)(action.meta.arg.originalArgs);
130+
})
131+
.addMatcher(api.endpoints.querySuccess.matchFulfilled, (state, action) => {
132+
expectExactType({} as ResultType)(action.payload.result);
133+
// @ts-expect-error
134+
console.log(action.error);
135+
expectExactType({} as ArgType)(action.meta.arg.originalArgs);
136+
})
137+
.addMatcher(api.endpoints.querySuccess.matchRejected, (state, action) => {
138+
expectExactType({} as SerializedError)(action.error);
139+
expectExactType({} as ArgType)(action.meta.arg.originalArgs);
140+
});
141+
},
142+
});
143+
});

0 commit comments

Comments
 (0)