Skip to content

Commit

Permalink
feat: implement batch rendering
Browse files Browse the repository at this point in the history
  • Loading branch information
boschni committed Sep 12, 2020
1 parent 3d967bf commit c14d6ce
Show file tree
Hide file tree
Showing 25 changed files with 221 additions and 114 deletions.
11 changes: 7 additions & 4 deletions docs/src/pages/docs/comparison.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,26 +33,29 @@ Feature/Capability Key:
| Scroll Recovery ||||
| Cache Manipulation ||||
| Outdated Query Dismissal ||||
| Render Optimization<sup>2</sup> || 🛑 | 🛑 |
| Auto Garbage Collection || 🛑 | 🛑 |
| Mutation Hooks || 🟡 ||
| Prefetching APIs || 🔶 ||
| Query Cancellation || 🛑 | 🛑 |
| Partial Query Matching<sup>2</sup> || 🛑 | 🛑 |
| Partial Query Matching<sup>3</sup> || 🛑 | 🛑 |
| Stale While Revalidate ||| 🛑 |
| Stale Time Configuration || 🛑 | 🛑 |
| Window Focus Refetching ||| 🛑 |
| Network Status Refetching ||||
| Automatic Refetch after Mutation<sup>3</sup> | 🔶 | 🔶 ||
| Automatic Refetch after Mutation<sup>4</sup> | 🔶 | 🔶 ||
| Cache Dehydration/Rehydration || 🛑 ||
| React Suspense (Experimental) ||| 🛑 |

### Notes

