From a1b37bb3c93be51770c2a990e81c2026d7b3f4d9 Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 17 Aug 2026 16:48:03 +0200 Subject: [PATCH] Performance: Ignore redundant layout updates When someone starts or stops speaking, the layout will often be recomputed only to find out that there is ultimately no layout change. We can ignore these redundant updates to avoid re-rendering the InCallView and Grid components, which are relatively slow. For that specific, common case, this reduces JS CPU usage by as much as 70% in my testing. --- src/state/CallViewModel/CallViewModel.ts | 14 +++-- src/state/layout-types.test.ts | 74 ++++++++++++++++++++++++ src/state/layout-types.ts | 28 +++++++++ 3 files changed, 112 insertions(+), 4 deletions(-) create mode 100644 src/state/layout-types.test.ts diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index f25804599..e0a50e147 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -79,7 +79,7 @@ import { type ReactionInfo, type ReactionOption, } from "../../reactions"; -import { shallowEquals } from "../../utils/array"; +import { shallowEquals as shallowArrayEquals } from "../../utils/array"; import { type MediaDevices } from "../MediaDevices"; import { constant, type Behavior } from "../Behavior"; import { E2eeType } from "../../e2ee/e2eeType"; @@ -89,6 +89,7 @@ import { getUrlParams, HeaderStyle } from "../../UrlParams"; import { type ProcessorState } from "../../livekit/TrackProcessorContext"; import { ElementWidgetActions, widget } from "../../widget"; import { + layoutShallowEquals, type Alignment, type GridLayoutMedia, type Layout, @@ -941,7 +942,7 @@ export function createCallViewModel$( bins.sort(([, bin1], [, bin2]) => bin1 - bin2).map(([m]) => m), ); }), - distinctUntilChanged(shallowEquals), + distinctUntilChanged(shallowArrayEquals), ), ); @@ -1000,7 +1001,7 @@ export function createCallViewModel$( const spotlight$ = scope.behavior( spotlightAndPip$.pipe( map(({ spotlight }) => spotlight), - distinctUntilChanged(shallowEquals), + distinctUntilChanged(shallowArrayEquals), ), ); @@ -1209,6 +1210,7 @@ export function createCallViewModel$( } return layout; }), + distinctUntilChanged(), scope.bind(), ) .subscribe((orientation) => { @@ -1574,7 +1576,11 @@ export function createCallViewModel$( * The layout of tiles in the call interface. */ const layout$ = scope.behavior( - layoutInternals$.pipe(map(({ layout }) => layout)), + layoutInternals$.pipe( + map(({ layout }) => layout), + // Drop redundant layout updates before they would hit React. + distinctUntilChanged(layoutShallowEquals), + ), ); const overflowing$ = scope.behavior( diff --git a/src/state/layout-types.test.ts b/src/state/layout-types.test.ts new file mode 100644 index 000000000..468c15bb5 --- /dev/null +++ b/src/state/layout-types.test.ts @@ -0,0 +1,74 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { test, expect } from "vitest"; +import { + type Alignment, + layoutShallowEquals, + type Layout, +} from "./layout-types"; +import { + type SpotlightTileViewModel, + type GridTileViewModel, +} from "./TileViewModel"; +import { BehaviorSubject } from "rxjs"; + +const spotlightTile = {} as unknown as SpotlightTileViewModel; +const gridTile = {} as unknown as GridTileViewModel; +const pipAlignment$ = new BehaviorSubject({ + inline: "end", + block: "end", +}); + +const spotlightExpanded: Layout = { + type: "spotlight-expanded", + spotlight: spotlightTile, + pipAlignment$, +}; + +const spotlightPortrait: Layout = { + type: "spotlight-portrait", + spotlight: spotlightTile, + grid: [gridTile], + setVisibleTiles: () => {}, +}; + +test("layoutShallowEquals considers a layout to be equal to its shallow clone", () => + expect(layoutShallowEquals(spotlightExpanded, { ...spotlightExpanded })).toBe( + true, + )); + +test("layoutShallowEquals detects a missing key", () => { + expect( + layoutShallowEquals(spotlightExpanded, { + ...spotlightExpanded, + pip: gridTile, + }), + ).toBe(false); + expect( + layoutShallowEquals( + { ...spotlightExpanded, pip: gridTile }, + spotlightExpanded, + ), + ).toBe(false); +}); + +test("layoutShallowEquals considers grid arrays with equal contents to be equal", () => + expect( + layoutShallowEquals(spotlightPortrait, { + ...spotlightPortrait, + grid: [...spotlightPortrait.grid], + }), + ).toBe(true)); + +test("layoutShallowEquals detects grid arrays with different contents", () => + expect( + layoutShallowEquals(spotlightPortrait, { + ...spotlightPortrait, + grid: [...spotlightPortrait.grid, gridTile], + }), + ).toBe(false)); diff --git a/src/state/layout-types.ts b/src/state/layout-types.ts index 5c63a9c90..be86f2a36 100644 --- a/src/state/layout-types.ts +++ b/src/state/layout-types.ts @@ -16,6 +16,7 @@ import { type SpotlightTileViewModel, } from "./TileViewModel.ts"; import { type Behavior } from "./Behavior.ts"; +import { shallowEquals as arrayShallowEquals } from "../utils/array.ts"; export interface GridLayoutMedia { type: "grid"; @@ -140,3 +141,30 @@ export type Layout = | OneOnOneDesktopLayout | OneOnOneMobileLayout | PipLayout; + +/** + * Tests whether the top-level properties and array elements of layout `a` are + * equal to those of layout `b`. Useful for deduping redundant layout updates. + */ +export function layoutShallowEquals(a: Layout, b: Layout): boolean { + // If a and b have the same number of keys and every key in a is also in b, + // then they have the same keys. + const aKeys = Object.keys(a); + const bKeys = Object.keys(b); + if (aKeys.length !== bKeys.length) return false; + + for (const key of aKeys) { + if (!(key in b)) return false; + + // Now check that they have the same values. + const aValue = (a as any)[key]; + const bValue = (b as any)[key]; + if (Array.isArray(aValue) && Array.isArray(bValue)) { + // Special case for arrays so we can detect when the grid tiles arrays are + // essentially the same. + if (!arrayShallowEquals(aValue, bValue)) return false; + } else if (aValue !== bValue) return false; + } + + return true; +}