From e1df71817abfc63d1644839fc04ca5a6311bce44 Mon Sep 17 00:00:00 2001 From: Robin Date: Tue, 4 Aug 2026 12:48:04 +0200 Subject: [PATCH 1/2] 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} From c90d1d8dda2605163a856968a11b39bb8537ab64 Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 5 Aug 2026 12:04:05 +0200 Subject: [PATCH 2/2] Expand GridTile test coverage --- src/tile/GridTile.test.tsx | 44 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/src/tile/GridTile.test.tsx b/src/tile/GridTile.test.tsx index 3ee528755..60fbc303c 100644 --- a/src/tile/GridTile.test.tsx +++ b/src/tile/GridTile.test.tsx @@ -5,7 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { type RemoteTrackPublication } from "livekit-client"; +import { + type LocalTrackPublication, + type RemoteTrackPublication, +} from "livekit-client"; import { test, expect } from "vitest"; import { act, render, screen } from "@testing-library/react"; import { axe } from "vitest-axe"; @@ -17,6 +20,9 @@ import { mockRtcMembership, mockRemoteMedia, mockRemoteParticipant, + mockLocalMedia, + mockLocalParticipant, + mockMediaDevices, } from "../utils/test"; import { GridTileViewModel } from "../state/TileViewModel"; import { ReactionsSenderProvider } from "../reactions/useReactionsSender"; @@ -54,7 +60,7 @@ const callVm = { handsRaised$: constant({}), } as Partial as CallViewModel; -test("GridTile is accessible", async () => { +test("GridTile displays remote media", async () => { const vm = mockRemoteMedia( mockRtcMembership("@alice:example.org", "AAAA"), { @@ -88,6 +94,40 @@ test("GridTile is accessible", async () => { screen.getByText("Alice"); }); +test("GridTile displays local media", async () => { + const vm = mockLocalMedia( + mockRtcMembership("@alice:example.org", "AAAA"), + { + rawDisplayName: "Alice", + getMxcAvatarUrl: () => "mxc://adfsg", + }, + mockLocalParticipant({ + getTrackPublication: () => + ({}) as Partial as LocalTrackPublication, + }), + mockMediaDevices({}), + ); + + const { container } = render( + + {}} + targetWidth={300} + targetHeight={200} + showSpeakingIndicators + showNameTags + showRingingStatus + showOutline + focusable + /> + , + ); + expect(await axe(container)).toHaveNoViolations(); + // Name should be visible + screen.getByText("Alice"); +}); + test("GridTile displays ringing media", async () => { const pickupState$ = new BehaviorSubject< RingingMediaViewModel["pickupState$"]["value"]