Fix tooltips causing entire app to rerender

Summary:
Hovering a tooltip container would cause the full application to re-render. (Even if there isn't an actual tooltip!).

Fixed it by killing legacy context code, and using proper fragments rather than returning arrays.

Reviewed By: priteshrnandgaonkar

Differential Revision: D19969775

fbshipit-source-id: 59f6470d03b6c476305681fde7bbe3f0dca063aa
This commit is contained in:
Michel Weststrate
2020-02-19 22:15:25 -08:00
committed by Facebook Github Bot
parent 07e5d7faf7
commit 97f9b2494d
2 changed files with 193 additions and 209 deletions

View File

@@ -7,10 +7,9 @@
* @format
*/
import TooltipProvider, {TooltipOptions} from './TooltipProvider';
import {TooltipOptions, TooltipContext} from './TooltipProvider';
import styled from '@emotion/styled';
import React, {Component} from 'react';
import PropTypes from 'prop-types';
import React, {useContext, useCallback, useRef, useEffect} from 'react';
const TooltipContainer = styled.div({
display: 'contents',
@@ -25,59 +24,40 @@ type TooltipProps = {
options?: TooltipOptions;
};
type TooltipState = {
open: boolean;
};
export default function Tooltip(props: TooltipProps) {
const tooltipManager = useContext(TooltipContext);
const ref = useRef<HTMLDivElement | null>();
const isOpen = useRef<boolean>(false);
export default class Tooltip extends Component<TooltipProps, TooltipState> {
static contextTypes = {
TOOLTIP_PROVIDER: PropTypes.object,
};
context!: {
TOOLTIP_PROVIDER: TooltipProvider;
};
ref: HTMLDivElement | undefined | null;
state = {
open: false,
};
componentWillUnmount() {
if (this.state.open === true) {
this.context.TOOLTIP_PROVIDER.close();
useEffect(
() => () => {
if (isOpen.current) {
tooltipManager.close();
}
}
onMouseEnter = () => {
if (this.ref != null) {
this.context.TOOLTIP_PROVIDER.open(
this.ref,
this.props.title,
this.props.options || {},
},
[],
);
this.setState({open: true});
const onMouseEnter = useCallback(() => {
if (ref.current && props.title) {
tooltipManager.open(ref.current, props.title, props.options || {});
isOpen.current = true;
}
};
}, []);
onMouseLeave = () => {
this.context.TOOLTIP_PROVIDER.close();
this.setState({open: false});
};
const onMouseLeave = useCallback(() => {
if (isOpen.current) {
tooltipManager.close();
isOpen.current = false;
}
}, []);
setRef = (ref: HTMLDivElement | null) => {
this.ref = ref;
};
render() {
return (
<TooltipContainer
ref={this.setRef}
onMouseEnter={this.onMouseEnter}
onMouseLeave={this.onMouseLeave}>
{this.props.children}
ref={ref as any}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}>
{props.children}
</TooltipContainer>
);
}
}

View File

@@ -9,8 +9,7 @@
import styled from '@emotion/styled';
import {colors} from './colors';
import {Component} from 'react';
import PropTypes from 'prop-types';
import {memo, createContext, useMemo, useState, useRef} from 'react';
import {
TopProperty,
LeftProperty,
@@ -134,63 +133,84 @@ type TooltipState = {
timeoutID: ReturnType<typeof setTimeout> | null | undefined;
};
export default class TooltipProvider extends Component<
TooltipProps,
TooltipState
> {
static childContextTypes = {
TOOLTIP_PROVIDER: PropTypes.object,
};
interface TooltipManager {
open(
container: HTMLDivElement,
title: React.ReactNode,
options: TooltipOptions,
): void;
close(): void;
}
state: TooltipState = {
tooltip: null,
timeoutID: undefined,
};
getChildContext() {
return {TOOLTIP_PROVIDER: this};
}
export const TooltipContext = createContext<TooltipManager>(undefined as any);
const TooltipProvider: React.FC<{}> = memo(function TooltipProvider({
children,
}) {
const timeoutID = useRef<NodeJS.Timeout>();
const [tooltip, setTooltip] = useState<TooltipObject | undefined>(undefined);
const tooltipManager = useMemo(
() => ({
open(
container: HTMLDivElement,
title: React.ReactNode,
options: TooltipOptions,
) {
if (timeoutID.current) {
clearTimeout(timeoutID.current);
}
const node = container.childNodes[0];
if (node == null || !(node instanceof HTMLElement)) {
return;
}
if (options.delay) {
this.state.timeoutID = setTimeout(() => {
this.setState({
tooltip: {
timeoutID.current = setTimeout(() => {
setTooltip({
rect: node.getBoundingClientRect(),
title,
options: options,
},
});
}, options.delay);
return;
}
this.setState({
tooltip: {
setTooltip({
rect: node.getBoundingClientRect(),
title,
options: options,
},
});
}
},
close() {
if (this.state.timeoutID) {
clearTimeout(this.state.timeoutID);
}
this.setState({tooltip: null});
if (timeoutID.current) {
clearTimeout(timeoutID.current);
}
setTooltip(undefined);
},
}),
[],
);
getTooltipTail(tooltip: TooltipObject) {
return (
<>
{tooltip && tooltip.title ? <Tooltip tooltip={tooltip} /> : null}
<TooltipContext.Provider value={tooltipManager}>
{children}
</TooltipContext.Provider>
</>
);
});
function Tooltip({tooltip}: {tooltip: TooltipObject}) {
return (
<>
{getTooltipTail(tooltip)}
{getTooltipBubble(tooltip)}
</>
);
}
export default TooltipProvider;
function getTooltipTail(tooltip: TooltipObject) {
const opts = Object.assign(defaultOptions, tooltip.options);
if (!opts.showTail) {
return null;
@@ -228,9 +248,9 @@ export default class TooltipProvider extends Component<
options={opts}
/>
);
}
}
getTooltipBubble(tooltip: TooltipObject) {
function getTooltipBubble(tooltip: TooltipObject) {
const opts = Object.assign(defaultOptions, tooltip.options);
let left: LeftProperty<number> = 'auto';
let top: TopProperty<number> = 'auto';
@@ -277,20 +297,4 @@ export default class TooltipProvider extends Component<
{tooltip.title}
</TooltipBubble>
);
}
getTooltipElement() {
const {tooltip} = this.state;
return (
tooltip &&
tooltip.title && [
this.getTooltipTail(tooltip),
this.getTooltipBubble(tooltip),
]
);
}
render() {
return [this.getTooltipElement(), this.props.children];
}
}