fix(electrobun): eliminate remaining reactive cycles (tabs store + palette)

project-tabs-store: replaced Map reassignment (_tabs = new Map(_tabs)) with
version counter pattern. Map reassignment created new object reference on
every getActiveTab() call from $derived → infinite loop.

CommandPalette: replaced $derived COMMANDS array with plain function call.
$derived with .map() created new array every evaluation → infinite loop
when any i18n state changed.
This commit is contained in:
Hibryda 2026-03-23 22:19:38 +01:00
parent 9d45caa8df
commit 86251f9d92
2 changed files with 22 additions and 21 deletions

View file

@ -55,15 +55,17 @@
{ id: 'zoom-out', labelKey: 'palette.zoomOut', shortcut: 'Ctrl+-', action: () => dispatch('zoom-out') }, { id: 'zoom-out', labelKey: 'palette.zoomOut', shortcut: 'Ctrl+-', action: () => dispatch('zoom-out') },
]; ];
let COMMANDS = $derived<Command[]>( // Build commands once — NOT $derived (creating new array per evaluation causes loops)
COMMAND_DEFS.map(d => ({ function buildCommands(): Command[] {
return COMMAND_DEFS.map(d => ({
id: d.id, id: d.id,
label: t(d.labelKey as any), label: t(d.labelKey as any),
description: d.descKey ? t(d.descKey as any) : undefined, description: d.descKey ? t(d.descKey as any) : undefined,
shortcut: d.shortcut, shortcut: d.shortcut,
action: d.action, action: d.action,
})) }));
); }
let COMMANDS = buildCommands();
let query = $state(''); let query = $state('');
let selectedIdx = $state(0); let selectedIdx = $state(0);

View file

@ -1,13 +1,11 @@
/** /**
* Project tabs store per-project tab state. * Project tabs store per-project tab state.
* *
* Tracks which tab is active and which tabs have been activated (PERSISTED-LAZY * Uses a version counter to signal changes instead of Map reassignment.
* pattern) for each project card. This allows cross-component access e.g., * Map reassignment (`_tabs = new Map(_tabs)`) caused infinite reactive loops
* StatusBar can show which tab is active, palette commands can switch tabs. * in Svelte 5 because each call created a new object reference.
*/ */
// ── Types ────────────────────────────────────────────────────────────────
export type ProjectTab = export type ProjectTab =
| 'model' | 'docs' | 'context' | 'files' | 'model' | 'docs' | 'context' | 'files'
| 'ssh' | 'memory' | 'comms' | 'tasks'; | 'ssh' | 'memory' | 'comms' | 'tasks';
@ -23,7 +21,9 @@ interface TabState {
// ── State ──────────────────────────────────────────────────────────────── // ── State ────────────────────────────────────────────────────────────────
let _tabs = $state<Map<string, TabState>>(new Map()); // Plain Map — NOT reactive. We use _version to signal changes.
const _tabs = new Map<string, TabState>();
let _version = $state(0);
// ── Internal helper ────────────────────────────────────────────────────── // ── Internal helper ──────────────────────────────────────────────────────
@ -32,36 +32,35 @@ function ensureEntry(projectId: string): TabState {
if (!entry) { if (!entry) {
entry = { activeTab: 'model', activatedTabs: new Set(['model']) }; entry = { activeTab: 'model', activatedTabs: new Set(['model']) };
_tabs.set(projectId, entry); _tabs.set(projectId, entry);
// Trigger reactivity by reassigning the map // Do NOT bump version here — this is a read-path side effect
_tabs = new Map(_tabs); // that would cause infinite loops when called from $derived
} }
return entry; return entry;
} }
// ── Getters ────────────────────────────────────────────────────────────── // ── Getters (read _version to subscribe to changes) ─────────────────────
export function getActiveTab(projectId: string): ProjectTab { export function getActiveTab(projectId: string): ProjectTab {
void _version; // subscribe to version counter
return _tabs.get(projectId)?.activeTab ?? 'model'; return _tabs.get(projectId)?.activeTab ?? 'model';
} }
export function isTabActivated(projectId: string, tab: ProjectTab): boolean { export function isTabActivated(projectId: string, tab: ProjectTab): boolean {
void _version;
return _tabs.get(projectId)?.activatedTabs.has(tab) ?? (tab === 'model'); return _tabs.get(projectId)?.activatedTabs.has(tab) ?? (tab === 'model');
} }
// ── Actions ────────────────────────────────────────────────────────────── // ── Actions (bump version to notify subscribers) ────────────────────────
export function setActiveTab(projectId: string, tab: ProjectTab): void { export function setActiveTab(projectId: string, tab: ProjectTab): void {
const entry = ensureEntry(projectId); const entry = ensureEntry(projectId);
entry.activeTab = tab; entry.activeTab = tab;
entry.activatedTabs = new Set([...entry.activatedTabs, tab]); entry.activatedTabs.add(tab);
// Trigger reactivity _version++; // signal change without creating new objects
_tabs = new Map(_tabs);
} }
/** Remove tab state when a project is deleted. */
export function removeProject(projectId: string): void { export function removeProject(projectId: string): void {
if (_tabs.has(projectId)) { if (_tabs.delete(projectId)) {
_tabs.delete(projectId); _version++;
_tabs = new Map(_tabs);
} }
} }