Skip to content

feat(profiles): reserve profile room for an open channel's roster - #2269

Merged
loganj merged 6 commits into
mainfrom
larry/profile-cache-grow
Nov 3, 2026
Merged

loganj merged 6 commits into
mainfrom
larry/profile-cache-grow

Conversation

@loganj

@loganj loganj commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

🤖

Summary

  • In channels with more than 1,024 members, the app could not keep every member's name in memory. The profile cache holds at most 1,024 profiles. So the mention menu loaded only the first 1,024 members. Loading them also pushed every other cached name out, and names elsewhere in the app changed to a short hex key.
  • Now an open channel reserves room for its whole member list on top of the 1,024-profile base, up to 10,240 profiles in total. When you leave the channel, the limit goes back to 1,024 and the app removes the profiles nobody has asked for recently. The room is temporary: the cache stays at 1,024 when no large channel is open.
  • The mention menu now loads the whole member list into the reserved room. It asks again for names the cache removed after it first asked for them. It does not ask again each time it opens for members already known to have no name.
  • Labeling a channel's members now looks up only that channel's own people (and the owners of its agents). Before, it passed every cached profile to the naming rules. So labeling no longer gets slower as the profile cache grows.

Details

  • profile-directory.ts: reserve(members) returns a release function. The limit is min(10_240, 1_024 + largest reservation). A grow takes effect at once. A release shrinks after the current task (a setTimeout), so a release followed by a new reservation in the same React commit (a remount or a member count change) only resizes to the new size and keeps the loaded profiles. A shrink also trims the known-unnamed set and publishes once.
  • budget.ts: the byte budget is now the entry limit × 2 KiB (2 MiB at the base, 20 MiB at the cap), so a larger limit is not cut short by the old fixed 2 MiB budget.
  • verified-profile-directory.ts follows the same limit. On a shrink it trims by request order first, then resizes its signed-head store. Any head that the byte budget removes is treated as evicted, so a later request fetches it again instead of keeping a name that can never be checked.
  • Conversation.tsx reserves only while the conversation is active. A retained background tab gives its room back.
  • MentionCompletion.tsx skips known-unnamed members unless the user clicks Retry, and starts over when the cache reports evictions. Space completion asks for up to the current limit, not a fixed 1,024.
  • identity-names/directory.ts builds a scope's labels from the scope's own keys and their agents' owners.
  • docs/channels.md and src/bundled/mentions/README.md describe the new limit and the mention menu rules.
  • Tests use a new unsignedProfiles() fixture for cache-size tests, so the large-cache tests stay fast. The verified-cache shrink test still uses real signed events.

Review fixes

  • The mention menu's allowance counts only this roster's earlier requests, so a second large channel loads its whole roster. A roster loads into the room its channel reserved (limit minus the 1,024 base), so a roster past the 10,240 cap keeps other names cached. Space completion uses the same room.
  • Verified-name syncs have their own pool of 80 (enough for 10,240 people at 128 each), apart from the 32 artifact syncs. A shrink stops the syncs it emptied at once.
  • A shrink cancels background profile reads still in flight. evictions() counts them, so the mention menu asks again.
  • Conversation.tsx reserves in a layout effect, before a child's effect reads the limit.
  • A background profile read of many pages publishes once, when its pages settle. The session passes the read's signal to accept, so only that read's own pages wait; live traffic and a foreground read that joins publish at once. An accepted page counts as loaded before its publish.
  • The channel avatar preview reads at most the first 1,024 members. With the reserved room it had read a whole 9,000-member roster to show three avatars.
  • scopeKeys in the naming policy owns the scope rule; the name directory uses it.

Measurement

New Browser measurement profile-roster-scale.spec.mjs: a 9,000-member channel, open the mention menu until every member is read, then leave the channel. It checks each member is read once.

Chromium frames > 50 ms longest frame time past 60 Hz
main (loads 1,024) 1 60 ms 126 ms
before the fold 16 117–133 ms ~970 ms
this PR 1–2 133–150 ms 168–201 ms

WebKit: main 3 frames over 50 ms (longest 110 ms); this PR 2 (longest 131 ms). Leaving the channel: no frame over 50 ms in Chromium, one 51 ms frame in WebKit.

The profile listeners were not the cost (label refold is ~6 ms per publish at 9,000). The open mention menu re-ranked every name on each 500-profile page. The one remaining long frame is a single re-rank of 9,000 names.

Not in this PR

  • Mention ranking is still linear in the roster, so one publish at 9,000 members costs one ~130 ms frame while the menu is open.

🤖 Generated with Claude Code

@loganj
loganj marked this pull request as ready for review November 3, 2026 17:07
@loganj
loganj requested a review from a team as a code owner November 3, 2026 17:07

@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.

🤖 the reservation approach looks right to me: the largest open roster sets the limit, the deferred shrink keeps profiles across a remount, and the scoped naming lookup matches the naming spec. there are four blocking issues inline, mostly where the bigger cache meets the mention demand budget and the live transport, plus three nonblocking nits.

Comment thread src/features/relay/budget.ts Resolved
Comment thread src/features/relay/profile-directory.ts Resolved

@kalvinnchau kalvinnchau left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤖 Two additional findings on cache headroom and naming-policy ownership.

loganj and others added 6 commits November 3, 2026 13:17
The profile cache held at most 1,024 profiles. In a channel with more
members than that, the mention menu could load only the first 1,024
names, and loading them pushed every other cached name out, so names
elsewhere fell back to hex keys.

An open channel now reserves room for its whole roster on top of the
1,024-profile base, up to 10,240 profiles in total. Leaving the channel
returns the limit to 1,024 and evicts the least recently requested
profiles at once. The verified-name cache follows the same limit. The
mention menu loads the whole roster up to the reserved limit, and asks
again for names the cache evicted after it first requested them.

Scope labels now look up only the scope's own keys and their agents'
owners instead of passing every cached identity to the naming policy,
so labelling a channel no longer costs time in proportion to the whole
profile cache.

Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Logan Johnson <[email protected]>
…request order

Review of the roster reservation found three ways the cache lost names it
should keep or kept room it should give back:

- A roster count change ran the effect cleanup before the new reservation,
  so the cache shrank to its base and evicted the roster it had just loaded.
  A release now shrinks after the current task, so a release and a new
  reservation in one commit only resize to the new size.
- A retained thread tab in the background held its reservation until it
  closed. A conversation now reserves room only while it is active.
- The verified cache shrank its head store, which evicts by arrival order,
  before it trimmed by request order. A recently requested verified name
  could lose its head and never be asked for again. It now trims first and
  treats any head the byte budget drops as evicted.

Also: the known-unnamed list shrinks with the cache, the mention menu does
not reread known-unnamed members each time it opens, Space completion asks
for up to the current limit, and the large cache tests use unsigned
profiles so they stay fast under a loaded run.

Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Logan Johnson <[email protected]>
… step

Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Logan Johnson <[email protected]>
… cancel stale reads on shrink

Review of the roster reservation found these gaps:

- The mention menu counted every earlier request in the session against
  the current roster's allowance. After a first large channel, a second
  one loaded only part of its roster. Only this roster's own earlier
  requests count now.
- Verified-name syncs shared the 32 artifact sync slots, at 128 people
  each, so verified names stopped at about 4,096 people and a large roster
  could refuse artifact views. They now have their own pool of 80, enough
  for the 10,240-profile limit.
- A shrink left the verified syncs it emptied open until the next ensure.
  It now stops them at once.
- A background read still in flight when a channel released its room
  landed after the shrink as the newest entries and could push out names
  the next view had just loaded. A shrink now cancels those reads and
  counts them with evictions, so the mention menu asks again within the
  new limit. Foreground reads and reconnect refreshes keep going.
- The channel reserves in a layout effect, so the mention menu reads the
  grown limit in the same commit the roster arrives.

Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Logan Johnson <[email protected]>
A 9,000-member channel showed 16 frames over 50 ms while the mention menu
loaded its roster. The profile listeners were not the cost: the open menu
re-ranked every name on each 500-profile page, about 60 ms per publish.

- A background profile read of many pages now publishes once, when its
  pages settle. The session accepts each page before the read returns it,
  so the directory recognises a page by its pending batch. A foreground
  read that joins the batch still publishes at once. An accepted page
  counts as loaded before its publish, so asking again reads nothing.
- The channel avatar preview reads at most the first 1,024 members. With
  room reserved for the roster, it had read all 9,000 profiles just to
  show three avatars.
- A new Browser measurement opens the mention menu in a 9,000-member
  channel, checks each member is read once, and records frame gaps for
  the load and for leaving the channel.

Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Logan Johnson <[email protected]>
…t the cap

- The folded publish now applies only to events that answer the
  directory's own background read: the session passes the read's signal
  to accept. Live traffic publishes at once, so a profile update (an
  agent owner revocation) no longer waits behind a read that has not
  finished.
- A roster loads into the room its channel reserved (limit minus the
  1,024 base), in the mention menu and in Space completion. A roster past
  the 10,240 cap no longer evicts the viewer, visible authors and DM
  names. With no reservation a view may still use the base.
- scopeKeys in the naming policy owns the scope rule (a selection plus
  its agents' owners). The name directory looks up those rows with it
  instead of keeping its own copy, and the policy contract says a scope's
  input holds only those rows.

Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Logan Johnson <[email protected]>
@loganj
loganj force-pushed the larry/profile-cache-grow branch from 2544926 to a1f623a Compare November 3, 2026 18:30

@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.

🤖 all of my earlier comments are fixed in a1f623a, and I checked each fix against the code. the 9,000-member measurement covers the frame-cost question. the one long frame left comes from mention ranking being linear in the roster, which the description calls out, and I think it is fine to follow up separately. lgtm

@loganj
loganj merged commit fe6eaa2 into main Nov 3, 2026
41 checks passed
@loganj
loganj deleted the larry/profile-cache-grow branch November 3, 2026 18:49
dr0uleau added a commit that referenced this pull request Nov 3, 2026
* commit '05fba2455':
  feat(agents): show each session's system prompt and dividers in activity (#1855)
  feat(bestie): hourly check-ins from your Bestie (#2037)
  Authorize local apps to use Buzz MCP tools (#2211)
  feat(bestie): choose the private channels Bestie can read (#2036)
  Polish message actions, spacing, and membership notices (#2258)
  test(unread): prevent startup catch-up in receipt reserve journey (#2274)
  test(search): wait for rejected join clearance to settle (#2278)
  test(reactions): provide stable emoji media for icon rendering (#2279)
  feat(profiles): reserve profile room for an open channel's roster (#2269)

Signed-off-by: Daniel Rouleau <[email protected]>
Co-authored-by: Codex <[email protected]>

# Conflicts:
#	src/bundled/codex/runtime.ts
ljknight added a commit to ljknight/buzz-app that referenced this pull request Nov 3, 2026
…ection

* origin/main: (39 commits)
  Authorize local apps to use Buzz MCP tools (block#2211)
  feat(bestie): choose the private channels Bestie can read (block#2036)
  Polish message actions, spacing, and membership notices (block#2258)
  test(unread): prevent startup catch-up in receipt reserve journey (block#2274)
  test(search): wait for rejected join clearance to settle (block#2278)
  test(reactions): provide stable emoji media for icon rendering (block#2279)
  feat(profiles): reserve profile room for an open channel's roster (block#2269)
  feat(host): harden byte lifecycle and add protected image upload (block#2199)
  Updated onboarding (block#2183)
  perf(relay): read each signed roster once in large channels (block#2244)
  AIDA-2346: Keep spoilers hidden in reminder previews (block#2261)
  fix: AIDA-2371 Keep sidebar selection pill aligned during resize (block#2182)
  fix(relay): skip background rereads of profiles a read found absent (block#2147)
  fix: prevent startup logo selection (block#1823)
  fix(agents): send sign-in evidence with agent Git credentials (block#2240)
  perf(mentions): stop scanning every member on each mention keystroke (block#2246)
  fix(attachments): send image dimensions so previews don't shift (block#2262)
  feat(shortcuts): add Slack-style hotkeys (block#2170)
  perf(plugins): share host request clients across calls (block#2243)
  fix(identity): keep federated sessions and agent grants in one keychain item (block#2225)
  ...

# Conflicts:
#	src/features/messages/ThreadPanel.tsx
arividar pushed a commit to minniai/minni-buzz-app that referenced this pull request Nov 4, 2026
…ock#2269)

🤖

## Summary
- In channels with more than 1,024 members, the app could not keep every
member's name in memory. The profile cache holds at most 1,024 profiles.
So the mention menu loaded only the first 1,024 members. Loading them
also pushed every other cached name out, and names elsewhere in the app
changed to a short hex key.
- Now an open channel reserves room for its whole member list on top of
the 1,024-profile base, up to 10,240 profiles in total. When you leave
the channel, the limit goes back to 1,024 and the app removes the
profiles nobody has asked for recently. The room is temporary: the cache
stays at 1,024 when no large channel is open.
- The mention menu now loads the whole member list into the reserved
room. It asks again for names the cache removed after it first asked for
them. It does not ask again each time it opens for members already known
to have no name.
- Labeling a channel's members now looks up only that channel's own
people (and the owners of its agents). Before, it passed every cached
profile to the naming rules. So labeling no longer gets slower as the
profile cache grows.

## Details
- `profile-directory.ts`: `reserve(members)` returns a release function.
The limit is `min(10_240, 1_024 + largest reservation)`. A grow takes
effect at once. A release shrinks after the current task (a
`setTimeout`), so a release followed by a new reservation in the same
React commit (a remount or a member count change) only resizes to the
new size and keeps the loaded profiles. A shrink also trims the
known-unnamed set and publishes once.
- `budget.ts`: the byte budget is now the entry limit × 2 KiB (2 MiB at
the base, 20 MiB at the cap), so a larger limit is not cut short by the
old fixed 2 MiB budget.
- `verified-profile-directory.ts` follows the same limit. On a shrink it
trims by request order first, then resizes its signed-head store. Any
head that the byte budget removes is treated as evicted, so a later
request fetches it again instead of keeping a name that can never be
checked.
- `Conversation.tsx` reserves only while the conversation is active. A
retained background tab gives its room back.
- `MentionCompletion.tsx` skips known-unnamed members unless the user
clicks Retry, and starts over when the cache reports evictions. Space
completion asks for up to the current limit, not a fixed 1,024.
- `identity-names/directory.ts` builds a scope's labels from the scope's
own keys and their agents' owners.
- `docs/channels.md` and `src/bundled/mentions/README.md` describe the
new limit and the mention menu rules.
- Tests use a new `unsignedProfiles()` fixture for cache-size tests, so
the large-cache tests stay fast. The verified-cache shrink test still
uses real signed events.

## Review fixes
- The mention menu's allowance counts only this roster's earlier
requests, so a second large channel loads its whole roster. A roster
loads into the room its channel reserved (limit minus the 1,024 base),
so a roster past the 10,240 cap keeps other names cached. Space
completion uses the same room.
- Verified-name syncs have their own pool of 80 (enough for 10,240
people at 128 each), apart from the 32 artifact syncs. A shrink stops
the syncs it emptied at once.
- A shrink cancels background profile reads still in flight.
`evictions()` counts them, so the mention menu asks again.
- `Conversation.tsx` reserves in a layout effect, before a child's
effect reads the limit.
- A background profile read of many pages publishes once, when its pages
settle. The session passes the read's signal to `accept`, so only that
read's own pages wait; live traffic and a foreground read that joins
publish at once. An accepted page counts as loaded before its publish.
- The channel avatar preview reads at most the first 1,024 members. With
the reserved room it had read a whole 9,000-member roster to show three
avatars.
- `scopeKeys` in the naming policy owns the scope rule; the name
directory uses it.

## Measurement
New Browser measurement `profile-roster-scale.spec.mjs`: a 9,000-member
channel, open the mention menu until every member is read, then leave
the channel. It checks each member is read once.

| Chromium | frames > 50 ms | longest frame | time past 60 Hz |
|---|---|---|---|
| main (loads 1,024) | 1 | 60 ms | 126 ms |
| before the fold | 16 | 117–133 ms | ~970 ms |
| this PR | 1–2 | 133–150 ms | 168–201 ms |

WebKit: main 3 frames over 50 ms (longest 110 ms); this PR 2 (longest
131 ms). Leaving the channel: no frame over 50 ms in Chromium, one 51 ms
frame in WebKit.

The profile listeners were not the cost (label refold is ~6 ms per
publish at 9,000). The open mention menu re-ranked every name on each
500-profile page. The one remaining long frame is a single re-rank of
9,000 names.

## Not in this PR
- Mention ranking is still linear in the roster, so one publish at 9,000
members costs one ~130 ms frame while the menu is open.

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

---------

Signed-off-by: Logan Johnson <[email protected]>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <[email protected]>
matt2e added a commit that referenced this pull request Nov 4, 2026
…2395)

## What users see

Someone mentions you in a channel you haven't opened this session, and
their profile hasn't loaded yet. You leave any channel and open the
**Inbox**. The row shows a shortened key (`npub…srl`) and a letter
avatar instead of their name. It stays that way while the Inbox is open,
and nothing asks again. Their name appears only after you open their
channel.

This is a regression from #2269 ("reserve profile room for an open
channel's roster", by Logan Johnson, `loganj`). A bisect over 248
commits, with no skips, found its merge commit `fe6eaa222` as the first
bad one. Its parent `589c2a489` names the author right away. The channel
being left doesn't need to be large: in the repro, #Alpha has one
member.


https://github.com/user-attachments/assets/e1e24cfb-14c1-4a4c-ba65-993443e28b5c

Left: the branch base 8668dbd. Right: this branch at b3d1f09. Both
sides click Inbox on the same frame. On the left, the Inbox's read for
Priya is cancelled 6 ms after it's sent and the row stays `npub…srl`. On
the right, the read returns 200 in 16 ms and the row shows "Priya Shah"
from the first Inbox frame.

![Three seconds after clicking Inbox: the base build (red, left) still
shows npub…srl with a letter avatar; the fix (green, right) shows Priya
Shah with her
avatar](https://github.com/user-attachments/assets/f378e271-14b3-4921-bd45-516340b00c47)

## Cause

1. An open channel reserves profile-cache room for its roster
(`Conversation.tsx`, a layout effect). Leaving releases that
reservation, and the release schedules the shrink one task later
(`shrinking ??= setTimeout(resize)`).
2. In the same commit, the Inbox's mount effect asks for the names it
shows, as a background read.
3. When `resize()` runs, it treats the shrink (1025 → 1024 in the repro)
as a reason to cancel **every** pending background read that isn't a
reconnect refresh. That includes the read the Inbox just started.
4. The cancel is silent: `ensure()` resolves normally, and only
`evictions()` moves. The mention menu is the only consumer of
`evictions()`. The Inbox's effect has no dependency that changes, so it
never asks again.

The cancel in #2269 was right for the departing view's reads, for
example the mention menu's 500-author roster pages, which would
otherwise land late and push the next view's names out of the
1,024-entry cache. It was wrong for reads the **next** view started
while the shrink was waiting.

## The fix

The fix is in one production file,
`src/features/relay/profile-directory.ts` (+28/−8):

- **Stamp at creation.** A counter goes up each time a reservation is
released. A read records that count when it is created, and the stamp
never changes (`readonly releases`).
- **One predicate.** `shrinkCancels(batch)` is true for a background
read that isn't a reconnect refresh and was created before the latest
release. `resize()` uses it to choose what to cancel. `ensure()` uses it
to decide whether a request may join a pending read.
- **No joining a read that's about to be cancelled.** While a shrink is
waiting, a request doesn't join a pending read that the shrink would
cancel. That name gets its own read, and the shrink still cancels the
rest of the departing page. Joins on foreground and refresh reads, and
any join while no shrink is waiting, work as before.
- A remount or a roster-count change is unaffected. `reserve()` clears
the waiting shrink before the children's effects ask for names.
- The `evictions()` doc comment, the `resize()` comment and one line of
`docs/channels.md` now describe the narrower cancel. `docs/buzz-help.md`
is unchanged, because this restores the earlier behaviour.

**Why the stamp isn't updated on a join.** The first commit (8429c27)
updated a pending read's stamp whenever a later request joined it.
Review showed that this kept a whole departing roster page alive for the
sake of one shared author. The reader runs one background job at a time,
so those pages also landed before the Inbox's own read. In a
9,000-member scenario, 3 joined pages loaded 1,500 roster profiles into
the 1,024-entry cache. That pushed out 5 names the Inbox had already
shown, and those rows went back to `npub…`, which is the case #2269 was
written to prevent. With the creation-time stamp and the join skip, the
same scenario cancels 19 pages and keeps all the Inbox's names and all
200 earlier names, with the same 20 reads.

**Rejected alternatives**
- **Make the Inbox re-read when `evictions()` moves.** That fixes one
page, and every switch from a channel to the Inbox would send the read,
cancel it and send it again.
- **Bump `generation()` when a shrink cancels reads.** That changes what
`generation()` means: it marks a full cache purge, which other code keys
on.
- **Drop the cancel, or skip it for small shrinks.** Either one brings
back the eviction case #2269 fixed.

**Accepted costs.** These were noted in review and don't block:
- A release that leaves a larger reservation open can split a join into
a second, redundant read until the next task.
- If the next view asks again for every author on a departing page, that
page isn't cancelled. It loads only names the next view asked for, so
the cost is one redundant read and no names are pushed out.

## Surfaces covered

The directory change covers every page that replaces a channel and asks
for names in its mount effect without watching `evictions()`:
- the Inbox (tested and recorded);
- Drafts, opened straight from a channel (`DraftsView.tsx`);
- Reminders (`RemindersPage.tsx`);
- Workflows owner names (`WorkflowLanding.tsx`);
- Agents → Watching (`watching/Tab.tsx`);
- the Inbox hover preview in open PR #2347, which copies the Inbox's
effect.

These were found by reading the code. Only the Inbox has a browser case
or a recording. The others use the same directory rule, which the unit
test covers.

**Out of scope.** Views that stay mounted when you leave a channel are
still exposed if their read happens to be in flight at that moment:
- sidebar DM labels (`useChannelLabels.ts`);
- Inbox names for edited-message authors (`unread.ts`);
- channel previews loaded in the background (`store.ts`), which are
re-read when the channel opens.

The window is one relay round trip at the moment you leave. None of
these was reproduced, so a follow-up is warranted only if one is.

## Tests

### Unit test (Vitest), `src/features/relay/profile-details.test.ts`

The new test, "keeps background reads asked for after a release when the
shrink runs", sits next to #2269's cancellation test, which is
unchanged.
- **Setup:** reserve room. Start two departing background reads: one for
a `stale` author, and one page holding `joined` and `other`. Release.
Before the timer fires, the next view asks for `fresh` and `joined`.
- **Assertions:**
- how each read is split: `[stale]`, `[joined, other]`, `[fresh,
joined]`;
- `evictions()` moves by exactly 2, because both departing reads are
cancelled;
- every read is answered with all four profiles, and only `fresh` and
`joined` end up in the cache.
- **Mutants:** in the round 2 review, four mutants were run against this
test at b01fe78, and each failed:
1. the first commit's stamp update on join: evictions +1, and `other` is
named;
  2. no join skip: `joined` stays unnamed;
3. a shrink that never cancels: evictions +0, and `stale` and `other`
are named;
4. main's behaviour: `fresh` and `joined` stay unnamed, with evictions
+3.

Mutants 1 and 2 issued 3 reads either way, so b3d1f09 added the
read-split assertion. With the join skip removed, the third read holds
only `fresh`, and the test now fails on the split too.

### Browser case, `tests/browser/inbox.spec.mjs`

One case was added, "Inbox names after leaving a channel › names an
author seen only in the Inbox", and none were removed.
- **The flow:** open on #Alpha, click Inbox, and expect the row for an
author seen only in #Launch to contain "Priya Shah". Also expect exactly
one kind-0 query naming her.
- **Why it needs a browser:** the bug depends on ordering across two
surfaces: the channel's layout-effect release, then the Inbox's
mount-effect read, then the deferred shrink. The unit test proves the
directory rule, not that the real Inbox's read starts after the release
and before the shrink.
- **Fail, then pass:** it fails without the fix in both Chromium and
WebKit, where the row shows `npub…`. It passed 5/5 per engine at
8429c27 and passed again in both engines after the join change.
Production code hasn't changed since then; b3d1f09 changes only the
unit test and a comment.
- **Why CI missed this:** no journey checked the name of an author seen
only in the Inbox.

### Other checks during development

These were not re-run for this push:
- `profile-details.test.ts`: 25/25 pass.
- The 16 unit test files that import the profile directory: 630/630
pass.
- `profile-roster-scale.spec.mjs`, #2269's 9,000-member check: passes in
both measurement projects.
- `typecheck`, `lint` and `git diff --check`: clean.
- Pre-commit hooks: passed on each commit. Pre-push hooks were skipped
for this push (`--no-verify`).

## Review rounds

- **Round 1 (8429c27):** one blocker. Updating the stamp on a join
kept departing roster pages alive (the 9,000-member scenario above).
Fixed in b01fe78, along with the optional items: a clearer field
comment, a `stale` assertion that can actually fail (the test now
answers every read with all profiles), and a minimal `docs/channels.md`
diff.
- **Round 2 (b01fe78):** the blocker is fixed, with no new defects,
and the change was judged at the 9/10 bar. Its optional items, asserting
how each read splits and a comment wording, are in b3d1f09.

## Status

This is a draft until a human tries it in the app (AGENTS.md "Before
opening a PR", step 3). To test: open the app on a channel, then click
**Inbox**. An author who mentioned you in a channel you haven't opened
this session should show their name and avatar right away, without
opening their channel.

Deferred:
- the human test above;
- hosted CI on this head;
- a WebKit recording (the browser case covers WebKit);
- recordings of the other covered pages.

Logan Johnson (`loganj`): this narrows #2269's cancel-on-shrink and
changes the `evictions()` contract wording. Your review is welcome.

🤖 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]>
kalvinnchau added a commit that referenced this pull request Nov 6, 2026
## Problem

Each Cmd+K keystroke ranks every cached profile against the typed text.
`personMatch` calls `matchPerson` for each of a person's shown and
hidden names. `matchPerson` folds the whole name on every call: NFKD,
two regex replaces and a lowercase per code point, plus array spreads.
It also folds the query again for every name. #2322 added a verified
full name and profile names to each person. #2269 raised the profile
cache ceiling from 1,024 to 10,240. Together they made this
per-keystroke pass several times larger, and nearly all of it re-folds
names that did not change since the previous keystroke.

## Change

- `matchTier` returns the tier `matchPerson` would give. It reads folded
names from a module-level cache that holds at most 65,536 names and is
cleared when full. Names that fold to more than 128 code points are not
cached.
- `matchPerson` and `matchTier` share the matching body (`search`), so
tier rules live in one place. `matchPerson` still builds its own fold
with origins for underlining.
- `rankPeople` folds the query once per ranking and passes it to
`personMatch`, which uses `matchTier`. Before, `personMatch` refolded
the same query for every person: 10,240 folds per keystroke with a full
profile cache, about 2.4–4.4 ms for a 2–4 character query in a Node
micro-benchmark. This last step is not in the table below.
- `nameStarts` and the cache share one fold helper.

## No behavior changes

`matchTier` runs the same search over the same folded characters as
`matchPerson`, so people rank exactly as before. A new test checks that
`matchTier` gives `matchPerson`'s tier for repeated names and changing
queries, including accented, compatibility, final-sigma and padded
names. Underlined positions still come from `matchPerson`.

## Evidence

Two benchmarks compare main and this PR:

- **Browser.** A production build runs in Chromium at 4× CPU throttling.
The rows are keydown → next-paint medians (Event Timing) for typing in
Cmd+K, with names that miss and names that match people, and for typing
in the composer. Main `8c0cb0c1e` and this PR were run as six serial,
interleaved pairs on one Linux and one macOS workstation. All keys
matched in every run.
- **Render.** `SearchResults` renders in jsdom with 1,700, 5,000 or
10,240 cached profiles (accented and plain names, half with verified
full names). It types `a`, `al`, `ale`, `alex` (cold), then repeats the
same keystrokes (warm). Values are the median time to render one
keystroke. Main and this PR were interleaved on 2 Linux and 2 macOS
workstations, with 16 samples per cell.

| metric | Linux: main | Linux: this PR | Linux Δ % | macOS: main |
macOS: this PR | macOS Δ % |
|---|---|---|---|---|---|---|
| browser: Cmd+K, names that miss | 128 ms | 104 ms | −18.8% | 88 ms |
72 ms | −18.2% |
| browser: Cmd+K, people | 144 ms | 112 ms | −22.2% | 96 ms | 80 ms |
−16.7% |
| browser: composer, `@` mention | 448 ms | 440 ms | −1.8% (noise) | 272
ms | 272 ms | 0% |
| browser: composer, plain prose | 24 ms | 24 ms | 0% | 16 ms | 16 ms |
0% |
| render: 1,700 profiles, cold | 23.0 ms | 16.0 ms | −30.4% | 13.8 ms |
10.0 ms | −27.8% |
| render: 1,700 profiles, warm | 16.3 ms | 9.5 ms | −41.8% | 9.7 ms |
6.0 ms | −38.3% |
| render: 5,000 profiles, cold | 39.3 ms | 22.5 ms | −42.6% | 22.2 ms |
13.0 ms | −41.5% |
| render: 5,000 profiles, warm | 34.7 ms | 15.5 ms | −55.4% | 19.9 ms |
9.3 ms | −53.1% |
| render: 10,240 profiles, cold | 69.1 ms | 32.7 ms | −52.6% | 39.5 ms |
19.8 ms | −49.8% |
| render: 10,240 profiles, warm | 65.8 ms | 25.7 ms | −61.0% | 37.5 ms |
15.5 ms | −58.6% |

Lower is better.

- Event Timing rounds durations to 8 ms, so the Linux mention change is
one rounding step and counts as noise.
- The render benchmark ran before two changes: the bypass for names over
128 code points and the shared fold helper. Neither affects names as
short as these.

Checks: the search, mentions, shell and channel-members unit suites
pass, along with `tsc` and `biome`.

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

---------

Signed-off-by: Kalvin C <[email protected]>
Signed-off-by: Kalvin Chau <[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