From e392c6919636d9eb377e84adfa5ca24179e77373 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Wed, 10 Jun 2026 15:55:06 -0400 Subject: [PATCH] fix(06-05): make AuthSplash dead-end state reachable + persist redirect guard - CalendarShell now captures maybeRedirectToLogin() return value in meQuery.isError effect - When the one-shot guard is exhausted (returns false), arm loginRedirectExhausted state - Render AuthSplash state=dead-end (tap-to-retry) when guard is exhausted, not indefinite redirecting spinner - Reset loginRedirectExhausted on successful auth (meQuery.isSuccess) for session recovery - Add sessionStorage.clear() to beforeEach so CalendarShell tests are isolated - RED test committed in prior commit (36ef7a0) --- .../pwa/src/components/CalendarShell.test.tsx | 2 ++ apps/pwa/src/components/CalendarShell.tsx | 24 +++++++++++++++---- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/apps/pwa/src/components/CalendarShell.test.tsx b/apps/pwa/src/components/CalendarShell.test.tsx index 33c4497..cd13be9 100644 --- a/apps/pwa/src/components/CalendarShell.test.tsx +++ b/apps/pwa/src/components/CalendarShell.test.tsx @@ -140,6 +140,8 @@ function renderWithClient(ui: React.ReactElement) { describe('CalendarShell — CAL-03 render smoke', () => { beforeEach(() => { vi.clearAllMocks() + // Reset the one-shot redirect guard between tests + sessionStorage.clear() // Default mock responses ;(fetchMe as Mock).mockResolvedValue({ diff --git a/apps/pwa/src/components/CalendarShell.tsx b/apps/pwa/src/components/CalendarShell.tsx index 6cee341..c8346c5 100644 --- a/apps/pwa/src/components/CalendarShell.tsx +++ b/apps/pwa/src/components/CalendarShell.tsx @@ -88,6 +88,10 @@ export function CalendarShell({ onOpenSettings }: { onOpenSettings?: () => void const queryClient = useQueryClient() // Ref to ensure the session-expiry redirect timer fires only once per expiry const sessionExpiredRedirectFired = useRef(false) + // When the one-shot redirect guard is already exhausted (flag set from a prior + // navigation), maybeRedirectToLogin() returns false — arm the dead-end state so + // AuthSplash shows the tap-to-retry recovery instead of spinning forever (D-11). + const [loginRedirectExhausted, setLoginRedirectExhausted] = useState(false) // Fetch current user to build per-member color config const meQuery = useQuery({ @@ -195,19 +199,25 @@ export function CalendarShell({ onOpenSettings }: { onOpenSettings?: () => void // If this is the first failure, maybeRedirectToLogin() sets a sessionStorage // flag and navigates the browser to /api/login (top-level nav, no CORS block). // The page will unmount as the browser navigates. If the flag is already set - // (already bounced through login once and still failing), returns false and the - // existing "Sign-in required" branch below renders. + // (already bounced through login once and still failing), returns false — arm + // loginRedirectExhausted so the dead-end "Tap to try again" branch renders + // instead of spinning indefinitely (D-11 dead-end recovery). useEffect(() => { if (meQuery.isError) { - maybeRedirectToLogin() + const willRedirect = maybeRedirectToLogin() + if (!willRedirect) { + setLoginRedirectExhausted(true) + } } }, [meQuery.isError]) // Clear the one-shot flag on a successful /api/me load so a later session // expiry can trigger another redirect instead of showing "Sign-in required". + // Also reset the dead-end state in case the component is reused after auth recovery. useEffect(() => { if (meQuery.isSuccess) { clearLoginRedirect() + setLoginRedirectExhausted(false) } }, [meQuery.isSuccess]) @@ -257,7 +267,13 @@ export function CalendarShell({ onOpenSettings }: { onOpenSettings?: () => void } if (meQuery.isError) { - // useEffect at line 196 calls maybeRedirectToLogin() — splash shows while redirect fires + // loginRedirectExhausted: the one-shot guard was already set (prior navigation), + // so maybeRedirectToLogin() returned false — show dead-end tap-to-retry (D-11). + // Otherwise the useEffect at line 196 already fired maybeRedirectToLogin() and + // the browser is navigating — show the redirecting splash while it does. + if (loginRedirectExhausted) { + return + } return }