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..b7aa993 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,14 @@ 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) { + reject(controller.signal.reason as Error) + return + } + const promise = (resolversCache.get(key)?.item as Promise | undefined) ?? Promise.resolve(item) @@ -486,14 +506,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 +533,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 +551,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 +566,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 +585,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 +621,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 })