Skip to content

fix(components): restore Virtua row measurements when a session reopens - #695

Merged
zxch3n merged 3 commits into
mainfrom
fix/conversation-open-blank-flash
Sep 14, 2026
Merged

zxch3n merged 3 commits into
mainfrom
fix/conversation-open-blank-flash

Conversation

@zxch3n

@zxch3n zxch3n commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

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.tsx is the obvious suspect and is
not 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:

t=  0 ms  visible  rows=6  scrollTop=124834/125609   ← outgoing conversation
t= 55 ms  hidden   rows=0  scrollTop=120954/121728   ← BLANK; height is an estimate
t= 89 ms  hidden   rows=2  scrollTop=124835/125609   ← corrected, still hidden
t=111 ms  visible  rows=6                            ← readable again

Summary

Cache Virtua's row measurements per session, next to the existing reading
position, and hand them back through Virtualizer.cache so a reopen lays out
at the real height and reveals a commit earlier.

  • use-scroll-position-cache.tssaveVirtualizerCache / getVirtualizerCache
    over a second per-session LRU; clearScrollPosition / clearAllScrollPositions
    clear both.
  • use-sticky-scroll.ts — read the snapshot at mount (Virtualizer consumes
    cache only then), persist when the initial layout settles and after
    scrolling stops.
  • view.tsx — pass cache, call the persist hook from onScrollEnd.

Three guards, all covered by ablation-verified tests:

  • The snapshot is consumed on the first render that actually mounts the
    virtualizer
    , not the first render with a positive item count. useStickyScroll
    runs 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.
  • The snapshot is positional, so it is restored only when the row count is
    unchanged. A conversation that grew while closed starts cold rather than
    sizing shifted indexes.
  • Measurements are stored on settle and on scroll-end, never at unmount:
    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:

click
  │
  ├─ React builds the stream items ................ unchanged
  │
  ├─ commit 1: Virtua mounts, viewportSize unknown ─┐
  │            rows = 0, height = ESTIMATE          │  BLANK PANE
  ├─ commit 2: rows measured, height CORRECTED ─────┤  (gate holds it hidden
  │            restore offset rewritten             │   across the correction)
  ├─ commit 3: isInitialScrollLayoutReady() → true ─┘
  │
  └─ visible

with the cached snapshot:

  ├─ commit 1: Virtua mounts with REAL row heights ─┐  BLANK PANE
  │            no correction needed                 │  (1 commit, not 3)
  ├─ commit 2: ready → visible ─────────────────────┘

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 session
page does it). First column is the cold, uncached open.

build 1 2 3 4 5
main 61 52 54 53 54 ms
cache restore, read ungated 68 57 36 57 57 ms
this branch 64 34 17 35 17 ms

The 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.ts extended with four cases (existing suite,
    no new file). Each was ablation-verified — the fix was removed and the
    test observed to fail:
    • revert to the item-count condition → "waits for the real rows when the
      session opens on its empty state" fails with
      expected undefined to be [ 'settled' ]
    • drop the restore → "hands the previous measurements back" fails
    • drop the row-count guard → "starts cold when the conversation grew" fails
    • drop the settled guard → first attempt passed silently, so the test was
      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 and
    public boundary), pnpm --filter @lody/e2e check, pnpm run docs check
    (0 errors; 34 AGENTS.md size warnings are pre-existing).
  • Numbers above reproduced with
    e2e/scripts/capture-conversation-open-flicker.mjs against production
    Storybook builds of main and this branch.
  • Not done: no measurement inside the packaged Electron app. The reported
    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 format also rewrites apps/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

  • Review focus: use-sticky-scroll.ts — the mount-time snapshot read is a
    ref captured on the first render with rows, which is safe only because
    session-chat-interface.tsx keys the stream on session.id; and
    persistVirtualizerCache's two call sites.
  • Decisions to challenge: caching an opaque third-party snapshot keyed by
    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.
  • Plausible failures / evidence gaps: a session whose row count is unchanged
    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 just
    the unit coverage.

Authoring context

  • User goal / directives: reproduce the post-feat: read long conversations by window with shared history writes #376 open flicker with
    Playwright + Storybook, then fix it.
  • Constraints / non-goals: no change to the windowed read path, the history
    writer, or feat: read long conversations by window with shared history writes #376's gate semantics.
  • Risk-bearing decisions: a new per-session in-memory LRU holding Virtua
    snapshots; restoring one is gated on an unchanged row count.
  • Destructive or irreversible behavior: none. The cache is in-memory,
    cleared with the existing scroll-position cache, and a miss is the current
    cold path.
  • Deliberately not done or tested: not measured in the packaged app; the
    remaining ~33 ms blank is not addressed, as it needs Virtua's mount behavior
    to change.
  • Unknowns / confidence: high confidence in correctness (ablation-verified
    tests, full suite green); moderate confidence that the app-side improvement
    matches the lab ratio.

zxch3n and others added 2 commits September 14, 2026 14:51
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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +151 to +155
if (!initialVirtualizerCacheRef.current.taken && itemCount > 0) {
initialVirtualizerCacheRef.current = {
taken: true,
value: getVirtualizerCache(sessionId, itemCount),
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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>
@zxch3n

zxch3n commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

👍 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. useStickyScroll is called at view.tsx:1435, well above the empty-state early return at view.tsx:1771, so it runs even on renders that never mount Virtualizer. buildChatVirtualRows skips the empty sentinel, so virtualRows.length is 0 — but session-chat-interface.tsx always passes a non-null leading fragment, making leadingRowCount 1. itemCount > 0 was therefore true during document acquisition, the lookup answered for a one-row list, latched taken, and the real conversation never asked again.

Fix. useStickyScroll takes hasVirtualizedRows and consumes the snapshot only when the caller is about to mount the virtualizer.

The repro story was also wrong, which is why I did not catch this. It passed no leadingContent, so its empty phase reported itemCount === 0 and the latch never fired early — the synthetic lab improved while the shipped path did not. The story now renders the empty state on every switch and supplies the same non-null fragment the session page does.

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):

1 2 3 4 5
main 61 52 54 53 54 ms
cache fix, read ungated (the bug you found) 68 57 36 57 57 ms
with hasVirtualizedRows 64 34 17 35 17 ms

The middle row is back at baseline, which is the end-to-end confirmation of the report.

New regression test waits for the real rows when the session opens on its empty state in tests/use-sticky-scroll.test.ts. Ablated back to the pre-fix condition it fails with expected undefined to be [ 'settled' ] — i.e. it fails exactly the way you described, not incidentally.

Full suite green: 476 files, 3620 tests.

@zxch3n
zxch3n merged commit b73c365 into main Sep 14, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant