fix(timeline): pause sse while hidden
This commit is contained in:
@@ -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 }]),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user