> **<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.
> **<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.
> **<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`.
> **<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.
> **<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.
> **<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.
[swr]: https://github.com/vercel/swr
[apollo]: https://github.com/apollographql/apollo-client
Expand Down
3 changes: 2 additions & 1 deletion rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@ import commonJS from 'rollup-plugin-commonjs'
import visualizer from 'rollup-plugin-visualizer'
import replace from '@rollup/plugin-replace'

const external = ['react']
const external = ['react', 'react-dom']
const hydrationExternal = [...external, 'react-query']

const globals = {
react: 'React',
'react-dom': 'ReactDOM',
}
const hydrationGlobals = {
...globals,
Expand Down
8 changes: 7 additions & 1 deletion src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@ export { getDefaultReactQueryConfig } from './config'
export { queryCache, queryCaches, makeQueryCache } from './queryCache'
export { setFocusHandler } from './setFocusHandler'
export { setOnlineHandler } from './setOnlineHandler'
export { CancelledError, isCancelledError, isError, setConsole } from './utils'
export {
CancelledError,
isCancelledError,
isError,
setConsole,
setBatchedUpdates,
} from './utils'

// Types
export * from './types'
Expand Down
55 changes: 55 additions & 0 deletions src/core/notifyManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { getBatchedUpdates, scheduleMicrotask } from './utils'

// TYPES

type NotifyCallback = () => void

// CLASS

export class NotifyManager {
private queue: NotifyCallback[]
private transactions: number

constructor() {
this.queue = []
this.transactions = 0
}

batch(callback: () => void): void {
this.transactions++
callback()
this.transactions--
if (!this.transactions) {
this.flush()
}
}

schedule(notify: NotifyCallback): void {
if (this.transactions) {
this.queue.push(notify)
} else {
scheduleMicrotask(() => {
notify()
})
}
}

flush(): void {
const queue = this.queue
this.queue = []
if (queue.length) {
scheduleMicrotask(() => {
const batchedUpdates = getBatchedUpdates()
batchedUpdates(() => {
queue.forEach(notify => {
notify()
})
})
})
}
}
}

// SINGLETON

export const notifyManager = new NotifyManager()
11 changes: 7 additions & 4 deletions src/core/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
} from './types'
import type { QueryCache } from './queryCache'
import { QueryObserver, UpdateListener } from './queryObserver'
import { notifyManager } from './notifyManager'

// TYPES

Expand Down Expand Up @@ -132,11 +133,13 @@ export class Query<TResult, TError> {
private dispatch(action: Action<TResult, TError>): void {
this.state = queryReducer(this.state, action)

this.observers.forEach(observer => {
observer.onQueryUpdate(action)
})
notifyManager.batch(() => {
this.observers.forEach(observer => {
observer.onQueryUpdate(action)
})

this.queryCache.notifyGlobalListeners(this)
this.queryCache.notifyGlobalListeners(this)
})
}

private scheduleGc(): void {
Expand Down
34 changes: 21 additions & 13 deletions src/core/queryCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import {
deepIncludes,
getQueryArgs,
isDocumentVisible,
isPlainObject,
isOnline,
isPlainObject,
isServer,
} from './utils'
import { getResolvedQueryConfig } from './config'
Expand All @@ -18,6 +18,7 @@ import {
TypedQueryFunctionArgs,
ResolvedQueryConfig,
} from './types'
import { notifyManager } from './notifyManager'

// TYPES

Expand Down Expand Up @@ -89,8 +90,12 @@ export class QueryCache {
0
)

this.globalListeners.forEach(listener => {
listener(this, query)
notifyManager.batch(() => {
this.globalListeners.forEach(listener => {
notifyManager.schedule(() => {
listener(this, query)
})
})
})
}

Expand Down Expand Up @@ -194,17 +199,18 @@ export class QueryCache {
options || {}

try {
await Promise.all(
this.getQueries(predicate, options).map(query => {
const enabled = query.isEnabled()
const promises: Promise<unknown>[] = []

notifyManager.batch(() => {
this.getQueries(predicate, options).forEach(query => {
const enabled = query.isEnabled()
if ((enabled && refetchActive) || (!enabled && refetchInactive)) {
return query.fetch()
promises.push(query.fetch())
}

return undefined
})
)
})

await Promise.all(promises)
} catch (err) {
if (throwOnError) {
throw err
Expand Down Expand Up @@ -349,9 +355,11 @@ export function makeQueryCache(config?: QueryCacheConfig) {

export function onVisibilityOrOnlineChange(type: 'focus' | 'online') {
if (isDocumentVisible() && isOnline()) {
queryCaches.forEach(queryCache => {
queryCache.getQueries().forEach(query => {
query.onInteraction(type)
notifyManager.batch(() => {
queryCaches.forEach(queryCache => {
queryCache.getQueries().forEach(query => {
query.onInteraction(type)
})
})
})
}
Expand Down
5 changes: 4 additions & 1 deletion src/core/queryObserver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
isDocumentVisible,
isValidTimeout,
} from './utils'
import { notifyManager } from './notifyManager'
import type { QueryResult, ResolvedQueryConfig } from './types'
import type { Query, Action, FetchMoreOptions, RefetchOptions } from './query'

Expand Down Expand Up @@ -139,7 +140,9 @@ export class QueryObserver<TResult, TError> {
}

private notify(): void {
this.listener?.(this.currentResult)
notifyManager.schedule(() => {
this.listener?.(this.currentResult)
})
}

private updateStaleTimeout(): void {
Expand Down
5 changes: 3 additions & 2 deletions src/core/tests/queryCache.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
mockConsoleError,
mockNavigatorOnLine,
} from '../../react/tests/utils'
import { makeQueryCache, queryCache as defaultQueryCache } from '..'
import { makeQueryCache, queryCache as defaultQueryCache } from '../..'
import { isCancelledError, isError } from '../utils'

describe('queryCache', () => {
Expand Down Expand Up @@ -348,14 +348,15 @@ describe('queryCache', () => {
expect(query.queryCache).toBe(queryCache)
})

test('notifyGlobalListeners passes the same instance', () => {
test('notifyGlobalListeners passes the same instance', async () => {
const key = queryKey()

const queryCache = makeQueryCache()
const subscriber = jest.fn()
const unsubscribe = queryCache.subscribe(subscriber)
const query = queryCache.buildQuery(key)
query.setData('foo')
await sleep(1)
expect(subscriber).toHaveBeenCalledWith(queryCache, query)

unsubscribe()
Expand Down
2 changes: 1 addition & 1 deletion src/core/tests/utils.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { replaceEqualDeep, deepIncludes, isPlainObject } from '../utils'
import { setConsole, queryCache } from '..'
import { setConsole, queryCache } from '../..'
import { queryKey } from '../../react/tests/utils'

describe('core/utils', () => {
Expand Down
31 changes: 31 additions & 0 deletions src/core/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,3 +250,34 @@ export function createSetHandler(fn: () => void) {
removePreviousHandler = callback(fn)
}
}

/**
* Schedules a microtask.
* This can be useful to schedule state updates after rendering.
*/
export function scheduleMicrotask(callback: () => void): void {
Promise.resolve()
.then(callback)
.catch(error =>
setTimeout(() => {
throw error
})
)
}

type BatchUpdateFunction = (callback: () => void) => void

// Default to a dummy "batch" implementation that just runs the callback
let batchedUpdates: BatchUpdateFunction = (callback: () => void) => {
callback()
}

// Allow injecting another batching function later
export function setBatchedUpdates(fn: BatchUpdateFunction) {
batchedUpdates = fn
}

// Supply a getter just to skip dealing with ESM bindings
export function getBatchedUpdates(): BatchUpdateFunction {
return batchedUpdates
}
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,6 @@
import { setBatchedUpdates } from './core/index'
import { unstable_batchedUpdates } from './react/reactBatchedUpdates'
setBatchedUpdates(unstable_batchedUpdates)

export * from './core/index'
export * from './react/index'
3 changes: 3 additions & 0 deletions src/react/reactBatchedUpdates.native.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// @ts-ignore
import { unstable_batchedUpdates } from 'react-native'
export { unstable_batchedUpdates }
2 changes: 2 additions & 0 deletions src/react/reactBatchedUpdates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import ReactDOM from 'react-dom'
export const unstable_batchedUpdates = ReactDOM.unstable_batchedUpdates
10 changes: 8 additions & 2 deletions src/react/tests/ReactQueryCacheProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,14 @@ import React, { useEffect } from 'react'
import { render, waitFor } from '@testing-library/react'

import { sleep, queryKey } from './utils'
import { ReactQueryCacheProvider, useQuery, useQueryCache } from '..'
import { makeQueryCache, queryCache, QueryCache } from '../../core'
import {
ReactQueryCacheProvider,
useQuery,
useQueryCache,
makeQueryCache,
queryCache,
QueryCache,
} from '../..'

describe('ReactQueryCacheProvider', () => {
test('when not used, falls back to global cache', async () => {
Expand Down
3 changes: 1 addition & 2 deletions src/react/tests/ReactQueryConfigProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@ import React, { useState } from 'react'
import { act, fireEvent, render, waitFor } from '@testing-library/react'

import { sleep, queryKey } from './utils'
import { ReactQueryConfigProvider, useQuery } from '..'
import { queryCache } from '../../core'
import { ReactQueryConfigProvider, useQuery, queryCache } from '../..'

describe('ReactQueryConfigProvider', () => {
// // See https://github.com/tannerlinsley/react-query/issues/105
Expand Down
9 changes: 7 additions & 2 deletions src/react/tests/ssr.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,13 @@ import React from 'react'
import { renderToString } from 'react-dom/server'

import { sleep, queryKey } from './utils'
import { usePaginatedQuery, ReactQueryCacheProvider, useQuery } from '..'
import { queryCache, makeQueryCache } from '../../core'
import {
usePaginatedQuery,
ReactQueryCacheProvider,
useQuery,
queryCache,
makeQueryCache,
} from '../..'

describe('Server Side Rendering', () => {
// A frozen cache does not cache any data. This is the default
Expand Down
6 changes: 3 additions & 3 deletions src/react/tests/suspense.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@ import { ErrorBoundary } from 'react-error-boundary'
import * as React from 'react'

import { sleep, queryKey, mockConsoleError } from './utils'
import { useQuery } from '..'
import { queryCache } from '../../core'
import {
useQuery,
queryCache,
ReactQueryErrorResetBoundary,
useErrorResetBoundary,
} from '../ReactQueryErrorResetBoundary'
} from '../..'

describe("useQuery's in Suspense mode", () => {
it('should not call the queryFn twice when used in Suspense mode', async () => {
Expand Down
3 changes: 1 addition & 2 deletions src/react/tests/useInfiniteQuery.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@ import { render, waitFor, fireEvent } from '@testing-library/react'
import * as React from 'react'

import { sleep, queryKey, waitForMs } from './utils'
import { useInfiniteQuery, useQueryCache } from '..'
import { InfiniteQueryResult } from '../../core'
import { useInfiniteQuery, useQueryCache, InfiniteQueryResult } from '../..'

interface Result {
items: number[]
Expand Down
2 changes: 1 addition & 1 deletion src/react/tests/useIsFetching.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { render, fireEvent, waitFor } from '@testing-library/react'
import * as React from 'react'

import { sleep, queryKey, mockConsoleError } from './utils'
import { useQuery, useIsFetching } from '..'
import { useQuery, useIsFetching } from '../..'

describe('useIsFetching', () => {
// See https://github.com/tannerlinsley/react-query/issues/105
Expand Down

0 comments on commit c14d6ce

Please sign in to comment.