diff --git a/.changeset/studio-navigation-speed.md b/.changeset/studio-navigation-speed.md new file mode 100644 index 00000000..32d4e24d --- /dev/null +++ b/.changeset/studio-navigation-speed.md @@ -0,0 +1,5 @@ +--- +"@cloudhal/cli": patch +--- + +Studio switches screens faster: the signed-in session is kept between navigations (asked again after five minutes, on sign-out, on revoking sessions and on any 401) instead of being checked with the server on every click, and the pending screen shows after 150 ms instead of the router's one second, so a short wait no longer looks frozen. diff --git a/apps/studio/src/lib/api/http.ts b/apps/studio/src/lib/api/http.ts index bd6a269e..411dc7e8 100644 --- a/apps/studio/src/lib/api/http.ts +++ b/apps/studio/src/lib/api/http.ts @@ -1,4 +1,5 @@ /** The fetch core of the Studio API client: `request`, its error type and the URL/body helpers. */ +import { forgetSession } from "../session-cache.ts"; export interface ApiError { error: string; @@ -27,6 +28,8 @@ export async function request(path: string, init: RequestInit = {}): Promise< const body = (await res .json() .catch(() => ({ error: "invalid_json", message: res.statusText }))) as T | ApiError; + // The session ended on the server: the next navigation asks again (and lands on the login). + if (res.status === 401) forgetSession(); if (!res.ok) throw new ApiRequestError(res.status, body as ApiError); return body as T; } diff --git a/apps/studio/src/lib/auth.ts b/apps/studio/src/lib/auth.ts index 5459a096..609c32f0 100644 --- a/apps/studio/src/lib/auth.ts +++ b/apps/studio/src/lib/auth.ts @@ -1,8 +1,20 @@ import { adminClient, organizationClient } from "better-auth/client/plugins"; import { createAuthClient } from "better-auth/react"; +import { forgetSession } from "./session-cache.ts"; -/** Same-origin (Vite proxies /api in dev; `cloudhal studio` serves both in prod), so no baseURL. */ -export const authClient = createAuthClient({ plugins: [organizationClient(), adminClient()] }); +/** + * Same-origin (Vite proxies /api in dev; `cloudhal studio` serves both in prod), so no baseURL. + * Any auth call but the session check itself (sign-in, sign-out, revoking sessions, a new + * password) may change the session, so the one kept between navigations is forgotten. + */ +export const authClient = createAuthClient({ + plugins: [organizationClient(), adminClient()], + fetchOptions: { + onResponse: ({ request }) => { + if (!String(request.url).includes("/get-session")) forgetSession(); + }, + }, +}); /** A Better Auth call that failed; `code` is Better Auth's error code (e.g. `INVALID_PASSWORD`). */ export class AuthRequestError extends Error { diff --git a/apps/studio/src/lib/session-cache.ts b/apps/studio/src/lib/session-cache.ts new file mode 100644 index 00000000..044d255c --- /dev/null +++ b/apps/studio/src/lib/session-cache.ts @@ -0,0 +1,38 @@ +/** + * The signed-in session, kept between navigations. Every protected route checks it in + * `beforeLoad`, and asking the server each time cost 240–640 ms before a screen could start + * (the URL changed, the screen did not). A session that was there a moment ago still is: it is + * reused for `MAX_AGE_MS`, and forgotten on sign-out, on revoking sessions and on any 401 from + * the API, so the next navigation asks again and an expired session still lands on the login. + */ +const MAX_AGE_MS = 5 * 60_000; + +let kept: { at: number; session: Promise } | undefined; + +/** + * The session from `load`, or the one kept from a recent call. Concurrent callers share one + * request; a failed or empty answer is never kept. + */ +export function cachedSession( + load: () => Promise, + now = Date.now(), +): Promise { + if (kept && now - kept.at < MAX_AGE_MS) return kept.session as Promise; + const session = load().then( + (value) => { + if (value == null && kept?.session === session) kept = undefined; + return value; + }, + (error: unknown) => { + if (kept?.session === session) kept = undefined; + throw error; + }, + ); + kept = { at: now, session }; + return session; +} + +/** Ask the server again on the next navigation (sign-out, revoked sessions, a 401). */ +export function forgetSession(): void { + kept = undefined; +} diff --git a/apps/studio/src/router.tsx b/apps/studio/src/router.tsx index b6387761..39e0e42c 100644 --- a/apps/studio/src/router.tsx +++ b/apps/studio/src/router.tsx @@ -13,6 +13,7 @@ import { type AnalyticsSearch, validateAnalyticsSearch } from "./lib/analytics-s import { api } from "./lib/api.ts"; import { authClient } from "./lib/auth.ts"; import { isQueueTab, type QueueTab } from "./lib/comments-shell.ts"; +import { cachedSession } from "./lib/session-cache.ts"; import { AccountShell } from "./routes/AccountShell.tsx"; import { SiteShell } from "./routes/SiteShell.tsx"; @@ -153,11 +154,18 @@ export interface RouterContext { } async function requireSession() { - const { data, error } = await authClient.getSession(); + // Kept between navigations (lib/session-cache.ts): asking each time held every screen back. + const last: { status?: number } = {}; + const data = await cachedSession(async () => { + const r = await authClient.getSession(); + if (r.error) last.status = r.error.status; + return r.data; + }); if (data) return data; // RFC-0036 §5: a playground has no sign-in (its API refuses /api/auth with 403) and answers // every call as its one fixed principal. - if (error?.status === 403 && (await isPlayground())) return null; + // (A caller that shared another's request has no status of its own: it asks too.) + if ((last.status ?? 403) === 403 && (await isPlayground())) return null; throw redirect({ to: "/login" }); } @@ -498,6 +506,10 @@ export function createStudioRouter(queryClient: QueryClient) { routeTree, context: { queryClient }, defaultPreload: "intent", + // The router's own default waits a second before showing the pending screen, so a short wait + // looked frozen: the URL had changed and nothing else had. + defaultPendingMs: 150, + defaultPendingMinMs: 300, defaultPendingComponent: RoutePending, defaultErrorComponent: RouteError, }); diff --git a/apps/studio/test/session-cache.test.ts b/apps/studio/test/session-cache.test.ts new file mode 100644 index 00000000..b90e9542 --- /dev/null +++ b/apps/studio/test/session-cache.test.ts @@ -0,0 +1,33 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { cachedSession, forgetSession } from "../src/lib/session-cache.ts"; + +describe("cachedSession (a navigation does not wait for the server's session check)", () => { + afterEach(() => forgetSession()); + + it("asks once, shares a request in flight, and reuses the answer for five minutes", async () => { + let calls = 0; + const load = async () => { + calls++; + return { user: "ada" }; + }; + const [a, b] = await Promise.all([cachedSession(load, 0), cachedSession(load, 0)]); + expect(a).toEqual({ user: "ada" }); + expect(b).toBe(a); + await cachedSession(load, 4 * 60_000); + expect(calls).toBe(1); + await cachedSession(load, 6 * 60_000); + expect(calls).toBe(2); + }); + + it("never keeps an empty or failed answer, and forgets on request", async () => { + let calls = 0; + expect(await cachedSession(async () => (calls++, null), 0)).toBeNull(); + await expect( + cachedSession(async () => (calls++, Promise.reject(new Error("x"))), 0), + ).rejects.toThrow(); + await cachedSession(async () => (calls++, { user: "ada" }), 0); + forgetSession(); + await cachedSession(async () => (calls++, { user: "ada" }), 0); + expect(calls).toBe(4); + }); +});