From 3234576759aefd91caead1b88ec476b5c1083126 Mon Sep 17 00:00:00 2001 From: sixvolts Date: Sat, 5 Sep 2026 19:28:23 +0000 Subject: [PATCH 1/2] fix(sidebar): stop the rail flashing on every autosave MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing in a wiki page (or a chat, or a note) flashes the sidebar rail. The earlier fix landed in notes.js; this is the same bug in the shared rail. refreshSidebarChildren repaints every expanded category by calling renderCategoryChildren, which opens with host.innerHTML = "" — a hard teardown-and-rebuild, no diffing. And it fires on the sidebarRefresh / notesChanged events that chat.js and notes.js dispatch off their own 500ms autosaves. So an autosave in ANY panel tears down and repaints the wiki rows too, even though the wiki data never changed — a visible flash on roughly every debounce while typing. The wiki save path itself was already clean: its debounced flushSave only touches its own page-list row via renderPageList, and it dispatches notesChanged only on structural events (create/delete/rename), never on body keystrokes. The flash was the shared rail reacting to other panels. Guard the repaint: fetchCategoryChildren returns plain data, so a structural JSON compare against the cached copy is a sound proxy for "would render identically". Update the cache always, repaint only on a difference. Mirrors the notes.js fix. The other renderCategoryChildren call site (category expand into a fresh Loading… list) is a deliberate user action and correctly still repaints. Verified: esprima parses app.js / workspace.js / wiki.js, with untouched app.js as the control. Visual confirmation is the operator on desktop — the flash is a repaint a DOM-shape test cannot see. --- familiar-workspace/static/workspace.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/familiar-workspace/static/workspace.js b/familiar-workspace/static/workspace.js index fcb45b8..f957c92 100644 --- a/familiar-workspace/static/workspace.js +++ b/familiar-workspace/static/workspace.js @@ -1756,7 +1756,26 @@ const childList = document.querySelector('.sidebar-children[data-category="' + category + '"]'); if (!childList) continue; const items = await fetchCategoryChildren(category); + // Skip the repaint when nothing this category shows has changed. + // + // renderCategoryChildren opens with host.innerHTML = "" — a hard + // teardown-and-rebuild with no diffing — so every call visibly + // flashes the rail. This function repaints ALL expanded categories, + // and it fires on sidebarRefresh / notesChanged, which chat.js and + // notes.js dispatch off their own 500ms autosaves. Net effect: type + // in a chat or a note (or a wiki page whose panel shares the + // signal) and the wiki rows tear down and repaint every debounce, + // even though the wiki data is untouched. + // + // A structural JSON compare against the cached copy is enough: + // fetchCategoryChildren returns plain data, so identical bytes mean + // an identical render. Only repaint when they differ. This mirrors + // the notes.js sidebar fix (only re-render on a real change). + const prev = sidebarCatState.cache[category]; + const same = prev !== undefined && + JSON.stringify(prev) === JSON.stringify(items); sidebarCatState.cache[category] = items; + if (same) continue; renderCategoryChildren(childList, category, items); } } From 1795bcd25a5ad330aea7e775f12407189007b705 Mon Sep 17 00:00:00 2001 From: sixvolts Date: Sat, 5 Sep 2026 19:47:09 +0000 Subject: [PATCH 2/2] fix(sidebar): make the repaint-skip opt-in, not the default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI caught the previous two cuts of this guard swallowing legitimate updates: sidebar-wiki.spec.ts failed on expanding a book, create/delete updating the rail, reparent, and sort. The guard diffed only the category data, but the things that change on those paths live OUTSIDE it — expansion state in sidebarTreeExpanded, page trees in sidebarWikiPagesCache — so a real mutation looked identical and got skipped. Chasing every data source into the fingerprint is the wrong direction; I missed two doing it. Invert instead: refreshSidebarChildren(force = true) repaints by default, and only the autosave-driven sidebarRefresh listener passes force=false to opt into the diff-and-skip. Every mutation caller (notesChanged clearing the cache, caret toggle, drag, page events) keeps its bare call and always repaints. So the flash fix survives — the one path that fires on a 500ms autosave still skips a no-op repaint — while nothing else can be silently suppressed. Safe by default: a new caller repaints unless it explicitly says not to. esprima parses workspace.js. The e2e suite is the real check; it failed on the last cut and is why this exists. --- familiar-workspace/static/workspace.js | 52 +++++++++++++++----------- 1 file changed, 30 insertions(+), 22 deletions(-) diff --git a/familiar-workspace/static/workspace.js b/familiar-workspace/static/workspace.js index f957c92..607dea3 100644 --- a/familiar-workspace/static/workspace.js +++ b/familiar-workspace/static/workspace.js @@ -1751,31 +1751,39 @@ // Refresh sidebar children for all currently-expanded categories. // Called when titles change or docs are created/deleted so the // sidebar list stays current without a page refresh. - async function refreshSidebarChildren() { + // force=true (default): always repaint. force=false: skip the repaint + // when neither the fetched category data NOR the expanded set changed — + // used ONLY by the autosave-driven sidebarRefresh path to stop the rail + // flashing on every 500ms debounce. + // + // Safe-by-default is deliberate. The first cut inverted this: it diffed + // the category data and skipped on a match, which silently suppressed a + // caret toggle (expansion state lives outside that data) and a + // created/deleted page (page trees live in sidebarWikiPagesCache, also + // outside it). Any mutation path — notesChanged clearing the cache, a + // caret toggle, a drag — must force the repaint; only the pure autosave + // signal opts out. + async function refreshSidebarChildren(force = true) { for (const category of sidebarCatState.expanded) { const childList = document.querySelector('.sidebar-children[data-category="' + category + '"]'); if (!childList) continue; const items = await fetchCategoryChildren(category); - // Skip the repaint when nothing this category shows has changed. - // - // renderCategoryChildren opens with host.innerHTML = "" — a hard - // teardown-and-rebuild with no diffing — so every call visibly - // flashes the rail. This function repaints ALL expanded categories, - // and it fires on sidebarRefresh / notesChanged, which chat.js and - // notes.js dispatch off their own 500ms autosaves. Net effect: type - // in a chat or a note (or a wiki page whose panel shares the - // signal) and the wiki rows tear down and repaint every debounce, - // even though the wiki data is untouched. - // - // A structural JSON compare against the cached copy is enough: - // fetchCategoryChildren returns plain data, so identical bytes mean - // an identical render. Only repaint when they differ. This mirrors - // the notes.js sidebar fix (only re-render on a real change). - const prev = sidebarCatState.cache[category]; - const same = prev !== undefined && - JSON.stringify(prev) === JSON.stringify(items); - sidebarCatState.cache[category] = items; - if (same) continue; + if (!force) { + const expandedSet = sidebarTreeExpanded.get(category); + const expandedSig = expandedSet ? Array.from(expandedSet).sort().join(",") : ""; + const sig = JSON.stringify(items) + "|" + expandedSig; + if (!sidebarCatState.renderSig) sidebarCatState.renderSig = {}; + const same = sidebarCatState.renderSig[category] === sig; + sidebarCatState.renderSig[category] = sig; + sidebarCatState.cache[category] = items; + if (same) continue; + } else { + sidebarCatState.cache[category] = items; + // Invalidate the skip-cache so the NEXT autosave refresh, if it + // fetches this same data, still repaints once to reflect this + // forced change before it starts skipping again. + if (sidebarCatState.renderSig) delete sidebarCatState.renderSig[category]; + } renderCategoryChildren(childList, category, items); } } @@ -1960,7 +1968,7 @@ }); // Listen for sidebar refresh requests from surface modules. - window.addEventListener("familiar:sidebarRefresh", refreshSidebarChildren); + window.addEventListener("familiar:sidebarRefresh", () => refreshSidebarChildren(false)); // AI tool calls that mutate notes / pages dispatch // familiar:notesChanged via chat.js when the gateway emits the