From 5e3104f8d2f4a7568cb43c7fd8db9b13c295e9e1 Mon Sep 17 00:00:00 2001 From: Kalvin Chau Date: Sat, 31 Oct 2026 12:09:11 -0700 Subject: [PATCH 1/7] perf(identity-names): re-render a names consumer only when a label it resolved changes useidentitynames re-rendered every consumer on any profile, agent-library or provider change. each hook now records the labels it resolved, keyed by the full resolve arguments, re-resolves them on a names change and re-renders only on a difference. past 500 recorded labels it falls back to re-rendering on every change. the mention chooser and composer chips read names scopes directly, so they follow the names revision themselves while open or holding recipients. Co-authored-by: peon <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Kalvin Chau --- src/bundled/mentions/team-mentions.test.tsx | 30 ++++ src/bundled/mentions/use-mention-choices.ts | 12 +- .../identity-names/consumers.test.tsx | 8 +- src/features/identity-names/react.test.tsx | 137 ++++++++++++++++++ src/features/identity-names/react.ts | 69 +++++++-- src/features/messages/RichComposerInput.tsx | 11 +- 6 files changed, 249 insertions(+), 18 deletions(-) create mode 100644 src/features/identity-names/react.test.tsx diff --git a/src/bundled/mentions/team-mentions.test.tsx b/src/bundled/mentions/team-mentions.test.tsx index 620caf21a6..9676db5dd7 100644 --- a/src/bundled/mentions/team-mentions.test.tsx +++ b/src/bundled/mentions/team-mentions.test.tsx @@ -675,3 +675,33 @@ it("directory pages rebuild the name index only for people they add", async () = view.unmount(); h.library.dispose(); }); + +it("an open chooser relabels choices when only names change", async () => { + const h = fixture(); + let name = "Before"; + let revision = 0; + const listeners = new Set<() => void>(); + Object.assign(h.session, { + names: { + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + snapshot: () => revision, + scope: () => () => ({ name, source: "agent-directory" }), + }, + }); + function Chooser() { + const model = useMentionChoices(h.session, "parent", false, "", "open"); + return <>{model.candidates.map((choice) => choice.label).join(",")}; + } + render(); + await waitFor(() => expect(screen.getByText("Before")).toBeInTheDocument()); + act(() => { + name = "After"; + revision++; + for (const listener of listeners) listener(); + }); + expect(screen.getByText("After")).toBeInTheDocument(); + h.library.dispose(); +}); diff --git a/src/bundled/mentions/use-mention-choices.ts b/src/bundled/mentions/use-mention-choices.ts index 90725ea8be..0d6a7d4ce7 100644 --- a/src/bundled/mentions/use-mention-choices.ts +++ b/src/bundled/mentions/use-mention-choices.ts @@ -12,7 +12,6 @@ import type { MentionRecipient } from "../../features/messages/mention-draft"; import type { Agent } from "../../features/agents/service"; import { useAgentChoices } from "../../features/agents/use-choices"; import { AgentsContext, useAgentsSnapshot } from "../../features/agents/react"; -import { useIdentityNames } from "../../features/identity-names/react"; import { DraftMentionRoster } from "../../features/messages/draft-mention-roster"; import { SelectedMentionContext } from "../../features/messages/selected-mention-context"; import { archivedMention } from "../../features/messages/mention-admission"; @@ -27,6 +26,7 @@ import { } from "./mention-ranking"; type Person = ReturnType["people"][number]; const none: readonly Person[] = []; +const noSubscribe = () => () => {}; // Derived once per cached candidate list (see mentionCandidates), so a // keystroke reuses labels and keys instead of renaming every member. const labeled = new WeakMap< @@ -223,7 +223,11 @@ export function useMentionChoices( lifetime, ); const archives = useMentionArchives(session, open); - const resolve = useIdentityNames(session.names); + // Labels come from names scopes directly: watch every name change while open. + const names = useSyncExternalStore( + (open && session.names?.subscribe) || noSubscribe, + () => session.names?.snapshot() ?? 0, + ); const [installed, install] = useState<{ session: ComposerSession; key: string; @@ -278,7 +282,7 @@ export function useMentionChoices( void channel; void agents; void archives; - void resolve; + void names; void revision; // A closed or disabled chooser shows nothing, so it names nothing. if (!open) @@ -296,7 +300,7 @@ export function useMentionChoices( channel, agents, archives, - resolve, + names, revision, ]); // Install membership first; agent discovery must not hide known members. diff --git a/src/features/identity-names/consumers.test.tsx b/src/features/identity-names/consumers.test.tsx index 3a6859284b..0af84c50a7 100644 --- a/src/features/identity-names/consumers.test.tsx +++ b/src/features/identity-names/consumers.test.tsx @@ -4,7 +4,7 @@ stubAvatarBrowserApis(); import "@testing-library/jest-dom/vitest"; import { act, cleanup, render, screen } from "@testing-library/react"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; -import { createRef } from "react"; +import { createRef, useSyncExternalStore } from "react"; import { Context } from "@deepseek-ai/cordis"; import userEvent from "@testing-library/user-event"; import { PluginRuntime } from "../../plugins/runtime"; @@ -385,8 +385,12 @@ it.each(["live", "reconnect", "clear-cache"] as const)( it("uses channel scope in link previews and activity, and participant scope in sidebar DMs", async () => { const f = fixture(); function Sidebar() { + const list = useSyncExternalStore( + f.session.channels.subscribeList, + f.session.channels.list, + ); const labels = useChannelLabels( - f.session.channels.list().channels, + list.channels, f.session.profiles, f.session.names, ); diff --git a/src/features/identity-names/react.test.tsx b/src/features/identity-names/react.test.tsx new file mode 100644 index 0000000000..0ac5b15273 --- /dev/null +++ b/src/features/identity-names/react.test.tsx @@ -0,0 +1,137 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { act, cleanup, render, screen } from "@testing-library/react"; +import { afterEach, expect, it } from "vitest"; +import { npubEncode } from "nostr-tools/nip19"; +import { useIdentityNames } from "./react"; +import { bindNames, type NameSource } from "./service"; +import { createAgentDirectory } from "./testing"; + +afterEach(cleanup); + +const [a, b, c] = ["a", "b", "c"].map((key) => key.repeat(64)) as [ + string, + string, + string, +]; +const suffix = (key: string) => npubEncode(key).slice(-4); + +function fixture() { + let profiles = new Map([[a, { name: "Alex" }]]); + const library = { + status: "ready", + definitions: [], + identities: [], + } as const; + const listeners = new Set<() => void>(); + const source: NameSource = { + profiles: { + snapshot: () => profiles, + subscribe: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + ensure: async () => {}, + }, + agentLibrary: { + snapshot: () => library, + subscribe: () => () => {}, + refresh: async () => {}, + retain: () => () => {}, + }, + }; + const names = bindNames(source, { + snapshot: () => [createAgentDirectory()], + subscribe: () => () => {}, + }); + const set = (pubkey: string, name: string) => { + profiles = new Map([...profiles, [pubkey, { name }]]); + for (const listener of listeners) listener(); + }; + return { + names, + set, + publish: (pubkey: string, name: string) => act(() => set(pubkey, name)), + }; +} + +it("re-renders a names consumer only when a label it resolved changes", () => { + const { names, publish } = fixture(); + let renders = 0; + function Author() { + renders++; + const resolve = useIdentityNames(names); + return {resolve(a, "Member")}; + } + render(); + expect(screen.getByText("Alex")).toBeInTheDocument(); + const settled = renders; + // Another identity's profile leaves this author's label alone. + publish(c, "Casey"); + expect(renders).toBe(settled); + // A namesake changes it. + publish(b, "Alex"); + expect(screen.getByText(`Alex · ${suffix(a)}`)).toBeInTheDocument(); + publish(a, "Ana"); + expect(screen.getByText("Ana")).toBeInTheDocument(); + expect(renders).toBe(settled + 2); + names.dispose(); +}); + +it("watches each candidate scope a key was resolved in", () => { + const { names, publish } = fixture(); + const wide = [a, c]; + const narrow = [a]; + function Author() { + const resolve = useIdentityNames(names); + return ( + <> + wide {resolve(a, "Member", wide)} + narrow {resolve(a, "Member", narrow)} + + ); + } + render(); + publish(c, "Alex"); + expect(screen.getByText(`wide Alex · ${suffix(a)}`)).toBeInTheDocument(); + expect(screen.getByText("narrow Alex")).toBeInTheDocument(); + names.dispose(); +}); + +it("shows a name change made between render and subscribe", () => { + const { names, set } = fixture(); + function Author() { + const resolve = useIdentityNames(names); + return {resolve(a, "Member")}; + } + function Renamer() { + set(a, "Ana"); + return null; + } + render( + <> + + + , + ); + expect(screen.getByText("Ana")).toBeInTheDocument(); + names.dispose(); +}); + +it("re-renders on every name change once it resolves too many labels to watch", () => { + const { names, publish } = fixture(); + const scopes = Array.from({ length: 501 }, () => [a]); + let renders = 0; + function Author() { + renders++; + const resolve = useIdentityNames(names); + return ( + {scopes.map((scope) => resolve(a, "Member", scope)).at(-1)} + ); + } + render(); + const settled = renders; + publish(c, "Casey"); + expect(renders).toBe(settled + 1); + names.dispose(); +}); diff --git a/src/features/identity-names/react.ts b/src/features/identity-names/react.ts index 2a602f9705..57bd7aa40a 100644 --- a/src/features/identity-names/react.ts +++ b/src/features/identity-names/react.ts @@ -1,17 +1,64 @@ import type { NamingIdentity } from "./policy"; -import { useSyncExternalStore, useCallback } from "react"; +import { useSyncExternalStore, useCallback, useMemo } from "react"; import { useListedChannel } from "../relay/listed-channel"; import type { RelaySession } from "../relay/session"; import type { IdentityNameView } from "./service"; -const noop = () => () => {}; const zero = () => 0; +const watched = 500; /** The interface arrives through the captured session, never a global lookup. */ export function useIdentityNames(names: IdentityNameView | undefined) { - const revision = useSyncExternalStore( - names?.subscribe ?? noop, - names?.snapshot ?? zero, - zero, - ); + // Re-render only when a label this component resolved changes: one arriving + // profile should not repaint every name on screen. + const store = useMemo(() => { + type Args = Parameters; + const shown = new Map(); + const ids = new WeakMap(); + let next = 0; + const id = (value: object | undefined) => { + if (!value) return 0; + if (!ids.has(value)) ids.set(value, ++next); + return ids.get(value); + }; + let revision = 0; + let overflow = false; + let seen = names?.snapshot(); + // A changed label, or too many to watch, re-renders and starts over. + const refresh = () => { + if ( + !overflow && + [...shown.values()].every( + ({ args, label }) => names?.resolve(...args) === label, + ) + ) + return false; + shown.clear(); + overflow = false; + revision++; + return true; + }; + return { + show(args: Args, label: string | undefined) { + if (!names) return; + const [pubkey, fallback, candidates, displayFacts] = args; + seen = names?.snapshot(); + const key = `${pubkey}\n${fallback}\n${id(candidates)}\n${id(displayFacts)}`; + if (shown.has(key) || shown.size < watched) + shown.set(key, { args, label }); + else overflow = true; + }, + subscribe(listener: () => void) { + if (!names) return () => {}; + const stop = names.subscribe(() => { + if (refresh()) listener(); + }); + // A change between render and subscribe. + if (names.snapshot() !== seen && refresh()) listener(); + return stop; + }, + snapshot: () => revision, + }; + }, [names]); + const revision = useSyncExternalStore(store.subscribe, store.snapshot, zero); return useCallback( ( pubkey: string, @@ -21,11 +68,11 @@ export function useIdentityNames(names: IdentityNameView | undefined) { ): string => { // Invalidate memoized labels and completion choices on name changes. void revision; - return ( - names?.resolve(pubkey, fallback, candidates, displayFacts) ?? fallback - ); + const label = names?.resolve(pubkey, fallback, candidates, displayFacts); + store.show([pubkey, fallback, candidates, displayFacts], label); + return label ?? fallback; }, - [names, revision], + [names, store, revision], ); } diff --git a/src/features/messages/RichComposerInput.tsx b/src/features/messages/RichComposerInput.tsx index 628ccc7e87..1fb89ee2fd 100644 --- a/src/features/messages/RichComposerInput.tsx +++ b/src/features/messages/RichComposerInput.tsx @@ -3,7 +3,7 @@ import { useMentionArchives } from "./use-mention-archives"; import { useContext, type ReactNode } from "react"; import { DraftMentionRoster } from "./draft-mention-roster"; import { archivedMention } from "./mention-admission"; -import { useLayoutEffect, useMemo, useRef } from "react"; +import { useLayoutEffect, useMemo, useRef, useSyncExternalStore } from "react"; import { useIdentityNames } from "../identity-names/react"; import { useListedChannel } from "../relay/listed-channel"; import { InlineChip } from "../../shared/design-system/ui/InlineChip"; @@ -23,6 +23,8 @@ import { messageLinkParts } from "./message-link-parts"; import { messageReferences } from "./message-references"; import { EditableInput, type EditableInputProps } from "./EditableInput"; +const noSubscribe = () => () => {}; + /** Decorations never change source text or infer notification recipients from names. */ export function RichComposerInput({ draft, @@ -55,8 +57,14 @@ export function RichComposerInput({ const recipientKey = JSON.stringify( draft.recipients.map(({ pubkey, name }) => [pubkey, name]), ); + // Chip facts come from names.lookup, not only resolved labels. + const names = useSyncExternalStore( + (recipientKey !== "[]" && session.names?.subscribe) || noSubscribe, + () => session.names?.snapshot() ?? 0, + ); const { profiles, qualifiers, labels } = useMemo(() => { void archives; + void names; const recipients = JSON.parse(recipientKey) as [string, string][]; const profiles = new Map(directory.profiles); for (const [pubkey, name] of recipients) @@ -102,6 +110,7 @@ export function RichComposerInput({ roster, members, archives, + names, resolveName, ]); const previous = useRef(labels); From bad68d4d879f6c8dc0177ac9ebb4ef5f1e85cfac Mon Sep 17 00:00:00 2001 From: Kalvin Chau Date: Sat, 31 Oct 2026 15:20:39 -0700 Subject: [PATCH 2/7] perf(identity-names): compare watched labels in getsnapshot each consumer's snapshot re-resolves its recorded labels only when the names revision moved, and the record restarts on the first render at a new version, so a change between two labels of one render is no longer hidden. callers that read names.lookup or names.scope share usenamesrevision. adds tests for a mid-render change and strictmode. Co-authored-by: peon <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Kalvin Chau --- src/bundled/mentions/use-mention-choices.ts | 7 +- src/features/identity-names/react.test.tsx | 40 +++++++ src/features/identity-names/react.ts | 125 +++++++++++--------- src/features/messages/RichComposerInput.tsx | 11 +- 4 files changed, 115 insertions(+), 68 deletions(-) diff --git a/src/bundled/mentions/use-mention-choices.ts b/src/bundled/mentions/use-mention-choices.ts index 0d6a7d4ce7..823c74e542 100644 --- a/src/bundled/mentions/use-mention-choices.ts +++ b/src/bundled/mentions/use-mention-choices.ts @@ -18,6 +18,7 @@ import { archivedMention } from "../../features/messages/mention-admission"; import { mentionHistory } from "../../features/messages/mention-history"; import { directoryChanges, mentionCandidates } from "./mention-candidates"; import { useListedChannel } from "../../features/relay/listed-channel"; +import { useNamesRevision } from "../../features/identity-names/react"; import { exactMention, matchingMentions, @@ -26,7 +27,6 @@ import { } from "./mention-ranking"; type Person = ReturnType["people"][number]; const none: readonly Person[] = []; -const noSubscribe = () => () => {}; // Derived once per cached candidate list (see mentionCandidates), so a // keystroke reuses labels and keys instead of renaming every member. const labeled = new WeakMap< @@ -224,10 +224,7 @@ export function useMentionChoices( ); const archives = useMentionArchives(session, open); // Labels come from names scopes directly: watch every name change while open. - const names = useSyncExternalStore( - (open && session.names?.subscribe) || noSubscribe, - () => session.names?.snapshot() ?? 0, - ); + const names = useNamesRevision(session.names, open); const [installed, install] = useState<{ session: ComposerSession; key: string; diff --git a/src/features/identity-names/react.test.tsx b/src/features/identity-names/react.test.tsx index 0ac5b15273..49d7351985 100644 --- a/src/features/identity-names/react.test.tsx +++ b/src/features/identity-names/react.test.tsx @@ -1,6 +1,7 @@ // @vitest-environment jsdom import "@testing-library/jest-dom/vitest"; import { act, cleanup, render, screen } from "@testing-library/react"; +import { StrictMode } from "react"; import { afterEach, expect, it } from "vitest"; import { npubEncode } from "nostr-tools/nip19"; import { useIdentityNames } from "./react"; @@ -118,6 +119,45 @@ it("shows a name change made between render and subscribe", () => { names.dispose(); }); +it("shows a name change made between two of its own labels", () => { + const { names, set } = fixture(); + let renamed = false; + function Author() { + const resolve = useIdentityNames(names); + const first = resolve(a, "Member"); + if (!renamed) { + renamed = true; + set(a, "Ana"); + } + return ( + + {first} {resolve(c, "Member")} + + ); + } + render(); + expect(screen.getByText("Ana Member")).toBeInTheDocument(); + names.dispose(); +}); + +it("follows label changes under StrictMode double rendering", () => { + const { names, publish } = fixture(); + function Author() { + const resolve = useIdentityNames(names); + return {resolve(a, "Member")}; + } + render( + + + , + ); + publish(b, "Alex"); + expect(screen.getByText(`Alex · ${suffix(a)}`)).toBeInTheDocument(); + publish(a, "Ana"); + expect(screen.getByText("Ana")).toBeInTheDocument(); + names.dispose(); +}); + it("re-renders on every name change once it resolves too many labels to watch", () => { const { names, publish } = fixture(); const scopes = Array.from({ length: 501 }, () => [a]); diff --git a/src/features/identity-names/react.ts b/src/features/identity-names/react.ts index 57bd7aa40a..e6eab7b423 100644 --- a/src/features/identity-names/react.ts +++ b/src/features/identity-names/react.ts @@ -3,62 +3,19 @@ import { useSyncExternalStore, useCallback, useMemo } from "react"; import { useListedChannel } from "../relay/listed-channel"; import type { RelaySession } from "../relay/session"; import type { IdentityNameView } from "./service"; -const zero = () => 0; +const noSubscribe = () => () => {}; const watched = 500; -/** The interface arrives through the captured session, never a global lookup. */ +/** The interface arrives through the captured session, never a global lookup. + * Pass stable `candidates` and `displayFacts` arrays: each new one is watched separately. */ export function useIdentityNames(names: IdentityNameView | undefined) { // Re-render only when a label this component resolved changes: one arriving // profile should not repaint every name on screen. - const store = useMemo(() => { - type Args = Parameters; - const shown = new Map(); - const ids = new WeakMap(); - let next = 0; - const id = (value: object | undefined) => { - if (!value) return 0; - if (!ids.has(value)) ids.set(value, ++next); - return ids.get(value); - }; - let revision = 0; - let overflow = false; - let seen = names?.snapshot(); - // A changed label, or too many to watch, re-renders and starts over. - const refresh = () => { - if ( - !overflow && - [...shown.values()].every( - ({ args, label }) => names?.resolve(...args) === label, - ) - ) - return false; - shown.clear(); - overflow = false; - revision++; - return true; - }; - return { - show(args: Args, label: string | undefined) { - if (!names) return; - const [pubkey, fallback, candidates, displayFacts] = args; - seen = names?.snapshot(); - const key = `${pubkey}\n${fallback}\n${id(candidates)}\n${id(displayFacts)}`; - if (shown.has(key) || shown.size < watched) - shown.set(key, { args, label }); - else overflow = true; - }, - subscribe(listener: () => void) { - if (!names) return () => {}; - const stop = names.subscribe(() => { - if (refresh()) listener(); - }); - // A change between render and subscribe. - if (names.snapshot() !== seen && refresh()) listener(); - return stop; - }, - snapshot: () => revision, - }; - }, [names]); - const revision = useSyncExternalStore(store.subscribe, store.snapshot, zero); + const labels = useMemo(() => watchLabels(names), [names]); + const version = useSyncExternalStore( + names?.subscribe ?? noSubscribe, + labels.version, + labels.version, + ); return useCallback( ( pubkey: string, @@ -67,15 +24,73 @@ export function useIdentityNames(names: IdentityNameView | undefined) { displayFacts?: readonly NamingIdentity[], ): string => { // Invalidate memoized labels and completion choices on name changes. - void revision; + void version; const label = names?.resolve(pubkey, fallback, candidates, displayFacts); - store.show([pubkey, fallback, candidates, displayFacts], label); + labels.show(version, [pubkey, fallback, candidates, displayFacts], label); return label ?? fallback; }, - [names, store, revision], + [names, labels, version], + ); +} + +/** For callers that read `names.lookup` or `names.scope` directly, which + * `useIdentityNames` does not watch: re-render on every name change while active. */ +export function useNamesRevision( + names: IdentityNameView | undefined, + active: boolean, +) { + return useSyncExternalStore( + (active && names?.subscribe) || noSubscribe, + () => names?.snapshot() ?? 0, ); } +/** The labels one consumer rendered at its current version. The version moves + * when a name change would alter one of them, or there were too many to watch. */ +function watchLabels(names: IdentityNameView | undefined) { + type Args = Parameters; + const shown = new Map(); + const ids = new WeakMap(); + let next = 0; + const id = (value: object | undefined) => { + if (!value) return 0; + if (!ids.has(value)) ids.set(value, ++next); + return ids.get(value); + }; + let overflow = false; + let version = 0; + let recorded = 0; + let seen = names?.snapshot(); + return { + show(rendered: number, args: Args, label: string | undefined) { + // The first label rendered at a new version starts a new record. + if (rendered !== recorded) { + shown.clear(); + overflow = false; + recorded = rendered; + } + const [pubkey, fallback, candidates, displayFacts] = args; + const key = `${pubkey}\n${fallback}\n${id(candidates)}\n${id(displayFacts)}`; + if (!names || shown.has(key)) return; + if (shown.size < watched) shown.set(key, { args, label }); + else overflow = true; + }, + version() { + const revision = names?.snapshot(); + if (revision === seen) return version; + seen = revision; + if ( + overflow || + [...shown.values()].some( + ({ args, label }) => names?.resolve(...args) !== label, + ) + ) + version++; + return version; + }, + }; +} + const noMembers: readonly string[] = []; /** Channel membership defines ambiguity, not the community-wide profile cache. */ export function useChannelIdentityNames( diff --git a/src/features/messages/RichComposerInput.tsx b/src/features/messages/RichComposerInput.tsx index 1fb89ee2fd..7c50e1ed47 100644 --- a/src/features/messages/RichComposerInput.tsx +++ b/src/features/messages/RichComposerInput.tsx @@ -3,8 +3,8 @@ import { useMentionArchives } from "./use-mention-archives"; import { useContext, type ReactNode } from "react"; import { DraftMentionRoster } from "./draft-mention-roster"; import { archivedMention } from "./mention-admission"; -import { useLayoutEffect, useMemo, useRef, useSyncExternalStore } from "react"; -import { useIdentityNames } from "../identity-names/react"; +import { useLayoutEffect, useMemo, useRef } from "react"; +import { useIdentityNames, useNamesRevision } from "../identity-names/react"; import { useListedChannel } from "../relay/listed-channel"; import { InlineChip } from "../../shared/design-system/ui/InlineChip"; import type { ConversationExtensions } from "../conversation/contracts"; @@ -23,8 +23,6 @@ import { messageLinkParts } from "./message-link-parts"; import { messageReferences } from "./message-references"; import { EditableInput, type EditableInputProps } from "./EditableInput"; -const noSubscribe = () => () => {}; - /** Decorations never change source text or infer notification recipients from names. */ export function RichComposerInput({ draft, @@ -58,10 +56,7 @@ export function RichComposerInput({ draft.recipients.map(({ pubkey, name }) => [pubkey, name]), ); // Chip facts come from names.lookup, not only resolved labels. - const names = useSyncExternalStore( - (recipientKey !== "[]" && session.names?.subscribe) || noSubscribe, - () => session.names?.snapshot() ?? 0, - ); + const names = useNamesRevision(session.names, recipientKey !== "[]"); const { profiles, qualifiers, labels } = useMemo(() => { void archives; void names; From 35e7043cd1aeda83505cc1bbc2569928c91db4ab Mon Sep 17 00:00:00 2001 From: Kalvin Chau Date: Sat, 31 Oct 2026 15:26:50 -0700 Subject: [PATCH 3/7] perf(identity-names): resolve composer chips via lookup composer chip labels come from names.lookup in the memo that already follows the names revision, so fresh candidate arrays no longer grow a watched-label record on every names emit. a resolve callback kept from an older render adds to the current record instead of restarting it. Co-authored-by: peon <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Kalvin Chau --- src/features/identity-names/react.test.tsx | 17 +++++++++++++++++ src/features/identity-names/react.ts | 5 +++-- src/features/messages/RichComposerInput.tsx | 8 +++----- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/features/identity-names/react.test.tsx b/src/features/identity-names/react.test.tsx index 49d7351985..7f2725dd99 100644 --- a/src/features/identity-names/react.test.tsx +++ b/src/features/identity-names/react.test.tsx @@ -158,6 +158,23 @@ it("follows label changes under StrictMode double rendering", () => { names.dispose(); }); +it("keeps watching after an older resolve callback runs", () => { + const { names, publish } = fixture(); + const callbacks: ((pubkey: string, fallback: string) => string)[] = []; + function Author() { + const resolve = useIdentityNames(names); + callbacks.push(resolve); + return {resolve(a, "Member")}; + } + render(); + publish(b, "Alex"); + expect(screen.getByText(`Alex · ${suffix(a)}`)).toBeInTheDocument(); + callbacks[0]?.(c, "Member"); + publish(a, "Ana"); + expect(screen.getByText("Ana")).toBeInTheDocument(); + names.dispose(); +}); + it("re-renders on every name change once it resolves too many labels to watch", () => { const { names, publish } = fixture(); const scopes = Array.from({ length: 501 }, () => [a]); diff --git a/src/features/identity-names/react.ts b/src/features/identity-names/react.ts index e6eab7b423..b980a540e3 100644 --- a/src/features/identity-names/react.ts +++ b/src/features/identity-names/react.ts @@ -63,8 +63,9 @@ function watchLabels(names: IdentityNameView | undefined) { let seen = names?.snapshot(); return { show(rendered: number, args: Args, label: string | undefined) { - // The first label rendered at a new version starts a new record. - if (rendered !== recorded) { + // The first label rendered at a newer version starts a new record; a + // callback kept from an older render adds to the current one. + if (rendered > recorded) { shown.clear(); overflow = false; recorded = rendered; diff --git a/src/features/messages/RichComposerInput.tsx b/src/features/messages/RichComposerInput.tsx index 7c50e1ed47..042974ec61 100644 --- a/src/features/messages/RichComposerInput.tsx +++ b/src/features/messages/RichComposerInput.tsx @@ -4,7 +4,7 @@ import { useContext, type ReactNode } from "react"; import { DraftMentionRoster } from "./draft-mention-roster"; import { archivedMention } from "./mention-admission"; import { useLayoutEffect, useMemo, useRef } from "react"; -import { useIdentityNames, useNamesRevision } from "../identity-names/react"; +import { useNamesRevision } from "../identity-names/react"; import { useListedChannel } from "../relay/listed-channel"; import { InlineChip } from "../../shared/design-system/ui/InlineChip"; import type { ConversationExtensions } from "../conversation/contracts"; @@ -49,13 +49,12 @@ export function RichComposerInput({ ); const archives = useMentionArchives(session); const roster = useContext(DraftMentionRoster)?.recipients; - const resolveName = useIdentityNames(session.names); // 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]), ); - // Chip facts come from names.lookup, not only resolved labels. + // Chip labels and facts come from names.lookup. const names = useNamesRevision(session.names, recipientKey !== "[]"); const { profiles, qualifiers, labels } = useMemo(() => { void archives; @@ -94,7 +93,7 @@ export function RichComposerInput({ labels: new Map( recipients.map(([pubkey, name]) => [ pubkey, - resolveName(pubkey, name, candidates, displayFacts), + session.names?.lookup(pubkey, candidates, displayFacts)?.name ?? name, ]), ), }; @@ -106,7 +105,6 @@ export function RichComposerInput({ members, archives, names, - resolveName, ]); const previous = useRef(labels); const revealed = useRef( From 93f548df1d9312c2662a554396f7f733351b9fc1 Mon Sep 17 00:00:00 2001 From: Kalvin Chau Date: Sat, 31 Oct 2026 15:34:40 -0700 Subject: [PATCH 4/7] perf(identity-names): pass stable candidate arrays to name resolvers notes, solo share, todos and search built candidate arrays on every render. watched labels are keyed by array identity, so each render grew the record toward its cap. memoize the built arrays and share one empty member list. Co-authored-by: peon <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Kalvin Chau --- src/app/shell/SearchResults.tsx | 3 ++- src/bundled/channels/useSoloShare.ts | 10 +++++++--- src/bundled/relay-notes/NotesPage.tsx | 5 ++++- src/bundled/todos/TodosPanel.tsx | 3 ++- 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/app/shell/SearchResults.tsx b/src/app/shell/SearchResults.tsx index a72e85d23a..9dd6bd778f 100644 --- a/src/app/shell/SearchResults.tsx +++ b/src/app/shell/SearchResults.tsx @@ -84,6 +84,7 @@ function conversationName( /** The most rows the People and Agents groups each show. */ const PERSON_ROWS = 6; +const noMembers: readonly string[] = []; export function SearchResults({ session, @@ -949,7 +950,7 @@ export function SearchResults({ displayId, profiles.get(displayId)?.name ?? displayId.slice(0, 10), list.channels.find((channel) => channel.id === message.channelId) - ?.members ?? [], + ?.members ?? noMembers, ); return { key: message.id, diff --git a/src/bundled/channels/useSoloShare.ts b/src/bundled/channels/useSoloShare.ts index 1cd9d47264..e31ab14102 100644 --- a/src/bundled/channels/useSoloShare.ts +++ b/src/bundled/channels/useSoloShare.ts @@ -1,5 +1,6 @@ import { useEffect, + useMemo, useRef, useState, useSyncExternalStore, @@ -199,9 +200,12 @@ export function useSoloShare({ .catch(() => {}); }, [session, destinationProfileKeys]); const resolveName = useIdentityNames(session.names); - const displayKeys = [ - ...new Set([...(source?.members ?? []), ...(destinationMembers ?? [])]), - ]; + const displayKeys = useMemo( + () => [ + ...new Set([...(source?.members ?? []), ...(destinationMembers ?? [])]), + ], + [source?.members, destinationMembers], + ); const labels = new Map( displayKeys.map((key) => [ key, diff --git a/src/bundled/relay-notes/NotesPage.tsx b/src/bundled/relay-notes/NotesPage.tsx index 7b035dd438..c49c1c0d96 100644 --- a/src/bundled/relay-notes/NotesPage.tsx +++ b/src/bundled/relay-notes/NotesPage.tsx @@ -222,7 +222,10 @@ export function NotesCommunity({ }: { session: RelaySession } & NotesNavigation) { const choices = useAgentChoices(session); const resolveName = useIdentityNames(session.names); - const candidates = choices.identities.map((agent) => agent.pubkey); + const candidates = useMemo( + () => choices.identities.map((agent) => agent.pubkey), + [choices.identities], + ); const agents = choices.identities.map((agent) => ({ pubkey: agent.pubkey, picture: agent.avatar, diff --git a/src/bundled/todos/TodosPanel.tsx b/src/bundled/todos/TodosPanel.tsx index ae23dc180b..564967dcdf 100644 --- a/src/bundled/todos/TodosPanel.tsx +++ b/src/bundled/todos/TodosPanel.tsx @@ -80,6 +80,7 @@ type Draft = { base: string | null; input: string; }; +const noMembers: readonly string[] = []; const empty: Draft = { content: "", original: "", base: null, input: "" }; const statuses: [Status, string][] = [ ["todo", "To do"], @@ -145,7 +146,7 @@ export function TodosPanel({ (channel) => channel.id === context.channelId, )?.members; const resolveName = (pubkey: string, fallback: string) => - resolveIdentity(pubkey, fallback, members ?? []); + resolveIdentity(pubkey, fallback, members ?? noMembers); const key = `todos-draft-v1:${context.channelId}`; const [saved] = useState(() => readDraft(context.scope, key)); const savedDirty = !!saved && saved.content !== saved.original; From 511a9185f87e9b7c2125da53568d0510efbbad64 Mon Sep 17 00:00:00 2001 From: Kalvin Chau Date: Sat, 31 Oct 2026 15:42:25 -0700 Subject: [PATCH 5/7] perf(search): resolve people labels through one names scope the people block rebuilt its candidates and display facts every render, so each keystroke grew the watched-label record. it now resolves those labels through names.scope, normalized once per render, and follows the names revision while people results are shown. Co-authored-by: peon <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Kalvin Chau --- src/app/shell/SearchResults.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/app/shell/SearchResults.tsx b/src/app/shell/SearchResults.tsx index 9dd6bd778f..aab62654af 100644 --- a/src/app/shell/SearchResults.tsx +++ b/src/app/shell/SearchResults.tsx @@ -1,5 +1,8 @@ import { workflowLabel } from "../../features/relay/workflow-attribution"; -import { useIdentityNames } from "../../features/identity-names/react"; +import { + useIdentityNames, + useNamesRevision, +} from "../../features/identity-names/react"; import { npubEncode } from "nostr-tools/nip19"; import { useEffect, @@ -322,6 +325,8 @@ export function SearchResults({ peopleActive, ); useMentionArchives(session, showAuthorPicker || peopleActive); + // People labels come from one names scope built each render. + useNamesRevision(session.names, peopleActive); // The viewer's own agents open a new chat, not a direct message: the // same agents the new-chat composer accepts. Their earlier chats // still show as conversations. @@ -589,13 +594,14 @@ export function SearchResults({ name: person.name, ...(person.isAgent ? { isAgent: true } : {}), })); + const named = session.names?.scope(candidates, displayFacts); for (const { pubkey, source, agent } of eligible) { const dm = relationships.dm(pubkey); const agentName = messageableAgents.get(pubkey)?.name ?? sessionAgents.get(pubkey)?.name; people.push({ pubkey, - label: resolveName(pubkey, source.name, candidates, displayFacts), + label: named?.(pubkey)?.name ?? source.name, username: source.username, agentName: agentName !== source.name ? agentName : undefined, agent, From a4e09fddea27f49727cefdc4941528991f2818e0 Mon Sep 17 00:00:00 2001 From: Kalvin Chau Date: Sat, 31 Oct 2026 15:53:27 -0700 Subject: [PATCH 6/7] perf(identity-names): watch one scope per shown label key the watched-label record by pubkey and fallback. a label shown again under a new candidate or display-fact list, as after a member joins, re-renders on the next names change and starts a new record, instead of keeping and re-resolving every earlier list. drops the array identity ids. Co-authored-by: peon <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Kalvin Chau --- src/features/identity-names/react.test.tsx | 24 ++++++++++++++++++++-- src/features/identity-names/react.ts | 23 ++++++++++----------- 2 files changed, 33 insertions(+), 14 deletions(-) diff --git a/src/features/identity-names/react.test.tsx b/src/features/identity-names/react.test.tsx index 7f2725dd99..66fbbcfdaf 100644 --- a/src/features/identity-names/react.test.tsx +++ b/src/features/identity-names/react.test.tsx @@ -177,13 +177,13 @@ it("keeps watching after an older resolve callback runs", () => { it("re-renders on every name change once it resolves too many labels to watch", () => { const { names, publish } = fixture(); - const scopes = Array.from({ length: 501 }, () => [a]); + const fallbacks = Array.from({ length: 501 }, (_, i) => `Member ${i}`); let renders = 0; function Author() { renders++; const resolve = useIdentityNames(names); return ( - {scopes.map((scope) => resolve(a, "Member", scope)).at(-1)} + {fallbacks.map((fallback) => resolve(a, fallback)).at(-1)} ); } render(); @@ -192,3 +192,23 @@ it("re-renders on every name change once it resolves too many labels to watch", expect(renders).toBe(settled + 1); names.dispose(); }); + +it("re-renders once and starts over when a label's candidate list changes", () => { + const { names, publish } = fixture(); + let renders = 0; + function Author({ members }: { members: readonly string[] }) { + renders++; + const resolve = useIdentityNames(names); + return {resolve(a, "Member", members)}; + } + const view = render(); + view.rerender(); + const settled = renders; + publish(c, "Casey"); + expect(renders).toBe(settled + 1); + publish(b, "Blair"); + expect(renders).toBe(settled + 1); + publish(c, "Alex"); + expect(screen.getByText(`Alex · ${suffix(a)}`)).toBeInTheDocument(); + names.dispose(); +}); diff --git a/src/features/identity-names/react.ts b/src/features/identity-names/react.ts index b980a540e3..e8cbef8001 100644 --- a/src/features/identity-names/react.ts +++ b/src/features/identity-names/react.ts @@ -6,7 +6,7 @@ import type { IdentityNameView } from "./service"; const noSubscribe = () => () => {}; const watched = 500; /** The interface arrives through the captured session, never a global lookup. - * Pass stable `candidates` and `displayFacts` arrays: each new one is watched separately. */ + * Pass stable `candidates` and `displayFacts` arrays: a new one re-renders on the next name change. */ export function useIdentityNames(names: IdentityNameView | undefined) { // Re-render only when a label this component resolved changes: one arriving // profile should not repaint every name on screen. @@ -46,17 +46,10 @@ export function useNamesRevision( } /** The labels one consumer rendered at its current version. The version moves - * when a name change would alter one of them, or there were too many to watch. */ + * when a name change would alter one of them, or they could not all be watched. */ function watchLabels(names: IdentityNameView | undefined) { type Args = Parameters; const shown = new Map(); - const ids = new WeakMap(); - let next = 0; - const id = (value: object | undefined) => { - if (!value) return 0; - if (!ids.has(value)) ids.set(value, ++next); - return ids.get(value); - }; let overflow = false; let version = 0; let recorded = 0; @@ -70,10 +63,16 @@ function watchLabels(names: IdentityNameView | undefined) { overflow = false; recorded = rendered; } + if (!names || overflow) return; const [pubkey, fallback, candidates, displayFacts] = args; - const key = `${pubkey}\n${fallback}\n${id(candidates)}\n${id(displayFacts)}`; - if (!names || shown.has(key)) return; - if (shown.size < watched) shown.set(key, { args, label }); + const key = `${pubkey}\n${fallback}`; + const entry = shown.get(key); + // One name in two scopes, such as after a member joins, would grow the + // record with every new list: re-render on the next change instead. + if (entry) + overflow ||= + entry.args[2] !== candidates || entry.args[3] !== displayFacts; + else if (shown.size < watched) shown.set(key, { args, label }); else overflow = true; }, version() { From 7f61de62584bdb9231f142de770e8350c777c737 Mon Sep 17 00:00:00 2001 From: Kalvin Chau Date: Sun, 1 Nov 2026 11:42:10 -0800 Subject: [PATCH 7/7] perf(solo-share): keep destination members stable across renders the deduplicated destination roster was rebuilt every render, so the display-key memo and the names record saw a new candidate list each time. memoize it on the channel's member list. Co-authored-by: peon <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Kalvin Chau --- src/bundled/channels/useSoloShare.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/bundled/channels/useSoloShare.ts b/src/bundled/channels/useSoloShare.ts index e31ab14102..61ecadabf2 100644 --- a/src/bundled/channels/useSoloShare.ts +++ b/src/bundled/channels/useSoloShare.ts @@ -95,14 +95,17 @@ export function useSoloShare({ const chosen = list.channels.find( (item) => item.id === destination && item.id !== channelId, ); - const destinationMembers = + const readyMembers = chosen && destinationRoster?.id === chosen.id && destinationRoster.status === "ready" && - !chosen.cached && - chosen.members - ? [...new Set(chosen.members)] + !chosen.cached + ? chosen.members : undefined; + const destinationMembers = useMemo( + () => (readyMembers ? [...new Set(readyMembers)] : undefined), + [readyMembers], + ); useEffect(() => { void rosterReload; if (!enabled) return;