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.
This commit is contained in:
Robin
2026-08-04 12:48:04 +02:00
parent 608695fc95
commit e1df71817a

View File

@@ -14,6 +14,7 @@ import {
useEffect, useEffect,
useRef, useRef,
useState, useState,
useMemo,
} from "react"; } from "react";
import { type animated } from "@react-spring/web"; import { type animated } from "@react-spring/web";
import classNames from "classnames"; import classNames from "classnames";
@@ -105,20 +106,22 @@ interface UserMediaTileProps extends TileProps {
playbackMuted: boolean; playbackMuted: boolean;
waitingForMedia?: boolean; waitingForMedia?: boolean;
primaryButton?: ReactNode; primaryButton?: ReactNode;
menuStart?: ReactNode;
menuEnd?: ReactNode;
focusUrl: string | undefined; focusUrl: string | undefined;
} }
const UserMediaTile: FC<UserMediaTileProps> = ({ /**
* 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<UserMediaTileProps & { menu: ReactNode }> = ({
ref, ref,
vm, vm,
showSpeakingIndicators, showSpeakingIndicators,
playbackMuted, playbackMuted,
waitingForMedia, waitingForMedia,
primaryButton, primaryButton,
menuStart, menu,
menuEnd,
className, className,
focusUrl, focusUrl,
displayName, displayName,
@@ -166,24 +169,26 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
: t("microphone_off"); : t("microphone_off");
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const menu = ( const menuTrigger = useMemo(
<> () => (
{menuStart} <button
{/* aria-label={t("common.options")}
No additional menu item (used to be the manual fit to frame. tabIndex={focusable ? undefined : -1}
Placeholder for future menu items that should be placed here. >
*/} <OverflowHorizontalIcon aria-hidden width={20} height={20} />
{menuEnd} </button>
</> ),
[t, focusable],
); );
const raisedHandOnClick = vm.local const raisedHandOnClick = useMemo(
? (): void => void toggleRaisedHand() () => (vm.local ? (): void => void toggleRaisedHand() : undefined),
: undefined; [vm.local, toggleRaisedHand],
);
const showSpeaking = showSpeakingIndicators && speaking; const showSpeaking = showSpeakingIndicators && speaking;
const tile = ( return (
<MediaView <MediaView
ref={ref} ref={ref}
video={video} video={video}
@@ -213,14 +218,7 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
open={menuOpen} open={menuOpen}
onOpenChange={setMenuOpen} onOpenChange={setMenuOpen}
title={displayName} title={displayName}
trigger={ trigger={menuTrigger}
<button
aria-label={t("common.options")}
tabIndex={focusable ? undefined : -1}
>
<OverflowHorizontalIcon aria-hidden width={20} height={20} />
</button>
}
side="left" side="left"
align="start" align="start"
> >
@@ -241,9 +239,37 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
{...props} {...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 ( return (
<ContextMenu title={displayName} trigger={tile} hasAccessibleAlternative> <ContextMenu
title={props.displayName}
trigger={<UserMediaTileInner {...props} menu={menu} />}
hasAccessibleAlternative
>
{menu} {menu}
</ContextMenu> </ContextMenu>
); );
@@ -279,6 +305,29 @@ const LocalUserMediaTile: FC<LocalUserMediaTileProps> = ({
[vm, latestAlwaysShow], [vm, latestAlwaysShow],
); );
const menuStart = useMemo(
() => (
<ToggleMenuItem
Icon={VisibilityOnIcon}
label={t("video_tile.always_show")}
checked={alwaysShow}
onSelect={onSelectAlwaysShow}
/>
),
[t, alwaysShow, onSelectAlwaysShow],
);
const menuEnd = useMemo(
() =>
onOpenProfile && (
<MenuItem
Icon={UserProfileIcon}
label={t("common.profile")}
onSelect={onOpenProfile}
/>
),
[t, onOpenProfile],
);
return ( return (
<UserMediaTile <UserMediaTile
ref={ref} ref={ref}
@@ -297,23 +346,8 @@ const LocalUserMediaTile: FC<LocalUserMediaTileProps> = ({
</button> </button>
) )
} }
menuStart={ menuStart={menuStart}
<ToggleMenuItem menuEnd={menuEnd}
Icon={VisibilityOnIcon}
label={t("video_tile.always_show")}
checked={alwaysShow}
onSelect={onSelectAlwaysShow}
/>
}
menuEnd={
onOpenProfile && (
<MenuItem
Icon={UserProfileIcon}
label={t("common.profile")}
onSelect={onOpenProfile}
/>
)
}
focusable={focusable} focusable={focusable}
focusUrl={focusUrl} focusUrl={focusUrl}
{...props} {...props}