Skip to content

Commit

Permalink
feat: query cache provider (#476)
Browse files Browse the repository at this point in the history
* feat: query cache provider

the query cache now comes from a context, meaning users can easily override and control the cache being used inside their application

* fix: track multiple query caches

this commit does the follwing:
track all active query caches added via the provider, handles mounting/unmounting
if creating a cache, ensure we only do it once
when unmounting an internally-created cache, make sure to clear it first
setFocusHandler now loops through all of the currently-active caches

* test: add in-depth tests around query cache providers

* style: format touched files

* fixup! style: format touched files

* docs: correct readme description

Co-authored-by: Tanner Linsley <tannerlinsley@gmail.com>

Co-authored-by: Jack Ellis <jack.ellis@godaddy.com>
Co-authored-by: Tanner Linsley <tannerlinsley@gmail.com>
  • Loading branch information
3 people committed May 15, 2020
1 parent 588d54c commit 51375fd
Show file tree
Hide file tree
Showing 17 changed files with 568 additions and 99 deletions.
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,8 +257,10 @@ This library is being built and maintained by me, @tannerlinsley and I am always
- [`queryCache.isFetching`](#querycacheisfetching)
- [`queryCache.subscribe`](#querycachesubscribe)
- [`queryCache.clear`](#querycacheclear)
- [`useQueryCache`](#usequerycache)
- [`useIsFetching`](#useisfetching)
- [`ReactQueryConfigProvider`](#reactqueryconfigprovider)
- [`ReactQueryCacheProvider`](#reactquerycacheprovider)
- [`setConsole`](#setconsole)
- [Contributors ✨](#contributors-)

Expand Down Expand Up @@ -2524,6 +2526,18 @@ queryCache.clear()
- `queries: Array<Query>`
- This will be an array containing the queries that were found.
## `useQueryCache`
The `useQueryCache` hook returns the current queryCache instance.
```js
import { useQueryCache } from 'react-query';

const queryCache = useQueryCache()
```
If you are using the `ReactQueryCacheProvider` to set a custom cache, you cannot simply import `{ queryCache }` any more. This hook will ensure you're getting the correct instance.
## `useIsFetching`
`useIsFetching` is an optional hook that returns the `number` of the queries that your application is loading or fetching in the background (useful for app-wide loading indicators).
Expand Down Expand Up @@ -2584,6 +2598,29 @@ function App() {
- Must be **stable** or **memoized**. Do not create an inline object!
- For non-global properties please see their usage in both the [`useQuery` hook](#usequery) and the [`useMutation` hook](#usemutation).
## `ReactQueryCacheProvider`
`ReactQueryCacheProvider` is an optional provider component for explicitly setting the query cache used by React Query. This is useful for creating component-level caches that are not completely global, as well as making truly isolated unit tests.
```js
import { ReactQueryCacheProvider, makeQueryCache } from 'react-query';

const queryCache = makeQueryCache()

function App() {
return (
<ReactQueryCacheProvider queryCache={queryCache}>
...
</ReactQueryCacheProvider>
)
}
```
### Options
- `queryCache: Object`
- In instance of queryCache, you can use the `makeQueryCache` factory to create this.
- If not provided, a new cache will be generated.
## `setConsole`
`setConsole` is an optional utility function that allows you to replace the `console` interface used to log errors. By default, the `window.console` object is used. If no global `console` object is found in the environment, nothing will be logged.
Expand Down
7 changes: 6 additions & 1 deletion src/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
export { queryCache } from './queryCache'
export {
queryCache,
makeQueryCache,
ReactQueryCacheProvider,
useQueryCache,
} from './queryCache'
export { ReactQueryConfigProvider } from './config'
export { setFocusHandler } from './setFocusHandler'
export { useIsFetching } from './useIsFetching'
Expand Down
7 changes: 6 additions & 1 deletion src/index.production.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
export { queryCache } from './queryCache'
export {
queryCache,
makeQueryCache,
ReactQueryCacheProvider,
useQueryCache,
} from './queryCache'
export { ReactQueryConfigProvider } from './config'
export { setFocusHandler } from './setFocusHandler'
export { useIsFetching } from './useIsFetching'
Expand Down
41 changes: 40 additions & 1 deletion src/queryCache.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import React from 'react'
import {
isServer,
functionalUpdate,
Expand All @@ -14,6 +15,42 @@ import { defaultConfigRef } from './config'

export const queryCache = makeQueryCache()

export const queryCacheContext = React.createContext(queryCache)

export const queryCaches = [queryCache]

export function useQueryCache() {
return React.useContext(queryCacheContext)
}

export function ReactQueryCacheProvider({ queryCache, children }) {
const cache = React.useMemo(() => queryCache || makeQueryCache(), [
queryCache,
])

React.useEffect(() => {
queryCaches.push(cache)

return () => {
// remove the cache from the active list
const i = queryCaches.indexOf(cache)
if (i >= 0) {
queryCaches.splice(i, 1)
}
// if the cache was created by us, we need to tear it down
if (queryCache == null) {
cache.clear()
}
}
}, [cache, queryCache])

return (
<queryCacheContext.Provider value={cache}>
{children}
</queryCacheContext.Provider>
)
}

const actionInit = {}
const actionFailed = {}
const actionMarkStale = {}
Expand All @@ -32,7 +69,7 @@ export function makeQueryCache() {
}

const notifyGlobalListeners = () => {
cache.isFetching = Object.values(queryCache.queries).reduce(
cache.isFetching = Object.values(cache.queries).reduce(
(acc, query) => (query.state.isFetching ? acc + 1 : acc),
0
)
Expand Down Expand Up @@ -129,6 +166,7 @@ export function makeQueryCache() {
query.config = { ...query.config, ...config }
} else {
query = makeQuery({
cache,
queryKey,
queryHash,
queryVariables,
Expand Down Expand Up @@ -212,6 +250,7 @@ export function makeQueryCache() {
}

function makeQuery(options) {
const queryCache = options.cache
const reducer = options.config.queryReducer || defaultQueryReducer

const noQueryHash = typeof options.queryHash === 'undefined'
Expand Down
50 changes: 26 additions & 24 deletions src/setFocusHandler.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { isOnline, isDocumentVisible, Console, isServer } from './utils'
import { defaultConfigRef } from './config'
import { queryCache } from './queryCache'
import { queryCaches } from './queryCache'

const visibilityChangeEvent = 'visibilitychange'
const focusEvent = 'focus'
Expand All @@ -9,29 +9,31 @@ const onWindowFocus = () => {
const { refetchAllOnWindowFocus } = defaultConfigRef.current

if (isDocumentVisible() && isOnline()) {
queryCache
.refetchQueries(query => {
if (!query.instances.length) {
return false
}

if (query.config.manual === true) {
return false
}

if (query.shouldContinueRetryOnFocus) {
// delete promise, so `fetch` will create new one
delete query.promise
return true
}

if (typeof query.config.refetchOnWindowFocus === 'undefined') {
return refetchAllOnWindowFocus
} else {
return query.config.refetchOnWindowFocus
}
})
.catch(Console.error)
queryCaches.forEach(queryCache =>
queryCache
.refetchQueries(query => {
if (!query.instances.length) {
return false
}

if (query.config.manual === true) {
return false
}

if (query.shouldContinueRetryOnFocus) {
// delete promise, so `fetch` will create new one
delete query.promise
return true
}

if (typeof query.config.refetchOnWindowFocus === 'undefined') {
return refetchAllOnWindowFocus
} else {
return query.config.refetchOnWindowFocus
}
})
.catch(Console.error)
)
}
}

Expand Down
177 changes: 177 additions & 0 deletions src/tests/ReactQueryCacheProvider.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import React, { useEffect } from 'react'
import { render, cleanup, waitForElement } from '@testing-library/react'
import {
ReactQueryCacheProvider,
makeQueryCache,
queryCache,
useQuery,
useQueryCache,
} from '../index'
import { sleep } from './utils'

describe('ReactQueryCacheProvider', () => {
afterEach(() => {
cleanup()
queryCache.clear()
})

test('when not used, falls back to global cache', async () => {
function Page() {
const { data } = useQuery('test', async () => {
await sleep(10)
return 'test'
})

return (
<div>
<h1>{data}</h1>
</div>
)
}

const rendered = render(<Page />)

await waitForElement(() => rendered.getByText('test'))

expect(queryCache.getQuery('test')).toBeDefined()
})
test('sets a specific cache for all queries to use', async () => {
const cache = makeQueryCache()

function Page() {
const { data } = useQuery('test', async () => {
await sleep(10)
return 'test'
})

return (
<div>
<h1>{data}</h1>
</div>
)
}

const rendered = render(
<ReactQueryCacheProvider queryCache={cache}>
<Page />
</ReactQueryCacheProvider>
)

await waitForElement(() => rendered.getByText('test'))

expect(queryCache.getQuery('test')).not.toBeDefined()
expect(cache.getQuery('test')).toBeDefined()
})
test('implicitly creates a new cache for all queries to use', async () => {
function Page() {
const { data } = useQuery('test', async () => {
await sleep(10)
return 'test'
})

return (
<div>
<h1>{data}</h1>
</div>
)
}

const rendered = render(
<ReactQueryCacheProvider>
<Page />
</ReactQueryCacheProvider>
)

await waitForElement(() => rendered.getByText('test'))

expect(queryCache.getQuery('test')).not.toBeDefined()
})
test('allows multiple caches to be partitioned', async () => {
const cache1 = makeQueryCache()
const cache2 = makeQueryCache()

function Page1() {
const { data } = useQuery('test1', async () => {
await sleep(10)
return 'test1'
})

return (
<div>
<h1>{data}</h1>
</div>
)
}
function Page2() {
const { data } = useQuery('test2', async () => {
await sleep(10)
return 'test2'
})

return (
<div>
<h1>{data}</h1>
</div>
)
}

const rendered = render(
<>
<ReactQueryCacheProvider queryCache={cache1}>
<Page1 />
</ReactQueryCacheProvider>
<ReactQueryCacheProvider queryCache={cache2}>
<Page2 />
</ReactQueryCacheProvider>
</>
)

await waitForElement(() => rendered.getByText('test1'))
await waitForElement(() => rendered.getByText('test2'))

expect(cache1.getQuery('test1')).toBeDefined()
expect(cache1.getQuery('test2')).not.toBeDefined()
expect(cache2.getQuery('test1')).not.toBeDefined()
expect(cache2.getQuery('test2')).toBeDefined()
})
test('when cache changes, previous cache is cleaned', () => {
let caches = []
const customCache = makeQueryCache()

function Page() {
const queryCache = useQueryCache()
useEffect(() => {
caches.push(queryCache)
}, [queryCache])

const { data } = useQuery('test', async () => {
await sleep(10)
return 'test'
})

return (
<div>
<h1>{data}</h1>
</div>
)
}

function App({ cache }) {
return (
<ReactQueryCacheProvider queryCache={cache}>
<Page />
</ReactQueryCacheProvider>
)
}

const rendered = render(<App />)

expect(caches).toHaveLength(1)
jest.spyOn(caches[0], 'clear')

rendered.rerender(<App cache={customCache} />)

expect(caches).toHaveLength(2)
expect(caches[0].clear).toHaveBeenCalled()
})
})

0 comments on commit 51375fd

Please sign in to comment.