Repository navigation
perf(chat): long-process topic switch/expand jank - lazy mount + paged step reveal + O(1) lookup - #243
Merged
Conversation
…d step reveal + O(1) step lookup
Report: switching to the topic where AIO worked on AIO itself (9 rounds /
18 messages) loads visibly slower than other topics, and expanding its
hundreds of intermediate steps collapses the frame rate. Measured the real
DB: that topic carries 16.53MB of agent_steps_json / ~1674 step cards (the
worst single message: 588 steps, 381 tool calls, 8.01MB). Three stacked
costs, all fixed:
1. Collapsed historical rounds still mounted their cards (the main switch
cost). max-height:0 hides without unmounting, so a topic switch built up
to 100 cards per past round (~469 cards across this topic's five big
rounds), each with its own effects, signals and 920ms entrance timer,
plus one linear stepById scan per card. Now the body is gated by
<Show when={isExpanded()}>: a collapsed round mounts ZERO cards (Solid's
false branch builds no DOM/components/effects) and the id-sync effect
short-circuits while collapsed, resolving lazily on expand.
2. 'Show earlier' mounted every remaining card in one frame. Replaced the
showAllSteps boolean with paged reveal: each click adds another
STEP_WINDOW (100) page and the button label counts down the remainder.
3. stepById ran timelineSteps().find(...) per card, and timelineSteps()
sliced the whole array on every call - ~345k element copies for a full
reveal. Now a memoized timeline + id->step Map make it O(n) once and
O(1) per card.
Supporting: content-visibility:auto + contain-intrinsic-size on the step
card (offscreen cards skip layout/paint, the MessageBubble pattern);
paged-in historical steps are pre-marked as animated (same contract as
ChatInterface's load-earlier) so expanding does not fire a hundred
simultaneous entrance animations.
Window arithmetic extracted to utils/stepWindow.ts with 6 unit tests
(active pass-through, tail-window bounds, page increments, short/empty
lists, negative reveal, countdown). Untouched: the active round's streaming
queue/auto-follow, DiffView (measured: 1 changed file - not a bottleneck),
and the legacy no-agentSteps ToolCallBubble fallback.
Verified: npm run verify green (93 FE tests incl. 6 new, gates, lint, tsc,
build; Rust suite green). Real-device frame timing still needs the installed
build - the mount arithmetic here is 469 -> 0 cards on switch and 588 -> 100
per reveal click.
Merged
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
Field report: switching from a new topic to the topic where AIO was working on the AIO project itself (9 rounds / 18 messages) loads visibly slower than other switches, and expanding its hundreds of intermediate steps collapses the frame rate.
I measured the real DB before touching anything — that topic carries 16.53 MB of
agent_steps_json/ ~1674 step cards:Three stacked costs, all fixed:
1. Collapsed historical rounds still mounted every card (the switch cost)
max-height: 0hides without unmounting — its own comment block in this file already warned about it. A topic switch therefore built up to 100 step cards per past round (~469 across this topic's five heavy rounds), each with its own signals/effects/memos and a 920 ms entrance timer, plus one linearstepByIdscan per card. The body is now gated by<Show when={isExpanded()}>: a collapsed round mounts zero cards (Solid's false branch builds no DOM, components, or effects), and the id-sync effect short-circuits while collapsed, resolving lazily on expand.2. "Show earlier" mounted every remaining card in a single frame (the expand cost)
The
showAllStepsboolean flipped totrueand revealed all 588 at once. Replaced with paged reveal: each click adds oneSTEP_WINDOW(100) page, the button counts down the remainder — bounded DOM growth per frame.3. O(n²) step lookup
stepByIdwastimelineSteps().find(...), andtimelineSteps()re-sliced the whole array on every call — roughly 345k element copies for a full reveal. Now a memoizedtimeline+id → stepMap: O(n) once, O(1) per card.Supporting changes
content-visibility: auto+contain-intrinsic-size: auto 56pxon the step card — offscreen cards skip layout/paint (the pattern already proven on MessageBubble rows, where this exact problem was solved before)utils/stepWindow.ts(pure, 6 unit tests)Deliberately untouched
DiffView— measured: this topic has 1 deduplicated changed file, it is not a bottleneckagentStepsToolCallBubblefallback (older data, not exercised here)Verification
npm run verifyfully green: 93 FE tests (6 new covering active pass-through, tail-window bounds, page increments, short/empty/negative inputs, countdown), gates, lint, tsc, build; Rust suite green