Skip to content

Commit 2a23b3c

Browse files
committed
feat: add structural sharing of data between query results
1 parent 58acca1 commit 2a23b3c

File tree

7 files changed

+336
-98
lines changed

7 files changed

+336
-98
lines changed

docs/src/pages/docs/comparison.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ Feature/Capability Key:
1818
| Supported Query Signatures | Promise | Promise | GraphQL Query |
1919
| Supported Query Keys | JSON | JSON | GraphQL Query |
2020
| Query Key Change Detection | Deep Compare (Serialization) | Referential Equality (===) | Deep Compare (Serialization) |
21+
| Query Data Memoization Level | Query + Deep Structural Sharing | Query | Entity + Deep Structural Sharing |
2122
| Bundle Size | [![][bp-react-query]][bpl-react-query] | [![][bp-swr]][bpl-swr] | [![][bp-apollo]][bpl-apollo] |
2223
| Queries ||||
2324
| Caching ||||

docs/src/pages/docs/overview.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ Once you grasp the nature of server state in your application, **even more chall
2525
- Reflecting updates to data as quickly as possible
2626
- Performance optimizations like pagination and lazy loading data
2727
- Managing memory and garbage collection of server state
28+
- Memoizing query results with structural sharing
2829

2930
If you're not overwhelmed by that list, then that must mean that you've probably solved all of your server state problems already and deserve an award. However, if you are like a vast majority of people, you either have yet to tackle all or most of these challenges and we're only scratching the surface!
3031

