Skip to content

Commit 834955e

Browse files
committed
feat(hydration): add de/rehydration
Add functionality for dehydrating and hydrating a queryCache - Add dehydrate(queryCache) - Add dehydrateQuery(query) - Add hydrate(queryCache, dehydratedQueries) - Add initialQueries-prop to CacheProvider
1 parent 3bbcb9a commit 834955e

File tree

6 files changed

+471
-24
lines changed

6 files changed

+471
-24
lines changed

src/core/hydration.js

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { statusSuccess, isServer } from './utils'
2+
import { defaultConfigRef } from './config'
3+
4+
export function dehydrateQuery(query) {
5+
const dehydratedQuery = {}
6+
7+
// Most config is not dehydrated but instead meant to configure again when
8+
// consuming the de/rehydrated data, typically with useQuery on the client.
9+
// Sometimes it might make sense to prefetch data on the server and include
10+
// in the html-payload, but not consume it on the initial render.
11+
// We still schedule stale and garbage collection right away, which means
12+
// we need to specifically include staleTime and cacheTime in dehydration.
13+
if (query.config.staleTime !== defaultConfigRef.current.queries.staleTime) {
14+
dehydratedQuery.staleTime = query.config.staleTime
15+
}
16+
if (query.config.cacheTime !== defaultConfigRef.current.queries.cacheTime) {
17+
dehydratedQuery.cacheTime = query.config.cacheTime
18+
}
19+
if (query.state.data !== undefined) {
20+
dehydratedQuery.initialData = query.state.data
21+
}
22+
23+
return dehydratedQuery
24+
}
25+
26+
export function dehydrate(queryCache) {
27+
const dehydratedQueries = {}
28+
for (const [queryHash, query] of Object.entries(queryCache.queries)) {
29+
if (query.state.status === statusSuccess) {
30+
dehydratedQueries[queryHash] = dehydrateQuery(query)
31+
}
32+
}
33+
34+
return dehydratedQueries
35+
}
36+
37+
export function hydrate(
38+
queryCache,
39+
dehydratedQueries,
40+
{ queryKeyParserFn = JSON.parse } = {}
41+
) {
42+
const queriesToInit = []
43+
44+
for (const [queryHash, query] of Object.entries(dehydratedQueries)) {
45+
const queryKey = queryKeyParserFn(queryHash)
46+
const queryConfig = query || {}
47+
48+
queryCache.createQuery(queryKey, queryConfig)
49+
50+
// We avoid keeping a reference to the query itself here since
51+
// that would mean the query could not be garbage collected as
52+
// long as someone kept a reference to the initQueries-function
53+
queriesToInit.push(queryHash)
54+
}
55+
56+
return function initializeQueries() {
57+
while (queriesToInit.length > 0) {
58+
const queryHash = queriesToInit.shift()
59+
const query = queryCache.queries[queryHash]
60+
61+
if (!isServer && query && query.initializeQuery) {
62+
query.initializeQuery()
63+
}
64+
}
65+
}
66+
}

src/core/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ export {
99
setConsole,
1010
deepIncludes,
1111
} from './utils'
12+
export { dehydrateQuery, dehydrate, hydrate } from './hydration'

src/core/queryCache.js

Lines changed: 40 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export function makeQueryCache({ frozen = isServer, defaultConfig } = {}) {
1717
const globalListeners = []
1818

1919
const configRef = defaultConfig
20-
? { current: { ...defaultConfigRef.current, ...defaultConfig }}
20+
? { current: { ...defaultConfigRef.current, ...defaultConfig } }
2121
: defaultConfigRef
2222

2323
const queryCache = {
@@ -117,7 +117,7 @@ export function makeQueryCache({ frozen = isServer, defaultConfig } = {}) {
117117
})
118118
}
119119

