Attempt to fix Failed to execute 'removeChild' on 'Node' (#9196)

* Switch tooltips to use React Portals

* Remove redundant React key to simplify reconciliation

* Fix cleanup and it.each test

* Update snapshots due to style order difference
This commit is contained in:
Michael Telatynski 2022-08-17 14:35:33 +01:00 committed by GitHub
parent 3461573df9
commit 27a7263965
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 66 additions and 65 deletions

View file

@ -632,13 +632,13 @@ export function topicToHtml(
emojiBodyElements = formatEmojis(topic, false); emojiBodyElements = formatEmojis(topic, false);
} }
return isFormattedTopic ? return isFormattedTopic
<span ? <span
key="body"
ref={ref} ref={ref}
dangerouslySetInnerHTML={{ __html: safeTopic }} dangerouslySetInnerHTML={{ __html: safeTopic }}
dir="auto" dir="auto"
/> : <span key="body" ref={ref} dir="auto"> />
: <span ref={ref} dir="auto">
{ emojiBodyElements || topic } { emojiBodyElements || topic }
</span>; </span>;
} }

View file

@ -52,8 +52,10 @@ export interface ITooltipProps {
maxParentWidth?: number; maxParentWidth?: number;
} }
export default class Tooltip extends React.Component<ITooltipProps> { type State = Partial<Pick<CSSProperties, "display" | "right" | "top" | "transform" | "left">>;
private tooltipContainer: HTMLElement;
export default class Tooltip extends React.PureComponent<ITooltipProps, State> {
private static container: HTMLElement;
private parent: Element; private parent: Element;
// XXX: This is because some components (Field) are unable to `import` the Tooltip class, // XXX: This is because some components (Field) are unable to `import` the Tooltip class,
@ -65,37 +67,47 @@ export default class Tooltip extends React.Component<ITooltipProps> {
alignment: Alignment.Natural, alignment: Alignment.Natural,
}; };
// Create a wrapper for the tooltip outside the parent and attach it to the body element constructor(props) {
super(props);
this.state = {};
// Create a wrapper for the tooltips and attach it to the body element
if (!Tooltip.container) {
Tooltip.container = document.createElement("div");
Tooltip.container.className = "mx_Tooltip_wrapper";
document.body.appendChild(Tooltip.container);
}
}
public componentDidMount() { public componentDidMount() {
this.tooltipContainer = document.createElement("div"); window.addEventListener('scroll', this.updatePosition, {
this.tooltipContainer.className = "mx_Tooltip_wrapper";
document.body.appendChild(this.tooltipContainer);
window.addEventListener('scroll', this.renderTooltip, {
passive: true, passive: true,
capture: true, capture: true,
}); });
this.parent = ReactDOM.findDOMNode(this).parentNode as Element; this.parent = ReactDOM.findDOMNode(this).parentNode as Element;
this.renderTooltip(); this.updatePosition();
} }
public componentDidUpdate() { public componentDidUpdate() {
this.renderTooltip(); this.updatePosition();
} }
// Remove the wrapper element, as the tooltip has finished using it // Remove the wrapper element, as the tooltip has finished using it
public componentWillUnmount() { public componentWillUnmount() {
ReactDOM.unmountComponentAtNode(this.tooltipContainer); window.removeEventListener('scroll', this.updatePosition, {
document.body.removeChild(this.tooltipContainer);
window.removeEventListener('scroll', this.renderTooltip, {
capture: true, capture: true,
}); });
} }
// Add the parent's position to the tooltips, so it's correctly // Add the parent's position to the tooltips, so it's correctly
// positioned, also taking into account any window zoom // positioned, also taking into account any window zoom
private updatePosition(style: CSSProperties) { private updatePosition = (): void => {
// When the tooltip is hidden, no need to thrash the DOM with `style` attribute updates (performance)
if (!this.props.visible) return;
const parentBox = this.parent.getBoundingClientRect(); const parentBox = this.parent.getBoundingClientRect();
const width = UIStore.instance.windowWidth; const width = UIStore.instance.windowWidth;
const spacing = 6; const spacing = 6;
@ -112,6 +124,7 @@ export default class Tooltip extends React.Component<ITooltipProps> {
parentBox.left - window.scrollX + (parentWidth / 2) parentBox.left - window.scrollX + (parentWidth / 2)
); );
const style: State = {};
switch (this.props.alignment) { switch (this.props.alignment) {
case Alignment.Natural: case Alignment.Natural:
if (parentBox.right > width / 2) { if (parentBox.right > width / 2) {
@ -153,25 +166,20 @@ export default class Tooltip extends React.Component<ITooltipProps> {
break; break;
} }
return style; this.setState(style);
} };
private renderTooltip = () => {
let style: CSSProperties = {};
// When the tooltip is hidden, no need to thrash the DOM with `style`
// attribute updates (performance)
if (this.props.visible) {
style = this.updatePosition({});
}
// Hide the entire container when not visible. This prevents flashing of the tooltip
// if it is not meant to be visible on first mount.
style.display = this.props.visible ? "block" : "none";
public render() {
const tooltipClasses = classNames("mx_Tooltip", this.props.tooltipClassName, { const tooltipClasses = classNames("mx_Tooltip", this.props.tooltipClassName, {
"mx_Tooltip_visible": this.props.visible, "mx_Tooltip_visible": this.props.visible,
"mx_Tooltip_invisible": !this.props.visible, "mx_Tooltip_invisible": !this.props.visible,
}); });
const style = { ...this.state };
// Hide the entire container when not visible.
// This prevents flashing of the tooltip if it is not meant to be visible on first mount.
style.display = this.props.visible ? "block" : "none";
const tooltip = ( const tooltip = (
<div className={tooltipClasses} style={style}> <div className={tooltipClasses} style={style}>
<div className="mx_Tooltip_chevron" /> <div className="mx_Tooltip_chevron" />
@ -179,14 +187,10 @@ export default class Tooltip extends React.Component<ITooltipProps> {
</div> </div>
); );
// Render the tooltip manually, as we wish it not to be rendered within the parent
ReactDOM.render<Element>(tooltip, this.tooltipContainer);
};
public render() {
// Render a placeholder
return ( return (
<div className={this.props.className} /> <div className={this.props.className}>
{ ReactDOM.createPortal(tooltip, Tooltip.container) }
</div>
); );
} }
} }

View file

@ -35,6 +35,14 @@ describe('<TooltipTarget />', () => {
'data-test-id': 'test', 'data-test-id': 'test',
}; };
afterEach(() => {
// clean up renderer tooltips
const wrapper = document.querySelector('.mx_Tooltip_wrapper');
while (wrapper?.firstChild) {
wrapper.removeChild(wrapper.lastChild);
}
});
const getComponent = (props = {}) => { const getComponent = (props = {}) => {
const wrapper = renderIntoDocument<HTMLSpanElement>( const wrapper = renderIntoDocument<HTMLSpanElement>(
// wrap in element so renderIntoDocument can render functional component // wrap in element so renderIntoDocument can render functional component
@ -49,31 +57,20 @@ describe('<TooltipTarget />', () => {
const getVisibleTooltip = () => document.querySelector('.mx_Tooltip.mx_Tooltip_visible'); const getVisibleTooltip = () => document.querySelector('.mx_Tooltip.mx_Tooltip_visible');
afterEach(() => {
// clean up visible tooltips
const tooltipWrapper = document.querySelector('.mx_Tooltip_wrapper');
if (tooltipWrapper) {
document.body.removeChild(tooltipWrapper);
}
});
it('renders container', () => { it('renders container', () => {
const component = getComponent(); const component = getComponent();
expect(component).toMatchSnapshot(); expect(component).toMatchSnapshot();
expect(getVisibleTooltip()).toBeFalsy(); expect(getVisibleTooltip()).toBeFalsy();
}); });
for (const alignment in Alignment) { const alignmentKeys = Object.keys(Alignment).filter((o: any) => isNaN(o));
if (isNaN(Number(alignment))) { it.each(alignmentKeys)("displays %s aligned tooltip on mouseover", async (alignment) => {
it(`displays ${alignment} aligned tooltip on mouseover`, () => {
const wrapper = getComponent({ alignment: Alignment[alignment] }); const wrapper = getComponent({ alignment: Alignment[alignment] });
act(() => { act(() => {
Simulate.mouseOver(wrapper); Simulate.mouseOver(wrapper);
}); });
expect(getVisibleTooltip()).toMatchSnapshot(); expect(getVisibleTooltip()).toMatchSnapshot();
}); });
}
}
it('hides tooltip on mouseleave', () => { it('hides tooltip on mouseleave', () => {
const wrapper = getComponent(); const wrapper = getComponent();
@ -101,8 +98,8 @@ describe('<TooltipTarget />', () => {
Simulate.focus(wrapper); Simulate.focus(wrapper);
}); });
expect(getVisibleTooltip()).toBeTruthy(); expect(getVisibleTooltip()).toBeTruthy();
await act(async () => { act(() => {
await Simulate.blur(wrapper); Simulate.blur(wrapper);
}); });
expect(getVisibleTooltip()).toBeFalsy(); expect(getVisibleTooltip()).toBeFalsy();
}); });

View file

@ -3,7 +3,7 @@
exports[`<TooltipTarget /> displays Bottom aligned tooltip on mouseover 1`] = ` exports[`<TooltipTarget /> displays Bottom aligned tooltip on mouseover 1`] = `
<div <div
class="mx_Tooltip test tooltipClassName mx_Tooltip_visible" class="mx_Tooltip test tooltipClassName mx_Tooltip_visible"
style="top: 6px; left: 0px; transform: translate(-50%); display: block;" style="display: block; top: 6px; left: 0px; transform: translate(-50%);"
> >
<div <div
class="mx_Tooltip_chevron" class="mx_Tooltip_chevron"
@ -15,7 +15,7 @@ exports[`<TooltipTarget /> displays Bottom aligned tooltip on mouseover 1`] = `
exports[`<TooltipTarget /> displays InnerBottom aligned tooltip on mouseover 1`] = ` exports[`<TooltipTarget /> displays InnerBottom aligned tooltip on mouseover 1`] = `
<div <div
class="mx_Tooltip test tooltipClassName mx_Tooltip_visible" class="mx_Tooltip test tooltipClassName mx_Tooltip_visible"
style="top: -50px; left: 0px; transform: translate(-50%); display: block;" style="display: block; top: -50px; left: 0px; transform: translate(-50%);"
> >
<div <div
class="mx_Tooltip_chevron" class="mx_Tooltip_chevron"
@ -27,7 +27,7 @@ exports[`<TooltipTarget /> displays InnerBottom aligned tooltip on mouseover 1`]
exports[`<TooltipTarget /> displays Left aligned tooltip on mouseover 1`] = ` exports[`<TooltipTarget /> displays Left aligned tooltip on mouseover 1`] = `
<div <div
class="mx_Tooltip test tooltipClassName mx_Tooltip_visible" class="mx_Tooltip test tooltipClassName mx_Tooltip_visible"
style="right: 1030px; top: 0px; transform: translateY(-50%); display: block;" style="display: block; right: 1030px; top: 0px; transform: translateY(-50%);"
> >
<div <div
class="mx_Tooltip_chevron" class="mx_Tooltip_chevron"
@ -39,7 +39,7 @@ exports[`<TooltipTarget /> displays Left aligned tooltip on mouseover 1`] = `
exports[`<TooltipTarget /> displays Natural aligned tooltip on mouseover 1`] = ` exports[`<TooltipTarget /> displays Natural aligned tooltip on mouseover 1`] = `
<div <div
class="mx_Tooltip test tooltipClassName mx_Tooltip_visible" class="mx_Tooltip test tooltipClassName mx_Tooltip_visible"
style="left: 6px; top: 0px; transform: translateY(-50%); display: block;" style="display: block; left: 6px; top: 0px; transform: translateY(-50%);"
> >
<div <div
class="mx_Tooltip_chevron" class="mx_Tooltip_chevron"
@ -51,7 +51,7 @@ exports[`<TooltipTarget /> displays Natural aligned tooltip on mouseover 1`] = `
exports[`<TooltipTarget /> displays Right aligned tooltip on mouseover 1`] = ` exports[`<TooltipTarget /> displays Right aligned tooltip on mouseover 1`] = `
<div <div
class="mx_Tooltip test tooltipClassName mx_Tooltip_visible" class="mx_Tooltip test tooltipClassName mx_Tooltip_visible"
style="left: 6px; top: 0px; transform: translateY(-50%); display: block;" style="display: block; left: 6px; top: 0px; transform: translateY(-50%);"
> >
<div <div
class="mx_Tooltip_chevron" class="mx_Tooltip_chevron"
@ -63,7 +63,7 @@ exports[`<TooltipTarget /> displays Right aligned tooltip on mouseover 1`] = `
exports[`<TooltipTarget /> displays Top aligned tooltip on mouseover 1`] = ` exports[`<TooltipTarget /> displays Top aligned tooltip on mouseover 1`] = `
<div <div
class="mx_Tooltip test tooltipClassName mx_Tooltip_visible" class="mx_Tooltip test tooltipClassName mx_Tooltip_visible"
style="top: -6px; left: 0px; transform: translate(-50%, -100%); display: block;" style="display: block; top: -6px; left: 0px; transform: translate(-50%, -100%);"
> >
<div <div
class="mx_Tooltip_chevron" class="mx_Tooltip_chevron"
@ -75,7 +75,7 @@ exports[`<TooltipTarget /> displays Top aligned tooltip on mouseover 1`] = `
exports[`<TooltipTarget /> displays TopRight aligned tooltip on mouseover 1`] = ` exports[`<TooltipTarget /> displays TopRight aligned tooltip on mouseover 1`] = `
<div <div
class="mx_Tooltip test tooltipClassName mx_Tooltip_visible" class="mx_Tooltip test tooltipClassName mx_Tooltip_visible"
style="top: -6px; right: 1024px; transform: translateY(-100%); display: block;" style="display: block; top: -6px; right: 1024px; transform: translateY(-100%);"
> >
<div <div
class="mx_Tooltip_chevron" class="mx_Tooltip_chevron"