From 1e068aad7aa2ebb23d3bced13730c470c99c544a Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 6 Nov 2026 12:19:41 -0800 Subject: [PATCH 1/4] fix(web): retry the first session check and read it once --- apps/web/src/App.tsx | 75 +++++++++++----------------- apps/web/src/pages/invite-accept.tsx | 11 ++-- apps/web/src/queries/keys.ts | 2 + apps/web/src/queries/session.test.ts | 60 ++++++++++++++++++++++ apps/web/src/queries/session.ts | 33 ++++++++++++ 5 files changed, 127 insertions(+), 54 deletions(-) create mode 100644 apps/web/src/queries/session.test.ts create mode 100644 apps/web/src/queries/session.ts diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index a4f60ced..9d85a0ae 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -14,6 +14,11 @@ import { humanFault, responseCode } from "./pages/workspace/human-fault.ts"; import { ProductApiError } from "@corbits-builder/client"; import { queryClient } from "./queries/client.ts"; import { keys } from "./queries/keys.ts"; +import { + sessionAuth, + sessionQuery, + sessionReadRetryable, +} from "./queries/session.ts"; import { hostLatency, productClient } from "./api/transport.ts"; import { useQuery } from "@tanstack/react-query"; import { Toaster, toast } from "sonner"; @@ -48,11 +53,9 @@ import { ProjectMenu } from "./pages/project-menu.jsx"; import { ProjectLibrary } from "./pages/project-library.jsx"; import { ensureWorkspace, - getHubSession, openWorkspace, signOutHub, workspaceTenantId, - type HubUser, } from "./api/auth.ts"; import { WorkspaceMenu } from "./workspace-menu.tsx"; import { Settings, type SettingsTab } from "./pages/settings/settings.jsx"; @@ -81,7 +84,7 @@ import { } from "@corbits/react-ui/ui/dialog"; import { Auth } from "./pages/auth.jsx"; import { StageWorkspace } from "./pages/workspace/index.tsx"; -import { firstRunScreen, type HubAuthState } from "./first-run.ts"; +import { firstRunScreen } from "./first-run.ts"; import { ModelSetup, clearModelSetupDeferral, @@ -547,8 +550,9 @@ export function App() { }; const slowHost = useSlowHost(); - const [auth, setAuth] = useState("unknown"); - const [sessionReadFailed, setSessionReadFailed] = useState(false); + const sessionRead = useQuery(sessionQuery); + const auth = sessionAuth(sessionRead); + const user = sessionRead.data?.user ?? null; // A refused read says nothing about what there is, so each query keeps what // it last read. @@ -589,7 +593,8 @@ export function App() { // Auth and "no coordinator yet" are not a dead hub. Only a dropped // connection or a 5xx is offline. const offline = - sessionReadFailed || + (sessionRead.data === undefined && + sessionReadRetryable(sessionRead.failureReason)) || (readFailure !== null && !signedOutByHost && (readStatus === undefined || readStatus >= 500)); @@ -599,7 +604,7 @@ export function App() { (providersRead.isError && providersRead.data === undefined) || (projectsRead.isError && projectsRead.data === undefined); useEffect(() => { - if (signedOutByHost) setAuth("signed-out"); + if (signedOutByHost) queryClient.setQueryData(keys.session, null); }, [signedOutByHost]); // Query keys are not scoped to a person, so the cache belongs to whoever was // signed in. Clearing it also saves the empty cache over the stored one. @@ -609,29 +614,14 @@ export function App() { setSelected(null); }, [auth]); const [setupLater, setSetupLater] = useState(modelSetupDeferred); - const [user, setUser] = useState(null); + // A hub that is down or unreachable is retried and is the loading screen's + // to show; only a session that will never read is a toast. useEffect(() => { - if (auth !== "signed-in") { - setUser(null); - return; - } - let cancelled = false; - const readUser = async () => { - try { - const session = await getHubSession(); - if (!cancelled) setUser(session?.user ?? null); - } catch (cause) { - if (cancelled) return; - // A hub that is down or unreachable is the loading screen's to show, never a toast. - if (cause instanceof ApiFailure && cause.detail.retryable) return; - toast.error(humanFault(cause, wording.auth.accountUnreadable)); - } - }; - void readUser(); - return () => { - cancelled = true; - }; - }, [auth]); + if (sessionRead.error) + toast.error( + humanFault(sessionRead.error, wording.auth.accountUnreadable), + ); + }, [sessionRead.error]); const signOut = async () => { try { await signOutHub(); @@ -639,8 +629,7 @@ export function App() { // The session is ended locally either way; the person hears the hub kept it. toast.error(humanFault(cause, wording.auth.signOutFailed)); } - setUser(null); - setAuth("signed-out"); + queryClient.setQueryData(keys.session, null); }; // Re-reads everything the fold rests on: the first read, every write that @@ -661,22 +650,12 @@ export function App() { setSetupLater(false); setSelected(null); setView("projects"); - void queryClient.resetQueries(); + // Who is signed in is the same in every workspace. + void queryClient.resetQueries({ + predicate: ({ queryKey }) => queryKey[0] !== keys.session[0], + }); }; - useEffect(() => { - const readSession = async () => { - try { - const session = await getHubSession(); - setAuth(session ? "signed-in" : "signed-out"); - } catch (cause) { - if (!(cause instanceof ApiFailure)) throw cause; - setSessionReadFailed(true); - } - }; - void readSession(); - }, []); - // The workspace tenant is created the first time this session signs in; the // fold reads below all scope themselves with it, so they wait for it. useEffect(() => { @@ -778,7 +757,7 @@ export function App() { return ( { - setAuth("signed-in"); + void queryClient.invalidateQueries({ queryKey: keys.session }); }} /> ); @@ -922,7 +901,9 @@ export function App() { + queryClient.setQueryData(keys.session, { user: changed }) + } onSignOut={signOut} {...(settingsTab !== undefined ? { requestedTab: settingsTab } diff --git a/apps/web/src/pages/invite-accept.tsx b/apps/web/src/pages/invite-accept.tsx index 9f3934c7..ecfd4465 100644 --- a/apps/web/src/pages/invite-accept.tsx +++ b/apps/web/src/pages/invite-accept.tsx @@ -9,18 +9,19 @@ */ import { wording } from "../config.ts"; import { useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import { toast } from "sonner"; import { ProductApiError } from "@corbits-builder/client"; import { Banner, Button, Mark, StateLabel } from "../components.jsx"; import { productClient } from "../api/transport.ts"; -import { getHubSession, type HubUser } from "../api/auth.ts"; +import { sessionQuery } from "../queries/session.ts"; import { navigate } from "../routes.ts"; import type { Invitation } from "@corbits-builder/schemas/invitation"; export function InvitationAccept({ invitationId }: { invitationId: string }) { const [invitation, setInvitation] = useState(null); const [roleName, setRoleName] = useState(null); - const [user, setUser] = useState(null); + const user = useQuery(sessionQuery).data?.user ?? null; const [failed, setFailed] = useState(null); const [accepted, setAccepted] = useState(null); const [busy, setBusy] = useState(false); @@ -30,13 +31,9 @@ export function InvitationAccept({ invitationId }: { invitationId: string }) { async function load() { try { const client = productClient(); - const [record, session] = await Promise.all([ - client.invitation(invitationId), - getHubSession(), - ]); + const record = await client.invitation(invitationId); if (cancelled) return; setInvitation(record); - setUser(session?.user ?? null); try { const tenantId = record.tenantId; const roles = await client.tenantRoles(tenantId); diff --git a/apps/web/src/queries/keys.ts b/apps/web/src/queries/keys.ts index 5f961a5e..ca8cacc7 100644 --- a/apps/web/src/queries/keys.ts +++ b/apps/web/src/queries/keys.ts @@ -5,6 +5,8 @@ * instance of that resource. */ export const keys = { + /** Who is signed in, or null when no one is. */ + session: ["session"] as const, workspaces: ["workspaces"] as const, designDraft: { all: ["designDraft"] as const, diff --git a/apps/web/src/queries/session.test.ts b/apps/web/src/queries/session.test.ts new file mode 100644 index 00000000..d9d98dec --- /dev/null +++ b/apps/web/src/queries/session.test.ts @@ -0,0 +1,60 @@ +import { afterEach, expect, test } from "bun:test"; +import { QueryClient, QueryObserver } from "@tanstack/react-query"; +import { sessionAuth, sessionQuery } from "./session.ts"; + +const realFetch = globalThis.fetch; +afterEach(() => { + globalThis.fetch = realFetch; +}); + +function answers(...replies: (() => Response)[]) { + let calls = 0; + globalThis.fetch = Object.assign( + async () => { + const reply = replies[Math.min(calls, replies.length - 1)]; + calls += 1; + if (reply === undefined) throw new Error("no reply"); + return reply(); + }, + { preconnect: realFetch.preconnect }, + ); + return () => calls; +} + +// The session as the interface reads it, once the check has settled. +function settledSession() { + const observer = new QueryObserver(new QueryClient(), { + ...sessionQuery, + retryDelay: 0, + }); + expect(sessionAuth(observer.getCurrentResult())).toBe("unknown"); + return new Promise>( + (resolve) => { + const unsubscribe = observer.subscribe((read) => { + if (read.status === "pending") return; + unsubscribe(); + resolve(read); + }); + }, + ); +} + +test("a first session check made while the hub is down signs in once it answers", async () => { + const calls = answers( + () => { + throw new TypeError("connection refused"); + }, + () => new Response("bad gateway", { status: 502 }), + () => Response.json({ user: { id: "u1", email: "a@example.test" } }), + ); + const read = await settledSession(); + expect(calls()).toBe(3); + expect(sessionAuth(read)).toBe("signed-in"); +}); + +test("a session that will never read leads to signing in, not a stuck boot", async () => { + const calls = answers(() => new Response("bad request", { status: 400 })); + const read = await settledSession(); + expect(calls()).toBe(1); + expect(sessionAuth(read)).toBe("signed-out"); +}); diff --git a/apps/web/src/queries/session.ts b/apps/web/src/queries/session.ts new file mode 100644 index 00000000..ff3ede64 --- /dev/null +++ b/apps/web/src/queries/session.ts @@ -0,0 +1,33 @@ +import { queryOptions } from "@tanstack/react-query"; +import { getHubSession, type HubSession } from "../api/auth.ts"; +import { ApiFailure } from "../api/types.ts"; +import type { HubAuthState } from "../first-run.ts"; +import { keys } from "./keys.ts"; + +/** A hub that is down or unreachable answers later; anything else will not. */ +export function sessionReadRetryable(cause: unknown): boolean { + return cause instanceof ApiFailure && cause.detail.retryable; +} + +/** + * The better-auth session, read once for the whole interface. It is re-read + * until the hub answers, so a first check made while the hub was down still + * settles without a reload. It is never saved: a stale identity from the + * last visit must not open the workspace. + */ +export const sessionQuery = queryOptions({ + queryKey: keys.session, + queryFn: getHubSession, + staleTime: Infinity, + retry: (_failures, cause) => sessionReadRetryable(cause), + meta: { persist: false }, +}); + +/** A session that cannot be read at all leaves only signing in again. */ +export function sessionAuth(read: { + data: HubSession | undefined; + isError: boolean; +}): HubAuthState { + if (read.data !== undefined) return read.data ? "signed-in" : "signed-out"; + return read.isError ? "signed-out" : "unknown"; +} From fd9db474825b75623097d41ed0e32df564dcfbcd Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 6 Nov 2026 13:54:26 -0800 Subject: [PATCH 2/4] refactor(web): simplify the shared session read --- apps/web/src/App.tsx | 32 +++++++++------ apps/web/src/pages/invite-accept.tsx | 12 ++++-- apps/web/src/queries/session.test.ts | 60 ---------------------------- apps/web/src/queries/session.ts | 33 --------------- 4 files changed, 27 insertions(+), 110 deletions(-) delete mode 100644 apps/web/src/queries/session.test.ts delete mode 100644 apps/web/src/queries/session.ts diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 9d85a0ae..777619f7 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -14,11 +14,6 @@ import { humanFault, responseCode } from "./pages/workspace/human-fault.ts"; import { ProductApiError } from "@corbits-builder/client"; import { queryClient } from "./queries/client.ts"; import { keys } from "./queries/keys.ts"; -import { - sessionAuth, - sessionQuery, - sessionReadRetryable, -} from "./queries/session.ts"; import { hostLatency, productClient } from "./api/transport.ts"; import { useQuery } from "@tanstack/react-query"; import { Toaster, toast } from "sonner"; @@ -53,6 +48,7 @@ import { ProjectMenu } from "./pages/project-menu.jsx"; import { ProjectLibrary } from "./pages/project-library.jsx"; import { ensureWorkspace, + getHubSession, openWorkspace, signOutHub, workspaceTenantId, @@ -84,7 +80,7 @@ import { } from "@corbits/react-ui/ui/dialog"; import { Auth } from "./pages/auth.jsx"; import { StageWorkspace } from "./pages/workspace/index.tsx"; -import { firstRunScreen } from "./first-run.ts"; +import { firstRunScreen, type HubAuthState } from "./first-run.ts"; import { ModelSetup, clearModelSetupDeferral, @@ -550,8 +546,20 @@ export function App() { }; const slowHost = useSlowHost(); - const sessionRead = useQuery(sessionQuery); - const auth = sessionAuth(sessionRead); + // Read once and never saved: a stale identity must not open the workspace. + const sessionRead = useQuery({ + queryKey: keys.session, + queryFn: getHubSession, + staleTime: Infinity, + // Only a hub that is down or unreachable answers later. + retry: (_, cause) => cause instanceof ApiFailure && cause.detail.retryable, + meta: { persist: false }, + }); + const auth: HubAuthState = sessionRead.data + ? "signed-in" + : sessionRead.data === null || sessionRead.isError + ? "signed-out" + : "unknown"; const user = sessionRead.data?.user ?? null; // A refused read says nothing about what there is, so each query keeps what @@ -593,8 +601,7 @@ export function App() { // Auth and "no coordinator yet" are not a dead hub. Only a dropped // connection or a 5xx is offline. const offline = - (sessionRead.data === undefined && - sessionReadRetryable(sessionRead.failureReason)) || + (sessionRead.isPending && sessionRead.failureCount > 0) || (readFailure !== null && !signedOutByHost && (readStatus === undefined || readStatus >= 500)); @@ -614,8 +621,7 @@ export function App() { setSelected(null); }, [auth]); const [setupLater, setSetupLater] = useState(modelSetupDeferred); - // A hub that is down or unreachable is retried and is the loading screen's - // to show; only a session that will never read is a toast. + // Retried failures stay on the loading screen; only a final one is a toast. useEffect(() => { if (sessionRead.error) toast.error( @@ -787,7 +793,7 @@ export function App() { if (view === "invite" && urlInvite !== null) { return ( <> - + ); diff --git a/apps/web/src/pages/invite-accept.tsx b/apps/web/src/pages/invite-accept.tsx index ecfd4465..1bab05d7 100644 --- a/apps/web/src/pages/invite-accept.tsx +++ b/apps/web/src/pages/invite-accept.tsx @@ -9,19 +9,23 @@ */ import { wording } from "../config.ts"; import { useEffect, useState } from "react"; -import { useQuery } from "@tanstack/react-query"; import { toast } from "sonner"; import { ProductApiError } from "@corbits-builder/client"; import { Banner, Button, Mark, StateLabel } from "../components.jsx"; import { productClient } from "../api/transport.ts"; -import { sessionQuery } from "../queries/session.ts"; +import type { HubUser } from "../api/auth.ts"; import { navigate } from "../routes.ts"; import type { Invitation } from "@corbits-builder/schemas/invitation"; -export function InvitationAccept({ invitationId }: { invitationId: string }) { +export function InvitationAccept({ + invitationId, + user, +}: { + invitationId: string; + user: HubUser | null; +}) { const [invitation, setInvitation] = useState(null); const [roleName, setRoleName] = useState(null); - const user = useQuery(sessionQuery).data?.user ?? null; const [failed, setFailed] = useState(null); const [accepted, setAccepted] = useState(null); const [busy, setBusy] = useState(false); diff --git a/apps/web/src/queries/session.test.ts b/apps/web/src/queries/session.test.ts deleted file mode 100644 index d9d98dec..00000000 --- a/apps/web/src/queries/session.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { afterEach, expect, test } from "bun:test"; -import { QueryClient, QueryObserver } from "@tanstack/react-query"; -import { sessionAuth, sessionQuery } from "./session.ts"; - -const realFetch = globalThis.fetch; -afterEach(() => { - globalThis.fetch = realFetch; -}); - -function answers(...replies: (() => Response)[]) { - let calls = 0; - globalThis.fetch = Object.assign( - async () => { - const reply = replies[Math.min(calls, replies.length - 1)]; - calls += 1; - if (reply === undefined) throw new Error("no reply"); - return reply(); - }, - { preconnect: realFetch.preconnect }, - ); - return () => calls; -} - -// The session as the interface reads it, once the check has settled. -function settledSession() { - const observer = new QueryObserver(new QueryClient(), { - ...sessionQuery, - retryDelay: 0, - }); - expect(sessionAuth(observer.getCurrentResult())).toBe("unknown"); - return new Promise>( - (resolve) => { - const unsubscribe = observer.subscribe((read) => { - if (read.status === "pending") return; - unsubscribe(); - resolve(read); - }); - }, - ); -} - -test("a first session check made while the hub is down signs in once it answers", async () => { - const calls = answers( - () => { - throw new TypeError("connection refused"); - }, - () => new Response("bad gateway", { status: 502 }), - () => Response.json({ user: { id: "u1", email: "a@example.test" } }), - ); - const read = await settledSession(); - expect(calls()).toBe(3); - expect(sessionAuth(read)).toBe("signed-in"); -}); - -test("a session that will never read leads to signing in, not a stuck boot", async () => { - const calls = answers(() => new Response("bad request", { status: 400 })); - const read = await settledSession(); - expect(calls()).toBe(1); - expect(sessionAuth(read)).toBe("signed-out"); -}); diff --git a/apps/web/src/queries/session.ts b/apps/web/src/queries/session.ts deleted file mode 100644 index ff3ede64..00000000 --- a/apps/web/src/queries/session.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { queryOptions } from "@tanstack/react-query"; -import { getHubSession, type HubSession } from "../api/auth.ts"; -import { ApiFailure } from "../api/types.ts"; -import type { HubAuthState } from "../first-run.ts"; -import { keys } from "./keys.ts"; - -/** A hub that is down or unreachable answers later; anything else will not. */ -export function sessionReadRetryable(cause: unknown): boolean { - return cause instanceof ApiFailure && cause.detail.retryable; -} - -/** - * The better-auth session, read once for the whole interface. It is re-read - * until the hub answers, so a first check made while the hub was down still - * settles without a reload. It is never saved: a stale identity from the - * last visit must not open the workspace. - */ -export const sessionQuery = queryOptions({ - queryKey: keys.session, - queryFn: getHubSession, - staleTime: Infinity, - retry: (_failures, cause) => sessionReadRetryable(cause), - meta: { persist: false }, -}); - -/** A session that cannot be read at all leaves only signing in again. */ -export function sessionAuth(read: { - data: HubSession | undefined; - isError: boolean; -}): HubAuthState { - if (read.data !== undefined) return read.data ? "signed-in" : "signed-out"; - return read.isError ? "signed-out" : "unknown"; -} From 573fdc08a77512572106f57a4133430d4b34f860 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 6 Nov 2026 14:56:12 -0800 Subject: [PATCH 3/4] fix(web): keep the session query when the cache clears on sign-out --- apps/web/src/App.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 777619f7..c33c4423 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -617,7 +617,9 @@ export function App() { // signed in. Clearing it also saves the empty cache over the stored one. useEffect(() => { if (auth !== "signed-out") return; - queryClient.clear(); + queryClient.removeQueries({ + predicate: ({ queryKey }) => queryKey[0] !== keys.session[0], + }); setSelected(null); }, [auth]); const [setupLater, setSetupLater] = useState(modelSetupDeferred); From 332381b9427b9b9e6eba0040d14a2c5c76800ac1 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 6 Nov 2026 15:03:55 -0800 Subject: [PATCH 4/4] refactor(web): replace the nested ternary for the sign-in state --- apps/web/src/App.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index c33c4423..4ec28e33 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -555,11 +555,10 @@ export function App() { retry: (_, cause) => cause instanceof ApiFailure && cause.detail.retryable, meta: { persist: false }, }); - const auth: HubAuthState = sessionRead.data - ? "signed-in" - : sessionRead.data === null || sessionRead.isError - ? "signed-out" - : "unknown"; + let auth: HubAuthState = "unknown"; + if (sessionRead.data) auth = "signed-in"; + else if (sessionRead.data === null || sessionRead.isError) + auth = "signed-out"; const user = sessionRead.data?.user ?? null; // A refused read says nothing about what there is, so each query keeps what