Skip to content

Commit ead5e1e

Browse files
committed
fix: fixed non cancellation on unmount
1 parent 0c24c42 commit ead5e1e

4 files changed

Lines changed: 677 additions & 48 deletions

File tree

src/lib/queries/QueryClientProvider$.tsx

Lines changed: 66 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import { hashKey, type QueryKey, useQueryClient } from "@tanstack/react-query"
1+
import {
2+
hashKey,
3+
type QueryClient,
4+
type QueryKey,
5+
useQueryClient,
6+
} from "@tanstack/react-query"
27
import { createContext, memo, useContext, useEffect, useState } from "react"
38
import {
49
fromEvent,
@@ -9,6 +14,7 @@ import {
914
} from "rxjs"
1015

1116
type CacheEntry = {
17+
queryKey: QueryKey
1218
query$: Observable<unknown>
1319
signal: AbortSignal
1420
sub: Subscription | undefined
@@ -19,6 +25,8 @@ type CacheEntry = {
1925
export class QueryClient$ {
2026
public readonly queryMap: Map<string, CacheEntry> = new Map()
2127

28+
constructor(public readonly queryClient: QueryClient) {}
29+
2230
getQuery(queryHash: string) {
2331
return this.queryMap.get(queryHash)
2432
}
@@ -31,17 +39,12 @@ export class QueryClient$ {
3139
const queryHash = hashKey(queryKey)
3240

3341
const sharedQuery$ = query$.pipe(
34-
/**
35-
* abort signal is triggered on:
36-
* - manual cancellation from user
37-
* - unmounting the component
38-
* @see https://tanstack.com/query/latest/docs/framework/react/guides/query-cancellation
39-
*/
4042
takeUntil(fromEvent(signal, "abort")),
4143
share(),
4244
)
4345

4446
const cacheEntry: CacheEntry = {
47+
queryKey,
4548
query$: sharedQuery$,
4649
signal,
4750
sub: undefined,
@@ -60,7 +63,9 @@ export class QueryClient$ {
6063
}
6164
},
6265
complete: () => {
63-
this.deleteQuery(queryHash)
66+
if (this.queryMap.get(queryHash) === cacheEntry) {
67+
this.deleteQuery(queryHash)
68+
}
6469
},
6570
})
6671

@@ -74,14 +79,29 @@ export class QueryClient$ {
7479

7580
if (!entry) return
7681

82+
entry.isCompleted = true
83+
84+
this.queryMap.delete(queryHash)
85+
7786
if (entry.sub) {
7887
entry.sub.unsubscribe()
7988
entry.sub = undefined
8089
}
8190

82-
entry.isCompleted = true
83-
84-
this.queryMap.delete(queryHash)
91+
/**
92+
* Only cancel queries whose stream already emitted at least once.
93+
* Those are "live" streams stuck in the take(1) refetch loop and
94+
* need an explicit cancel so the pending queryFnAsync promise
95+
* settles. One-shot observables (used like promises) that haven't
96+
* resolved yet should keep react-query's default behavior: stay
97+
* pending and resolve naturally even after unmount.
98+
*/
99+
if (!entry.signal.aborted && entry.lastData !== undefined) {
100+
this.queryClient?.cancelQueries({
101+
queryKey: entry.queryKey,
102+
exact: true,
103+
})
104+
}
85105
}
86106

87107
destroy() {
@@ -99,44 +119,42 @@ export const Context = createContext<QueryClient$ | undefined>(undefined)
99119
* (including via `queryClient.clear()`), matching `useQuery$` observable
100120
* entries are dropped.
101121
*/
102-
export const QueryClientProvider$ = memo(
103-
({
104-
children,
105-
client: _client,
106-
}: {
107-
children: React.ReactNode
108-
client?: QueryClient$
109-
}) => {
110-
const [client] = useState(() => _client ?? new QueryClient$())
111-
const queryClient = useQueryClient()
112-
113-
useEffect(
114-
function subscribeToQueryCache() {
115-
return queryClient.getQueryCache().subscribe((event) => {
116-
const activeObservers = event.query.getObserversCount()
117-
118-
/**
119-
* When observers unmount, we need to delete the query from the cache
120-
* because if the query contains a stream, it will continue. Nothing stops it.
121-
* However this is only valid when there is no more observers.
122-
*/
123-
if (event.type === "observerRemoved" && activeObservers === 0) {
124-
client.deleteQuery(event.query.queryHash)
125-
}
126-
})
127-
},
128-
[queryClient, client],
129-
)
130-
131-
useEffect(() => {
132-
return () => {
133-
client.destroy()
134-
}
135-
}, [client])
122+
export const QueryClientProvider$ = memo(function QueryClientProvider$({
123+
children,
124+
client: _client,
125+
}: {
126+
children: React.ReactNode
127+
client?: QueryClient$
128+
}) {
129+
const queryClient = useQueryClient()
130+
const [client] = useState(() => _client ?? new QueryClient$(queryClient))
131+
132+
useEffect(
133+
function subscribeToQueryCache() {
134+
return queryClient.getQueryCache().subscribe((event) => {
135+
const activeObservers = event.query.getObserversCount()
136+
137+
/**
138+
* When all observers unmount we need to delete the query from the cache
139+
* because if the query contains a stream, it will continue. Nothing stops it.
140+
* However this is only valid when there is no more observers.
141+
*/
142+
if (event.type === "observerRemoved" && activeObservers === 0) {
143+
client.deleteQuery(event.query.queryHash)
144+
}
145+
})
146+
},
147+
[queryClient, client],
148+
)
149+
150+
useEffect(() => {
151+
return () => {
152+
client.destroy()
153+
}
154+
}, [client])
136155

137-
return <Context.Provider value={client}>{children}</Context.Provider>
138-
},
139-
)
156+
return <Context.Provider value={client}>{children}</Context.Provider>
157+
})
140158

141159
export const useQueryClient$ = () => {
142160
const client = useContext(Context)

0 commit comments

Comments
 (0)