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') },
];
let COMMANDS = $derived<Command[]>(
COMMAND_DEFS.map(d => ({
// Build commands once — NOT $derived (creating new array per evaluation causes loops)
function buildCommands(): Command[] {
return COMMAND_DEFS.map(d => ({
id: d.id,
label: t(d.labelKey as any),
description: d.descKey ? t(d.descKey as any) : undefined,
shortcut: d.shortcut,
action: d.action,
}))
);
}));
}
let COMMANDS = buildCommands();
let query = $state('');
let selectedIdx = $state(0);

View file

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