fix(electrobun): eliminate $effect/$derived cycles in 3 more components

TaskBoardTab: $effect called loadTasks() which wrote ++pollToken ($state)
→ triggered $effect re-run → infinite loop. Fix: onMount instead.
Also tasksByCol $derived created new objects via .reduce/.filter.

FileBrowser: $effect read openDirs (via new Set(openDirs)) AND wrote to
it (openDirs = s) → infinite loop. Fix: onMount with fresh Set.

CommsTab: $effect called loadChannels()/loadAgents() which wrote $state
→ potential cycle. Fix: onMount instead.

Rule: NEVER use $effect for initialization that writes to $state.
Always use onMount for async init + side effects.
This commit is contained in:
Hibryda 2026-03-24 13:03:48 +01:00
parent 8d09632879
commit 20e4d2cdec
3 changed files with 19 additions and 16 deletions

View file

@ -186,11 +186,12 @@
} }
} }
$effect(() => { // Use onMount instead of $effect — loadChannels/loadAgents write to $state
import { onMount } from 'svelte';
onMount(() => {
loadChannels(); loadChannels();
loadAgents(); loadAgents();
appRpc.addMessageListener('btmsg.newMessage', onNewMessage); appRpc.addMessageListener('btmsg.newMessage', onNewMessage);
// Feature 4: Fallback 30s poll for missed events
pollTimer = setInterval(() => { pollTimer = setInterval(() => {
if (mode === 'channels' && activeChannelId) { if (mode === 'channels' && activeChannelId) {
loadChannelMessages(activeChannelId); loadChannelMessages(activeChannelId);
@ -198,7 +199,6 @@
loadDmMessages(activeDmAgentId); loadDmMessages(activeDmAgentId);
} }
}, 30000); }, 30000);
return () => { return () => {
if (pollTimer) clearInterval(pollTimer); if (pollTimer) clearInterval(pollTimer);
appRpc.removeMessageListener?.('btmsg.newMessage', onNewMessage); appRpc.removeMessageListener?.('btmsg.newMessage', onNewMessage);

View file

@ -263,13 +263,13 @@
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
} }
// Load root directory on mount // Load root directory on mount — use onMount, NOT $effect
$effect(() => { // $effect reads openDirs (via new Set(openDirs)) and writes to it → infinite loop
import { onMount } from 'svelte';
onMount(() => {
if (cwd) { if (cwd) {
loadDir(cwd); loadDir(cwd);
const s = new Set(openDirs); openDirs = new Set([cwd]);
s.add(cwd);
openDirs = s;
} }
}); });
</script> </script>

View file

@ -51,12 +51,13 @@
// ── Derived ────────────────────────────────────────────────────────── // ── Derived ──────────────────────────────────────────────────────────
let tasksByCol = $derived( // NO $derived — .reduce creates new objects every evaluation → infinite loop
COLUMNS.reduce((acc, col) => { function getTasksByCol(): Record<string, Task[]> {
return COLUMNS.reduce((acc, col) => {
acc[col] = tasks.filter(t => t.status === col); acc[col] = tasks.filter(t => t.status === col);
return acc; return acc;
}, {} as Record<string, Task[]>) }, {} as Record<string, Task[]>);
); }
// ── Data fetching ──────────────────────────────────────────────────── // ── Data fetching ────────────────────────────────────────────────────
@ -177,10 +178,12 @@
if (!payload.groupId || payload.groupId === groupId) loadTasks(); if (!payload.groupId || payload.groupId === groupId) loadTasks();
} }
$effect(() => { // Use onMount instead of $effect — loadTasks() writes to $state (pollToken++)
// which would trigger $effect re-run → infinite loop
import { onMount } from 'svelte';
onMount(() => {
loadTasks(); loadTasks();
appRpc.addMessageListener('bttask.changed', onTaskChanged); appRpc.addMessageListener('bttask.changed', onTaskChanged);
// Feature 4: Fallback 30s poll for missed events
pollTimer = setInterval(loadTasks, 30000); pollTimer = setInterval(loadTasks, 30000);
return () => { return () => {
if (pollTimer) clearInterval(pollTimer); if (pollTimer) clearInterval(pollTimer);
@ -243,11 +246,11 @@
> >
<div class="tb-col-header"> <div class="tb-col-header">
<span class="tb-col-label">{COL_LABELS[col]}</span> <span class="tb-col-label">{COL_LABELS[col]}</span>
<span class="tb-col-count">{tasksByCol[col]?.length ?? 0}</span> <span class="tb-col-count">{getTasksByCol()[col]?.length ?? 0}</span>
</div> </div>
<div class="tb-col-body"> <div class="tb-col-body">
{#each tasksByCol[col] ?? [] as task (task.id)} {#each getTasksByCol()[col] ?? [] as task (task.id)}
<div <div
class="tb-card" class="tb-card"
class:dragging={draggedTaskId === task.id} class:dragging={draggedTaskId === task.id}