Compare commits

..

8 Commits

Author SHA1 Message Date
Robin
a6a02778a1 Performance: Avoid re-rendering entire spotlight layout so often
This removes a lot of unnecessary jank in spotlight layout when swapping individual speakers in and out of the spotlight tile.
2026-08-10 11:16:35 +02:00
renovate[bot]
978ffee183 Update ghcr.io/element-hq/element-web:develop Docker digest to a23cced (#4155)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-07 09:28:35 +02:00
renovate[bot]
2d64cf4b30 Update ghcr.io/element-hq/element-web:develop Docker digest to 6fb12e1 (#4154)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-06 09:38:48 +02:00
Robin
eb65a28a75 Merge pull request #4146 from element-hq/performance
Performance: Enable React Compiler
2026-08-05 11:10:40 +02:00
Robin
9d400323d0 Clarify why certain tests disable the React Compiler 2026-08-05 10:54:49 +02:00
renovate[bot]
6d7d3093b3 Update ghcr.io/element-hq/element-web:develop Docker digest to b4e2d2d (#4149)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-05 10:16:05 +02:00
Robin
2b07e9e822 Merge pull request #4148 from element-hq/renovate/i18next-26.x
Update dependency i18next to v26
2026-08-04 22:49:21 +02:00
renovate[bot]
7ae1fc6cd8 Update dependency i18next to v26 2026-08-04 17:21:53 +00:00
7 changed files with 79 additions and 153 deletions

View File

@@ -13,7 +13,7 @@ services:
- ./backend/playwright_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z
element-web:
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
image: ghcr.io/element-hq/element-web:develop@sha256:0183c0a7f4292252349ddcff2de92a83a4571b283da49cc54adb9efbf3d20695
image: ghcr.io/element-hq/element-web:develop@sha256:a23cced80588306c66bc7cfa859c591be03ece31f25d0a6a2e1e935943a35c92
element-web-1:
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
image: ghcr.io/element-hq/element-web:develop@sha256:0183c0a7f4292252349ddcff2de92a83a4571b283da49cc54adb9efbf3d20695
image: ghcr.io/element-hq/element-web:develop@sha256:a23cced80588306c66bc7cfa859c591be03ece31f25d0a6a2e1e935943a35c92

View File

@@ -88,7 +88,7 @@
"eslint-plugin-element-call": "link:eslint",
"eslint-plugin-storybook": "^10.3.6",
"fetch-mock": "11.1.5",
"i18next": "^25.0.0",
"i18next": "^26.0.0",
"i18next-browser-languagedetector": "^8.0.0",
"i18next-cli": "^1.61.0",
"jsdom": "^26.0.0",

24
pnpm-lock.yaml generated
View File

@@ -167,8 +167,8 @@ importers:
specifier: 11.1.5
version: 11.1.5
i18next:
specifier: ^25.0.0
version: 25.10.10(typescript@5.9.3)
specifier: ^26.0.0
version: 26.3.6(typescript@5.9.3)
i18next-browser-languagedetector:
specifier: ^8.0.0
version: 8.2.1
@@ -237,7 +237,7 @@ importers:
version: 19.2.8(react@19.2.8)
react-i18next:
specifier: ^16.0.0 <16.7.0
version: 16.6.6(i18next@25.10.10(typescript@5.9.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3)
version: 16.6.6(i18next@26.3.6(typescript@5.9.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3)
react-router-dom:
specifier: ^7.0.0
version: 7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
@@ -4278,14 +4278,6 @@ packages:
resolution: {integrity: sha512-n5UexwEVt0OoIAhG2MWpSnAVJW1U8mQrQTmXyxc5DMAx+NLhcLZhSMJo/FnUsA5JQ3obTYqTgB7YIuZKWpDgow==}
hasBin: true
i18next@25.10.10:
resolution: {integrity: sha512-cqUW2Z3EkRx7NqSyywjkgCLK7KLCL6IFVFcONG7nVYIJ3ekZ1/N5jUsihHV6Bq37NfhgtczxJcxduELtjTwkuQ==}
peerDependencies:
typescript: ^5 || ^6
peerDependenciesMeta:
typescript:
optional: true
i18next@26.3.6:
resolution: {integrity: sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==}
peerDependencies:
@@ -9931,12 +9923,6 @@ snapshots:
transitivePeerDependencies:
- '@swc/helpers'
i18next@25.10.10(typescript@5.9.3):
dependencies:
'@babel/runtime': 7.29.2
optionalDependencies:
typescript: 5.9.3
i18next@26.3.6(typescript@5.9.3):
optionalDependencies:
typescript: 5.9.3
@@ -11094,11 +11080,11 @@ snapshots:
react: 19.2.8
scheduler: 0.27.0
react-i18next@16.6.6(i18next@25.10.10(typescript@5.9.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3):
react-i18next@16.6.6(i18next@26.3.6(typescript@5.9.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3):
dependencies:
'@babel/runtime': 7.29.2
html-parse-stringify: 3.0.1
i18next: 25.10.10(typescript@5.9.3)
i18next: 26.3.6(typescript@5.9.3)
react: 19.2.8
use-sync-external-store: 1.6.0(react@19.2.8)
optionalDependencies:

View File

@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type ReactNode } from "react";
import { type FC, type ReactNode } from "react";
import { useObservableEagerState } from "observable-hooks";
import classNames from "classnames";
@@ -13,6 +13,9 @@ import { type CallLayout } from "./CallLayout";
import { type SpotlightLandscapeLayout as SpotlightLandscapeLayoutModel } from "../state/layout-types.ts";
import styles from "./SpotlightLandscapeLayout.module.css";
import { useUpdateLayout, useVisibleTiles } from "./Grid";
import { type MediaViewModel } from "../state/media/MediaViewModel.ts";
import { type Behavior } from "../state/Behavior.ts";
import { useBehavior } from "../useBehavior.ts";
/**
* An implementation of the "spotlight landscape" layout, in which the spotlight
@@ -54,16 +57,10 @@ export const makeSpotlightLandscapeLayout: CallLayout<
useUpdateLayout();
useVisibleTiles(model.setVisibleTiles);
useObservableEagerState(minBounds$);
const withIndicators =
useObservableEagerState(model.spotlight.media$).length > 1;
return (
<div ref={ref} className={styles.layer}>
<div
className={classNames(styles.spotlight, {
[styles.withIndicators]: withIndicators,
})}
/>
<SpotlightSlot media$={model.spotlight.media$} />
<div className={styles.grid}>
{model.grid.map((m) => (
<Slot key={m.id} className={styles.slot} id={m.id} model={m} />
@@ -73,3 +70,20 @@ export const makeSpotlightLandscapeLayout: CallLayout<
);
},
});
interface SpotlightSlotProps {
media$: Behavior<MediaViewModel[]>;
}
// This component isolates the subscription to the spotlight media so that it
// can change without causing the whole layout to re-render
const SpotlightSlot: FC<SpotlightSlotProps> = ({ media$ }) => {
const withIndicators = useBehavior(media$).length > 1;
return (
<div
className={classNames(styles.spotlight, {
[styles.withIndicators]: withIndicators,
})}
/>
);
};

View File

@@ -5,10 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
type LocalTrackPublication,
type RemoteTrackPublication,
} from "livekit-client";
import { type RemoteTrackPublication } from "livekit-client";
import { test, expect } from "vitest";
import { act, render, screen } from "@testing-library/react";
import { axe } from "vitest-axe";
@@ -20,9 +17,6 @@ import {
mockRtcMembership,
mockRemoteMedia,
mockRemoteParticipant,
mockLocalMedia,
mockLocalParticipant,
mockMediaDevices,
} from "../utils/test";
import { GridTileViewModel } from "../state/TileViewModel";
import { ReactionsSenderProvider } from "../reactions/useReactionsSender";
@@ -60,7 +54,7 @@ const callVm = {
handsRaised$: constant({}),
} as Partial<CallViewModel> as CallViewModel;
test("GridTile displays remote media", async () => {
test("GridTile is accessible", async () => {
const vm = mockRemoteMedia(
mockRtcMembership("@alice:example.org", "AAAA"),
{
@@ -94,40 +88,6 @@ test("GridTile displays remote media", 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<LocalTrackPublication> as LocalTrackPublication,
}),
mockMediaDevices({}),
);
const { container } = render(
<ReactionsSenderProvider vm={callVm} rtcSession={fakeRtcSession}>
<GridTile
vm={new GridTileViewModel(constant(vm))}
onOpenProfile={() => {}}
targetWidth={300}
targetHeight={200}
showSpeakingIndicators
showNameTags
showRingingStatus
showOutline
focusable
/>
</ReactionsSenderProvider>,
);
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"]

View File

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

View File

@@ -28,7 +28,7 @@ test("useTypedEventEmitterState reacts to events", async () => {
const emitter = new TestEmitter();
const Test: FC = () => {
// Disable the React Compiler
// Disable the React Compiler to work around the following bug:
// https://github.com/react/react/issues/34901
"use no memo";
@@ -55,7 +55,7 @@ test("useTypedEventEmitterState reacts to changes made by an effect mounted on t
const emitter = new TestEmitter();
const Test: FC = () => {
// Disable the React Compiler
// Disable the React Compiler to work around the following bug:
// https://github.com/react/react/issues/34901
"use no memo";
@@ -77,7 +77,7 @@ test("useTypedEventEmitterState reacts to changes in getState", async () => {
const emitter = new TestEmitter();
const Test: FC = () => {
// Disable the React Compiler
// Disable the React Compiler to work around the following bug:
// https://github.com/react/react/issues/34901
"use no memo";