Skip to content

Commit 89bec20

Browse files
committed
Use deep equal to compare incoming data for referential stability
Fixes TanStack#430
1 parent 1353c42 commit 89bec20

File tree

10 files changed

+181
-15
lines changed

10 files changed

+181
-15
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,4 @@ yarn-debug.log*
2525
yarn-error.log*
2626
.history
2727
size-plugin.json
28+
stats.html

README.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,8 @@ Enjoy this library? Try them all! [React Table](https://github.com/tannerlinsley
3434
- Request Cancellation
3535
- [React Suspense](https://reactjs.org/docs/concurrent-mode-suspense.html) + Fetch-As-You-Render Query Prefetching
3636
- [Dedicated Devtools (React Query Devtools)](https://github.com/tannerlinsley/react-query-devtools)
37-
- <a href="https://bundlephobia.com/result?p=react-query@latest" target="\_parent">
38-
<img alt="" src="https://badgen.net/bundlephobia/minzip/react-query@latest" />
37+
- 4kb - 6kb (depending on features imported) <a href="https://bundlephobia.com/result?p=react-query@latest" target="\_parent">
38+
<img alt="" src="https://badgen.net/bundlephobia/minzip/react-query@latest" />
3939
</a>
4040

4141
<details>
@@ -255,7 +255,6 @@ This library is being built and maintained by me, @tannerlinsley and I am always
255255
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
256256
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
257257

258-
259258
- [Installation](#installation)
260259
- [Defaults to keep in mind](#defaults-to-keep-in-mind)
261260
- [Queries](#queries)
@@ -335,6 +334,7 @@ Out of the box, React Query is configured with **aggressive but sane** defaults.
335334
- Query results that become unused (all instances of the query are unmounted) will still be cached in case they are used again for a default of 5 minutes before they are garbage collected. To change this, you can alter the default `cacheTime` for queries to something other than `1000 * 60 * 5` milliseconds.
336335
- Stale queries will automatically be refetched in the background **when the browser window is refocused by the user**. You can disable this using the `refetchOnWindowFocus` option in queries or the global config.
337336
- Queries that fail will silently and automatically be retried **3 times, with exponential backoff delay** before capturing and displaying an error to the UI. To change this, you can alter the default `retry` and `retryDelay` options for queries to something other than `3` and the default exponential backoff function.
337+
- Query results by default are deep compared to detect if data has actually changed and if not, the data reference remains unchanged to better help with value stabilization with regards to useMemo and useCallback. The default deep compare function use here (`config.isDataEqual`) only supports comparing JSON-compatible primitives. If you are dealing with any non-json compatible values in your query responses OR are seeing performance issues with the deep compare function, you should probably disable it (`config.isDataEqual = () => false`) or customize it to better fit your needs.
338338

339339
# Queries
340340

@@ -2218,7 +2218,7 @@ const promise = mutate(variables, {
22182218
- Defaults to the global query config's `useErrorBoundary` value, which is `false`
22192219
- Set this to true if you want mutation errors to be thrown in the render phase and propagate to the nearest error boundary
22202220
- `selectedUseQueryOptions`
2221-
- *Selected* options of `useQuery` are also applicable here. E.g. `retry` and `retryDelay` can be used as described in the [`useQuery` section](#usequery). *Documentation of these options will be improved in the future.*
2221+
- _Selected_ options of `useQuery` are also applicable here. E.g. `retry` and `retryDelay` can be used as described in the [`useQuery` section](#usequery). _Documentation of these options will be improved in the future._
22222222
22232223
### Returns
22242224
@@ -2550,6 +2550,7 @@ const queryConfig = {
25502550
refetchInterval: false,
25512551
queryFnParamsFilter: args => filteredArgs,
25522552
refetchOnMount: true,
2553+
isDataEqual: (previous, next) => true, // or false
25532554
}
25542555

25552556
function App() {
@@ -2604,6 +2605,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
26042605
26052606
<!-- markdownlint-enable -->
26062607
<!-- prettier-ignore-end -->
2608+
26072609
<!-- ALL-CONTRIBUTORS-LIST:END -->
26082610
26092611
This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome!

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"formatReadme": "yarn doctoc",
2727
"doctoc": "npx doctoc --maxlevel 2 README.md",
2828
"jump2header": "npx @strdr4605/jump2header --header 'documentation' --start 'Installation' -e 2 --silent -l 2",
29+
"stats": "open ./stats.html",
2930
"postinstall": "node ./scripts/postinstall.js || exit 0",
3031
"dtslint": "dtslint types"
3132
},
@@ -81,6 +82,7 @@
8182
"rollup-plugin-peer-deps-external": "^2.2.2",
8283
"rollup-plugin-prettier": "^0.6.0",
8384
"rollup-plugin-size": "^0.2.2",
84-
"rollup-plugin-terser": "^5.2.0"
85+
"rollup-plugin-terser": "^5.2.0",
86+
"rollup-plugin-visualizer": "^4.0.4"
8587
}
8688
}

rollup.config.js

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import babel from 'rollup-plugin-babel'
22
import { terser } from 'rollup-plugin-terser'
33
import size from 'rollup-plugin-size'
4+
import externalDeps from 'rollup-plugin-peer-deps-external'
5+
import resolve from 'rollup-plugin-node-resolve'
6+
import commonJS from 'rollup-plugin-commonjs'
7+
import visualizer from 'rollup-plugin-visualizer'
8+
import replace from '@rollup/plugin-replace'
49

510
const external = ['react']
611

@@ -17,7 +22,7 @@ export default [
1722
sourcemap: true,
1823
},
1924
external,
20-
plugins: [babel()],
25+
plugins: [resolve(), babel(), commonJS(), externalDeps()],
2126
},
2227
{
2328
input: 'src/index.js',
@@ -27,7 +32,7 @@ export default [
2732
sourcemap: true,
2833
},
2934
external,
30-
plugins: [babel(), terser()],
35+
plugins: [resolve(), babel(), commonJS(), externalDeps()],
3136
},
3237
{
3338
input: 'src/index.js',
@@ -39,7 +44,7 @@ export default [
3944
globals,
4045
},
4146
external,
42-
plugins: [babel()],
47+
plugins: [resolve(), babel(), commonJS(), externalDeps()],
4348
},
4449
{
4550
input: 'src/index.js',
@@ -51,6 +56,15 @@ export default [
5156
globals,
5257
},
5358
external,
54-
plugins: [babel(), terser(), size()],
59+
plugins: [
60+
replace({ 'process.env.NODE_ENV': `"production"`, delimiters: ['', ''] }),
61+
resolve(),
62+
babel(),
63+
commonJS(),
64+
externalDeps(),
65+
terser(),
66+
size(),
67+
visualizer(),
68+
],
5569
},
5670
]

src/config.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React from 'react'
2-
import { noop, stableStringify, identity } from './utils'
2+
import { noop, stableStringify, identity, deepEqual } from './utils'
33

44
export const configContext = React.createContext()
55

@@ -21,6 +21,7 @@ export const defaultConfigRef = {
2121
onError: noop,
2222
onSettled: noop,
2323
refetchOnMount: true,
24+
isDataEqual: deepEqual,
2425
},
2526
}
2627

src/queryCache.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -396,7 +396,9 @@ export function makeQueryCache() {
396396
...query.queryVariables
397397
)
398398

399-
query.setData(data)
399+
query.setData(old =>
400+
query.config.isDataEqual(old, data) ? old : data
401+
)
400402

401403
query.instances.forEach(
402404
instance =>

src/tests/useQuery.test.js

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -750,4 +750,61 @@ describe('useQuery', () => {
750750
const query = queryCache.getQuery('test')
751751
expect(query.cacheTimeout).toBe(undefined)
752752
})
753+
754+
it('should not cause memo churn when data does not change', async () => {
755+
const queryFn = jest.fn()
756+
const memoFn = jest.fn()
757+
const originalVisibilityState = document.visibilityState
758+
759+
function mockVisibilityState(value) {
760+
Object.defineProperty(document, 'visibilityState', {
761+
value,
762+
configurable: true,
763+
})
764+
}
765+
766+
// make page unfocused
767+
mockVisibilityState('hidden')
768+
769+
function Page() {
770+
const query = useQuery(
771+
'test',
772+
() =>
773+
queryFn() || {
774+
data: {
775+
nested: true,
776+
},
777+
}
778+
)
779+
780+
React.useMemo(() => {
781+
memoFn()
782+
console.log(query.data)
783+
return query.data
784+
}, [query.data])
785+
786+
return (
787+
<div>
788+
<div>status {query.status}</div>
789+
<div>isFetching {query.isFetching ? 'true' : 'false'}</div>
790+
</div>
791+
)
792+
}
793+
794+
const rendered = render(<Page />)
795+
796+
await waitForElement(() => rendered.getByText('status loading'))
797+
await waitForElement(() => rendered.getByText('status success'))
798+
799+
act(() => {
800+
// reset visibilityState to original value
801+
mockVisibilityState(originalVisibilityState)
802+
window.dispatchEvent(new FocusEvent('focus'))
803+
})
804+
805+
await waitForElement(() => rendered.getByText('isFetching true'))
806+
await waitForElement(() => rendered.getByText('isFetching false'))
807+
expect(queryFn).toHaveBeenCalledTimes(2)
808+
expect(memoFn).toHaveBeenCalledTimes(2)
809+
})
753810
})

src/useInfiniteQuery.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,19 +29,19 @@ export function useInfiniteQuery(...args) {
2929
rebuiltPageVariables.push(args)
3030
} else {
3131
// get an up-to-date cursor based on the previous data set
32-
const nextCursor = getGetFetchMore()(data[data.length - 1], data);
32+
const nextCursor = getGetFetchMore()(data[data.length - 1], data)
3333

3434
// break early if there's no next cursor
3535
// otherwise we'll start from the beginning
3636
// which will cause unwanted duplication
3737
if (!nextCursor) {
38-
break;
38+
break
3939
}
4040

4141
const pageArgs = [
4242
// remove the last argument (the previously saved cursor)
4343
...args.slice(0, -1),
44-
nextCursor
44+
nextCursor,
4545
]
4646

4747
data.push(await originalQueryFn(...pageArgs))

src/utils.js

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,3 +136,39 @@ export function handleSuspense(queryInfo) {
136136
}
137137
}
138138
}
139+
140+
// This deep-equal is directly based on https://github.com/epoberezkin/fast-deep-equal.
141+
// The parts for comparing any non-JSON-supported values has been removed
142+
export function deepEqual(a, b) {
143+
if (a === b) return true
144+
145+
if (a && b && typeof a == 'object' && typeof b == 'object') {
146+
var length, i, keys
147+
if (Array.isArray(a)) {
148+
length = a.length
149+
// eslint-disable-next-line eqeqeq
150+
if (length != b.length) return false
151+
for (i = length; i-- !== 0; ) if (!deepEqual(a[i], b[i])) return false
152+
return true
153+
}
154+
155+
keys = Object.keys(a)
156+
length = keys.length
157+
if (length !== Object.keys(b).length) return false
158+
159+
for (i = length; i-- !== 0; )
160+
if (!Object.prototype.hasOwnProperty.call(b, keys[i])) return false
161+
162+
for (i = length; i-- !== 0; ) {
163+
var key = keys[i]
164+
165+
if (!deepEqual(a[key], b[key])) return false
166+
}
167+
168+
return true
169+
}
170+
171+
// true if both NaN, false otherwise
172+
// eslint-disable-next-line no-self-compare
173+
return a !== a && b !== b
174+
}

yarn.lock

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3242,6 +3242,11 @@ es-to-primitive@^1.2.1:
32423242
is-date-object "^1.0.1"
32433243
is-symbol "^1.0.2"
32443244

3245+
escape-goat@^2.0.0:
3246+
version "2.1.1"
3247+
resolved "https://registry.yarnpkg.com/escape-goat/-/escape-goat-2.1.1.tgz#1b2dc77003676c457ec760b2dc68edb648188675"
3248+
integrity sha512-8/uIhbG12Csjy2JEW7D9pHbreaVaS/OpN3ycnyvElTdwM5n6GY6W6e2IPemfvGZeUMqZ9A/3GqIZMgKnBhAw/Q==
3249+
32453250
escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5:
32463251
version "1.0.5"
32473252
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
@@ -4229,6 +4234,11 @@ is-directory@^0.3.1:
42294234
resolved "https://registry.yarnpkg.com/is-directory/-/is-directory-0.3.1.tgz#61339b6f2475fc772fd9c9d83f5c8575dc154ae1"
42304235
integrity sha1-YTObbyR1/Hcv2cnYP1yFddwVSuE=
42314236

4237+
is-docker@^2.0.0:
4238+
version "2.0.0"
4239+
resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-2.0.0.tgz#2cb0df0e75e2d064fe1864c37cdeacb7b2dcf25b"
4240+
integrity sha512-pJEdRugimx4fBMra5z2/5iRdZ63OhYV0vr0Dwm5+xtW4D1FvRkB8hamMIhnWfyJeDdyr/aa7BDyNbtG38VxgoQ==
4241+
42324242
is-extendable@^0.1.0, is-extendable@^0.1.1:
42334243
version "0.1.1"
42344244
resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-0.1.1.tgz#62b110e289a471418e3ec36a617d472e301dfc89"
@@ -4340,6 +4350,11 @@ is-wsl@^1.1.0:
43404350
resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-1.1.0.tgz#1f16e4aa22b04d1336b66188a66af3c600c3a66d"
43414351
integrity sha1-HxbkqiKwTRM2tmGIpmrzxgDDpm0=
43424352

4353+
is-wsl@^2.1.1:
4354+
version "2.1.1"
4355+
resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-2.1.1.tgz#4a1c152d429df3d441669498e2486d3596ebaf1d"
4356+
integrity sha512-umZHcSrwlDHo2TGMXv0DZ8dIUGunZ2Iv68YZnrmCiBPkZ4aaOhtv7pXJKeki9k3qJ3RJr0cDyitcl5wEH3AYog==
4357+
43434358
isarray@1.0.0, isarray@^1.0.0, isarray@~1.0.0:
43444359
version "1.0.0"
43454360
resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11"
@@ -5278,6 +5293,11 @@ nan@^2.12.1:
52785293
resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.0.tgz#7818f722027b2459a86f0295d434d1fc2336c52c"
52795294
integrity sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg==
52805295

5296+
nanoid@^3.0.1:
5297+
version "3.1.3"
5298+
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.3.tgz#b2bcfcfda4b4d6838bc22a0c8dd3c0a17a204c20"
5299+
integrity sha512-Zw8rTOUfh6FlKgkEbHiB1buOF2zOPOQyGirABUWn+9Z7m9PpyoLVkh6Ksc53vBjndINQ2+9LfRPaHxb/u45EGg==
5300+
52815301
nanomatch@^1.2.9:
52825302
version "1.2.13"
52835303
resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119"
@@ -5562,6 +5582,14 @@ onetime@^2.0.0:
55625582
dependencies:
55635583
mimic-fn "^1.0.0"
55645584

5585+
open@^7.0.3:
5586+
version "7.0.3"
5587+
resolved "https://registry.yarnpkg.com/open/-/open-7.0.3.tgz#db551a1af9c7ab4c7af664139930826138531c48"
5588+
integrity sha512-sP2ru2v0P290WFfv49Ap8MF6PkzGNnGlAwHweB4WR4mr5d2d0woiCluUeJ218w7/+PmoBy9JmYgD5A4mLcWOFA==
5589+
dependencies:
5590+
is-docker "^2.0.0"
5591+
is-wsl "^2.1.1"
5592+
55655593
optimist@^0.6.1:
55665594
version "0.6.1"
55675595
resolved "https://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz#da3ea74686fa21a19a111c326e90eb15a0196686"
@@ -5929,6 +5957,13 @@ punycode@^2.1.0, punycode@^2.1.1:
59295957
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec"
59305958
integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==
59315959

5960+
pupa@^2.0.0:
5961+
version "2.0.1"
5962+
resolved "https://registry.yarnpkg.com/pupa/-/pupa-2.0.1.tgz#dbdc9ff48ffbea4a26a069b6f9f7abb051008726"
5963+
integrity sha512-hEJH0s8PXLY/cdXh66tNEQGndDrIKNqNC5xmrysZy3i5C3oEoLna7YAOad+7u125+zH1HNXUmGEkrhb3c2VriA==
5964+
dependencies:
5965+
escape-goat "^2.0.0"
5966+
59325967
q@^1.1.2:
59335968
version "1.5.1"
59345969
resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7"
@@ -6316,6 +6351,17 @@ rollup-plugin-terser@^5.2.0:
63166351
serialize-javascript "^2.1.2"
63176352
terser "^4.6.2"
63186353

6354+
rollup-plugin-visualizer@^4.0.4:
6355+
version "4.0.4"
6356+
resolved "https://registry.yarnpkg.com/rollup-plugin-visualizer/-/rollup-plugin-visualizer-4.0.4.tgz#69b9140c6faf46328997ed2d08b974252bf9d683"
6357+
integrity sha512-odkyLiVxCEXh4AWFSl75+pbIapzhEZkOVww8pKUgraOHicSH67MYMnAOHWQVK/BYeD1cCiF/0kk8/XNX2+LM9A==
6358+
dependencies:
6359+
nanoid "^3.0.1"
6360+
open "^7.0.3"
6361+
pupa "^2.0.0"
6362+
source-map "^0.7.3"
6363+
yargs "^15.0.0"
6364+
63196365
rollup-pluginutils@^1.5.2:
63206366
version "1.5.2"
63216367
resolved "https://registry.yarnpkg.com/rollup-pluginutils/-/rollup-pluginutils-1.5.2.tgz#1e156e778f94b7255bfa1b3d0178be8f5c552408"
@@ -6607,6 +6653,11 @@ source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.1:
66076653
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
66086654
integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
66096655

6656+
source-map@^0.7.3:
6657+
version "0.7.3"
6658+
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.3.tgz#5302f8169031735226544092e64981f751750383"
6659+
integrity sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==
6660+
66106661
sourcemap-codec@^1.4.1:
66116662
version "1.4.8"
66126663
resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4"
@@ -7403,7 +7454,7 @@ yargs@^13.3.0:
74037454
y18n "^4.0.0"
74047455
yargs-parser "^13.1.1"
74057456

7406-
yargs@^15.1.0:
7457+
yargs@^15.0.0, yargs@^15.1.0:
74077458
version "15.3.1"
74087459
resolved "https://registry.yarnpkg.com/yargs/-/yargs-15.3.1.tgz#9505b472763963e54afe60148ad27a330818e98b"
74097460
integrity sha512-92O1HWEjw27sBfgmXiixJWT5hRBp2eobqXicLtPBIDBhYB+1HpwZlXmbW2luivBJHBzki+7VyCLRtAkScbTBQA==

0 commit comments

Comments
 (0)