From 09a2f413b5a1c0dccd22161744beb6c3b0709a3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20C=2E=20For=C3=A9s?= Date: Sun, 12 Apr 2026 14:25:44 +0200 Subject: [PATCH 1/2] fix: resolve medium-severity audit issues in core query and React bindings - Wrap BroadcastChannel.postMessage() in try-catch to prevent DataCloneError crashes - Add abort signal support to once() to fix event listener leaks in stream()/sequence() generators - Guard against abort race condition in trigger() by checking signal.aborted before cache write - Capture broadcast reference in subscribeBroadcast() to prevent stale closure after configure() - Handle rejected promises in forget() to prevent hanging or throwing on pending cache items - Add BroadcastChannel availability guard in QueryProvider for edge runtime compatibility - Fix tautological test assertions comparing variables to themselves in cache access test --- src/query/options.ts | 2 +- src/query/query.test.ts | 4 +- src/query/query.ts | 86 +++++++++++++++++++++----- src/react/components/QueryProvider.tsx | 6 ++ 4 files changed, 80 insertions(+), 18 deletions(-) diff --git a/src/query/options.ts b/src/query/options.ts index 21f1980..b4a50ae 100644 --- a/src/query/options.ts +++ b/src/query/options.ts @@ -195,7 +195,7 @@ export type Unsubscriber = () => void * Subscribes to the event and automatically unsubscribes after receiving it. */ export type OnceFunction = { - (key: string, event: QueryEvent): Promise> + (key: string, event: QueryEvent, signal?: AbortSignal): Promise> } /** diff --git a/src/query/query.test.ts b/src/query/query.test.ts index 9183916..95a8bb7 100644 --- a/src/query/query.test.ts +++ b/src/query/query.test.ts @@ -805,8 +805,8 @@ describe.concurrent('query', function () { const { items, resolvers } = caches() - expect(items).toBe(items) - expect(resolvers).toBe(resolvers) + expect(items).toBe(itemsCache) + expect(resolvers).toBe(resolversCache) }) it('respects fresh option from configure()', async ({ expect }) => { diff --git a/src/query/query.ts b/src/query/query.ts index 6652f4d..9ea1346 100644 --- a/src/query/query.ts +++ b/src/query/query.ts @@ -139,7 +139,12 @@ export function createQuery(instanceOptions?: Configuration): Query { case 'resolved': case 'hydrated': case 'forgotten': - broadcast?.postMessage({ event: `${event}:${key}`, detail }) + try { + broadcast?.postMessage({ event: `${event}:${key}`, detail }) + } catch { + // Silently ignore DataCloneError or other postMessage failures + // (e.g. when the detail is not structurally cloneable). + } } } @@ -271,7 +276,14 @@ export function createQuery(instanceOptions?: Configuration): Query { if (item !== undefined) { itemsCache.delete(key) - emit(key, 'forgotten', await item.item) + + // Wrap in try-catch so that rejected or pending-then-rejected + // promises don't prevent the rest of the keys from being forgotten. + try { + emit(key, 'forgotten', await item.item) + } catch { + emit(key, 'forgotten', undefined) + } } } } @@ -368,6 +380,13 @@ export function createQuery(instanceOptions?: Configuration): Query { // Awaits the fetching to get the result item. const item = await result + // If the signal was aborted after the fetch resolved but + // before we write to the cache, bail out to avoid writing + // stale data that contradicts the abort. + if (controller.signal.aborted) { + return + } + const promise = (resolversCache.get(key)?.item as Promise | undefined) ?? Promise.resolve(item) @@ -486,14 +505,19 @@ export function createQuery(instanceOptions?: Configuration): Query { * context. */ function subscribeBroadcast(): Unsubscriber { + // Capture the current broadcast reference so that the unsubscriber + // always targets the same channel that was subscribed to, even if + // configure() replaces the broadcast channel later. + const currentBroadcast = broadcast + function onBroadcastMessage(message: MessageEvent) { events.dispatchEvent(new CustomEvent(message.data.event, { detail: message.data.detail })) } - broadcast?.addEventListener('message', onBroadcastMessage) + currentBroadcast?.addEventListener('message', onBroadcastMessage) return function () { - broadcast?.removeEventListener('message', onBroadcastMessage) + currentBroadcast?.removeEventListener('message', onBroadcastMessage) } } @@ -508,14 +532,17 @@ export function createQuery(instanceOptions?: Configuration): Query { * @param keys - A single key, array of keys, or object mapping names to keys. * @returns A promise that resolves with the fetched value(s). */ - async function next(keys: string | { [K in keyof T]: string }): Promise { + async function next( + keys: string | { [K in keyof T]: string }, + signal?: AbortSignal + ): Promise { if (typeof keys === 'string') { - const event = await once(keys, 'refetching') + const event = await once(keys, 'refetching', signal) return (await (event.detail as Promise)) as T } if (Array.isArray(keys)) { - const promises = keys.map((key) => once(key, 'refetching')) + const promises = keys.map((key) => once(key, 'refetching', signal)) const events = await Promise.all(promises) const details = events.map((event) => event.detail as Promise) return (await Promise.all(details)) as T @@ -523,7 +550,7 @@ export function createQuery(instanceOptions?: Configuration): Query { const objectKeys = keys as Record const entries = Object.entries(objectKeys) - const promises = entries.map(([, key]) => once(key, 'refetching')) + const promises = entries.map(([, key]) => once(key, 'refetching', signal)) const events = await Promise.all(promises) const details = await Promise.all(events.map((event) => event.detail as Promise)) const result = Object.fromEntries(entries.map(([name], i) => [name, details[i]])) @@ -538,8 +565,14 @@ export function createQuery(instanceOptions?: Configuration): Query { * @yields The resolved value(s) each time a refetch completes. */ async function* stream(keys: string | { [K in keyof T]: string }) { - for (;;) { - yield await next(keys) + const controller = new AbortController() + + try { + for (;;) { + yield await next(keys, controller.signal) + } + } finally { + controller.abort() } } @@ -551,12 +584,29 @@ export function createQuery(instanceOptions?: Configuration): Query { * @param event - The type of event to wait for. * @returns A promise that resolves with the event details. */ - function once(key: string, event: QueryEvent) { - return new Promise>(function (resolve) { + function once(key: string, event: QueryEvent, signal?: AbortSignal) { + return new Promise>(function (resolve, reject) { const unsubscribe = subscribe(key, event, function (event) { resolve(event) - unsubscribe() + cleanup() }) + + function cleanup() { + unsubscribe() + signal?.removeEventListener('abort', onAbort) + } + + function onAbort() { + cleanup() + reject(signal!.reason) + } + + signal?.addEventListener('abort', onAbort) + + // If the signal is already aborted, clean up immediately. + if (signal?.aborted) { + onAbort() + } }) } @@ -570,8 +620,14 @@ export function createQuery(instanceOptions?: Configuration): Query { * @yields The event details each time the event occurs. */ async function* sequence(key: string, event: QueryEvent) { - for (;;) { - yield await once(key, event) + const controller = new AbortController() + + try { + for (;;) { + yield await once(key, event, controller.signal) + } + } finally { + controller.abort() } } diff --git a/src/react/components/QueryProvider.tsx b/src/react/components/QueryProvider.tsx index e1c6cf2..0abcad2 100644 --- a/src/react/components/QueryProvider.tsx +++ b/src/react/components/QueryProvider.tsx @@ -46,6 +46,12 @@ export function QueryProvider({ useEffect( function () { + // Guard against environments where BroadcastChannel is unavailable + // (e.g. certain edge runtimes or older server-side environments). + if (typeof BroadcastChannel === 'undefined') { + return + } + const broadcast = new BroadcastChannel('query') localQuery.configure({ broadcast }) From a5dd795b10d151f724d63dce3c4caeac2c869fbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20C=2E=20For=C3=A9s?= Date: Sun, 12 Apr 2026 14:27:58 +0200 Subject: [PATCH 2/2] fix: reject promise instead of silently returning when abort race is detected The abort guard in trigger() was returning without resolving or rejecting the outer promise, which would leave callers hanging indefinitely. Now properly rejects with the abort reason. --- src/query/query.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/query/query.ts b/src/query/query.ts index 9ea1346..b7aa993 100644 --- a/src/query/query.ts +++ b/src/query/query.ts @@ -384,6 +384,7 @@ export function createQuery(instanceOptions?: Configuration): Query { // before we write to the cache, bail out to avoid writing // stale data that contradicts the abort. if (controller.signal.aborted) { + reject(controller.signal.reason as Error) return }