From fd7bfb5b964fe644546af81bcc700c80b3616a64 Mon Sep 17 00:00:00 2001 From: peon <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@buzz.block.builderlab.xyz> Date: Wed, 30 Sep 2026 14:02:04 -0700 Subject: [PATCH] perf(messages): stop re-rendering every row on each channel-list publish The store replaces the channel list on every message in any channel, and every mounted message row subscribed to the whole list. Rows, message management items and channel identity names now subscribe to the one roster fact they read through useListedChannel, and the reference directory keeps its channel array while the fields a reference reads are unchanged. The rich composer input subscribes to its own channel instead of relying on the directory subscription for membership changes. Also on the per-message path: unread evidence carries its channel and reply root instead of re-deriving them per selector, thread activity is folded only when there is unread thread activity, and the history byte budget sizes each retained event once instead of re-serializing the whole window. Co-authored-by: peon <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@buzz.block.builderlab.xyz> Signed-off-by: peon <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@buzz.block.builderlab.xyz> --- src/features/identity-names/react.ts | 13 ++--- src/features/messages/MessageManagement.tsx | 20 ++++--- src/features/messages/MessageRow.tsx | 52 +++++++++--------- src/features/messages/ReferenceText.tsx | 51 +++++++++++++++--- src/features/messages/RichComposerInput.tsx | 3 ++ src/features/relay/budget.test.ts | 17 ++++++ src/features/relay/budget.ts | 17 ++++++ src/features/relay/listed-channel.test.tsx | 60 +++++++++++++++++++++ src/features/relay/listed-channel.ts | 32 +++++++++++ src/features/relay/store.ts | 21 ++++---- src/features/relay/unread.ts | 41 ++++++++------ 11 files changed, 250 insertions(+), 77 deletions(-) create mode 100644 src/features/relay/budget.test.ts create mode 100644 src/features/relay/listed-channel.test.tsx create mode 100644 src/features/relay/listed-channel.ts diff --git a/src/features/identity-names/react.ts b/src/features/identity-names/react.ts index b09626e24..0f2a56fe9 100644 --- a/src/features/identity-names/react.ts +++ b/src/features/identity-names/react.ts @@ -1,5 +1,6 @@ import type { NamingIdentity } from "./policy"; import { useSyncExternalStore, useCallback } from "react"; +import { useListedChannel } from "../relay/listed-channel"; import type { RelaySession } from "../relay/session"; import type { IdentityNameView } from "./service"; const noop = () => () => {}; @@ -28,21 +29,17 @@ export function useIdentityNames(names: IdentityNameView | undefined) { ); } -const noChannels = () => undefined; const noMembers: readonly string[] = []; /** Channel membership defines ambiguity, not the community-wide profile cache. */ export function useChannelIdentityNames( session: RelaySession | undefined, channelId: string | undefined, ) { - const list = useSyncExternalStore( - session?.channels?.subscribeList ?? noop, - session?.channels?.list ?? noChannels, - noChannels, + const members = useListedChannel( + session?.channels, + channelId, + (channel) => channel?.members ?? noMembers, ); - const members = - list?.channels.find((channel) => channel.id === channelId)?.members ?? - noMembers; const resolve = useIdentityNames(session?.names); return useCallback( (pubkey: string, fallback: string) => resolve(pubkey, fallback, members), diff --git a/src/features/messages/MessageManagement.tsx b/src/features/messages/MessageManagement.tsx index f87630522..292ef8a8f 100644 --- a/src/features/messages/MessageManagement.tsx +++ b/src/features/messages/MessageManagement.tsx @@ -14,6 +14,7 @@ import { Button } from "../../shared/design-system/ui/Button"; import { AlertDialog } from "../../shared/design-system/ui/AlertDialog"; import { MenuItem, MenuIcon } from "../../shared/design-system/ui/Menu"; import type { ChannelMessage } from "../relay/contracts"; +import { useListedChannel } from "../relay/listed-channel"; import type { RelaySession } from "../relay/session"; import type { OutgoingEvent } from "../relay/outbox"; import { MessageEditScope, useMessageEditScope } from "./MessageEditScope"; @@ -172,9 +173,15 @@ export function MessageManagementItems({ const management = useContext(Management); const editor = useMessageEditScope(); const afterClose = useAfterMessageMenuClose(); - const channels = useSyncExternalStore( - session.channels.subscribeList, - session.channels.list, + const writable = useListedChannel( + session.channels, + row.channelId, + (channel) => !!channel && !channel.readOnly, + ); + const archived = useListedChannel( + session.channels, + row.channelId, + (channel) => !!channel?.archived, ); const target = { kind: "message" as const, @@ -192,17 +199,14 @@ export function MessageManagementItems({ (row.delivery && !["accepted", "seen"].includes(row.delivery)) ) return null; - const member = channels.channels.find( - (channel) => channel.id === row.channelId, - ); - if (!member || member.readOnly) return null; + if (!writable) return null; const busy = management.operations.some( (item) => ["sending", "accepted"].includes(item.delivery) && [5, 40003].includes(item.event.kind) && item.event.tags.some(([name, id]) => name === "e" && id === row.id), ); - const own = row.authorId === session.viewer && !member.archived; + const own = row.authorId === session.viewer && !archived; const canEdit = own && editor && lastEditableMessage(session, [row]); const canDelete = own && session.outbox?.supports(5); const attention = session.unread.attention(row.channelId, row.id); diff --git a/src/features/messages/MessageRow.tsx b/src/features/messages/MessageRow.tsx index a4e09ab86..c134027fb 100644 --- a/src/features/messages/MessageRow.tsx +++ b/src/features/messages/MessageRow.tsx @@ -16,6 +16,7 @@ import { type FocusEvent, type ReactNode, } from "react"; +import { useListedChannel } from "../relay/listed-channel"; import type { RelaySession } from "../relay/session"; import type { UnreadCapability } from "../relay/unread"; import { MediaAttachment, type MediaPlayback } from "./MediaAttachment"; @@ -45,13 +46,6 @@ import { ToastNotice } from "../../shared/design-system/ui/Toast"; import { ReportMessageDialog } from "./ReportMessageDialog"; import { messageCopyLink, messageCopyText } from "./message-copy"; -const emptySubscribe = () => () => {}; -const EMPTY_CHANNEL_LIST = Object.freeze({ - status: "unavailable" as const, - channels: Object.freeze([]), -}); -const emptyChannelList = () => EMPTY_CHANNEL_LIST; - export type MessageRowProps = { row: ChannelMessage; session?: RelaySession | undefined; @@ -166,13 +160,26 @@ export const MessageRow = memo(function MessageRow({ row.channelId, row.threadRootId ?? row.id, ); - const channelList = useSyncExternalStore( - session?.channels.subscribeList ?? emptySubscribe, - session?.channels.list ?? emptyChannelList, - session?.channels.list ?? emptyChannelList, + const channels = session?.channels; + const listed = useListedChannel( + channels, + row.channelId, + (channel) => !!channel, + ); + const cached = useListedChannel( + channels, + row.channelId, + (channel) => !!channel?.cached, + ); + const archived = useListedChannel( + channels, + row.channelId, + (channel) => !!channel?.archived, ); - const cached = channelList.channels.some( - (channel) => channel.id === row.channelId && channel.cached, + const readOnly = useListedChannel( + channels, + row.channelId, + (channel) => !!channel?.readOnly, ); const unreadLabel = threadUnread?.manual === "local-only" @@ -212,12 +219,9 @@ export const MessageRow = memo(function MessageRow({ scope && session.outbox?.supports(7) && session.outbox.supports(5) && - (!session.channels.get || - channelList.channels.some((channel) => channel.id === row.channelId)) && - !channelList.channels.find((channel) => channel.id === row.channelId) - ?.archived && - !channelList.channels.find((channel) => channel.id === row.channelId) - ?.readOnly + (!session.channels.get || listed) && + !archived && + !readOnly ); const rowRef = useRef(null); const menuTrigger = useRef(null); @@ -415,14 +419,8 @@ export const MessageRow = memo(function MessageRow({ !!( row.delivery && !["accepted", "seen"].includes(row.delivery) ) || - !!channelList.channels.find( - (channel) => channel.id === row.channelId, - )?.archived || - (!!session?.channels.get && - !channelList.channels.some( - (channel) => - channel.id === row.channelId && !channel.readOnly, - )) + archived || + (!!session?.channels.get && (!listed || readOnly)) } link={messageCopyLink(row, scope)} copyText={() => diff --git a/src/features/messages/ReferenceText.tsx b/src/features/messages/ReferenceText.tsx index d2026457c..288ddc26e 100644 --- a/src/features/messages/ReferenceText.tsx +++ b/src/features/messages/ReferenceText.tsx @@ -2,7 +2,7 @@ import { useChannelIdentityNames } from "../identity-names/react"; import { useSyncExternalStore, type ReactNode } from "react"; import { AtIcon, RobotIcon } from "../../shared/design-system/icons/index"; import type { RelaySession } from "../relay/session"; -import type { Profile, ChannelSummary } from "../relay/contracts"; +import type { ChannelList, ChannelSummary, Profile } from "../relay/contracts"; import { messageReferences } from "./message-references"; import { MessageLink } from "../conversation/MessageLink"; import type { ConversationExtensions } from "../conversation/contracts"; @@ -11,7 +11,12 @@ import type { AgentLibrary } from "../agents/library"; import styles from "../../shared/InlineReference.module.css"; const emptyProfiles: ReadonlyMap = new Map(); -const emptyChannels: readonly ChannelSummary[] = []; +/** The only channel fields a reference may read; the rest can be out of date. */ +export type ReferenceChannel = Pick< + ChannelSummary, + "id" | "name" | "channelType" | "private" | "archived" +>; +const emptyChannels: readonly ReferenceChannel[] = []; const emptyAgents: AgentLibrary["identities"] = []; export const emptyReferenceDirectory = { profiles: emptyProfiles, @@ -20,8 +25,37 @@ export const emptyReferenceDirectory = { }; const noop = () => () => {}; const profilesSnapshot = () => emptyProfiles; -const channelsSnapshot = () => undefined; +const channelsSnapshot = () => emptyChannels; const agentsSnapshot = () => undefined; +type ChannelLists = Pick; +const referenced = new WeakMap< + ChannelLists, + { list: ChannelList; channels: readonly ReferenceChannel[] } +>(); +/** Previews and activity replace the list and its summaries on every message. + * Retain the prior channels while every field a reference reads is unchanged, + * so mounted rows render only when a label could change. */ +function referenceChannels(queries: ChannelLists) { + const list = queries.list(); + const prior = referenced.get(queries); + if (prior?.list === list) return prior.channels; + const channels = + prior?.channels.length === list.channels.length && + list.channels.every((channel, index) => { + const old = prior.channels[index]; + return ( + old?.id === channel.id && + old.name === channel.name && + old.channelType === channel.channelType && + old.private === channel.private && + old.archived === channel.archived + ); + }) + ? prior.channels + : list.channels; + referenced.set(queries, { list, channels }); + return channels; +} export function useReferenceDirectory( session: RelaySession | undefined, @@ -34,9 +68,10 @@ export function useReferenceDirectory( : (session?.profiles?.snapshot ?? profilesSnapshot), profilesSnapshot, ); + const queries = session?.channels; const channels = useSyncExternalStore( - session?.channels?.subscribeList ?? noop, - session?.channels?.list ?? channelsSnapshot, + queries?.subscribeList ?? noop, + queries?.list ? () => referenceChannels(queries) : channelsSnapshot, channelsSnapshot, ); const agents = useSyncExternalStore( @@ -46,7 +81,7 @@ export function useReferenceDirectory( ); return { profiles, - channels: channels?.channels ?? emptyChannels, + channels, agents: agents?.identities ?? emptyAgents, }; } @@ -54,7 +89,7 @@ export function useReferenceDirectory( /** Buzz links carry no community, so they always name a channel in the receiving one. */ export function channelForLink( url: string, - channels: readonly ChannelSummary[], + channels: readonly ReferenceChannel[], ) { const parsed = parseBuzzLink(url); const target = parsed?.format === "legacy" ? parsed : undefined; @@ -63,7 +98,7 @@ export function channelForLink( export function channelLinkLabel( url: string, - channels: readonly ChannelSummary[], + channels: readonly ReferenceChannel[], ) { const channel = channelForLink(url, channels); const parsed = parseBuzzLink(url); diff --git a/src/features/messages/RichComposerInput.tsx b/src/features/messages/RichComposerInput.tsx index 7923332eb..de75507cd 100644 --- a/src/features/messages/RichComposerInput.tsx +++ b/src/features/messages/RichComposerInput.tsx @@ -5,6 +5,7 @@ import { mentionCandidates } from "./mention-candidates"; import { useAgentChoices } from "../agents/use-choices"; import { useLayoutEffect, useMemo, useRef } from "react"; import { useIdentityNames } from "../identity-names/react"; +import { useListedChannel } from "../relay/listed-channel"; import { InlineChip } from "../../shared/design-system/ui/InlineChip"; import type { ConversationExtensions } from "../conversation/contracts"; import type { CustomEmoji } from "../relay/emoji"; @@ -42,6 +43,8 @@ export function RichComposerInput({ inviteAgents?: boolean; }) { const directory = useReferenceDirectory(session); + // Candidates read this channel's roster and state during render. + useListedChannel(session.channels, channelId, (channel) => channel); useMentionArchives(session); const roster = useContext(DraftMentionRoster); const profiles = new Map(directory.profiles); diff --git a/src/features/relay/budget.test.ts b/src/features/relay/budget.test.ts new file mode 100644 index 000000000..0aaae7e1a --- /dev/null +++ b/src/features/relay/budget.test.ts @@ -0,0 +1,17 @@ +import { expect, it } from "vitest"; +import { byteSize, listByteSize } from "./budget"; + +it("sizes a list exactly as byteSize does, including after it changes", () => { + const items = [ + { id: "a", content: "plain", tags: [["h", "channel"]] }, + { id: "b", content: 'quote " and \\ slash', tags: [] }, + { id: "c", content: "héllo 🐝 \n", tags: [["e", "a"]] }, + ]; + for (const count of [0, 1, 2, 3, 2, 3]) + expect(listByteSize(items.slice(0, count))).toBe( + byteSize(items.slice(0, count)), + ); + expect(listByteSize([...items].reverse())).toBe( + byteSize([...items].reverse()), + ); +}); diff --git a/src/features/relay/budget.ts b/src/features/relay/budget.ts index be42a79f4..7631d39f4 100644 --- a/src/features/relay/budget.ts +++ b/src/features/relay/budget.ts @@ -4,6 +4,23 @@ export const OUTBOX_INPUT_MAX_BYTES = 32 * 1024; export const byteSize = (value: unknown) => new TextEncoder().encode(JSON.stringify(value)).byteLength; +const sizes = new WeakMap(); +/** `byteSize` of a list whose members never change, such as verified events. + * A live window checks its budget on every message; each member is serialized + * once instead of the whole window each time. */ +export function listByteSize(values: readonly object[]) { + let bytes = 2 + Math.max(0, values.length - 1); + for (const value of values) { + let size = sizes.get(value); + if (size === undefined) { + size = byteSize(value); + sizes.set(value, size); + } + bytes += size; + } + return bytes; +} + export class ByteLru { private items = new Map(); private bytes = 0; diff --git a/src/features/relay/listed-channel.test.tsx b/src/features/relay/listed-channel.test.tsx new file mode 100644 index 000000000..14be9bf44 --- /dev/null +++ b/src/features/relay/listed-channel.test.tsx @@ -0,0 +1,60 @@ +// @vitest-environment jsdom +import { act, cleanup, render } from "@testing-library/react"; +import { afterEach, expect, it } from "vitest"; +import type { ChannelList } from "./contracts"; +import { useListedChannel } from "./listed-channel"; + +afterEach(cleanup); + +it("renders only when the selected fact of its own channel changes", () => { + const members = ["a"]; + let list: ChannelList = { + status: "ready", + channels: [ + { id: "one", name: "One", members }, + { id: "two", name: "Two", members: [] }, + ], + }; + const listeners = new Set<() => void>(); + const queries = { + list: () => list, + subscribeList: (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; + const publish = (next: ChannelList) => + act(() => { + list = next; + for (const notify of listeners) notify(); + }); + const seen: unknown[] = []; + function Probe() { + const archived = useListedChannel(queries, "one", (c) => !!c?.archived); + const roster = useListedChannel(queries, "one", (c) => c?.members); + seen.push([archived, roster]); + return null; + } + render(); + expect(seen).toEqual([[false, members]]); + + // A message replaces the list and every summary; no selected fact changed. + publish({ + ...list, + channels: [ + { id: "one", name: "One", members, preview: "new" }, + { id: "two", name: "Two", members: [], archived: true }, + ], + }); + expect(seen).toHaveLength(1); + + publish({ + ...list, + channels: [{ id: "one", name: "One", members, archived: true }], + }); + expect(seen.at(-1)).toEqual([true, members]); + expect(seen).toHaveLength(2); + + publish({ ...list, channels: [] }); + expect(seen.at(-1)).toEqual([false, undefined]); +}); diff --git a/src/features/relay/listed-channel.ts b/src/features/relay/listed-channel.ts new file mode 100644 index 000000000..47826dc70 --- /dev/null +++ b/src/features/relay/listed-channel.ts @@ -0,0 +1,32 @@ +import { useSyncExternalStore } from "react"; +import type { ChannelList, ChannelQueries, ChannelSummary } from "./contracts"; + +const unlisted = () => () => {}; +const listed = new WeakMap>(); +/** One roster fact about one channel. Previews and activity replace the list and + * its summaries on every message, so `select` must return a primitive or a + * reference the store retains (such as `members`); the caller then renders only + * when that fact changes. */ +export function useListedChannel( + queries: Partial> | undefined, + channelId: string | undefined, + select: (channel: ChannelSummary | undefined) => T, +): T { + const snapshot = () => { + const list = queries?.list?.(); + if (!list || channelId === undefined) return select(undefined); + let index = listed.get(list); + if (!index) { + index = new Map(); + for (const channel of list.channels) + if (!index.has(channel.id)) index.set(channel.id, channel); + listed.set(list, index); + } + return select(index.get(channelId)); + }; + return useSyncExternalStore( + queries?.subscribeList ?? unlisted, + snapshot, + snapshot, + ); +} diff --git a/src/features/relay/store.ts b/src/features/relay/store.ts index 439aef90e..8e0ee727f 100644 --- a/src/features/relay/store.ts +++ b/src/features/relay/store.ts @@ -16,7 +16,7 @@ import type { RelayReader, ReadOptions, Priority } from "./reader"; import type { ProfileDirectory } from "./profile-directory"; import { parseWindow, windowFilter, type WindowCursor } from "./window"; import { readSessionWindow } from "./session-window"; -import { ByteLru, byteSize } from "./budget"; +import { ByteLru, byteSize, listByteSize } from "./budget"; import type { HeadPersistence, SavedHead } from "./persistence"; import { createMediaPreparation, saveData } from "./media"; import { relayDebug } from "./debug"; @@ -161,6 +161,9 @@ export function createChannelStore( events: readonly RelayEvent[]; preview?: string | undefined; }>(64, 4 * 1024 * 1024); + /** `byteSize` of a tail, without serializing its retained events again. */ + const tailBytes = (tail: NonNullable>) => + byteSize({ ...tail, events: [] }) - 2 + listByteSize(tail.events); let media = createMediaPreparation(); const controllers = new Set(); const accessVersions = new Map(); @@ -1730,7 +1733,8 @@ export function createChannelStore( ).values(), ]), ); - tails.set(channelId, { events: retained, preview }); + const tail = { events: retained, preview }; + tails.set(channelId, tail, tailBytes(tail)); } for (const state of windows.values()) { if (disposed || generation !== epoch) return; @@ -1769,7 +1773,7 @@ export function createChannelStore( let retained = [...state.events, ...incoming]; let limited = false; if ( - byteSize(retained) > maxHistoryBytes || + listByteSize(retained) > maxHistoryBytes || retained.filter( (event) => channelRowKind(event.kind) && !localIds.has(event.id), ).length > maxHistoryRows @@ -1792,7 +1796,7 @@ export function createChannelStore( (tag) => tag[0] === "e" && keep.has(tag[1] ?? ""), )), ); - while (retained.length && byteSize(retained) > maxHistoryBytes) + while (retained.length && listByteSize(retained) > maxHistoryBytes) retained.splice(0, Math.max(1, Math.ceil(retained.length / 4))); const retainedIds = new Set(retained.map((event) => event.id)); for (const id of state.traffic.keys()) @@ -1809,11 +1813,10 @@ export function createChannelStore( for (const channelId of changedChannels) { const tail = tails.peek(channelId); const state = windows.get(channelId); - if (tail && state) - tails.set(channelId, { - ...tail, - preview: messagePreview(state.snapshot.rows), - }); + if (tail && state) { + const next = { ...tail, preview: messagePreview(state.snapshot.rows) }; + tails.set(channelId, next, tailBytes(next)); + } } setList(list); } diff --git a/src/features/relay/unread.ts b/src/features/relay/unread.ts index b73e5f77c..3f481903b 100644 --- a/src/features/relay/unread.ts +++ b/src/features/relay/unread.ts @@ -107,9 +107,14 @@ export interface UnreadCapability { } const contentKind = (event: RelayEvent) => event.kind === 9 || event.kind === 40002 || event.kind === 40008; +const channelIds = new WeakMap(); +/** Verified events are frozen, and every evidence index asks for each one. */ const channelOf = (event: RelayEvent) => { + if (channelIds.has(event)) return channelIds.get(event); const tags = event.tags.filter(([name]) => name === "h"); - return tags.length === 1 ? tags[0]?.[1] : undefined; + const channelId = tags.length === 1 ? tags[0]?.[1] : undefined; + channelIds.set(event, channelId); + return channelId; }; const auxiliaryKind = (event: RelayEvent) => event.kind === 40003 || event.kind === 5 || event.kind === 9005; @@ -235,6 +240,7 @@ export function createUnread({ } type Evidence = { event: RelayEvent; + channelId: string; rootId: string | undefined; mentioned: boolean; }; @@ -263,6 +269,7 @@ export function createUnread({ const rows = byChannel.get(channel) ?? []; rows.push({ event, + channelId: channel, rootId: threadReference(event) ? rootId : undefined, mentioned: event.tags.some( ([name, value]) => name === "p" && value === viewer, @@ -275,19 +282,17 @@ export function createUnread({ indexEvidence(); return tombstones.has(event.id); } - function inTarget(event: RelayEvent, target: ReadTarget) { + /** Evidence of the target's channel: the index already resolved its channel + * and reply root, which every selector would otherwise derive per event. */ + function inTarget({ event, rootId }: Evidence, target: ReadTarget) { return ( - channelOf(event) === target.channelId && - (target.kind === "channel" || - (target.kind === "message" && target.messageId === event.id) || - (target.kind === "thread" && - !!threadReference(event) && - root(event) === target.rootId)) + target.kind === "channel" || + (target.kind === "message" && target.messageId === event.id) || + (target.kind === "thread" && rootId === target.rootId) ); } - function isUnread({ event, rootId }: Evidence, state: ReadState) { - const channelId = channelOf(event); - if (!channelId || event.pubkey === viewer) return false; + function isUnread({ event, channelId, rootId }: Evidence, state: ReadState) { + if (event.pubkey === viewer) return false; const frontier = effectiveFrontier( state, `msg:${event.id}`, @@ -422,7 +427,7 @@ export function createUnread({ (event.created_at === latest.created_at && event.id < latest.id)) ) latest = event; - if (!inTarget(entry.event, target) || !isUnread(entry, state)) continue; + if (!inTarget(entry, target) || !isUnread(entry, state)) continue; count++; if (priority(entry, dm)) attention++; } @@ -472,11 +477,8 @@ export function createUnread({ indexEvidence(); const state = reads.state(); const grouped = new Map(); - const presented = new Map( - foldMessages(channelId, "", [...events.values()], { - includeReplies: true, - }).map((message) => [message.id, message.content]), - ); + // Only unread thread activity is presented; most publishes have none. + let presented: Map | undefined; for (const evidence of byChannel.get(channelId) ?? []) { const { event, rootId, mentioned } = evidence; const broadcast = event.tags.some( @@ -489,6 +491,11 @@ export function createUnread({ ) continue; const current = grouped.get(rootId); + presented ??= new Map( + foldMessages(channelId, "", [...events.values()], { + includeReplies: true, + }).map((message) => [message.id, message.content]), + ); const preview = presented.get(event.id) ?? event.content; if (!current) { grouped.set(