Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 14 additions & 7 deletions src/lib/queries/QueryClientProvider$.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
})
}
}
Expand Down
13 changes: 11 additions & 2 deletions src/lib/queries/createObservableQueryFn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
})
}
Expand Down
45 changes: 45 additions & 0 deletions src/lib/queries/useQuery$.reactivity.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<object | undefined>({})
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 <span data-testid="data">{JSON.stringify(data)}</span>
}

render(<Comp />, { 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<object | undefined>({})
Expand Down