Skip to content

Commit 4608cf8

Browse files
committed
feat: implement batch rendering
1 parent 14dba5c commit 4608cf8

25 files changed

+225
-108
lines changed

docs/src/pages/docs/comparison.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,26 +33,29 @@ Feature/Capability Key:
3333
| Scroll Recovery ||||
3434
| Cache Manipulation ||||
3535
| Outdated Query Dismissal ||||
36+
| Render Optimization<sup>2</sup> || 🛑 | 🛑 |
3637
| Auto Garbage Collection || 🛑 | 🛑 |
3738
| Mutation Hooks || 🟡 ||
3839
| Prefetching APIs || 🔶 ||
3940
| Query Cancellation || 🛑 | 🛑 |
40-
| Partial Query Matching<sup>2</sup> || 🛑 | 🛑 |
41+
| Partial Query Matching<sup>3</sup> || 🛑 | 🛑 |
4142
| Stale While Revalidate ||| 🛑 |
4243
| Stale Time Configuration || 🛑 | 🛑 |
4344
| Window Focus Refetching ||| 🛑 |
4445
| Network Status Refetching ||||
45-
| Automatic Refetch after Mutation<sup>3</sup> | 🔶 | 🔶 ||
46+
| Automatic Refetch after Mutation<sup>4</sup> | 🔶 | 🔶 ||
4647
| Cache Dehydration/Rehydration || 🛑 ||
4748
| React Suspense (Experimental) ||| 🛑 |
4849

4950
### Notes
5051

5152
> **<sup>1</sup> Lagged / "Lazy" Queries** - React Query provides a way to continue to see an existing query's data while the next query loads (similar to the same UX that suspense will soon provide natively). This is extremely important when writing pagination UIs or infinite loading UIs where you do not want to show a hard loading state whenever a new query is requested. Other libraries do not have this capability and render a hard loading state for the new query (unless it has been prefetched), while the new query loads.
5253
53-
> **<sup>2</sup> Partial query matching** - Because React Query uses deterministic query key serialization, this allows you to manipulate variable groups of queries without having to know each individual query-key that you want to match, eg. you can refetch every query that starts with `todos` in its key, regardless of variables, or you can target specific queries with (or without) variables or nested properties, and even use a filter function to only match queries that pass your specific conditions.
54+
> **<sup>2</sup> Render Optimization** - React Query has excellent rendering performance. It will only re-render your components when a query is updated. For example because it has new data, or to indicate it is fetching. React Query also batches updates together to make sure your application only re-renders once when multiple components are using the same query. If you are only interested in the `data` or `error` properties, you can reduce the number of renders even more by setting `notifyOnStatusChange` to `false`.
5455
55-
> **<sup>3</sup> Automatic Refetch after Mutation** - For truly automatic refetching to happen after a mutation occurs, a schema is necessary (like the one graphQL provides) along with heuristics that help the library know how to identify individual entities and entities types in that schema.
56+
> **<sup>3</sup> Partial query matching** - Because React Query uses deterministic query key serialization, this allows you to manipulate variable groups of queries without having to know each individual query-key that you want to match, eg. you can refetch every query that starts with `todos` in its key, regardless of variables, or you can target specific queries with (or without) variables or nested properties, and even use a filter function to only match queries that pass your specific conditions.
57+
58+
> **<sup>4</sup> Automatic Refetch after Mutation** - For truly automatic refetching to happen after a mutation occurs, a schema is necessary (like the one graphQL provides) along with heuristics that help the library know how to identify individual entities and entities types in that schema.
5659
5760
[swr]: https://github.com/vercel/swr
5861
[apollo]: https://github.com/apollographql/apollo-client

rollup.config.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,12 @@ import commonJS from 'rollup-plugin-commonjs'
77
import visualizer from 'rollup-plugin-visualizer'
88
import replace from '@rollup/plugin-replace'
99

10-
const external = ['react']
10+
const external = ['react', 'react-dom']
1111
const hydrationExternal = [...external, 'react-query']
1212

