Skip to content

Rework desktop message-timeline scrolling: de-virtualize + native overflow-anchor#1338

Merged
wesbillman merged 11 commits into
mainfrom
brain/timeline-scroll-rework
Jun 29, 2026
Merged

Rework desktop message-timeline scrolling: de-virtualize + native overflow-anchor#1338
wesbillman merged 11 commits into
mainfrom
brain/timeline-scroll-rework

Conversation

@wesbillman

@wesbillman wesbillman commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator

Rework desktop message-timeline scrolling: native scroll + smaller cold window

What & why

The desktop message list was virtualized, which made scroll position fragile: the reading row jumped on older-history loads, pages could cascade off a single scroll gesture, and short channels cold-loaded top-anchored then shifted down. This reworks the timeline to keep loaded rows in the DOM and lean on browser/native scroll anchoring instead of a virtualizer-specific restore stack.

The first de-virtualized version fixed the scroll feel but cold-mounted ~300 rows and produced a visible longtask (~600ms in earlier measurement). The final commit keeps the native-scroll behavior while opening channels on a smaller recent window so cold entry does not synchronously build hundreds of rows.

Changes

  • Render loaded timeline rows directly with content-visibility instead of virtualizing the main message timeline.
  • Re-enable native overflow-anchor on the timeline and delete the virtualizer-era convergence / restore machinery.
  • Keep a single scroll writer per concern: bottom-pin, target jump, and the explicit top-edge prepend hold where needed.
  • Reduce the initial channel history window from 300 to 60 events. Older history streams in on scroll-up via the existing pagination path (standard chat behavior) instead of being pre-built on open.
  • Preserve find/deep-link behavior outside the initial window:
    • find-in-channel relay hits are allowed even if not currently loaded;
    • the active hit is cached/spliced into the same target-event path used by deep links;
    • the DOM scroll retries after the row commits.
  • Add/refresh no-shift e2e coverage for timeline prepend + late reflow and thread-panel late reflow.

Behavior change to review

Oldest history is no longer pre-built on channel open. A channel opens on the recent window and loads older history as the user scrolls up. Find/deep-link targets outside the 60-row window are fetched/spliced on demand so they still land in the timeline.

Out of scope / follow-up

The deep-history top-edge bounce is tracked separately and is not part of this PR.

Validation

Local + reviewer-verified before push:

  • pnpm exec biome check ...
  • pnpm exec tsc --noEmit --pretty false
  • pnpm build
  • cold-switch-longtask.perf.ts: median longest longtask ~115–133ms (down from ~630ms de-virt baseline) ✅
  • scroll-history.spec.ts: 14/14 ✅
  • timeline-no-shift.spec.ts: timeline maxDrift: 0; thread-panel maxDrift: 0 with meaningful rAF sample count ✅
  • virtualization.spec.ts non-timeline coverage ✅
  • unit suite: 1172/1172 ✅
  • Wes hands-on approval: scroll feels good / ready for PR ✅

@wesbillman

Copy link
Copy Markdown
Collaborator Author

@codex please review

1 similar comment
@wesbillman

Copy link
Copy Markdown
Collaborator Author

@codex please review

@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: f870c12f61

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".


if (messagesArrived > 0) {
setNewMessageCount((current) => current + messagesArrived);
} else if (messagesArrived > 0) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Do not count prepended history as new messages

When the reader is scrolled up and fetchOlder prepends older history, messages.length - prevCount is positive even though no latest message arrived (the tail id is unchanged). Because this branch now runs for every positive growth, the scroll-to-latest pill reports the fetched history page as “new messages” instead of staying at “Jump to latest”; this happens on normal scrollback pagination and was previously avoided by the prepend check. Please gate the counter on an actual append/new latest message, not on prepends.

Useful? React with 👍 / 👎.

