// Per-tab session state for the currently logged-in user. // // Only mutated client-side (onMount / form submits) so the module-level // instance can't leak across SSR requests — SSR always renders the // `loaded === false` state, and the client refreshes after hydration. // // IMPORTANT: do not call any `api/*` helper from `+page.server.ts` / // `+layout.server.ts`. The `setOn401Hook` below is registered at // module load (gated on `browser`, so it only fires in the client // bundle), so a 401 from a server-side fetch would mutate this // module-level `session.user` across SvelteKit requests — a real // cross-request state leak. The `if (browser)` guard makes that // failure mode mechanical rather than convention-based. import { browser } from '$app/environment'; import { setOn401Hook } from './api/client'; import { me, type User } from './api/auth'; class SessionStore { user = $state(null); loaded = $state(false); // Bumped on every explicit setUser so an in-flight refresh started before // a login/logout can't clobber the fresh state when it resolves. private seq = 0; async refresh(): Promise { const seq = this.seq; try { const u = await me(); if (seq === this.seq) this.user = u; } finally { this.loaded = true; } } setUser(user: User | null): void { this.seq++; this.user = user; this.loaded = true; } } export const session = new SessionStore(); // When any backend call returns 401, drop the cached user. Before this // hook, the `*OrEmpty` wrappers silently returned empty pages on 401 // — so a mid-session expiry left the UI rendering as "logged in but // no bookmarks/collections/etc." until the user manually reloaded. // With the hook the session.user reactive store flips to null on the // first 401, so the layout re-renders the login affordance. // // Gated on `browser` so it's only installed in the client bundle. // See the module-level comment above for the SSR rationale. if (browser) { setOn401Hook(() => session.setUser(null)); }