Skip to content

feat: add structural sharing of data between query results #883

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Aug 19, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/src/pages/docs/comparison.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ Feature/Capability Key:
| Supported Query Signatures | Promise | Promise | GraphQL Query |
| Supported Query Keys | JSON | JSON | GraphQL Query |
| Query Key Change Detection | Deep Compare (Serialization) | Referential Equality (===) | Deep Compare (Serialization) |
| Query Data Memoization Level | Query + Structural Sharing | Query | Query + Entity + Structural Sharing |
| Bundle Size | [![][bp-react-query]][bpl-react-query] | [![][bp-swr]][bpl-swr] | [![][bp-apollo]][bpl-apollo] |
| Queries | ✅ | ✅ | ✅ |
| Caching | ✅ | ✅ | ✅ |
Expand Down
1 change: 1 addition & 0 deletions docs/src/pages/docs/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ Once you grasp the nature of server state in your application, **even more chall
- Reflecting updates to data as quickly as possible
- Performance optimizations like pagination and lazy loading data
- Managing memory and garbage collection of server state
- Memoizing query results with structural sharing

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!

Expand Down
3 changes: 1 addition & 2 deletions src/core/config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { stableStringify, identity, deepEqual } from './utils'
import { stableStringify, identity } from './utils'
import {
ArrayQueryKey,
QueryKey,
Expand Down Expand Up @@ -44,7 +44,6 @@ export const DEFAULT_CONFIG: ReactQueryConfig = {
refetchInterval: false,
queryFnParamsFilter: identity,
refetchOnMount: true,
isDataEqual: deepEqual,
useErrorBoundary: false,
},
mutations: {
Expand Down
41 changes: 26 additions & 15 deletions src/core/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import {
noop,
Console,
getStatusProps,
shallowEqual,
Updater,
replaceEqualDeep,
} from './utils'
import { QueryInstance, OnStateUpdateFunction } from './queryInstance'
import {
Expand Down Expand Up @@ -81,7 +81,7 @@ interface FetchAction {

interface SuccessAction<TResult> {
type: ActionType.Success
updater: Updater<TResult | undefined, TResult>
data: TResult | undefined
isStale: boolean
}

Expand Down Expand Up @@ -157,14 +157,9 @@ export class Query<TResult, TError> {
}

private dispatch(action: Action<TResult, TError>): void {
const newState = queryReducer(this.state, action)

// Only update state if something has changed
if (!shallowEqual(this.state, newState)) {
this.state = newState
this.instances.forEach(d => d.onStateUpdate(newState, action))
this.notifyGlobalListeners(this)
}
this.state = queryReducer(this.state, action)
this.instances.forEach(d => d.onStateUpdate(this.state, action))
this.notifyGlobalListeners(this)
}

scheduleStaleTimeout(): void {
Expand Down Expand Up @@ -283,11 +278,25 @@ export class Query<TResult, TError> {
}

setData(updater: Updater<TResult | undefined, TResult>): void {
const prevData = this.state.data

// Get the new data
let data: TResult | undefined = functionalUpdate(updater, prevData)

// Structurally share data between prev and new data
data = replaceEqualDeep(prevData, data)

// Use prev data if an isDataEqual function is defined and returns `true`
if (this.config.isDataEqual?.(prevData, data)) {
data = prevData
}

const isStale = this.config.staleTime === 0

// Set data and mark it as cached
this.dispatch({
type: ActionType.Success,
updater,
data,
isStale,
})

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

try {
// Set up the query refreshing state
this.dispatch({ type: ActionType.Fetch })
// Set to fetching state if not already in it
if (!this.state.isFetching) {
this.dispatch({ type: ActionType.Fetch })
}

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

this.setData(old => (this.config.isDataEqual!(old, data) ? old! : data))
this.setData(data)

delete this.promise

Expand Down Expand Up @@ -610,7 +621,7 @@ export function queryReducer<TResult, TError>(
return {
...state,
...getStatusProps(QueryStatus.Success),
data: functionalUpdate(action.updater, state.data),
data: action.data,
error: null,
isStale: action.isStale,
isFetched: true,
Expand Down
228 changes: 192 additions & 36 deletions src/core/tests/utils.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { setConsole, queryCache } from '../'
import { deepEqual, shallowEqual } from '../utils'
import { deepEqual, replaceEqualDeep } from '../utils'
import { queryKey } from '../../react/tests/utils'

describe('core/utils', () => {
Expand Down Expand Up @@ -42,63 +42,219 @@ describe('core/utils', () => {
expect(deepEqual(a, b)).toEqual(false)
})

it('should return `false` for different dates', () => {
it('return `false` for equal dates', () => {
const date1 = new Date(2020, 3, 1)
const date2 = new Date(2020, 3, 2)
const date2 = new Date(2020, 3, 1)
expect(deepEqual(date1, date2)).toEqual(false)
})
})

it('return `true` for equal dates', () => {
const date1 = new Date(2020, 3, 1)
const date2 = new Date(2020, 3, 1)
expect(deepEqual(date1, date2)).toEqual(true)
describe('replaceEqualDeep', () => {
it('should return the previous value when the next value is an equal primitive', () => {
expect(replaceEqualDeep(1, 1)).toBe(1)
expect(replaceEqualDeep('1', '1')).toBe('1')
expect(replaceEqualDeep(true, true)).toBe(true)
expect(replaceEqualDeep(false, false)).toBe(false)
expect(replaceEqualDeep(null, null)).toBe(null)
expect(replaceEqualDeep(undefined, undefined)).toBe(undefined)
})
it('should return the next value when the previous value is a different value', () => {
const date1 = new Date()
const date2 = new Date()
expect(replaceEqualDeep(1, 0)).toBe(0)
expect(replaceEqualDeep(1, 2)).toBe(2)
expect(replaceEqualDeep('1', '2')).toBe('2')
expect(replaceEqualDeep(true, false)).toBe(false)
expect(replaceEqualDeep(false, true)).toBe(true)
expect(replaceEqualDeep(date1, date2)).toBe(date2)
})
})

describe('shallowEqual', () => {
it('should return `true` for empty objects', () => {
expect(shallowEqual({}, {})).toEqual(true)
it('should return the next value when the previous value is a different type', () => {
const array = [1]
const object = { a: 'a' }
expect(replaceEqualDeep(0, undefined)).toBe(undefined)
expect(replaceEqualDeep(undefined, 0)).toBe(0)
expect(replaceEqualDeep(2, undefined)).toBe(undefined)
expect(replaceEqualDeep(undefined, 2)).toBe(2)
expect(replaceEqualDeep(undefined, null)).toBe(null)
expect(replaceEqualDeep(null, undefined)).toBe(undefined)
expect(replaceEqualDeep({}, undefined)).toBe(undefined)
expect(replaceEqualDeep([], undefined)).toBe(undefined)
expect(replaceEqualDeep(array, object)).toBe(object)
expect(replaceEqualDeep(object, array)).toBe(array)
})

it('should return `true` for equal values', () => {
expect(shallowEqual(1, 1)).toEqual(true)
it('should return the previous value when the next value is an equal array', () => {
const prev = [1, 2]
const next = [1, 2]
expect(replaceEqualDeep(prev, next)).toBe(prev)
})

it('should return `true` for equal arrays', () => {
expect(shallowEqual([1, 2], [1, 2])).toEqual(true)
it('should return a copy when the previous value is a different array subset', () => {
const prev = [1, 2]
const next = [1, 2, 3]
const result = replaceEqualDeep(prev, next)
expect(result).toEqual(next)
expect(result).not.toBe(prev)
expect(result).not.toBe(next)
})

it('should return `true` for equal shallow objects', () => {
const a = { a: 'a', b: 'b' }
const b = { a: 'a', b: 'b' }
expect(shallowEqual(a, b)).toEqual(true)
it('should return a copy when the previous value is a different array superset', () => {
const prev = [1, 2, 3]
const next = [1, 2]
const result = replaceEqualDeep(prev, next)
expect(result).toEqual(next)
expect(result).not.toBe(prev)
expect(result).not.toBe(next)
})

it('should return `true` for equal deep objects with same identities', () => {
const deep = { b: 'b' }
const a = { a: deep, c: 'c' }
const b = { a: deep, c: 'c' }
expect(shallowEqual(a, b)).toEqual(true)
it('should return the previous value when the next value is an equal empty array', () => {
const prev: any[] = []
const next: any[] = []
expect(replaceEqualDeep(prev, next)).toBe(prev)
})

it('should return `false` for non equal values', () => {
expect(shallowEqual(1, 2)).toEqual(false)
it('should return the previous value when the next value is an equal empty object', () => {
const prev = {}
const next = {}
expect(replaceEqualDeep(prev, next)).toBe(prev)
})

it('should return `false` for equal arrays', () => {
expect(shallowEqual([1, 2], [1, 3])).toEqual(false)
it('should return the previous value when the next value is an equal object', () => {
const prev = { a: 'a' }
const next = { a: 'a' }
expect(replaceEqualDeep(prev, next)).toBe(prev)
})

it('should return `false` for non equal shallow objects', () => {
const a = { a: 'a', b: 'b' }
const b = { a: 'a', b: 'c' }
expect(shallowEqual(a, b)).toEqual(false)
it('should replace different values in objects', () => {
const prev = { a: { b: 'b' }, c: 'c' }
const next = { a: { b: 'b' }, c: 'd' }
const result = replaceEqualDeep(prev, next)
expect(result).toEqual(next)
expect(result).not.toBe(prev)
expect(result).not.toBe(next)
expect(result.a).toBe(prev.a)
expect(result.c).toBe(next.c)
})

it('should return `false` for equal deep objects with different identities', () => {
const a = { a: { b: 'b' }, c: 'c' }
const b = { a: { b: 'b' }, c: 'c' }
expect(shallowEqual(a, b)).toEqual(false)
it('should replace different values in arrays', () => {
const prev = [1, { a: 'a' }, { b: { b: 'b' } }, [1]] as const
const next = [1, { a: 'a' }, { b: { b: 'c' } }, [1]] as const
const result = replaceEqualDeep(prev, next)
expect(result).toEqual(next)
expect(result).not.toBe(prev)
expect(result).not.toBe(next)
expect(result[0]).toBe(prev[0])
expect(result[1]).toBe(prev[1])
expect(result[2]).not.toBe(next[2])
expect(result[2].b.b).toBe(next[2].b.b)
expect(result[3]).toBe(prev[3])
})

it('should replace different values in arrays when the next value is a subset', () => {
const prev = [{ a: 'a' }, { b: 'b' }, { c: 'c' }]
const next = [{ a: 'a' }, { b: 'b' }]
const result = replaceEqualDeep(prev, next)
expect(result).toEqual(next)
expect(result).not.toBe(prev)
expect(result).not.toBe(next)
expect(result[0]).toBe(prev[0])
expect(result[1]).toBe(prev[1])
expect(result[2]).toBeUndefined()
})

it('should replace different values in arrays when the next value is a superset', () => {
const prev = [{ a: 'a' }, { b: 'b' }]
const next = [{ a: 'a' }, { b: 'b' }, { c: 'c' }]
const result = replaceEqualDeep(prev, next)
expect(result).toEqual(next)
expect(result).not.toBe(prev)
expect(result).not.toBe(next)
expect(result[0]).toBe(prev[0])
expect(result[1]).toBe(prev[1])
expect(result[2]).toBe(next[2])
})

it('should copy objects which are not arrays or objects', () => {
const prev = [{ a: 'a' }, { b: 'b' }, { c: 'c' }, 1]
const next = [{ a: 'a' }, new Map(), { c: 'c' }, 2]
const result = replaceEqualDeep(prev, next)
expect(result).not.toBe(prev)
expect(result).not.toBe(next)
expect(result[0]).toBe(prev[0])
expect(result[1]).toBe(next[1])
expect(result[2]).toBe(prev[2])
expect(result[3]).toBe(next[3])
})

it('should support equal objects which are not arrays or objects', () => {
const map = new Map()
const prev = [map, [1]]
const next = [map, [1]]
const result = replaceEqualDeep(prev, next)
expect(result).toBe(prev)
})

it('should support non equal objects which are not arrays or objects', () => {
const map1 = new Map()
const map2 = new Map()
const prev = [map1, [1]]
const next = [map2, [1]]
const result = replaceEqualDeep(prev, next)
expect(result).not.toBe(prev)
expect(result).not.toBe(next)
expect(result[0]).toBe(next[0])
expect(result[1]).toBe(prev[1])
})

it('should replace all parent objects if some nested value changes', () => {
const prev = {
todo: { id: '1', meta: { createdAt: 0 }, state: { done: false } },
otherTodo: { id: '2', meta: { createdAt: 0 }, state: { done: true } },
}
const next = {
todo: { id: '1', meta: { createdAt: 0 }, state: { done: true } },
otherTodo: { id: '2', meta: { createdAt: 0 }, state: { done: true } },
}
const result = replaceEqualDeep(prev, next)
expect(result).toEqual(next)
expect(result).not.toBe(prev)
expect(result).not.toBe(next)
expect(result.todo).not.toBe(prev.todo)
expect(result.todo).not.toBe(next.todo)
expect(result.todo.id).toBe(next.todo.id)
expect(result.todo.meta).toBe(prev.todo.meta)
expect(result.todo.state).not.toBe(next.todo.state)
expect(result.todo.state.done).toBe(next.todo.state.done)
expect(result.otherTodo).toBe(prev.otherTodo)
})

it('should replace all parent arrays if some nested value changes', () => {
const prev = {
todos: [
{ id: '1', meta: { createdAt: 0 }, state: { done: false } },
{ id: '2', meta: { createdAt: 0 }, state: { done: true } },
],
}
const next = {
todos: [
{ id: '1', meta: { createdAt: 0 }, state: { done: true } },
{ id: '2', meta: { createdAt: 0 }, state: { done: true } },
],
}
const result = replaceEqualDeep(prev, next)
expect(result).toEqual(next)
expect(result).not.toBe(prev)
expect(result).not.toBe(next)
expect(result.todos).not.toBe(prev.todos)
expect(result.todos).not.toBe(next.todos)
expect(result.todos[0]).not.toBe(prev.todos[0])
expect(result.todos[0]).not.toBe(next.todos[0])
expect(result.todos[0].id).toBe(next.todos[0].id)
expect(result.todos[0].meta).toBe(prev.todos[0].meta)
expect(result.todos[0].state).not.toBe(next.todos[0].state)
expect(result.todos[0].state.done).toBe(next.todos[0].state.done)
expect(result.todos[1]).toBe(prev.todos[1])
})
})
})
Loading