Preferences storm
Debouncing the rightPanel resize feedback loop (#1041) — Corvu's idempotent re-emits + a no-equals prefs cell cause ~200 writes/min; the fix splits apply-local-now from a coalesced, patch-space server flush.
Issue #1041 · part of #951 · pre-existing, surfaced by the #1034 daemon-restart postmortem.
What was happening
The right-panel splitter and the Code-tab tree splitter are both @corvu/resizable fully controlled by a sizes prop fed from the local-authority preferences store. Two facts about Corvu turn that into a write storm:
The handler persisted it on every emission, with no equality gate and no coalescing:
// App.tsx:604-614 (and CodeTab.tsx:482-483, vertical split)
onSizesChange={(sizes) => {
if (sizes[1] !== undefined) rightPanel.setPanelSize(sizes[1]);
}}
// useRightPanel.ts:118-128
setPanelSize: (size) => { if (size > MIN_PANEL_SIZE) updatePreferences({ rightPanel: { size } }); },
setCodeTabTreeSize: (size) => { if (size >= MIN_TREE_SIZE && size <= MAX_TREE_SIZE) updatePreferences({ rightPanel: { codeTabTreeSize: size } }); },
// wire.ts:72-78 → useCell.ts:189-192 (local authority)
patch: async (p) => { applyLocal(p); await options.mutate(p); } // sync store write + server RPC, every call
sizes propcreateEffect fires onSizesChange([…])setPanelSize → updatePreferences({rightPanel:{size}})useCell.patch: applyLocal (sync store) + mutate → server RPCstate.json on every patch (no equals on the prefs cell)The shared sink is real: preferences and the session autosave are two keys in one Conf store writing one state.json; the autosave is a 500 ms throttle, preferences is one-disk-write-per-patch.
state.ts:88-127 (single Conf, prefs+session keys) · session.ts:114-133 (500ms autosave) · server.ts:134-143 (applyAndPublish equals gate) · surface.ts:99-114 prefs cell has no equals; :128 session cell does.
The pivotal constraint (this is what picks the design)
Could we just stop controlling Corvu (pass initialSizes, let Corvu own live size, debounce the whole write)? No. ChromeBar reads the panel size reactively, mid-drag, to keep the floating chrome’s right edge pinned to the panel’s left edge:
// ChromeBar.tsx:112-114
right: rightPanel.collapsed() ? 0 : `${rightPanel.panelSize() * 100}vw`,
If the store lagged the drag, the chrome controls would visibly trail the splitter. So the local store write must stay synchronous every frame; only the server RPC may be deferred. That rules out the uncontrolled-Corvu refactor and forces a split: apply-local-now, flush-to-server-later.
The fix
1 Drop Corvu’s idempotent re-emits — useRightPanel.ts
The size mutators are the adapter for Corvu’s re-emit quirk — so they filter it. Skip the write when the value matches the stored value within Corvu’s precision (it rounds to 6 decimals). This alone kills the steady-state storm: every unchanged re-emit becomes a true no-op (before the fix, it fired an RPC on every re-emit before reconcile dedup’d the store).
const EPSILON = 1e-6; // Corvu fixToPrecision rounds to PRECISION=6
setPanelSize: (size) => {
+ if (size > MIN_PANEL_SIZE && Math.abs(size - rp().size) > EPSILON)
updatePreferences({ rightPanel: { size } });
},
// same Math.abs(size - rp().codeTabTreeSize) > EPSILON guard on setCodeTabTreeSize
Framed as “filter Corvu’s idempotent re-emits,” not “reduce server load” — that’s why it lives at the adapter and not in the transport layer (per Lowy).
2 Coalesce the server flush at the local/server seam — useCell.ts (useCellLocal)
A real drag emits dozens of distinct values/sec, each passing guard 1. Those are the legitimate burst to collapse. The split lives where the local/server boundary already lives — inside useCellLocal, the one place that owns “apply locally, then tell the server.” Add an opt-in coalesceMs; the cell owner declares its flush cadence in wire.ts:
// useCellLocal: apply locally every call (sync — ChromeBar tracks live),
// trailing-debounce only the server mutate.
patch: async (p) => {
applyLocal(p); // sync store write, unchanged
if (coalesce) coalesce.push(p); // trailing debounce → mutate(merged)
else await options.mutate(p);
},
// wire.ts — opt in for preferences only:
app.cells.preferences.use({ authority: "local", initial: DEFAULT_PREFERENCES,
+ coalesceMs: 150,
onError });
Merge in patch-space, don’t snapshot. The coalescer accumulates pending patches by folding them through the cell’s existing patch-merge into one PreferencesPatch, then flushes that. This keeps the wire payload in P (patch) space — so an interleaved {collapsed} write inside the 150 ms window is merged in, not clobbered, and we never push a full Preferences (T) through a patch-typed mutate. (This is the post-review shape — see below.)
3 Defense-in-depth: equals on the server prefs cell — server surface.ts
Mirror the session cell’s equals so any no-op patch that still reaches the server skips the disk write + republish. Honest billing: once the client drops no-ops (1) and coalesces (2), this is belt-and-suspenders — it only catches the rare unchanged-final-value case. One line, established precedent, fine to land; not load-bearing.
test Regression guard
Unit tests assert bounded patch rate during a resize interaction: a tight loop of unchanged values emits zero RPCs (guard 1); a rapid sequence of changing values emits ≤1 trailing RPC per window (coalesce 2) — useCellCoalesce.test.ts plus coalesce assertions in useRightPanel.test.ts.
Reviewer pass — what changed
Surface & sequencing
| File | Change | Part |
|---|---|---|
useRightPanel.ts | EPSILON no-op guard in the two size mutators | 1 |
useCell.ts | coalesceMs opt-in: sync local apply + trailing patch-merge flush; doc the await-contract change | 2 |
wire.ts | coalesceMs: 150 on the preferences cell; add @solid-primitives/scheduled dep | 2 |
server/src/surface.ts | equals on the prefs cell (mirror session) | 3 · optional |
*.test.ts | bounded-patch-rate regression near useRightPanel tests | test |
Bug fix, not user-visible feature work — shipped as a single PR, #1050 , merged 2026-05-30; issue #1041 closed.