Skip to content

Commit fdb4ab6

Browse files
committed
Update merge logic to make it optional and handle different use cases
- Make `merge` an optional callback - Flip args order to put `currentCacheData` first - Use Immer to handle the "mutate or return" behavior - Only do structural sharing if there's no `merge`
1 parent 4860a89 commit fdb4ab6

File tree

3 files changed

+139
-9
lines changed

3 files changed

+139
-9
lines changed

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

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
isAnyOf,
77
isFulfilled,
88
isRejectedWithValue,
9+
createNextState,
910
} from '@reduxjs/toolkit'
1011
import type {
1112
CombinedState as CombinedQueryState,
@@ -157,16 +158,37 @@ export function buildSlice({
157158
meta.arg.queryCacheKey,
158159
(substate) => {
159160
if (substate.requestId !== meta.requestId) return
160-
const { merge = (x: any) => x } = definitions[
161+
const { merge } = definitions[
161162
meta.arg.endpointName
162163
] as QueryDefinition<any, any, any, any>
163164
substate.status = QueryStatus.fulfilled
164-
let newData = merge(payload, substate.data)
165165

166-
substate.data =
167-
definitions[meta.arg.endpointName].structuralSharing ?? true
168-
? copyWithStructuralSharing(substate.data, newData)
169-
: newData
166+
if (merge) {
167+
if (substate.data !== undefined) {
168+
// There's existing cache data. Let the user merge it in themselves.
169+
// We're already inside an Immer-powered reducer, and the user could just mutate `substate.data`
170+
// themselves inside of `merge()`. But, they might also want to return a new value.
171+
// Try to let Immer figure that part out, save the result, and assign it to `substate.data`.
172+
let newData = createNextState(
173+
substate.data,
174+
(draftSubstateData) => {
175+
// As usual with Immer, you can mutate _or_ return inside here, but not both
176+
return merge(draftSubstateData, payload)
177+
}
178+
)
179+
substate.data = newData
180+
} else {
181+
// Presumably a fresh request. Just cache the response data.
182+
substate.data = payload
183+
}
184+
} else {
185+
// Assign or safely update the cache data.
186+
substate.data =
187+
definitions[meta.arg.endpointName].structuralSharing ?? true
188+
? copyWithStructuralSharing(substate.data, payload)
189+
: payload
190+
}
191+
170192
delete substate.error
171193
substate.fulfilledTimeStamp = meta.fulfilledTimeStamp
172194
}

packages/toolkit/src/query/endpointDefinitions.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -278,15 +278,22 @@ export interface QueryExtraOptions<
278278

279279
/**
280280
* Can be provided to merge the current cache value into the new cache value.
281+
* If supplied, no automatic structural sharing will be applied - it's up to
282+
* you to update the cache appropriately.
283+
*
284+
* Since this is wrapped with Immer, you , you may either mutate the `currentCacheValue` directly,
285+
* or return a new value, but _not_ both at once. *
286+
*
287+
* Will only be called if the existing `currentCacheValue` is not `undefined`.
281288
*
282289
* Useful if you don't want a new request to completely override the current cache value,
283290
* maybe because you have manually updated it from another source and don't want those
284291
* updates to get lost.
285292
*/
286293
merge?(
287-
newCacheValue: ResultType,
288-
currentCacheValue: ResultType | undefined
289-
): ResultType
294+
currentCacheData: ResultType,
295+
responseData: ResultType
296+
): ResultType | void
290297
}
291298

292299
export type QueryDefinition<

packages/toolkit/src/query/tests/buildSlice.test.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ const authSlice = createSlice({
2929

3030
const storeRef = setupApiStore(api, { auth: authSlice.reducer })
3131

32+
function delay(ms: number) {
33+
return new Promise((resolve) => setTimeout(resolve, ms))
34+
}
35+
3236
it('only resets the api state when resetApiState is dispatched', async () => {
3337
storeRef.store.dispatch({ type: 'unrelated' }) // trigger "registered middleware" into place
3438
const initialState = storeRef.store.getState()
@@ -77,3 +81,100 @@ it('only resets the api state when resetApiState is dispatched', async () => {
7781

7882
expect(storeRef.store.getState()).toEqual(initialState)
7983
})
84+
85+
describe.only('`merge` callback', () => {
86+
const baseQuery = (args?: any) => ({ data: args })
87+
88+
interface Todo {
89+
id: string
90+
text: string
91+
}
92+
93+
it('Calls `merge` once there is existing data, and allows mutations of cache state', async () => {
94+
let mergeCalled = false
95+
let queryFnCalls = 0
96+
const todoTexts = ['A', 'B', 'C', 'D']
97+
98+
const api = createApi({
99+
baseQuery,
100+
endpoints: (build) => ({
101+
getTodos: build.query<Todo[], void>({
102+
async queryFn() {
103+
const text = todoTexts[queryFnCalls]
104+
return { data: [{ id: `${queryFnCalls++}`, text }] }
105+
},
106+
merge(currentCacheValue, responseData) {
107+
mergeCalled = true
108+
currentCacheValue.push(...responseData)
109+
},
110+
}),
111+
}),
112+
})
113+
114+
const storeRef = setupApiStore(api, undefined, {
115+
withoutTestLifecycles: true,
116+
})
117+
118+
const selectTodoEntry = api.endpoints.getTodos.select()
119+
120+
const res = storeRef.store.dispatch(api.endpoints.getTodos.initiate())
121+
await res
122+
expect(mergeCalled).toBe(false)
123+
const todoEntry1 = selectTodoEntry(storeRef.store.getState())
124+
expect(todoEntry1.data).toEqual([{ id: '0', text: 'A' }])
125+
126+
res.refetch()
127+
128+
await delay(10)
129+
130+
expect(mergeCalled).toBe(true)
131+
const todoEntry2 = selectTodoEntry(storeRef.store.getState())
132+
133+
expect(todoEntry2.data).toEqual([
134+
{ id: '0', text: 'A' },
135+
{ id: '1', text: 'B' },
136+
])
137+
})
138+
139+
it('Allows returning a different value from `merge`', async () => {
140+
let firstQueryFnCall = true
141+
142+
const api = createApi({
143+
baseQuery,
144+
endpoints: (build) => ({
145+
getTodos: build.query<Todo[], void>({
146+
async queryFn() {
147+
const item = firstQueryFnCall
148+
? { id: '0', text: 'A' }
149+
: { id: '1', text: 'B' }
150+
firstQueryFnCall = false
151+
return { data: [item] }
152+
},
153+
merge(currentCacheValue, responseData) {
154+
return responseData
155+
},
156+
}),
157+
}),
158+
})
159+
160+
const storeRef = setupApiStore(api, undefined, {
161+
withoutTestLifecycles: true,
162+
})
163+
164+
const selectTodoEntry = api.endpoints.getTodos.select()
165+
166+
const res = storeRef.store.dispatch(api.endpoints.getTodos.initiate())
167+
await res
168+
169+
const todoEntry1 = selectTodoEntry(storeRef.store.getState())
170+
expect(todoEntry1.data).toEqual([{ id: '0', text: 'A' }])
171+
172+
res.refetch()
173+
174+
await delay(10)
175+
176+
const todoEntry2 = selectTodoEntry(storeRef.store.getState())
177+
178+
expect(todoEntry2.data).toEqual([{ id: '1', text: 'B' }])
179+
})
180+
})

0 commit comments

Comments
 (0)