perf(desktop): subscribe to session UI state at the granularity each surface reads - #1998
Merged
Merged
Conversation
Astro-Han
force-pushed
the
perf/desktop-session-ui-subscription
branch
from
August 3, 2026 11:58
265f207 to
3a8ea86
Compare
`createAppShellSessionUiStateController` is an external store, but it only ever had a single `onChange` wired to one `forceRender()`. Add `subscribe` and a selector hook so a component can follow one derived reading of the store instead of every write to it (#1985). The selector caches on a caller-supplied equality: `useSyncExternalStore` requires a snapshot that keeps its identity while nothing it selects changed, so a selector deriving a fresh object must say what "unchanged" means for it. Without that the shell's own snapshot selector loops rather than merely over-rendering, which the new contract test pins. `LiveTurnSnapshot` is the low-entropy reading of a live turn — phase, a few booleans, the settled message id — everything the shell derives from the active projection except the streamed content itself. A text delta cannot change it. The aggregate top-level subscription still stands; moving its readers is the next commit.
AppShell destructured the whole session UI store, so a write to any slice re-rendered the entire shell — including one write per streamed token. The subscription now matches what each surface reads (#1985): - AppShell selects the six low-frequency maps by raw reference, plus a `LiveTurnSnapshot` and the sidebar's pulse set by value. None of them change when a delta grows the streamed text. - ChatMessageSurface subscribes to the projection and the shell-run record itself. It is their only renderer, so they never reach the shell. - The per-delta reconcile moves into <LiveTurnReconciler/>, which follows every delta and owns no subtree. `useShellLiveTurn` now takes the snapshot rather than the projection, and `deriveModelWait` takes booleans — it only ever asked whether the buffers were empty. This needs no `memo`: the sidebar and composer stop re-rendering because their parent does, not because a comparison blocks them.
…fect event `useEffectEvent` returns a fresh identity every render and must not cross a component boundary or enter a dep array. `reconcilePersistedMessages` already comes from `useStableActions`, whose whole purpose is a fixed identity bound to the latest committed render — the wrapper made the dep array dishonest and re-ran the reconcile for unrelated shell renders.
…oize it Three things the review surfaced, one cause: the selector seam was shaped so that neither the compiler nor the tests could see what the shell reads. - `useAppShellSessionUiReads` is now that list, in one place. The contract test drives the hook itself, so adding a token-rate selection to it fails the test — before, the test asserted against a copy of the list and a real regression in AppShell would have gone green. - Selectors are module-level and take what they vary by as `arg`, so the snapshot is memoized rather than published through a render-phase ref write. React permits that write only for lazy initialization; a discarded concurrent render would otherwise hand its selector to the committed subscription. `arg` also makes the activeId switch explicit, now covered. - `LiveTurnSnapshot.streamingTextComplete` is gone: `streamingMessageId` is set only when the text step completed, so its presence already carried that fact. The freed field is `turnId`, which `deriveTurnActive` needs.
A settled turn must drop its phase but keep its id — `deriveTurnActive` reads the first to retire this renderer's arm and the second to tell a sibling turn apart from it. And the handoff message id must stay absent while the text step is open, since its presence is what says the step closed.
Astro-Han
force-pushed
the
perf/desktop-session-ui-subscription
branch
from
August 3, 2026 12:02
3a8ea86 to
b2ff08e
Compare
`useSyncExternalStore` reads a snapshot several times for one store state — in the subscription callback, during render, and again in a passive effect — and demands the same value each time. The cache only compared the previous VALUE, so a selector deriving a fresh object handed React a new identity on every call and force-rendered forever; the only thing standing between the app and a freeze was every caller remembering to pass `isEqual`. Key the cache by the state it derived from. Idempotence per store state now belongs to the one adapter that connects arbitrary derivations to the store, `isEqual` drops to what it should have been (carrying a value's identity ACROSS a state that did not change the selection), and each selector runs at most once per store change instead of once per read.
…ribers `selectLiveTurn` was defined word-for-word in both the chat surface and the reconciler, so changing one would silently leave the other behind. Keep it with the rest of the session-UI selectors, where the snapshot selector can build on it too. `deriveLiveTurnSnapshot` also allocated a flattened tool array per call just to ask two yes/no questions of it.
Three of these tests could not fail for the reason they named.
The settled-turn snapshot case built `{...armLiveTurn(id), terminal: true}`,
a projection the reducer cannot produce: `complete` and `abort` both return
`undefined` for a turn with no steps. It also only asserted on fields, while
the two #1987 Stop witnesses actually meet one layer up, in `useShellLiveTurn`
— which had no behavioural test at all. Build the projection from a real
`text_delta` + `complete` and assert on `turnActive` through that adapter, so
dropping `turnId` on the way in fails here.
The render-boundary case selected through a COPY of the shell's snapshot
selector, the very pattern this branch removed elsewhere. Fold its positive
assertions (the arm and the first token each cost one render) into the case
that drives `useAppShellSessionUiReads` itself, which was asserting only that
deltas cost nothing — leaving the snapshot comparator free to be gutted.
Astro-Han
marked this pull request as ready for review
August 3, 2026 12:23
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
AppShellContentdestructured the whole session UI store, so every write to any slice re-rendered the entire shell — including one write per streamed token. The session list, the composer, and any open non-chat surface all re-rendered at token rate, though none of them readliveTurnBySession.The controller was already an external store; it only lacked per-subscriber notification. This adds
subscribeand a selector hook, then moves each read to the boundary that owns it:useAppShellSessionUiReadsis the shell's complete read, in one named place: six low-frequency maps by raw reference, plus aLiveTurnSnapshotand the sidebar's pulse set by value. A delta changes none of them.ChatMessageSurfacesubscribes to the projection and the shell-run record itself. It is their only renderer, so they never reach the shell.<LiveTurnReconciler/>carries the per-delta reconcile that used to sit in AppShell's effect list. It follows every delta and owns no subtree.Two findings shaped this beyond the issue's original framing:
subscribe+ selector alone saves nothing. The component holding the selector isAppShellContent. Selecting the full projection there re-renders the whole subtree exactly as before. What makes it work is that the shell's reads turn out to be low-entropy already — evenhomeSurfaceActiveonly wanted three.length === 0booleans. OnlyChatMessageSurface's two props genuinely need token-rate data, so only that read had to sink.The chat surface needs no
memo. The sidebar and composer stop re-rendering because their parent does, not because a comparison blocks them. That is why this avoids stabilizing ~25 props per panel.useShellLiveTurnnow takes the snapshot rather than the projection, andderiveModelWaittakes booleans — it only ever asked whether the buffers were empty.Rebased onto #1987.
LiveTurnSnapshotcarriesturnIdsoderiveTurnActivekeeps both its witnesses; the snapshot'sphaseis already absent once terminal, which is the other one.streamingTextCompletewas dropped from the snapshot in the same pass —streamingMessageIdis set only when the text step closed, so its presence already carried that fact.Closes #1985
Verification
npm --workspace apps/desktop run test:dist— 1384 pass, 0 fail (includes fix(desktop): take the running turn from the run, not from session status #1987'sderiveTurnActivematrix).npm --workspace apps/desktop run typecheck— clean (preload, main, renderer, storybook).npm run format:check/npm run lint— clean.npm --workspace apps/desktop run e2e— 60 passed, 1 failed:plan-reminders.spec.ts:75 › closes a reminder menu before opening its edit dialog(an autofocus assertion in the reminder form). Pre-existing: it fails identically on unmodifiedorigin/mainat1530ac4d5, verified by checking that commit out in the same worktree and running the same spec. Not addressed here.Contract coverage in
ui-render-memo-boundary-contract.test.ts, on the existingcreateRoot+act+ fake-DOM harness — no new test framework:useAppShellSessionUiReadsitself, no delta after the first re-renders the shell;activeIdunder a still store reads the new session on the first render.Plus two snapshot facts in
app-shell-session-ui-state.test.ts: a settled turn drops its phase but keeps its id, and the handoff message id stays absent while the text step is open.Verified by mutation, not just by passing. Adding a
state.liveTurnBySessionselection to the production hook turns the render-count test red. Dropping the value-equality step does not merely over-render —useSyncExternalStorerejects the fresh snapshot outright with "The result of getSnapshot should be cached" and loops.Not run: no screenshot or Storybook evidence — this PR removes renders and changes no pixels.
Review focus
useSyncExternalStorerequires a snapshot that keeps its identity while nothing it selects changed, so a selector deriving a fresh object must supply an equality. Three places depend on that discipline:getSnapshotmust never build aSet/array/object without a matching comparator.ChatMessageSurfaceselects the raw per-session shell-run record and doesObject.valuesin auseMemofor this reason.arg, so the snapshot is memoized rather than published through a render-phase ref write — React permits that write only for lazy initialization, and a discarded concurrent render would otherwise hand its selector to the committed subscription.LiveTurnSnapshotmust stay free of buffers. Anything added to it whose identity changes per delta silently puts the whole shell back on the token path; the render-count contract is the guard.Notification is synchronous, immediately after the state swap. A scheduler there (rAF or otherwise) would delay the terminal-turn handoff, which reads the state it announces.
activeSessionIdis passed toChatMessageSurfaceexplicitly rather than derived fromactiveSession.id. They are equal today, but only because the shell substitutes a placeholder carrying the same id for an unsaved chat.Known gap
ChatMessageSurface's own subscription is not pinned by a unit contract — removing it would leave these tests green and be caught only by the E2E streaming journey. Doing better means rendering the real surface with its fullChatViewdependency tree, which the fake DOM does not currently support. Tracked as a follow-up rather than fixed here.useAppShellSessionUiStatestill re-exports the controller's eleven setters alongside the controller itself. Collapsing that mirror touches every setter call site across the session-event and effect wiring, so it is deliberately out of scope.