fix(web): harden timeline sse reconnect lifecycle

This commit is contained in:
2026-03-31 23:06:07 +02:00
parent 73ef3b2bba
commit c3b3dffb6e
2 changed files with 25 additions and 6 deletions
+4 -3
View File
@@ -5,13 +5,14 @@ import { prisma } from "@capakraken/db";
import { SSE_EVENT_TYPES, SystemRole, type PermissionOverrides } from "@capakraken/shared"; import { SSE_EVENT_TYPES, SystemRole, type PermissionOverrides } from "@capakraken/shared";
import { auth } from "~/server/auth.js"; import { auth } from "~/server/auth.js";
// Start the reminder scheduler (idempotent — only starts once)
startReminderScheduler();
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export const runtime = "nodejs"; export const runtime = "nodejs";
export async function GET() { export async function GET() {
// Start lazily on the first real SSE request so builds/import-time evaluation
// never attempt reminder processing against a live database.
startReminderScheduler();
const session = await auth(); const session = await auth();
if (!session?.user) { if (!session?.user) {
+21 -3
View File
@@ -15,10 +15,27 @@ export function useTimelineSSE() {
useEffect(() => { useEffect(() => {
let es: EventSource | null = null; let es: EventSource | null = null;
let reconnectAttempts = 0; let reconnectAttempts = 0;
let isDisposed = false;
function scheduleReconnect() {
if (isDisposed || reconnectTimeout.current) return;
reconnectAttempts++;
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000);
reconnectTimeout.current = setTimeout(() => {
reconnectTimeout.current = null;
if (isDisposed) return;
connect();
}, delay);
}
function connect() { function connect() {
if (isDisposed) return;
es = new EventSource("/api/sse/timeline"); es = new EventSource("/api/sse/timeline");
es.onopen = () => {
reconnectAttempts = 0;
};
es.onmessage = (event) => { es.onmessage = (event) => {
try { try {
const data = JSON.parse(event.data as string) as { type: string }; const data = JSON.parse(event.data as string) as { type: string };
@@ -35,6 +52,7 @@ export function useTimelineSSE() {
void queryClient.invalidateQueries({ queryKey: [["timeline", "getMyEntriesView"]] }); void queryClient.invalidateQueries({ queryKey: [["timeline", "getMyEntriesView"]] });
void queryClient.invalidateQueries({ queryKey: [["timeline", "getHolidayOverlays"]] }); void queryClient.invalidateQueries({ queryKey: [["timeline", "getHolidayOverlays"]] });
void queryClient.invalidateQueries({ queryKey: [["timeline", "getMyHolidayOverlays"]] }); void queryClient.invalidateQueries({ queryKey: [["timeline", "getMyHolidayOverlays"]] });
void queryClient.invalidateQueries({ queryKey: [["vacation", "list"]] });
void queryClient.invalidateQueries({ queryKey: [["allocation", "list"]] }); void queryClient.invalidateQueries({ queryKey: [["allocation", "list"]] });
break; break;
@@ -82,18 +100,18 @@ export function useTimelineSSE() {
es.onerror = () => { es.onerror = () => {
es?.close(); es?.close();
reconnectAttempts++; scheduleReconnect();
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000);
reconnectTimeout.current = setTimeout(connect, delay);
}; };
} }
connect(); connect();
return () => { return () => {
isDisposed = true;
es?.close(); es?.close();
if (reconnectTimeout.current) { if (reconnectTimeout.current) {
clearTimeout(reconnectTimeout.current); clearTimeout(reconnectTimeout.current);
reconnectTimeout.current = null;
} }
}; };
}, [queryClient]); }, [queryClient]);