perf(ui): mount a switched-to transcript progressively - #2191
Conversation
Switching back to a long session rendered every turn in one React commit, and inserting ~22k nodes at once is a single ~0.35s frame (the apache#2052 trace: 147ms render, 57ms restyle, 144ms layout). The first commit now mounts only a tail window of turns; the rest arrive in idle chunks with scroll compensation, staying pinned inside the bottom lock and preserving the reading position anywhere else. The full turns array still feeds deriveTurnPresentation and the prompt rail, so presentation caching and rail geometry are unchanged. The turn-size warm-up starts once the fill completes, a rail click on a not-yet-mounted turn mounts it and then scrolls, and a search target is mounted before use-chat-scroll queries it. Closes apache#2052
…transcript-mount # Conflicts: # packages/ui/src/chat-view.tsx
|
Thanks for the careful attribution and performance work here. The measured improvement is valuable, and I think progressive mounting is a reasonable incremental step for this issue; this PR does not need to grow into full transcript virtualization or an Astryx-level change. A fresh review found three scoped items I would like to close before approval:
I’m treating the native scrollbar thumb shortening as the transcript grows, and broader fill/warm-up geometry stabilization, as non-blocking follow-up work. For this PR, preserving the reading anchor, restoring the prompt rail, covering the exact shrink boundary, retaining the measured first-commit improvement, and returning CI to green are sufficient. |
Review items from apache#2191: - the prompt rail's IntersectionObserver re-snapshots per fill step via mountedTurnsRevision, so turns mounted by the idle fill are observed and the first tick activates after scrolling to the beginning - reconcileMountWindow also re-windows when the turn count shrinks exactly to the window start; that boundary sliced to an empty transcript - the fill publishes data-progressive-fill on the scroller, and a new scroll-geometry E2E anchors on a historical turn and verifies its viewport position holds while earlier chunks mount
|
All three closed in 1004abe:
Local runs: ui unit 18/18, scroll-geometry + prompt-rail 9/9. 7f77b13 removes a local measurement probe I committed by accident in the same push. |
|
Thanks for addressing the three review items. The production changes look good to me now, and all other checks are passing. The one remaining blocker is the new progressive-fill geometry E2E, which fails in CI with the reading anchor moving by 7,974px. The test sets Could you make the test reliably establish the unlocked reading state before sampling the anchor, and keep the progressive-fill boundary distinct from the subsequent turn-size warm-up as needed? Once this test is deterministic and CI is green, I have no other blocking concerns with the PR. |
The anchor test raced two things it could not see. Astryx's scroll handler skips direction-based unlock whenever scrollHeight changed in the same event, and the fill grows the document every step, so a scripted upward scroll almost never unlocked the bottom follower and the spring re-pinned the viewport. And any protocol round-trip between asserting the filling state and installing the watch left a window for the fill to finish first. The watch now arms inside the page before the session switch is dispatched, releases the follower through its wheel fast path plus an upward write, proves the position holds across consecutive frames before sampling, samples only while data-progressive-fill reports filling, and fails loudly when the fill races instead of passing over nothing.
|
Fixed in be56008; CI is green. Cause: the fill grows the document every step, and Astryx ignores scroll direction when the height changed in the same event. So the test's scripted scroll rarely unlocked the follower, and the spring pulled the viewport back to the bottom. The test now unlocks the follower properly, waits until the position actually holds, and measures only while the fill is running. If the fill finishes too fast, it fails with a clear message instead of passing without measuring. Btw the prompt-rail failures I mentioned earlier also happen on clean main here, so that's just my local environment. |
Summary
Switching back to a long session froze the renderer for ~0.35s. The whole transcript mounted in one React commit, and 22k nodes in one commit cost 147ms render, 57ms restyle, and 144ms layout in the issue's trace. The issue already ruled out the spring scroll and the warm-up, so this reduces what mounts.
progressive-turn-mount.ts) and reconciled during render, so the small commit is the first commit. A bulk arrival without a session change (message loading, revision navigation) re-windows the same way; streaming appends land in the mounted tail and never touch the window.deriveTurnPresentationand the prompt rail. Presentation caching and rail geometry are not window-dependent; only the JSX mapping is sliced.onNavigateFallback). A search target is mounted beforeuse-chat-scrollqueries it.Closes #2052
Verification
Measured with the issue's own scenario on the e2e
long-transcriptfixture (24 markdown-heavy turns): a Playwright probe creates an empty session, switches back to the long one, and recordsPerformanceObserverlongtask entries, 5 rounds per configuration, 6x CDP CPU throttling for a stable contrast (unthrottled, this machine produces no >50ms tasks in either configuration).The first long task after the click is the switch-back commit, the one that blocks interaction:
Both configurations then show one ~130-160ms task at ~1.1s after the click; that is the pre-existing turn-size warm-up batch, which runs at idle and is unchanged here.
@maka/uisuite: 339 tests, 337 passed; the 2 failures are inastryx-form-controls-localizationand reproduce identically with clean main sources in this environment.ui-render-memo-boundary-contract(renders ChatView from dist and pins the memo-skip contract): 14/14.