Skip to content

threads:changed ships all 545 rows on every state change and the renderer replaces the list wholesale #617

Closed

Description

@currentbits

#91 fixed this for thread:updated: memoized panes, split the streaming subscription from the list subscription, added patchThreadList so a streaming thread patches one row. The sibling channel never got the same treatment and now does exactly what #91 was closed for.

Measured on the live workspace (2026-08-20)

threads in store                  545   (532 archived)
threads:changed payload           0.76 MB, all 545 decorated rows, every time
structuredClone of that payload   2.6 ms   (Electron IPC path)
JSON.parse of that payload        1.4 ms   (web / wireClient path)
pushThreadsChanged() call sites   59 in electron/runner.js

pushThreadsChanged (runner.js:1813-1815) is unconditional:

function pushThreadsChanged() {
  pushFn("threads:changed", services.listThreads(store));
}

It fires on per-turn state transitions — turn start, turn end, permission prompt raised, verify result, title landed, status flips (six of the 59 sites are inside the stream onEvent handler). Not per token, so this is not a render storm on one thread; it is a fixed 545-row cost paid on every state change of every thread, growing with every thread ever created. 532 of those rows are archived and cannot have changed.

The main-process cache cannot do what its comment says

listThreads (services.js:3321-3346) is carefully written to preserve row identity:

// ...reuse decorated rows keyed on the thread object so an updateThread that
// only patched one row keeps every other row's identity (the renderer's
// patchThreadList and memo'd cards key on that).

Object identity does not survive ipcRenderer structured clone (or JSON.parse on the wire path). Every row arrives in the renderer as a brand-new object no matter what the cache did. The compute saving is real — listThreads returns in 0–1 ms — but the stated purpose, keeping the renderer's memos alive, is unreachable from that side of the boundary.

The renderer then throws away what identity it had:

// useCoder.ts:618
const applyThreads = useCallback((next: ThreadInfo[]) => {
  threadsRef.current = next;
  setThreads(next);
}, []);

Straight replace. So each broadcast invalidates every memo keyed on threads: buildWaitStates(threads), liveById, projectById, displayThreads, buildFlatSidebar(...) over all 545 rows (Sidebar.tsx:1363-1440), and every card's props are new objects.

Someone already hit this and patched one memo by hand — threadTitles (Sidebar.tsx:1326-1340) does a manual field-wise compare and returns the previous Map when nothing changed. That workaround is the proof of the pattern; it just lives in one memo instead of at the boundary.

Fix: reconcile by value where the payload lands

One function in applyThreads: keep a by-id map of the current rows, and for each incoming row reuse the previous object when the fields are equal.

value-diff of the live 545-row payload: 2.7 ms, 544/545 rows reused

That 2.7 ms is a deliberately pessimistic measurement (whole-row JSON.stringify compare); a field-wise compare over 42 known keys is far cheaper. In exchange, 544 rows keep their identity, every list memo short-circuits, and every memo'd card skips its re-render — and the main-process cache's promise finally holds end to end.

Why this over trimming the protocol: most of the 59 sites change exactly one thread and could push a row patch instead of a whole list, but that is 59 call-site edits plus a protocol change, and it would not help the sites that genuinely do change list shape. One value-diff at the landing point fixes every channel and every caller at once. Trimming the payload can come later if the 0.76 MB serialize itself shows up in a profile.

Tests

  • applyThreads with an identical payload returns rows that are === to the previous ones; setThreads is called with an array whose members are the prior objects.
  • One changed row: that row is a new object, the other 544 are reused.
  • Add / remove / reorder still produce the correct list.
  • test/sidebar.test.tsx: a threads:changed that changes nothing does not re-run buildFlatSidebar (assert via a render counter on a card).

Related

#91 (same defect, fixed for the other channel), #564 (renderer transcript windowing — the other half of what the renderer holds), #225 (the same 545 threads cost the main process 310 ms per store flush), #418 (architecture umbrella), #553 (sidebar filters will add more list-level memos on top of this).

Activity

  1. added and removed on Aug 20, 2026
  2. currentbits commented on Aug 21, 2026

    @currentbits
    OwnerAuthor

    Implemented in this worktree: value-diff at the applyThreads landing point.

    reconcileThreadList reuses previous row objects when fields match (and keeps the array identity when nothing moved), so a threads:changed clone of 545 rows no longer invalidates list memos or memo'd cards. Protocol still ships the full list; trimming that payload is deferred as the issue said.

    Tests: test/threadPatch.test.ts (identity / one-row / add-remove-reorder), test/threadPatchWiring.test.tsx (applyThreads through useCoder), test/sidebar.test.tsx (card render counter on a no-op push).

  3. currentbits commented on Aug 22, 2026

    @currentbits
    OwnerAuthor

    Landed on main in the 2026-08-22 backlog merge (2d33d30..bf7c4dc, released as nightly nightly-202608220926-bf7c4dc).

    Evidence on main: src/threadPatch.ts; threads:changed ships a patch, not all rows

    The branch had been finished for a while but was never merged; verification after landing: typecheck clean, vite build ok, core 38/38, renderer 1468/1468, memory 204/204, electron 1848/1848.

  4. added and removed on Aug 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions