Skip to content

perf(messages): cut main-thread work while a busy channel is open - #2729

Merged
kalvinnchau merged 3 commits into
mainfrom
peon/r3-activity-reactions
Nov 6, 2026
Merged

kalvinnchau merged 3 commits into
mainfrom
peon/r3-activity-reactions

Conversation

@kalvinnchau

@kalvinnchau kalvinnchau commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Three costs add up while a busy channel is open and new messages arrive.

  • Reading observation restarts on every message. useReading lists latestMessageId as an effect dependency, so each new latest message tears down the effect and runs it again. That disposes the reading lease, the observers and the listeners, then measures rows synchronously in the passive-effect phase (getClientRects / getBoundingClientRect). The result is a forced layout per message, and a dwell that new messages arriving faster than 300 ms never let complete.
  • Activity reactions feed pruning. feat(activity): show reactions to your messages in Activity #2638 added this feed. On every channel-list notification, which live messages trigger, it prunes up to 100 items with allowed(item.channelId). Each call rescans the list and runs members.includes(viewer), even though the callback has just built the same allowed set.
  • Own-reaction ring. feat(messages): highlight reactions you've made #2709 draws the ring with an inset box-shadow, which adds paint-bound long-animation-frame blocking while the pointer rests on a message.

Change

This PR combines three changes:

  • useReading reads the latest message id through useEffectEvent at measurement time, and it is no longer an effect dependency. A new latest message already mutates the list, and the existing mutation observer remeasures it in the next frame.
  • The reactions prune filter uses next.has(item.channelId), the set built a few lines above from the same list snapshot and predicate.
  • The own-reaction ring is drawn with outline: 1px solid and outline-offset: -1px instead of the inset shadow. It uses the same color variables, and in forced-colors mode only outline-color: Highlight is set.

No behavior changes

  • Reading.
    • The latest id is read when the bottom row is measured, so catch-up still targets the newest rendered row. catchUp itself still skips when a newer row exists and revalidates its target.
    • If the latest id changes with no DOM mutation, the bottom check finds no matching shown row, the same outcome as the old restart.
    • rootId, channelId, session and enabled still restart the effect.
    • A new test checks that a new latest id neither restarts observation nor takes a lease, and that catch-up reaches the new row after it is measured. The test fails without this change.
  • Reactions. next holds exactly the ids allowed would accept, built from the same snapshot with no await before the filter. load and eventVisibility still call allowed.
  • Ring. It has the same width, color and position, inside the pill's border box, and follows the capsule radius. An outline paints above the pill's content and an inset shadow below it. The pill's button is transparent at rest and the hover glyph is transformed, so the ring looks the same. Outlines follow border-radius from Safari 16.4 / WebKitGTK 2.40.

Evidence

Main 8c0cb0c1e and this PR were interleaved for 6 rounds on 4 Linux and 5 macOS workstations. Each workstation's median is taken first, and the table shows the mean of those medians.

  • Browser rows. Production build, Chromium at 4× CPU throttling. Traffic appends a message every 200 ms to the open channel and to another one.
    • pointer on message: the pointer rests on a message, which reveals its action bar, for 4.5 s.
    • no pointer: the same run with the pointer away.
    • at rest: the channel stays open for 3 s with no interaction.
  • Unit row. Vitest micro-benchmark with 400 channels, one of them with 5,001 members and the viewer at the end of its list, and 100 reaction items in that channel. It delivers 50 channel-list notifications. It ran on 2 Linux and 2 macOS workstations.
metric Linux: main Linux: this PR Linux Δ % macOS: main macOS: this PR macOS Δ %
pointer on message: blocking 156.2 ms 35.3 ms −77.4% 32.3 ms 0.0 ms −100%
pointer on message: task 2,017.7 ms 1,997.4 ms −1.0% 1,458.1 ms 1,461.7 ms +0.2% (noise)
pointer on message: script 1,028.8 ms 1,010.5 ms −1.8% 644.5 ms 640.2 ms −0.7% (noise)
no pointer: blocking 133.9 ms 13.7 ms −89.8% 25.6 ms 0.0 ms −100%
no pointer: task 1,843.9 ms 1,864.5 ms +1.1% (noise) 1,365.2 ms 1,353.7 ms −0.8%
at rest: task 318.0 ms 324.4 ms +2.0% (noise, one box +11%) 235.1 ms 236.0 ms +0.4% (noise)
reactions list callback ×50 (unit) 464.4 ms 6.9 ms −98.5% 309.0 ms 4.0 ms −98.7%

Lower is better. Blocking is long-animation-frame blocking time. Every workstation moved the same way on both blocking rows (per box: Linux −77.0% to −77.9% and −88.1% to −91.9%; macOS −100% on all five). Neither variant blocks in the at-rest run.

Checks:

  • The messages vitest suites pass (96 files, 2,835 tests).
  • inbox-reactions passes (10 tests).
  • tsc, biome and check-contrast pass.

🤖 Generated with Claude Code

@kalvinnchau
kalvinnchau marked this pull request as ready for review November 6, 2026 04:33
@kalvinnchau
kalvinnchau requested a review from a team as a code owner November 6, 2026 04:33
kalvinnchau and others added 2 commits November 5, 2026 23:54
the channel-list callback filtered snapshot items through allowed(), which
scans the whole list and members.includes(viewer) per item on every list
notification (one per live message). reuse the set already built for the
callback.

Co-authored-by: Claude Opus 5.5 <[email protected]>
the inset box-shadow ring added for reactions you have made costs hover
frames on macos; a 1px outline with a -1px offset draws the same ring.
forced colors keep a highlight ring through outline-color.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
the reading effect depended on latestMessageId, so every message appended to
the open list tore down its observers and listeners, disposed queued observe
leases, and remeasured synchronously in the passive effect, forcing layout.
read the latest id at measurement time instead; the list mutation already
remeasures in the next frame.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@kalvinnchau kalvinnchau changed the title perf(activity): reuse available channel set when pruning reaction items perf(messages): cut main-thread work while a busy channel is open Nov 6, 2026

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 Star Lord’s automated source review via Wes’s account, with independent Rocket, Mantis and Groot coverage.

Reviewed head 465dad8c0466b374d84cd3c4f2c2a72484d3ffb6 against base 8c0cb0c1efb68068b89c813ad6f890d8b8f07401.

No actionable findings. The source preserves dwell/catch-up target checks and lease cancellation on lifecycle changes, reuses the same allowed-channel predicate for reaction pruning, and retains the reaction self-ring tokens with separate button focus styling. The added test checks observer continuity and catch-up after the new row is measured.

Existing limitation: continuous list mutations less than 300 ms apart still restart dwell. This change removes redundant effect restarts; it does not eliminate that starvation case.

Source-only review: no tests, app workflows, CI checks or performance measurements were run. The reported benchmark improvements, rendered outline equivalence and forced-colors behavior remain unverified.

@wpfleger96 wpfleger96 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🤖 bottomId reads the latest id at measure time through useEffectEvent, so a new message no longer tears down observation, and session/channel/root changes still restart it. the reaction prune now checks the same allowedChannel set it just built, and the outline keeps the self-reaction tokens and the forced-colors ring. LGTM. not a blocker, just worth knowing: mutations less than 300 ms apart still restart the dwell, this removes the extra effect restarts on top of that

@kalvinnchau
kalvinnchau merged commit fca0629 into main Nov 6, 2026
172 of 174 checks passed
@kalvinnchau
kalvinnchau deleted the peon/r3-activity-reactions branch November 6, 2026 16:10
wpfleger96 added a commit that referenced this pull request Nov 6, 2026
…leanup

