Skip to content

Commit d06cf09

Browse files
authored
Switch secondary React trees to the createRoot API (#28296)
* Switch secondary React trees to the createRoot API Signed-off-by: Michael Telatynski <[email protected]> * Iterate Signed-off-by: Michael Telatynski <[email protected]> * Iterate Signed-off-by: Michael Telatynski <[email protected]> * Add comment Signed-off-by: Michael Telatynski <[email protected]> --------- Signed-off-by: Michael Telatynski <[email protected]>
1 parent 2f8e982 commit d06cf09

File tree

13 files changed

+158
-140
lines changed

13 files changed

+158
-140
lines changed

src/components/views/elements/PersistedElement.tsx

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ Please see LICENSE files in the repository root for full details.
66
*/
77

88
import React, { MutableRefObject, ReactNode, StrictMode } from "react";
9-
import ReactDOM from "react-dom";
9+
import { createRoot, Root } from "react-dom/client";
1010
import { isNullOrUndefined } from "matrix-js-sdk/src/utils";
1111
import { TooltipProvider } from "@vector-im/compound-web";
1212

@@ -24,7 +24,7 @@ export const getPersistKey = (appId: string): string => "widget_" + appId;
2424
// We contain all persisted elements within a master container to allow them all to be within the same
2525
// CSS stacking context, and thus be able to control their z-indexes relative to each other.
2626
function getOrCreateMasterContainer(): HTMLDivElement {
27-
let container = getContainer("mx_PersistedElement_container");
27+
let container = document.getElementById("mx_PersistedElement_container") as HTMLDivElement;
2828
if (!container) {
2929
container = document.createElement("div");
3030
container.id = "mx_PersistedElement_container";
@@ -34,18 +34,10 @@ function getOrCreateMasterContainer(): HTMLDivElement {
3434
return container;
3535
}
3636

37-
function getContainer(containerId: string): HTMLDivElement {
38-
return document.getElementById(containerId) as HTMLDivElement;
39-
}
40-
4137
function getOrCreateContainer(containerId: string): HTMLDivElement {
42-
let container = getContainer(containerId);
43-
44-
if (!container) {
45-
container = document.createElement("div");
46-
container.id = containerId;
47-
getOrCreateMasterContainer().appendChild(container);
48-
}
38+
const container = document.createElement("div");
39+
container.id = containerId;
40+
getOrCreateMasterContainer().appendChild(container);
4941

5042
return container;
5143
}
@@ -83,6 +75,8 @@ export default class PersistedElement extends React.Component<IProps> {
8375
private childContainer?: HTMLDivElement;
8476
private child?: HTMLDivElement;
8577

78+
private static rootMap: Record<string, [root: Root, container: Element]> = {};
79+
8680
public constructor(props: IProps) {
8781
super(props);
8882

@@ -99,14 +93,16 @@ export default class PersistedElement extends React.Component<IProps> {
9993
* @param {string} persistKey Key used to uniquely identify this PersistedElement
10094
*/
10195
public static destroyElement(persistKey: string): void {
102-
const container = getContainer("mx_persistedElement_" + persistKey);
103-
if (container) {
104-
container.remove();
96+
const pair = PersistedElement.rootMap[persistKey];
97+
if (pair) {
98+
pair[0].unmount();
99+
pair[1].remove();
105100
}
101+
delete PersistedElement.rootMap[persistKey];
106102
}
107103

108104
public static isMounted(persistKey: string): boolean {
109-
return Boolean(getContainer("mx_persistedElement_" + persistKey));
105+
return Boolean(PersistedElement.rootMap[persistKey]);
110106
}
111107

112108
private collectChildContainer = (ref: HTMLDivElement): void => {
@@ -179,7 +175,14 @@ export default class PersistedElement extends React.Component<IProps> {
179175
</StrictMode>
180176
);
181177

182-
ReactDOM.render(content, getOrCreateContainer("mx_persistedElement_" + this.props.persistKey));
178+
let rootPair = PersistedElement.rootMap[this.props.persistKey];
179+
if (!rootPair) {
180+
const container = getOrCreateContainer("mx_persistedElement_" + this.props.persistKey);
181+
const root = createRoot(container);
182+
rootPair = [root, container];
183+
PersistedElement.rootMap[this.props.persistKey] = rootPair;
184+
}
185+
rootPair[0].render(content);
183186
}
184187

185188
private updateChildVisibility(child?: HTMLDivElement, visible = false): void {

src/components/views/messages/EditHistoryMessage.tsx

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ import classNames from "classnames";
1313
import * as HtmlUtils from "../../../HtmlUtils";
1414
import { editBodyDiffToHtml } from "../../../utils/MessageDiffUtils";
1515
import { formatTime } from "../../../DateUtils";
16-
import { pillifyLinks, unmountPills } from "../../../utils/pillify";
17-
import { tooltipifyLinks, unmountTooltips } from "../../../utils/tooltipify";
16+
import { pillifyLinks } from "../../../utils/pillify";
17+
import { tooltipifyLinks } from "../../../utils/tooltipify";
1818
import { _t } from "../../../languageHandler";
1919
import Modal from "../../../Modal";
2020
import RedactedBody from "./RedactedBody";
@@ -23,6 +23,7 @@ import ConfirmAndWaitRedactDialog from "../dialogs/ConfirmAndWaitRedactDialog";
2323
import ViewSource from "../../structures/ViewSource";
2424
import SettingsStore from "../../../settings/SettingsStore";
2525
import MatrixClientContext from "../../../contexts/MatrixClientContext";
26+
import { ReactRootManager } from "../../../utils/react";
2627

2728
function getReplacedContent(event: MatrixEvent): IContent {
2829
const originalContent = event.getOriginalContent();
@@ -47,8 +48,8 @@ export default class EditHistoryMessage extends React.PureComponent<IProps, ISta
4748
public declare context: React.ContextType<typeof MatrixClientContext>;
4849

4950
private content = createRef<HTMLDivElement>();
50-
private pills: Element[] = [];
51-
private tooltips: Element[] = [];
51+
private pills = new ReactRootManager();
52+
private tooltips = new ReactRootManager();
5253

5354
public constructor(props: IProps, context: React.ContextType<typeof MatrixClientContext>) {
5455
super(props, context);
@@ -103,7 +104,7 @@ export default class EditHistoryMessage extends React.PureComponent<IProps, ISta
103104
private tooltipifyLinks(): void {
104105
// not present for redacted events
105106
if (this.content.current) {
106-
tooltipifyLinks(this.content.current.children, this.pills, this.tooltips);
107+
tooltipifyLinks(this.content.current.children, this.pills.elements, this.tooltips);
107108
}
108109
}
109110

@@ -113,8 +114,8 @@ export default class EditHistoryMessage extends React.PureComponent<IProps, ISta
113114
}
114115

115116
public componentWillUnmount(): void {
116-
unmountPills(this.pills);
117-
unmountTooltips(this.tooltips);
117+
this.pills.unmount();
118+
this.tooltips.unmount();
118119
const event = this.props.mxEvent;
119120
event.localRedactionEvent()?.off(MatrixEventEvent.Status, this.onAssociatedStatusChanged);
120121
}

src/components/views/messages/TextualBody.tsx

Lines changed: 13 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ Please see LICENSE files in the repository root for full details.
77
*/
88

99
import React, { createRef, SyntheticEvent, MouseEvent, StrictMode } from "react";
10-
import ReactDOM from "react-dom";
1110
import { MsgType } from "matrix-js-sdk/src/matrix";
1211
import { TooltipProvider } from "@vector-im/compound-web";
1312

@@ -17,8 +16,8 @@ import Modal from "../../../Modal";
1716
import dis from "../../../dispatcher/dispatcher";
1817
import { _t } from "../../../languageHandler";
1918
import SettingsStore from "../../../settings/SettingsStore";
20-
import { pillifyLinks, unmountPills } from "../../../utils/pillify";
21-
import { tooltipifyLinks, unmountTooltips } from "../../../utils/tooltipify";
19+
import { pillifyLinks } from "../../../utils/pillify";
20+
import { tooltipifyLinks } from "../../../utils/tooltipify";
2221
import { IntegrationManagers } from "../../../integrations/IntegrationManagers";
2322
import { isPermalinkHost, tryTransformPermalinkToLocalHref } from "../../../utils/permalinks/Permalinks";
2423
import { Action } from "../../../dispatcher/actions";
@@ -36,6 +35,7 @@ import { EditWysiwygComposer } from "../rooms/wysiwyg_composer";
3635
import { IEventTileOps } from "../rooms/EventTile";
3736
import { MatrixClientPeg } from "../../../MatrixClientPeg";
3837
import CodeBlock from "./CodeBlock";
38+
import { ReactRootManager } from "../../../utils/react";
3939

4040
interface IState {
4141
// the URLs (if any) to be previewed with a LinkPreviewWidget inside this TextualBody.
@@ -48,9 +48,9 @@ interface IState {
4848
export default class TextualBody extends React.Component<IBodyProps, IState> {
4949
private readonly contentRef = createRef<HTMLDivElement>();
5050

51-
private pills: Element[] = [];
52-
private tooltips: Element[] = [];
53-
private reactRoots: Element[] = [];
51+
private pills = new ReactRootManager();
52+
private tooltips = new ReactRootManager();
53+
private reactRoots = new ReactRootManager();
5454

5555
private ref = createRef<HTMLDivElement>();
5656

@@ -82,7 +82,7 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
8282
// tooltipifyLinks AFTER calculateUrlPreview because the DOM inside the tooltip
8383
// container is empty before the internal component has mounted so calculateUrlPreview
8484
// won't find any anchors
85-
tooltipifyLinks([content], this.pills, this.tooltips);
85+
tooltipifyLinks([content], [...this.pills.elements, ...this.reactRoots.elements], this.tooltips);
8686

8787
if (this.props.mxEvent.getContent().format === "org.matrix.custom.html") {
8888
// Handle expansion and add buttons
@@ -113,12 +113,11 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
113113
private wrapPreInReact(pre: HTMLPreElement): void {
114114
const root = document.createElement("div");
115115
root.className = "mx_EventTile_pre_container";
116-
this.reactRoots.push(root);
117116

118117
// Insert containing div in place of <pre> block
119118
pre.parentNode?.replaceChild(root, pre);
120119

121-
ReactDOM.render(
120+
this.reactRoots.render(
122121
<StrictMode>
123122
<CodeBlock onHeightChanged={this.props.onHeightChanged}>{pre}</CodeBlock>
124123
</StrictMode>,
@@ -137,16 +136,9 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
137136
}
138137

139138
public componentWillUnmount(): void {
140-
unmountPills(this.pills);
141-
unmountTooltips(this.tooltips);
142-
143-
for (const root of this.reactRoots) {
144-
ReactDOM.unmountComponentAtNode(root);
145-
}
146-
147-
this.pills = [];
148-
this.tooltips = [];
149-
this.reactRoots = [];
139+
this.pills.unmount();
140+
this.tooltips.unmount();
141+
this.reactRoots.unmount();
150142
}
151143

152144
public shouldComponentUpdate(nextProps: Readonly<IBodyProps>, nextState: Readonly<IState>): boolean {
@@ -204,7 +196,8 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
204196
</StrictMode>
205197
);
206198

207-
ReactDOM.render(spoiler, spoilerContainer);
199+
this.reactRoots.render(spoiler, spoilerContainer);
200+
208201
node.parentNode?.replaceChild(spoilerContainer, node);
209202

210203
node = spoilerContainer;

src/utils/exportUtils/HtmlExport.tsx

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,13 @@ Please see LICENSE files in the repository root for full details.
77
*/
88

99
import React from "react";
10-
import ReactDOM from "react-dom";
10+
import { createRoot } from "react-dom/client";
1111
import { Room, MatrixEvent, EventType, MsgType } from "matrix-js-sdk/src/matrix";
1212
import { renderToStaticMarkup } from "react-dom/server";
1313
import { logger } from "matrix-js-sdk/src/logger";
1414
import escapeHtml from "escape-html";
1515
import { TooltipProvider } from "@vector-im/compound-web";
16+
import { defer } from "matrix-js-sdk/src/utils";
1617

1718
import Exporter from "./Exporter";
1819
import { mediaFromMxc } from "../../customisations/Media";
@@ -263,7 +264,7 @@ export default class HTMLExporter extends Exporter {
263264
return wantsDateSeparator(prevEvent.getDate() || undefined, event.getDate() || undefined);
264265
}
265266

266-
public getEventTile(mxEv: MatrixEvent, continuation: boolean): JSX.Element {
267+
public getEventTile(mxEv: MatrixEvent, continuation: boolean, ref?: () => void): JSX.Element {
267268
return (
268269
<div className="mx_Export_EventWrapper" id={mxEv.getId()}>
269270
<MatrixClientContext.Provider value={this.room.client}>
@@ -287,6 +288,7 @@ export default class HTMLExporter extends Exporter {
287288
layout={Layout.Group}
288289
showReadReceipts={false}
289290
getRelationsForEvent={this.getRelationsForEvent}
291+
ref={ref}
290292
/>
291293
</TooltipProvider>
292294
</MatrixClientContext.Provider>
@@ -298,7 +300,10 @@ export default class HTMLExporter extends Exporter {
298300
const avatarUrl = this.getAvatarURL(mxEv);
299301
const hasAvatar = !!avatarUrl;
300302
if (hasAvatar) await this.saveAvatarIfNeeded(mxEv);
301-
const EventTile = this.getEventTile(mxEv, continuation);
303+
// We have to wait for the component to be rendered before we can get the markup
304+
// so pass a deferred as a ref to the component.
305+
const deferred = defer<void>();
306+
const EventTile = this.getEventTile(mxEv, continuation, deferred.resolve);
302307
let eventTileMarkup: string;
303308

304309
if (
@@ -308,9 +313,12 @@ export default class HTMLExporter extends Exporter {
308313
) {
309314
// to linkify textual events, we'll need lifecycle methods which won't be invoked in renderToString
310315
// So, we'll have to render the component into a temporary root element
311-
const tempRoot = document.createElement("div");
312-
ReactDOM.render(EventTile, tempRoot);
313-
eventTileMarkup = tempRoot.innerHTML;
316+
const tempElement = document.createElement("div");
317+
const tempRoot = createRoot(tempElement);
318+
tempRoot.render(EventTile);
319+
await deferred.promise;
320+
eventTileMarkup = tempElement.innerHTML;
321+
tempRoot.unmount();
314322
} else {
315323
eventTileMarkup = renderToStaticMarkup(EventTile);
316324
}

src/utils/pillify.tsx

Lines changed: 7 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ Please see LICENSE files in the repository root for full details.
77
*/
88

99
import React, { StrictMode } from "react";
10-
import ReactDOM from "react-dom";
1110
import { PushProcessor } from "matrix-js-sdk/src/pushprocessor";
1211
import { MatrixClient, MatrixEvent, RuleId } from "matrix-js-sdk/src/matrix";
1312
import { TooltipProvider } from "@vector-im/compound-web";
@@ -16,6 +15,7 @@ import SettingsStore from "../settings/SettingsStore";
1615
import { Pill, pillRoomNotifLen, pillRoomNotifPos, PillType } from "../components/views/elements/Pill";
1716
import { parsePermalink } from "./permalinks/Permalinks";
1817
import { PermalinkParts } from "./permalinks/PermalinkConstructor";
18+
import { ReactRootManager } from "./react";
1919

2020
/**
2121
* A node here is an A element with a href attribute tag.
@@ -48,23 +48,23 @@ const shouldBePillified = (node: Element, href: string, parts: PermalinkParts |
4848
* to turn into pills.
4949
* @param {MatrixEvent} mxEvent - the matrix event which the DOM nodes are
5050
* part of representing.
51-
* @param {Element[]} pills: an accumulator of the DOM nodes which contain
51+
* @param {ReactRootManager} pills - an accumulator of the DOM nodes which contain
5252
* React components which have been mounted as part of this.
5353
* The initial caller should pass in an empty array to seed the accumulator.
5454
*/
5555
export function pillifyLinks(
5656
matrixClient: MatrixClient,
5757
nodes: ArrayLike<Element>,
5858
mxEvent: MatrixEvent,
59-
pills: Element[],
59+
pills: ReactRootManager,
6060
): void {
6161
const room = matrixClient.getRoom(mxEvent.getRoomId()) ?? undefined;
6262
const shouldShowPillAvatar = SettingsStore.getValue("Pill.shouldShowPillAvatar");
6363
let node = nodes[0];
6464
while (node) {
6565
let pillified = false;
6666

67-
if (node.tagName === "PRE" || node.tagName === "CODE" || pills.includes(node)) {
67+
if (node.tagName === "PRE" || node.tagName === "CODE" || pills.elements.includes(node)) {
6868
// Skip code blocks and existing pills
6969
node = node.nextSibling as Element;
7070
continue;
@@ -83,9 +83,9 @@ export function pillifyLinks(
8383
</StrictMode>
8484
);
8585

86-
ReactDOM.render(pill, pillContainer);
86+
pills.render(pill, pillContainer);
87+
8788
node.parentNode?.replaceChild(pillContainer, node);
88-
pills.push(pillContainer);
8989
// Pills within pills aren't going to go well, so move on
9090
pillified = true;
9191

@@ -147,9 +147,8 @@ export function pillifyLinks(
147147
</StrictMode>
148148
);
149149

150-
ReactDOM.render(pill, pillContainer);
150+
pills.render(pill, pillContainer);
151151
roomNotifTextNode.parentNode?.replaceChild(pillContainer, roomNotifTextNode);
152-
pills.push(pillContainer);
153152
}
154153
// Nothing else to do for a text node (and we don't need to advance
155154
// the loop pointer because we did it above)
@@ -165,20 +164,3 @@ export function pillifyLinks(
165164
node = node.nextSibling as Element;
166165
}
167166
}
168-
169-
/**
170-
* Unmount all the pill containers from React created by pillifyLinks.
171-
*
172-
* It's critical to call this after pillifyLinks, otherwise
173-
* Pills will leak, leaking entire DOM trees via the event
174-
* emitter on BaseAvatar as per
175-
* https://github.com/vector-im/element-web/issues/12417
176-
*
177-
* @param {Element[]} pills - array of pill containers whose React
178-
* components should be unmounted.
179-
*/
180-
export function unmountPills(pills: Element[]): void {
181-
for (const pillContainer of pills) {
182-
ReactDOM.unmountComponentAtNode(pillContainer);
183-
}
184-
}

0 commit comments

Comments
 (0)