120-
queryCache.buildQuery = (userQueryKey, config = {}) => {
120+
queryCache.createQuery = (userQueryKey, config) => {
121121
config = {
122122
...configRef.current.shared,
123123
...configRef.current.queries,
@@ -156,31 +156,39 @@ export function makeQueryCache({ frozen = isServer, defaultConfig } = {}) {
156156
}
157157
}
158158

159-
// If the query started with data, schedule
160-
// a stale timeout
161-
if (!isServer && query.state.data) {
162-
query.scheduleStaleTimeout()
163-
164-
// Simulate a query healing process
165-
query.heal()
166-
// Schedule for garbage collection in case
167-
// nothing subscribes to this query
168-
query.scheduleGarbageCollection()
169-
}
170-
171159
if (!frozen) {
172160
queryCache.queries[queryHash] = query
161+
}
173162

174-
if (isServer) {
175-
notifyGlobalListeners()
176-
} else {
177-
// Here, we setTimeout so as to not trigger
178-
// any setState's in parent components in the
179-
// middle of the render phase.
180-
setTimeout(() => {
163+
query.initializeQuery = () => {
164+
if (!frozen) {
165+
if (isServer) {
166+
// On the server, queries should never be created
167+
// in the render phase, so we call notify synchronously
181168
notifyGlobalListeners()
182-
})
169+
} else {
170+
if (query.state.data) {
171+
// If the query started with data, schedule
172+
// a stale timeout
173+
query.scheduleStaleTimeout()
174+
175+
// Simulate a query healing process
176+
query.heal()
177+
178+
// Schedule for garbage collection in case
179+
// nothing subscribes to this query
180+
query.scheduleGarbageCollection()
181+
}
182+
// Here, we setTimeout so as to not trigger
183+
// any setState's in parent components in the
184+
// middle of the render phase.
185+
setTimeout(() => {
186+
notifyGlobalListeners()
187+
})
188+
}
183189
}
190+
191+
delete query.initializeQuery
184192
}
185193
}
186194

@@ -195,6 +203,16 @@ export function makeQueryCache({ frozen = isServer, defaultConfig } = {}) {
195203
return query
196204
}
197205

206+
queryCache.buildQuery = (userQueryKey, config = {}) => {
207+
const query = queryCache.createQuery(userQueryKey, config)
208+
209+
if (query.initializeQuery) {
210+
query.initializeQuery()
211+
}
212+
213+
return query
214+
}
215+
198216
queryCache.prefetchQuery = async (...args) => {
199217
if (
200218
isObject(args[1]) &&

src/core/tests/hydration.test.js

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import { sleep } from './utils'
2+
import { makeQueryCache, dehydrate, hydrate } from '../'
3+
4+
describe('dehydration and rehydration', () => {
5+
test('should work with serializeable values', async () => {
6+
// "Server part"
7+
const queryCache = makeQueryCache()
8+
const fetchData = value => Promise.resolve(value)
9+
await queryCache.prefetchQuery('string', () => fetchData('string'))
10+
await queryCache.prefetchQuery('number', () => fetchData(1))
11+
await queryCache.prefetchQuery('boolean', () => fetchData(true))
12+
await queryCache.prefetchQuery('null', () => fetchData(null))
13+
await queryCache.prefetchQuery('array', () => fetchData(['string', 0]))
14+
await queryCache.prefetchQuery('nested', () =>
15+
fetchData({ key: [{ nestedKey: 1 }] })
16+
)
17+
const dehydrated = dehydrate(queryCache)
18+
const stringified = JSON.stringify(dehydrated)
19+
20+
// "Client part"
21+
const parsed = JSON.parse(stringified)
22+
const clientQueryCache = makeQueryCache()
23+
const initQueries = hydrate(clientQueryCache, parsed)
24+
initQueries()
25+
expect(clientQueryCache.getQuery('string').state.data).toBe('string')
26+
expect(clientQueryCache.getQuery('number').state.data).toBe(1)
27+
expect(clientQueryCache.getQuery('boolean').state.data).toBe(true)
28+
expect(clientQueryCache.getQuery('null').state.data).toBe(null)
29+
expect(clientQueryCache.getQuery('array').state.data).toEqual(['string', 0])
30+
expect(clientQueryCache.getQuery('nested').state.data).toEqual({
31+
key: [{ nestedKey: 1 }],
32+
})
33+
34+
const fetchDataClientSide = jest.fn()
35+
await clientQueryCache.prefetchQuery('string', fetchDataClientSide)
36+
await clientQueryCache.prefetchQuery('number', fetchDataClientSide)
37+
await clientQueryCache.prefetchQuery('boolean', fetchDataClientSide)
38+
await clientQueryCache.prefetchQuery('null', fetchDataClientSide)
39+
await clientQueryCache.prefetchQuery('array', fetchDataClientSide)
40+
await clientQueryCache.prefetchQuery('nested', fetchDataClientSide)
41+
expect(fetchDataClientSide).toHaveBeenCalledTimes(0)
42+
43+
queryCache.clear({ notify: false })
44+
clientQueryCache.clear({ notify: false })
45+
})
46+
47+
test('should default to scheduling staleness immediately', async () => {
48+
// "Server part"
49+
const queryCache = makeQueryCache()
50+
const fetchData = value => Promise.resolve(value)
51+
await queryCache.prefetchQuery('string', () => fetchData('string'))
52+
const dehydrated = dehydrate(queryCache)
53+
const stringified = JSON.stringify(dehydrated)
54+
55+
// "Client part"
56+
const parsed = JSON.parse(stringified)
57+
const clientQueryCache = makeQueryCache()
58+
const initQueries = hydrate(clientQueryCache, parsed)
59+
initQueries()
60+
expect(clientQueryCache.getQuery('string').state.data).toBe('string')
61+
expect(clientQueryCache.getQuery('string').state.isStale).toBe(false)
62+
await sleep(10)
63+
expect(clientQueryCache.getQuery('string').state.isStale).toBe(true)
64+
65+
queryCache.clear({ notify: false })
66+
clientQueryCache.clear({ notify: false })
67+
})
68+
69+
test('should respect staleTime', async () => {
70+
// "Server part"
71+
const queryCache = makeQueryCache()
72+
const fetchData = value => Promise.resolve(value)
73+
await queryCache.prefetchQuery('string', () => fetchData('string'), {
74+
staleTime: 50,
75+
})
76+
const dehydrated = dehydrate(queryCache)
77+
const stringified = JSON.stringify(dehydrated)
78+
79+
// "Client part"
80+
const parsed = JSON.parse(stringified)
81+
const clientQueryCache = makeQueryCache()
82+
const initQueries = hydrate(clientQueryCache, parsed)
83+
initQueries()
84+
expect(clientQueryCache.getQuery('string').state.data).toBe('string')
85+
expect(clientQueryCache.getQuery('string').state.isStale).toBe(false)
86+
await sleep(10)
87+
expect(clientQueryCache.getQuery('string').state.isStale).toBe(false)
88+
await sleep(50)
89+
expect(clientQueryCache.getQuery('string').state.isStale).toBe(true)
90+
91+
queryCache.clear({ notify: false })
92+
clientQueryCache.clear({ notify: false })
93+
})
94+
95+
test('should schedule garbage collection', async () => {
96+
// "Server part"
97+
const queryCache = makeQueryCache()
98+
const fetchData = value => Promise.resolve(value)
99+
await queryCache.prefetchQuery('string', () => fetchData('string'), {
100+
cacheTime: 50,
101+
})
102+
const dehydrated = dehydrate(queryCache)
103+
const stringified = JSON.stringify(dehydrated)
104+
105+
// "Client part"
106+
const parsed = JSON.parse(stringified)
107+
const clientQueryCache = makeQueryCache()
108+
const initQueries = hydrate(clientQueryCache, parsed)
109+
initQueries()
110+
expect(clientQueryCache.getQuery('string').state.data).toBe('string')
111+
await sleep(10)
112+
expect(clientQueryCache.getQuery('string')).toBeTruthy()
113+
await sleep(50)
114+
expect(clientQueryCache.getQuery('string')).toBeFalsy()
115+
116+
queryCache.clear({ notify: false })
117+
clientQueryCache.clear({ notify: false })
118+
})
119+
120+
test('should work with complex keys', async () => {
121+
// "Server part"
122+
const queryCache = makeQueryCache()
123+
const fetchData = value => Promise.resolve(value)
124+
await queryCache.prefetchQuery(
125+
['string', { key: ['string'], key2: 0 }],
126+
() => fetchData('string')
127+
)
128+
const dehydrated = dehydrate(queryCache)
129+
const stringified = JSON.stringify(dehydrated)
130+
131+
// "Client part"
132+
const parsed = JSON.parse(stringified)
133+
const clientQueryCache = makeQueryCache()
134+
const initQueries = hydrate(clientQueryCache, parsed)
135+
initQueries()
136+
expect(
137+
clientQueryCache.getQuery(['string', { key: ['string'], key2: 0 }]).state
138+
.data
139+
).toBe('string')
140+
141+
const fetchDataClientSide = jest.fn()
142+
await clientQueryCache.prefetchQuery(
143+
['string', { key: ['string'], key2: 0 }],
144+
fetchDataClientSide
145+
)
146+
expect(fetchDataClientSide).toHaveBeenCalledTimes(0)
147+
148+
queryCache.clear({ notify: false })
149+
clientQueryCache.clear({ notify: false })
150+
})
151+
152+
test('should not include default config in dehydration', async () => {
153+
const queryCache = makeQueryCache()
154+
const fetchData = value => Promise.resolve(value)
155+
await queryCache.prefetchQuery('string', () => fetchData('string'))
156+
const dehydrated = dehydrate(queryCache)
157+
158+
// This is testing implementation details that can change and are not
159+
// part of the public API, but is important for keeping the payload small
160+
// Exact shape is not important here, just that staleTime and cacheTime
161+
// (and any future other config) is not included in it
162+
expect(dehydrated['["string"]'].staleTime).toBe(undefined)
163+
expect(dehydrated['["string"]'].cacheTime).toBe(undefined)
164+
})
165+
})

src/react/ReactQueryCacheProvider.js

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,35 @@
11
import React from 'react'
2-
import { queryCache, makeQueryCache } from '../core'
2+
import { queryCache, makeQueryCache, hydrate } from '../core'
33

44
export const queryCacheContext = React.createContext(queryCache)
55

66
export const queryCaches = [queryCache]
77

88
export const useQueryCache = () => React.useContext(queryCacheContext)
99

10-
export function ReactQueryCacheProvider({ queryCache, children }) {
10+
export function ReactQueryCacheProvider({
11+
queryCache,
12+
initialQueries,
13+
children,
14+
}) {
1115
const resolvedQueryCache = React.useMemo(
1216
() => queryCache || makeQueryCache(),
1317
[queryCache]
1418
)
1519

20+
// TODO: Add tests for initialQueries including initQueries
21+
const initializeQueries = React.useMemo(() => {
22+
if (initialQueries) {
23+
return hydrate(resolvedQueryCache, initialQueries)
24+
}
25+
}, [resolvedQueryCache, initialQueries])
26+
27+
React.useEffect(() => {
28+
if (initializeQueries) {
29+
initializeQueries()
30+
}
31+
}, [initializeQueries])
32+
1633
React.useEffect(() => {
1734
queryCaches.push(resolvedQueryCache)
1835

0 commit comments

Comments
 (0)