From 15f1291cbcbb5920f87fd0ea73dba27ce12d91b1 Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 6 Aug 2025 14:17:00 +0200 Subject: [PATCH] Fix widget initialization in React development mode (#30463) Since the upgrade to React 19, widget initialization (most notably affecting group calls) has been broken in development mode. This is because React now executes all callback refs twice, and the callback ref that receives the widget's iframe was not prepared to deal with that. I've fixed this by creating and attaching the iframe to the DOM in the callback ref, which allows us to properly couple its lifetime to that of the StopGapWidget. I've also added some insurance against strict mode-style races in StopGapWidget (doesn't hurt). --- src/components/views/elements/AppTile.tsx | 120 +++++++++++----------- src/stores/widgets/StopGapWidget.ts | 30 ++++-- 2 files changed, 78 insertions(+), 72 deletions(-) diff --git a/src/components/views/elements/AppTile.tsx b/src/components/views/elements/AppTile.tsx index cfb95abcc3..e88650788a 100644 --- a/src/components/views/elements/AppTile.tsx +++ b/src/components/views/elements/AppTile.tsx @@ -62,6 +62,20 @@ import { parseUrl } from "../../../utils/UrlUtils"; import RightPanelStore from "../../../stores/right-panel/RightPanelStore.ts"; import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases.ts"; +// Note that there is advice saying allow-scripts shouldn't be used with allow-same-origin +// because that would allow the iframe to programmatically remove the sandbox attribute, but +// this would only be for content hosted on the same origin as the element client: anything +// hosted on the same origin as the client will get the same access as if you clicked +// a link to it. +const sandboxFlags = + "allow-forms allow-popups allow-popups-to-escape-sandbox " + + "allow-same-origin allow-scripts allow-presentation allow-downloads"; + +// Additional iframe feature permissions +// (see - https://sites.google.com/a/chromium.org/dev/Home/chromium-security/deprecating-permissions-in-cross-origin-iframes and https://wicg.github.io/feature-policy/) +const iframeFeatures = + "microphone; camera; encrypted-media; autoplay; display-capture; clipboard-write; clipboard-read;"; + interface IProps { app: IWidget | IApp; // If room is not specified then it is an account level widget @@ -138,7 +152,7 @@ export default class AppTile extends React.Component { }; private contextMenuButton = createRef(); - private iframe?: HTMLIFrameElement; // ref to the iframe (callback style) + private iframeParent: HTMLElement | null = null; // parent div of the iframe private allowedWidgetsWatchRef?: string; private persistKey: string; private sgWidget?: StopGapWidget; @@ -397,18 +411,46 @@ export default class AppTile extends React.Component { }); } + /** + * Creates the widget iframe and opens communication with the widget. + */ private startMessaging(): void { - try { - this.sgWidget?.startMessaging(this.iframe!); - } catch (e) { - logger.error("Failed to start widget", e); - } + // We create the iframe ourselves rather than leaving the job to React, + // because we need the lifetime of the messaging and the iframe to be + // the same; we don't want strict mode, for instance, to cause the + // messaging to restart (lose its state) without also killing the widget + const iframe = document.createElement("iframe"); + iframe.title = WidgetUtils.getWidgetName(this.props.app); + iframe.allow = iframeFeatures; + iframe.src = this.sgWidget!.embedUrl; + iframe.allowFullscreen = true; + iframe.sandbox = sandboxFlags; + this.iframeParent!.appendChild(iframe); + // In order to start the widget messaging we need iframe.contentWindow + // to exist. Waiting until the next layout gives the browser a chance to + // initialize it. + requestAnimationFrame(() => { + // Handle the race condition (seen in strict mode) where the element + // is added and then removed before we enter this callback + if (iframe.parentElement === null) return; + try { + this.sgWidget?.startMessaging(iframe); + } catch (e) { + logger.error("Failed to start widget", e); + } + }); } - private iframeRefChange = (ref: HTMLIFrameElement): void => { - this.iframe = ref; + /** + * Callback ref for the parent div of the iframe. + */ + private iframeParentRef = (element: HTMLElement | null): void => { + // Detach the existing iframe (if any) from the document so we know not + // to do anything further with it, like starting up the messaging + this.iframeParent?.querySelector("iframe")?.remove(); + this.iframeParent = element; if (this.unmounted) return; - if (ref) { + if (element && this.sgWidget) { this.startMessaging(); } else { this.resetWidget(this.props); @@ -426,24 +468,8 @@ export default class AppTile extends React.Component { /** * Ends all widget interaction, such as cancelling calls and disabling webcams. - * @private - * @returns {Promise<*>} Resolves when the widget is terminated, or timeout passed. */ - private async endWidgetActions(): Promise { - // widget migration dev note: async to maintain signature - // HACK: This is a really dirty way to ensure that Jitsi cleans up - // its hold on the webcam. Without this, the widget holds a media - // stream open, even after death. See https://github.com/vector-im/element-web/issues/7351 - if (this.iframe) { - // In practice we could just do `+= ''` to trick the browser - // into thinking the URL changed, however I can foresee this - // being optimized out by a browser. Instead, we'll just point - // the iframe at a page that is reasonably safe to use in the - // event the iframe doesn't wink away. - // This is relative to where the Element instance is located. - this.iframe.src = "about:blank"; - } - + private endWidgetActions(): void { if (WidgetType.JITSI.matches(this.props.app.type) && this.props.room) { LegacyCallHandler.instance.hangupCallApp(this.props.room.roomId); } @@ -457,6 +483,7 @@ export default class AppTile extends React.Component { this.sgWidget?.stopMessaging({ forceDestroy: true }); } + private onWidgetReady = (): void => { this.setState({ loading: false }); }; @@ -554,16 +581,11 @@ export default class AppTile extends React.Component { } private reload(): void { - this.endWidgetActions().then(() => { - // reset messaging - this.resetWidget(this.props); - this.startMessaging(); - - if (this.iframe && this.sgWidget) { - // Reload iframe - this.iframe.src = this.sgWidget.embedUrl; - } - }); + this.endWidgetActions(); + // reset messaging + this.resetWidget(this.props); + this.iframeParent?.querySelector("iframe")?.remove(); + this.startMessaging(); } // TODO replace with full screen interactions @@ -621,20 +643,6 @@ export default class AppTile extends React.Component { public render(): React.ReactNode { let appTileBody: JSX.Element | undefined; - // Note that there is advice saying allow-scripts shouldn't be used with allow-same-origin - // because that would allow the iframe to programmatically remove the sandbox attribute, but - // this would only be for content hosted on the same origin as the element client: anything - // hosted on the same origin as the client will get the same access as if you clicked - // a link to it. - const sandboxFlags = - "allow-forms allow-popups allow-popups-to-escape-sandbox " + - "allow-same-origin allow-scripts allow-presentation allow-downloads"; - - // Additional iframe feature permissions - // (see - https://sites.google.com/a/chromium.org/dev/Home/chromium-security/deprecating-permissions-in-cross-origin-iframes and https://wicg.github.io/feature-policy/) - const iframeFeatures = - "microphone; camera; encrypted-media; autoplay; display-capture; clipboard-write; " + "clipboard-read;"; - const appTileBodyClass = classNames({ "mx_AppTileBody": true, "mx_AppTileBody--large": !this.props.miniMode, @@ -654,8 +662,6 @@ export default class AppTile extends React.Component { ); - const widgetTitle = WidgetUtils.getWidgetName(this.props.app); - if (this.sgWidget === null) { appTileBody = (
@@ -692,16 +698,8 @@ export default class AppTile extends React.Component { } else if (this.sgWidget) { appTileBody = ( <> -
+
{this.state.loading && loadingElement} -