diff --git a/.changeset/clever-ways-wonder.md b/.changeset/clever-ways-wonder.md new file mode 100644 index 00000000..f904dbb2 --- /dev/null +++ b/.changeset/clever-ways-wonder.md @@ -0,0 +1,9 @@ +--- +"@toapi/client": minor +"@toapi/common": minor +"@toapi/react": minor +--- + +- Do not wait for revalidations in transition +- Await revalidations correctly +- Serialize revalidation state updates diff --git a/packages/toapi-client/src/build-observable.ts b/packages/toapi-client/src/build-observable.ts index fdd9f9ca..12226424 100644 --- a/packages/toapi-client/src/build-observable.ts +++ b/packages/toapi-client/src/build-observable.ts @@ -1,4 +1,4 @@ -import type { Observable } from "@toapi/common"; +import type { Logger, Observable, Subscription } from "@toapi/common"; import type { PubSub } from "./pub-sub.js"; import type { Cache } from "./cache.js"; import { request } from "./request.js"; @@ -9,25 +9,27 @@ interface Options { init: RequestInit; pubSub: PubSub; cache: Cache; -} - -interface Subscription { - (data: Promise): void; + logger?: Logger; } export function buildObservable( options: Options, ): Observable & Promise { - const { url, pubSub } = options; + const { url, pubSub, logger } = options; const { data } = request(options); - function subscribe(callback: Subscription) { + function subscribe(callback: Subscription) { const { data } = request(options); callback(data); return pubSub.subscribe(async (invalidUrls) => { if (invalidUrls.has(url)) { const { data } = request(options); - callback(data); + // TODO in major release pass the finished data, not the promise here so subscribers don't need to bother with races. + try { + await callback(data); + } catch (error) { + logger?.error?.(error); + } } }); } diff --git a/packages/toapi-client/src/create-fetch-client.ts b/packages/toapi-client/src/create-fetch-client.ts index e10d58e2..4cf92b59 100644 --- a/packages/toapi-client/src/create-fetch-client.ts +++ b/packages/toapi-client/src/create-fetch-client.ts @@ -65,6 +65,7 @@ export function createFetchClient< }, pubSub, cache, + logger: options.logger, }); } diff --git a/packages/toapi-common/src/observable.ts b/packages/toapi-common/src/observable.ts index 35d9f19e..aef30bcf 100644 --- a/packages/toapi-common/src/observable.ts +++ b/packages/toapi-common/src/observable.ts @@ -1,4 +1,6 @@ +export type Subscription = (value: Promise) => void | Promise; + export type Observable = { readonly queryKey: string; - subscribe(callback: (value: Promise) => void): () => void; + subscribe(callback: Subscription): () => void; }; diff --git a/packages/toapi-react/src/use-query.ts b/packages/toapi-react/src/use-query.ts index 6da81330..d4462f7c 100644 --- a/packages/toapi-react/src/use-query.ts +++ b/packages/toapi-react/src/use-query.ts @@ -5,12 +5,13 @@ type ObservablePromise = Promise & Observable; interface Options { startTransition?: typeof React.startTransition; + onStatusChange?(status: "loading" | "idle"): void; } export function useQuery( // TODO remove the useless function-form in next major release query: ObservablePromise | (() => ObservablePromise), - { startTransition = React.startTransition }: Options = {}, + { startTransition = React.startTransition, onStatusChange }: Options = {}, ) { const observable = typeof query === "function" ? query() : query; const queryKey = observable.queryKey; @@ -24,15 +25,23 @@ export function useQuery( React.useEffect(() => { let active = true; - const unsubscribe = observable.subscribe((next) => { - startTransition(async () => { - try { - const value = await next; - // A late update from a subscription we have already left behind must - // not overwrite the current one. - if (active) setState({ promise: next, queryKey, value }); - } catch {} - }); + let emitted = 0; + let applied = 0; + const unsubscribe = observable.subscribe(async (next) => { + const seq = ++emitted; + onStatusChange?.("loading"); + try { + const value = await next; + // A late update from a subscription we have already left behind must + // not overwrite the current one. + if (!active || seq < applied) return; + applied = seq; + startTransition(() => setState({ promise: next, queryKey, value })); + } finally { + if (!active || seq < applied) return; + applied = seq; + onStatusChange?.("idle"); + } }); return () => { active = false;