feat(06-05): global session-expiry interstitial via QueryCache/MutationCache onError

- 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
This commit is contained in:
Lucas Berger
2026-06-10 11:32:45 -04:00
parent e7b34a5ce2
commit 139ef00ed4
3 changed files with 79 additions and 2 deletions
+38 -1
View File
@@ -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 <AuthSplash state="redirecting" />
}
// ── 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 (
<AuthSplash
state="redirecting"
heading="Session expired"
body="Signing you back in…"
/>
)
}
// ── Calendar content ───────────────────────────────────────────────────────
// The content panel (right of sidebar on desktop, full-width on phone).