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
}