fix(ui): prevent async listener leaks on back navigation

This commit is contained in:
thibaud-lclr 2026-04-21 10:33:12 +02:00
parent 2618c2ce77
commit b6e31f9312
3 changed files with 123 additions and 78 deletions

View file

@ -66,28 +66,41 @@ export default function NotificationCenter() {
}, [projectId]); }, [projectId]);
useEffect(() => { useEffect(() => {
let cancelled = false;
let unlisten: (() => void) | null = null; let unlisten: (() => void) | null = null;
const setup = async () => { const setup = async () => {
unlisten = await listen<NewNotificationEvent>("new-notification", (event) => { try {
const incoming = event.payload.notification; const cleanup = await listen<NewNotificationEvent>("new-notification", (event) => {
const incoming = event.payload.notification;
if (projectId && incoming.project_id !== projectId) { if (projectId && incoming.project_id !== projectId) {
return;
}
setNotifications((prev) => {
const withoutDuplicate = prev.filter((n) => n.id !== incoming.id);
return [incoming, ...withoutDuplicate];
});
void showSystemNotification(incoming);
});
if (cancelled) {
cleanup();
return; return;
} }
setNotifications((prev) => { unlisten = cleanup;
const withoutDuplicate = prev.filter((n) => n.id !== incoming.id); } catch (error: unknown) {
return [incoming, ...withoutDuplicate]; console.error("Failed to subscribe to notifications", error);
}); }
void showSystemNotification(incoming);
});
}; };
void setup(); void setup();
return () => { return () => {
cancelled = true;
if (unlisten) { if (unlisten) {
unlisten(); unlisten();
} }

View file

@ -160,74 +160,93 @@ export default function ProjectLiveAgent() {
useEffect(() => { useEffect(() => {
if (!projectId) return; if (!projectId) return;
let cancelled = false;
let stop: (() => void) | null = null; let stop: (() => void) | null = null;
void (async () => { const setup = async () => {
const [unlistenMessage, unlistenStarted, unlistenChunk, unlistenFinished, unlistenError] = try {
await Promise.all([ const [unlistenMessage, unlistenStarted, unlistenChunk, unlistenFinished, unlistenError] =
listen<LiveEventPayload>("live-agent-message", (event) => { await Promise.all([
const payload = event.payload; listen<LiveEventPayload>("live-agent-message", (event) => {
if (payload.project_id !== projectId) return; const payload = event.payload;
if (payload.session_id !== selectedSessionId) return; if (payload.project_id !== projectId) return;
if (payload.session_id !== selectedSessionId) return;
setMessages((prev) => { setMessages((prev) => {
const existingIndex = prev.findIndex((msg) => msg.id === payload.message.id); const existingIndex = prev.findIndex((msg) => msg.id === payload.message.id);
if (existingIndex === -1) { if (existingIndex === -1) {
return [...prev, payload.message]; return [...prev, payload.message];
}
const next = [...prev];
next[existingIndex] = payload.message;
return next;
});
if (payload.message.sender === "agent" && payload.message.content.trim() !== "") {
setStreamingAgentResponse(null);
} }
}),
const next = [...prev]; listen<LiveStreamStatusPayload>("live-agent-stream-started", (event) => {
next[existingIndex] = payload.message; const payload = event.payload;
return next; if (payload.project_id !== projectId) return;
}); if (payload.session_id !== selectedSessionId) return;
setStreamingAgentResponse("");
if (payload.message.sender === "agent" && payload.message.content.trim() !== "") { }),
listen<LiveStreamChunkPayload>("live-agent-stream-chunk", (event) => {
const payload = event.payload;
if (payload.project_id !== projectId) return;
if (payload.session_id !== selectedSessionId) return;
setStreamingAgentResponse((prev) => `${prev ?? ""}${payload.chunk}`);
}),
listen<LiveStreamStatusPayload>("live-agent-stream-finished", (event) => {
const payload = event.payload;
if (payload.project_id !== projectId) return;
if (payload.session_id !== selectedSessionId) return;
setStreamingAgentResponse(null); setStreamingAgentResponse(null);
} }),
}), listen<LiveStreamStatusPayload>("live-agent-stream-error", (event) => {
listen<LiveStreamStatusPayload>("live-agent-stream-started", (event) => { const payload = event.payload;
const payload = event.payload; if (payload.project_id !== projectId) return;
if (payload.project_id !== projectId) return; if (payload.session_id !== selectedSessionId) return;
if (payload.session_id !== selectedSessionId) return; setStreamingAgentResponse(null);
setStreamingAgentResponse(""); setMessages((prev) =>
}), prev.filter((msg) => !(msg.sender === "agent" && msg.content.trim() === ""))
listen<LiveStreamChunkPayload>("live-agent-stream-chunk", (event) => { );
const payload = event.payload; if (payload.error) {
if (payload.project_id !== projectId) return; setError(payload.error);
if (payload.session_id !== selectedSessionId) return; }
setStreamingAgentResponse((prev) => `${prev ?? ""}${payload.chunk}`); }),
}), ]);
listen<LiveStreamStatusPayload>("live-agent-stream-finished", (event) => {
const payload = event.payload;
if (payload.project_id !== projectId) return;
if (payload.session_id !== selectedSessionId) return;
setStreamingAgentResponse(null);
}),
listen<LiveStreamStatusPayload>("live-agent-stream-error", (event) => {
const payload = event.payload;
if (payload.project_id !== projectId) return;
if (payload.session_id !== selectedSessionId) return;
setStreamingAgentResponse(null);
setMessages((prev) =>
prev.filter((msg) => !(msg.sender === "agent" && msg.content.trim() === ""))
);
if (payload.error) {
setError(payload.error);
}
}),
]);
stop = () => { const cleanup = () => {
unlistenMessage(); unlistenMessage();
unlistenStarted(); unlistenStarted();
unlistenChunk(); unlistenChunk();
unlistenFinished(); unlistenFinished();
unlistenError(); unlistenError();
}; };
})();
if (cancelled) {
cleanup();
return;
}
stop = cleanup;
} catch (err: unknown) {
if (!cancelled) {
setError(getErrorMessage(err));
}
}
};
void setup();
return () => { return () => {
if (stop) stop(); cancelled = true;
if (stop) {
stop();
}
}; };
}, [projectId, selectedSessionId]); }, [projectId, selectedSessionId]);

View file

@ -76,18 +76,31 @@ export default function ProjectTasks() {
useEffect(() => { useEffect(() => {
if (!projectId) return; if (!projectId) return;
let stop: (() => void) | null = null; let cancelled = false;
let unlisten: (() => void) | null = null;
void (async () => { void listen<TaskEventPayload>("agent-task-updated", (event) => {
const unlisten = await listen<TaskEventPayload>("agent-task-updated", (event) => { if (event.payload.project_id !== projectId) return;
if (event.payload.project_id !== projectId) return; void refresh();
void refresh(); })
.then((cleanup) => {
if (cancelled) {
cleanup();
return;
}
unlisten = cleanup;
})
.catch((err: unknown) => {
if (!cancelled) {
setError(getErrorMessage(err));
}
}); });
stop = unlisten;
})();
return () => { return () => {
if (stop) stop(); cancelled = true;
if (unlisten) {
unlisten();
}
}; };
}, [projectId]); }, [projectId]);