Skip to content

Commit

Permalink
Merge pull request #54744 from callstack-internal/zirgulis/improve-Ac…
Browse files Browse the repository at this point in the history
…tiveHoverable-cpu-performance

Improve ActiveHoverable CPU performance
  • Loading branch information
mountiny authored Jan 24, 2025
2 parents e5e0ca9 + a1e4533 commit cfc5400
Showing 1 changed file with 53 additions and 93 deletions.
146 changes: 53 additions & 93 deletions src/components/Hoverable/ActiveHoverable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,141 +15,101 @@ type OnMouseEvents = Record<MouseEvents, (e: MouseEvent) => void>;

function ActiveHoverable({onHoverIn, onHoverOut, shouldHandleScroll, shouldFreezeCapture, children}: ActiveHoverableProps, outerRef: Ref<HTMLElement>) {
const [isHovered, setIsHovered] = useState(false);

const elementRef = useRef<HTMLElement | null>(null);
const isScrollingRef = useRef(false);
const isHoveredRef = useRef(false);
const isVisibiltyHidden = useRef(false);
const isVisibilityHidden = useRef(false);

const updateIsHovered = useCallback(
(hovered: boolean) => {
if (shouldFreezeCapture) {
return;
}

isHoveredRef.current = hovered;
// Nullish coalescing operator (`??`) wouldn't be appropriate here because
// it's not a matter of providing a default when encountering `null` or `undefined`
// but rather making a decision based on the truthy nature of the complete expressions.
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
if ((shouldHandleScroll && isScrollingRef.current) || shouldFreezeCapture) {
isVisibilityHidden.current = false;

if (shouldHandleScroll && isScrollingRef.current) {
return;
}

setIsHovered(hovered);

if (hovered) {
onHoverIn?.();
} else {
onHoverOut?.();
}
},
[shouldHandleScroll, shouldFreezeCapture],
[shouldHandleScroll, shouldFreezeCapture, onHoverIn, onHoverOut],
);

useEffect(() => {
if (isHovered) {
onHoverIn?.();
} else {
onHoverOut?.();
}
}, [isHovered, onHoverIn, onHoverOut]);

useEffect(() => {
if (!shouldHandleScroll) {
return;
}

const scrollingListener = DeviceEventEmitter.addListener(CONST.EVENTS.SCROLLING, (scrolling: boolean) => {
isScrollingRef.current = scrolling;
if (!isScrollingRef.current) {
setIsHovered(isHoveredRef.current);
if (scrolling && isHovered) {
setIsHovered(false);
onHoverOut?.();
} else if (!scrolling && elementRef.current?.matches(':hover')) {
setIsHovered(true);
onHoverIn?.();
}
});

return () => scrollingListener.remove();
}, [shouldHandleScroll]);
}, [shouldHandleScroll, isHovered, onHoverIn, onHoverOut]);

useEffect(() => {
// Do not mount a listener if the component is not hovered
if (!isHovered) {
return;
}

/**
* Checks the hover state of a component and updates it based on the event target.
* This is necessary to handle cases where the hover state might get stuck due to an unreliable mouseleave trigger,
* such as when an element is removed before the mouseleave event is triggered.
* @param event The hover event object.
*/
const unsetHoveredIfOutside = (event: MouseEvent) => {
// We're also returning early if shouldFreezeCapture is true in order
// to not update the hover state but keep it frozen.
if (!elementRef.current || elementRef.current.contains(event.target as Node) || shouldFreezeCapture) {
return;
const handleVisibilityChange = () => {
if (document.visibilityState === 'hidden') {
isVisibilityHidden.current = true;
setIsHovered(false);
} else {
isVisibilityHidden.current = false;
}

setIsHovered(false);
};

document.addEventListener('mouseover', unsetHoveredIfOutside, true);

return () => document.removeEventListener('mouseover', unsetHoveredIfOutside);
}, [isHovered, elementRef, shouldFreezeCapture]);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => document.removeEventListener('visibilitychange', handleVisibilityChange);
}, []);

useEffect(() => {
const unsetHoveredWhenDocumentIsHidden = () => {
if (document.visibilityState !== 'hidden') {
const handleMouseEvents = useCallback(
(type: 'enter' | 'leave' | 'blur') => () => {
if (shouldFreezeCapture) {
return;
}

isVisibiltyHidden.current = true;
setIsHovered(false);
};

document.addEventListener('visibilitychange', unsetHoveredWhenDocumentIsHidden);
const newHoverState = type === 'enter';
isHoveredRef.current = newHoverState;
isVisibilityHidden.current = false;

return () => document.removeEventListener('visibilitychange', unsetHoveredWhenDocumentIsHidden);
}, []);
updateIsHovered(newHoverState);
},
[shouldFreezeCapture, updateIsHovered],
);

const child = useMemo(() => getReturnValue(children, !isScrollingRef.current && isHovered), [children, isHovered]);
const child = useMemo(() => getReturnValue(children, isHovered), [children, isHovered]);

const {onMouseEnter, onMouseLeave, onMouseMove, onBlur} = child.props as OnMouseEvents;
const {onMouseEnter, onMouseLeave, onBlur} = child.props as OnMouseEvents;

const hoverAndForwardOnMouseEnter = useCallback(
(e: MouseEvent) => {
isVisibiltyHidden.current = false;
updateIsHovered(true);
return cloneElement(child, {
ref: mergeRefs(elementRef, outerRef, child.ref),
onMouseEnter: (e: MouseEvent) => {
handleMouseEvents('enter')();
onMouseEnter?.(e);
},
[updateIsHovered, onMouseEnter],
);

const unhoverAndForwardOnMouseLeave = useCallback(
(e: MouseEvent) => {
updateIsHovered(false);
onMouseLeave: (e: MouseEvent) => {
handleMouseEvents('leave')();
onMouseLeave?.(e);
},
[updateIsHovered, onMouseLeave],
);

const unhoverAndForwardOnBlur = useCallback(
(event: MouseEvent) => {
// 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 (!elementRef.current?.contains(event.target as Node) && !elementRef.current?.contains(event.relatedTarget as Node) && !shouldFreezeCapture) {
setIsHovered(false);
}

onBlur?.(event);
},
[onBlur, shouldFreezeCapture],
);

const handleAndForwardOnMouseMove = useCallback(
(e: MouseEvent) => {
isVisibiltyHidden.current = false;
updateIsHovered(true);
onMouseMove?.(e);
onBlur: (e: MouseEvent) => {
handleMouseEvents('blur')();
onBlur?.(e);
},
[updateIsHovered, onMouseMove],
);

return cloneElement(child, {
ref: mergeRefs(elementRef, outerRef, child.ref),
onMouseEnter: hoverAndForwardOnMouseEnter,
onMouseLeave: unhoverAndForwardOnMouseLeave,
onBlur: unhoverAndForwardOnBlur,
...(isVisibiltyHidden.current ? {onMouseMove: handleAndForwardOnMouseMove} : {}),
});
}

Expand Down

0 comments on commit cfc5400

Please sign in to comment.