From e1df71817abfc63d1644839fc04ca5a6311bce44 Mon Sep 17 00:00:00 2001 From: Robin Date: Tue, 4 Aug 2026 12:48:04 +0200 Subject: [PATCH] Performance: Avoid re-rendering context menus so often A change in a tile's speaking indicator could cause its entire tree of context menu components to re-render, which is expensive. Isolating the behavior subscriptions in their own component avoids this. --- src/tile/GridTile.tsx | 122 +++++++++++++++++++++++++++--------------- 1 file changed, 78 insertions(+), 44 deletions(-) diff --git a/src/tile/GridTile.tsx b/src/tile/GridTile.tsx index 657bf0bc8..01d59f9d1 100644 --- a/src/tile/GridTile.tsx +++ b/src/tile/GridTile.tsx @@ -14,6 +14,7 @@ import { useEffect, useRef, useState, + useMemo, } from "react"; import { type animated } from "@react-spring/web"; import classNames from "classnames"; @@ -105,20 +106,22 @@ interface UserMediaTileProps extends TileProps { playbackMuted: boolean; waitingForMedia?: boolean; primaryButton?: ReactNode; - menuStart?: ReactNode; - menuEnd?: ReactNode; focusUrl: string | undefined; } -const UserMediaTile: FC = ({ +/** + * A user media tile without a context menu. + */ +// The context menu is kept separate from this component for performance +// reasons (c.f. UserMediaTile) +const UserMediaTileInner: FC = ({ ref, vm, showSpeakingIndicators, playbackMuted, waitingForMedia, primaryButton, - menuStart, - menuEnd, + menu, className, focusUrl, displayName, @@ -166,24 +169,26 @@ const UserMediaTile: FC = ({ : t("microphone_off"); const [menuOpen, setMenuOpen] = useState(false); - const menu = ( - <> - {menuStart} - {/* - No additional menu item (used to be the manual fit to frame. - Placeholder for future menu items that should be placed here. - */} - {menuEnd} - + const menuTrigger = useMemo( + () => ( + + ), + [t, focusable], ); - const raisedHandOnClick = vm.local - ? (): void => void toggleRaisedHand() - : undefined; + const raisedHandOnClick = useMemo( + () => (vm.local ? (): void => void toggleRaisedHand() : undefined), + [vm.local, toggleRaisedHand], + ); const showSpeaking = showSpeakingIndicators && speaking; - const tile = ( + return ( = ({ open={menuOpen} onOpenChange={setMenuOpen} title={displayName} - trigger={ - - } + trigger={menuTrigger} side="left" align="start" > @@ -241,9 +239,37 @@ const UserMediaTile: FC = ({ {...props} /> ); +}; +/** + * A user media tile enhanced with a context menu. + */ +const UserMediaTile: FC< + UserMediaTileProps & { menuStart?: ReactNode; menuEnd?: ReactNode } +> = ({ menuStart, menuEnd, ...props }) => { + const menu = useMemo( + () => ( + <> + {menuStart} + {/* + No additional menu item (used to be the manual fit to frame. + Placeholder for future menu items that should be placed here. + */} + {menuEnd} + + ), + [menuStart, menuEnd], + ); + + // ContextMenu is expensive to render, so we avoid subscribing to any + // frequently-changing behaviors here and instead keep them isolated in the + // UserMediaTileInner component return ( - + } + hasAccessibleAlternative + > {menu} ); @@ -279,6 +305,29 @@ const LocalUserMediaTile: FC = ({ [vm, latestAlwaysShow], ); + const menuStart = useMemo( + () => ( + + ), + [t, alwaysShow, onSelectAlwaysShow], + ); + const menuEnd = useMemo( + () => + onOpenProfile && ( + + ), + [t, onOpenProfile], + ); + return ( = ({ ) } - menuStart={ - - } - menuEnd={ - onOpenProfile && ( - - ) - } + menuStart={menuStart} + menuEnd={menuEnd} focusable={focusable} focusUrl={focusUrl} {...props}