fix(electrobun): isolate blink state to store, prevent prop-cascade re-renders
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.
This commit is contained in:
parent
d08227fc98
commit
2709600319
4 changed files with 114 additions and 94 deletions
|
|
@ -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<typeof setInterval>;
|
||||
$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<string[]>([]);
|
||||
|
||||
$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 @@
|
|||
<div
|
||||
class="project-grid"
|
||||
role="list"
|
||||
aria-label="{activeGroup?.name ?? 'Projects'} projects"
|
||||
aria-label="{getActiveGroup()?.name ?? 'Projects'} projects"
|
||||
>
|
||||
{#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 @@
|
|||
<div
|
||||
class="empty-group"
|
||||
role="listitem"
|
||||
style:display={filteredProjects.length === 0 ? "flex" : "none"}
|
||||
style:display={getFilteredProjects().length === 0 ? "flex" : "none"}
|
||||
>
|
||||
<p class="empty-group-text">
|
||||
No projects in {activeGroup?.name ?? "this group"}
|
||||
No projects in {getActiveGroup()?.name ?? "this group"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -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)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -567,11 +545,11 @@
|
|||
|
||||
<!-- Status bar (health-backed) -->
|
||||
<StatusBar
|
||||
projectCount={filteredProjects.length}
|
||||
{totalTokens}
|
||||
{totalCost}
|
||||
projectCount={getFilteredProjects().length}
|
||||
totalTokens={getTotalTokensDerived()}
|
||||
totalCost={getTotalCostDerived()}
|
||||
{sessionDuration}
|
||||
groupName={activeGroup?.name ?? ""}
|
||||
groupName={getActiveGroup()?.name ?? ""}
|
||||
/>
|
||||
|
||||
{#if DEBUG_ENABLED && debugLog.length > 0}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue