From 2709600319530005778aded76abf7a4a6fad43ff Mon Sep 17 00:00:00 2001 From: Hibryda Date: Tue, 24 Mar 2026 12:05:39 +0100 Subject: [PATCH] fix(electrobun): isolate blink state to store, prevent prop-cascade re-renders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause found via bisect: blinkVisible prop changed every 500ms, causing complete re-render of ALL ProjectCard trees (AgentPane, Terminal, all tabs) — even display:none content is re-evaluated by Svelte 5. Fix: blink-store.svelte.ts owns the timer. StatusDot reads directly from store, not from parent prop. No prop cascades. Also: replaced $derived with .filter()/.map() (creates new arrays) with plain functions in ProjectCard to prevent reactive loops. --- ui-electrobun/src/mainview/App.svelte | 122 +++++++----------- ui-electrobun/src/mainview/ProjectCard.svelte | 46 ++++--- .../src/mainview/blink-store.svelte.ts | 28 ++++ .../src/mainview/ui/StatusDot.svelte | 12 +- 4 files changed, 114 insertions(+), 94 deletions(-) create mode 100644 ui-electrobun/src/mainview/blink-store.svelte.ts diff --git a/ui-electrobun/src/mainview/App.svelte b/ui-electrobun/src/mainview/App.svelte index a871162..65ef3c9 100644 --- a/ui-electrobun/src/mainview/App.svelte +++ b/ui-electrobun/src/mainview/App.svelte @@ -66,43 +66,19 @@ let appReady = $state(false); let sessionStart = $state(Date.now()); - // ── Stable derived values (avoid creating new objects in template → prevents infinite loops) ── + // ── Stable derived values ── + // NOTE: ALL derived values use plain function calls — NO $derived() + // to prevent Svelte 5 reactive loops when store getters create new refs. function isProjectMounted(groupId: string): boolean { return groupId === getActiveGroupId(); } - let filteredProjects = $derived(getFilteredProjects()); - let activeGroup = $derived(getActiveGroup()); - let totalTokens = $derived(getTotalTokensDerived()); - let totalCost = $derived(getTotalCostDerived()); - let wizardGroups = $derived(getGroups().map(g => ({ id: g.id, name: g.name }))); - let wizardExistingNames = $derived(getProjects().map(p => p.name)); + // These are called directly in template — Svelte tracks the inner $state reads + // but doesn't create intermediate $derived objects that could loop. - // ── Blink timer (untracked — must not create reactive dependency) ──── - let blinkVisible = $state(true); - let _blinkId: ReturnType; - $effect(() => { - _blinkId = setInterval(() => { - untrack(() => { - blinkVisible = !blinkVisible; - }); - }, 500); - return () => clearInterval(_blinkId); - }); + // Blink is now in blink-store.svelte.ts — no prop passing needed - // ── Session duration (untracked) ────────────────────────────── + // ── Session duration (plain JS, no reactive system) ── let sessionDuration = $state("0m"); - $effect(() => { - function update() { - const mins = Math.floor((Date.now() - sessionStart) / 60000); - untrack(() => { - sessionDuration = - mins < 60 ? `${mins}m` : `${Math.floor(mins / 60)}h ${mins % 60}m`; - }); - } - update(); - const id = setInterval(update, 10000); - return () => clearInterval(id); - }); // ── Window controls ───────────────────────────────────────── function handleClose() { @@ -204,37 +180,7 @@ new URLSearchParams(window.location.search).has("debug"); let debugLog = $state([]); - $effect(() => { - if (!DEBUG_ENABLED) return; - function debugClick(e: MouseEvent) { - const el = e.target as HTMLElement; - const tag = el?.tagName; - const cls = (el?.className?.toString?.() ?? "").slice(0, 40); - const elAtPoint = document.elementFromPoint(e.clientX, e.clientY); - let line = `CLICK ${tag}.${cls} @(${e.clientX},${e.clientY})`; - if (elAtPoint && elAtPoint !== el) { - const overTag = (elAtPoint as HTMLElement).tagName; - const overCls = ( - (elAtPoint as HTMLElement).className?.toString?.() ?? "" - ).slice(0, 40); - line += ` OVERLAY: ${overTag}.${overCls}`; - } - debugLog = [...debugLog.slice(-8), line]; - } - function debugDown(e: MouseEvent) { - const el = e.target as HTMLElement; - debugLog = [ - ...debugLog.slice(-8), - `DOWN ${el?.tagName}.${(el?.className?.toString?.() ?? "").slice(0, 40)}`, - ]; - } - document.addEventListener("click", debugClick, true); - document.addEventListener("mousedown", debugDown, true); - return () => { - document.removeEventListener("click", debugClick, true); - document.removeEventListener("mousedown", debugDown, true); - }; - }); + // Debug overlay listeners are set up in onMount, not $effect // ── i18n: lang/dir sync is handled inside setLocale() — no $effect needed ── @@ -243,6 +189,15 @@ setAgentToastFn(showToast); setupErrorBoundary(); + // Blink + session timers — MUST be in onMount, NOT $effect + // $effect interacts with reactive graph and causes cycles + // Blink timer is in blink-store — start it here + import('./blink-store.svelte.ts').then(m => m.startBlink()); + const sessionId = setInterval(() => { + const mins = Math.floor((Date.now() - sessionStart) / 60000); + sessionDuration = mins < 60 ? `${mins}m` : `${Math.floor(mins / 60)}h ${mins % 60}m`; + }, 10000); + const initTasks = [ initI18n().catch(console.error), themeStore.initTheme(appRpc).catch(console.error), @@ -299,9 +254,33 @@ } window.addEventListener("palette-command", handlePaletteCommand); + // Debug overlay (plain listeners, no $effect) + let debugCleanup = () => {}; + if (DEBUG_ENABLED) { + function debugClick(e: MouseEvent) { + const el = e.target as HTMLElement; + const tag = el?.tagName; + const cls = (el?.className?.toString?.() ?? '').slice(0, 40); + debugLog = [...debugLog.slice(-8), `CLICK ${tag}.${cls} @(${e.clientX},${e.clientY})`]; + } + function debugDown(e: MouseEvent) { + const el = e.target as HTMLElement; + debugLog = [...debugLog.slice(-8), `DOWN ${el?.tagName}.${(el?.className?.toString?.() ?? '').slice(0, 40)}`]; + } + document.addEventListener('click', debugClick, true); + document.addEventListener('mousedown', debugDown, true); + debugCleanup = () => { + document.removeEventListener('click', debugClick, true); + document.removeEventListener('mousedown', debugDown, true); + }; + } + const cleanup = keybindingStore.installListener(); return () => { cleanup(); + debugCleanup(); + import('./blink-store.svelte.ts').then(m => m.stopBlink()); + clearInterval(sessionId); document.removeEventListener("keydown", handleSearchShortcut); window.removeEventListener("palette-command", handlePaletteCommand); }; @@ -434,7 +413,7 @@
{#each getProjects() as project (project.id)} {#if isProjectMounted(project.groupId ?? "dev")} @@ -454,7 +433,6 @@ model={project.model} contextPct={project.contextPct} burnRate={project.burnRate} - {blinkVisible} clonesAtMax={cloneCountForProject(project.id) >= 3} onClone={handleClone} worktreeBranch={project.worktreeBranch} @@ -468,10 +446,10 @@

- No projects in {activeGroup?.name ?? "this group"} + No projects in {getActiveGroup()?.name ?? "this group"}

@@ -481,8 +459,8 @@ onClose={() => setShowWizard(false)} onCreated={handleWizardCreated} groupId={getActiveGroupId()} - groups={wizardGroups} - existingNames={wizardExistingNames} + groups={getGroups().map(g => ({ id: g.id, name: g.name }))} + existingNames={getProjects().map(p => p.name)} />
@@ -567,11 +545,11 @@ {#if DEBUG_ENABLED && debugLog.length > 0} diff --git a/ui-electrobun/src/mainview/ProjectCard.svelte b/ui-electrobun/src/mainview/ProjectCard.svelte index 3f7b0b5..fae8916 100644 --- a/ui-electrobun/src/mainview/ProjectCard.svelte +++ b/ui-electrobun/src/mainview/ProjectCard.svelte @@ -28,7 +28,7 @@ model?: string; contextPct?: number; burnRate?: number; - blinkVisible?: boolean; + // blinkVisible removed — StatusDot reads from blink-store directly /** Worktree branch name — set when this is a clone card. */ worktreeBranch?: string; /** ID of parent project — set when this is a clone card. */ @@ -51,7 +51,7 @@ model = 'claude-opus-4-5', contextPct = 0, burnRate = 0, - blinkVisible = true, + // blinkVisible removed worktreeBranch, cloneOf, clonesAtMax = false, @@ -60,9 +60,13 @@ }: Props = $props(); // ── Agent session (reactive from store) ────────────────────────── + // CRITICAL: Use shared constants for fallback values. Creating new [] or {} + // inside $derived causes infinite loops because each evaluation returns a + // new reference → Svelte thinks the value changed → re-renders → new ref → loop. + const EMPTY_MESSAGES: AgentMessage[] = []; let session = $derived(getSession(id)); let agentStatus: AgentStatus = $derived(session?.status ?? 'idle'); - let agentMessages: AgentMessage[] = $derived(session?.messages ?? []); + let agentMessages: AgentMessage[] = $derived(session?.messages ?? EMPTY_MESSAGES); let agentCost = $derived(session?.costUsd ?? 0); let agentTokens = $derived((session?.inputTokens ?? 0) + (session?.outputTokens ?? 0)); let agentModel = $derived(session?.model ?? model); @@ -83,19 +87,25 @@ ); // File references from tool_call messages - let fileRefs = $derived( - agentMessages + // NOTE: Do NOT use $derived with .filter()/.map() — creates new arrays every + // evaluation, which Svelte interprets as "changed" → re-render → new arrays → loop. + // Instead, compute these as plain functions called in template (no caching). + function getFileRefs(): string[] { + return agentMessages .filter((m) => m.role === 'tool-call' && m.toolPath) .map((m) => m.toolPath!) .filter((p, i, arr) => arr.indexOf(p) === i) - .slice(0, 20) - ); + .slice(0, 20); + } - let turnCount = $derived(agentMessages.filter((m) => m.role === 'user' || m.role === 'assistant').length); + function getTurnCount(): number { + return agentMessages.filter((m) => m.role === 'user' || m.role === 'assistant').length; + } - // ── Demo messages (fallback when no real session) ──────────────── - const demoMessages: AgentMessage[] = []; - let displayMessages = $derived(agentMessages.length > 0 ? agentMessages : demoMessages); + // ── Display messages (no $derived — avoids new ref on re-render) ── + function getDisplayMessages(): AgentMessage[] { + return agentMessages.length > 0 ? agentMessages : EMPTY_MESSAGES; + } // ── Clone dialog state ────────────────────────────────────────── let showCloneDialog = $state(false); @@ -160,7 +170,7 @@
- +
{name} @@ -267,7 +277,7 @@ aria-label="Model" >
Turns - {turnCount} + {getTurnCount()}
@@ -332,10 +342,10 @@ class:meter-danger={computedContextPct >= 90} >
- {#if fileRefs.length > 0} + {#if getFileRefs().length > 0}
- - {#each fileRefs as ref} + + {#each getFileRefs() as ref}
{ref}
@@ -344,7 +354,7 @@ {/if}
- {#each displayMessages.slice(-10) as msg} + {#each getDisplayMessages().slice(-10) as msg}
{msg.role} {msg.content.slice(0, 60)}{msg.content.length > 60 ? '...' : ''} diff --git a/ui-electrobun/src/mainview/blink-store.svelte.ts b/ui-electrobun/src/mainview/blink-store.svelte.ts new file mode 100644 index 0000000..dffb1a8 --- /dev/null +++ b/ui-electrobun/src/mainview/blink-store.svelte.ts @@ -0,0 +1,28 @@ +/** + * Blink store — global blink state for status dots. + * + * StatusDot reads this directly. No component receives blink as a prop, + * preventing cascading re-renders of entire ProjectCard trees every 500ms. + */ + +let _visible = $state(true); +let _intervalId: ReturnType | undefined; + +export function getBlinkVisible(): boolean { + return _visible; +} + +export function startBlink(): void { + if (_intervalId) return; + _intervalId = setInterval(() => { + _visible = !_visible; + }, 500); +} + +export function stopBlink(): void { + if (_intervalId) { + clearInterval(_intervalId); + _intervalId = undefined; + } + _visible = true; +} diff --git a/ui-electrobun/src/mainview/ui/StatusDot.svelte b/ui-electrobun/src/mainview/ui/StatusDot.svelte index 0295a58..67e8f93 100644 --- a/ui-electrobun/src/mainview/ui/StatusDot.svelte +++ b/ui-electrobun/src/mainview/ui/StatusDot.svelte @@ -1,20 +1,23 @@