Skip to content

perf(threads): virtualize the thread panel's replies - #2911

Draft
jameskraus wants to merge 2 commits into
jameskraus/thread-history-windowfrom
jameskraus/thread-virtualization
Draft

jameskraus wants to merge 2 commits into
jameskraus/thread-history-windowfrom
jameskraus/thread-virtualization

Conversation

@jameskraus

@jameskraus jameskraus commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #2869 (the sliding thread window); review that first.

The thread panel mounts only the top-level replies near its viewport, so a scroll gesture costs the same in a 5,000-reply thread as in a short one. This implements the "Bounded work" requirement in docs/channels.md#scrolling-long-threads.

Behavior

  • Virtual list. ThreadPanel renders its top-level replies, each with its branch, through Virtua's Virtualizer: the same library, patch and pattern as ChannelTimeline (as="ol", bufferSize 1600, keepMounted, shift). A custom item (ThreadItem in src/features/messages/thread-items.tsx) clones the reply's own li and merges Virtua's position style and measuring ref into it. Its class, data-layout, ref and the [data-thread-rows] > ol > li structure stay as they are, so the panel's CSS and selectors are unchanged. Items carry aria-posinset/aria-setsize for the loaded window. The root and its divider sit above the list as Virtua's startMargin.
  • Kept mounted. These stay mounted wherever they are: the exact target, an own send awaiting its reveal, the focused row, rows that ask through MessageRow's existing keepMounted (an open menu, report or edit), and the reply a Back/Forward return restores. Each resolves to the item that renders it: a top-level activity group renders its later entries inside its first reply's item. Focus is tracked by item, so a collapsed group's toggle counts too. A focused or selected item stays visible while Virtua re-measures it at a new index.
  • Reading anchors. Anchors skip rows Virtua has mounted at an estimated offset but not yet measured (checkVisibility), so the reply being read holds its place as pages land.
  • Both edges at once. Virtua's shift holds the scroll position from the list's end, so it is right only for a change at the start. feat(threads): read long threads through a sliding window #2869's window changes both ends in one snapshot: an older page lands at the top while a newer one is evicted at the bottom (or the reverse). stageEdges renders the start's change first with shift, then, in the same layout cycle, the end's change without it. Virtua's index-keyed size cache then always shifts by the right amount.
  • Unchanged. Keyboard selection (message-selection.ts already handles unmounted newest rows), exact reveal through useMessageReveal, the panel's reading/older/target anchors, follow-at-bottom and Jump to latest.

Measurements

bin/pnpm perf --scenario thread-scroll --repeat 3, medians, locally on one MacBook Pro. The base is #2869's head (fc039fc); the head is this branch. Chromium runs at 4× CPU throttle; WebKit at 1×, frames only.

Case Metric Chromium base → head WebKit base → head
thread-scroll-fling p95 frame interval 157 → 51 ms 35 → 17 ms
worst frame interval / frames over 33 ms 157 → 52 ms / 10 → 13 43 → 21 ms / 3 → 0
task time / LoAF blocking 1,308 → 900 ms / 504 → 0 ms —
thread-scroll-reverse p95 / worst frame interval 159 → 51 / 170 → 57 ms 32 → 20 / 54 → 26 ms
task time / LoAF blocking 2,820 → 1,759 ms / 1,144 → 0 ms —
thread-scroll-deep-page worst frame interval 1,502 → 159 ms 274 → 64 ms
task time / LoAF blocking 2,262 → 326 ms / 1,511 → 97 ms —
reading shift 0 → 0.25 px 0 → 0.25 px
thread-scroll-open first rows 750 → 728 ms 214 → 230 ms
all scroll cases mounted rows 443–481 → 14–15 443–481 → 15

The open case's first rows are unchanged. Chromium's "over 33 ms" counts move within noise, since its composited scrolling hides compositing stalls. The deep-page case asserts that the reply being read holds within a pixel.

Test changes

These tests assumed every loaded reply is in the DOM. Each change keeps what the assertion protected.

