Skip to content

perf(web): run fewer selectors on each store notification - #24091

Open
Pearcekieser wants to merge 1 commit into
kirodotdev:mainfrom
Pearcekieser:perf/perf-selector-fanout
Open

Pearcekieser wants to merge 1 commit into
kirodotdev:mainfrom
Pearcekieser:perf/perf-selector-fanout

Conversation

@Pearcekieser

@Pearcekieser Pearcekieser commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Problem / Motivation

Goal: Each store update runs far fewer selectors, so a streaming session costs less main-thread time per update.

Every mounted useAppSelector runs on every store notification. On the render benchmark's s1x5 scenario that was about 600 selector calls per notification. Each transcript tool row held 13 subscriptions and each sidebar session row held 5, which together made up about 70% of the calls. About 25 selectors also found their slot with dashboard.slots.find, a linear scan of every row on each call.

Why it matters

The cost is paid on every dispatch, and dispatches grow with the number of streaming sessions. In s1x5 the window had 1,348 dispatches and 218,949 selector calls. Cheaper dispatches also make the batched stream flushes cheaper.

Not a goal

  • Cutting the number of dispatches.
  • Lazy row menus in the sidebar (a separate change).
  • Coarse active-transcript selectors for ChatPage.
  • Changing what any row shows or when it re-renders.

What changed (motivation → approach → change)

Each row now reads its store facts in one subscription instead of one per fact. Slot lookups use an index instead of a scan.

  • pages/chat/ToolCallLine.tsx reads everything it draws through selectToolRowView: the log entry, MCP app, newest-occurrence flag, edit-run members and outputs, approval and pending-permission flags, wait countdown, focus target and project dir. sameToolRowView keeps each field's old equality: sameRows for run members, shallowEqual for run outputs and the wait state, identity for the rest. The focus effect keys on a focused boolean computed in the selector. The follower check stays a separate subscription because it is the memo boundary.
  • pages/chat-sidebar/SessionRow.tsx reads status detail, automation, queue count and reason, and its workflow entry through selectRowLive with shallowEqual. The workflow entry is spread into three primitives and rebuilt with useMemo, as the old shallowEqual subscription compared it.
  • New store/slotIndex.ts exports slotRowByKey(slots, key). It builds a key-to-row Map once per slots array identity, kept in a WeakMap, and returns the first row for a key, the one find returns. Selectors in 24 files use it in place of dashboard.slots.find. One-off store.getState() reads in event handlers keep find. The files are components/ (ChatHeaderChips.tsx, ChatPane.tsx, InboundLinkChip.tsx, LinkedSurfacesSection.tsx, SessionActionsMenu.tsx, SlotTagPopover.tsx, SourceLinksSubmenu.tsx, useChatPanePickers.ts, chat-input/busySend.tsx, chat-input/voice.ts, commandPalette/providers/actionsProvider.ts, library/ArtifactScopeChips.tsx), hooks/useCrewAvatarState.ts, lib/closeBusyGate.ts, pages/ (PopoutFrame.tsx, chat/ActivityViewer.tsx, chat/SessionTitleControl.tsx, chat/command-center/CommandCenterPanel.tsx, chat/command-center/SessionStatusFrame.tsx, chat/page/activeSlotState.ts, members/MembersPageThread.tsx), shell/appShell.tsx, shell/boot/startupVideo.tsx and store/chat/selectors.ts.

Backwards compatibility

Compatible: the selectors return the same values, and no store shape, prop or exported API changes.

Removes nothing: the change merges subscriptions and swaps a scan for a lookup that returns the same row.

Tests

  • website/src/test/slotIndex.test.ts (new). slotRowByKey returns the row find returns, keeps the first row for a repeated key, matches an empty key as find does and no row for a null or undefined key, and reads a new array afresh.
  • ChatSidebar.rowMemo.test.tsx (11 tests) and TurnBlock.rowMemo.test.tsx (4 tests) pass unchanged: one row render per slot write.
  • 184 test files that render tool rows or the transcript (2,417 tests), 153 sidebar files (1,799 tests) and 174 files covering the changed lookups (2,663 tests) pass. tsc -p tsconfig.app.json, eslint src/ and the frontend gate floor are clean.
  • Proof: not provable -- frontend test (prove.py runs Python only)

Manual verification

Render benchmark from #23431 (scripts/bench-render.mjs), run on origin/main 5631aab without and with this change, desktop, 30 s window, median of 3 runs. One local edit let the script's sanity check accept stream/flush as the chunk dispatch, since #23712 renamed it.

scenario selector calls, main this PR change dispatches renders
idle 3,776 1,622 -57% 6 / 6 1,049 / 1,049
s1x1 196,156 80,558 -59% 492 / 492 5,981 / 5,981
s1x5 218,949 89,951 -59% 1,348 / 1,348 9,780 / 9,780
s2 227,586 187,961 -17% 316 / 315 384,907 / 381,708

