From 97f9b2494d557171fc44f1914678ee0e14943906 Mon Sep 17 00:00:00 2001 From: Michel Weststrate Date: Wed, 19 Feb 2020 22:15:25 -0800 Subject: [PATCH] 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 --- src/ui/components/Tooltip.tsx | 84 +++---- src/ui/components/TooltipProvider.tsx | 318 +++++++++++++------------- 2 files changed, 193 insertions(+), 209 deletions(-) diff --git a/src/ui/components/Tooltip.tsx b/src/ui/components/Tooltip.tsx index a13000bea..8fe4abd5f 100644 --- a/src/ui/components/Tooltip.tsx +++ b/src/ui/components/Tooltip.tsx @@ -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(); + const isOpen = useRef(false); -export default class Tooltip extends Component { - 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 ( - - {this.props.children} - - ); - } + return ( + + {props.children} + + ); } diff --git a/src/ui/components/TooltipProvider.tsx b/src/ui/components/TooltipProvider.tsx index 28b5b201f..a36d7f528 100644 --- a/src/ui/components/TooltipProvider.tsx +++ b/src/ui/components/TooltipProvider.tsx @@ -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 | 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 = 'auto'; - let top: TopProperty = 'auto'; - let bottom: BottomProperty = 'auto'; - let right: RightProperty = '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 ( - - ); - } - - getTooltipBubble(tooltip: TooltipObject) { - const opts = Object.assign(defaultOptions, tooltip.options); - let left: LeftProperty = 'auto'; - let top: TopProperty = 'auto'; - let bottom: BottomProperty = 'auto'; - let right: RightProperty = '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 ( - - {tooltip.title} - - ); - } - - 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(undefined as any); + +const TooltipProvider: React.FC<{}> = memo(function TooltipProvider({ + children, +}) { + const timeoutID = useRef(); + const [tooltip, setTooltip] = useState(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 ? : null} + + {children} + + + ); +}); + +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 = 'auto'; + let top: TopProperty = 'auto'; + let bottom: BottomProperty = 'auto'; + let right: RightProperty = '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 ( + + ); +} + +function getTooltipBubble(tooltip: TooltipObject) { + const opts = Object.assign(defaultOptions, tooltip.options); + let left: LeftProperty = 'auto'; + let top: TopProperty = 'auto'; + let bottom: BottomProperty = 'auto'; + let right: RightProperty = '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 ( + + {tooltip.title} + + ); }