From f25d26b099273cb62a53fd037bfa063a612c29a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 07:19:37 +0000 Subject: [PATCH 1/4] perf: locate bridge queries by hash instead of scanning the query cache refetchQueries/cancelQueries with { queryKey, exact: true } iterate every query in the TanStack cache and re-hash the key against each entry. The observable bridge runs the refetch once per emission of a live stream, so the lookup is replaced with a direct queryCache.get(queryHash), and the per-emission map lookup in the cache-entry subscription is replaced with the closed-over entry. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FqgGygsYCHiwqmWqFj3wYm --- src/lib/queries/QueryClientProvider$.tsx | 28 +++++++++++++++------- src/lib/queries/createObservableQueryFn.ts | 19 +++++++++++---- 2 files changed, 33 insertions(+), 14 deletions(-) diff --git a/src/lib/queries/QueryClientProvider$.tsx b/src/lib/queries/QueryClientProvider$.tsx index 1fd1a48..604446c 100644 --- a/src/lib/queries/QueryClientProvider$.tsx +++ b/src/lib/queries/QueryClientProvider$.tsx @@ -7,6 +7,7 @@ import { import { createContext, memo, useContext, useEffect, useState } from "react" import { fromEvent, + noop, type Observable, type Subscription, share, @@ -55,12 +56,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,10 +107,17 @@ export class QueryClient$ { * final value — cancelling it would reject it prematurely. */ if (cancelQuery && !entry.signal.aborted && entry.lastData !== undefined) { - this.queryClient?.cancelQueries({ - queryKey: entry.queryKey, - exact: true, - }) + /** + * Cancel the single target query located directly by hash instead of + * `cancelQueries({ queryKey, exact: true })`, which scans the whole + * query cache and re-hashes the key against every entry. `revert` and + * the swallowed rejection mirror cancelQueries' defaults. + */ + this.queryClient + ?.getQueryCache() + .get(queryHash) + ?.cancel({ revert: true }) + .catch(noop) } } diff --git a/src/lib/queries/createObservableQueryFn.ts b/src/lib/queries/createObservableQueryFn.ts index 4e0322e..80c7987 100644 --- a/src/lib/queries/createObservableQueryFn.ts +++ b/src/lib/queries/createObservableQueryFn.ts @@ -6,7 +6,7 @@ import { type QueryKey, skipToken, } from "@tanstack/react-query" -import { defer, delay, type Observable, take } from "rxjs" +import { defer, delay, noop, type Observable, take } from "rxjs" import type { QueryClient$ } from "./QueryClientProvider$" export type ObservableQueryFn< @@ -65,10 +65,19 @@ export function createObservableQueryFn< */ if (queryCacheEntry?.isCompleted) return - queryClient?.refetchQueries({ - queryKey: context.queryKey, - exact: true, - }) + /** + * This runs once per emission of a live stream, so locate the + * single target query directly by hash instead of + * `refetchQueries({ queryKey, exact: true })`, which scans the + * whole query cache and re-hashes the key against every entry. + * Mirrors refetchQueries' per-query behavior (disabled/static + * skip, cancelRefetch, swallowed rejection) for one query. + */ + const query = queryClient?.getQueryCache().get(queryHash) + + if (query && !query.isDisabled() && !query.isStatic?.()) { + query.fetch(undefined, { cancelRefetch: true }).catch(noop) + } }) } } From a3208463a5fc924b57e0c73514f179e6b17406cc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 12:28:49 +0000 Subject: [PATCH 2/4] fix: fall back to the scanning lookup for custom queryKeyHashFn TanStack stores a query under the hash produced by its configured queryKeyHashFn, while the bridge computes the default hashKey, so the direct queryCache.get lookup missed those queries entirely: live streams published their first value and were never refetched again, and the teardown cancel was skipped. Keep the O(1) lookup for the default hashing case and fall back to refetchQueries/cancelQueries when it misses, rather than re-deriving the configured hash here. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FqgGygsYCHiwqmWqFj3wYm --- src/lib/queries/QueryClientProvider$.tsx | 19 +++++--- src/lib/queries/createObservableQueryFn.ts | 19 +++++++- src/lib/queries/useQuery$.reactivity.test.tsx | 45 +++++++++++++++++++ 3 files changed, 77 insertions(+), 6 deletions(-) diff --git a/src/lib/queries/QueryClientProvider$.tsx b/src/lib/queries/QueryClientProvider$.tsx index 604446c..d7d7f3f 100644 --- a/src/lib/queries/QueryClientProvider$.tsx +++ b/src/lib/queries/QueryClientProvider$.tsx @@ -112,12 +112,21 @@ export class QueryClient$ { * `cancelQueries({ queryKey, exact: true })`, which scans the whole * query cache and re-hashes the key against every entry. `revert` and * the swallowed rejection mirror cancelQueries' defaults. + * + * As in the refetch path, `queryHash` is the default `hashKey` output + * and misses queries configuring a custom `queryKeyHashFn`, so those + * fall back to the scanning API. */ - this.queryClient - ?.getQueryCache() - .get(queryHash) - ?.cancel({ revert: true }) - .catch(noop) + const query = this.queryClient?.getQueryCache().get(queryHash) + + if (query) { + query.cancel({ revert: true }).catch(noop) + } else { + this.queryClient?.cancelQueries({ + queryKey: entry.queryKey, + exact: true, + }) + } } } diff --git a/src/lib/queries/createObservableQueryFn.ts b/src/lib/queries/createObservableQueryFn.ts index 80c7987..23bbb6e 100644 --- a/src/lib/queries/createObservableQueryFn.ts +++ b/src/lib/queries/createObservableQueryFn.ts @@ -75,7 +75,24 @@ export function createObservableQueryFn< */ const query = queryClient?.getQueryCache().get(queryHash) - if (query && !query.isDisabled() && !query.isStatic?.()) { + /** + * `queryHash` is the default `hashKey` output, but a query + * configuring `queryKeyHashFn` (inline, via `setQueryDefaults` + * or via client `defaultOptions`) is stored under that custom + * hash instead, so the direct lookup misses it. Fall back to + * the scanning API rather than re-deriving the configured hash + * here, which would duplicate TanStack internals. + */ + if (!query) { + queryClient?.refetchQueries({ + queryKey: context.queryKey, + exact: true, + }) + + return + } + + if (!query.isDisabled() && !query.isStatic?.()) { query.fetch(undefined, { cancelRefetch: true }).catch(noop) } }) 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({}) From cfc1900fd70dc046b4b2c64d479016afa1abe90b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 12:40:30 +0000 Subject: [PATCH 3/4] fix: batch the direct fetch and cancel like the query client does refetchQueries and cancelQueries wrap their work in notifyManager.batch, which queues the observer notifications raised during the call and flushes them in a single React batched update. Calling query.fetch and query.cancel bare notified per event instead, changing how renders coalesce for live streams. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FqgGygsYCHiwqmWqFj3wYm --- src/lib/queries/QueryClientProvider$.tsx | 8 +++++++- src/lib/queries/createObservableQueryFn.ts | 16 +++++++++++++--- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/lib/queries/QueryClientProvider$.tsx b/src/lib/queries/QueryClientProvider$.tsx index d7d7f3f..bd7cb3c 100644 --- a/src/lib/queries/QueryClientProvider$.tsx +++ b/src/lib/queries/QueryClientProvider$.tsx @@ -1,5 +1,6 @@ import { hashKey, + notifyManager, type QueryClient, type QueryKey, useQueryClient, @@ -120,7 +121,12 @@ export class QueryClient$ { const query = this.queryClient?.getQueryCache().get(queryHash) if (query) { - query.cancel({ revert: true }).catch(noop) + /** + * Batched for the same reason as the refetch path: `cancelQueries` + * flushes the notifications raised by the cancel in one React + * update rather than one per event. + */ + notifyManager.batch(() => query.cancel({ revert: true })).catch(noop) } else { this.queryClient?.cancelQueries({ queryKey: entry.queryKey, diff --git a/src/lib/queries/createObservableQueryFn.ts b/src/lib/queries/createObservableQueryFn.ts index 23bbb6e..772daf9 100644 --- a/src/lib/queries/createObservableQueryFn.ts +++ b/src/lib/queries/createObservableQueryFn.ts @@ -1,6 +1,7 @@ import { CancelledError, hashKey, + notifyManager, type QueryClient, type QueryFunctionContext, type QueryKey, @@ -92,9 +93,18 @@ export function createObservableQueryFn< return } - if (!query.isDisabled() && !query.isStatic?.()) { - query.fetch(undefined, { cancelRefetch: true }).catch(noop) - } + /** + * `notifyManager.batch` is not optional: it defers observer + * notifications raised during the fetch and flushes them in a + * single React batched update, exactly as `refetchQueries` + * does. Calling `fetch` bare would notify per event and change + * how renders coalesce. + */ + notifyManager.batch(() => { + if (!query.isDisabled() && !query.isStatic?.()) { + query.fetch(undefined, { cancelRefetch: true }).catch(noop) + } + }) }) } } From 6afa99ba4d3b00d8e3074e006d01177fed6cc8f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 12:58:13 +0000 Subject: [PATCH 4/4] perf: match the bridge query by identity instead of re-hashing The cost of refetchQueries/cancelQueries with { queryKey, exact: true } is not the iteration but the re-hashing: matchQuery JSON.stringifies the key again for every query in the cache, since each one may carry its own queryKeyHashFn. An identity predicate skips hashing entirely, as context.queryKey is the very array the target query holds. This replaces the direct queryCache.get + query.fetch path. Batching, the disabled/static skip, cancelRefetch and error swallowing all go back to living inside the query client rather than being duplicated here, so the custom queryKeyHashFn fallback and the notifyManager.batch replication are no longer needed. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FqgGygsYCHiwqmWqFj3wYm --- src/lib/queries/QueryClientProvider$.tsx | 32 ++++---------- src/lib/queries/createObservableQueryFn.ts | 49 +++++----------------- 2 files changed, 18 insertions(+), 63 deletions(-) diff --git a/src/lib/queries/QueryClientProvider$.tsx b/src/lib/queries/QueryClientProvider$.tsx index bd7cb3c..7c8991d 100644 --- a/src/lib/queries/QueryClientProvider$.tsx +++ b/src/lib/queries/QueryClientProvider$.tsx @@ -1,6 +1,5 @@ import { hashKey, - notifyManager, type QueryClient, type QueryKey, useQueryClient, @@ -8,7 +7,6 @@ import { import { createContext, memo, useContext, useEffect, useState } from "react" import { fromEvent, - noop, type Observable, type Subscription, share, @@ -109,30 +107,14 @@ export class QueryClient$ { */ if (cancelQuery && !entry.signal.aborted && entry.lastData !== undefined) { /** - * Cancel the single target query located directly by hash instead of - * `cancelQueries({ queryKey, exact: true })`, which scans the whole - * query cache and re-hashes the key against every entry. `revert` and - * the swallowed rejection mirror cancelQueries' defaults. - * - * As in the refetch path, `queryHash` is the default `hashKey` output - * and misses queries configuring a custom `queryKeyHashFn`, so those - * fall back to the scanning API. + * 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`. */ - const query = this.queryClient?.getQueryCache().get(queryHash) - - if (query) { - /** - * Batched for the same reason as the refetch path: `cancelQueries` - * flushes the notifications raised by the cancel in one React - * update rather than one per event. - */ - notifyManager.batch(() => query.cancel({ revert: true })).catch(noop) - } else { - this.queryClient?.cancelQueries({ - queryKey: entry.queryKey, - exact: true, - }) - } + this.queryClient?.cancelQueries({ + predicate: (query) => query.queryKey === entry.queryKey, + }) } } diff --git a/src/lib/queries/createObservableQueryFn.ts b/src/lib/queries/createObservableQueryFn.ts index 772daf9..7dfd93c 100644 --- a/src/lib/queries/createObservableQueryFn.ts +++ b/src/lib/queries/createObservableQueryFn.ts @@ -1,13 +1,12 @@ import { CancelledError, hashKey, - notifyManager, type QueryClient, type QueryFunctionContext, type QueryKey, skipToken, } from "@tanstack/react-query" -import { defer, delay, noop, type Observable, take } from "rxjs" +import { defer, delay, type Observable, take } from "rxjs" import type { QueryClient$ } from "./QueryClientProvider$" export type ObservableQueryFn< @@ -67,43 +66,17 @@ export function createObservableQueryFn< if (queryCacheEntry?.isCompleted) return /** - * This runs once per emission of a live stream, so locate the - * single target query directly by hash instead of - * `refetchQueries({ queryKey, exact: true })`, which scans the - * whole query cache and re-hashes the key against every entry. - * Mirrors refetchQueries' per-query behavior (disabled/static - * skip, cancelRefetch, swallowed rejection) for one query. + * 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`. */ - const query = queryClient?.getQueryCache().get(queryHash) - - /** - * `queryHash` is the default `hashKey` output, but a query - * configuring `queryKeyHashFn` (inline, via `setQueryDefaults` - * or via client `defaultOptions`) is stored under that custom - * hash instead, so the direct lookup misses it. Fall back to - * the scanning API rather than re-deriving the configured hash - * here, which would duplicate TanStack internals. - */ - if (!query) { - queryClient?.refetchQueries({ - queryKey: context.queryKey, - exact: true, - }) - - return - } - - /** - * `notifyManager.batch` is not optional: it defers observer - * notifications raised during the fetch and flushes them in a - * single React batched update, exactly as `refetchQueries` - * does. Calling `fetch` bare would notify per event and change - * how renders coalesce. - */ - notifyManager.batch(() => { - if (!query.isDisabled() && !query.isStatic?.()) { - query.fetch(undefined, { cancelRefetch: true }).catch(noop) - } + queryClient?.refetchQueries({ + predicate: (query) => query.queryKey === context.queryKey, }) }) }