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,
};
useEffect(
() => () => {
if (isOpen.current) {
tooltipManager.close();
}
},
[],
);
context!: {
TOOLTIP_PROVIDER: TooltipProvider;
};
ref: HTMLDivElement | undefined | null;
state = {
open: false,
};
componentWillUnmount() {
if (this.state.open === true) {
this.context.TOOLTIP_PROVIDER.close();
const onMouseEnter = useCallback(() => {
if (ref.current && props.title) {
tooltipManager.open(ref.current, props.title, props.options || {});
isOpen.current = true;
}
}
}, []);
onMouseEnter = () => {
if (this.ref != null) {
this.context.TOOLTIP_PROVIDER.open(
this.ref,
this.props.title,
this.props.options || {},
);
this.setState({open: true});
const onMouseLeave = useCallback(() => {
if (isOpen.current) {
tooltipManager.close();
isOpen.current = false;
}
};
}, []);
onMouseLeave = () => {
this.context.TOOLTIP_PROVIDER.close();
this.setState({open: false});
};
setRef = (ref: HTMLDivElement | null) => {
this.ref = ref;
};
render() {
return (
<TooltipContainer
ref={this.setRef}
onMouseEnter={this.onMouseEnter}
onMouseLeave={this.onMouseLeave}>
{this.props.children}
</TooltipContainer>
);
}
return (
<TooltipContainer
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,163 +133,168 @@ type TooltipState = {
timeoutID: ReturnType<typeof setTimeout> | null | undefined;
};
export default class TooltipProvider extends Component<
TooltipProps,
TooltipState
> {
static childContextTypes = {
TOOLTIP_PROVIDER: PropTypes.object,
};
state: TooltipState = {
tooltip: null,
timeoutID: undefined,
};
getChildContext() {
return {TOOLTIP_PROVIDER: this};
}
interface TooltipManager {
open(
container: HTMLDivElement,
title: React.ReactNode,
options: TooltipOptions,
) {
const node = container.childNodes[0];
if (node == null || !(node instanceof HTMLElement)) {
return;
}
if (options.delay) {
this.state.timeoutID = setTimeout(() => {
this.setState({
tooltip: {
rect: node.getBoundingClientRect(),
title,
options: options,
},
});
}, options.delay);
return;
}
this.setState({
tooltip: {
rect: node.getBoundingClientRect(),
title,
options: options,
},
});
}
close() {
if (this.state.timeoutID) {
clearTimeout(this.state.timeoutID);
}
this.setState({tooltip: null});
}
getTooltipTail(tooltip: TooltipObject) {
const opts = Object.assign(defaultOptions, tooltip.options);
if (!opts.showTail) {
return null;
}
let left: LeftProperty<number> = 'auto';
let top: TopProperty<number> = 'auto';
let bottom: BottomProperty<number> = 'auto';
let right: RightProperty<number> = 'auto';
if (opts.position === 'below') {
left = tooltip.rect.left + TAIL_AB_POSITION_HORIZONTAL_OFFSET;
top = tooltip.rect.bottom;
} else if (opts.position === 'above') {
left = tooltip.rect.left + TAIL_AB_POSITION_HORIZONTAL_OFFSET;
bottom = window.innerHeight - tooltip.rect.top;
} else if (opts.position === 'toRight') {
left = tooltip.rect.right + TAIL_LR_POSITION_HORIZONTAL_OFFSET;
top = tooltip.rect.top;
} else if (opts.position === 'toLeft') {
right =
window.innerWidth -
tooltip.rect.left +
TAIL_LR_POSITION_HORIZONTAL_OFFSET;
top = tooltip.rect.top;
}
return (
<TooltipTail
key="tail"
top={top}
left={left}
bottom={bottom}
right={right}
options={opts}
/>
);
}
getTooltipBubble(tooltip: TooltipObject) {
const opts = Object.assign(defaultOptions, tooltip.options);
let left: LeftProperty<number> = 'auto';
let top: TopProperty<number> = 'auto';
let bottom: BottomProperty<number> = 'auto';
let right: RightProperty<number> = 'auto';
if (opts.position === 'below') {
left = tooltip.rect.left;
top = tooltip.rect.bottom;
if (opts.showTail) {
top += BUBBLE_SHOWTAIL_OFFSET;
}
} else if (opts.position === 'above') {
bottom = window.innerHeight - tooltip.rect.top;
if (opts.showTail) {
bottom += BUBBLE_SHOWTAIL_OFFSET;
}
left = tooltip.rect.left;
} else if (opts.position === 'toRight') {
left = tooltip.rect.right + BUBBLE_LR_POSITION_HORIZONTAL_OFFSET;
if (opts.showTail) {
left += BUBBLE_SHOWTAIL_OFFSET;
}
top = tooltip.rect.top + BUBBLE_BELOW_POSITION_VERTICAL_OFFSET;
} else if (opts.position === 'toLeft') {
right =
window.innerWidth -
tooltip.rect.left +
BUBBLE_LR_POSITION_HORIZONTAL_OFFSET;
if (opts.showTail) {
right += BUBBLE_SHOWTAIL_OFFSET;
}
top = tooltip.rect.top + BUBBLE_BELOW_POSITION_VERTICAL_OFFSET;
}
return (
<TooltipBubble
key="bubble"
top={top}
left={left}
bottom={bottom}
right={right}
options={opts}>
{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];
}
): void;
close(): void;
}
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) {
timeoutID.current = setTimeout(() => {
setTooltip({
rect: node.getBoundingClientRect(),
title,
options: options,
});
}, options.delay);
return;
}
setTooltip({
rect: node.getBoundingClientRect(),
title,
options: options,
});
},
close() {
if (timeoutID.current) {
clearTimeout(timeoutID.current);
}
setTooltip(undefined);
},
}),
[],
);
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;
}
let left: LeftProperty<number> = 'auto';
let top: TopProperty<number> = 'auto';
let bottom: BottomProperty<number> = 'auto';
let right: RightProperty<number> = 'auto';
if (opts.position === 'below') {
left = tooltip.rect.left + TAIL_AB_POSITION_HORIZONTAL_OFFSET;
top = tooltip.rect.bottom;
} else if (opts.position === 'above') {
left = tooltip.rect.left + TAIL_AB_POSITION_HORIZONTAL_OFFSET;
bottom = window.innerHeight - tooltip.rect.top;
} else if (opts.position === 'toRight') {
left = tooltip.rect.right + TAIL_LR_POSITION_HORIZONTAL_OFFSET;
top = tooltip.rect.top;
} else if (opts.position === 'toLeft') {
right =
window.innerWidth -
tooltip.rect.left +
TAIL_LR_POSITION_HORIZONTAL_OFFSET;
top = tooltip.rect.top;
}
return (
<TooltipTail
key="tail"
top={top}
left={left}
bottom={bottom}
right={right}
options={opts}
/>
);
}
function getTooltipBubble(tooltip: TooltipObject) {
const opts = Object.assign(defaultOptions, tooltip.options);
let left: LeftProperty<number> = 'auto';
let top: TopProperty<number> = 'auto';
let bottom: BottomProperty<number> = 'auto';
let right: RightProperty<number> = 'auto';
if (opts.position === 'below') {
left = tooltip.rect.left;
top = tooltip.rect.bottom;
if (opts.showTail) {
top += BUBBLE_SHOWTAIL_OFFSET;
}
} else if (opts.position === 'above') {
bottom = window.innerHeight - tooltip.rect.top;
if (opts.showTail) {
bottom += BUBBLE_SHOWTAIL_OFFSET;
}
left = tooltip.rect.left;
} else if (opts.position === 'toRight') {
left = tooltip.rect.right + BUBBLE_LR_POSITION_HORIZONTAL_OFFSET;
if (opts.showTail) {
left += BUBBLE_SHOWTAIL_OFFSET;
}
top = tooltip.rect.top + BUBBLE_BELOW_POSITION_VERTICAL_OFFSET;
} else if (opts.position === 'toLeft') {
right =
window.innerWidth -
tooltip.rect.left +
BUBBLE_LR_POSITION_HORIZONTAL_OFFSET;
if (opts.showTail) {
right += BUBBLE_SHOWTAIL_OFFSET;
}
top = tooltip.rect.top + BUBBLE_BELOW_POSITION_VERTICAL_OFFSET;
}
return (
<TooltipBubble
key="bubble"
top={top}
left={left}
bottom={bottom}
right={right}
options={opts}>
{tooltip.title}
</TooltipBubble>
);
}