Assertion Change
About 20 counts of [data-message-id] / ol [data-message-id] in message-navigation, navigation-thread-history, thread-opening, messages, thread-window threadMessageCount / threadReplyCount (tests/browser/thread-rows.mjs): rendered rows plus each unmounted item, from aria-setsize. That is the old number, independent of the viewport.
thread-window uniqueness check over all mounted ids (305) The held count equals 305. A repeated reply would add an item.
thread-window "prepended page" index ≥ 50 among mounted rows The reading reply's aria-posinset ≥ 51.
messages and navigation-thread-history rows acted on far from the viewport scrollUntilMounted scrolls a viewport at a time, waiting rendering updates (no timers), until the row mounts.
navigation-thread-history live reply visible while reading above Above the reader the arrival loads without mounting: the held count rises by one.
thread-resize "thread stays beside the channel" one-shot bounds after a scale change Polls until the layout settles: rows re-measure after the change. 10/10 Chromium and 3/3 WebKit. Before, the snapshot caught a 1px transient overlap in 1 of 5 runs.
nested-replies hover after a programmatic scroll Waits frames() and for Virtua to resume hit-testing (pointer-events: auto), as the channel's clickRow does. Before, Playwright's hover retried scroll alignments into the tab bar.
thread-resize "contraction clamped with a live reply" The 700px spacer moves from inside a reply's row to just above the virtual list. Rows inside the list resize only once Virtua measures them, so they cannot clamp in the reply's commit; content above the list still does. Removing the panel's clamped handling fails it ("later update must not repeat resize correction"); with it, it passes.
Perf "gestures traverse history" (offset range > 5000px) repliesTraversed ≥ 10, from the first reply in view. WebKit's wheel train recorded 4,706px of offset for 6,000px of input: Virtua turns estimated heights into measured ones by moving the offset.

No browser case is added or removed. jsdom suites that mount the real panel use virtua.testing.tsx, a stand-in that mounts every item in the owner's container and item types and exposes keepMounted. thread-items.test.ts covers stageEdges. A ThreadPanel.test.tsx case keeps the item holding an activity group mounted for an exact link to its later step; resolving to the entry's own item instead fails it.

