From 3bc74c7a24b4e5e3de68ac07da3f95be016952d5 Mon Sep 17 00:00:00 2001
From: Vit Horacek <36083550+mountiny@users.noreply.github.com>
Date: Mon, 2 Oct 2023 19:37:08 +0100
Subject: [PATCH] Revert "Hoverable component refactored from class into
 functional approach"

---
 src/components/Hoverable/index.js | 275 +++++++++++++++---------------
 1 file changed, 141 insertions(+), 134 deletions(-)

diff --git a/src/components/Hoverable/index.js b/src/components/Hoverable/index.js
index f39c44b278ae..38ea64952a2c 100644
--- a/src/components/Hoverable/index.js
+++ b/src/components/Hoverable/index.js
@@ -1,73 +1,103 @@
 import _ from 'underscore';
-import React, {useEffect, useCallback, useState, useRef, useMemo, useImperativeHandle} from 'react';
+import React, {Component} from 'react';
 import {DeviceEventEmitter} from 'react-native';
 import {propTypes, defaultProps} from './hoverablePropTypes';
 import * as DeviceCapabilities from '../../libs/DeviceCapabilities';
 import CONST from '../../CONST';
 
-function mapChildren(children, callbackParam) {
-    if (_.isArray(children) && children.length === 1) {
-        return children[0];
-    }
-
-    if (_.isFunction(children)) {
-        return children(callbackParam);
-    }
-
-    return children;
-}
-
 /**
  * It is necessary to create a Hoverable component instead of relying solely on Pressable support for hover state,
  * because nesting Pressables causes issues where the hovered state of the child cannot be easily propagated to the
  * parent. https://github.com/necolas/react-native-web/issues/1875
  */