1313
const globals = {
1414
react: 'React',
15+
'react-dom': 'ReactDOM',
1516
}
1617
const hydrationGlobals = {
1718
...globals,

src/core/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,13 @@ export {
77
} from './queryCache'
88
export { setFocusHandler } from './setFocusHandler'
99
export { setOnlineHandler } from './setOnlineHandler'
10-
export { CancelledError, isCancelledError, isError, setConsole } from './utils'
10+
export {
11+
CancelledError,
12+
isCancelledError,
13+
isError,
14+
setConsole,
15+
setBatchedUpdates,
16+
} from './utils'
1117

1218
// Types
1319
export * from './types'

src/core/notifyManager.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { getBatchedUpdates, scheduleMicrotask } from './utils'
2+
3+
// TYPES
4+
5+
type NotifyCallback = () => void
6+
7+
// CLASS
8+
9+
export class NotifyManager {
10+
private queue: NotifyCallback[]
11+
private transactions: number
12+
13+
constructor() {
14+
this.queue = []
15+
this.transactions = 0
16+
}
17+
18+
batch<T>(callback: () => T): T {
19+
this.transactions++
20+
const result = callback()
21+
this.transactions--
22+
if (!this.transactions) {
23+
this.flush()
24+
}
25+
return result
26+
}
27+
28+
schedule(notify: NotifyCallback): void {
29+
if (this.transactions) {
30+
this.queue.push(notify)
31+
} else {
32+
scheduleMicrotask(() => {
33+
notify()
34+
})
35+
}
36+
}
37+
38+
flush(): void {
39+
const queue = this.queue
40+
this.queue = []
41+
if (queue.length) {
42+
scheduleMicrotask(() => {
43+
const batchedUpdates = getBatchedUpdates()
44+
batchedUpdates(() => {
45+
queue.forEach(notify => {
46+
notify()
47+
})
48+
})
49+
})
50+
}
51+
}
52+
}
53+
54+
// SINGLETON
55+
56+
export const notifyManager = new NotifyManager()

src/core/query.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
} from './types'
2424
import type { QueryCache } from './queryCache'
2525
import { QueryObserver, UpdateListener } from './queryObserver'
26+
import { notifyManager } from './notifyManager'
2627

2728
// TYPES
2829

