From 697176103e3aa91fa2ff6eac28ab2d483a231c99 Mon Sep 17 00:00:00 2001
From: Larry
<627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Date: Fri, 25 Sep 2026 11:43:48 -0400
Subject: [PATCH 1/2] perf(sidebar): keep closed row menus behind the item memo
boundary
Since #91, every sidebar row has actions, so every row was wrapped in its
own ContextMenuRoot and MenuPopup outside the memoized ChannelSidebarItem.
Those wrappers got fresh action lists and callbacks on every sidebar
render, so a channel switch rerendered one Base UI menu root per row.
Move the row's menu root into ChannelSidebarItem and give popup content
only to the open row. Closed rows now keep equal props and skip the
render. With 4x CPU throttling, the median warm switch in
channel-opening.spec falls from 257ms to 164ms (24 samples each).
Signed-off-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
---
src/bundled/channels/ChannelSidebarItem.tsx | 41 +++++++++++-
.../channel-navigation/ChannelSidebar.tsx | 65 +++++++++----------
2 files changed, 67 insertions(+), 39 deletions(-)
diff --git a/src/bundled/channels/ChannelSidebarItem.tsx b/src/bundled/channels/ChannelSidebarItem.tsx
index 6145dd2f4..31a49c3ad 100644
--- a/src/bundled/channels/ChannelSidebarItem.tsx
+++ b/src/bundled/channels/ChannelSidebarItem.tsx
@@ -1,7 +1,11 @@
import { UserStatusDisplay } from "../../features/user-status/StatusDisplay";
-import { memo } from "react";
+import { memo, type ReactNode } from "react";
import { Avatar } from "../../shared/design-system/ui/Avatar";
-import { ContextMenuTrigger } from "../../shared/design-system/ui/Menu";
+import {
+ ContextMenuRoot,
+ ContextMenuTrigger,
+ MenuPopup,
+} from "../../shared/design-system/ui/Menu";
import type { ChannelSummary, Profile } from "../../features/relay/contracts";
import type { RelaySession } from "../../features/relay/session";
import { ChatCircleIcon } from "../../shared/design-system/icons/index";
@@ -34,6 +38,11 @@ export const ChannelSidebarItem = memo(function ChannelSidebarItem({
menuEnabled,
sectionKey,
onOpenMenu,
+ menuOpen = false,
+ menuAnchor,
+ menuContent,
+ onCloseMenu,
+ menuFinalFocus,
}: {
channel: ChannelSummary;
profile?: Profile | undefined;
@@ -56,6 +65,12 @@ export const ChannelSidebarItem = memo(function ChannelSidebarItem({
sectionKey: string,
anchor?: HTMLElement,
) => void;
+ menuOpen?: boolean;
+ menuAnchor?: HTMLElement | undefined;
+ /** Only the open row receives content, so closed rows keep equal props. */
+ menuContent?: ReactNode;
+ onCloseMenu?: () => void;
+ menuFinalFocus?: (channelId: string) => HTMLElement | false;
}) {
const peer =
channel.channelType === "dm" && channel.participants?.length === 1
@@ -64,7 +79,7 @@ export const ChannelSidebarItem = memo(function ChannelSidebarItem({
const presence = usePresenceStatus(peer ? session.presence : undefined, peer);
const Icon =
channel.channelType === "dm" ? ChatCircleIcon : channelIcon(channel);
- return (
+ const row = (
);
+ if (!menuEnabled) return row;
+ return (
+ {
+ if (open) {
+ if (sectionKey) onOpenMenu?.(channel, sectionKey);
+ } else if (menuOpen) onCloseMenu?.();
+ }}
+ >
+ {row}
+ menuFinalFocus?.(channel.id) ?? false}
+ >
+ {menuContent}
+
+
+ );
});
diff --git a/src/features/channel-navigation/ChannelSidebar.tsx b/src/features/channel-navigation/ChannelSidebar.tsx
index 5a9ddad9f..a4006262b 100644
--- a/src/features/channel-navigation/ChannelSidebar.tsx
+++ b/src/features/channel-navigation/ChannelSidebar.tsx
@@ -22,7 +22,6 @@ import type { OpenTarget } from "../navigation/targets";
import { Panel } from "../../shared/design-system/ui/Panel";
import { Button } from "../../shared/design-system/ui/Button";
import {
- ContextMenuRoot,
MenuItem,
MenuIcon,
MenuPopup,
@@ -525,6 +524,17 @@ function ReadySidebar({
setReadWrite(undefined);
closeMenu();
}, [closeMenu]);
+ const rowMenuFinalFocus = useCallback(
+ (channelId: string) =>
+ startingSession.current
+ ? (document
+ .getElementById("new-session-prompt")
+ ?.querySelector('[role="textbox"]') ?? false)
+ : (sidebar.list.current?.querySelector(
+ `[data-channel-id="${CSS.escape(channelId)}"]`,
+ ) ?? false),
+ [sidebar.list],
+ );
useLayoutEffect(() => {
if (!rowFocus) return;
sidebar.list.current
@@ -849,7 +859,7 @@ function ReadySidebar({
menuEnabled &&
rowMenu?.channelId === channel.id &&
rowMenu.sectionKey === section.key;
- const channelItem = (
+ return (
+ {actions}
+ {readWrite?.pending && (
+ Saving…
+ )}
+ {readWrite?.error && (
+ {readWrite.error}
+ )}
+ >
+ ) : undefined
+ }
+ onCloseMenu={closeRowMenu}
+ menuFinalFocus={rowMenuFinalFocus}
/>
);
- if (!menuEnabled) return channelItem;
- return (
- {
- if (open) openRowMenu(channel, section.key);
- else if (menuOpen) closeRowMenu();
- }}
- >
- {channelItem}
-
- startingSession.current
- ? (document
- .getElementById("new-session-prompt")
- ?.querySelector(
- '[role="textbox"]',
- ) ?? false)
- : (sidebar.list.current?.querySelector(
- `[data-channel-id="${CSS.escape(channel.id)}"]`,
- ) ?? false)
- }
- >
- {actions}
- {readWrite?.pending && Saving…
}
- {readWrite?.error && (
- {readWrite.error}
- )}
-
-
- );
})}
);
From 9a7dfd16e8b260656f92a0b1e36b67b881afcc5e Mon Sep 17 00:00:00 2001
From: Larry
<627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Date: Fri, 25 Sep 2026 12:41:20 -0400
Subject: [PATCH 2/2] fix(sidebar): keep row menu items through the closing
fade
Closed rows now get no menu content, so the row that just closed also
lost its items while its popup faded out. Keep that row's last content
until Base UI reports the close as complete.
Also check in ChannelSidebar.test that unchanged rows skip their menu
root and popup on a channel switch. The item test no longer needs its
own ContextMenuRoot because the item now owns it.
Signed-off-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
---
.../channels/ChannelSidebarItem.test.tsx | 37 +++++++++----------
src/bundled/channels/ChannelSidebarItem.tsx | 12 +++++-
.../ChannelSidebar.test.tsx | 31 +++++++++++++++-
3 files changed, 57 insertions(+), 23 deletions(-)
diff --git a/src/bundled/channels/ChannelSidebarItem.test.tsx b/src/bundled/channels/ChannelSidebarItem.test.tsx
index 5da68a162..0e45d3d5e 100644
--- a/src/bundled/channels/ChannelSidebarItem.test.tsx
+++ b/src/bundled/channels/ChannelSidebarItem.test.tsx
@@ -13,7 +13,6 @@ import type { RelaySession } from "../../features/relay/session";
import type { UnreadSnapshot } from "../../features/relay/unread";
import type { PresenceStatus } from "../../features/presence/presence";
import { ChannelSidebarItem } from "./ChannelSidebarItem";
-import { ContextMenuRoot } from "../../shared/design-system/ui/Menu";
afterEach(cleanup);
@@ -245,25 +244,23 @@ it.each(["ContextMenu", "F10"])(
channelType: "stream" as const,
};
render(
-
-
- ,
+ ,
);
const parent = screen.getByRole("button", { name: "Alpha" });
fireEvent.keyDown(parent, { key, shiftKey: key === "F10" });
diff --git a/src/bundled/channels/ChannelSidebarItem.tsx b/src/bundled/channels/ChannelSidebarItem.tsx
index 31a49c3ad..872708b0d 100644
--- a/src/bundled/channels/ChannelSidebarItem.tsx
+++ b/src/bundled/channels/ChannelSidebarItem.tsx
@@ -1,5 +1,5 @@
import { UserStatusDisplay } from "../../features/user-status/StatusDisplay";
-import { memo, type ReactNode } from "react";
+import { memo, useLayoutEffect, useRef, type ReactNode } from "react";
import { Avatar } from "../../shared/design-system/ui/Avatar";
import {
ContextMenuRoot,
@@ -77,6 +77,11 @@ export const ChannelSidebarItem = memo(function ChannelSidebarItem({
? channel.participants[0]
: undefined;
const presence = usePresenceStatus(peer ? session.presence : undefined, peer);
+ // Keep the closing row's items through the popup's exit transition.
+ const lastMenuContent = useRef(undefined);
+ useLayoutEffect(() => {
+ if (menuContent !== undefined) lastMenuContent.current = menuContent;
+ }, [menuContent]);
const Icon =
channel.channelType === "dm" ? ChatCircleIcon : channelIcon(channel);
const row = (
@@ -200,6 +205,9 @@ export const ChannelSidebarItem = memo(function ChannelSidebarItem({
if (sectionKey) onOpenMenu?.(channel, sectionKey);
} else if (menuOpen) onCloseMenu?.();
}}
+ onOpenChangeComplete={(open) => {
+ if (!open) lastMenuContent.current = undefined;
+ }}
>
{row}
menuFinalFocus?.(channel.id) ?? false}
>
- {menuContent}
+ {menuContent ?? lastMenuContent.current}
);
diff --git a/src/features/channel-navigation/ChannelSidebar.test.tsx b/src/features/channel-navigation/ChannelSidebar.test.tsx
index 0b8d65c63..477f7cc8a 100644
--- a/src/features/channel-navigation/ChannelSidebar.test.tsx
+++ b/src/features/channel-navigation/ChannelSidebar.test.tsx
@@ -9,7 +9,10 @@ import type { Navigation } from "../navigation/controller";
import { ChannelSidebar } from "./ChannelSidebar";
import { ChannelNavigationProvider } from "./ChannelNavigationState";
-const { rowRender } = vi.hoisted(() => ({ rowRender: vi.fn() }));
+const { rowRender, menuRender } = vi.hoisted(() => ({
+ rowRender: vi.fn(),
+ menuRender: vi.fn(),
+}));
// Observe the real row below ChannelSidebarItem's production memo boundary.
// React hooks, the parent, the item and the row implementation remain real.
vi.mock("../../bundled/channels/ChannelSidebarRow", async (original) => {
@@ -25,6 +28,22 @@ vi.mock("../../bundled/channels/ChannelSidebarRow", async (original) => {
},
};
});
+// Observe the real row menu provider and popup the same way.
+vi.mock("../../shared/design-system/ui/Menu", async (original) => {
+ const actual =
+ await original();
+ return {
+ ...actual,
+ ContextMenuRoot: (props: Parameters[0]) => {
+ menuRender("root", props);
+ return ;
+ },
+ MenuPopup: (props: Parameters[0]) => {
+ menuRender("popup", props);
+ return ;
+ },
+ };
+});
beforeEach(() => {
// jsdom has no layout; browser journeys own geometry. Only supply the API.
vi.stubGlobal(
@@ -111,10 +130,20 @@ it("does not rebuild unchanged rows on channel switches and refreshes session ac
const mounted = render(h.view("alpha"));
await screen.findByRole("button", { name: "gamma" });
rowRender.mockClear();
+ menuRender.mockClear();
mounted.rerender(h.view("beta"));
expect(rowRender.mock.calls.map(([props]) => props.channel.id)).not.toContain(
"gamma",
);
+ // Unchanged rows keep their menu provider and popup behind the memo too.
+ expect(
+ menuRender.mock.calls.filter(([, props]) =>
+ String(props["aria-label"] ?? "").includes("gamma"),
+ ),
+ ).toEqual([]);
+ expect(
+ menuRender.mock.calls.filter(([kind]) => kind === "root"),
+ ).toHaveLength(rowRender.mock.calls.length);
expect(screen.getByRole("button", { name: "beta" })).toHaveAttribute(
"aria-current",
"page",