fix(timeline): pause sse while hidden

This commit is contained in:
2026-04-01 15:05:34 +02:00
parent 3258b59e21
commit 4b14db9dc6
3 changed files with 109 additions and 6 deletions
+69 -1
View File
@@ -52,13 +52,43 @@ class MockEventSource {
} }
} }
type VisibilityHandler = () => void;
class MockVisibilityDocument {
visibilityState: "visible" | "hidden" = "visible";
private readonly listeners = new Set<VisibilityHandler>();
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", () => { describe("useTimelineSSE", () => {
let mockDocument: MockVisibilityDocument;
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers(); vi.useFakeTimers();
vi.clearAllMocks(); vi.clearAllMocks();
effectCleanups.length = 0; effectCleanups.length = 0;
MockEventSource.instances.length = 0; MockEventSource.instances.length = 0;
mockDocument = new MockVisibilityDocument();
vi.stubGlobal("EventSource", MockEventSource); vi.stubGlobal("EventSource", MockEventSource);
vi.stubGlobal("document", mockDocument);
}); });
afterEach(() => { afterEach(() => {
@@ -109,7 +139,7 @@ describe("useTimelineSSE", () => {
firstConnection?.emitError(); firstConnection?.emitError();
firstConnection?.emitError(); firstConnection?.emitError();
expect(firstConnection?.close).toHaveBeenCalledTimes(2); expect(firstConnection?.close).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(1999); vi.advanceTimersByTime(1999);
expect(MockEventSource.instances).toHaveLength(1); expect(MockEventSource.instances).toHaveLength(1);
@@ -172,4 +202,42 @@ describe("useTimelineSSE", () => {
expect(MockEventSource.instances).toHaveLength(1); expect(MockEventSource.instances).toHaveLength(1);
expect(firstConnection?.close).toHaveBeenCalled(); 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 }]),
);
});
}); });
+39 -5
View File
@@ -22,6 +22,18 @@ export function useTimelineSSE() {
let reconnectAttempts = 0; let reconnectAttempts = 0;
let isDisposed = false; let isDisposed = false;
let shouldResyncOnOpen = 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() { function scheduleReconnect() {
if (isDisposed || reconnectTimeout.current) return; if (isDisposed || reconnectTimeout.current) return;
@@ -36,6 +48,7 @@ export function useTimelineSSE() {
function connect() { function connect() {
if (isDisposed) return; if (isDisposed) return;
if (canObserveVisibility && document.visibilityState === "hidden") return;
es = new EventSource("/api/sse/timeline"); es = new EventSource("/api/sse/timeline");
es.onopen = () => { es.onopen = () => {
@@ -68,20 +81,41 @@ export function useTimelineSSE() {
es.onerror = () => { es.onerror = () => {
shouldResyncOnOpen = true; shouldResyncOnOpen = true;
es?.close(); closeConnection();
scheduleReconnect(); 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(); connect();
return () => { return () => {
isDisposed = true; isDisposed = true;
es?.close(); if (canObserveVisibility) {
if (reconnectTimeout.current) { document.removeEventListener("visibilitychange", handleVisibilityChange);
clearTimeout(reconnectTimeout.current);
reconnectTimeout.current = null;
} }
closeConnection();
clearReconnectTimer();
}; };
}, [queryClient]); }, [queryClient]);
} }
+1
View File
@@ -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: 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: 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 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: Slices: