diff --git a/src/lib/queries/QueryClientProvider$.tsx b/src/lib/queries/QueryClientProvider$.tsx index 1fd1a48..7c8991d 100644 --- a/src/lib/queries/QueryClientProvider$.tsx +++ b/src/lib/queries/QueryClientProvider$.tsx @@ -55,12 +55,14 @@ export class QueryClient$ { this.queryMap.set(queryHash, cacheEntry) const sub = sharedQuery$.subscribe({ + /** + * Write on the closed-over entry directly: this subscription is torn + * down by `deleteQuery` before the map slot can ever point to another + * entry, so a per-emission `queryMap.get(queryHash)` lookup would + * always resolve to `cacheEntry` anyway. + */ next: (data) => { - const entry = this.queryMap.get(queryHash) - - if (entry) { - entry.lastData = { value: data } - } + cacheEntry.lastData = { value: data } }, complete: () => { if (this.queryMap.get(queryHash) === cacheEntry) { @@ -104,9 +106,14 @@ export class QueryClient$ { * final value — cancelling it would reject it prematurely. */ if (cancelQuery && !entry.signal.aborted && entry.lastData !== undefined) { + /** + * Matched by identity for the same reason as the refetch path: the + * `{ queryKey, exact: true }` filter re-hashes the key for every + * query in the cache, while an identity predicate skips hashing and + * keeps all cancel semantics inside `cancelQueries`. + */ this.queryClient?.cancelQueries({ - queryKey: entry.queryKey, - exact: true, + predicate: (query) => query.queryKey === entry.queryKey, }) } } diff --git a/src/lib/queries/createObservableQueryFn.ts b/src/lib/queries/createObservableQueryFn.ts index 4e0322e..7dfd93c 100644 --- a/src/lib/queries/createObservableQueryFn.ts +++ b/src/lib/queries/createObservableQueryFn.ts @@ -65,9 +65,18 @@ export function createObservableQueryFn< */ if (queryCacheEntry?.isCompleted) return + /** + * This runs once per emission of a live stream. The cost of + * the equivalent `{ queryKey, exact: true }` filter is not the + * iteration but the re-hashing: it JSON.stringifies the key + * again for every query in the cache. Matching the query by + * identity instead skips hashing entirely — `context.queryKey` + * is the very array the target query holds — while leaving all + * refetch semantics (batching, disabled/static skip, + * cancelRefetch, error swallowing) inside `refetchQueries`. + */ queryClient?.refetchQueries({ - queryKey: context.queryKey, - exact: true, + predicate: (query) => query.queryKey === context.queryKey, }) }) } diff --git a/src/lib/queries/useQuery$.reactivity.test.tsx b/src/lib/queries/useQuery$.reactivity.test.tsx index 20af7b1..5cb57b2 100644 --- a/src/lib/queries/useQuery$.reactivity.test.tsx +++ b/src/lib/queries/useQuery$.reactivity.test.tsx @@ -91,6 +91,51 @@ describe("useQuery$ live-query reactivity", () => { ) }) + it("re-renders when the query uses a custom queryKeyHashFn", async () => { + const liveQuery$ = new BehaviorSubject(["a", "b"]) + const db$ = new BehaviorSubject({}) + const queryClient = createQueryClient() + + function Comp() { + const { data } = useQuery$({ + ...liveQueryOptions, + queryKey: ["live", "custom-hash"], + /** + * TanStack stores the query under the hash produced here, which + * differs from the default `hashKey` output. + */ + queryKeyHashFn: (queryKey) => `custom:${JSON.stringify(queryKey)}`, + queryFn: () => + db$.pipe( + filter(isDefined), + switchMap(() => liveQuery$), + map((items) => [...items]), + ), + }) + + return {JSON.stringify(data)} + } + + render(, { wrapper: createWrapper(queryClient) }) + + await act(async () => { + await waitForTimeout(100) + }) + + expect(screen.getByTestId("data").textContent).toBe( + JSON.stringify(["a", "b"]), + ) + + await act(async () => { + liveQuery$.next(["a", "b", "c"]) + await waitForTimeout(200) + }) + + expect(screen.getByTestId("data").textContent).toBe( + JSON.stringify(["a", "b", "c"]), + ) + }) + it("re-renders with two observers on the same key", async () => { const liveQuery$ = new BehaviorSubject(["a", "b"]) const db$ = new BehaviorSubject({})