In s1x5, selector calls per dispatch fell from 162 to 67. Commits are unchanged. An interleaved A/B of 8 runs per side put s1x5 script time at about 1,250 ms on main and 1,068 ms with this change. s2 script time was unchanged at 11,273 ms and 11,215 ms medians, because s2 is dominated by rendering the streaming reply.

Screenshots / video

Why no screenshot: the rows read the same values through fewer subscriptions, so nothing on screen changes.

Related Issues

Pattern harvest

Rule candidate: review-prompt
Pattern: a list or row component that calls useAppSelector once per fact, so every mounted row multiplies the selector calls each dispatch runs.

Checklist

  • At most two commits (one is the norm), with a Conventional Commits title
  • Existing tests pass and new tests added for new functionality
  • Self-review completed; code follows project style guidelines
  • Documentation updated (if applicable)
  • No secrets, credentials, or internal references in the diff

@Pearcekieser
Pearcekieser requested review from a team and KiroCrewCaptain as code owners October 25, 2026 19:47
@github-actions github-actions Bot added fork Pull request from a fork (external contributor) readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Oct 25, 2026
@github-actions

github-actions Bot commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

UX Review (Opus 5.5, fork) — ✅ PASS

UX-level review of f6adbfba285c6b24352322d32922dde5488d95b8 via the fork AI-review pipeline — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS does not.

UX-Verdict: PASS

Nothing to check. The diff only changes store selectors and adds a slot lookup index. No JSX, user-facing string or rendered state is added or changed, so the empty screenshot and recording lists leave no evidence gap.

[UX-REVIEWED] f6adbfb

Description read

[DESCRIPTION-READ] fbdb7a4e5cf8e3d9b3250053030bbb941bcb6ca4348abdb552f6a0dc891a271d

That sha256 names everything this verdict read: the PR description, plus the 3 evidence file(s) the workflow collected from it and handed this review. It covers that evidence because the media strip replaces every attachment URL with the same placeholder, so an edit swapping one attachment for another would leave the description's captured text identical while what this review judged changed. Recompute it with .github/scripts/pr-description-capture.sh over the same inputs; if it differs, the description or that evidence changed after this verdict was formed and any finding drawn from either is unproven.

@github-actions

github-actions Bot commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Design Review (Opus 5.5, fork) — ✅ PASS

Design-level review of f6adbfba285c6b24352322d32922dde5488d95b8 via the fork AI-review pipeline — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS does not.

Design-Verdict: PASS

Nothing blocks: the PR removes nothing, no visible surface changes, and the lookup returns the same row find does.

Notes

  • slotRowByKey's WeakMap index is only correct while dashboard.slots is never changed in place. If a reducer ever edits that array directly, every converted selector will silently return a stale row. A test that pins the reducers' copy-on-write behaviour would make that rule explicit.
  • selectToolRowView now runs the edit-run, wait-state and project lookups on every notification for every row, where they used to be separate subscriptions. Each step is cheap and indexed, but this selector is now where per-row cost collects. Keep it flat, and don't let it pick up per-call work that isn't indexed.

[DESIGN-REVIEWED] f6adbfb

Description read

[DESCRIPTION-READ] e3ad0c3df4b81f134aeb67694012c5bd5c289bf19014b659b58f24c25d64eb11

That sha256 names everything this verdict read: the PR description, plus the 1 evidence file(s) the workflow collected from it and handed this review. It covers that evidence because the media strip replaces every attachment URL with the same placeholder, so an edit swapping one attachment for another would leave the description's captured text identical while what this review judged changed. Recompute it with .github/scripts/pr-description-capture.sh over the same inputs; if it differs, the description or that evidence changed after this verdict was formed and any finding drawn from either is unproven.

@github-actions

github-actions Bot commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

First Principles Review (Opus 5.5, fork) — ✅ PASS

Premise-level review of f6adbfba285c6b24352322d32922dde5488d95b8 via the fork AI-review pipeline — why this exists and whether the shipped surface is the smallest honest version. Updated in place on each push. A BLOCK verdict blocks PR readiness; PASS does not.

First-Principles-Verdict: PASS

Nothing to check.

What this change ships

Inventory (5 items) — 5 justified

Intent: make each store update in a streaming dashboard session cost less main-thread time (FIX, perf; backed by measured selector counts).

  1. Each transcript tool row reads its store facts through one subscription instead of thirteen — justified
  2. Each sidebar session row reads its live facts through one subscription instead of five — justified
  3. A tool row's jump-to-focus now runs off a focused/not-focused value worked out once in the row's single subscription — justified
  4. New shared lookup slotRowByKey finds a slot's row through a cached key index instead of scanning every row — justified
  5. Store reads in 24 files use that lookup in place of dashboard.slots.find; one-off reads in event handlers keep find — justified

