Repository navigation
perf(threads): virtualize the thread panel's replies - #2911
Draft
jameskraus wants to merge 2 commits into
Draft
jameskraus wants to merge 2 commits into
jameskraus wants to merge 2 commits into
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
ThreadPanelrenders its top-level replies, each with its branch, through Virtua'sVirtualizer: the same library, patch and pattern asChannelTimeline(as="ol",bufferSize1600,keepMounted,shift). A custom item (ThreadIteminsrc/features/messages/thread-items.tsx) clones the reply's ownliand merges Virtua's position style and measuring ref into it. Its class,data-layout, ref and the[data-thread-rows] > ol > listructure stay as they are, so the panel's CSS and selectors are unchanged. Items carryaria-posinset/aria-setsizefor the loaded window. The root and its divider sit above the list as Virtua'sstartMargin.MessageRow's existingkeepMounted(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.checkVisibility), so the reply being read holds its place as pages land.shiftholds 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).stageEdgesrenders the start's change first withshift, 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.message-selection.tsalready handles unmounted newest rows), exact reveal throughuseMessageReveal, 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.thread-scroll-flingthread-scroll-reversethread-scroll-deep-pagethread-scroll-openThe 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.
[data-message-id]/ol [data-message-id]inmessage-navigation,navigation-thread-history,thread-opening,messages,thread-windowthreadMessageCount/threadReplyCount(tests/browser/thread-rows.mjs): rendered rows plus each unmounted item, fromaria-setsize. That is the old number, independent of the viewport.thread-windowuniqueness check over all mounted ids (305)thread-window"prepended page" index ≥ 50 among mounted rowsaria-posinset≥ 51.messagesandnavigation-thread-historyrows acted on far from the viewportscrollUntilMountedscrolls a viewport at a time, waiting rendering updates (no timers), until the row mounts.navigation-thread-historylive reply visible while reading abovethread-resize"thread stays beside the channel" one-shot bounds after a scale changenested-replieshover after a programmatic scrollframes()and for Virtua to resume hit-testing (pointer-events: auto), as the channel'sclickRowdoes. Before, Playwright's hover retried scroll alignments into the tab bar.thread-resize"contraction clamped with a live reply"clampedhandling fails it ("later update must not repeat resize correction"); with it, it passes.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 exposeskeepMounted.thread-items.test.tscoversstageEdges. AThreadPanel.test.tsxcase 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 onorigin/main(984cda2).Readiness
Draft.
[data-selected]rule, mounted-id uniqueness inthread-window. Left as known edges:thread-rows.mjscounts an unmounted item as one row, which holds while unmounted items show no open branch.scripts/perf/thread-native/run.mjs), not run yet.