Comment on lines +334 to +335
if (!jumpToMessage(searchActiveMessageId, { behavior: "smooth" })) {
pendingSearchTargetRef.current = searchActiveMessageId;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Clear stale pending search targets on active-match changes

If a relay search hit is outside the rendered window, this stores it in pendingSearchTargetRef; but a later successful jump to another match or closing the find bar never clears that ref. When the async spliced event for the old hit eventually commits, the retry effect below will jump back to that stale match even though it is no longer active. Clear the pending target whenever searchActiveMessageId changes to a different value/null or when the immediate jump succeeds.

Useful? React with 👍 / 👎.

@wesbillman

Copy link
Copy Markdown
Collaborator Author

Adversarial review: I found one CHANGE blocker. I tried to submit this as a Request Changes review, but GitHub rejected it because this token is the PR author, so posting as a normal PR comment instead.

CHANGE: the timeline rows are not actually using content-visibility

The PR description and design rely on rendering loaded rows directly with content-visibility to keep de-virtualization from turning into a full layout/paint cost as the loaded window grows. But the row wrapper is currently:

<div className="ln-auto" key={getTimelineItemKey(item)}>
  {renderItem(item)}
</div>

in desktop/src/features/messages/ui/TimelineMessageList.tsx.

I cannot find any .ln-auto utility/class definition in the repo. The existing utilities are named content-visibility-auto, content-visibility-auto-interactive, and content-visibility-auto-row in desktop/src/shared/styles/globals.css. So this appears to be a typo / stale class name, and the shipped DOM gets no content-visibility: auto or contain-intrinsic-size on timeline rows.

Why this matters:

  • This PR removes virtualization, so content-visibility is the main mitigation for keeping offscreen loaded rows cheap.
  • The smaller 60-event cold window helps initial entry, but after scrollback / long-lived sessions the cache can retain many loaded messages (up to the visible-content cap), and every loaded row will fully participate in style/layout/paint if the class is inert.
  • It invalidates the stated design and perf validation axis: the tests/perf numbers are meant to validate “de-virtualized + content-visibility”, but the implementation is currently “de-virtualized without content-visibility”.

Suggested fix: replace ln-auto with the existing content-visibility-auto utility (or define ln-auto explicitly if that name is intentional), then rerun the relevant perf/no-shift checks. I’d also consider adding a small regression/assertion around the row wrapper class/style because unknown Tailwind/CSS utility typos won’t be caught by typecheck.

Otherwise

The high-level scroll design looks coherent to me: native anchoring for normal in-viewport reflow, one explicit anchored-row restore for top-edge prepends, smaller cold window, and search/deep-link splice/retry for targets outside the initial window. I did not find another blocker in the scrollback / target retry paths.

Verdict: Request changes until timeline rows actually get the intended content-visibility behavior.

wesbillman and others added 11 commits June 29, 2026 08:41
…ll anchoring

Render all loaded timeline rows directly via a `.map` wrapped in the
`ln-auto` (content-visibility) utility instead of `VirtualizedList`, and
remove `[overflow-anchor:none]` from the scroll container so the browser's
native scroll anchoring holds the reading position across reflow/prepend.

Collapses the virtualizer-consumer wiring in MessageTimeline: drops the
index-model deep-link convergence, the index bottom-pin, and the
virtualizer option fed into the scroll hooks. Jump-to-message is now purely
DOM-based (all rows are mounted). The floating active-day header (which was
portaled and driven by the virtualizer's top-visible index) is removed here;
a scroll-driven replacement lands in a follow-up.

First commit of the timeline scroll rework. Hook-internal cleanup
(useAnchoredScroll / useLoadOlderOnScroll virtualizer branches, the orphaned
convergence modules) and the JS prepend-delta removal follow in later
commits, gated on the native-anchoring de-risk probe.

Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
…horing

With the timeline de-virtualized and `overflow-anchor` re-enabled, the browser
holds the reading position across an older-history prepend on its own. The
non-virtualized load-older handler's `capture height -> double-rAF ->
restoreScrollPosition(prev + delta)` was a second scroll writer that would
double-correct the same reflow once native anchoring is live. Remove it: the
sentinel now only triggers `fetchOlder` and re-arms the observer.

This is the native-anchoring de-risk gate — the no-shift probe re-runs against
this commit to confirm the <=2px hold survives with no JS scroll write masking
it. The dead virtualizer branch in this hook is removed in a follow-up cleanup.

Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
The native-anchoring de-risk gate passed (no-shift probe: 0px drift, 0 missing
samples, 240 frames, with no JS prepend restore), so the windowing-era scroll
machinery is now dead code. Remove it:

- useLoadOlderOnScroll: drop the entire virtualizer prepend-restore branch
  (index re-aim, abandon-to-bottom, ResizeObserver cede plumbing) and the
  options it needed. The hook is now just an IntersectionObserver that triggers
  fetchOlder and re-arms; native anchoring holds the position.
- Delete useConvergentScrollToMessage and scrollConvergence (+ its test):
  deep-link is pure DOM scrollIntoView now that all rows are mounted.
- MessageTimeline: stop consuming the now-removed hook return values.

Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
With native scroll anchoring proven (no-shift probe: 0px through prepend +
above-anchor reflow, JS delta gone), the hook's JS message-anchor restore is
the redundant second writer the one-owner rule forbids. Remove it and the
windowing-era machinery it implied:

- delete restoreAnchorToMessage / findNearestNewerMessageId (the
  computeAnchor -> scrollBy(0, delta) restore) and both call sites (layout
  effect + ResizeObserver);
- drop the convergence cede, the load-older cede, the isPrepend cede, and the
  settle-window (settlingRef);
- drop the convergeToTarget / pinToBottomByIndex options and the
  restoreScrollPosition / setLoadOlderRestoreInFlight / getAnchorIsAtBottom
  result surface (all unused after the prior cleanup).

Preserved behaviors (native anchoring does NOT cover these): mount bottom-pin,
stick-to-bottom-on-append, send-snap, at-bottom re-pin on reflow, DOM deep-link
+ its retry-on-commit target effect, isAtBottom / new-message-count.

822 -> 439 lines. tsc + biome clean. Gated on the final no-shift probe pass.

Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
…-out

Accumulate every relay batch of a scrollback pass and commit to the cache
once, so a reply-heavy window grows in a single bounded step instead of
painting in several small increments under one spinner. Remove the
[overflow-anchor:none] opt-out from the thread panel body so native scroll
anchoring can hold the reading position there too.

Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
The de-virtualization rework relied on native `overflow-anchor` to hold scroll
position across an older-history prepend, but the browser suppresses scroll
anchoring at the top edge (no anchor node above the viewport at scrollTop ~0),
so a prepend left the user pinned at offset 0 staring at the oldest of the new
page. And because nothing pushed scrollTop off 0, the load-older observer
re-armed into the still-intersecting sentinel and cascaded page-after-page to
the channel start.

Three coordinated changes, one concern each:

- useAnchoredScroll: when a prepend lands while anchored mid-history, re-pin the
  anchored row to its saved offset by id (single scroll writer for the prepend).
  Runs in the post-commit layout effect, after the deferred snapshot commits, so
  the row's true position is known. Native anchoring keeps in-viewport reflow.

- useLoadOlderOnScroll: one long-lived observer that fires once on entering the
  trigger band then disarms until the sentinel leaves and re-enters — a fresh
  scroll-up gesture. Replaces the re-arm-on-resolve that double-fired because the
  prepend commits a few frames late. One gesture -> one page.

- Widen the trigger band to 600px (PRELOAD_MARGIN_PX) so the next page preloads
  while scrolling. Kept below a typical prepend height so the landed page still
  pushes the sentinel out of the band and re-arms the gate.

Adds a regression test asserting the reading row stays fixed across a prepend,
and a fetch-count probe in the e2e bridge for the one-page-per-gesture test.

Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
A channel shorter than the viewport painted top-anchored: the scroll
content column only got min-h-full for the intro/empty surfaces, so the
message-list case had no slack for its mt-auto to consume and the list
sat snug at the top. Extend min-h-full to the list surface so mt-auto
bottom-pins a short channel from the first paint, matching a full
channel — and so a late older-history prepend lands above the content
without shifting the rows the reader is on.

Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
…tualization

The de-virtualization rework left behind machinery that only existed to
serve the old windowed renderer, now unreferenced:

- ActiveDayHeader: a floating day label needed only because in-stream day
  dividers unmounted when scrolled out of the virtual window. Rows persist
  in the DOM now, so the sticky in-stream DayDivider holds the viewport top
  again and the portaled label is dead. Delete the component.
- resolveActiveDayTimestamp + the indexByMessageId map: the former fed the
  floating label; the latter was the virtualizer's O(1) deep-link resolver.
  Scroll is DOM-based now (querySelector), so nothing consumes either.
  Remove both, simplify buildTimelineItems to return { items }, and drop
  their unit tests. Refresh the stale "virtualizer" doc comments.

Also fix scroll-history e2e "preserves user scroll while older channel
history loads": its floating-day-header assertions targeted the removed
ActiveDayHeader and failed on a null lookup. Drop those assertions and the
getActiveDayHeader helper; the scroll-preservation poll (the test's real
guarantee) is unchanged and still passes.

Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Open channel timelines with a smaller recent-history window so native-scroll timelines do not synchronously build hundreds of rows on cold entry. Older history still streams in via the existing scroll-up pagination path.

Fetch/splice find-in-channel hits that live outside the initial window so search targets can still be scrolled into view, and retry DOM target scrolling after the row commits. Keep the no-shift coverage aligned with the smaller initial window.

Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Do not count older-history prepends as new bottom messages while keeping the anchored-row restore for scrollback pagination.

Clear stale pending find targets when the active match changes or clears, so a delayed splice cannot jump back to an inactive search hit.

Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
…ut prepend drift

The row wrapper used an inert `ln-auto` class that was never defined, so
the de-virtualized timeline never actually got content-visibility. Swapping
in the shared `content-visibility-auto` utility (intrinsic-size 200px)
over-reserves the common ~54px row by ~2x, which shifts the prepend anchor
and breaks the no-shift reading-position guarantee.

Add a timeline-scoped `.timeline-row-cv` class with `contain-intrinsic-size:
auto 60px` (at the short-message floor; over-reserving breaks anchoring,
under-reserving self-corrects via the `auto` keyword after first render).
The shared 200px utility is left untouched for sidebar/forum surfaces.

Add a class-agnostic e2e guard asserting a timeline row resolves
content-visibility: auto via computed style, so a future typo/removal can't
ship inert again.

Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
@wesbillman wesbillman force-pushed the brain/timeline-scroll-rework branch from 107198d to e4d9cf8 Compare June 29, 2026 14:45
@wesbillman wesbillman merged commit 4d61969 into main Jun 29, 2026
25 checks passed
@wesbillman wesbillman deleted the brain/timeline-scroll-rework branch June 29, 2026 14:56
tlongwell-block pushed a commit that referenced this pull request Jun 29, 2026
Brings the merged #1321 multi-tenant relay rewrite (and other main
commits) under the chart-fix branch so #1348 renders and tests against
current main. No conflicts.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>

* origin/main:
  Multi-tenant Buzz relay: community_id as a server-resolved key (comprehensive rewrite) (#1321)
  Disable persona start while runtime discovery runs (#1353)
  chore(deps): update dependency @tanstack/react-virtual to v3.14.4 (#1342)
  Fix sidebar unread indicator placement (#1319)
  fix(desktop): un-clip hover action bar's upward bleed under content-visibility (#1354)
  Allow Huddle between 2 humans in DM (#1347)
  chore(release): release Buzz Desktop version 0.3.36 (#1352)
  Polish agent runtime cards (#1327)
  Rework desktop message-timeline scrolling: de-virtualize + native overflow-anchor (#1338)
  Keep wave huddles pending for placeholder profiles (#1349)
wesbillman added a commit that referenced this pull request Jun 29, 2026
… on loaded CI

The "does not teleport upward when user abandons fetch by jumping to bottom"
smoke test couples a 5s mock history-fetch delay with a 6s poll for the
prepend to land. The wheel-up + abandon path burns 2-3s of that window, so on
a loaded CI runner the ~1s margin evaporates and the poll times out (it failed
all 3 retries in one run while passing locally). The prepend is a deterministic
5s mock timer and the no-teleport assertion runs only after it lands, so the
only failure mode is waiting too little — widening the poll to 12s removes the
flake without weakening the test. Pre-existing test (last touched in #1338);
unrelated to the perf change in this branch.

Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
wesbillman added a commit that referenced this pull request Jun 29, 2026
Several scroll-history smoke tests pace a mock history fetch by a few seconds
then poll on tight 3-6s budgets for the prepend to land. On fast machines they
pass (verified 4-5x locally per test); on a loaded CI runner the wheel-up loop
+ render eats the margin and the poll times out, and an early sample of the
oldest rendered row can read null before the rows paint. Both are races on
deterministic mock events, so the assertions (no upward teleport / anchored
row holds) are unchanged — only the waits are widened and the baseline sample
now waits for a concrete rendered row.

- "abandons fetch by jumping to bottom": landing poll 6s -> 12s.
- "older-history prepend keeps the reading row fixed": guard the oldest-index
  baseline behind a poll-for-a-rendered-row; landing poll 4s -> 8s.
- "preserves user scroll while older channel history loads": same baseline
  guard; landing poll 3s -> 8s.

Pre-existing tests (last touched in #1338); unrelated to the perf change in
this branch (which only reduces render work and does not touch the scroll path).

Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant