diff --git a/docs/src/pages/docs/comparison.md b/docs/src/pages/docs/comparison.md index d2c3cf0aa7..911c3f3385 100644 --- a/docs/src/pages/docs/comparison.md +++ b/docs/src/pages/docs/comparison.md @@ -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 | ✅ | ✅ | ✅ | diff --git a/docs/src/pages/docs/overview.md b/docs/src/pages/docs/overview.md index 09fb3bd437..7318aa849a 100644 --- a/docs/src/pages/docs/overview.md +++ b/docs/src/pages/docs/overview.md @@ -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! diff --git a/src/core/config.ts b/src/core/config.ts index cb37fd94d4..d2807a6672 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -1,4 +1,4 @@ -import { stableStringify, identity, deepEqual } from './utils' +import { stableStringify, identity } from './utils' import { ArrayQueryKey, QueryKey, @@ -44,7 +44,6 @@ export const DEFAULT_CONFIG: ReactQueryConfig = { refetchInterval: false, queryFnParamsFilter: identity, refetchOnMount: true, - isDataEqual: deepEqual, useErrorBoundary: false, }, mutations: { diff --git a/src/core/query.ts b/src/core/query.ts index 70d7f38c73..fbb31e94be 100644 --- a/src/core/query.ts +++ b/src/core/query.ts @@ -6,8 +6,8 @@ import { noop, Console, getStatusProps, - shallowEqual, Updater, + replaceEqualDeep, } from './utils' import { QueryInstance, OnStateUpdateFunction } from './queryInstance' import { @@ -81,7 +81,7 @@ interface FetchAction { interface SuccessAction { type: ActionType.Success - updater: Updater + data: TResult | undefined isStale: boolean } @@ -157,14 +157,9 @@ export class Query { } private dispatch(action: Action): 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 { @@ -283,11 +278,25 @@ export class Query { } setData(updater: Updater): 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, }) @@ -502,13 +511,15 @@ export class Query { 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 @@ -610,7 +621,7 @@ export function queryReducer( return { ...state, ...getStatusProps(QueryStatus.Success), - data: functionalUpdate(action.updater, state.data), + data: action.data, error: null, isStale: action.isStale, isFetched: true, diff --git a/src/core/tests/utils.test.tsx b/src/core/tests/utils.test.tsx index cb1e0ad592..c6c345c003 100644 --- a/src/core/tests/utils.test.tsx +++ b/src/core/tests/utils.test.tsx @@ -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', () => { @@ -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]) }) }) }) diff --git a/src/core/utils.ts b/src/core/utils.ts index 7a878f097b..751029fcc1 100644 --- a/src/core/utils.ts +++ b/src/core/utils.ts @@ -69,10 +69,6 @@ export function stableStringify(value: any): string { return JSON.stringify(value, stableStringifyReplacer) } -export function isObject(a: unknown): boolean { - return a && typeof a === 'object' && !Array.isArray(a) -} - export function deepIncludes(a: any, b: any): boolean { if (a === b) { return true @@ -135,57 +131,77 @@ export function getQueryArgs( } export function deepEqual(a: any, b: any): boolean { - return equal(a, b, true) -} - -export function shallowEqual(a: any, b: any): boolean { - return equal(a, b, false) -} - -// This deep-equal is directly based on https://github.com/epoberezkin/fast-deep-equal. -// The parts for comparing any non-JSON-supported values has been removed -function equal(a: any, b: any, deep: boolean, depth = 0): boolean { - if (a === b) return true - - if ( - (deep || !depth) && - a && - b && - typeof a == 'object' && - typeof b == 'object' - ) { - let length, i - if (Array.isArray(a)) { - length = a.length - // eslint-disable-next-line eqeqeq - if (length != b.length) return false - for (i = length; i-- !== 0; ) - if (!equal(a[i], b[i], deep, depth + 1)) return false - return true - } + return replaceEqualDeep(a, b) === a +} - if (a.valueOf !== Object.prototype.valueOf) - return a.valueOf() === b.valueOf() +/** + * This function returns `a` if `b` is deeply equal. + * If not, it will replace any deeply equal children of `b` with those of `a`. + * This can be used for structural sharing between JSON values for example. + */ +export function replaceEqualDeep(a: unknown, b: T): T +export function replaceEqualDeep(a: any, b: any): any { + if (a === b) { + return a + } - const keys = Object.keys(a) - length = keys.length - if (length !== Object.keys(b).length) return false + const array = Array.isArray(a) && Array.isArray(b) - for (i = length; i-- !== 0; ) - if (!Object.prototype.hasOwnProperty.call(b, keys[i])) return false + if (array || (isPlainObject(a) && isPlainObject(b))) { + const aSize = array ? a.length : Object.keys(a).length + const bItems = array ? b : Object.keys(b) + const bSize = bItems.length + const copy: any = array ? [] : {} - for (i = length; i-- !== 0; ) { - const key = keys[i] + let equalItems = 0 - if (!equal(a[key], b[key], deep, depth + 1)) return false + for (let i = 0; i < bSize; i++) { + const key = array ? i : bItems[i] + copy[key] = replaceEqualDeep(a[key], b[key]) + if (copy[key] === a[key]) { + equalItems++ + } } + return aSize === bSize && equalItems === aSize ? a : copy + } + + return b +} + +export function isObject(a: unknown): boolean { + return a && typeof a === 'object' && !Array.isArray(a) +} + +// Copied from: https://github.com/jonschlinkert/is-plain-object +function isPlainObject(o: any): o is Object { + if (!hasObjectPrototype(o)) { + return false + } + + // If has modified constructor + const ctor = o.constructor + if (typeof ctor === 'undefined') { return true } - // true if both NaN, false otherwise - // eslint-disable-next-line no-self-compare - return a !== a && b !== b + // If has modified prototype + const prot = ctor.prototype + if (!hasObjectPrototype(prot)) { + return false + } + + // If constructor does not have an Object-specific method + if (!prot.hasOwnProperty('isPrototypeOf')) { + return false + } + + // Most likely a plain Object + return true +} + +function hasObjectPrototype(o: any): boolean { + return Object.prototype.toString.call(o) === '[object Object]' } export function getStatusProps(status: T) { diff --git a/src/react/tests/useQuery.test.tsx b/src/react/tests/useQuery.test.tsx index 0a7788e70d..2110bceebf 100644 --- a/src/react/tests/useQuery.test.tsx +++ b/src/react/tests/useQuery.test.tsx @@ -214,6 +214,60 @@ describe('useQuery', () => { }) }) + it('should share equal data structures between query results', async () => { + const key = queryKey() + + const result1 = [ + { id: '1', done: false }, + { id: '2', done: false }, + ] + + const result2 = [ + { id: '1', done: false }, + { id: '2', done: true }, + ] + + const states: QueryResult[] = [] + + let count = 0 + + function Page() { + const state = useQuery(key, () => { + count++ + return count === 1 ? result1 : result2 + }) + + states.push(state) + + const { refetch } = state + + React.useEffect(() => { + setTimeout(() => { + refetch() + }, 10) + }, [refetch]) + return null + } + + render() + + await waitFor(() => expect(states.length).toBe(4)) + + const todos = states[2].data! + const todo1 = todos[0] + const todo2 = todos[1] + + const newTodos = states[3].data! + const newTodo1 = newTodos[0] + const newTodo2 = newTodos[1] + + expect(todos).toEqual(result1) + expect(newTodos).toEqual(result2) + expect(newTodos).not.toBe(todos) + expect(newTodo1).toBe(todo1) + expect(newTodo2).not.toBe(todo2) + }) + // See https://github.com/tannerlinsley/react-query/issues/137 it('should not override initial data in dependent queries', async () => { const key1 = queryKey()