fix(components): restore Virtua row measurements when a session reopens - #695
Conversation
Doc-backed stories that mount `SessionChatStreamView` over a warm `ConversationView`: the LoroDoc, the view and every `HistoryWriter.append` happen while the stream is unmounted, so opening or switching costs exactly what the sidebar costs on an already-loaded session. `OpenLongConversation` toggles one conversation; `SwitchBetweenLongConversations` holds two warm 3,000-turn conversations under distinct session ids, because the reading position and stream-item caches are per session. `e2e/scripts/capture-conversation-open-flicker.mjs` drives them under Playwright and samples the pane every animation frame — viewport presence, computed visibility, committed row count, scroll offset — plus a screen recording. CPU throttling stretches the flash past the recording's frame period. Model: claude-opus-5 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Opening a long conversation showed an empty pane for ~55 ms on a production build, longer on a slower machine. The cause is not #376's visibility gate: ablating it kept the pane visible and the row set still emptied before it refilled, now also exposing the uncorrected scroll position. The cost is the cold virtualizer. With no measured row heights it lays a 3,000-turn conversation out at an estimated total height, writes the restore offset into that wrong coordinate space, and only corrects once the first rows are measured; the viewport stays hidden across those commits. Virtua's measurements are now cached per session next to the reading position and handed back through `Virtualizer.cache`, so a reopen lays out at the real height and reveals a commit earlier. Two guards: the snapshot is positional, so it is only restored when the row count is unchanged, and it is stored when the initial layout settles and after scrolling stops, never at unmount — React detaches the virtualizer ref before cleanup effects run. Switching between two warm 3,000-turn conversations: 54 ms blank before, 33 ms after. A first, uncached open is unchanged, and the remaining blank is the one commit Virtua needs before it knows its viewport size. Model: claude-opus-5 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8ac980a441
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| if (!initialVirtualizerCacheRef.current.taken && itemCount > 0) { | ||
| initialVirtualizerCacheRef.current = { | ||
| taken: true, | ||
| value: getVirtualizerCache(sessionId, itemCount), | ||
| }; |
There was a problem hiding this comment.
Delay consuming the cache until Virtua actually mounts
In the shipped SessionChatInterface path, useSessionDoc initially returns a null history while its async store acquisition runs, so buildChatStreamItems emits the empty sentinel and SessionChatStreamView takes the empty-state branch without mounting Virtualizer. However, the interface always supplies a non-null leadingContent fragment, making itemCount > 0; this block therefore permanently sets taken and looks up an irrelevant one-row cache. When the real history arrives, the valid session snapshot is never requested, so reopening an actual conversation still uses a cold virtualizer and retains the reported blank flash that this commit is intended to fix. Consume the snapshot only when the real virtualized rows are about to mount.
AGENTS.md reference: packages/components/src/hooks/AGENTS.md:L7-L10
Useful? React with 👍 / 👎.
The stored measurements were read on the first render with a positive item count, which on the shipped session path is the wrong render. A session whose document is still being acquired returns from the empty-state branch before `Virtualizer` mounts, yet every hook above that return has already run, and the session page's always non-null leading fragment counts as one row. The lookup therefore answered for a one-row list, latched, and never asked again for the real conversation — the blank flash stayed on the path the previous commit set out to fix. `useStickyScroll` now takes `hasVirtualizedRows` and reads the snapshot only when the caller is about to mount the virtualizer. The story missed this because it passed no `leadingContent`, so its empty phase reported zero items and the latch never fired early. It now renders the empty state on every switch and supplies the same non-null fragment the session page does. Switching between two warm 3,000-turn conversations on that faithful path: 54 ms blank on main, 57 ms with the read ungated, 17-35 ms with this fix. Reported by the automated reviewer on #695. Model: claude-opus-5 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
👍 Confirmed and fixed in f9d7ce8 — this was a real P1: the previous commit was close to a no-op on the shipped path. Verified the mechanism. Fix. The repro story was also wrong, which is why I did not catch this. It passed no Evidence. Production Storybook builds, switching between two warm 3,000-turn conversations on that faithful path, blank pane per switch (first row is the cold, uncached open):
The middle row is back at baseline, which is the end-to-end confirmation of the report. New regression test Full suite green: 476 files, 3620 tests. |
Related issue
Problem / pressure
After #376, opening or switching to a long conversation flashes an empty pane.
Reported with a screen recording: the outline rail stays drawn, the body goes
blank for several frames, then content pops in.
The visibility gate #376 added at
view.tsxis the obvious suspect and isnot the cause. Ablating it (forcing
visibility: 'visible', remeasuring,reverting) kept the pane visible while the row set still went
6 → 0 → 2 → 6,and additionally exposed the uncorrected scroll position for a frame. The gate
converts a visible jump into a visible blank; it does not create it.
The real cost is the cold virtualizer. With no measured row heights, Virtua
lays a 3,000-turn conversation out at an estimated total height, the restore
offset is written into that wrong coordinate space, and the correction only
lands once the first rows are measured:
Summary
Cache Virtua's row measurements per session, next to the existing reading
position, and hand them back through
Virtualizer.cacheso a reopen lays outat the real height and reveals a commit earlier.
use-scroll-position-cache.ts—saveVirtualizerCache/getVirtualizerCacheover a second per-session LRU;
clearScrollPosition/clearAllScrollPositionsclear both.
use-sticky-scroll.ts— read the snapshot at mount (Virtualizerconsumescacheonly then), persist when the initial layout settles and afterscrolling stops.
view.tsx— passcache, call the persist hook fromonScrollEnd.Three guards, all covered by ablation-verified tests:
virtualizer, not the first render with a positive item count.
useStickyScrollruns above the empty-state early return, and the session page's always non-null
leading fragment counts as a row, so a session still acquiring its document
would otherwise answer for a one-row list and never ask again. Caught by the
automated reviewer; see f9d7ce8.
unchanged. A conversation that grew while closed starts cold rather than
sizing shifted indexes.
React detaches the virtualizer ref before cleanup effects run, so the handle
is already gone there (found by instrumenting — the first attempt logged
handle=false).Visual explanation
Where the mount time goes, and what the fix removes:
The remaining blank is the one commit Virtua needs before it knows its
viewport size.
Before / after
Production Storybook build, 5 rounds, measured per animation frame.
Blank pane per switch between two warm 3,000-turn conversations, on the
faithful path (empty-state render + non-null
leadingContent, as the sessionpage does it). First column is the cold, uncached open.
mainThe middle row is the bug the reviewer caught: back at baseline, because the
snapshot was consumed during the empty state.
A shortening, not an elimination. The first, uncached open is unchanged by
design, and the residual blank is the one commit Virtua needs before it knows
its viewport size.
Test plan
tests/use-sticky-scroll.test.tsextended with four cases (existing suite,no new file). Each was ablation-verified — the fix was removed and the
test observed to fail:
session opens on its empty state" fails with
expected undefined to be [ 'settled' ]rewritten to exercise the real path (scroll-end arriving before the initial
layout settles); it then fails under ablation
pnpm --filter @lody/components test: 476 files, 3620 tests pass.pnpm typecheck,pnpm check:quick(lint, i18n, code-collab, platform andpublic boundary),
pnpm --filter @lody/e2e check,pnpm run docs check(0 errors; 34 AGENTS.md size warnings are pre-existing).
e2e/scripts/capture-conversation-open-flicker.mjsagainst productionStorybook builds of
mainand this branch.recording shows a longer flash than this synthetic lab, because real rows are
heavier; the lab understates the absolute win and does not prove the app-side
total.
pnpm formatalso rewritesapps/electron/.../app-updater-sparkle-policy.test.mjs;that is pre-existing repo-wide churn and was reverted as unrelated.
Context handoff
Instructions for reviewing agents
use-sticky-scroll.ts— the mount-time snapshot read is aref captured on the first render with rows, which is safe only because
session-chat-interface.tsxkeys the stream onsession.id; andpersistVirtualizerCache's two call sites.row count, rather than deriving row heights from the ConversationView index
rows we already have; and persisting on settle (few rows measured) as well as
on scroll-end.
but whose content changed in place would restore stale heights (Virtua
re-measures, so this should self-correct — untested); no Electron-side
measurement; the two lab conversations legitimately measure to different
heights on first mount, which is pre-existing and unexplained. The first
round of this PR passed its own tests while being near-inert on the shipped
path, so weigh the story's fidelity to
session-chat-interface.tsx, not justthe unit coverage.
Authoring context
Playwright + Storybook, then fix it.
writer, or feat: read long conversations by window with shared history writes #376's gate semantics.
snapshots; restoring one is gated on an unchanged row count.
cleared with the existing scroll-position cache, and a miss is the current
cold path.
remaining ~33 ms blank is not addressed, as it needs Virtua's mount behavior
to change.
tests, full suite green); moderate confidence that the app-side improvement
matches the lab ratio.