Skip to content

Commit bb1443c

Browse files
authored
Feature/cachetime zero (#3054)
* refactor: cacheTime-zero remove special handling for cacheTime: 0 and schedule a normal garbage collection for those queries. They will be eligible for gc after a setTimeout(0), but then they will only be optionally removed. This makes sure that paused queries are NOT gc'ed * refactor: cacheTime-zero remove special test "about online queries with cacheTime:0 should not fetch if paused and then unmounted". paused queries will now be kept until they continue, just like with every other query, unless query cancellation or abort signal was involved * refactor: cacheTime-zero adapt "remounting" test: if the same query with cacheTime 0 unmounts and remounts in the same cycle, the query will now be picked up and will not go to loading state again. I think this is okay * refactor: cacheTime-zero re-add instant query removal after fetching, because fetching via `queryClient.fetchQuery` will not remove the query otherwise, because the normal gc-mechanism now checks for `hadObservers` due to a suspense issue :/ * refactor: cacheTime-zero weird edge case: the previous logic was instantly removing the query _while_ it was still fetching, which is something we likely don't want. The data will stay in the currentQuery of the observer if the observer unsubscribes but still exists, and a new subscription will pick it up, unless the query was explicitly cancelled or the abort signal was consumed. * refactor: cacheTime-zero we need to wait a tick because even cacheTime 0 now waits at least a setTimeout(0) to be eligible for gc * refactor: cacheTime-zero schedule a new garbage collection after each new fetch; this won't do anything when you still have observers, but it fixes an edge case where prefetching took longer than the cacheTime, in which case the query was again never removed test needed adaption because we don't instantly remove, but deferred by a tick * refactor: cacheTime-zero stabilize test * refactor: cacheTime-zero apply a different suspense "workaround": do not garbage collect when fetching optimistically (done only by suspense) - gc will kick in once an observer subscribes; this will make sure we can still gc other fetches that don't have an observer consistently, like prefetching when the fetch takes longer than the gc time (which was leaking with the old workaround) * refactor: cacheTime-zero remove leftover * refactor: cacheTime-zero since every fetch triggers a new gc cycle, we don't need to do this in a loop anymore also, reset isFetchingOptimistic after every fetch
1 parent bd0f87c commit bb1443c

File tree

7 files changed

+74
-142
lines changed

7 files changed

+74
-142
lines changed

src/core/mutation.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -131,11 +131,7 @@ export class Mutation<
131131
removeObserver(observer: MutationObserver<any, any, any, any>): void {
132132
this.observers = this.observers.filter(x => x !== observer)
133133

134-
if (this.cacheTime) {
135-
this.scheduleGc()
136-
} else {
137-
this.optionalRemove()
138-
}
134+
this.scheduleGc()
139135

140136
this.mutationCache.notify({
141137
type: 'observerRemoved',

src/core/query.ts

Lines changed: 12 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -154,20 +154,19 @@ export class Query<
154154
revertState?: QueryState<TData, TError>
155155
state: QueryState<TData, TError>
156156
meta: QueryMeta | undefined
157+
isFetchingOptimistic?: boolean
157158

158159
private cache: QueryCache
159160
private promise?: Promise<TData>
160161
private retryer?: Retryer<TData, TError>
161162
private observers: QueryObserver<any, any, any, any, any>[]
162163
private defaultOptions?: QueryOptions<TQueryFnData, TError, TData, TQueryKey>
163164
private abortSignalConsumed: boolean
164-
private hadObservers: boolean
165165

166166
constructor(config: QueryConfig<TQueryFnData, TError, TData, TQueryKey>) {
167167
super()
168168

169169
this.abortSignalConsumed = false
170-
this.hadObservers = false
171170
this.defaultOptions = config.defaultOptions
172171
this.setOptions(config.options)
173172
this.observers = []
@@ -177,7 +176,6 @@ export class Query<
177176
this.initialState = config.state || this.getDefaultState(this.options)
178177
this.state = this.initialState
179178
this.meta = config.meta
180-
this.scheduleGc()
181179
}
182180

183181
private setOptions(
@@ -197,12 +195,8 @@ export class Query<
197195
}
198196

199197
protected optionalRemove() {
200-
if (!this.observers.length) {
201-
if (this.state.fetchStatus === 'idle') {
202-
this.cache.remove(this)
203-
} else if (this.hadObservers) {
204-
this.scheduleGc()
205-
}
198+
if (!this.observers.length && this.state.fetchStatus === 'idle') {
199+
this.cache.remove(this)
206200
}
207201
}
208202

@@ -307,7 +301,6 @@ export class Query<
307301
addObserver(observer: QueryObserver<any, any, any, any, any>): void {
308302
if (this.observers.indexOf(observer) === -1) {
309303
this.observers.push(observer)
310-
this.hadObservers = true
311304

312305
// Stop the query from being garbage collected
313306
this.clearGcTimeout()
@@ -331,11 +324,7 @@ export class Query<
331324
}
332325
}
333326

334-
if (this.cacheTime) {
335-
this.scheduleGc()
336-
} else {
337-
this.cache.remove(this)
338-
}
327+
this.scheduleGc()
339328
}
340329

341330
this.cache.notify({ type: 'observerRemoved', query: this, observer })
@@ -455,10 +444,11 @@ export class Query<
455444
// Notify cache callback
456445
this.cache.config.onSuccess?.(data, this as Query<any, any, any, any>)
457446

458-
// Remove query after fetching if cache time is 0
459-
if (this.cacheTime === 0) {
460-
this.optionalRemove()
447+
if (!this.isFetchingOptimistic) {
448+
// Schedule query gc after fetching
449+
this.scheduleGc()
461450
}
451+
this.isFetchingOptimistic = false
462452
},
463453
onError: (error: TError | { silent?: boolean }) => {
464454
// Optimistically update state if needed
@@ -477,10 +467,11 @@ export class Query<
477467
getLogger().error(error)
478468
}
479469

480-
// Remove query after fetching if cache time is 0
481-
if (this.cacheTime === 0) {
482-
this.optionalRemove()
470+
if (!this.isFetchingOptimistic) {
471+
// Schedule query gc after fetching
472+
this.scheduleGc()
483473
}
474+
this.isFetchingOptimistic = false
484475
},
485476
onFail: () => {
486477
this.dispatch({ type: 'failed' })

src/core/queryObserver.ts

Lines changed: 3 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -309,15 +309,8 @@ export class QueryObserver<
309309

310310
const query = this.client
311311
.getQueryCache()
312-
.build(
313-
this.client,
314-
defaultedOptions as QueryOptions<
315-
TQueryFnData,
316-
TError,
317-
TQueryData,
318-
TQueryKey
319-
>
320-
)
312+
.build(this.client, defaultedOptions)
313+
query.isFetchingOptimistic = true
321314

322315
return query.fetch().then(() => this.createResult(query, defaultedOptions))
323316
}
@@ -655,17 +648,7 @@ export class QueryObserver<
655648
}
656649

657650
private updateQuery(): void {
658-
const query = this.client
659-
.getQueryCache()
660-
.build(
661-
this.client,
662-
this.options as QueryOptions<
663-
TQueryFnData,
664-
TError,
665-
TQueryData,
666-
TQueryKey
667-
>
668-
)
651+
const query = this.client.getQueryCache().build(this.client, this.options)
669652

670653
if (query === this.currentQuery) {
671654
return

src/core/tests/query.test.tsx

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
onlineManager,
1414
QueryFunctionContext,
1515
} from '../..'
16+
import { waitFor } from '@testing-library/react'
1617

1718
describe('query', () => {
1819
let queryClient: QueryClient
@@ -472,7 +473,6 @@ describe('query', () => {
472473
})
473474

474475
test('queries with cacheTime 0 should be removed immediately after unsubscribing', async () => {
475-
const consoleMock = mockConsoleError()
476476
const key = queryKey()
477477
let count = 0
478478
const observer = new QueryObserver(queryClient, {
@@ -486,13 +486,12 @@ describe('query', () => {
486486
})
487487
const unsubscribe1 = observer.subscribe()
488488
unsubscribe1()
489-
await sleep(10)
489+
await waitFor(() => expect(queryCache.find(key)).toBeUndefined())
490490
const unsubscribe2 = observer.subscribe()
491491
unsubscribe2()
492-
await sleep(10)
493-
expect(count).toBe(2)
494-
expect(queryCache.find(key)).toBeUndefined()
495-
consoleMock.mockRestore()
492+
493+
await waitFor(() => expect(queryCache.find(key)).toBeUndefined())
494+
expect(count).toBe(1)
496495
})
497496

498497
test('should be garbage collected when unsubscribed to', async () => {
@@ -506,7 +505,7 @@ describe('query', () => {
506505
const unsubscribe = observer.subscribe()
507506
expect(queryCache.find(key)).toBeDefined()
508507
unsubscribe()
509-
expect(queryCache.find(key)).toBeUndefined()
508+
await waitFor(() => expect(queryCache.find(key)).toBeUndefined())
510509
})
511510

512511
test('should be garbage collected later when unsubscribed and query is fetching', async () => {
@@ -529,7 +528,7 @@ describe('query', () => {
529528
expect(queryCache.find(key)).toBeDefined()
530529
await sleep(10)
531530
// should be removed after an additional staleTime wait
532-
expect(queryCache.find(key)).toBeUndefined()
531+
await waitFor(() => expect(queryCache.find(key)).toBeUndefined())
533532
})
534533

535534
test('should not be garbage collected unless there are no subscribers', async () => {

src/core/tests/queryClient.test.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -416,9 +416,10 @@ describe('queryClient', () => {
416416
},
417417
{ cacheTime: 0 }
418418
)
419-
const result2 = queryClient.getQueryData(key1)
420419
expect(result).toEqual(1)
421-
expect(result2).toEqual(undefined)
420+
await waitFor(() =>
421+
expect(queryClient.getQueryData(key1)).toEqual(undefined)
422+
)
422423
})
423424

424425
test('should keep a query in cache if cache time is Infinity', async () => {

src/reactjs/tests/useQuery.test.tsx

Lines changed: 48 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -691,52 +691,78 @@ describe('useQuery', () => {
691691
expect(states[1]).toMatchObject({ data: 'data' })
692692
})
693693

694-
it('should create a new query when re-mounting with cacheTime 0', async () => {
694+
it('should pick up a query when re-mounting with cacheTime 0', async () => {
695695
const key = queryKey()
696696
const states: UseQueryResult<string>[] = []
697697

698698
function Page() {
699699
const [toggle, setToggle] = React.useState(false)
700700

701-
React.useEffect(() => {
702-
setActTimeout(() => {
703-
setToggle(true)
704-
}, 20)
705-
}, [setToggle])
706-
707-
return toggle ? <Component key="1" /> : <Component key="2" />
701+
return (
702+
<div>
703+
<button onClick={() => setToggle(true)}>toggle</button>
704+
{toggle ? (
705+
<Component key="2" value="2" />
706+
) : (
707+
<Component key="1" value="1" />
708+
)}
709+
</div>
710+
)
708711
}
709712

710-
function Component() {
713+
function Component({ value }: { value: string }) {
711714
const state = useQuery(
712715
key,
713716
async () => {
714-
await sleep(5)
715-
return 'data'
717+
await sleep(10)
718+
return 'data: ' + value
716719
},
717720
{
718721
cacheTime: 0,
722+
notifyOnChangeProps: 'all',
719723
}
720724
)
721725
states.push(state)
722-
return null
726+
return (
727+
<div>
728+
<div>{state.data}</div>
729+
</div>
730+
)
723731
}
724732

725-
renderWithClient(queryClient, <Page />)
733+
const rendered = renderWithClient(queryClient, <Page />)
726734

727-
await sleep(100)
735+
await rendered.findByText('data: 1')
728736

729-
expect(states.length).toBe(5)
737+
rendered.getByRole('button', { name: /toggle/i }).click()
738+
739+
await rendered.findByText('data: 2')
740+
741+
expect(states.length).toBe(4)
730742
// First load
731-
expect(states[0]).toMatchObject({ isLoading: true, isSuccess: false })
743+
expect(states[0]).toMatchObject({
744+
isLoading: true,
745+
isSuccess: false,
746+
isFetching: true,
747+
})
732748
// First success
733-
expect(states[1]).toMatchObject({ isLoading: false, isSuccess: true })
734-
// Switch
735-
expect(states[2]).toMatchObject({ isLoading: false, isSuccess: true })
736-
// Second load
737-
expect(states[3]).toMatchObject({ isLoading: true, isSuccess: false })
749+
expect(states[1]).toMatchObject({
750+
isLoading: false,
751+
isSuccess: true,
752+
isFetching: false,
753+
})
754+
// Switch, goes to fetching
755+
expect(states[2]).toMatchObject({
756+
isLoading: false,
757+
isSuccess: true,
758+
isFetching: true,
759+
})
738760
// Second success
739-
expect(states[4]).toMatchObject({ isLoading: false, isSuccess: true })
761+
expect(states[3]).toMatchObject({
762+
isLoading: false,
763+
isSuccess: true,
764+
isFetching: false,
765+
})
740766
})
741767

742768
it('should not get into an infinite loop when removing a query with cacheTime 0 and rerendering', async () => {
@@ -5097,64 +5123,6 @@ describe('useQuery', () => {
50975123

50985124
onlineMock.mockRestore()
50995125
})
5100-
5101-
it('online queries with cacheTime:0 should not fetch if paused and then unmounted', async () => {
5102-
const key = queryKey()
5103-
let count = 0
5104-
5105-
function Component() {
5106-
const state = useQuery({
5107-
queryKey: key,
5108-
queryFn: async () => {
5109-
count++
5110-
await sleep(10)
5111-
return 'data' + count
5112-
},
5113-
cacheTime: 0,
5114-
})
5115-
5116-
return (
5117-
<div>
5118-
<div>
5119-
status: {state.status}, fetchStatus: {state.fetchStatus}
5120-
</div>
5121-
<div>data: {state.data}</div>
5122-
</div>
5123-
)
5124-
}
5125-
5126-
function Page() {
5127-
const [show, setShow] = React.useState(true)
5128-
5129-
return (
5130-
<div>
5131-
{show && <Component />}
5132-
<button onClick={() => setShow(false)}>hide</button>
5133-
</div>
5134-
)
5135-
}
5136-
5137-
const onlineMock = mockNavigatorOnLine(false)
5138-
5139-
const rendered = renderWithClient(queryClient, <Page />)
5140-
5141-
await waitFor(() =>
5142-
rendered.getByText('status: loading, fetchStatus: paused')
5143-
)
5144-
5145-
rendered.getByRole('button', { name: /hide/i }).click()
5146-
5147-
onlineMock.mockReturnValue(true)
5148-
window.dispatchEvent(new Event('online'))
5149-
5150-
await sleep(15)
5151-
5152-
expect(queryClient.getQueryState(key)).not.toBeDefined()
5153-
5154-
expect(count).toBe(0)
5155-
5156-
onlineMock.mockRestore()
5157-
})
51585126
})
51595127

51605128
describe('networkMode always', () => {

src/reactjs/useBaseQuery.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,12 +59,6 @@ export function useBaseQuery<
5959
if (typeof defaultedOptions.staleTime !== 'number') {
6060
defaultedOptions.staleTime = 1000
6161
}
62-
63-
// Set cache time to 1 if the option has been set to 0
64-
// when using suspense to prevent infinite loop of fetches
65-
if (defaultedOptions.cacheTime === 0) {
66-
defaultedOptions.cacheTime = 1
67-
}
6862
}
6963

7064
if (defaultedOptions.suspense || defaultedOptions.useErrorBoundary) {

0 commit comments

Comments
 (0)