End the component's media device scope on unmount

The component built its `MediaDevices` once, in a scope nothing ever
ended, so every mount left device observers running for the rest of the
page's life. Building it in an effect ties the scope to the component's
lifetime — and to the options it was built with, which were previously
frozen at first render.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Timo K.
2026-09-08 13:50:17 +02:00
co-authored by Claude Fable 5.1
parent a010cc983f
commit 608f107f8d
+48 -31
View File
@@ -32,7 +32,14 @@ Please see LICENSE in the repository root for full details.
// sits in a `@layer`, which loses to unlayered rules either way.
import "../src/base.css";
import { type FC, type JSX, type ReactNode, useMemo, useState } from "react";
import {
type FC,
type JSX,
type ReactNode,
useEffect,
useMemo,
useState,
} from "react";
import { type MatrixClient } from "matrix-js-sdk";
import { logger } from "matrix-js-sdk/lib/logger";
import { MemoryRouter } from "react-router-dom";
@@ -65,7 +72,6 @@ import { Config } from "../src/config/Config";
import { type ConfigOptions } from "../src/config/ConfigOptions";
import { i18n } from "../src/utils/i18n";
import { useTheme } from "../src/useTheme";
import { useInitial } from "../src/useInitial";
import { useStableValue } from "../src/useStableValue";
import styles from "./ElementCall.module.css";
@@ -200,13 +206,22 @@ export const ElementCall: FC<ElementCallProps> = ({
[roomId, intent, stableConfig],
);
const mediaDevices = useInitial(
() =>
new MediaDevices(new ObservableScope(), {
controlledAudioDevices: params.controlledAudioDevices,
callIntent: params.callIntent,
}),
);
// Created in an effect so that the scope it lives in ends when the component
// is unmounted (or these options change), rather than keeping its device
// observers running for the rest of the page's life. Null until then, which
// is one render.
const { controlledAudioDevices, callIntent } = params;
const [mediaDevices, setMediaDevices] = useState<MediaDevices | null>(null);
useEffect(() => {
const scope = new ObservableScope();
setMediaDevices(
new MediaDevices(scope, { controlledAudioDevices, callIntent }),
);
return (): void => {
setMediaDevices(null);
scope.end();
};
}, [controlledAudioDevices, callIntent]);
const room = client.getRoom(roomId);
const rtcSession = useMemo(
@@ -227,28 +242,30 @@ export const ElementCall: FC<ElementCallProps> = ({
embedded cannot disturb the host's URL. */}
<MemoryRouter>
<div ref={setContainer} className={styles.root}>
{container !== null && rtcSession !== null && (
<RootElementProvider value={container}>
<Decoration>
<TooltipProvider>
<ClientProvider client={client}>
<MediaDevicesContext value={mediaDevices}>
<ProcessorProvider>
<ElementCallView
client={client}
rtcSession={rtcSession}
isPasswordlessUser={false}
confineToRoom={params.confineToRoom}
preload={params.preload}
skipLobby={params.skipLobby}
/>
</ProcessorProvider>
</MediaDevicesContext>
</ClientProvider>
</TooltipProvider>
</Decoration>
</RootElementProvider>
)}
{container !== null &&
rtcSession !== null &&
mediaDevices !== null && (
<RootElementProvider value={container}>
<Decoration>
<TooltipProvider>
<ClientProvider client={client}>
<MediaDevicesContext value={mediaDevices}>
<ProcessorProvider>
<ElementCallView
client={client}
rtcSession={rtcSession}
isPasswordlessUser={false}
confineToRoom={params.confineToRoom}
preload={params.preload}
skipLobby={params.skipLobby}
/>
</ProcessorProvider>
</MediaDevicesContext>
</ClientProvider>
</TooltipProvider>
</Decoration>
</RootElementProvider>
)}
</div>
</MemoryRouter>
</UrlParamsProvider>