diff --git a/web/src/App.tsx b/web/src/App.tsx index fc23635a..3a11e6c2 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -205,9 +205,12 @@ function AppInner() { } const invalidations = [ queryClient.invalidateQueries({ queryKey: queryKeys.sessions }), - ...(selectedSessionId ? [ - queryClient.invalidateQueries({ queryKey: queryKeys.session(selectedSessionId) }) - ] : []) + // Invalidate ALL cached session-detail entries on reconnect, not just + // the selected one. With `SESSION_DETAIL_STALE_TIME_MS` extending the + // freshness window on `useSession`, a previously-viewed session that + // received updates during the SSE gap would otherwise serve stale + // cached data on remount. See tiann/hapi#884. + queryClient.invalidateQueries({ queryKey: ['session'] }) ] const refreshMessages = (selectedSessionId && api) ? fetchLatestMessages(api, selectedSessionId) diff --git a/web/src/hooks/queries/useSession.test.ts b/web/src/hooks/queries/useSession.test.ts index b5adcfdc..7d374cbc 100644 --- a/web/src/hooks/queries/useSession.test.ts +++ b/web/src/hooks/queries/useSession.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { isSessionNotFoundError } from './useSession' +import { isSessionNotFoundError, SESSION_DETAIL_STALE_TIME_MS } from './useSession' describe('isSessionNotFoundError', () => { it('matches hub 404 session responses', () => { @@ -11,3 +11,13 @@ describe('isSessionNotFoundError', () => { expect(isSessionNotFoundError(null)).toBe(false) }) }) + +describe('SESSION_DETAIL_STALE_TIME_MS', () => { + // SSE patches the cache directly on session-updated events, so the REST + // endpoint is just a cold-start / reconnect-recovery path. A long staleTime + // suppresses focus-refetch and remount-refetch storms — primary lever for + // the refetch-storm fix (tiann/hapi#884). + it('is set to a value that suppresses focus/mount refetches', () => { + expect(SESSION_DETAIL_STALE_TIME_MS).toBeGreaterThanOrEqual(10_000) + }) +}) diff --git a/web/src/hooks/queries/useSession.ts b/web/src/hooks/queries/useSession.ts index d9d6e5be..adf086ea 100644 --- a/web/src/hooks/queries/useSession.ts +++ b/web/src/hooks/queries/useSession.ts @@ -8,6 +8,17 @@ export function isSessionNotFoundError(error: unknown): boolean { && (error.message.includes('HTTP 404') || error.message.includes('Session not found')) } +// Session detail freshness is driven by SSE events (`useSSE` patches the cache +// directly on `session-updated`). The REST endpoint is only a cold-start / +// reconnect-recovery path, so a long per-query staleTime extends the global +// default (5s, see `web/src/lib/query-client.ts`) for `useSession` only — this +// suppresses remount-refetch when the user navigates back to a recently-viewed +// session within the window, without making the UI stale. Explicit +// `invalidateQueries` calls (SSE fallback path, reconnect-recovery in +// `App.tsx`) still refetch active observers regardless of staleTime, so live +// updates and recovery flows continue to work. See tiann/hapi#884. +export const SESSION_DETAIL_STALE_TIME_MS = 30_000 + export function useSession(api: ApiClient | null, sessionId: string | null): { session: Session | null isLoading: boolean @@ -25,6 +36,7 @@ export function useSession(api: ApiClient | null, sessionId: string | null): { return await api.getSession(sessionId) }, enabled: Boolean(api && sessionId), + staleTime: SESSION_DETAIL_STALE_TIME_MS, retry: (failureCount, error) => { if (isSessionNotFoundError(error)) { return false