Browser runs, locally, of the 30 spec files that touch threads: WebKit 240/240 pass; Chromium 237 pass and 2 fail. The 2 failures are typing.spec.mjs:290 (React #185), which fails identically on origin/main (984cda2).

Readiness

Draft.

  • Agent review. Both blocking findings are fixed: keep-mounted for activity-group entries, and anchors on unmeasured rows (the reading shift). Optional findings fixed: the sent reveal retrying after the second pass, the [data-selected] rule, mounted-id uniqueness in thread-window. Left as known edges:
    • one painted frame can show a newly landed page's rows still unmeasured during the open fade-in;
    • thread-rows.mjs counts an unmounted item as one row, which holds while unmounted items show no open branch.
  • Native macOS harness run (scripts/perf/thread-native/run.mjs), not run yet.
  • Human test in the app:
    1. Open a long thread and scroll up and down quickly through several pages. Replies paint without blank bands, and the reply you're reading holds its place when pages land.
    2. Open a menu or start editing a reply, scroll far away and back. The menu or edit is still there.
    3. Open a link to a reply deep in a long thread. It is revealed and focused; Back and Forward return to where you were reading.
    4. Use Up/Down to select replies past the edge of the viewport. Selection moves into rows that were not mounted.
    5. Expand a nested branch, scroll away and back. It stays open.

jameskraus and others added 2 commits November 6, 2026 17:37
The thread panel mounts only the top-level replies near its viewport,
each with its branch, through the Virtua list the channel timeline
already uses. A custom item keeps each reply's own list item, class,
data attributes and ref, so the panel's CSS and selectors are unchanged;
items carry aria-posinset/aria-setsize for the loaded window. The root
and its divider sit above the list as Virtua's start margin.

Kept mounted wherever they are: the exact target, an own send awaiting
its reveal, the focused row, rows that ask through MessageRow's
keepMounted (an open menu, report or edit), and the reply a Back/Forward
return restores. A focused or selected item stays visible while Virtua
remeasures it.

Virtua's shift holds the scroll position from the list's end, so it is
right only for a change at the start. A sliding window changes both ends
in one snapshot; the panel renders the start's change first with shift,
then the end's change, before paint (stageEdges).

Browser tests that counted mounted rows count what the thread holds
(rendered rows plus unmounted items from aria-setsize), scroll a row into
mounting before acting on it, and wait for Virtua to resume hit-testing
before hovering. The perf suite and native harness count held replies the
same way and report mountedRows; gesture validity counts replies
traversed, since Virtua's corrections move the offset.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: James Kraus <[email protected]>
A top-level activity group renders its later entries inside its first
reply's item, so keep-mounted resolves an entry to that item, and focus
is tracked by item rather than by its first message (a collapsed group's
toggle has none). Reading anchors skip rows Virtua has mounted at an
estimated offset and not yet measured; anchoring on one left the reader
about 9px off when an older page landed while a newer one was evicted.
The own-send reveal retries after the second staging pass.

The deep-page perf case now requires the reply being read to hold within
a pixel. Thread tests wait for Virtua to resume hit-testing before acting
on a row they scrolled into mounting, and the pane-overlap check waits
for the layout to settle after its scale change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: James Kraus <[email protected]>
baxen added a commit that referenced this pull request Nov 7, 2026
## Summary

These are fixes for three browser-journey flakes that are failing open
PRs which don't touch the code under test. The change is test-only. No
browser cases are added or removed, and no assertion is relaxed.

The red `Browser journeys (chromium, 3/9)` / `(webkit, 4/14)` shards on
most open PRs come from a different problem:
`sidebar-geometry.spec.mjs:224` is broken on main. #2945 and #2946
already fix it, so this PR leaves it alone.

### 1. `channel-completion.spec.mjs`: cold dev-server load timed out

**Evidence:** `Flake check` on #2935 failed
`channel-completion.spec.mjs:7` on both engines with `page.goto: Timeout
15000ms exceeded` on
`/tests/fixtures/mentions.html?channels&stream&test-controls`
([chromium](https://github.com/block/buzz-app/actions/runs/42317084302/job/128232679087),
[webkit](https://github.com/block/buzz-app/actions/runs/42317084302/job/128232678918)).
#2935 doesn't touch that page.

**Cause:** this is the same cold load #2776 fixed for
`mentions.spec.mjs`. Each test here started its own Vite dev server for
the same ~1,600-module fixture.

**Fix:**
- `beforeAll` builds `mentions.html` once and serves it with
`build`/`preview`, using the same config as `mentions.spec.mjs`.
- The build keeps the dev server's CSS. Tailwind's optimizer and Vite's
CSS minifier each round the 20/14 line-height token to `1.42857`, so
line boxes become `19.99998px`. In WebKit that put the popup `4.03125px`
above the composer, and the test asserts exactly `4`. I confirmed this
locally: it fails 5/5 with either setting left on and passes with both
off. Turning both off keeps the geometry the test checked before.

### 2. `profiles.spec.mjs:940`: reopening Members right after closing
the profile

**Evidence:** `Flake check (webkit, 8/9)` on #2909 failed in repeat 8 of
10
([job](https://github.com/block/buzz-app/actions/runs/42313241314/job/128219877861)).
The trace shows this sequence:
1. `Escape` closes the profile panel.
2. Focus returns to "Manage members of One" within 40ms.
3. `Enter` is pressed 10ms later.
4. The DOM after that `Enter` shows the Profile panel again and no
Members dialog, so the next `row.click` times out.

**Fix:** after each `Escape`, wait for the address to leave the profile
(no `"detail"` in the `#buzz=` hash) before reopening Members. This is a
lifecycle barrier, not a delay. One step in this test already waited for
the panel to unmount before reopening; the other steps didn't wait at
all.

**Product follow-up (not fixed here):** the order of a dismissal's
history return and a quick reopen matters.
- If I delay `history.go` by 300ms, the unpatched test takes a different
path: the dismissal's `go(-1)` never runs, and the URL stays on the
profile visit under the reopened dialog.
- A `browser-history` unit sketch (deferred traversal: `push(channel)`,
`push(panel)`, `dismiss([panel])`, then `push(settings)` before the
traversal commits) lands on `channel`. The new `settings` visit is
undone.

Someone who owns navigation should decide whether a push during a
pending dismissal return should be deferred or replayed. That is a
navigation-history change, not a test fix.

### 3. `timeline.mjs` `edge()`: one wheel gesture stopped short

**Evidence:** `Browser journeys (webkit, 6/14)` on #2937 failed
`history-prefix.spec.mjs:37` with `wheel reaches timeline edge`,
`Received: 199`
([job](https://github.com/block/buzz-app/actions/runs/42320781857/job/128244819852)).
The trace shows the one `-3251px` wheel ended with `scrollend` and the
geometry then held still, so the poll could never pass.

**Fix:** `edge()` repeats the gesture, at most four times, until the
edge is reached. Every extra gesture must reduce the distance, so an
immobile timeline still fails. `upper()` already follows this rule. The
case of already being at the edge is unchanged: it still sends one small
wheel so production can start paging.

## Not in this PR, from the same survey of the 40 most recently updated
open PRs

- `sidebar-geometry.spec.mjs:224` (Drafts & sent row) is broken on main
since #2733 + #2728 crossed. #2945 is green and #2946 is in CI; either
one fixes it.
- `solo-chats.spec.mjs:322` (Share, webkit, on #2927) is being handled
in draft #2933.
- `layout.spec.mjs:803` on #2916 is already fixed on main by #2750.
#2916's merge ref predates it.
- #2911 (thread virtualization) and #2918 (per-channel notifications)
fail consistently in tests their own changes reach, so those look like
regressions for their authors, not flakes.

## Validation

All runs were local on macOS with
`tests/browser/playwright.ci.config.mjs` and `--no-deps`. These are not
hosted CI measurements.

- `channel-completion.spec.mjs`, `--repeat-each=5`, both engines: 20/20
passed with the change. Before the CSS settings were added, the built
fixture failed WebKit 5/5 (`4.03125`).
- `profiles.spec.mjs:940`, `--repeat-each=24 --workers=6`, WebKit:
passed. It also passed with `history.go/back/forward` delayed by 300ms,
in both engines.
- `history-prefix`, `history-loading`, `scroll`, `timeline-setup` and
`new-message-boundary` (all callers of `edge()`/`end()`), both engines:
70/70 passed.
- Full `history-prefix`, `channel-completion` and `profiles` files, both
engines: 43/44 passed. The one failure was `profiles.spec.mjs:6` in
WebKit, a test this PR doesn't touch. It then passed 4/4 both with and
without this change.

**Deferred and remaining gaps:**
- I couldn't reproduce the cold-load timeout or the WebKit stopping
short locally, so I have no local fail-before for those two. The
evidence is the CI traces linked above.
- The other `edge()` callers (24 spec files) are left to CI.
- A green run doesn't prove these flakes are gone.

Signed-off-by: Bradley Axen <[email protected]>
Co-authored-by: mbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz>
matt2e added a commit that referenced this pull request Nov 14, 2026
…tivity and message-navigation (#3444)

## Why

Two journeys fail intermittently in CI because each waits on a signal
that does not match what the test then asserts.

| Test | Sightings |
|---|---|
| `local-agent-activity.spec.mjs`, "opens an active message's empty
thread without losing its activity preview" | Browser journeys: [10 Nov,
main, WebKit
18/18](https://github.com/block/buzz-app/actions/runs/42849358399/job/129946045226),
[10 Nov, Chromium
9/9](https://github.com/block/buzz-app/actions/runs/42856816690/job/129972535241),
[11 Nov, WebKit
18/18](https://github.com/block/buzz-app/actions/runs/42933561357/job/130238403731),
[11 Nov, WebKit
18/18](https://github.com/block/buzz-app/actions/runs/43077988192/job/130723357985)
|
| `local-agent-activity.spec.mjs`, "finishing activity retires keyboard
controls without stealing focus: stop" | Browser journeys: [10 Nov,
WebKit
18/18](https://github.com/block/buzz-app/actions/runs/42840658251/job/129915569261)
|
| `message-navigation.spec.mjs`, "exact reveal uses ordinary dwell
rather than marking read at open" | Browser journeys: [10 Nov, main,
Chromium
5/9](https://github.com/block/buzz-app/actions/runs/42922432651/job/130200813397)
|

## Root cause and fix

One commit per spec.

**Thread rows still positioning.** While a thread positions its first
page, `ThreadPanel` renders its rows with `inert` and sets `aria-busy`
on the "Thread messages" region. Playwright's `toBeVisible()` ignores
`inert`, and `locator.press()` and `focus()` make no actionability
check. The two steps added in #3140 could therefore run before
positioning finished.

- In the empty-thread test, `activity.press("Enter")` went to the
still-focused reply composer, which is prefilled with a mention. WebKit
sent the draft and raised the "Mention people outside this channel?"
dialog. Chromium kept focus in the composer. The Activity tab never
appeared. The composer's `toBeFocused()` passes early because the
composer sits outside the inert rows.
- In the "stop" variant, `focus()` on Stop was a no-op, so the handoff
to "More message actions" never happened.

DOM snapshots from the four traces examined show `aria-busy="true"` and
`inert` on the rows at the moment of the press or focus. Every sighting
post-dates #3140. This is not a product bug: people cannot reach inert
rows either.

The tests now wait for the region's `aria-busy="false"`, the readiness
signal `channel-lifecycle`, `nested-replies` and
`navigation-thread-history` already use. The wait sits after the
composer `toBeFocused()` and before the Enter press, and in the
finishing loop after the bubble is visible and before any `focus()`, so
the bubble variant is covered as well.

**Read publication under a default timeout.** The exact-reveal dwell
test ended by polling the fixture's read publications with the default
10 s timeout. A publication needs the 300 ms reading dwell, then
read-state's 5 s publish debounce, then the flush's read, sign and
publish. That leaves about 4.5 s of slack. In the failing trace the
flush's read reached the fixture on time and the relay answered in about
4 ms, but the browser took 5.6 s to receive the response body. The
signing request arrived 0.9 s after the poll deadline. The evidence file
at teardown held the publication, so the behaviour was correct. The test
was waiting on the wrong signal for what it asserts, which is that dwell
marks read and open does not.

The test now asserts the local read with `readJournal` from
`reading.mjs`, as the sibling test in the same file does. Right after
the held query is seen, the journal's frontiers are still empty. At the
end it polls for a non-empty frontier set. The journal write is strictly
earlier than the publication and does not depend on the relay
round-trip. The existing check that publications are unchanged at open
is kept. The publication path stays covered by `unread.spec.mjs`, which
polls with a 12 s timeout.

No timeouts or tolerances changed. No browser cases were added or
removed.

## Evidence

Local runs on Linux, `bin/pnpm test:browser:ci --no-deps
--reporter=list`.

| Scope | Result |
|---|---|
| `local-agent-activity.spec.mjs -g "opens an active message\|without
stealing focus"`, WebKit, `--workers=1 --repeat-each=8` (4 tests × 8) |
32/32 passed |
| Same, Chromium | 32/32 passed |
| `message-navigation.spec.mjs -g "exact reveal uses ordinary dwell"`,
Chromium, `--workers=1 --repeat-each=8` | 8/8 passed |
| Same, WebKit, `--repeat-each=4` | 4/4 passed |
| Whole `message-navigation.spec.mjs`, `--workers=2` | Chromium 22/22,
WebKit 22/22 |

During triage, a test-only route that delayed the relay query by 1.5 to
2.5 s reproduced the inert-row failure deterministically: without the
wait WebKit and Chromium each failed 2/2 with the CI signature, and with
the wait all passed.

## Not reproduced, not run

- Neither failure reproduced on an idle host without that injected
delay. The slow response body in the read-publication failure was pinned
to CI load. The repeat runs guard against regression rather than
reproducing CI timing.
- No human has tested this yet, so `buzz-review-completed` is not set.

## Left alone

- The same default-timeout publication poll remains in "settings blocks
reading and focus in a retained thread" in this file and in
`notifications.spec.mjs`.
- Open PRs #3183, #3001 and #2610 edit other tests in
`local-agent-activity.spec.mjs`. Open PRs #2911, #2563, #2555, #2407 and
#2401 edit other parts of `message-navigation.spec.mjs`. A trial merge
of each against this change is clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: mtoohey <[email protected]>
Signed-off-by: Matt Toohey <[email protected]>
Co-authored-by: Claude Fable 5.1 <[email protected]>

This branch has not been deployed

No deployments
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