+class Hoverable extends Component {
+    constructor(props) {
+        super(props);
 
-function InnerHoverable({disabled, onHoverIn, onHoverOut, children, shouldHandleScroll}, outerRef) {
-    const [isHovered, setIsHovered] = useState(false);
-
-    const isScrolling = useRef(false);
-    const isHoveredRef = useRef(false);
-    const ref = useRef(null);
+        this.handleVisibilityChange = this.handleVisibilityChange.bind(this);
+        this.checkHover = this.checkHover.bind(this);
 
-    const updateIsHoveredOnScrolling = useCallback(
-        (hovered) => {
-            if (disabled) {
-                return;
-            }
+        this.state = {
+            isHovered: false,
+        };
 
-            isHoveredRef.current = hovered;
+        this.isHoveredRef = false;
+        this.isScrollingRef = false;
+        this.wrapperView = null;
+    }
 
-            if (shouldHandleScroll && isScrolling.current) {
-                return;
-            }
-            setIsHovered(hovered);
-        },
-        [disabled, shouldHandleScroll],
-    );
+    componentDidMount() {
+        document.addEventListener('visibilitychange', this.handleVisibilityChange);
+        document.addEventListener('mouseover', this.checkHover);
+
+        /**
+         * Only add the scrolling listener if the shouldHandleScroll prop is true
+         * and the scrollingListener is not already set.
+         */
+        if (!this.scrollingListener && this.props.shouldHandleScroll) {
+            this.scrollingListener = DeviceEventEmitter.addListener(CONST.EVENTS.SCROLLING, (scrolling) => {
+                /**
+                 * If user has stopped scrolling and the isHoveredRef is true, then we should update the hover state.
+                 */
+                if (!scrolling && this.isHoveredRef) {
+                    this.setState({isHovered: this.isHoveredRef}, this.props.onHoverIn);
+                } else if (scrolling && this.isHoveredRef) {
+                    /**
+                     * If the user has started scrolling and the isHoveredRef is true, then we should set the hover state to false.
+                     * This is to hide the existing hover and reaction bar.
+                     */
+                    this.setState({isHovered: false}, this.props.onHoverOut);
+                }
+                this.isScrollingRef = scrolling;
+            });
+        }
+    }
 
-    useEffect(() => {
-        const unsetHoveredWhenDocumentIsHidden = () => document.visibilityState === 'hidden' && setIsHovered(false);
+    componentDidUpdate(prevProps) {
+        if (prevProps.disabled === this.props.disabled) {
+            return;
+        }
 
-        document.addEventListener('visibilitychange', unsetHoveredWhenDocumentIsHidden);
+        if (this.props.disabled && this.state.isHovered) {
+            this.setState({isHovered: false});
+        }
+    }
 
-        return () => document.removeEventListener('visibilitychange', unsetHoveredWhenDocumentIsHidden);
-    }, []);
+    componentWillUnmount() {
+        document.removeEventListener('visibilitychange', this.handleVisibilityChange);
+        document.removeEventListener('mouseover', this.checkHover);
+        if (this.scrollingListener) {
+            this.scrollingListener.remove();
+        }
+    }
 
-    useEffect(() => {
-        if (!shouldHandleScroll) {
+    /**
+     * Sets the hover state of this component to true and execute the onHoverIn callback.
+     *
+     * @param {Boolean} isHovered - Whether or not this component is hovered.
+     */
+    setIsHovered(isHovered) {
+        if (this.props.disabled) {
             return;
         }
 
-        const scrollingListener = DeviceEventEmitter.addListener(CONST.EVENTS.SCROLLING, (scrolling) => {
-            isScrolling.current = scrolling;
-            if (!scrolling) {
-                setIsHovered(isHoveredRef.current);
-            }
-        });
+        /**
+         * Capture whther or not the user is hovering over the component.
+         * We will use this to determine if we should update the hover state when the user has stopped scrolling.
+         */
+        this.isHoveredRef = isHovered;
 
-        return () => scrollingListener.remove();
-    }, [shouldHandleScroll]);
+        /**
+         * If the isScrollingRef is true, then the user is scrolling and we should not update the hover state.
+         */
+        if (this.isScrollingRef && this.props.shouldHandleScroll && !this.state.isHovered) {
+            return;
+        }
+
+        if (isHovered !== this.state.isHovered) {
+            this.setState({isHovered}, isHovered ? this.props.onHoverIn : this.props.onHoverOut);
+        }
+    }
 
     /**
      * Checks the hover state of a component and updates it based on the event target.
@@ -75,108 +105,85 @@ function InnerHoverable({disabled, onHoverIn, onHoverOut, children, shouldHandle
      * such as when an element is removed before the mouseleave event is triggered.
      * @param {Event} e - The hover event object.
      */
-    const unsetHoveredIfOutside = useCallback(
-        (e) => {
-            if (!ref.current || !isHovered) {
-                return;
-            }
-
-            if (ref.current.contains(e.target)) {
-                return;
-            }
-
-            setIsHovered(false);
-        },
-        [isHovered],
-    );
-
-    useEffect(() => {
-        if (!DeviceCapabilities.hasHoverSupport()) {
+    checkHover(e) {
+        if (!this.wrapperView || !this.state.isHovered) {
             return;
         }
 
-        document.addEventListener('mouseover', unsetHoveredIfOutside);
+        if (this.wrapperView.contains(e.target)) {
+            return;
+        }
 
-        return () => document.removeEventListener('mouseover', unsetHoveredIfOutside);
-    }, [unsetHoveredIfOutside]);
+        this.setIsHovered(false);
+    }
 
-    useEffect(() => {
-        if (!disabled || !isHovered) {
+    handleVisibilityChange() {
+        if (document.visibilityState !== 'hidden') {
             return;
         }
-        setIsHovered(false);
-    }, [disabled, isHovered]);
 
-    useEffect(() => {
-        if (disabled) {
-            return;
+        this.setIsHovered(false);
+    }
+
+    render() {
+        let child = this.props.children;
+        if (_.isArray(this.props.children) && this.props.children.length === 1) {
+            child = this.props.children[0];
         }
-        if (onHoverIn && isHovered) {
-            return onHoverIn();
+
+        if (_.isFunction(child)) {
+            child = child(this.state.isHovered);
         }
-        if (onHoverOut && !isHovered) {
-            return onHoverOut();
+
+        if (!DeviceCapabilities.hasHoverSupport()) {
+            return child;
         }
-    }, [disabled, isHovered, onHoverIn, onHoverOut]);
-
-    // Expose inner ref to parent through outerRef. This enable us to use ref both in parent and child.
-    useImperativeHandle(outerRef, () => ref.current, []);
-
-    const child = useMemo(() => React.Children.only(mapChildren(children, isHovered)), [children, isHovered]);
-
-    const onMouseEnter = useCallback(
-        (el) => {
-            updateIsHoveredOnScrolling(true);
-
-            if (_.isFunction(child.props.onMouseEnter)) {
-                child.props.onMouseEnter(el);
-            }
-        },
-        [child.props, updateIsHoveredOnScrolling],
-    );
-
-    const onMouseLeave = useCallback(
-        (el) => {
-            updateIsHoveredOnScrolling(false);
-
-            if (_.isFunction(child.props.onMouseLeave)) {
-                child.props.onMouseLeave(el);
-            }
-        },
-        [child.props, updateIsHoveredOnScrolling],
-    );
-
-    const onBlur = useCallback(
-        (el) => {
-            // Check if the blur event occurred due to clicking outside the element
-            // and the wrapperView contains the element that caused the blur and reset isHovered
-            if (!ref.current.contains(el.target) && !ref.current.contains(el.relatedTarget)) {
-                setIsHovered(false);
-            }
-
-            if (_.isFunction(child.props.onBlur)) {
-                child.props.onBlur(el);
-            }
-        },
-        [child.props],
-    );
-
-    if (!DeviceCapabilities.hasHoverSupport()) {
-        return child;
-    }
 
-    return React.cloneElement(child, {
-        ref,
-        onMouseEnter,
-        onMouseLeave,
-        onBlur,
-    });
+        return React.cloneElement(React.Children.only(child), {
+            ref: (el) => {
+                this.wrapperView = el;
+
+                // Call the original ref, if any
+                const {ref} = child;
+                if (_.isFunction(ref)) {
+                    ref(el);
+                    return;
+                }
+
+                if (_.isObject(ref)) {
+                    ref.current = el;
+                }
+            },
+            onMouseEnter: (el) => {
+                this.setIsHovered(true);
+
+                if (_.isFunction(child.props.onMouseEnter)) {
+                    child.props.onMouseEnter(el);
+                }
+            },
+            onMouseLeave: (el) => {
+                this.setIsHovered(false);
+
+                if (_.isFunction(child.props.onMouseLeave)) {
+                    child.props.onMouseLeave(el);
+                }
+            },
+            onBlur: (el) => {
+                // Check if the blur event occurred due to clicking outside the element
+                // and the wrapperView contains the element that caused the blur and reset isHovered
+                if (!this.wrapperView.contains(el.target) && !this.wrapperView.contains(el.relatedTarget)) {
+                    this.setIsHovered(false);
+                }
+
+                if (_.isFunction(child.props.onBlur)) {
+                    child.props.onBlur(el);
+                }
+            },
+        });
+    }
 }
 
-const Hoverable = React.forwardRef(InnerHoverable);
-
 Hoverable.propTypes = propTypes;
 Hoverable.defaultProps = defaultProps;
-Hoverable.displayName = 'Hoverable';
 
 export default Hoverable;
