Skip to content

Commit 15f1291

Browse files
authored
Fix widget initialization in React development mode (element-hq#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).
1 parent 8a550cf commit 15f1291

File tree

2 files changed

+78
-72
lines changed

2 files changed

+78
-72
lines changed

src/components/views/elements/AppTile.tsx

Lines changed: 59 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,20 @@ import { parseUrl } from "../../../utils/UrlUtils";
6262
import RightPanelStore from "../../../stores/right-panel/RightPanelStore.ts";
6363
import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases.ts";
6464

65+
// Note that there is advice saying allow-scripts shouldn't be used with allow-same-origin
66+
// because that would allow the iframe to programmatically remove the sandbox attribute, but
67+
// this would only be for content hosted on the same origin as the element client: anything
68+
// hosted on the same origin as the client will get the same access as if you clicked
69+
// a link to it.
70+
const sandboxFlags =
71+
"allow-forms allow-popups allow-popups-to-escape-sandbox " +
72+
"allow-same-origin allow-scripts allow-presentation allow-downloads";
73+
74+
// Additional iframe feature permissions
75+
// (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/)
76+
const iframeFeatures =
77+
"microphone; camera; encrypted-media; autoplay; display-capture; clipboard-write; clipboard-read;";
78+
6579
interface IProps {
6680
app: IWidget | IApp;
6781
// If room is not specified then it is an account level widget
@@ -138,7 +152,7 @@ export default class AppTile extends React.Component<IProps, IState> {
138152
};
139153

140154
private contextMenuButton = createRef<any>();
141-
private iframe?: HTMLIFrameElement; // ref to the iframe (callback style)
155+
private iframeParent: HTMLElement | null = null; // parent div of the iframe
142156
private allowedWidgetsWatchRef?: string;
143157
private persistKey: string;
144158
private sgWidget?: StopGapWidget;
@@ -397,18 +411,46 @@ export default class AppTile extends React.Component<IProps, IState> {
397411
});
398412
}
399413

414+
/**
415+
* Creates the widget iframe and opens communication with the widget.
416+
*/
400417
private startMessaging(): void {
401-
try {
402-
this.sgWidget?.startMessaging(this.iframe!);
403-
} catch (e) {
404-
logger.error("Failed to start widget", e);
405-
}
418+
// We create the iframe ourselves rather than leaving the job to React,
419+
// because we need the lifetime of the messaging and the iframe to be
420+
// the same; we don't want strict mode, for instance, to cause the
421+
// messaging to restart (lose its state) without also killing the widget
422+
const iframe = document.createElement("iframe");
423+
iframe.title = WidgetUtils.getWidgetName(this.props.app);
424+
iframe.allow = iframeFeatures;
425+
iframe.src = this.sgWidget!.embedUrl;
426+
iframe.allowFullscreen = true;
427+
iframe.sandbox = sandboxFlags;
428+
this.iframeParent!.appendChild(iframe);
429+
// In order to start the widget messaging we need iframe.contentWindow
430+
// to exist. Waiting until the next layout gives the browser a chance to
431+
// initialize it.
432+
requestAnimationFrame(() => {
433+
// Handle the race condition (seen in strict mode) where the element
434+
// is added and then removed before we enter this callback
435+
if (iframe.parentElement === null) return;
436+
try {
437+
this.sgWidget?.startMessaging(iframe);
438+
} catch (e) {
439+
logger.error("Failed to start widget", e);
440+
}
441+
});
406442
}
407443