src/core/config.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { stableStringify, identity, deepEqual } from './utils'
1+
import { stableStringify, identity } from './utils'
22
import {
33
ArrayQueryKey,
44
QueryKey,
@@ -44,7 +44,6 @@ export const DEFAULT_CONFIG: ReactQueryConfig = {
4444
refetchInterval: false,
4545
queryFnParamsFilter: identity,
4646
refetchOnMount: true,
47-
isDataEqual: deepEqual,
4847
useErrorBoundary: false,
4948
},
5049
mutations: {

src/core/query.ts

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ import {
66
noop,
77
Console,
88
getStatusProps,
9-
shallowEqual,
109
Updater,
10+
replaceEqualDeep,
1111
} from './utils'
1212
import { QueryInstance, OnStateUpdateFunction } from './queryInstance'
1313
import {
@@ -81,7 +81,7 @@ interface FetchAction {
8181

8282
interface SuccessAction<TResult> {
8383
type: ActionType.Success
84-
updater: Updater<TResult | undefined, TResult>
84+
data: TResult | undefined
8585
isStale: boolean
8686
}
8787

@@ -157,14 +157,9 @@ export class Query<TResult, TError> {
157157
}
158158

159159
private dispatch(action: Action<TResult, TError>): void {
160-
const newState = queryReducer(this.state, action)
161-
162-
// Only update state if something has changed
163-
if (!shallowEqual(this.state, newState)) {
164-
this.state = newState
165-
this.instances.forEach(d => d.onStateUpdate(newState, action))
166-
this.notifyGlobalListeners(this)
167-
}
160+
this.state = queryReducer(this.state, action)
161+
this.instances.forEach(d => d.onStateUpdate(this.state, action))
162+
this.notifyGlobalListeners(this)
168163
}
169164

170165
scheduleStaleTimeout(): void {
@@ -283,11 +278,25 @@ export class Query<TResult, TError> {
283278
}
284279

285280
setData(updater: Updater<TResult | undefined, TResult>): void {
281+
const prevData = this.state.data
282+
283+
// Get the new data
284+
let data: TResult | undefined = functionalUpdate(updater, prevData)
285+
286+
// Structurally share data between prev and new data
287+
data = replaceEqualDeep(prevData, data)
288+
289+
// Use prev data if an isDataEqual function is defined and returns `true`
290+
if (this.config.isDataEqual?.(prevData, data)) {
291+
data = prevData
292+
}
293+
286294
const isStale = this.config.staleTime === 0
295+
287296
// Set data and mark it as cached
288297
this.dispatch({
289298
type: ActionType.Success,
290-
updater,
299+
data,
291300
isStale,
292301
})
293302

@@ -502,13 +511,15 @@ export class Query<TResult, TError> {
502511
this.cancelled = null
503512

504513
try {
505-
// Set up the query refreshing state
506-
this.dispatch({ type: ActionType.Fetch })
514+
// Set to fetching state if not already in it
515+
if (!this.state.isFetching) {
516+
this.dispatch({ type: ActionType.Fetch })
517+
}
507518

508519
// Try to get the data
509520
const data = await this.tryFetchData(queryFn!, this.queryKey)
510521

511-
this.setData(old => (this.config.isDataEqual!(old, data) ? old! : data))
522+
this.setData(data)
512523

513524
delete this.promise
514525

@@ -610,7 +621,7 @@ export function queryReducer<TResult, TError>(
610621
return {
611622
...state,
612623
...getStatusProps(QueryStatus.Success),
613-
data: functionalUpdate(action.updater, state.data),
624+
data: action.data,
614625
error: null,
615626
isStale: action.isStale,
616627
isFetched: true,

src/core/tests/utils.test.tsx

Lines changed: 192 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { setConsole, queryCache } from '../'
2-
import { deepEqual, shallowEqual } from '../utils'
2+
import { deepEqual, replaceEqualDeep } from '../utils'
33
import { queryKey } from '../../react/tests/utils'
44

55
describe('core/utils', () => {
@@ -42,63 +42,219 @@ describe('core/utils', () => {
4242
expect(deepEqual(a, b)).toEqual(false)
4343
})
4444

45-
it('should return `false` for different dates', () => {
45+
it('return `false` for equal dates', () => {
4646
const date1 = new Date(2020, 3, 1)
47-
const date2 = new Date(2020, 3, 2)
47+
const date2 = new Date(2020, 3, 1)
4848
expect(deepEqual(date1, date2)).toEqual(false)
4949
})
50+
})
5051

51-
it('return `true` for equal dates', () => {
52-
const date1 = new Date(2020, 3, 1)
53-
const date2 = new Date(2020, 3, 1)
54-
expect(deepEqual(date1, date2)).toEqual(true)
52+
describe('replaceEqualDeep', () => {
53+
it('should return the previous value when the next value is an equal primitive', () => {
54+
expect(replaceEqualDeep(1, 1)).toBe(1)
55+
expect(replaceEqualDeep('1', '1')).toBe('1')
56+
expect(replaceEqualDeep(true, true)).toBe(true)
57+
expect(replaceEqualDeep(false, false)).toBe(false)
58+
expect(replaceEqualDeep(null, null)).toBe(null)
59+
expect(replaceEqualDeep(undefined, undefined)).toBe(undefined)
60+
})
61+
it('should return the next value when the previous value is a different value', () => {
62+
const date1 = new Date()
63+
const date2 = new Date()
64+
expect(replaceEqualDeep(1, 0)).toBe(0)
65+
expect(replaceEqualDeep(1, 2)).toBe(2)
66+
expect(replaceEqualDeep('1', '2')).toBe('2')
67+
expect(replaceEqualDeep(true, false)).toBe(false)
68+
expect(replaceEqualDeep(false, true)).toBe(true)
69+
expect(replaceEqualDeep(date1, date2)).toBe(date2)
5570
})
56-
})
5771

58-
describe('shallowEqual', () => {
59-
it('should return `true` for empty objects', () => {
60-
expect(shallowEqual({}, {})).toEqual(true)
72+
it('should return the next value when the previous value is a different type', () => {
73+
const array = [1]
74+
const object = { a: 'a' }
75+
expect(replaceEqualDeep(0, undefined)).toBe(undefined)
76+
expect(replaceEqualDeep(undefined, 0)).toBe(0)
77+
expect(replaceEqualDeep(2, undefined)).toBe(undefined)
78+
expect(replaceEqualDeep(undefined, 2)).toBe(2)
79+
expect(replaceEqualDeep(undefined, null)).toBe(null)
80+
expect(replaceEqualDeep(null, undefined)).toBe(undefined)
81+
expect(replaceEqualDeep({}, undefined)).toBe(undefined)
82+
expect(replaceEqualDeep([], undefined)).toBe(undefined)
83+
expect(replaceEqualDeep(array, object)).toBe(object)
84+
expect(replaceEqualDeep(object, array)).toBe(array)
6185
})
6286

63-
it('should return `true` for equal values', () => {
64-
expect(shallowEqual(1, 1)).toEqual(true)
87+
it('should return the previous value when the next value is an equal array', () => {
88+
const prev = [1, 2]
89+
const next = [1, 2]
90+
expect(replaceEqualDeep(prev, next)).toBe(prev)
6591
})
6692

67-
it('should return `true` for equal arrays', () => {
68-
expect(shallowEqual([1, 2], [1, 2])).toEqual(true)
93+
it('should return a copy when the previous value is a different array subset', () => {
94+
const prev = [1, 2]
95+
const next = [1, 2, 3]
96+
const result = replaceEqualDeep(prev, next)
97+
expect(result).toEqual(next)
98+
expect(result).not.toBe(prev)
99+
expect(result).not.toBe(next)
69100
})
70101

71-
it('should return `true` for equal shallow objects', () => {
72-
const a = { a: 'a', b: 'b' }
73-
const b = { a: 'a', b: 'b' }
74-
expect(shallowEqual(a, b)).toEqual(true)
102+
it('should return a copy when the previous value is a different array superset', () => {
103+
const prev = [1, 2, 3]
104+
const next = [1, 2]
105+
const result = replaceEqualDeep(prev, next)
106+
expect(result).toEqual(next)
107+
expect(result).not.toBe(prev)
108+
expect(result).not.toBe(next)
75109
})
76110

77-
it('should return `true` for equal deep objects with same identities', () => {
78-
const deep = { b: 'b' }
79-
const a = { a: deep, c: 'c' }
80-
const b = { a: deep, c: 'c' }
81-
expect(shallowEqual(a, b)).toEqual(true)
111+
it('should return the previous value when the next value is an equal empty array', () => {
112+
const prev: any[] = []
113+
const next: any[] = []
114+
expect(replaceEqualDeep(prev, next)).toBe(prev)
82115
})
83116

84-
it('should return `false` for non equal values', () => {
85-
expect(shallowEqual(1, 2)).toEqual(false)
117+
it('should return the previous value when the next value is an equal empty object', () => {
118+
const prev = {}
119+
const next = {}
120+
expect(replaceEqualDeep(prev, next)).toBe(prev)
86121
})
87122

88-
it('should return `false` for equal arrays', () => {
89-
expect(shallowEqual([1, 2], [1, 3])).toEqual(false)
123+
it('should return the previous value when the next value is an equal object', () => {
124+
const prev = { a: 'a' }
125+
const next = { a: 'a' }
126+
expect(replaceEqualDeep(prev, next)).toBe(prev)
90127
})
91128

92-
it('should return `false` for non equal shallow objects', () => {
93-
const a = { a: 'a', b: 'b' }
94-
const b = { a: 'a', b: 'c' }
95-
expect(shallowEqual(a, b)).toEqual(false)
129+
it('should replace different values in objects', () => {
130+
const prev = { a: { b: 'b' }, c: 'c' }
131+
const next = { a: { b: 'b' }, c: 'd' }
132+
const result = replaceEqualDeep(prev, next)
133+
expect(result).toEqual(next)
134+
expect(result).not.toBe(prev)
135+
expect(result).not.toBe(next)
136+
expect(result.a).toBe(prev.a)
137+
expect(result.c).toBe(next.c)
96138
})
97139

98-
it('should return `false` for equal deep objects with different identities', () => {
99-
const a = { a: { b: 'b' }, c: 'c' }
100-
const b = { a: { b: 'b' }, c: 'c' }
101-
expect(shallowEqual(a, b)).toEqual(false)
140+
it('should replace different values in arrays', () => {
141+
const prev = [1, { a: 'a' }, { b: { b: 'b' } }, [1]] as const
142+
const next = [1, { a: 'a' }, { b: { b: 'c' } }, [1]] as const
143+
const result = replaceEqualDeep(prev, next)
144+
expect(result).toEqual(next)
145+
expect(result).not.toBe(prev)
146+
expect(result).not.toBe(next)
147+
expect(result[0]).toBe(prev[0])
148+
expect(result[1]).toBe(prev[1])
149+
expect(result[2]).not.toBe(next[2])
150+
expect(result[2].b.b).toBe(next[2].b.b)
151+
expect(result[3]).toBe(prev[3])
152+
})
153+
154+
it('should replace different values in arrays when the next value is a subset', () => {
155+
const prev = [{ a: 'a' }, { b: 'b' }, { c: 'c' }]
156+
const next = [{ a: 'a' }, { b: 'b' }]
157+
const result = replaceEqualDeep(prev, next)
158+
expect(result).toEqual(next)
159+
expect(result).not.toBe(prev)
160+
expect(result).not.toBe(next)
161+
expect(result[0]).toBe(prev[0])
162+
expect(result[1]).toBe(prev[1])
163+
expect(result[2]).toBeUndefined()
164+
})
165+
166+
it('should replace different values in arrays when the next value is a superset', () => {
167+
const prev = [{ a: 'a' }, { b: 'b' }]
168+
const next = [{ a: 'a' }, { b: 'b' }, { c: 'c' }]
169+
const result = replaceEqualDeep(prev, next)
170+
expect(result).toEqual(next)
171+
expect(result).not.toBe(prev)
172+
expect(result).not.toBe(next)
173+
expect(result[0]).toBe(prev[0])
174+
expect(result[1]).toBe(prev[1])
175+
expect(result[2]).toBe(next[2])
176+
})
177+
178+
it('should copy objects which are not arrays or objects', () => {
179+
const prev = [{ a: 'a' }, { b: 'b' }, { c: 'c' }, 1]
180+
const next = [{ a: 'a' }, new Map(), { c: 'c' }, 2]
181+
const result = replaceEqualDeep(prev, next)
182+
expect(result).not.toBe(prev)
183+
expect(result).not.toBe(next)
184+
expect(result[0]).toBe(prev[0])
185+
expect(result[1]).toBe(next[1])
186+
expect(result[2]).toBe(prev[2])
187+
expect(result[3]).toBe(next[3])
188+
})
189+
190+
it('should support equal objects which are not arrays or objects', () => {
191+
const map = new Map()
192+
const prev = [map, [1]]
193+
const next = [map, [1]]
194+
const result = replaceEqualDeep(prev, next)
195+
expect(result).toBe(prev)
196+
})
197+
198+
it('should support non equal objects which are not arrays or objects', () => {
199+
const map1 = new Map()
200+
const map2 = new Map()
201+
const prev = [map1, [1]]
202+
const next = [map2, [1]]
203+
const result = replaceEqualDeep(prev, next)
204+
expect(result).not.toBe(prev)
205+
expect(result).not.toBe(next)
206+
expect(result[0]).toBe(next[0])
207+
expect(result[1]).toBe(prev[1])
208+
})
209+
210+
it('should replace all parent objects if some nested value changes', () => {
211+
const prev = {
212+
todo: { id: '1', meta: { createdAt: 0 }, state: { done: false } },
213+
otherTodo: { id: '2', meta: { createdAt: 0 }, state: { done: true } },
214+
}
215+
const next = {
216+
todo: { id: '1', meta: { createdAt: 0 }, state: { done: true } },
217+
otherTodo: { id: '2', meta: { createdAt: 0 }, state: { done: true } },
218+
}
219+
const result = replaceEqualDeep(prev, next)
220+
expect(result).toEqual(next)
221+
expect(result).not.toBe(prev)
222+
expect(result).not.toBe(next)
223+
expect(result.todo).not.toBe(prev.todo)
224+
expect(result.todo).not.toBe(next.todo)
225+
expect(result.todo.id).toBe(next.todo.id)
226+
expect(result.todo.meta).toBe(prev.todo.meta)
227+
expect(result.todo.state).not.toBe(next.todo.state)
228+
expect(result.todo.state.done).toBe(next.todo.state.done)
229+
expect(result.otherTodo).toBe(prev.otherTodo)
230+
})
231+
232+
it('should replace all parent arrays if some nested value changes', () => {
233+
const prev = {
234+
todos: [
235+
{ id: '1', meta: { createdAt: 0 }, state: { done: false } },
236+
{ id: '2', meta: { createdAt: 0 }, state: { done: true } },
237+
],
238+
}
239+
const next = {
240+
todos: [
241+
{ id: '1', meta: { createdAt: 0 }, state: { done: true } },
242+
{ id: '2', meta: { createdAt: 0 }, state: { done: true } },
243+
],
244+
}
245+
const result = replaceEqualDeep(prev, next)
246+
expect(result).toEqual(next)
247+
expect(result).not.toBe(prev)
248+
expect(result).not.toBe(next)
249+
expect(result.todos).not.toBe(prev.todos)
250+
expect(result.todos).not.toBe(next.todos)
251+
expect(result.todos[0]).not.toBe(prev.todos[0])
252+
expect(result.todos[0]).not.toBe(next.todos[0])
253+
expect(result.todos[0].id).toBe(next.todos[0].id)
254+
expect(result.todos[0].meta).toBe(prev.todos[0].meta)
255+
expect(result.todos[0].state).not.toBe(next.todos[0].state)
256+
expect(result.todos[0].state.done).toBe(next.todos[0].state.done)
257+
expect(result.todos[1]).toBe(prev.todos[1])
102258
})
103259
})
104260
})

0 commit comments

Comments
 (0)