Skip to content

Commit ff325ae

Browse files
committed
Add upsertQueryData functionality per issue reduxjs#1720
1 parent f86d1e6 commit ff325ae

File tree

4 files changed

+195
-9
lines changed

4 files changed

+195
-9
lines changed

packages/toolkit/src/query/core/buildSlice.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { AnyAction, PayloadAction } from '@reduxjs/toolkit'
1+
import type { PayloadAction } from '@reduxjs/toolkit'
22
import {
33
combineReducers,
44
createAction,
@@ -22,7 +22,7 @@ import type {
2222
ConfigState,
2323
} from './apiState'
2424
import { QueryStatus } from './apiState'
25-
import type { MutationThunk, QueryThunk } from './buildThunks'
25+
import type { MutationThunk, QueryThunk, QueryThunkArg } from './buildThunks'
2626
import { calculateProvidedByThunk } from './buildThunks'
2727
import type {
2828
AssertTagTypes,
@@ -129,6 +129,31 @@ export function buildSlice({
129129
substate.data = applyPatches(substate.data as any, patches.concat())
130130
})
131131
},
132+
insertQueryResult(
133+
draft,
134+
{
135+
payload: {
136+
data,
137+
arg: { originalArgs, endpointName, queryCacheKey },
138+
fulfilledTimeStamp,
139+
},
140+
}: PayloadAction<{
141+
data: any
142+
arg: QueryThunkArg
143+
fulfilledTimeStamp: number
144+
}>
145+
) {
146+
draft[queryCacheKey] = {
147+
status: QueryStatus.fulfilled,
148+
endpointName,
149+
requestId: '',
150+
originalArgs,
151+
startedTimeStamp: fulfilledTimeStamp,
152+
fulfilledTimeStamp,
153+
data,
154+
error: undefined,
155+
}
156+
},
132157
},
133158
extraReducers(builder) {
134159
builder

packages/toolkit/src/query/core/buildThunks.ts

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,15 @@ export type UpdateQueryDataThunk<
163163
updateRecipe: Recipe<ResultTypeFrom<Definitions[EndpointName]>>
164164
) => ThunkAction<PatchCollection, PartialState, any, AnyAction>
165165