408-
private iframeRefChange = (ref: HTMLIFrameElement): void => {
409-
this.iframe = ref;
444+
/**
445+
* Callback ref for the parent div of the iframe.
446+
*/
447+
private iframeParentRef = (element: HTMLElement | null): void => {
448+
// Detach the existing iframe (if any) from the document so we know not
449+
// to do anything further with it, like starting up the messaging
450+
this.iframeParent?.querySelector("iframe")?.remove();
451+
this.iframeParent = element;
410452
if (this.unmounted) return;
411-
if (ref) {
453+
if (element && this.sgWidget) {
412454
this.startMessaging();
413455
} else {
414456
this.resetWidget(this.props);
@@ -426,24 +468,8 @@ export default class AppTile extends React.Component<IProps, IState> {
426468

427469
/**
428470
* Ends all widget interaction, such as cancelling calls and disabling webcams.
429-
* @private
430-
* @returns {Promise<*>} Resolves when the widget is terminated, or timeout passed.
431471
*/
432-
private async endWidgetActions(): Promise<void> {
433-
// widget migration dev note: async to maintain signature
434-
// HACK: This is a really dirty way to ensure that Jitsi cleans up
435-
// its hold on the webcam. Without this, the widget holds a media
436-
// stream open, even after death. See https://github.com/vector-im/element-web/issues/7351
437-
if (this.iframe) {
438-
// In practice we could just do `+= ''` to trick the browser
439-
// into thinking the URL changed, however I can foresee this
440-
// being optimized out by a browser. Instead, we'll just point
441-
// the iframe at a page that is reasonably safe to use in the
442-
// event the iframe doesn't wink away.
443-
// This is relative to where the Element instance is located.
444-
this.iframe.src = "about:blank";
445-
}
446-
472+
private endWidgetActions(): void {
447473
if (WidgetType.JITSI.matches(this.props.app.type) && this.props.room) {
448474
LegacyCallHandler.instance.hangupCallApp(this.props.room.roomId);
449475
}
@@ -457,6 +483,7 @@ export default class AppTile extends React.Component<IProps, IState> {
457483

458484
this.sgWidget?.stopMessaging({ forceDestroy: true });
459485
}
486+
460487
private onWidgetReady = (): void => {
461488
this.setState({ loading: false });
462489
};
@@ -554,16 +581,11 @@ export default class AppTile extends React.Component<IProps, IState> {
554581
}
555582

556583
private reload(): void {
557-
this.endWidgetActions().then(() => {
558-
// reset messaging
559-
this.resetWidget(this.props);
560-
this.startMessaging();
561-
562-
if (this.iframe && this.sgWidget) {
563-
// Reload iframe
564-
this.iframe.src = this.sgWidget.embedUrl;
565-
}
566-
});
584+
this.endWidgetActions();
585+
// reset messaging
586+
this.resetWidget(this.props);
587+
this.iframeParent?.querySelector("iframe")?.remove();
588+
this.startMessaging();
567589
}
568590

569591
// TODO replace with full screen interactions
@@ -621,20 +643,6 @@ export default class AppTile extends React.Component<IProps, IState> {
621643
public render(): React.ReactNode {
622644
let appTileBody: JSX.Element | undefined;
623645

624-
// Note that there is advice saying allow-scripts shouldn't be used with allow-same-origin
625-
// because that would allow the iframe to programmatically remove the sandbox attribute, but
626-
// this would only be for content hosted on the same origin as the element client: anything
627-
// hosted on the same origin as the client will get the same access as if you clicked
628-
// a link to it.
629-
const sandboxFlags =
630-
"allow-forms allow-popups allow-popups-to-escape-sandbox " +
631-
"allow-same-origin allow-scripts allow-presentation allow-downloads";
632-
633-
// Additional iframe feature permissions
634-
// (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/)
635-
const iframeFeatures =
636-
"microphone; camera; encrypted-media; autoplay; display-capture; clipboard-write; " + "clipboard-read;";
637-
638646
const appTileBodyClass = classNames({
639647
"mx_AppTileBody": true,
640648
"mx_AppTileBody--large": !this.props.miniMode,
@@ -654,8 +662,6 @@ export default class AppTile extends React.Component<IProps, IState> {
654662
</div>
655663
);
656664

657-
const widgetTitle = WidgetUtils.getWidgetName(this.props.app);
658-
659665
if (this.sgWidget === null) {
660666
appTileBody = (
661667
<div className={appTileBodyClass} style={appTileBodyStyles}>
@@ -692,16 +698,8 @@ export default class AppTile extends React.Component<IProps, IState> {
692698
} else if (this.sgWidget) {
693699
appTileBody = (
694700
<>
695-
<div className={appTileBodyClass} style={appTileBodyStyles}>
701+
<div className={appTileBodyClass} style={appTileBodyStyles} ref={this.iframeParentRef}>
696702
{this.state.loading && loadingElement}
697-
<iframe
698-
title={widgetTitle}
699-
allow={iframeFeatures}
700-
ref={this.iframeRefChange}
701-
src={this.sgWidget.embedUrl}
702-
allowFullScreen={true}
703-
sandbox={sandboxFlags}
704-
/>
705703
</div>
706704
{this.props.overlay}
707705
</>

src/stores/widgets/StopGapWidget.ts

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ export class ElementWidget extends Widget {
154154

155155
export class StopGapWidget extends EventEmitter {
156156
private client: MatrixClient;
157+
private iframe: HTMLIFrameElement | null = null;
157158
private messaging: ClientWidgetApi | null = null;
158159
private mockWidget: ElementWidget;
159160
private scalarToken?: string;
@@ -242,10 +243,6 @@ export class StopGapWidget extends EventEmitter {
242243
return parsed.toString().replace(/%24/g, "$");
243244
}
244245

245-
public get started(): boolean {
246-
return !!this.messaging;
247-
}
248-
249246
private onThemeChange = (theme: string): void => {
250247
this.messaging?.updateTheme({ name: theme });
251248
};
@@ -278,9 +275,10 @@ export class StopGapWidget extends EventEmitter {
278275
* This starts the messaging for the widget if it is not in the state `started` yet.
279276
* @param iframe the iframe the widget should use
280277
*/
281-
public startMessaging(iframe: HTMLIFrameElement): any {
282-
if (this.started) return;
278+
public startMessaging(iframe: HTMLIFrameElement): void {
279+
if (this.messaging !== null) return;
283280

281+
this.iframe = iframe;
284282
const allowedCapabilities = this.appTileProps.whitelistCapabilities || [];
285283
const driver = new StopGapWidgetDriver(
286284
allowedCapabilities,
@@ -478,16 +476,26 @@ export class StopGapWidget extends EventEmitter {
478476
* @param opts
479477
*/
480478
public stopMessaging(opts = { forceDestroy: false }): void {
481-
if (
482-
!opts?.forceDestroy &&
483-
ActiveWidgetStore.instance.getWidgetPersistence(this.mockWidget.id, this.roomId ?? null)
484-
) {
479+
if (this.messaging === null || this.iframe === null) return;
480+
if (opts.forceDestroy) {
481+
// HACK: This is a really dirty way to ensure that Jitsi cleans up
482+
// its hold on the webcam. Without this, the widget holds a media
483+
// stream open, even after death. See https://github.com/vector-im/element-web/issues/7351
484+
// In practice we could just do `+= ''` to trick the browser into
485+
// thinking the URL changed, however I can foresee this being
486+
// optimized out by a browser. Instead, we'll just point the iframe
487+
// at a page that is reasonably safe to use in the event the iframe
488+
// doesn't wink away.
489+
this.iframe!.src = "about:blank";
490+
} else if (ActiveWidgetStore.instance.getWidgetPersistence(this.mockWidget.id, this.roomId ?? null)) {
485491
logger.log("Skipping destroy - persistent widget");
486492
return;
487493
}
488-
if (!this.started) return;
494+
489495
WidgetMessagingStore.instance.stopMessaging(this.mockWidget, this.roomId);
496+
this.messaging?.removeAllListeners(); // Guard against the 'ready' event firing after stopping
490497
this.messaging = null;
498+
this.iframe = null;
491499

492500
SdkContextClass.instance.roomViewStore.off(UPDATE_EVENT, this.onRoomViewStoreUpdate);
493501

0 commit comments

Comments
 (0)