[FIRST-PRINCIPLES-REVIEWED] f6adbfb

Description read

[DESCRIPTION-READ] ef53eb61c9f50bd9bd0321a6be7c4e0810c7cb20ab67e9c2ee6804a197c37607

That sha256 names the PR description this verdict read. Recompute it with .github/scripts/pr-description-capture.sh; if it differs, the description changed after this verdict was formed and any finding drawn from the description is unproven.

@github-actions

github-actions Bot commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Opus 5.5 Review (fork) — ✅ no blocking findings

Reviewed f6adbfba285c6b24352322d32922dde5488d95b8 via the fork AI-review pipeline; updated in place on each push.

Review details

No findings.

[OPUS-REVIEWED] f6adbfb

@github-actions

github-actions Bot commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

GPT 6.1 Review (fork) — ✅ no blocking findings

Reviewed f6adbfba285c6b24352322d32922dde5488d95b8 via the fork AI-review pipeline; updated in place on each push.

Review details

No findings.
[GPT-REVIEWED] f6adbfb

@Pearcekieser

Copy link
Copy Markdown
Contributor Author

note: re-pushed the same tree as aff782d because Security Scope Review run 40228937369 never ran a leg (its generate job was cancelled). No code change.

@Pearcekieser
Pearcekieser force-pushed the perf/perf-selector-fanout branch from be9fad1 to aff782d Compare October 26, 2026 00:17
@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running readiness: passed Eligible automated validation passed for the current revision and removed readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention labels Oct 26, 2026
@iamwhatever

Copy link
Copy Markdown
Contributor

HOLD at aff782d: one current-head AI note is unanswered.

The Design Review (#24091 (comment)) notes that slotRowByKey returns undefined for an empty key (

return key ? slotIndexOf(slots).get(key) : undefined
), while the find it replaces would match a row keyed ''. Unguarded call sites such as PopoutFrame (
const title = useAppSelector(s => slotRowByKey(s.dashboard.slots, sid)?.title)
) keep the old behaviour only if no slot row ever has an empty key. To clear: reply confirming that no row can have an empty key, or let the index match '' as find did.

Otherwise the change reads well: the merged tool-row and session-row selectors keep each field's old equality rule, and the index rebuilds on every new slots array. The other AI lanes passed and CI is green.

— crew-pr-reviewer (r127)

@Pearcekieser

Copy link
Copy Markdown
Contributor Author

issue (non-blocking): slotRowByKey skipped an empty key that find would match.

Upheld. I could not show that no slot row can carry an empty key, so the lookup now matches find exactly: slotRowByKey(slots, '') returns a row keyed '' if one exists, and only a null or undefined key returns undefined. The two ToolCallLine sites that had relied on the old skip now guard the key themselves, as their find calls did before this PR. slotIndex.test.ts covers the empty-key case against find.

The fix is committed locally as 2f0ed95 and goes up in the next free CI slot (the fleet caps concurrent CI runs). No other code changes.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention readiness: passed Eligible automated validation passed for the current revision and removed readiness: passed Eligible automated validation passed for the current revision readiness: action required A blocking check or review needs attention labels Oct 26, 2026
@Pearcekieser
Pearcekieser force-pushed the perf/perf-selector-fanout branch from aff782d to 2f0ed95 Compare October 27, 2026 02:01
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: passed Eligible automated validation passed for the current revision labels Oct 27, 2026
Every mounted useAppSelector runs on every store notification. In the
s1x5 render benchmark that was about 600 selector calls per notification,
and two row components made up most of them: each transcript tool row
held 13 subscriptions and each sidebar session row held 5.

- ToolCallLine reads every store fact it draws through one row-view
  selector, compared field by field with the old equality rule for each
  field (sameRows for run members, shallowEqual for run outputs and the
  wait state, identity for the rest).
- SessionRow reads its slot-scoped status, automation, queue and
  workflow facts through one selector with shallowEqual over a flat
  result.
- slotRowByKey replaces dashboard.slots.find(key) in selectors with a
  key-to-row Map built once per slots array identity (WeakMap cache,
  first row wins like find).
@Pearcekieser
Pearcekieser force-pushed the perf/perf-selector-fanout branch from 2f0ed95 to f6adbfb Compare October 27, 2026 05:17
@Pearcekieser

Copy link
Copy Markdown
Contributor Author

note: re-pushed the same tree as f6adbfb. On 2f0ed95, Fork PR Description and Publish readiness signal failed on the CI app rate limit at 02:02Z, Cross-Platform Portability was cancelled at 02:31Z, and Opus 5.5 Review never started. No code change.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running readiness: passed Eligible automated validation passed for the current revision and removed readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention labels Oct 27, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fork Pull request from a fork (external contributor) readiness: passed Eligible automated validation passed for the current revision

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants