From 84a4999278f6625b1f39eec65fa134ac68fdb8d9 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 28 Oct 2026 11:23:41 -0400 Subject: [PATCH 1/6] fix(mentions): stop per-keystroke member scans in large channels Typing in any composer in a large channel (#general, ~2,500 members) rebuilt every mention candidate on each keystroke, even with the @ chooser closed: useTeamMentions called mentionCandidates unconditionally, and that build was O(members x agents) with per-member find/filter/some scans. - Build team eligibility only for shown team rows of an open chooser. - Cache mentionCandidates per session/channel on its immutable source snapshots, and build it in one keyed pass. - Reuse labeled choices and local keys per cached candidate list. - Match queries through a sorted index of name starts (matchingMentions), so an @ keystroke costs O(log n + matches) instead of ranking every candidate. - Compute composer chip names only when recipients change. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Logan Johnson --- src/bundled/mentions/MentionPicker.tsx | 3 +- src/bundled/mentions/mention-candidates.ts | 103 ++++++++++++++++--- src/bundled/mentions/mention-ranking.test.ts | 33 ++++++ src/bundled/mentions/mention-ranking.ts | 46 +++++++++ src/bundled/mentions/team-mentions.test.tsx | 33 ++++++ src/bundled/mentions/use-mention-choices.ts | 102 ++++++++++++------ src/bundled/mentions/use-team-mentions.ts | 3 +- src/features/messages/RichComposerInput.tsx | 89 +++++++++------- src/features/search/person-match.ts | 18 ++++ 9 files changed, 346 insertions(+), 84 deletions(-) diff --git a/src/bundled/mentions/MentionPicker.tsx b/src/bundled/mentions/MentionPicker.tsx index e2035a2e18..fa40032b46 100644 --- a/src/bundled/mentions/MentionPicker.tsx +++ b/src/bundled/mentions/MentionPicker.tsx @@ -78,7 +78,8 @@ export function MentionPicker({ const parentAdmission = !!channel && channel.channelType !== "solo"; const members = draftRoster?.map((person) => person.pubkey) ?? channel?.members; - const memberKey = members?.join(":") ?? ""; + // Only an open chooser demands names; typing must not join every member. + const memberKey = open && !draftRoster ? (members?.join(":") ?? "") : ""; useEffect(() => { if (draftRoster || !open || !memberKey) return; let current = true; diff --git a/src/bundled/mentions/mention-candidates.ts b/src/bundled/mentions/mention-candidates.ts index 37f5295d7f..01563811ee 100644 --- a/src/bundled/mentions/mention-candidates.ts +++ b/src/bundled/mentions/mention-candidates.ts @@ -16,11 +16,14 @@ function availableMentionAgents( agents: readonly T[], canInvite: boolean | undefined, ) { - return channel?.members && - (channel.channelType === "stream" || channel.channelType === "forum") && - canInvite - ? agents.filter((agent) => !channel.members?.includes(agent.pubkey)) - : []; + if ( + !channel?.members || + (channel.channelType !== "stream" && channel.channelType !== "forum") || + !canInvite + ) + return []; + const members = new Set(channel.members); + return agents.filter((agent) => !members.has(agent.pubkey)); } /** Row detail for an outside choice. Nobody can be added to a DM. */ @@ -34,15 +37,68 @@ export function outsideMentionDetail( : "Not in channel · Choose whether to add when you send"; } +type DirectoryPerson = MentionRecipient & { isAgent?: true }; +type ElsewhereAgent = Pick; +const none: readonly never[] = []; +// Rows change only with their sources, not with each keystroke. Snapshots are +// immutable, so identity is their revision; `joinable()` copies on each call. +const built = new WeakMap< + ComposerSession, + Map +>(); +export type MentionCandidate = ReturnType[number]; + /** What the chooser offers. The host admits recipients itself (mention-admission). No reads or writes. */ export function mentionCandidates( session: ComposerSession, channelId: string, inviteAgents = false, roster?: readonly MentionRecipient[], - directory: readonly (MentionRecipient & { isAgent?: true })[] = [], + directory: readonly DirectoryPerson[] = none, /** The owner's agents in their other communities (`agents.joinable()`). */ - elsewhere: readonly Pick[] = [], + elsewhere: readonly ElsewhereAgent[] = none, +): readonly MentionCandidate[] { + const sources = [ + // Only this channel's row; activity elsewhere replaces the list, not it. + session.channels.list().channels.find((c) => c.id === channelId), + session.agentChoices.snapshot(), + session.profiles.snapshot(), + session.archives?.snapshot(), + session.outbox?.supports(9000), + roster, + directory, + JSON.stringify( + elsewhere.map(({ pubkey, name, relay }) => [pubkey, name, relay]), + ), + ]; + // The chooser reads both its directory rows and its local rows each render. + const key = JSON.stringify([channelId, inviteAgents, directory === none]); + const cache: Map< + string, + { sources: readonly unknown[]; rows: MentionCandidate[] } + > = built.get(session) ?? new Map(); + built.set(session, cache); + const hit = cache.get(key); + if (hit?.sources.every((source, i) => source === sources[i])) return hit.rows; + const rows = buildCandidates( + session, + channelId, + inviteAgents, + roster, + directory, + elsewhere, + ); + cache.set(key, { sources, rows }); + return rows; +} + +function buildCandidates( + session: ComposerSession, + channelId: string, + inviteAgents: boolean, + roster: readonly MentionRecipient[] | undefined, + directory: readonly DirectoryPerson[], + elsewhere: readonly ElsewhereAgent[], ) { const channel = session.channels .list() @@ -103,6 +159,25 @@ export function mentionCandidates( roster, new Set(hosts.keys()), ); + // Keyed lookups keep one pass over members linear in a large channel. + const listed = new Map(); + const directoryAgents = new Set(); + for (const person of directory) { + if (!listed.has(person.pubkey)) listed.set(person.pubkey, person.name); + if (person.isAgent) directoryAgents.add(person.pubkey); + } + const rostered = new Map(); + for (const person of roster ?? []) + if (!rostered.has(person.pubkey)) rostered.set(person.pubkey, person.name); + const agentNames = new Map(); + const managed = new Set(); + for (const agent of agents) { + agentNames.set(agent.pubkey, [ + ...(agentNames.get(agent.pubkey) ?? []), + agent.name, + ]); + if (agent.managed) managed.add(agent.pubkey); + } return [...choices.values()] .filter((p) => admits(p.pubkey)) .map((recipient) => ({ @@ -115,22 +190,18 @@ export function mentionCandidates( agent: hosts.has(recipient.pubkey) || known.has(recipient.pubkey) || - directory.some((p) => p.pubkey === recipient.pubkey && p.isAgent), + directoryAgents.has(recipient.pubkey), owned: !!session.viewer && profiles.get(recipient.pubkey)?.ownerPubkey === session.viewer, - managed: agents.some((a) => a.pubkey === recipient.pubkey && a.managed), + managed: managed.has(recipient.pubkey), aliases: [ ...new Set( [ profiles.get(recipient.pubkey)?.name || - directory.find((person) => person.pubkey === recipient.pubkey) - ?.name || - roster?.find((person) => person.pubkey === recipient.pubkey) - ?.name, - ...agents - .filter((a) => a.pubkey === recipient.pubkey) - .map((a) => a.name), + listed.get(recipient.pubkey) || + rostered.get(recipient.pubkey), + ...(agentNames.get(recipient.pubkey) ?? []), hosts.has(recipient.pubkey) ? recipient.name : undefined, ].filter((name): name is string => !!name), ), diff --git a/src/bundled/mentions/mention-ranking.test.ts b/src/bundled/mentions/mention-ranking.test.ts index 2724fa3e94..302b8c646a 100644 --- a/src/bundled/mentions/mention-ranking.test.ts +++ b/src/bundled/mentions/mention-ranking.test.ts @@ -2,6 +2,7 @@ import { expect, it } from "vitest"; import { npubEncode } from "nostr-tools/nip19"; import { exactMention, + matchingMentions, rankMentions, type MentionChoice, } from "./mention-ranking"; @@ -159,3 +160,35 @@ it.each(mentionConformance.space)( ).toBe(fixture.expected); }, ); + +it("finds every ranked and Space choice through the name index", () => { + // The index is a prefilter: every portable fixture must rank, and choose on + // Space, exactly as it does when ranking reads the whole choice list. + for (const { query, choices } of [ + ...mentionConformance.ranking, + ...mentionConformance.space, + ]) { + const rows = choices.map(fixtureChoice); + const found = matchingMentions(rows, query); + expect(rankMentions(found, query)).toEqual(rankMentions(rows, query)); + expect(exactMention(found, query)).toBe(exactMention(rows, query)); + } + for (const { name, label, query, expected } of mentionConformance.matching) { + const row = choice("a", label, { label, aliases: [label] }); + if (expected !== null) + expect(matchingMentions([row], query), name).toEqual([row]); + } +}); + +it("returns index matches in list order and skips names that cannot match", () => { + const rows = [ + choice("1", "Mary-Jane Watson"), + choice("2", "Honey"), + choice("3", "Jane Doe"), + choice("4", "Agent", { label: "Agent · jane" }), + ]; + expect(matchingMentions(rows, "jane")).toEqual([rows[0], rows[2], rows[3]]); + expect(matchingMentions(rows, "oney")).toEqual([]); + expect(matchingMentions(rows, "jane d")).toEqual([rows[2]]); + expect(matchingMentions(rows, "")).toBe(rows); +}); diff --git a/src/bundled/mentions/mention-ranking.ts b/src/bundled/mentions/mention-ranking.ts index 7f2da2e827..94a2ac8b06 100644 --- a/src/bundled/mentions/mention-ranking.ts +++ b/src/bundled/mentions/mention-ranking.ts @@ -1,6 +1,7 @@ import { exactName, matchPerson, + nameStarts, normalizeName, } from "../../features/search/person-match"; import type { mentionCandidates } from "./mention-candidates"; @@ -28,6 +29,51 @@ export function mentionMatch(choice: MentionChoice, query: string) { if (Number.isFinite(visible)) return visible; return 4 + baseMatch(choice, query); } +// One sorted index of name starts per immutable choice list. +const indexes = new WeakMap< + readonly MentionChoice[], + { starts: string[]; owners: number[] } +>(); +/** + * The choices, in their given order, with a label or alias that the query can + * match: a superset of every choice that `rankMentions` or `exactMention` keeps + * for this query. A binary search of the list's name index finds them, so a + * keystroke costs O(log n + matches) instead of a scan of every choice. + */ +export function matchingMentions( + choices: readonly MentionChoice[], + query: string, +): readonly MentionChoice[] { + const needle = normalized(query); + if (!needle) return choices; + let index = indexes.get(choices); + if (!index) { + const entries = choices.flatMap((choice, owner) => + [...new Set([choice.label, ...choice.aliases])].flatMap((name) => + nameStarts(name).map((start) => ({ start, owner })), + ), + ); + entries.sort((a, b) => compare(a.start, b.start)); + index = { + starts: entries.map((entry) => entry.start), + owners: entries.map((entry) => entry.owner), + }; + indexes.set(choices, index); + } + let low = 0; + let high = index.starts.length; + while (low < high) { + const middle = (low + high) >> 1; + if ((index.starts[middle] ?? "") < needle) low = middle + 1; + else high = middle; + } + const found = new Set(); + for (let i = low; index.starts[i]?.startsWith(needle); i += 1) + found.add(index.owners[i] ?? 0); + return [...found] + .sort((a, b) => a - b) + .flatMap((owner) => choices[owner] ?? []); +} /** * Sorts by per-choice keys, so the order is transitive for any mix of people * and agents. Another community's agents follow everyone here. After match quality, your own agents come first. Agents that diff --git a/src/bundled/mentions/team-mentions.test.tsx b/src/bundled/mentions/team-mentions.test.tsx index 0ca074e295..65301bad30 100644 --- a/src/bundled/mentions/team-mentions.test.tsx +++ b/src/bundled/mentions/team-mentions.test.tsx @@ -498,3 +498,36 @@ it("waits for the initial roster before installing team membership facts", async h.library.dispose(); } }); + +it("a closed chooser reads no member while its composer rerenders", async () => { + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + disconnect() {} + }, + ); + const h = fixture({ count: 300 }); + const members = new Set(h.people.map((person) => person.pubkey)); + const get = vi.spyOn(h.session.profiles.snapshot(), "get"); + const memberReads = () => + get.mock.calls.filter(([key]) => members.has(key)).length; + const picker = (select: () => boolean) => ( + true} + /> + ); + const view = render(picker(() => true)); + // Each keystroke rerenders the composer and its closed chooser. + for (let key = 0; key < 5; key++) view.rerender(picker(() => true)); + expect(memberReads()).toBe(0); + await userEvent.click( + screen.getByRole("button", { name: "Mention a member" }), + ); + expect(memberReads()).toBeGreaterThan(0); +}); diff --git a/src/bundled/mentions/use-mention-choices.ts b/src/bundled/mentions/use-mention-choices.ts index 7a4578e2a9..85da28a23e 100644 --- a/src/bundled/mentions/use-mention-choices.ts +++ b/src/bundled/mentions/use-mention-choices.ts @@ -18,11 +18,30 @@ import { mentionHistory } from "../../features/messages/mention-history"; import { mentionCandidates } from "./mention-candidates"; import { exactMention, + matchingMentions, rankMentions, type MentionChoice, } from "./mention-ranking"; type Person = ReturnType["people"][number]; const none: readonly Person[] = []; +// Derived once per cached candidate list (see mentionCandidates), so a +// keystroke reuses labels and keys instead of renaming every member. +const labeled = new WeakMap< + readonly unknown[], + { sources: readonly unknown[]; choices: readonly MentionChoice[] } +>(); +const keySets = new WeakMap>(); +const keyMaps = new WeakMap< + readonly MentionChoice[], + ReadonlyMap +>(); +const byKey = (choices: readonly MentionChoice[]) => { + const cached = keyMaps.get(choices); + if (cached) return cached; + const map = new Map(choices.map((c) => [c.recipient.pubkey, c])); + keyMaps.set(choices, map); + return map; +}; /** * One installed key list per opening/query. Evidence stays live, order does not. * Local rows install first; directory rows only append after them. @@ -89,6 +108,10 @@ export function useMentionChoices( people, community?.joinable(), ); + const sources = [selected, people, session.names?.snapshot()]; + const hit = labeled.get(candidates); + if (hit?.sources.every((source, i) => source === sources[i])) + return hit.choices; const keys = [ ...new Set([ ...candidates.map((c) => c.recipient.pubkey), @@ -110,7 +133,7 @@ export function useMentionChoices( ]; // One naming scope per choice set, not one normalization per row. const name = session.names?.scope(keys, facts); - return candidates.map((choice) => { + const choices: readonly MentionChoice[] = candidates.map((choice) => { const named = name?.(choice.recipient.pubkey); return { ...choice, @@ -118,22 +141,25 @@ export function useMentionChoices( qualifier: named?.qualifier, }; }); + labeled.set(candidates, { sources, choices }); + return choices; }, [session, channelId, invite, roster, selected, people, community]); // Keys known without the directory search: members and agent choices. - const localKeys = useCallback( - () => - new Set( - mentionCandidates( - session, - channelId, - invite, - roster, - undefined, - community?.joinable(), - ).map((c) => c.recipient.pubkey), - ), - [session, channelId, invite, roster, community], - ); + const localKeys = useCallback(() => { + const local = mentionCandidates( + session, + channelId, + invite, + roster, + undefined, + community?.joinable(), + ); + const cached = keySets.get(local); + if (cached) return cached; + const keys = new Set(local.map((c) => c.recipient.pubkey)); + keySets.set(local, keys); + return keys; + }, [session, channelId, invite, roster, community]); const { candidates, local } = useMemo(() => { // Source revisions invalidate the projection; selection reads them again synchronously. void profiles; @@ -143,7 +169,11 @@ export function useMentionChoices( void resolve; void revision; // A closed or disabled chooser shows nothing, so it names nothing. - if (!open) return { candidates: [], local: new Set() }; + if (!open) + return { + candidates: [] as readonly MentionChoice[], + local: new Set() as ReadonlySet, + }; return { candidates: current(), local: localKeys() }; }, [ open, @@ -166,7 +196,9 @@ export function useMentionChoices( (!roster && !!invite && agents.pending && agents.status !== "ready"); let rows = same ? installed.rows : undefined; if (!same && installed) install(undefined); - const rank = (choices: MentionChoice[]) => + // Only choices the query can match; ranking reads no others. + const matches = matchingMentions(candidates, query); + const rank = (choices: readonly MentionChoice[]) => rankMentions( choices, query, @@ -174,12 +206,13 @@ export function useMentionChoices( (key) => session.presence?.status(key) ?? "unknown", ); const outside = open - ? rank(candidates.filter((c) => !local.has(c.recipient.pubkey))) + ? rank(matches.filter((c) => !local.has(c.recipient.pubkey))) : []; - const missingNames = channel?.members?.some((key) => !profiles.has(key)); + const missingNames = + open && channel?.members?.some((key) => !profiles.has(key)); if (open && !rows && !membersPending) { const ranked = rank( - candidates.filter((c) => local.has(c.recipient.pubkey)), + matches.filter((c) => local.has(c.recipient.pubkey)), ).slice(0, 50); if ( ranked.length || @@ -199,7 +232,7 @@ export function useMentionChoices( // Append-only: late invited agents and directory pages never move shown rows. // Member-list changes keep their installed set until the query changes. const shown = new Set(rows.map((row) => row.recipient.pubkey)); - const added = (invite ? rank(candidates.filter((c) => c.agent)) : outside) + const added = (invite ? rank(matches.filter((c) => c.agent)) : outside) .filter((c) => !shown.has(c.recipient.pubkey)) .slice(0, 50 - rows.length); if (added.length) { @@ -225,15 +258,21 @@ export function useMentionChoices( // directory search runs, failed, or has more pages, a namesake may be missing. const complete = !directory.loading && !directory.error && !directory.more; return useMemo(() => { - const spaceMention = (choices: MentionChoice[], keys: Set) => - pending - ? undefined - : ((missingNames - ? undefined - : exactMention(choices, query, (c) => - keys.has(c.recipient.pubkey), - )) ?? (complete ? exactMention(choices, query) : undefined)); - const live = new Map(candidates.map((c) => [c.recipient.pubkey, c])); + const spaceMention = ( + all: readonly MentionChoice[], + keys: ReadonlySet, + ) => { + if (pending) return undefined; + const choices = matchingMentions(all, query); + return ( + (missingNames + ? undefined + : exactMention(choices, query, (c) => + keys.has(c.recipient.pubkey), + )) ?? (complete ? exactMention(choices, query) : undefined) + ); + }; + const live = byKey(candidates); const choices = (rows ?? []).map((row) => { const now = live.get(row.recipient.pubkey); const reason = now @@ -273,7 +312,8 @@ export function useMentionChoices( (!space || spaceMention(fresh, localKeys()) === pubkey) ); }, - truncated: rankMentions(candidates, query).length > 50, + truncated: + rankMentions(matchingMentions(candidates, query), query).length > 50, }; }, [ session, diff --git a/src/bundled/mentions/use-team-mentions.ts b/src/bundled/mentions/use-team-mentions.ts index f86c331454..288e19b13a 100644 --- a/src/bundled/mentions/use-team-mentions.ts +++ b/src/bundled/mentions/use-team-mentions.ts @@ -102,7 +102,8 @@ export function useTeamMentions( roster, session.agentChoices.snapshot().identities, ); - const current = candidates(); + // Only shown team rows read eligibility; a closed chooser builds none. + const current = open && rows?.length ? candidates() : []; const eligible = new Map(current.map((c) => [c.recipient.pubkey, c])); const choices = (open ? (rows ?? []) : []).map( ({ value, eventId, members }) => { diff --git a/src/features/messages/RichComposerInput.tsx b/src/features/messages/RichComposerInput.tsx index 7358287be4..2e11a08949 100644 --- a/src/features/messages/RichComposerInput.tsx +++ b/src/features/messages/RichComposerInput.tsx @@ -47,44 +47,63 @@ export function RichComposerInput({ channelId, (channel) => channel?.members, ); - useMentionArchives(session); + const archives = useMentionArchives(session); const roster = useContext(DraftMentionRoster); - const profiles = new Map(directory.profiles); - for (const recipient of draft.recipients) - profiles.set(recipient.pubkey, { - ...profiles.get(recipient.pubkey), - name: recipient.name, - }); const resolveName = useIdentityNames(session.names); - // Chips name selections among the people who read this destination. Draft - // recipients stay in even when archived, so their chips keep a name. - const candidates = [ - ...new Set([ - ...(roster?.map((p) => p.pubkey) ?? members ?? []).filter( - (pubkey) => !archivedMention(session, pubkey), - ), - ...draft.recipients.map((p) => p.pubkey), - ]), - ]; - const displayFacts = draft.recipients - .filter((recipient) => !session.names?.lookup(recipient.pubkey, candidates)) - .map((recipient) => ({ - pubkey: recipient.pubkey, - name: recipient.name, - })); - const qualifiers = new Map( - draft.recipients.map((recipient) => [ - recipient.pubkey, - session.names?.lookup(recipient.pubkey, candidates, displayFacts) - ?.qualifier, - ]), - ); - const labels = new Map( - draft.recipients.map((recipient) => [ - recipient.pubkey, - resolveName(recipient.pubkey, recipient.name, candidates, displayFacts), - ]), + // Recipients, not keystrokes, change chip names: typing prose must not + // copy every profile or scan every member of a large channel. + const recipientKey = JSON.stringify( + draft.recipients.map(({ pubkey, name }) => [pubkey, name]), ); + const { profiles, qualifiers, labels } = useMemo(() => { + void archives; + const recipients = JSON.parse(recipientKey) as [string, string][]; + const profiles = new Map(directory.profiles); + for (const [pubkey, name] of recipients) + profiles.set(pubkey, { ...profiles.get(pubkey), name }); + if (!recipients.length) + return { + profiles, + qualifiers: new Map(), + labels: new Map(), + }; + // Chips name selections among the people who read this destination. Draft + // recipients stay in even when archived, so their chips keep a name. + const candidates = [ + ...new Set([ + ...(roster?.map((p) => p.pubkey) ?? members ?? []).filter( + (pubkey) => !archivedMention(session, pubkey), + ), + ...recipients.map(([pubkey]) => pubkey), + ]), + ]; + const displayFacts = recipients + .filter(([pubkey]) => !session.names?.lookup(pubkey, candidates)) + .map(([pubkey, name]) => ({ pubkey, name })); + return { + profiles, + qualifiers: new Map( + recipients.map(([pubkey]) => [ + pubkey, + session.names?.lookup(pubkey, candidates, displayFacts)?.qualifier, + ]), + ), + labels: new Map( + recipients.map(([pubkey, name]) => [ + pubkey, + resolveName(pubkey, name, candidates, displayFacts), + ]), + ), + }; + }, [ + session, + directory.profiles, + recipientKey, + roster, + members, + archives, + resolveName, + ]); const previous = useRef(labels); const revealed = useRef( new Set([...qualifiers].filter(([, suffix]) => suffix).map(([key]) => key)), diff --git a/src/features/search/person-match.ts b/src/features/search/person-match.ts index 5f3b1aa216..d62248846e 100644 --- a/src/features/search/person-match.ts +++ b/src/features/search/person-match.ts @@ -74,6 +74,24 @@ export function exactName( return exact.length === 1 ? exact[0] : undefined; } +/** + * Every folded suffix of `name` where `matchPerson` can begin a match: the + * trimmed name itself and each later word start. A query matches only as a + * prefix of one of these, so a sorted list of them finds matches by search. + */ +export function nameStarts(name: string) { + const chars = [...name].flatMap((char) => [...foldPoint(char)]); + let start = 0; + let end = chars.length; + while (start < end && space.test(chars[start] ?? "")) start += 1; + while (end > start && space.test(chars[end - 1] ?? "")) end -= 1; + const starts = [chars.slice(start, end).join("")]; + for (let from = start + 1; from < end; from += 1) + if (isWord(chars[from]) && !isWord(chars[from - 1])) + starts.push(chars.slice(from, end).join("")); + return starts; +} + export type PersonMatch = { /** 0 equal, 1 name starts with the query, 2 a word equals it, 3 a word starts with it. */ tier: 0 | 1 | 2 | 3; From 835b4fbeb6f97e0c27be4f5ae56840b2d8b38e11 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 28 Oct 2026 11:39:52 -0400 Subject: [PATCH 2/6] Keep mention caches across draft, team and empty-page rerenders The draft recreates its recipient array on every edit, team rows and the chooser shared one candidate cache slot, and every empty directory page was a fresh array. Each one rebuilt every label and the name index on an @ keystroke. Name on recipient facts, keep a slot per directory array, share one empty page, and keep rows for only the latest few channels. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Logan Johnson --- src/bundled/mentions/mention-candidates.ts | 37 ++++++++++++----- src/bundled/mentions/team-mentions.test.tsx | 45 +++++++++++++++++++++ src/bundled/mentions/use-mention-choices.ts | 8 +++- src/bundled/mentions/useMentionDirectory.ts | 3 +- 4 files changed, 81 insertions(+), 12 deletions(-) diff --git a/src/bundled/mentions/mention-candidates.ts b/src/bundled/mentions/mention-candidates.ts index 01563811ee..6793f73df1 100644 --- a/src/bundled/mentions/mention-candidates.ts +++ b/src/bundled/mentions/mention-candidates.ts @@ -42,10 +42,21 @@ type ElsewhereAgent = Pick; const none: readonly never[] = []; // Rows change only with their sources, not with each keystroke. Snapshots are // immutable, so identity is their revision; `joinable()` copies on each call. +// Each directory array keeps its own rows: the chooser and its team rows pass +// different directories in one render and must not evict each other. Only the +// latest few channels and directory pages keep rows. +type Built = { sources: readonly unknown[]; rows: MentionCandidate[] }; const built = new WeakMap< ComposerSession, - Map + Map> >(); +/** Moves `key` to the newest end, dropping the oldest past `limit`. */ +function recent(map: Map, key: K, value: V, limit: number) { + map.delete(key); + map.set(key, value); + if (map.size > limit) map.delete(map.keys().next().value as K); + return value; +} export type MentionCandidate = ReturnType[number]; /** What the chooser offers. The host admits recipients itself (mention-admission). No reads or writes. */ @@ -66,20 +77,26 @@ export function mentionCandidates( session.archives?.snapshot(), session.outbox?.supports(9000), roster, - directory, JSON.stringify( elsewhere.map(({ pubkey, name, relay }) => [pubkey, name, relay]), ), ]; - // The chooser reads both its directory rows and its local rows each render. - const key = JSON.stringify([channelId, inviteAgents, directory === none]); - const cache: Map< + const key = JSON.stringify([channelId, inviteAgents]); + const channels: Map< string, - { sources: readonly unknown[]; rows: MentionCandidate[] } + Map > = built.get(session) ?? new Map(); - built.set(session, cache); - const hit = cache.get(key); - if (hit?.sources.every((source, i) => source === sources[i])) return hit.rows; + built.set(session, channels); + // Local rows, team rows and the current directory page. + const cache: Map = recent( + channels, + key, + channels.get(key) ?? new Map(), + 4, + ); + const hit = cache.get(directory); + if (hit?.sources.every((source, i) => source === sources[i])) + return recent(cache, directory, hit, 3).rows; const rows = buildCandidates( session, channelId, @@ -88,7 +105,7 @@ export function mentionCandidates( directory, elsewhere, ); - cache.set(key, { sources, rows }); + recent(cache, directory, { sources, rows }, 3); return rows; } diff --git a/src/bundled/mentions/team-mentions.test.tsx b/src/bundled/mentions/team-mentions.test.tsx index 65301bad30..d417298fef 100644 --- a/src/bundled/mentions/team-mentions.test.tsx +++ b/src/bundled/mentions/team-mentions.test.tsx @@ -6,6 +6,8 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { MentionCompletion } from "./MentionCompletion"; import { MentionPicker } from "./MentionPicker"; import { mentionQuery } from "./mention-query"; +import { useMentionChoices } from "./use-mention-choices"; +import { useTeamMentions } from "./use-team-mentions"; import { createAgentLibrary } from "../../features/agents/library"; import { createAgentChoices } from "../../features/agents/choices"; import { ComposerCompletions } from "../../features/conversation/ComposerCompletions"; @@ -15,8 +17,11 @@ import type { } from "../../features/conversation/contracts"; import type { CompletionEditor } from "../../features/conversation/useCompletionEditor"; import type { ComposerInputElement } from "../../features/messages/composer-dom"; +import type { MentionRecipient } from "../../features/messages/mention-draft"; +import { SelectedMentionContext } from "../../features/messages/selected-mention-context"; import type { RelaySession } from "../../features/relay/session"; import type { Contribution } from "../../plugins/contributions"; +import * as personMatch from "../../features/search/person-match"; // jsdom lacks scrollIntoView; the search highlight keeps its row in view. beforeEach(() => { @@ -531,3 +536,43 @@ it("a closed chooser reads no member while its composer rerenders", async () => ); expect(memberReads()).toBeGreaterThan(0); }); + +it("an @ keystroke reuses the name index across draft and team rerenders", async () => { + const h = fixture({ count: 300 }); + const starts = vi.spyOn(personMatch, "nameStarts"); + function Chooser({ query }: { query: string }) { + const model = useMentionChoices(h.session, "parent", false, query, "open"); + // Shown team rows read candidates with their own directory. + useTeamMentions(h.session, "parent", false, query, model); + return null; + } + // The draft builds a new recipient array on every edit. + const draft = (query: string, selected: readonly MentionRecipient[]) => ( + + {/* Inline completion remounts on every keystroke. */} + + + ); + vi.useFakeTimers(); + // Each settled directory search finds nobody. + const settle = () => + act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + const view = render(draft("te", [])); + await settle(); + expect(h.session.directMessages.people).toHaveBeenCalled(); + expect(starts).toHaveBeenCalled(); + starts.mockClear(); + view.rerender(draft("tea", [])); + await settle(); + view.rerender(draft("team", [])); + await settle(); + expect(starts).not.toHaveBeenCalled(); + // A different selection is a different naming fact. + view.rerender(draft("team", [{ pubkey: "f".repeat(64), name: "Picked" }])); + expect(starts).toHaveBeenCalled(); + view.unmount(); + h.library.dispose(); + vi.useRealTimers(); +}); diff --git a/src/bundled/mentions/use-mention-choices.ts b/src/bundled/mentions/use-mention-choices.ts index 85da28a23e..9590f8b9bc 100644 --- a/src/bundled/mentions/use-mention-choices.ts +++ b/src/bundled/mentions/use-mention-choices.ts @@ -108,7 +108,13 @@ export function useMentionChoices( people, community?.joinable(), ); - const sources = [selected, people, session.names?.snapshot()]; + // Drafts recreate their recipient array on every edit; its names and keys + // are the naming fact, not its identity or positions. + const sources = [ + JSON.stringify(selected.map(({ pubkey, name }) => [pubkey, name])), + people, + session.names?.snapshot(), + ]; const hit = labeled.get(candidates); if (hit?.sources.every((source, i) => source === sources[i])) return hit.choices; diff --git a/src/bundled/mentions/useMentionDirectory.ts b/src/bundled/mentions/useMentionDirectory.ts index 8139a96411..76d24db243 100644 --- a/src/bundled/mentions/useMentionDirectory.ts +++ b/src/bundled/mentions/useMentionDirectory.ts @@ -148,7 +148,8 @@ export function useMentionDirectory( return useMemo( () => ({ /** The current query's page, or the last settled page while it loads. */ - people: (active && shown?.people) || empty, + // An empty page shares one array, so it changes no cached candidates. + people: active && shown?.people.length ? shown.people : empty, loading: active && !hit && !error, error, more: !!hit?.more, From 75172f1140f9807a049933278393efe31cbbd8e0 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 28 Oct 2026 11:50:52 -0400 Subject: [PATCH 3/6] Restore real timers after each team mention test Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Logan Johnson --- src/bundled/mentions/team-mentions.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bundled/mentions/team-mentions.test.tsx b/src/bundled/mentions/team-mentions.test.tsx index d417298fef..438318d8e3 100644 --- a/src/bundled/mentions/team-mentions.test.tsx +++ b/src/bundled/mentions/team-mentions.test.tsx @@ -32,6 +32,7 @@ beforeEach(() => { }); afterEach(() => { cleanup(); + vi.useRealTimers(); vi.unstubAllGlobals(); Reflect.deleteProperty(HTMLElement.prototype, "scrollIntoView"); vi.restoreAllMocks(); @@ -574,5 +575,4 @@ it("an @ keystroke reuses the name index across draft and team rerenders", async expect(starts).toHaveBeenCalled(); view.unmount(); h.library.dispose(); - vi.useRealTimers(); }); From eb88f672953b960449d795ba0d3089ac94b7b655 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 28 Oct 2026 12:19:49 -0400 Subject: [PATCH 4/6] Rebuild mention choices only for directory people that change them Most directory hits in a large channel are members a profile already names; they change no row, alias, key or label. Pass only the other people, interned by content, so a page of members rebuilds nothing and each settled page or carried merge with the same outsiders reuses one build instead of rebuilding over every member up to three times. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Logan Johnson --- .../mentions/mention-candidates.test.ts | 74 +++++++++++++++++++ src/bundled/mentions/mention-candidates.ts | 23 ++++++ src/bundled/mentions/team-mentions.test.tsx | 58 +++++++++++++++ src/bundled/mentions/use-mention-choices.ts | 57 +++++++++++--- 4 files changed, 200 insertions(+), 12 deletions(-) create mode 100644 src/bundled/mentions/mention-candidates.test.ts diff --git a/src/bundled/mentions/mention-candidates.test.ts b/src/bundled/mentions/mention-candidates.test.ts new file mode 100644 index 0000000000..b7b94237ff --- /dev/null +++ b/src/bundled/mentions/mention-candidates.test.ts @@ -0,0 +1,74 @@ +import { expect, it } from "vitest"; +import { directoryChanges, mentionCandidates } from "./mention-candidates"; +import type { ComposerSession } from "../../features/messages/composer-session"; + +const key = (n: number) => n.toString(16).padStart(64, "0"); +const members = [1, 2, 3, 4, 5].map(key); +// 1: person; 2: unnamed; 3: agent member; 4: person; 5: archived. +const profiles = new Map([ + [key(1), { name: "Ada" }], + [key(3), { name: "Bot", isAgent: true }], + [key(4), { name: "Cy" }], + [key(5), { name: "Gone" }], +]); +const agents = [ + { pubkey: key(3), name: "Bot" }, + { pubkey: key(6), name: "Helper", managed: true }, +]; +const elsewhere = [ + { pubkey: key(7), name: "Far", relay: "wss://other.example" }, +]; + +// A new session each time, so no cached rows answer. +function rows(directory?: Parameters[4]) { + const session = { + viewer: key(1), + channels: { + list: () => ({ + status: "ready", + channels: [{ id: "c", name: "C", channelType: "stream", members }], + }), + }, + agentChoices: { snapshot: () => ({ identities: agents }) }, + profiles: { snapshot: () => profiles }, + archives: { + snapshot: () => ({}), + state: (pubkey: string) => + pubkey === key(5) ? "archived" : "not-archived", + }, + outbox: { supports: () => true }, + } as unknown as ComposerSession; + return [ + ...mentionCandidates(session, "c", false, undefined, directory, elsewhere), + ].sort((a, b) => a.recipient.pubkey.localeCompare(b.recipient.pubkey)); +} + +it("directory people that change nothing can be left out", () => { + const directory = [ + { pubkey: key(1), name: "Ada Lovelace" }, // named member: no change + { pubkey: key(3), name: "Bot", isAgent: true as const }, // agent member: no change + { pubkey: key(2), name: "Bea" }, // unnamed member: names its row + { pubkey: key(4), name: "Cy", isAgent: true as const }, // marks a member an agent + { pubkey: key(5), name: "Gone" }, // archived member + { pubkey: key(6), name: "Helper Two" }, // renames an outside agent + { pubkey: key(7), name: "Near" }, // takes another community's agent row + { pubkey: key(8), name: "Dee" }, // outsider + ]; + const local = new Map(rows().map((row) => [row.recipient.pubkey, row])); + const changes = directoryChanges( + local, + directory, + profiles, + (pubkey) => !!profiles.get(pubkey)?.name, + ); + expect(changes.map((person) => person.pubkey)).toEqual( + [2, 4, 5, 6, 7, 8].map(key), + ); + expect(rows(changes)).toEqual(rows(directory)); + // Each other kept person does change the rows. The archived member has no + // row to compare, so it is kept, which is safe. + for (const person of changes.filter((p) => p.pubkey !== key(5))) + expect(rows(changes.filter((other) => other !== person))).not.toEqual( + rows(changes), + ); +}); diff --git a/src/bundled/mentions/mention-candidates.ts b/src/bundled/mentions/mention-candidates.ts index 6793f73df1..fc890c80d4 100644 --- a/src/bundled/mentions/mention-candidates.ts +++ b/src/bundled/mentions/mention-candidates.ts @@ -109,6 +109,29 @@ export function mentionCandidates( return rows; } +/** + * The directory people that change `mentionCandidates` or its labels, given + * its rows without a directory. A member a profile names gets that name, its + * aliases and, once naming knows the key, its label from the profile; the + * directory only adds its agent flag. Passing just these gives the same rows. + */ +export function directoryChanges( + local: ReadonlyMap, + directory: readonly T[], + profiles: ReadonlyMap, + named: (pubkey: string) => boolean, +) { + return directory.filter((person) => { + const row = local.get(person.pubkey); + return !( + row?.member && + profiles.get(person.pubkey)?.name && + named(person.pubkey) && + (!person.isAgent || row.agent) + ); + }); +} + function buildCandidates( session: ComposerSession, channelId: string, diff --git a/src/bundled/mentions/team-mentions.test.tsx b/src/bundled/mentions/team-mentions.test.tsx index 438318d8e3..1e6ccb32ce 100644 --- a/src/bundled/mentions/team-mentions.test.tsx +++ b/src/bundled/mentions/team-mentions.test.tsx @@ -576,3 +576,61 @@ it("an @ keystroke reuses the name index across draft and team rerenders", async view.unmount(); h.library.dispose(); }); + +it("directory pages rebuild the name index only for people they add", async () => { + const h = fixture({ count: 300 }); + const profiles = h.session.profiles.snapshot(); + // Naming knows every profile, as the real service does. + Object.assign(h.session, { + names: { + subscribe: () => () => {}, + snapshot: () => 0, + scope: () => (key: string) => { + const name = profiles.get(key)?.name; + return name ? { name, source: "public-profile" } : undefined; + }, + }, + }); + const members = h.people.slice(1, 4); + const outsider = { pubkey: "e".repeat(64), name: "Teal Outsider" }; + let page = members; + // Every settled search returns a new array. + vi.mocked(h.session.directMessages.people).mockImplementation(async () => ({ + people: [...page], + hasMore: false, + })); + const starts = vi.spyOn(personMatch, "nameStarts"); + function Chooser({ query }: { query: string }) { + const model = useMentionChoices(h.session, "parent", false, query, "open"); + useTeamMentions(h.session, "parent", false, query, model); + return null; + } + vi.useFakeTimers(); + const settle = () => + act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + // Inline completion remounts on every keystroke. + const type = async (query: string) => { + view.rerender(); + await settle(); + }; + const view = render(); + await settle(); + starts.mockClear(); + // Pages of members change no row. + await type("pe"); + await type("per"); + expect(h.session.directMessages.people).toHaveBeenCalledTimes(3); + expect(starts).not.toHaveBeenCalled(); + // An outsider is a new choice set: one rebuild, then reused while it stays. + page = [...members, outsider]; + await type("pers"); + expect(starts).toHaveBeenCalledTimes(301); + starts.mockClear(); + await type("perso"); + await type("person"); + expect(starts).not.toHaveBeenCalled(); + view.unmount(); + h.library.dispose(); +}); diff --git a/src/bundled/mentions/use-mention-choices.ts b/src/bundled/mentions/use-mention-choices.ts index 9590f8b9bc..ae5f915747 100644 --- a/src/bundled/mentions/use-mention-choices.ts +++ b/src/bundled/mentions/use-mention-choices.ts @@ -15,7 +15,7 @@ import { DraftMentionRoster } from "../../features/messages/draft-mention-roster import { SelectedMentionContext } from "../../features/messages/selected-mention-context"; import { archivedMention } from "../../features/messages/mention-admission"; import { mentionHistory } from "../../features/messages/mention-history"; -import { mentionCandidates } from "./mention-candidates"; +import { directoryChanges, mentionCandidates } from "./mention-candidates"; import { exactMention, matchingMentions, @@ -31,17 +31,31 @@ const labeled = new WeakMap< { sources: readonly unknown[]; choices: readonly MentionChoice[] } >(); const keySets = new WeakMap>(); -const keyMaps = new WeakMap< - readonly MentionChoice[], - ReadonlyMap ->(); -const byKey = (choices: readonly MentionChoice[]) => { - const cached = keyMaps.get(choices); +const keyMaps = new WeakMap>(); +const byKey = ( + choices: readonly T[], +) => { + const cached = keyMaps.get(choices) as ReadonlyMap | undefined; if (cached) return cached; const map = new Map(choices.map((c) => [c.recipient.pubkey, c])); keyMaps.set(choices, map); return map; }; +// Directory people that change the choice set, interned by content: each +// settled page and each carried merge is a new array with the same people. +const pages = new WeakMap>(); +function page(session: ComposerSession, people: readonly Person[]) { + const key = JSON.stringify( + people.map(({ pubkey, name, isAgent }) => [pubkey, name, !!isAgent]), + ); + const seen = pages.get(session) ?? new Map(); + pages.set(session, seen); + const kept = seen.get(key) ?? people; + seen.delete(key); + seen.set(key, kept); + if (seen.size > 8) seen.delete(seen.keys().next().value ?? ""); + return kept; +} /** * One installed key list per opening/query. Evidence stays live, order does not. * Local rows install first; directory rows only append after them. @@ -100,19 +114,39 @@ export function useMentionChoices( ]; }, [directory.people, carried]); const current = useCallback(() => { - const candidates = mentionCandidates( + const elsewhere = community?.joinable(); + const local = mentionCandidates( session, channelId, invite, roster, + undefined, + elsewhere, + ); + // Only directory people that change the choice set; a page of members rebuilds nothing. + const known = session.names?.scope(); + const changed = directoryChanges( + byKey(local), people, - community?.joinable(), + session.profiles.snapshot(), + (key) => !!known?.(key), ); + const outside = changed.length ? page(session, changed) : none; + const candidates = outside.length + ? mentionCandidates( + session, + channelId, + invite, + roster, + outside, + elsewhere, + ) + : local; // Drafts recreate their recipient array on every edit; its names and keys // are the naming fact, not its identity or positions. const sources = [ JSON.stringify(selected.map(({ pubkey, name }) => [pubkey, name])), - people, + outside, session.names?.snapshot(), ]; const hit = labeled.get(candidates); @@ -126,9 +160,8 @@ export function useMentionChoices( ]; // Directory pages are menu-local, not cached profiles. Name them only when no // other source knows the key, so namesakes in this choice set are qualified. - const known = session.names?.scope(); const facts = [ - ...people + ...outside .filter((person) => !known?.(person.pubkey)) .map(({ pubkey, name, isAgent }) => ({ pubkey, From e7313ea00f5a60666142f4ade620d1a2ee6fa2f8 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 28 Oct 2026 12:25:54 -0400 Subject: [PATCH 5/6] Intern directory changes regardless of their order Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Logan Johnson --- src/bundled/mentions/use-mention-choices.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/bundled/mentions/use-mention-choices.ts b/src/bundled/mentions/use-mention-choices.ts index ae5f915747..e12392ae9b 100644 --- a/src/bundled/mentions/use-mention-choices.ts +++ b/src/bundled/mentions/use-mention-choices.ts @@ -43,10 +43,14 @@ const byKey = ( }; // Directory people that change the choice set, interned by content: each // settled page and each carried merge is a new array with the same people. +// Their order changes no choice (ranking and naming sort by key), so it is +// not part of the content. const pages = new WeakMap>(); function page(session: ComposerSession, people: readonly Person[]) { const key = JSON.stringify( - people.map(({ pubkey, name, isAgent }) => [pubkey, name, !!isAgent]), + [...people] + .sort((a, b) => (a.pubkey < b.pubkey ? -1 : a.pubkey > b.pubkey ? 1 : 0)) + .map(({ pubkey, name, isAgent }) => [pubkey, name, !!isAgent]), ); const seen = pages.get(session) ?? new Map(); pages.set(session, seen); From e089a86e59fc3564e423b5978c1433d22745f403 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 28 Oct 2026 13:08:41 -0400 Subject: [PATCH 6/6] Bound the mention name index for long profile names Profile names are member input of any length. Each index entry copied the rest of the name at every word start, so a many-word name grew the index with the square of its length. Cut entries to 32 code points and keep at most 16 per name; a name with more is matched directly by ranking. Also fail a test when candidate building reads a session store that its cache does not compare. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Logan Johnson --- .../mentions/mention-candidates.test.ts | 37 +++++++++++++++--- src/bundled/mentions/mention-candidates.ts | 2 + src/bundled/mentions/mention-ranking.test.ts | 38 ++++++++++++++++++- src/bundled/mentions/mention-ranking.ts | 27 ++++++++----- src/features/search/person-match.ts | 29 ++++++++++---- 5 files changed, 111 insertions(+), 22 deletions(-) diff --git a/src/bundled/mentions/mention-candidates.test.ts b/src/bundled/mentions/mention-candidates.test.ts index b7b94237ff..f6ea94c11f 100644 --- a/src/bundled/mentions/mention-candidates.test.ts +++ b/src/bundled/mentions/mention-candidates.test.ts @@ -20,8 +20,16 @@ const elsewhere = [ ]; // A new session each time, so no cached rows answer. -function rows(directory?: Parameters[4]) { - const session = { +function rows( + directory?: Parameters[4], + session = fresh(), +) { + return [ + ...mentionCandidates(session, "c", false, undefined, directory, elsewhere), + ].sort((a, b) => a.recipient.pubkey.localeCompare(b.recipient.pubkey)); +} +function fresh() { + return { viewer: key(1), channels: { list: () => ({ @@ -38,11 +46,30 @@ function rows(directory?: Parameters[4]) { }, outbox: { supports: () => true }, } as unknown as ComposerSession; - return [ - ...mentionCandidates(session, "c", false, undefined, directory, elsewhere), - ].sort((a, b) => a.recipient.pubkey.localeCompare(b.recipient.pubkey)); } +it("reads only the session stores its cache compares", () => { + // mentionCandidates caches rows on these stores' snapshots (and the viewer, + // fixed per session). A new read here, including in the host's admission + // rules, must join that cache key, or rows go stale. + const read = new Set(); + const session = new Proxy(fresh(), { + get(target, key, receiver) { + read.add(key); + return Reflect.get(target, key, receiver); + }, + }); + rows([{ pubkey: key(8), name: "Dee" }], session); + expect([...read].sort()).toEqual([ + "agentChoices", + "archives", + "channels", + "outbox", + "profiles", + "viewer", + ]); +}); + it("directory people that change nothing can be left out", () => { const directory = [ { pubkey: key(1), name: "Ada Lovelace" }, // named member: no change diff --git a/src/bundled/mentions/mention-candidates.ts b/src/bundled/mentions/mention-candidates.ts index fc890c80d4..81a29da8ec 100644 --- a/src/bundled/mentions/mention-candidates.ts +++ b/src/bundled/mentions/mention-candidates.ts @@ -69,6 +69,8 @@ export function mentionCandidates( /** The owner's agents in their other communities (`agents.joinable()`). */ elsewhere: readonly ElsewhereAgent[] = none, ): readonly MentionCandidate[] { + // Every session store that buildCandidates and its admission rules read; a + // test fails when they read one more. The viewer is fixed per session. const sources = [ // Only this channel's row; activity elsewhere replaces the list, not it. session.channels.list().channels.find((c) => c.id === channelId), diff --git a/src/bundled/mentions/mention-ranking.test.ts b/src/bundled/mentions/mention-ranking.test.ts index 302b8c646a..992cf820fa 100644 --- a/src/bundled/mentions/mention-ranking.test.ts +++ b/src/bundled/mentions/mention-ranking.test.ts @@ -7,7 +7,7 @@ import { type MentionChoice, } from "./mention-ranking"; import { fixtureChoice, mentionConformance } from "./mention-rules.conformance"; -import { matchPerson } from "../../features/search/person-match"; +import { matchPerson, nameStarts } from "../../features/search/person-match"; const choice = ( key: string, name: string, @@ -192,3 +192,39 @@ it("returns index matches in list order and skips names that cannot match", () = expect(matchingMentions(rows, "jane d")).toEqual([rows[2]]); expect(matchingMentions(rows, "")).toBe(rows); }); + +it("bounds index work for long profile names and still matches them", () => { + // Profile names are member input of any length. + const words = Array.from({ length: 4000 }, (_, i) => `w${i}`).join(" "); + const long = `${"x".repeat(4000)}-tail`; + const starts = nameStarts(words); + expect(starts.complete).toBe(false); + expect(starts.starts).toHaveLength(16); + for (const name of [words, long]) + for (const start of nameStarts(name).starts) + expect([...start].length).toBeLessThanOrEqual(32); + const rows = [ + choice("1", words), + choice("2", long), + choice("3", "Wanda"), + choice("4", "Xavier"), + ]; + // Late words and queries longer than a key still rank as without the index. + for (const query of [ + "w3999", + "w39", + "tail", + "w", + "x".repeat(40), + `${"x".repeat(4000)}-t`, + "wanda", + ]) { + const found = matchingMentions(rows, query); + expect(rankMentions(found, query), query).toEqual( + rankMentions(rows, query), + ); + expect(exactMention(found, query), query).toBe(exactMention(rows, query)); + } + expect(matchingMentions(rows, "w3999")).toContain(rows[0]); + expect(matchingMentions(rows, "tail")).toContain(rows[1]); +}); diff --git a/src/bundled/mentions/mention-ranking.ts b/src/bundled/mentions/mention-ranking.ts index 94a2ac8b06..1be5d180bd 100644 --- a/src/bundled/mentions/mention-ranking.ts +++ b/src/bundled/mentions/mention-ranking.ts @@ -3,6 +3,7 @@ import { matchPerson, nameStarts, normalizeName, + startKey, } from "../../features/search/person-match"; import type { mentionCandidates } from "./mention-candidates"; export type MentionChoice = ReturnType[number] & { @@ -29,10 +30,11 @@ export function mentionMatch(choice: MentionChoice, query: string) { if (Number.isFinite(visible)) return visible; return 4 + baseMatch(choice, query); } -// One sorted index of name starts per immutable choice list. +// One sorted index of name starts per immutable choice list. Choices with a +// name too long to index are always kept for ranking to match directly. const indexes = new WeakMap< readonly MentionChoice[], - { starts: string[]; owners: number[] } + { starts: string[]; owners: number[]; unindexed: number[] } >(); /** * The choices, in their given order, with a label or alias that the query can @@ -44,19 +46,26 @@ export function matchingMentions( choices: readonly MentionChoice[], query: string, ): readonly MentionChoice[] { - const needle = normalized(query); + const needle = startKey(normalized(query)); if (!needle) return choices; let index = indexes.get(choices); if (!index) { - const entries = choices.flatMap((choice, owner) => - [...new Set([choice.label, ...choice.aliases])].flatMap((name) => - nameStarts(name).map((start) => ({ start, owner })), - ), - ); + const entries: { start: string; owner: number }[] = []; + const unindexed: number[] = []; + choices.forEach((choice, owner) => { + const names = [...new Set([choice.label, ...choice.aliases])].map( + nameStarts, + ); + if (names.some((name) => !name.complete)) unindexed.push(owner); + else + for (const name of names) + for (const start of name.starts) entries.push({ start, owner }); + }); entries.sort((a, b) => compare(a.start, b.start)); index = { starts: entries.map((entry) => entry.start), owners: entries.map((entry) => entry.owner), + unindexed, }; indexes.set(choices, index); } @@ -67,7 +76,7 @@ export function matchingMentions( if ((index.starts[middle] ?? "") < needle) low = middle + 1; else high = middle; } - const found = new Set(); + const found = new Set(index.unindexed); for (let i = low; index.starts[i]?.startsWith(needle); i += 1) found.add(index.owners[i] ?? 0); return [...found] diff --git a/src/features/search/person-match.ts b/src/features/search/person-match.ts index d62248846e..73d22600ad 100644 --- a/src/features/search/person-match.ts +++ b/src/features/search/person-match.ts @@ -74,10 +74,17 @@ export function exactName( return exact.length === 1 ? exact[0] : undefined; } +// Bounds on one name's index entries. Names come from member profiles, so a +// many-word name must not grow the index with the square of its length. +const startLimit = 16; +const startLength = 32; + /** - * Every folded suffix of `name` where `matchPerson` can begin a match: the - * trimmed name itself and each later word start. A query matches only as a - * prefix of one of these, so a sorted list of them finds matches by search. + * Where `matchPerson` can begin a match in `name`: the folded, trimmed name + * itself and each later word start, each cut to its first 32 code points. A + * query matches only where one of these starts with the query's first 32 code + * points, so a sorted list of them finds matches by search. A name with more + * than 16 starts is not `complete`: callers must match it directly. */ export function nameStarts(name: string) { const chars = [...name].flatMap((char) => [...foldPoint(char)]); @@ -85,13 +92,21 @@ export function nameStarts(name: string) { let end = chars.length; while (start < end && space.test(chars[start] ?? "")) start += 1; while (end > start && space.test(chars[end - 1] ?? "")) end -= 1; - const starts = [chars.slice(start, end).join("")]; + const key = (from: number) => + chars.slice(from, Math.min(end, from + startLength)).join(""); + const starts = [key(start)]; for (let from = start + 1; from < end; from += 1) - if (isWord(chars[from]) && !isWord(chars[from - 1])) - starts.push(chars.slice(from, end).join("")); - return starts; + if (isWord(chars[from]) && !isWord(chars[from - 1])) { + if (starts.length === startLimit) return { starts, complete: false }; + starts.push(key(from)); + } + return { starts, complete: true }; } +/** A query cut to the length of `nameStarts` entries. */ +export const startKey = (needle: string) => + [...needle].slice(0, startLength).join(""); + export type PersonMatch = { /** 0 equal, 1 name starts with the query, 2 a word equals it, 3 a word starts with it. */ tier: 0 | 1 | 2 | 3;