fix(timeline): resync after sse reconnect
This commit is contained in:
@@ -1,6 +1,10 @@
|
|||||||
import { SSE_EVENT_TYPES } from "@capakraken/shared";
|
import { SSE_EVENT_TYPES } from "@capakraken/shared";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { getTimelineSseInvalidationKeys, parseTimelineSseEvent } from "./timelineSsePolicy.js";
|
import {
|
||||||
|
getTimelineSseInvalidationKeys,
|
||||||
|
getTimelineSseResyncKeys,
|
||||||
|
parseTimelineSseEvent,
|
||||||
|
} from "./timelineSsePolicy.js";
|
||||||
|
|
||||||
describe("timelineSsePolicy", () => {
|
describe("timelineSsePolicy", () => {
|
||||||
it("returns null for malformed event payloads", () => {
|
it("returns null for malformed event payloads", () => {
|
||||||
@@ -33,4 +37,23 @@ describe("timelineSsePolicy", () => {
|
|||||||
[["notification", "unreadCount"]],
|
[["notification", "unreadCount"]],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns the full resync invalidation set for reconnect catch-up", () => {
|
||||||
|
expect(getTimelineSseResyncKeys()).toEqual([
|
||||||
|
[["timeline", "getEntries"]],
|
||||||
|
[["timeline", "getEntriesView"]],
|
||||||
|
[["timeline", "getMyEntriesView"]],
|
||||||
|
[["timeline", "getHolidayOverlays"]],
|
||||||
|
[["timeline", "getMyHolidayOverlays"]],
|
||||||
|
[["vacation", "list"]],
|
||||||
|
[["allocation", "list"]],
|
||||||
|
[["project", "list"]],
|
||||||
|
[["timeline", "getBudgetStatus"]],
|
||||||
|
[["notification", "listTasks"]],
|
||||||
|
[["notification", "taskCounts"]],
|
||||||
|
[["notification", "list"]],
|
||||||
|
[["notification", "unreadCount"]],
|
||||||
|
[["notification", "listReminders"]],
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,6 +15,18 @@ const NOTIFICATION_KEYS: TimelineQueryKey[] = [
|
|||||||
[["notification", "unreadCount"]],
|
[["notification", "unreadCount"]],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const RESYNC_INVALIDATION_KEYS: TimelineQueryKey[] = [
|
||||||
|
...TIMELINE_ENTRY_KEYS,
|
||||||
|
[["vacation", "list"]],
|
||||||
|
[["allocation", "list"]],
|
||||||
|
[["project", "list"]],
|
||||||
|
[["timeline", "getBudgetStatus"]],
|
||||||
|
[["notification", "listTasks"]],
|
||||||
|
[["notification", "taskCounts"]],
|
||||||
|
...NOTIFICATION_KEYS,
|
||||||
|
[["notification", "listReminders"]],
|
||||||
|
];
|
||||||
|
|
||||||
export function parseTimelineSseEvent(rawData: string): string | null {
|
export function parseTimelineSseEvent(rawData: string): string | null {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(rawData) as { type?: unknown };
|
const data = JSON.parse(rawData) as { type?: unknown };
|
||||||
@@ -69,3 +81,7 @@ export function getTimelineSseInvalidationKeys(eventType: string): TimelineQuery
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getTimelineSseResyncKeys(): TimelineQueryKey[] {
|
||||||
|
return RESYNC_INVALIDATION_KEYS;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { SSE_EVENT_TYPES } from "@capakraken/shared";
|
import { SSE_EVENT_TYPES } from "@capakraken/shared";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { getTimelineSseResyncKeys } from "./timelineSsePolicy.js";
|
||||||
|
|
||||||
const invalidateQueries = vi.fn();
|
const invalidateQueries = vi.fn();
|
||||||
const effectCleanups: Array<() => void> = [];
|
const effectCleanups: Array<() => void> = [];
|
||||||
@@ -83,11 +84,23 @@ describe("useTimelineSSE", () => {
|
|||||||
|
|
||||||
expect(MockEventSource.instances).toHaveLength(1);
|
expect(MockEventSource.instances).toHaveLength(1);
|
||||||
|
|
||||||
|
MockEventSource.instances[0]?.emitOpen();
|
||||||
|
|
||||||
MockEventSource.instances[0]?.emitMessage(JSON.stringify({ type: SSE_EVENT_TYPES.PING }));
|
MockEventSource.instances[0]?.emitMessage(JSON.stringify({ type: SSE_EVENT_TYPES.PING }));
|
||||||
|
|
||||||
expect(invalidateQueries).not.toHaveBeenCalled();
|
expect(invalidateQueries).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not resync on the initial successful SSE connection", () => {
|
||||||
|
useTimelineSSE();
|
||||||
|
|
||||||
|
expect(MockEventSource.instances).toHaveLength(1);
|
||||||
|
|
||||||
|
MockEventSource.instances[0]?.emitOpen();
|
||||||
|
|
||||||
|
expect(invalidateQueries).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("resets reconnect backoff after ping and reconnects only once per pending timer", () => {
|
it("resets reconnect backoff after ping and reconnects only once per pending timer", () => {
|
||||||
useTimelineSSE();
|
useTimelineSSE();
|
||||||
|
|
||||||
@@ -117,6 +130,31 @@ describe("useTimelineSSE", () => {
|
|||||||
expect(invalidateQueries).not.toHaveBeenCalled();
|
expect(invalidateQueries).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("resyncs timeline queries exactly once after a successful reconnect", () => {
|
||||||
|
useTimelineSSE();
|
||||||
|
|
||||||
|
const firstConnection = MockEventSource.instances[0];
|
||||||
|
expect(firstConnection).toBeDefined();
|
||||||
|
|
||||||
|
firstConnection?.emitOpen();
|
||||||
|
firstConnection?.emitError();
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(2_000);
|
||||||
|
|
||||||
|
expect(MockEventSource.instances).toHaveLength(2);
|
||||||
|
|
||||||
|
const secondConnection = MockEventSource.instances[1];
|
||||||
|
expect(secondConnection).toBeDefined();
|
||||||
|
|
||||||
|
secondConnection?.emitOpen();
|
||||||
|
secondConnection?.emitOpen();
|
||||||
|
|
||||||
|
expect(invalidateQueries).toHaveBeenCalledTimes(getTimelineSseResyncKeys().length);
|
||||||
|
expect(invalidateQueries.mock.calls).toEqual(
|
||||||
|
getTimelineSseResyncKeys().map((queryKey) => [{ queryKey }]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("clears a pending reconnect when the hook is disposed", () => {
|
it("clears a pending reconnect when the hook is disposed", () => {
|
||||||
useTimelineSSE();
|
useTimelineSSE();
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,11 @@
|
|||||||
import { SSE_EVENT_TYPES } from "@capakraken/shared";
|
import { SSE_EVENT_TYPES } from "@capakraken/shared";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { getTimelineSseInvalidationKeys, parseTimelineSseEvent } from "./timelineSsePolicy.js";
|
import {
|
||||||
|
getTimelineSseInvalidationKeys,
|
||||||
|
getTimelineSseResyncKeys,
|
||||||
|
parseTimelineSseEvent,
|
||||||
|
} from "./timelineSsePolicy.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Connects to the SSE timeline endpoint and invalidates React Query caches
|
* Connects to the SSE timeline endpoint and invalidates React Query caches
|
||||||
@@ -17,6 +21,7 @@ export function useTimelineSSE() {
|
|||||||
let es: EventSource | null = null;
|
let es: EventSource | null = null;
|
||||||
let reconnectAttempts = 0;
|
let reconnectAttempts = 0;
|
||||||
let isDisposed = false;
|
let isDisposed = false;
|
||||||
|
let shouldResyncOnOpen = false;
|
||||||
|
|
||||||
function scheduleReconnect() {
|
function scheduleReconnect() {
|
||||||
if (isDisposed || reconnectTimeout.current) return;
|
if (isDisposed || reconnectTimeout.current) return;
|
||||||
@@ -35,6 +40,14 @@ export function useTimelineSSE() {
|
|||||||
|
|
||||||
es.onopen = () => {
|
es.onopen = () => {
|
||||||
reconnectAttempts = 0;
|
reconnectAttempts = 0;
|
||||||
|
if (!shouldResyncOnOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
shouldResyncOnOpen = false;
|
||||||
|
for (const queryKey of getTimelineSseResyncKeys()) {
|
||||||
|
void queryClient.invalidateQueries({ queryKey });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
es.onmessage = (event) => {
|
es.onmessage = (event) => {
|
||||||
@@ -54,6 +67,7 @@ export function useTimelineSSE() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
es.onerror = () => {
|
es.onerror = () => {
|
||||||
|
shouldResyncOnOpen = true;
|
||||||
es?.close();
|
es?.close();
|
||||||
scheduleReconnect();
|
scheduleReconnect();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ Progress note:
|
|||||||
- 2026-04-01: overlay cleanup on timeline `viewMode` changes and initial-loading transitions landed with targeted e2e regression coverage for allocation popovers across view switches.
|
- 2026-04-01: overlay cleanup on timeline `viewMode` changes and initial-loading transitions landed with targeted e2e regression coverage for allocation popovers across view switches.
|
||||||
- 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.
|
||||||
|
|
||||||
Slices:
|
Slices:
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user