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:
@@ -28,7 +28,7 @@
|
|||||||
* success + events → ScheduleXCalendar
|
* success + events → ScheduleXCalendar
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect, useMemo } from 'react'
|
import { useState, useEffect, useMemo, useRef } from 'react'
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { ScheduleXCalendar, useCalendarApp } from '@schedule-x/react'
|
import { ScheduleXCalendar, useCalendarApp } from '@schedule-x/react'
|
||||||
import {
|
import {
|
||||||
@@ -83,8 +83,11 @@ export function CalendarShell({ onOpenSettings }: { onOpenSettings?: () => void
|
|||||||
const selectedView = useCalendarStore((s) => s.selectedView)
|
const selectedView = useCalendarStore((s) => s.selectedView)
|
||||||
const setEventForm = useCalendarStore((s) => s.setEventForm)
|
const setEventForm = useCalendarStore((s) => s.setEventForm)
|
||||||
const eventFormOpen = useCalendarStore((s) => s.eventFormOpen)
|
const eventFormOpen = useCalendarStore((s) => s.eventFormOpen)
|
||||||
|
const sessionExpired = useCalendarStore((s) => s.sessionExpired)
|
||||||
const { start, end } = calendarRange
|
const { start, end } = calendarRange
|
||||||
const queryClient = useQueryClient()
|
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
|
// Fetch current user to build per-member color config
|
||||||
const meQuery = useQuery({
|
const meQuery = useQuery({
|
||||||
@@ -208,6 +211,26 @@ export function CalendarShell({ onOpenSettings }: { onOpenSettings?: () => void
|
|||||||
}
|
}
|
||||||
}, [meQuery.isSuccess])
|
}, [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 ────────────────────────────────────────────────────────
|
// ── Render helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Determine which content to show in the calendar area.
|
// Determine which content to show in the calendar area.
|
||||||
@@ -238,6 +261,20 @@ export function CalendarShell({ onOpenSettings }: { onOpenSettings?: () => void
|
|||||||
return <AuthSplash state="redirecting" />
|
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 ───────────────────────────────────────────────────────
|
// ── Calendar content ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
// The content panel (right of sidebar on desktop, full-width on phone).
|
// The content panel (right of sidebar on desktop, full-width on phone).
|
||||||
|
|||||||
+22
-1
@@ -10,11 +10,32 @@ import './styles/index.css'
|
|||||||
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import ReactDOM from 'react-dom/client'
|
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 App from './App.js'
|
||||||
import { ErrorBoundary } from './components/ErrorBoundary.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({
|
const queryClient = new QueryClient({
|
||||||
|
queryCache: new QueryCache({ onError: onGlobalError }),
|
||||||
|
mutationCache: new MutationCache({ onError: onGlobalError }),
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
retry: 1,
|
retry: 1,
|
||||||
|
|||||||
@@ -50,6 +50,12 @@ export interface CalendarStore {
|
|||||||
/** UID of the most recently enqueued write. SyncStateToast polls sync-status for this. */
|
/** UID of the most recently enqueued write. SyncStateToast polls sync-status for this. */
|
||||||
lastSyncedUid: string | null
|
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
|
setSelectedView: (view: string) => void
|
||||||
setSelectedDate: (date: string) => void
|
setSelectedDate: (date: string) => void
|
||||||
setOpenEventId: (id: string | null) => void
|
setOpenEventId: (id: string | null) => void
|
||||||
@@ -77,6 +83,14 @@ export interface CalendarStore {
|
|||||||
* Pass null to dismiss the toast.
|
* Pass null to dismiss the toast.
|
||||||
*/
|
*/
|
||||||
setLastSyncedUid: (uid: string | null) => void
|
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 ────────────────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
@@ -141,6 +155,9 @@ export const useCalendarStore = create<CalendarStore>((set) => ({
|
|||||||
deleteDialogUid: null,
|
deleteDialogUid: null,
|
||||||
lastSyncedUid: null,
|
lastSyncedUid: null,
|
||||||
|
|
||||||
|
// Session-expiry flag — false by default; set by global QueryCache/MutationCache handler
|
||||||
|
sessionExpired: false,
|
||||||
|
|
||||||
setSelectedView: (view: string) => {
|
setSelectedView: (view: string) => {
|
||||||
set({ selectedView: view })
|
set({ selectedView: view })
|
||||||
// Persist to localStorage keyed by breakpoint group
|
// Persist to localStorage keyed by breakpoint group
|
||||||
@@ -166,4 +183,6 @@ export const useCalendarStore = create<CalendarStore>((set) => ({
|
|||||||
set({ deleteDialogOpen: open, deleteDialogUid: uid }),
|
set({ deleteDialogOpen: open, deleteDialogUid: uid }),
|
||||||
|
|
||||||
setLastSyncedUid: (uid: string | null) => set({ lastSyncedUid: uid }),
|
setLastSyncedUid: (uid: string | null) => set({ lastSyncedUid: uid }),
|
||||||
|
|
||||||
|
setSessionExpired: (expired: boolean) => set({ sessionExpired: expired }),
|
||||||
}))
|
}))
|
||||||
|
|||||||
Reference in New Issue
Block a user