From 4b14db9dc6bd93edc053b0f83676ab9a74d40424 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Wed, 1 Apr 2026 15:05:34 +0200 Subject: [PATCH] fix(timeline): pause sse while hidden --- apps/web/src/hooks/useTimelineSSE.test.ts | 70 ++++++++++++++++++++++- apps/web/src/hooks/useTimelineSSE.ts | 44 ++++++++++++-- docs/showcase-execution-batches.md | 1 + 3 files changed, 109 insertions(+), 6 deletions(-) diff --git a/apps/web/src/hooks/useTimelineSSE.test.ts b/apps/web/src/hooks/useTimelineSSE.test.ts index 3851825..d875c5e 100644 --- a/apps/web/src/hooks/useTimelineSSE.test.ts +++ b/apps/web/src/hooks/useTimelineSSE.test.ts @@ -52,13 +52,43 @@ class MockEventSource { } } +type VisibilityHandler = () => void; + +class MockVisibilityDocument { + visibilityState: "visible" | "hidden" = "visible"; + private readonly listeners = new Set(); + + addEventListener(type: string, listener: VisibilityHandler) { + if (type === "visibilitychange") { + this.listeners.add(listener); + } + } + + removeEventListener(type: string, listener: VisibilityHandler) { + if (type === "visibilitychange") { + this.listeners.delete(listener); + } + } + + setVisibility(state: "visible" | "hidden") { + this.visibilityState = state; + for (const listener of this.listeners) { + listener(); + } + } +} + describe("useTimelineSSE", () => { + let mockDocument: MockVisibilityDocument; + beforeEach(() => { vi.useFakeTimers(); vi.clearAllMocks(); effectCleanups.length = 0; MockEventSource.instances.length = 0; + mockDocument = new MockVisibilityDocument(); vi.stubGlobal("EventSource", MockEventSource); + vi.stubGlobal("document", mockDocument); }); afterEach(() => { @@ -109,7 +139,7 @@ describe("useTimelineSSE", () => { firstConnection?.emitError(); firstConnection?.emitError(); - expect(firstConnection?.close).toHaveBeenCalledTimes(2); + expect(firstConnection?.close).toHaveBeenCalledTimes(1); vi.advanceTimersByTime(1999); expect(MockEventSource.instances).toHaveLength(1); @@ -172,4 +202,42 @@ describe("useTimelineSSE", () => { expect(MockEventSource.instances).toHaveLength(1); expect(firstConnection?.close).toHaveBeenCalled(); }); + + it("does not connect while the page is hidden on mount, then connects when visible", () => { + mockDocument.visibilityState = "hidden"; + + useTimelineSSE(); + + expect(MockEventSource.instances).toHaveLength(0); + + mockDocument.setVisibility("visible"); + + expect(MockEventSource.instances).toHaveLength(1); + expect(invalidateQueries).not.toHaveBeenCalled(); + }); + + it("closes the active connection while hidden and resyncs once when visible again", () => { + useTimelineSSE(); + + const firstConnection = MockEventSource.instances[0]; + expect(firstConnection).toBeDefined(); + + firstConnection?.emitOpen(); + mockDocument.setVisibility("hidden"); + + expect(firstConnection?.close).toHaveBeenCalledTimes(1); + expect(MockEventSource.instances).toHaveLength(1); + + mockDocument.setVisibility("visible"); + + expect(MockEventSource.instances).toHaveLength(2); + + const secondConnection = MockEventSource.instances[1]; + secondConnection?.emitOpen(); + + expect(invalidateQueries).toHaveBeenCalledTimes(getTimelineSseResyncKeys().length); + expect(invalidateQueries.mock.calls).toEqual( + getTimelineSseResyncKeys().map((queryKey) => [{ queryKey }]), + ); + }); }); diff --git a/apps/web/src/hooks/useTimelineSSE.ts b/apps/web/src/hooks/useTimelineSSE.ts index 9050ff4..a8ee35f 100644 --- a/apps/web/src/hooks/useTimelineSSE.ts +++ b/apps/web/src/hooks/useTimelineSSE.ts @@ -22,6 +22,18 @@ export function useTimelineSSE() { let reconnectAttempts = 0; let isDisposed = false; let shouldResyncOnOpen = false; + const canObserveVisibility = typeof document !== "undefined" && "visibilityState" in document; + + function clearReconnectTimer() { + if (!reconnectTimeout.current) return; + clearTimeout(reconnectTimeout.current); + reconnectTimeout.current = null; + } + + function closeConnection() { + es?.close(); + es = null; + } function scheduleReconnect() { if (isDisposed || reconnectTimeout.current) return; @@ -36,6 +48,7 @@ export function useTimelineSSE() { function connect() { if (isDisposed) return; + if (canObserveVisibility && document.visibilityState === "hidden") return; es = new EventSource("/api/sse/timeline"); es.onopen = () => { @@ -68,20 +81,41 @@ export function useTimelineSSE() { es.onerror = () => { shouldResyncOnOpen = true; - es?.close(); + closeConnection(); scheduleReconnect(); }; } + function handleVisibilityChange() { + if (!canObserveVisibility) return; + if (document.visibilityState === "hidden") { + if (es || reconnectTimeout.current) { + shouldResyncOnOpen = true; + } + clearReconnectTimer(); + closeConnection(); + return; + } + + reconnectAttempts = 0; + if (!es) { + connect(); + } + } + + if (canObserveVisibility) { + document.addEventListener("visibilitychange", handleVisibilityChange); + } + connect(); return () => { isDisposed = true; - es?.close(); - if (reconnectTimeout.current) { - clearTimeout(reconnectTimeout.current); - reconnectTimeout.current = null; + if (canObserveVisibility) { + document.removeEventListener("visibilitychange", handleVisibilityChange); } + closeConnection(); + clearReconnectTimer(); }; }, [queryClient]); } diff --git a/docs/showcase-execution-batches.md b/docs/showcase-execution-batches.md index 7879b0f..ffc30f9 100644 --- a/docs/showcase-execution-batches.md +++ b/docs/showcase-execution-batches.md @@ -29,6 +29,7 @@ Progress note: - 2026-04-01: viewport-change behavior was tightened so point-anchored timeline popovers close on scroll/resize, while element-anchored hover cards remain repositionable; the viewport regression now passes with a non-happy-path e2e. - 2026-04-01: active timeline gestures now cancel on window blur / hidden-tab transitions instead of leaving drag or resize state stranded; regression coverage verifies a mid-resize focus loss reverts the preview and allows the next interaction to proceed cleanly. - 2026-04-01: timeline SSE reconnect now performs a one-shot catch-up invalidation on the next successful `onopen`, so missed updates during disconnects do not leave the timeline stale until a later event arrives. +- 2026-04-01: timeline SSE now pauses while the tab is hidden and reconnects with a one-shot resync after visibility returns, which reduces background reconnect churn without trusting missed live events to self-heal. Slices: