Skip to content

Commit 77b576c

Browse files
authored
nest data structure for cacheDataLoaded and queryFulfilled (#1078)
to prepare for adding more information in the resolved/rejected values in future releases
1 parent 62bc12d commit 77b576c

File tree

6 files changed

+151
-44
lines changed

6 files changed

+151
-44
lines changed

src/query/core/buildMiddleware/cacheLifecycle.ts

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@ import type {
99
QueryResultSelectorResult,
1010
} from '../buildSelectors'
1111
import type { PatchCollection, Recipe } from '../buildThunks'
12-
import type { SubMiddlewareApi, SubMiddlewareBuilder } from './types'
12+
import type {
13+
PromiseWithKnownReason,
14+
SubMiddlewareApi,
15+
SubMiddlewareBuilder,
16+
} from './types'
1317

1418
export type ReferenceCacheLifecycle = never
1519

@@ -88,7 +92,15 @@ declare module '../../endpointDefinitions' {
8892
*
8993
* If you don't interact with this promise, it will not throw.
9094
*/
91-
cacheDataLoaded: Promise<ResultType>
95+
cacheDataLoaded: PromiseWithKnownReason<
96+
{
97+
/**
98+
* The (transformed) query result.
99+
*/
100+
data: ResultType
101+
},
102+
typeof neverResolvedError
103+
>
92104
/**
93105
* Promise that allows you to wait for the point in time when the cache entry
94106
* has been removed from the cache, by not being used/subscribed to any more
@@ -150,6 +162,12 @@ declare module '../../endpointDefinitions' {
150162
}
151163
}
152164

165+
const neverResolvedError = new Error(
166+
'Promise never resolved before cacheEntryRemoved.'
167+
) as Error & {
168+
message: 'Promise never resolved before cacheEntryRemoved.'
169+
}
170+
153171
export const build: SubMiddlewareBuilder = ({
154172
api,
155173
reducerPath,
@@ -163,7 +181,7 @@ export const build: SubMiddlewareBuilder = ({
163181

164182
return (mwApi) => {
165183
type CacheLifecycle = {
166-
valueResolved?(value: unknown): unknown
184+
valueResolved?(value: { data: unknown }): unknown
167185
cacheEntryRemoved(): void
168186
}
169187
const lifecycleMap: Record<string, CacheLifecycle> = {}
@@ -201,7 +219,7 @@ export const build: SubMiddlewareBuilder = ({
201219
} else if (isFullfilledThunk(action)) {
202220
const lifecycle = lifecycleMap[cacheKey]
203221
if (lifecycle?.valueResolved) {
204-
lifecycle.valueResolved(action.payload.result)
222+
lifecycle.valueResolved({ data: action.payload.result })
205223
delete lifecycle.valueResolved
206224
}
207225
} else if (
@@ -244,16 +262,16 @@ export const build: SubMiddlewareBuilder = ({
244262
const onCacheEntryAdded = endpointDefinition?.onCacheEntryAdded
245263
if (!onCacheEntryAdded) return
246264

247-
const neverResolvedError = new Error(
248-
'Promise never resolved before cacheEntryRemoved.'
249-
)
250265
let lifecycle = {} as CacheLifecycle
251266

252267
const cacheEntryRemoved = new Promise<void>((resolve) => {
253268
lifecycle.cacheEntryRemoved = resolve
254269
})
255-
const cacheDataLoaded = Promise.race([
256-
new Promise<void>((resolve) => {
270+
const cacheDataLoaded: PromiseWithKnownReason<
271+
{ data: unknown },
272+
typeof neverResolvedError
273+
> = Promise.race([
274+
new Promise<{ data: unknown }>((resolve) => {
257275
lifecycle.valueResolved = resolve
258276
}),
259277
cacheEntryRemoved.then(() => {

src/query/core/buildMiddleware/queryLifecycle.ts

Lines changed: 54 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,23 @@
11
import { isPending, isRejected, isFulfilled } from '@reduxjs/toolkit'
2-
import { BaseQueryFn } from '../../baseQueryTypes'
3-
import { DefinitionType } from '../../endpointDefinitions'
2+
import { BaseQueryError, BaseQueryFn } from '../../baseQueryTypes'
3+
import {
4+
DefinitionType,
5+
QueryFulfilledRejectionReason,
6+
} from '../../endpointDefinitions'
47
import { Recipe } from '../buildThunks'
5-
import { SubMiddlewareBuilder } from './types'
8+
import {
9+
SubMiddlewareBuilder,
10+
PromiseWithKnownReason,
11+
PromiseConstructorWithKnownReason,
12+
} from './types'
613

714
export type ReferenceQueryLifecycle = never
815

916
declare module '../../endpointDefinitions' {
10-
export interface QueryLifecyclePromises<ResultType> {
17+
export interface QueryLifecyclePromises<
18+
ResultType,
19+
BaseQuery extends BaseQueryFn
20+
> {
1121
/**
1222
* Promise that will resolve with the (transformed) query result.
1323
*
@@ -17,9 +27,34 @@ declare module '../../endpointDefinitions' {
1727
*
1828
* If you don't interact with this promise, it will not throw.
1929
*/
20-
queryFulfilled: Promise<ResultType>
30+
queryFulfilled: PromiseWithKnownReason<
31+
{
32+
/**
33+
* The (transformed) query result.
34+
*/
35+
data: ResultType
36+
},
37+
QueryFulfilledRejectionReason<BaseQuery>
38+
>
2139
}
2240

41+
type QueryFulfilledRejectionReason<BaseQuery extends BaseQueryFn> =
42+
| {
43+
error: BaseQueryError<BaseQuery>
44+
/**
45+
* If this is `false`, that means this error was returned from the `baseQuery` or `queryFn` in a controlled manner.
46+
*/
47+
isUnhandledError: false
48+
}
49+
| {
50+
error: unknown
51+
/**
52+
* If this is `true`, that means that this error is the result of `baseQueryFn`, `queryFn` or `transformResponse` throwing an error instead of handling it properly.
53+
* There can not be made any assumption about the shape of `error`.
54+
*/
55+
isUnhandledError: true
56+
}
57+
2358
interface QueryExtraOptions<
2459
TagTypes extends string,
2560
ResultType,
@@ -52,7 +87,7 @@ declare module '../../endpointDefinitions' {
5287
ResultType,
5388
ReducerPath extends string = string
5489
> extends QueryBaseLifecycleApi<QueryArg, BaseQuery, ResultType, ReducerPath>,
55-
QueryLifecyclePromises<ResultType> {}
90+
QueryLifecyclePromises<ResultType, BaseQuery> {}
5691

5792
export interface MutationLifecycleApi<
5893
QueryArg,
@@ -65,7 +100,7 @@ declare module '../../endpointDefinitions' {
65100
ResultType,
66101
ReducerPath
67102
>,
68-
QueryLifecyclePromises<ResultType> {}
103+
QueryLifecyclePromises<ResultType, BaseQuery> {}
69104
}
70105

71106
export const build: SubMiddlewareBuilder = ({
@@ -80,8 +115,8 @@ export const build: SubMiddlewareBuilder = ({
80115

81116
return (mwApi) => {
82117
type CacheLifecycle = {
83-
resolve(value: unknown): unknown
84-
reject(value: unknown): unknown
118+
resolve(value: { data: unknown }): unknown
119+
reject(value: { error: unknown; isUnhandledError: boolean }): unknown
85120
}
86121
const lifecycleMap: Record<string, CacheLifecycle> = {}
87122

@@ -97,7 +132,10 @@ export const build: SubMiddlewareBuilder = ({
97132
const onQueryStarted = endpointDefinition?.onQueryStarted
98133
if (onQueryStarted) {
99134
const lifecycle = {} as CacheLifecycle
100-
const queryFulfilled = new Promise((resolve, reject) => {
135+
const queryFulfilled = new (Promise as PromiseConstructorWithKnownReason)<
136+
{ data: unknown },
137+
QueryFulfilledRejectionReason<any>
138+
>((resolve, reject) => {
101139
lifecycle.resolve = resolve
102140
lifecycle.reject = reject
103141
})
@@ -133,11 +171,14 @@ export const build: SubMiddlewareBuilder = ({
133171
}
134172
} else if (isFullfilledThunk(action)) {
135173
const { requestId } = action.meta
136-
lifecycleMap[requestId]?.resolve(action.payload.result)
174+
lifecycleMap[requestId]?.resolve({ data: action.payload.result })
137175
delete lifecycleMap[requestId]
138176
} else if (isRejectedThunk(action)) {
139-
const { requestId } = action.meta
140-
lifecycleMap[requestId]?.reject(action.payload ?? action.error)
177+
const { requestId, rejectedWithValue } = action.meta
178+
lifecycleMap[requestId]?.reject({
179+
error: action.payload ?? action.error,
180+
isUnhandledError: !rejectedWithValue,
181+
})
141182
delete lifecycleMap[requestId]
142183
}
143184

src/query/core/buildMiddleware/types.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,50 @@ export type SubMiddlewareBuilder = (
5252
RootState<EndpointDefinitions, string, string>,
5353
ThunkDispatch<any, any, AnyAction>
5454
>
55+
56+
export interface PromiseConstructorWithKnownReason {
57+
/**
58+
* Creates a new Promise with a known rejection reason.
59+
* @param executor A callback used to initialize the promise. This callback is passed two arguments:
60+
* a resolve callback used to resolve the promise with a value or the result of another promise,
61+
* and a reject callback used to reject the promise with a provided reason or error.
62+
*/
63+
new <T, R>(
64+
executor: (
65+
resolve: (value: T | PromiseLike<T>) => void,
66+
reject: (reason?: R) => void
67+
) => void
68+
): PromiseWithKnownReason<T, R>
69+
}
70+
71+
export interface PromiseWithKnownReason<T, R>
72+
extends Omit<Promise<T>, 'then' | 'catch'> {
73+
/**
74+
* Attaches callbacks for the resolution and/or rejection of the Promise.
75+
* @param onfulfilled The callback to execute when the Promise is resolved.
76+
* @param onrejected The callback to execute when the Promise is rejected.
77+
* @returns A Promise for the completion of which ever callback is executed.
78+
*/
79+
then<TResult1 = T, TResult2 = never>(
80+
onfulfilled?:
81+
| ((value: T) => TResult1 | PromiseLike<TResult1>)
82+
| undefined
83+
| null,
84+
onrejected?:
85+
| ((reason: R) => TResult2 | PromiseLike<TResult2>)
86+
| undefined
87+
| null
88+
): Promise<TResult1 | TResult2>
89+
90+
/**
91+
* Attaches a callback for only the rejection of the Promise.
92+
* @param onrejected The callback to execute when the Promise is rejected.
93+
* @returns A Promise for the completion of the callback.
94+
*/
95+
catch<TResult = never>(
96+
onrejected?:
97+
| ((reason: R) => TResult | PromiseLike<TResult>)
98+
| undefined
99+
| null
100+
): Promise<T | TResult>
101+
}

src/query/createApi.ts

Lines changed: 16 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export interface CreateApiOptions<
2323
*
2424
* ```ts
2525
* import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query'
26-
*
26+
*
2727
* const api = createApi({
2828
* // highlight-start
2929
* baseQuery: fetchBaseQuery({ baseUrl: '/' }),
@@ -37,12 +37,12 @@ export interface CreateApiOptions<
3737
baseQuery: BaseQuery
3838
/**
3939
* An array of string tag type names. Specifying tag types is optional, but you should define them so that they can be used for caching and invalidation. When defining an tag type, you will be able to [provide](../../usage/rtk-query/cached-data#providing-tags) them with `provides` and [invalidate](../../usage/rtk-query/cached-data#invalidating-tags) them with `invalidates` when configuring [endpoints](#endpoints).
40-
*
40+
*
4141
* @example
42-
*
42+
*
4343
* ```ts
4444
* import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query'
45-
*
45+
*
4646
* const api = createApi({
4747
* baseQuery: fetchBaseQuery({ baseUrl: '/' }),
4848
* // highlight-start
@@ -229,15 +229,14 @@ export function buildCreateApi<Modules extends [Module<any>, ...Module<any>[]]>(
229229
'`onStart`, `onSuccess` and `onError` have been replaced by `onQueryStarted`, please change your code accordingly'
230230
)
231231
}
232-
x.onQueryStarted ??= async (arg, { queryFulfilled, ...api }) => {
232+
x.onQueryStarted ??= (arg, { queryFulfilled, ...api }) => {
233233
const queryApi = { ...api, context: {} }
234234
x.onStart?.(arg, queryApi)
235-
try {
236-
const result = await queryFulfilled
237-
x.onSuccess?.(arg, queryApi, result, undefined)
238-
} catch (error) {
239-
x.onError?.(arg, queryApi, error, undefined)
240-
}
235+
return queryFulfilled.then(
236+
(result) =>
237+
x.onSuccess?.(arg, queryApi, result.data, undefined),
238+
(reason) => x.onError?.(arg, queryApi, reason.error, undefined)
239+
)
241240
}
242241
}
243242
return { ...x, type: DefinitionType.query } as any
@@ -253,15 +252,14 @@ export function buildCreateApi<Modules extends [Module<any>, ...Module<any>[]]>(
253252
'`onStart`, `onSuccess` and `onError` have been replaced by `onQueryStarted`, please change your code accordingly'
254253
)
255254
}
256-
x.onQueryStarted ??= async (arg, { queryFulfilled, ...api }) => {
255+
x.onQueryStarted ??= (arg, { queryFulfilled, ...api }) => {
257256
const queryApi = { ...api, context: {} }
258257
x.onStart?.(arg, queryApi)
259-
try {
260-
const result = await queryFulfilled
261-
x.onSuccess?.(arg, queryApi, result, undefined)
262-
} catch (error) {
263-
x.onError?.(arg, queryApi, error, undefined)
264-
}
258+
return queryFulfilled.then(
259+
(result) =>
260+
x.onSuccess?.(arg, queryApi, result.data, undefined),
261+
(reason) => x.onError?.(arg, queryApi, reason.error, undefined)
262+
)
265263
}
266264
}
267265
return { ...x, type: DefinitionType.mutation } as any

src/query/tests/cacheLifecycle.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ describe.each([['query'], ['mutation']] as const)(
108108
await fakeTimerWaitFor(() => {
109109
expect(gotFirstValue).toHaveBeenCalled()
110110
})
111-
expect(gotFirstValue).toHaveBeenCalledWith({ value: 'success' })
111+
expect(gotFirstValue).toHaveBeenCalledWith({ data: { value: 'success' } })
112112
expect(onCleanup).not.toHaveBeenCalled()
113113

114114
promise.unsubscribe(), await waitMs()

src/query/tests/queryLifecycle.test.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ describe.each([['query'], ['mutation']] as const)(
5959
storeRef.store.dispatch(extended.endpoints.injected.initiate('arg'))
6060
expect(onStart).toHaveBeenCalledWith('arg')
6161
await waitFor(() => {
62-
expect(onSuccess).toHaveBeenCalledWith({ value: 'success' })
62+
expect(onSuccess).toHaveBeenCalledWith({ data: { value: 'success' } })
6363
})
6464
})
6565

@@ -85,8 +85,11 @@ describe.each([['query'], ['mutation']] as const)(
8585
expect(onStart).toHaveBeenCalledWith('arg')
8686
await waitFor(() => {
8787
expect(onError).toHaveBeenCalledWith({
88-
status: 500,
89-
data: { value: 'error' },
88+
error: {
89+
status: 500,
90+
data: { value: 'error' },
91+
},
92+
isUnhandledError: false,
9093
})
9194
})
9295
expect(onSuccess).not.toHaveBeenCalled()

0 commit comments

Comments
 (0)