diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index a4f60ced..4ec28e33 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -52,7 +52,6 @@ import { openWorkspace, signOutHub, workspaceTenantId, - type HubUser, } from "./api/auth.ts"; import { WorkspaceMenu } from "./workspace-menu.tsx"; import { Settings, type SettingsTab } from "./pages/settings/settings.jsx"; @@ -547,8 +546,20 @@ export function App() { }; const slowHost = useSlowHost(); - const [auth, setAuth] = useState("unknown"); - const [sessionReadFailed, setSessionReadFailed] = useState(false); + // 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 }, + }); + 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 // it last read. @@ -589,7 +600,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 = - sessionReadFailed || + (sessionRead.isPending && sessionRead.failureCount > 0) || (readFailure !== null && !signedOutByHost && (readStatus === undefined || readStatus >= 500)); @@ -599,39 +610,25 @@ 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. 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); - const [user, setUser] = useState(null); + // Retried failures stay on the loading screen; only a final one 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 +636,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 +657,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 +764,7 @@ export function App() { return ( { - setAuth("signed-in"); + void queryClient.invalidateQueries({ queryKey: keys.session }); }} /> ); @@ -808,7 +794,7 @@ export function App() { if (view === "invite" && urlInvite !== null) { return ( <> - + ); @@ -922,7 +908,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..1bab05d7 100644 --- a/apps/web/src/pages/invite-accept.tsx +++ b/apps/web/src/pages/invite-accept.tsx @@ -13,14 +13,19 @@ 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 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, setUser] = useState(null); const [failed, setFailed] = useState(null); const [accepted, setAccepted] = useState(null); const [busy, setBusy] = useState(false); @@ -30,13 +35,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,