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; +}