* origin/main:
  test(mentions): serve the mentions fixture as a build so cold loads fit (#2776)
  test(browser): let a late publication land after its page closed (#2565)
  test(relay): wait for the verified owner before a guest edits an agent (#2413)
  test: wait for the companion default width measurement (#2750)
  test(browser): preserve conversation scroll while revealing actions (#2715)
  test(browser): wait for link hit-testing after thread close (#2792)
  perf(messages): skip micromark for plain prose bodies (#2780)
  perf(inbox): load the emoji name table on first use (#2774)
  perf(messages): cut main-thread work while a busy channel is open (#2729)
  Refine agent Attention cards, watch details, and status controls (#2717)
  fix(bestie): remove a Bestie from its agent card on any device (#2720)
  feat(agents): preview Builderlab agent card links with Add to Buzz (#2719)
kalvinnchau added a commit that referenced this pull request Nov 6, 2026
…h-up dwell (#2837)

## Problem

`ThreadPanel.test.tsx` › "observes a visible reply before its root
resolves, then enables thread catch-up" fails deterministically on main
since fca0629 (#2729).

Bisect: passes at `fca0629fb^`, fails at `fca0629fb` and at the current
main tip.

## Cause

#2729 reads `latestMessageId` through `useEffectEvent` instead of
restarting the observation effect. When the root resolves, its row
measures in the next frame. That list mutation triggers `reflow`, which
cancels and re-arms the 300 ms dwell one frame later. Catch-up still
fires exactly once, one frame later than the test expected.

## Change

- The test advances the extra measurement frame before asserting the 299
ms / 300 ms dwell boundary. No product code changes.

## Checks

- `vitest run src/features/messages/ThreadPanel.test.tsx`: 91/91 pass, 3
runs.
- `biome check` clean.

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

Signed-off-by: Kalvin Chau <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
tlongwell-block added a commit that referenced this pull request Nov 6, 2026
Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: Tyler Longwell <[email protected]>
Signed-off-by: Tyler Longwell <[email protected]>

* origin/main:
  test(messages): wait for the row measurement frame before thread catch-up dwell (#2837)
  Keep agent coordination history readable after work finishes (#2454)
  test(mentions): serve the mentions fixture as a build so cold loads fit (#2776)
  test(browser): let a late publication land after its page closed (#2565)
  test(relay): wait for the verified owner before a guest edits an agent (#2413)
  test: wait for the companion default width measurement (#2750)
  test(browser): preserve conversation scroll while revealing actions (#2715)
  test(browser): wait for link hit-testing after thread close (#2792)
  perf(messages): skip micromark for plain prose bodies (#2780)
  perf(inbox): load the emoji name table on first use (#2774)
  perf(messages): cut main-thread work while a busy channel is open (#2729)
  Refine agent Attention cards, watch details, and status controls (#2717)
  fix(bestie): remove a Bestie from its agent card on any device (#2720)
  feat(agents): preview Builderlab agent card links with Add to Buzz (#2719)
  Restore macOS Cmd-W window closing when no tab handles it (#2443)
  Make agent transcripts quieter and link finished replies to activity (#2424)
johnmatthewtennant added a commit that referenced this pull request Nov 6, 2026
…ve-enter

* origin/main: (21 commits)
  Make drafts and sent part of Messages (#2728)
  fix(canvas): render every Canvas within the save limit (#2839)
  test(messages): wait for the row measurement frame before thread catch-up dwell (#2837)
  Keep agent coordination history readable after work finishes (#2454)
  test(mentions): serve the mentions fixture as a build so cold loads fit (#2776)
  test(browser): let a late publication land after its page closed (#2565)
  test(relay): wait for the verified owner before a guest edits an agent (#2413)
  test: wait for the companion default width measurement (#2750)
  test(browser): preserve conversation scroll while revealing actions (#2715)
  test(browser): wait for link hit-testing after thread close (#2792)
  perf(messages): skip micromark for plain prose bodies (#2780)
  perf(inbox): load the emoji name table on first use (#2774)
  perf(messages): cut main-thread work while a busy channel is open (#2729)
  Refine agent Attention cards, watch details, and status controls (#2717)
  fix(bestie): remove a Bestie from its agent card on any device (#2720)
  feat(agents): preview Builderlab agent card links with Add to Buzz (#2719)
  Restore macOS Cmd-W window closing when no tab handles it (#2443)
  Make agent transcripts quieter and link finished replies to activity (#2424)
  fix(channels): wrap unread conversation stepping around the sidebar (#2682)
  fix(navigation): let bare buzz:// open Buzz without a broken-link notice (#2718)
  ...
ravarora2 added a commit that referenced this pull request Nov 6, 2026
Co-authored-by: BuzzPRReviewAssistant1 <a581a7e8115222acdc32ffb14bfce55d48c24478d98e155f8eba126b69b83c7b@buzz.block.builderlab.xyz>

* origin/main: (71 commits)
  test(activity): scope preview assertions to the request row (#2843)
  perf(search): fold people names once across palette keystrokes (#2570)
  fix(github): keep PR watches running when localStorage is full (#2699)
  Make drafts and sent part of Messages (#2728)
  fix(canvas): render every Canvas within the save limit (#2839)
  test(messages): wait for the row measurement frame before thread catch-up dwell (#2837)
  Keep agent coordination history readable after work finishes (#2454)
  test(mentions): serve the mentions fixture as a build so cold loads fit (#2776)
  test(browser): let a late publication land after its page closed (#2565)
  test(relay): wait for the verified owner before a guest edits an agent (#2413)
  test: wait for the companion default width measurement (#2750)
  test(browser): preserve conversation scroll while revealing actions (#2715)
  test(browser): wait for link hit-testing after thread close (#2792)
  perf(messages): skip micromark for plain prose bodies (#2780)
  perf(inbox): load the emoji name table on first use (#2774)
  perf(messages): cut main-thread work while a busy channel is open (#2729)
  Refine agent Attention cards, watch details, and status controls (#2717)
  fix(bestie): remove a Bestie from its agent card on any device (#2720)
  feat(agents): preview Builderlab agent card links with Add to Buzz (#2719)
  Restore macOS Cmd-W window closing when no tab handles it (#2443)
  ...

Signed-off-by: Ravneet Arora <[email protected]>
cheng-block added a commit to cheng-block/buzz-app that referenced this pull request Nov 6, 2026
…ime-hint

* upstream/main: (26 commits)
  Make drafts and sent part of Messages (block#2728)
  fix(canvas): render every Canvas within the save limit (block#2839)
  test(messages): wait for the row measurement frame before thread catch-up dwell (block#2837)
  Keep agent coordination history readable after work finishes (block#2454)
  test(mentions): serve the mentions fixture as a build so cold loads fit (block#2776)
  test(browser): let a late publication land after its page closed (block#2565)
  test(relay): wait for the verified owner before a guest edits an agent (block#2413)
  test: wait for the companion default width measurement (block#2750)
  test(browser): preserve conversation scroll while revealing actions (block#2715)
  test(browser): wait for link hit-testing after thread close (block#2792)
  perf(messages): skip micromark for plain prose bodies (block#2780)
  perf(inbox): load the emoji name table on first use (block#2774)
  perf(messages): cut main-thread work while a busy channel is open (block#2729)
  Refine agent Attention cards, watch details, and status controls (block#2717)
  fix(bestie): remove a Bestie from its agent card on any device (block#2720)
  feat(agents): preview Builderlab agent card links with Add to Buzz (block#2719)
  Restore macOS Cmd-W window closing when no tab handles it (block#2443)
  Make agent transcripts quieter and link finished replies to activity (block#2424)
  fix(channels): wrap unread conversation stepping around the sidebar (block#2682)
  fix(navigation): let bare buzz:// open Buzz without a broken-link notice (block#2718)
  ...

Signed-off-by: Cheng Peng <[email protected]>
Co-authored-by: Cursor <[email protected]>
matt2e added a commit that referenced this pull request Nov 7, 2026
## Failing test

`src/app/navigation.test.tsx` "keeps a return path for addressed plugin
settings and retires it on Close" (added by #2491) fails intermittently
in the Vitest flake-check lane:

```
Error: Test timed out in 5000ms.
```

It is one long journey on a full `App` mount with the default 5000 ms
timeout: open the Plugins list, open Pair mobile, Back, Forward, open
Appearance, open Pair mobile, Close, Back, Back.

## Why this is a timeout flake, not a race

The test is slow all the time, and every failure is the same timeout. No
assertion has failed.

- **Main:** 25 green `JavaScript` shard runs since #2491 took 2148–4414
ms (median 3921 ms). That leaves under 1.2 s of headroom.
- **Flake-check failures:** each one was a cold iteration 1 that ran
past 5000 ms. The same test then passed iterations 2–5 at its usual
speed, with no product or test change between them.

| Run | Iter 1 | Iters 2–5 |
|---|---|---|
| #2718
[42217828904](https://github.com/block/buzz-app/actions/runs/42217828904),
Flake check 1/2 | **× 5045 ms** | 4061–4133 ms |
| #2718, Flake check 2/2 | 3796 ms | 3377–3581 ms |
| #2729
[42194107421](https://github.com/block/buzz-app/actions/runs/42194107421)
attempt 1 | **× 5055 ms** | 3973–4399 ms |
| #2729 attempt 2 | **× 5224 ms** | 4016–4423 ms |

- **Neither PR touches this test's path.** #2718 changes deep-link
ingress and #2729 changes messages and relay code. The flake check
selected this file only through imports (`navigation.test.tsx` →
`services.ts` → `deep-links.ts`). #2729's attempt 3 passed.
- **Local near-miss:** in the local baseline below, one iteration on
unmodified `main` took 4920 ms.

## What this changes

The test is split where its title already splits. Each new test mounts
its own `App` with the file's usual per-test setup. The 5000 ms timeout
is unchanged.

1. **"keeps a return path for addressed plugin settings through Back and
Forward":** open the list, open Pair mobile, Back, Forward. These are
the original's first-half assertions, verbatim.
2. **"retires every visit to addressed plugin settings on Close":** open
the list, open Pair mobile, open Appearance, open Pair mobile, Close,
Back, Back. These are the original's second-half assertions, verbatim.
- The test keeps one earlier Pair mobile visit behind Appearance.
`dismissPluginDetails` retires *every* matching visit, and the final
Back landing on the list is what proves it.
- Without the earlier visit, that last assertion is vacuous. With Close
mutated to retire only the current visit
(`navigation.dismiss([navigation.snapshot().entry.id], settings)`):

| Version | Result with the mutated Close |
|---|---|
| Original test | fails |
| Split without the earlier visit | **passes** (misses the regression) |
| This split | fails |

Every assertion in the original test is kept in one of the two tests.
Production code is unchanged.

## Verification

All runs were local, on macOS (16 cores). The flake-check runs happened
while the machine was under heavy unrelated load (load average 35–40).

- `tsc --noEmit` and `biome check --error-on-warnings
src/app/navigation.test.tsx` pass.
- Each new test passes run alone (`-t`), and the whole file passes under
`--sequence.shuffle` with seeds 1–3. Seed 2 runs the Close test first.

**Per-test timings (ms).** The command was `BUZZ_TEST_WORKERS=2
./bin/node scripts/flake-check.mjs vitest --iterations=5 <files>`. The
file list is the same 15-file selection as #2718's failing lane.

| Test | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|
| Before: original (on `3bcf1c4bb`) | 2407 | 1994 | 2487 | 2037 | 4920 |
| After: Back and Forward | 1399 | 2071 | 1538 | 906 | 1384 |
| After: Close | 1951 | 2966 | 2131 | 1496 | 1801 |

Running `navigation.test.tsx` alone the same way: Back and Forward took
848–1595 ms and Close took 1352–2114 ms, with 0 of 5 iterations failing.

**Headroom.** The local worst case drops from 4920 to 2966 ms. The Close
half is the longer one, at about 0.75–0.8× the original's local time.
Scaled to CI's 3921 ms median, it should land near 3 s, about 1.8–2 s
under the timeout instead of about 1.1 s. That is an estimate. This PR's
own flake-check lane will repeat the edited file and give the real CI
numbers.

**Unrelated local failures.** Each 15-file run had one failure in
`src/app/entity-navigation.test.tsx`, which this PR does not touch, and
both happened under the load above:
- Before: "opens Inbox for a malformed launch link when the catalog
settles after the client" failed a `findByRole` wait.
- After: "normalizes old Bestie links … retaining { …(2) } scope" timed
out at 5608 ms.

No browser journeys were added or removed.

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

Signed-off-by: Matt Toohey <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
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.

3 participants