From 9962c43149902c612fdeb4227460132d2171d2df Mon Sep 17 00:00:00 2001 From: Danial Beg Date: Thu, 1 Oct 2026 16:33:15 -0700 Subject: [PATCH 1/2] Say something while the app is loading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening the app showed an empty parchment rectangle until sign-in resolved, which on a cold load takes several seconds. Nothing on screen said it was working, so it read as broken, and the reliable fix was to reload — which works, because the second load has a warm token. The app was teaching people to refresh it. The loading screen had no content: the right background, the grain overlay, and nothing else. It now has a spinner and a line of text, after a 400ms pause so a fast load still shows nothing — a spinner flashed for 200ms reads as jank rather than speed. After nine seconds it says it is taking too long and offers the reload people were reaching for anyway, which is the error state PRODUCTION-TODO #10 asked for. Profile attempts also get 2.5s, 4s, then 6s rather than a flat 6s. A transient failure is likelier than a genuinely slow query, and the old first attempt cost nearly seven seconds before the retry began. What that second change is not: a fix for the cold-start wait. I first wrote it as one, claiming the initial fetch raced Supabase's token rotation. Reading auth-js 2.101.1 says otherwise — __loadSession refreshes an expired session itself, and onAuthStateChange holds the init lock until it has, so INITIAL_SESSION only ever reaches us with a valid token. The seconds go on that refresh round-trip, before any of our code runs, and nothing here can shorten it. Only make it legible, which is what this does. --- web/src/App.tsx | 5 +- web/src/components/LoadingScreen.test.tsx | 39 +++++++++++++++ web/src/components/LoadingScreen.tsx | 60 +++++++++++++++++++++++ web/src/contexts/AuthContext.tsx | 19 ++++++- web/src/index.css | 31 ++++++++++++ 5 files changed, 151 insertions(+), 3 deletions(-) create mode 100644 web/src/components/LoadingScreen.test.tsx create mode 100644 web/src/components/LoadingScreen.tsx diff --git a/web/src/App.tsx b/web/src/App.tsx index e513e03..d4214c4 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,5 +1,5 @@ import { useState, useCallback, useEffect, useMemo, useRef } from 'react' -import { motion, AnimatePresence } from 'framer-motion' +import { AnimatePresence } from 'framer-motion' import { useAuth } from './contexts/AuthContext' import { saveOnboardingData } from './lib/profiles' import AuthScreen from './components/AuthScreen' @@ -14,6 +14,7 @@ import { signOut } from './lib/auth' import { currentGradeStartIdx } from './lib/currentGrade' import { useToast } from './contexts/ToastContext' import ResetPasswordScreen from './components/ResetPasswordScreen' +import LoadingScreen from './components/LoadingScreen' import { resolvePreferences } from './lib/preferences' import { useThemePref } from './lib/theme' import { ApplicationsProvider } from './contexts/ApplicationsContext' @@ -133,7 +134,7 @@ export default function App() { if (recovering && user) return switch (screen) { case 'loading': - return
+ return case 'auth': return case 'welcome-back': diff --git a/web/src/components/LoadingScreen.test.tsx b/web/src/components/LoadingScreen.test.tsx new file mode 100644 index 0000000..b3ed364 --- /dev/null +++ b/web/src/components/LoadingScreen.test.tsx @@ -0,0 +1,39 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { render, screen, act } from '@testing-library/react' +import LoadingScreen from './LoadingScreen' + +const advance = (ms: number) => act(() => { vi.advanceTimersByTime(ms) }) + +describe('LoadingScreen', () => { + afterEach(() => vi.useRealTimers()) + + it('shows nothing at all for a load that resolves quickly', () => { + vi.useFakeTimers() + render() + // A spinner flashed for 200ms reads as jank, not as speed. + advance(300) + expect(screen.queryByText(/Loading your dashboard/)).not.toBeInTheDocument() + }) + + it('says it is working once the wait is noticeable', () => { + vi.useFakeTimers() + render() + advance(500) + expect(screen.getByText('Loading your dashboard…')).toBeInTheDocument() + }) + + it('admits something is wrong, and offers the reload people were already doing', () => { + vi.useFakeTimers() + render() + advance(9500) + expect(screen.getByText('Still loading…')).toBeInTheDocument() + expect(screen.getByText(/connection may have dropped/)).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Reload' })).toBeInTheDocument() + }) + + it('is announced, so it is not silence to a screen reader either', () => { + vi.useFakeTimers() + render() + expect(screen.getByRole('status')).toHaveAttribute('aria-live', 'polite') + }) +}) diff --git a/web/src/components/LoadingScreen.tsx b/web/src/components/LoadingScreen.tsx new file mode 100644 index 0000000..d7203a7 --- /dev/null +++ b/web/src/components/LoadingScreen.tsx @@ -0,0 +1,60 @@ +/** + * LoadingScreen — what the app shows before it knows who you are. + * + * This used to be an empty parchment rectangle: the right background, the + * grain overlay, and nothing else. On a cold load the profile fetch can take + * several seconds (its first attempt races Supabase's own token rotation and + * times out, and only the retry succeeds), and for all of it the page looked + * broken rather than busy. People refreshed, which worked — the second load + * has a warm token — and so the app taught them to refresh it. + * + * Two rules here: + * + * The spinner waits a moment before appearing. A load that resolves in 200ms + * should not flash a spinner at anyone; that reads as jank, not speed. + * + * A load that is taking far too long says so, and offers the reload that + * people were doing anyway. Staring at a spinner with no end is the same + * failure as staring at a blank page, one step along. + */ + +import { useEffect, useState } from 'react' + +/** Long enough that a fast load shows nothing at all. */ +const SPINNER_AFTER_MS = 400 +/** By here something is wrong: the profile fetch has had a timeout and a retry. */ +const STUCK_AFTER_MS = 9000 + +export default function LoadingScreen() { + const [phase, setPhase] = useState<'quiet' | 'busy' | 'stuck'>('quiet') + + useEffect(() => { + const busy = setTimeout(() => setPhase('busy'), SPINNER_AFTER_MS) + const stuck = setTimeout(() => setPhase('stuck'), STUCK_AFTER_MS) + return () => { clearTimeout(busy); clearTimeout(stuck) } + }, []) + + return ( +
+
+ {phase !== 'quiet' && ( +
+
+ )} +
+ ) +} diff --git a/web/src/contexts/AuthContext.tsx b/web/src/contexts/AuthContext.tsx index d4688d7..ed3f19b 100644 --- a/web/src/contexts/AuthContext.tsx +++ b/web/src/contexts/AuthContext.tsx @@ -37,6 +37,12 @@ export function useAuth() { return useContext(AuthContext) } +/** + * How long each profile attempt gets. Short first, then patient — see + * fetchProfile. + */ +const ATTEMPT_TIMEOUTS_MS = [2500, 4000, 6000, 6000, 6000] + /** Reject after `ms` so a stalled promise can't hang an awaited caller. */ function withTimeout(p: Promise, ms: number): Promise { return new Promise((resolve, reject) => { @@ -95,7 +101,18 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { try { // Bound each attempt: this fetch gates the loading screen, so a stalled // Supabase call must not hang it. null = a genuine "no row". - const p = await withTimeout(getProfile(uid), 6000) + // + // Short first, then patient. A transient failure is far more likely + // than a genuinely slow query, and a flat 6s made the first one cost + // nearly seven seconds before the retry even began. + // + // Note this is NOT where a cold load spends its time: auth-js refreshes + // an expired session inside __loadSession and holds the init lock until + // it has, so INITIAL_SESSION only reaches us with a valid token and + // this fetch never races the rotation. The cold-start wait is that + // refresh round-trip, which happens before any of our code runs and + // which nothing here can shorten. + const p = await withTimeout(getProfile(uid), ATTEMPT_TIMEOUTS_MS[attempt]) setProfile(p) setProfileReady(true) return true diff --git a/web/src/index.css b/web/src/index.css index a6b3cdd..dfe54ac 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -979,6 +979,37 @@ button { to { transform: rotate(360deg); } } +/* ---- the screen before we know who you are ---- + It was the background and nothing else, so a slow profile fetch looked like + a broken page rather than a loading one. */ +.ld-box { + position: relative; z-index: 1; + display: flex; flex-direction: column; align-items: center; gap: 12px; + padding: 0 24px; max-width: 30rem; text-align: center; +} +.ld-spinner { + width: 26px; height: 26px; border-radius: 50%; + border: 2.5px solid var(--border-str); border-top-color: var(--c-go); + animation: module-spin 0.7s linear infinite; +} +.ld-text { + margin: 0; font-family: var(--font-body); + font-size: 14px; color: var(--text-muted); +} +.ld-note { + margin: 0; font-family: var(--font-body); + font-size: 13px; line-height: 1.5; color: var(--text-muted); +} +.ld-retry { + margin-top: 2px; padding: 8px 18px; border-radius: 9px; cursor: pointer; + border: 1px solid var(--border-str); background: var(--card-bg); + font-family: var(--font-body); font-size: 13px; font-weight: 600; color: var(--text); +} +.ld-retry:hover { border-color: var(--text-muted); } +@media (prefers-reduced-motion: reduce) { + .ld-spinner { animation-duration: 2.4s; } +} + /* Application Status overview — school rows, shared-task cards, timeline. */ .ast-grid { display: grid; From 4ad7b70fde6aa80e3c69c8e6310e8af7f14edf1c Mon Sep 17 00:00:00 2001 From: Danial Beg Date: Thu, 1 Oct 2026 16:47:21 -0700 Subject: [PATCH 2/2] Measure where a cold load actually goes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The claim is that the wait is auth-js swapping a refresh token for a live one before it hands us a session. That is read off its source, not measured on a real connection, and the difference matters: if it is right there is nothing to optimise here, and if it is wrong we have been looking in the wrong place. Three marks — the app starting, the auth listener firing, the profile landing — and one console line splitting the total between them. The first figure is everything before our code runs; the second is our own query, with which attempt won. Off unless asked for: any dev server, or `?timing` on the URL so a deployed preview can be checked without a build. Nothing leaves the browser. --- web/src/contexts/AuthContext.tsx | 5 ++++ web/src/lib/bootTiming.ts | 47 ++++++++++++++++++++++++++++++++ web/src/main.tsx | 5 ++++ 3 files changed, 57 insertions(+) create mode 100644 web/src/lib/bootTiming.ts diff --git a/web/src/contexts/AuthContext.tsx b/web/src/contexts/AuthContext.tsx index ed3f19b..e1862a3 100644 --- a/web/src/contexts/AuthContext.tsx +++ b/web/src/contexts/AuthContext.tsx @@ -1,6 +1,7 @@ import { createContext, useContext, useEffect, useState, useCallback, useRef } from 'react' import type { User } from '@supabase/supabase-js' import { supabase } from '../lib/supabase' +import { markBoot } from '../lib/bootTiming' import { getProfile } from '../lib/profiles' import type { UserProfile } from '../types/user' @@ -115,6 +116,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const p = await withTimeout(getProfile(uid), ATTEMPT_TIMEOUTS_MS[attempt]) setProfile(p) setProfileReady(true) + markBoot('profile-ready', attempt === 0 ? 'first try' : `try ${attempt + 1}`) return true } catch { // The profile row always exists (created by a trigger on signup), so a @@ -140,6 +142,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const { data: { subscription } } = supabase.auth.onAuthStateChange( async (event, session) => { listenerFired.current = true + // Everything before this point is auth-js: the client booting and, on + // a cold load, swapping the refresh token for a live one. + markBoot('auth-ready') const u = session?.user ?? null // PASSWORD_RECOVERY fires once, but the session it accompanies is // persisted. Holding the flag in memory alone meant a single reload diff --git a/web/src/lib/bootTiming.ts b/web/src/lib/bootTiming.ts new file mode 100644 index 0000000..e16f51c --- /dev/null +++ b/web/src/lib/bootTiming.ts @@ -0,0 +1,47 @@ +/** + * Where a cold load actually spends its time. + * + * The app shows a loading screen until sign-in resolves, and on a first visit + * that takes seconds. Reading auth-js says the bulk of it is the refresh + * round-trip it does before handing us a session — but that is inference from + * its source, not a measurement of this app on a real connection. + * + * This measures it. Off unless asked for: `?timing` on the URL, or any dev + * server. Nothing is sent anywhere; it prints one line to the console. + */ + +type Mark = 'app-start' | 'auth-ready' | 'profile-ready' + +const marks = new Map() +let reported = false + +function enabled(): boolean { + if (typeof window === 'undefined') return false + if (import.meta.env.DEV) return true + try { return new URLSearchParams(window.location.search).has('timing') } + catch { return false } +} + +export function markBoot(name: Mark, detail?: string) { + if (!enabled() || marks.has(name)) return + marks.set(name, performance.now()) + if (name === 'profile-ready') report(detail) +} + +function report(detail?: string) { + if (reported) return + reported = true + const start = marks.get('app-start') ?? 0 + const auth = marks.get('auth-ready') + const profile = marks.get('profile-ready') + if (auth == null || profile == null) return + const ms = (n: number) => `${Math.round(n)}ms` + // The first figure is everything before our code runs: the client booting + // and, on a cold load, swapping the refresh token for a live one. The second + // is our own profile query. If the wait is the round-trip, the first number + // is nearly all of it. + console.info( + `[edvifi boot] session ${ms(auth - start)} · profile ${ms(profile - auth)}` + + `${detail ? ` (${detail})` : ''} · total ${ms(profile - start)}`, + ) +} diff --git a/web/src/main.tsx b/web/src/main.tsx index 57fdaa1..05b699a 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -6,6 +6,11 @@ import { ToastProvider } from './contexts/ToastContext' import App from './App.tsx' import { initErrorTracking } from './lib/errorTracking' +import { markBoot } from './lib/bootTiming' + +// Before anything renders, so the session figure includes the client booting. +markBoot('app-start') + // Before render, so a crash during the first paint is still caught. initErrorTracking()