@@ -134,11 +135,13 @@ export class Query<TResult, TError> {
134135
private dispatch(action: Action<TResult, TError>): void {
135136
this.state = queryReducer(this.state, action)
136137

137-
this.observers.forEach(observer => {
138-
observer.onQueryUpdate(action)
139-
})
138+
notifyManager.batch(() => {
139+
this.observers.forEach(observer => {
140+
observer.onQueryUpdate(action)
141+
})
140142

141-
this.queryCache.notifyGlobalListeners(this)
143+
this.queryCache.notifyGlobalListeners(this)
144+
})
142145
}
143146

144147
private scheduleGc(): void {

src/core/queryCache.ts

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
TypedQueryFunctionArgs,
1919
ResolvedQueryConfig,
2020
} from './types'
21+
import { notifyManager } from './notifyManager'
2122

2223
// TYPES
2324

@@ -94,8 +95,12 @@ export class QueryCache {
9495
0
9596
)
9697

97-
this.globalListeners.forEach(listener => {
98-
listener(this, query)
98+
notifyManager.batch(() => {
99+
this.globalListeners.forEach(listener => {
100+
notifyManager.schedule(() => {
101+
listener(this, query)
102+
})
103+
})
99104
})
100105
}
101106

@@ -209,8 +214,10 @@ export class QueryCache {
209214
): Promise<Query<unknown, unknown>[]> {
210215
const queries = this.getQueries(predicate, options)
211216

212-
queries.forEach(query => {
213-
query.invalidate()
217+
notifyManager.batch(() => {
218+
queries.forEach(query => {
219+
query.invalidate()
220+
})
214221
})
215222

216223
const { refetchActive = true, refetchInactive = false } = options || {}
@@ -245,14 +252,16 @@ export class QueryCache {
245252
): Promise<Query<unknown, unknown>[]> {
246253
const promises: Promise<Query<unknown, unknown>>[] = []
247254

248-
this.getQueries(predicate, options).forEach(query => {
249-
let promise = query.fetch().then(() => query)
255+
notifyManager.batch(() => {
256+
this.getQueries(predicate, options).forEach(query => {
257+
let promise = query.fetch().then(() => query)
250258

251-
if (!options?.throwOnError) {
252-
promise = promise.catch(() => query)
253-
}
259+
if (!options?.throwOnError) {
260+
promise = promise.catch(() => query)
261+
}
254262

255-
promises.push(promise)
263+
promises.push(promise)
264+
})
256265
})
257266

258267
return Promise.all(promises)
@@ -398,9 +407,11 @@ export function makeQueryCache(config?: QueryCacheConfig) {
398407

399408
export function onVisibilityOrOnlineChange(type: 'focus' | 'online') {
400409
if (isDocumentVisible() && isOnline()) {
401-
queryCaches.forEach(queryCache => {
402-
queryCache.getQueries().forEach(query => {
403-
query.onInteraction(type)
410+
notifyManager.batch(() => {
411+
queryCaches.forEach(queryCache => {
412+
queryCache.getQueries().forEach(query => {
413+
query.onInteraction(type)
414+
})
404415
})
405416
})
406417
}

src/core/queryObserver.ts

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
isValidTimeout,
66
noop,
77
} from './utils'
8+
import { notifyManager } from './notifyManager'
89
import type { QueryResult, ResolvedQueryConfig } from './types'
910
import type { Query, Action, FetchMoreOptions, RefetchOptions } from './query'
1011

@@ -149,8 +150,13 @@ export class QueryObserver<TResult, TError> {
149150
}
150151
}
151152

152-
private notify(): void {
153-
this.listener?.(this.currentResult)
153+
private notify(global?: boolean): void {
154+
notifyManager.schedule(() => {
155+
this.listener?.(this.currentResult)
156+
if (global) {
157+
this.config.queryCache.notifyGlobalListeners(this.currentQuery)
158+
}
159+
})
154160
}
155161

156162
private updateStaleTimeout(): void {
@@ -172,8 +178,7 @@ export class QueryObserver<TResult, TError> {
172178
if (!this.isStale) {
173179
this.isStale = true
174180
this.updateResult()
175-
this.notify()
176-
this.config.queryCache.notifyGlobalListeners(this.currentQuery)
181+
this.notify(true)
177182
}
178183
}, timeout)
179184
}
@@ -221,20 +226,19 @@ export class QueryObserver<TResult, TError> {
221226
}
222227

223228
private updateResult(): void {
224-
const { currentQuery, previousQueryResult, config } = this
225-
const { state } = currentQuery
229+
const { state } = this.currentQuery
226230
let { data, status, updatedAt } = state
227231
let isPreviousData = false
228232

229233
// Keep previous data if needed
230234
if (
231-
config.keepPreviousData &&
235+
this.config.keepPreviousData &&
232236
state.isInitialData &&
233-
previousQueryResult?.isSuccess
237+
this.previousQueryResult?.isSuccess
234238
) {
235-
data = previousQueryResult.data
236-
updatedAt = previousQueryResult.updatedAt
237-
status = previousQueryResult.status
239+
data = this.previousQueryResult.data
240+
updatedAt = this.previousQueryResult.updatedAt
241+
status = this.previousQueryResult.status
238242
isPreviousData = true
239243
}
240244

src/core/tests/queryCache.test.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
mockConsoleError,
66
mockNavigatorOnLine,
77
} from '../../react/tests/utils'
8-
import { queryCache as defaultQueryCache, QueryCache } from '..'
8+
import { QueryCache, queryCache as defaultQueryCache } from '../..'
99
import { isCancelledError, isError } from '../utils'
1010

1111
describe('queryCache', () => {
@@ -379,14 +379,15 @@ describe('queryCache', () => {
379379
expect(query.queryCache).toBe(queryCache)
380380
})
381381

382-
test('notifyGlobalListeners passes the same instance', () => {
382+
test('notifyGlobalListeners passes the same instance', async () => {
383383
const key = queryKey()
384384

385385
const queryCache = new QueryCache()
386386
const subscriber = jest.fn()
387387
const unsubscribe = queryCache.subscribe(subscriber)
388388
const query = queryCache.buildQuery(key)
389389
query.setData('foo')
390+
await sleep(1)
390391
expect(subscriber).toHaveBeenCalledWith(queryCache, query)
391392

392393
unsubscribe()

src/core/tests/utils.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { replaceEqualDeep, deepIncludes, isPlainObject } from '../utils'
2-
import { setConsole, queryCache } from '..'
2+
import { setConsole, queryCache } from '../..'
33
import { queryKey } from '../../react/tests/utils'
44

55
describe('core/utils', () => {

src/core/utils.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,3 +250,34 @@ export function createSetHandler(fn: () => void) {
250250
removePreviousHandler = callback(fn)
251251
}
252252
}
253+
254+
/**
255+
* Schedules a microtask.
256+
* This can be useful to schedule state updates after rendering.
257+
*/
258+
export function scheduleMicrotask(callback: () => void): void {
259+
Promise.resolve()
260+
.then(callback)
261+
.catch(error =>
262+
setTimeout(() => {
263+
throw error
264+
})
265+
)
266+
}
267+
268+
type BatchUpdateFunction = (callback: () => void) => void
269+
270+
// Default to a dummy "batch" implementation that just runs the callback
271+
let batchedUpdates: BatchUpdateFunction = (callback: () => void) => {
272+
callback()
273+
}
274+
275+
// Allow injecting another batching function later
276+
export function setBatchedUpdates(fn: BatchUpdateFunction) {
277+
batchedUpdates = fn
278+
}
279+
280+
// Supply a getter just to skip dealing with ESM bindings
281+
export function getBatchedUpdates(): BatchUpdateFunction {
282+
return batchedUpdates
283+
}

0 commit comments

Comments
 (0)