166+
export type UpsertQueryDataThunk<
167+
Definitions extends EndpointDefinitions,
168+
PartialState
169+
> = <EndpointName extends QueryKeys<Definitions>>(
170+
endpointName: EndpointName,
171+
args: QueryArgFrom<Definitions[EndpointName]>,
172+
data: ResultTypeFrom<Definitions[EndpointName]>
173+
) => ThunkAction<PatchCollection, PartialState, any, AnyAction>
174+
166175
/**
167176
* An object returned from dispatching a `api.util.updateQueryData` call.
168177
*/
@@ -200,16 +209,26 @@ export function buildThunks<
200209
}) {
201210
type State = RootState<any, string, ReducerPath>
202211

212+
const querySubstateIdentifier = <
213+
EndpointName extends string & QueryKeys<Definitions>
214+
>(
215+
endpointName: EndpointName,
216+
args: QueryArgFrom<Definitions[EndpointName]>
217+
): QuerySubstateIdentifier => {
218+
const endpointDefinition = endpointDefinitions[endpointName]
219+
const queryCacheKey = serializeQueryArgs({
220+
queryArgs: args,
221+
endpointDefinition,
222+
endpointName,
223+
})
224+
return { queryCacheKey }
225+
}
226+
203227
const patchQueryData: PatchQueryDataThunk<EndpointDefinitions, State> =
204228
(endpointName, args, patches) => (dispatch) => {
205-
const endpointDefinition = endpointDefinitions[endpointName]
206229
dispatch(
207230
api.internalActions.queryResultPatched({
208-
queryCacheKey: serializeQueryArgs({
209-
queryArgs: args,
210-
endpointDefinition,
211-
endpointName,
212-
}),
231+
...querySubstateIdentifier(endpointName, args),
213232
patches,
214233
})
215234
)
@@ -255,6 +274,43 @@ export function buildThunks<
255274
return ret
256275
}
257276

277+
const upsertQueryData: UpsertQueryDataThunk<EndpointDefinitions, State> =
278+
(endpointName, args, data) => (dispatch, getState) => {
279+
const currentState = (
280+
api.endpoints[endpointName] as ApiEndpointQuery<any, any>
281+
).select(args)(getState())
282+
if ('data' in currentState) {
283+
return dispatch(
284+
api.util.updateQueryData(endpointName, args, () => data)
285+
)
286+
}
287+
let ret: PatchCollection = {
288+
patches: [{ op: 'replace', path: [], value: data }],
289+
inversePatches: [{ op: 'replace', path: [], value: undefined }],
290+
undo: () =>
291+
dispatch(
292+
api.internalActions.removeQueryResult(
293+
querySubstateIdentifier(endpointName, args)
294+
)
295+
),
296+
}
297+
298+
dispatch(
299+
api.internalActions.insertQueryResult({
300+
arg: {
301+
...querySubstateIdentifier(endpointName, args),
302+
endpointName,
303+
originalArgs: args,
304+
type: 'query',
305+
},
306+
data,
307+
fulfilledTimeStamp: Date.now(),
308+
})
309+
)
310+
311+
return ret
312+
}
313+
258314
const executeEndpoint: AsyncThunkPayloadCreator<
259315
ThunkResult,
260316
QueryThunkArg | MutationThunkArg,
@@ -494,6 +550,7 @@ In the case of an unhandled error, no tags will be "provided" or "invalidated".`
494550
mutationThunk,
495551
prefetch,
496552
updateQueryData,
553+
upsertQueryData,
497554
patchQueryData,
498555
buildMatchThunkActions,
499556
}

packages/toolkit/src/query/core/module.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
/**
22
* Note: this file should import all other files for type discovery and declaration merging
33
*/
4-
import type { PatchQueryDataThunk, UpdateQueryDataThunk } from './buildThunks'
4+
import type {
5+
PatchQueryDataThunk,
6+
UpdateQueryDataThunk,
7+
UpsertQueryDataThunk,
8+
} from './buildThunks'
59
import { buildThunks } from './buildThunks'
610
import type {
711
ActionCreatorWithPayload,
@@ -210,6 +214,25 @@ declare module '../apiTypes' {
210214
Definitions,
211215
RootState<Definitions, string, ReducerPath>
212216
>
217+
/**
218+
* A Redux thunk that manually adds a 'fulfilled' result to the API cache state with the provided data. Unlike `patchQueryData`, which can only update previously-fetched data, `upsertQueryData` can both update existing results and add completely new entries to the cache.
219+
*
220+
* The thunk action creator accepts three arguments: the name of the endpoint we are updating (such as `'getPost'`), any relevant query arguments, and the result data for this API call.
221+
*
222+
* Caution: This is an advanced function which should be avoided unless absolutely necessary.
223+
*
224+
* @example
225+
*
226+
* ```ts
227+
* dispatch(
228+
* api.util.updateQueryData('getPosts', '1', { id: 1, name: 'Teddy' })
229+
* )
230+
* ```
231+
*/
232+
upsertQueryData: UpsertQueryDataThunk<
233+
Definitions,
234+
RootState<Definitions, string, ReducerPath>
235+
>
213236
/**
214237
* A Redux thunk that applies a JSON diff/patch array to the cached data for a given query result. This immediately updates the Redux state with those changes.
215238
*
@@ -406,6 +429,7 @@ export const coreModule = (): Module<CoreModule> => ({
406429
mutationThunk,
407430
patchQueryData,
408431
updateQueryData,
432+
upsertQueryData,
409433
prefetch,
410434
buildMatchThunkActions,
411435
} = buildThunks({
@@ -434,6 +458,7 @@ export const coreModule = (): Module<CoreModule> => ({
434458
safeAssign(api.util, {
435459
patchQueryData,
436460
updateQueryData,
461+
upsertQueryData,
437462
prefetch,
438463
resetApiState: sliceActions.resetApiState,
439464
})

packages/toolkit/src/query/tests/optimisticUpdates.test.tsx

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,85 @@ describe('updateQueryData', () => {
224224
})
225225
})
226226

227+
describe('upsertQueryData', () => {
228+
test('can add new cache entries', async () => {
229+
const { result } = renderHook(
230+
() => api.endpoints.post.useQuery('4', { skip: true }),
231+
{
232+
wrapper: storeRef.wrapper,
233+
}
234+
)
235+
await hookWaitFor(() => expect(result.current.isUninitialized).toBeTruthy())
236+
237+
const dataBefore = result.current.data
238+
expect(dataBefore).toBeUndefined()
239+
240+
let returnValue!: ReturnType<ReturnType<typeof api.util.updateQueryData>>
241+
act(() => {
242+
returnValue = storeRef.store.dispatch(
243+
api.util.upsertQueryData('post', '4', {
244+
id: '4',
245+
title: 'All about cheese.',
246+
contents: 'I love cheese!',
247+
})
248+
)
249+
})
250+
251+
const selector = api.endpoints.post.select('4')
252+
253+
const queryStateAfter = selector(storeRef.store.getState())
254+
255+
expect(queryStateAfter.data).toEqual({
256+
id: '4',
257+
title: 'All about cheese.',
258+
contents: 'I love cheese!',
259+
})
260+
261+
// TODO: expect(returnValue).toEqual(???)
262+
})
263+
264+
test('can update existing', async () => {
265+
baseQuery
266+
.mockImplementationOnce(async () => ({
267+
id: '3',
268+
title: 'All about cheese.',
269+
contents: 'TODO',
270+
}))
271+
.mockResolvedValueOnce(42)
272+
273+
const { result } = renderHook(() => api.endpoints.post.useQuery('3'), {
274+
wrapper: storeRef.wrapper,
275+
})
276+
await hookWaitFor(() => expect(result.current.isSuccess).toBeTruthy())
277+
278+
const dataBefore = result.current.data
279+
expect(dataBefore).toEqual({
280+
id: '3',
281+
title: 'All about cheese.',
282+
contents: 'TODO',
283+
})
284+
285+
let returnValue!: ReturnType<ReturnType<typeof api.util.updateQueryData>>
286+
act(() => {
287+
returnValue = storeRef.store.dispatch(
288+
api.util.upsertQueryData('post', '3', {
289+
id: '3',
290+
title: 'All about cheese.',
291+
contents: 'I love cheese!',
292+
})
293+
)
294+
})
295+
296+
expect(result.current.data).toEqual({
297+
id: '3',
298+
title: 'All about cheese.',
299+
contents: 'I love cheese!',
300+
})
301+
302+
// TODO: expect(returnValue).toEqual(???)
303+
})
304+
})
305+
227306
describe('full integration', () => {
228307
test('success case', async () => {
229308
baseQuery

0 commit comments

Comments
 (0)