From 139ef00ed45470588b6bf4c03b4357a1d762e5f3 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Wed, 10 Jun 2026 11:32:45 -0400 Subject: [PATCH] feat(06-05): global session-expiry interstitial via QueryCache/MutationCache onError MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add sessionExpired boolean + setSessionExpired action to Zustand calendarStore - main.tsx: construct QueryClient with QueryCache+MutationCache onError (v5 pattern, not defaultOptions.onError) - onGlobalError: checks instanceof SessionExpiredError, calls setSessionExpired(true) via store.getState() - CalendarShell: read sessionExpired from store; render AuthSplash(state=redirecting, 'Session expired', 'Signing you back in…') - CalendarShell: useEffect fires clearLoginRedirect + maybeRedirectToLogin after 1.5s when sessionExpired (one-shot guard re-armed) - Context7 /tanstack/query confirmed v5 QueryCache/MutationCache constructor + onError signature --- apps/pwa/src/components/CalendarShell.tsx | 39 ++++++++++++++++++++++- apps/pwa/src/main.tsx | 23 ++++++++++++- apps/pwa/src/store/calendarStore.ts | 19 +++++++++++ 3 files changed, 79 insertions(+), 2 deletions(-) diff --git a/apps/pwa/src/components/CalendarShell.tsx b/apps/pwa/src/components/CalendarShell.tsx index fea479b..6cee341 100644 --- a/apps/pwa/src/components/CalendarShell.tsx +++ b/apps/pwa/src/components/CalendarShell.tsx @@ -28,7 +28,7 @@ * success + events → ScheduleXCalendar */ -import { useState, useEffect, useMemo } from 'react' +import { useState, useEffect, useMemo, useRef } from 'react' import { useQuery, useQueryClient } from '@tanstack/react-query' import { ScheduleXCalendar, useCalendarApp } from '@schedule-x/react' import { @@ -83,8 +83,11 @@ export function CalendarShell({ onOpenSettings }: { onOpenSettings?: () => void const selectedView = useCalendarStore((s) => s.selectedView) const setEventForm = useCalendarStore((s) => s.setEventForm) const eventFormOpen = useCalendarStore((s) => s.eventFormOpen) + const sessionExpired = useCalendarStore((s) => s.sessionExpired) const { start, end } = calendarRange const queryClient = useQueryClient() + // Ref to ensure the session-expiry redirect timer fires only once per expiry + const sessionExpiredRedirectFired = useRef(false) // Fetch current user to build per-member color config const meQuery = useQuery({ @@ -208,6 +211,26 @@ export function CalendarShell({ onOpenSettings }: { onOpenSettings?: () => void } }, [meQuery.isSuccess]) + // Session-expiry redirect (D-11) — fires when a mid-use 401 is detected by the + // global QueryCache/MutationCache handler and arms the Zustand sessionExpired flag. + // Re-arms the one-shot guard (clearLoginRedirect) so maybeRedirectToLogin fires + // afresh, then schedules the top-level navigation after ~1.5s (UI-SPEC §Surface 2 + // "≤2s before redirect, no dismiss button"). + useEffect(() => { + if (!sessionExpired) return + if (sessionExpiredRedirectFired.current) return + sessionExpiredRedirectFired.current = true + + clearLoginRedirect() + const timer = setTimeout(() => { + maybeRedirectToLogin() + }, 1500) + + return () => { + clearTimeout(timer) + } + }, [sessionExpired]) + // ── Render helpers ──────────────────────────────────────────────────────── // Determine which content to show in the calendar area. @@ -238,6 +261,20 @@ export function CalendarShell({ onOpenSettings }: { onOpenSettings?: () => void return } + // ── Session-expiry interstitial (D-11) ───────────────────────────────────── + // When a mid-use query or mutation returns 401, the global QueryCache/MutationCache + // handler sets sessionExpired=true. Show the "Session expired" interstitial while the + // 1.5s timer fires (wired above in the sessionExpired useEffect). + if (sessionExpired) { + return ( + + ) + } + // ── Calendar content ─────────────────────────────────────────────────────── // The content panel (right of sidebar on desktop, full-width on phone). diff --git a/apps/pwa/src/main.tsx b/apps/pwa/src/main.tsx index cd42445..dd5c0ab 100644 --- a/apps/pwa/src/main.tsx +++ b/apps/pwa/src/main.tsx @@ -10,11 +10,32 @@ import './styles/index.css' import React from 'react' import ReactDOM from 'react-dom/client' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { QueryClient, QueryClientProvider, QueryCache, MutationCache } from '@tanstack/react-query' import App from './App.js' import { ErrorBoundary } from './components/ErrorBoundary.js' +import { SessionExpiredError } from './api/client.js' +import { useCalendarStore } from './store/calendarStore.js' + +/** + * Global session-expiry error handler (D-11, Plan 06-05). + * + * When any query or mutation throws a SessionExpiredError, arm the interstitial + * via the Zustand store's imperative getter — safe to call outside React since + * Zustand's create() returns a store with a .getState() API. + * + * QueryCache and MutationCache onError callbacks are the correct v5 pattern for + * global error handling (NOT defaultOptions.onError, which was removed in v5). + * Confirmed via Context7 /tanstack/query docs for v5.101.0. + */ +function onGlobalError(error: unknown): void { + if (error instanceof SessionExpiredError) { + useCalendarStore.getState().setSessionExpired(true) + } +} const queryClient = new QueryClient({ + queryCache: new QueryCache({ onError: onGlobalError }), + mutationCache: new MutationCache({ onError: onGlobalError }), defaultOptions: { queries: { retry: 1, diff --git a/apps/pwa/src/store/calendarStore.ts b/apps/pwa/src/store/calendarStore.ts index 7c07dfb..34fa668 100644 --- a/apps/pwa/src/store/calendarStore.ts +++ b/apps/pwa/src/store/calendarStore.ts @@ -50,6 +50,12 @@ export interface CalendarStore { /** UID of the most recently enqueued write. SyncStateToast polls sync-status for this. */ lastSyncedUid: string | null + // ── Session-expiry flag (Plan 06-05 / D-11) ───────────────────────────── + // Set to true by the global QueryCache/MutationCache onError handler in main.tsx + // when a SessionExpiredError is detected from any query or mutation. + // Drives the "Session expired / Signing you back in…" interstitial in CalendarShell. + sessionExpired: boolean + setSelectedView: (view: string) => void setSelectedDate: (date: string) => void setOpenEventId: (id: string | null) => void @@ -77,6 +83,14 @@ export interface CalendarStore { * Pass null to dismiss the toast. */ setLastSyncedUid: (uid: string | null) => void + + /** + * Arm the session-expiry interstitial (D-11). + * Called imperatively from the global QueryCache/MutationCache onError handler + * (outside React, via getState().setSessionExpired) when a SessionExpiredError + * is caught from any query or mutation. + */ + setSessionExpired: (expired: boolean) => void } // ── Helpers ──────────────────────────────────────────────────────────────── @@ -141,6 +155,9 @@ export const useCalendarStore = create((set) => ({ deleteDialogUid: null, lastSyncedUid: null, + // Session-expiry flag — false by default; set by global QueryCache/MutationCache handler + sessionExpired: false, + setSelectedView: (view: string) => { set({ selectedView: view }) // Persist to localStorage keyed by breakpoint group @@ -166,4 +183,6 @@ export const useCalendarStore = create((set) => ({ set({ deleteDialogOpen: open, deleteDialogUid: uid }), setLastSyncedUid: (uid: string | null) => set({ lastSyncedUid: uid }), + + setSessionExpired: (expired: boolean) => set({ sessionExpired: expired }), }))