From 7b6a6c2910681f8e08e063d7c71d1a3204bcd35e Mon Sep 17 00:00:00 2001 From: Michel Smola Date: Tue, 6 Oct 2026 00:21:02 +0200 Subject: [PATCH 1/7] feat: await promise, then startTransition --- packages/toapi-react/src/use-query.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/packages/toapi-react/src/use-query.ts b/packages/toapi-react/src/use-query.ts index 6da81330..106b339c 100644 --- a/packages/toapi-react/src/use-query.ts +++ b/packages/toapi-react/src/use-query.ts @@ -24,15 +24,14 @@ 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 {} - }); + const unsubscribe = observable.subscribe(async (next) => { + try { + const value = await next; + // A late update from a subscription we have already left behind must + // not overwrite the current one. + if (active) + startTransition(() => setState({ promise: next, queryKey, value })); + } catch {} }); return () => { active = false; From 33f26165c46ca5a9afda52607669cec05f426e11 Mon Sep 17 00:00:00 2001 From: Michel Smola Date: Tue, 6 Oct 2026 00:38:09 +0200 Subject: [PATCH 2/7] feat: onStatusChange api --- packages/toapi-react/src/use-query.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/toapi-react/src/use-query.ts b/packages/toapi-react/src/use-query.ts index 106b339c..cc7da78e 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; @@ -26,12 +27,18 @@ export function useQuery( let active = true; const unsubscribe = observable.subscribe(async (next) => { try { + onStatusChange?.("loading"); const value = await next; // A late update from a subscription we have already left behind must // not overwrite the current one. - if (active) + if (active) { startTransition(() => setState({ promise: next, queryKey, value })); - } catch {} + } + } finally { + if (active) { + onStatusChange?.("idle"); + } + } }); return () => { active = false; From b332d7995d5441cdb90f3687963d95df4767ca6b Mon Sep 17 00:00:00 2001 From: Michel Smola Date: Tue, 6 Oct 2026 08:53:18 +0200 Subject: [PATCH 3/7] docs: todo comment for race condittion --- packages/toapi-client/src/build-observable.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/toapi-client/src/build-observable.ts b/packages/toapi-client/src/build-observable.ts index fdd9f9ca..2617753b 100644 --- a/packages/toapi-client/src/build-observable.ts +++ b/packages/toapi-client/src/build-observable.ts @@ -27,7 +27,8 @@ export function buildObservable( 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. + return callback(data); } }); } From 1e7ede736f003158187cb3a774ac9a27520f3d66 Mon Sep 17 00:00:00 2001 From: Michel Smola Date: Tue, 6 Oct 2026 08:56:17 +0200 Subject: [PATCH 4/7] feat: prevent out-of-order updates --- packages/toapi-react/src/use-query.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/toapi-react/src/use-query.ts b/packages/toapi-react/src/use-query.ts index cc7da78e..d4462f7c 100644 --- a/packages/toapi-react/src/use-query.ts +++ b/packages/toapi-react/src/use-query.ts @@ -25,19 +25,22 @@ export function useQuery( React.useEffect(() => { let active = true; + let emitted = 0; + let applied = 0; const unsubscribe = observable.subscribe(async (next) => { + const seq = ++emitted; + onStatusChange?.("loading"); try { - onStatusChange?.("loading"); const value = await next; // A late update from a subscription we have already left behind must // not overwrite the current one. - if (active) { - startTransition(() => setState({ promise: next, queryKey, value })); - } + if (!active || seq < applied) return; + applied = seq; + startTransition(() => setState({ promise: next, queryKey, value })); } finally { - if (active) { - onStatusChange?.("idle"); - } + if (!active || seq < applied) return; + applied = seq; + onStatusChange?.("idle"); } }); return () => { From 8f3d2e38f1dd47a3e68185cba96381f33efbc593 Mon Sep 17 00:00:00 2001 From: Michel Smola Date: Tue, 6 Oct 2026 09:06:07 +0200 Subject: [PATCH 5/7] feat: wait for data in subscription callback --- packages/toapi-client/src/build-observable.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/toapi-client/src/build-observable.ts b/packages/toapi-client/src/build-observable.ts index 2617753b..2ee1552a 100644 --- a/packages/toapi-client/src/build-observable.ts +++ b/packages/toapi-client/src/build-observable.ts @@ -28,7 +28,11 @@ export function buildObservable( if (invalidUrls.has(url)) { const { data } = request(options); // TODO in major release pass the finished data, not the promise here so subscribers don't need to bother with races. - return callback(data); + callback(data); + try { + // wait for the promise to resolve so revalidation handlers correctly await a fully revalidated state + await data; + } catch {} } }); } From 713aaaef3bf92b90d65efff5edaa061361c54586 Mon Sep 17 00:00:00 2001 From: Michel Smola Date: Tue, 6 Oct 2026 09:44:36 +0200 Subject: [PATCH 6/7] feat: await subscription callbacks and handle their errors --- packages/toapi-client/src/build-observable.ts | 19 ++++++++----------- .../toapi-client/src/create-fetch-client.ts | 1 + packages/toapi-common/src/observable.ts | 4 +++- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/toapi-client/src/build-observable.ts b/packages/toapi-client/src/build-observable.ts index 2ee1552a..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,30 +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); // TODO in major release pass the finished data, not the promise here so subscribers don't need to bother with races. - callback(data); try { - // wait for the promise to resolve so revalidation handlers correctly await a fully revalidated state - await data; - } catch {} + 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; }; From 48b69f657a00865f8b56c76fce9dafdc4f2313ce Mon Sep 17 00:00:00 2001 From: Michel Smola Date: Tue, 6 Oct 2026 09:48:12 +0200 Subject: [PATCH 7/7] docs: changeset --- .changeset/clever-ways-wonder.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/clever-ways-wonder.md 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