Skip to content

Commit cfc5400

Browse files
authored
Merge pull request #54744 from callstack-internal/zirgulis/improve-ActiveHoverable-cpu-performance
Improve ActiveHoverable CPU performance
2 parents e5e0ca9 + a1e4533 commit cfc5400

File tree

1 file changed

+53
-93
lines changed

1 file changed

+53
-93
lines changed

src/components/Hoverable/ActiveHoverable.tsx

+53-93
Original file line numberDiff line numberDiff line change
@@ -15,141 +15,101 @@ type OnMouseEvents = Record<MouseEvents, (e: MouseEvent) => void>;
1515

1616
function ActiveHoverable({onHoverIn, onHoverOut, shouldHandleScroll, shouldFreezeCapture, children}: ActiveHoverableProps, outerRef: Ref<HTMLElement>) {
1717
const [isHovered, setIsHovered] = useState(false);
18-
1918
const elementRef = useRef<HTMLElement | null>(null);
2019
const isScrollingRef = useRef(false);
2120
const isHoveredRef = useRef(false);
22-
const isVisibiltyHidden = useRef(false);
21+
const isVisibilityHidden = useRef(false);
2322

2423
const updateIsHovered = useCallback(
2524
(hovered: boolean) => {
25+
if (shouldFreezeCapture) {
26+
return;
27+
}
28+
2629
isHoveredRef.current = hovered;
27-
// Nullish coalescing operator (`??`) wouldn't be appropriate here because
28-
// it's not a matter of providing a default when encountering `null` or `undefined`
29-
// but rather making a decision based on the truthy nature of the complete expressions.
30-
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
31-
if ((shouldHandleScroll && isScrollingRef.current) || shouldFreezeCapture) {
30+
isVisibilityHidden.current = false;
31+
32+
if (shouldHandleScroll && isScrollingRef.current) {
3233
return;
3334
}
35+
3436
setIsHovered(hovered);
37+
38+
if (hovered) {
39+
onHoverIn?.();
40+
} else {
41+
onHoverOut?.();
42+
}
3543
},
36-
[shouldHandleScroll, shouldFreezeCapture],
44+
[shouldHandleScroll, shouldFreezeCapture, onHoverIn, onHoverOut],
3745
);
3846

39-
useEffect(() => {
40-
if (isHovered) {
41-
onHoverIn?.();
42-
} else {
43-
onHoverOut?.();
44-
}
45-
}, [isHovered, onHoverIn, onHoverOut]);
46-
4747
useEffect(() => {
4848
if (!shouldHandleScroll) {
4949
return;
5050
}
5151

5252
const scrollingListener = DeviceEventEmitter.addListener(CONST.EVENTS.SCROLLING, (scrolling: boolean) => {
5353
isScrollingRef.current = scrolling;
54-
if (!isScrollingRef.current) {
55-
setIsHovered(isHoveredRef.current);
54+
if (scrolling && isHovered) {
55+
setIsHovered(false);
56+
onHoverOut?.();
57+
} else if (!scrolling && elementRef.current?.matches(':hover')) {
58+
setIsHovered(true);
59+
onHoverIn?.();
5660
}
5761
});
5862

5963
return () => scrollingListener.remove();
60-
}, [shouldHandleScroll]);
64+
}, [shouldHandleScroll, isHovered, onHoverIn, onHoverOut]);
6165

6266
useEffect(() => {
63-
// Do not mount a listener if the component is not hovered
64-
if (!isHovered) {
65-
return;
66-
}
67-
68-
/**
69-
* Checks the hover state of a component and updates it based on the event target.
70-
* This is necessary to handle cases where the hover state might get stuck due to an unreliable mouseleave trigger,
71-
* such as when an element is removed before the mouseleave event is triggered.
72-
* @param event The hover event object.
73-
*/
74-
const unsetHoveredIfOutside = (event: MouseEvent) => {
75-
// We're also returning early if shouldFreezeCapture is true in order
76-
// to not update the hover state but keep it frozen.
77-
if (!elementRef.current || elementRef.current.contains(event.target as Node) || shouldFreezeCapture) {
78-
return;
67+
const handleVisibilityChange = () => {
68+
if (document.visibilityState === 'hidden') {
69+
isVisibilityHidden.current = true;
70+
setIsHovered(false);
71+
} else {
72+
isVisibilityHidden.current = false;
7973
}
80-
81-
setIsHovered(false);
8274
};
8375

84-
document.addEventListener('mouseover', unsetHoveredIfOutside, true);
85-
86-
return () => document.removeEventListener('mouseover', unsetHoveredIfOutside);
87-
}, [isHovered, elementRef, shouldFreezeCapture]);
76+
document.addEventListener('visibilitychange', handleVisibilityChange);
77+
return () => document.removeEventListener('visibilitychange', handleVisibilityChange);
78+
}, []);
8879

89-
useEffect(() => {
90-
const unsetHoveredWhenDocumentIsHidden = () => {
91-
if (document.visibilityState !== 'hidden') {
80+
const handleMouseEvents = useCallback(
81+
(type: 'enter' | 'leave' | 'blur') => () => {
82+
if (shouldFreezeCapture) {
9283
return;
9384
}
9485

95-
isVisibiltyHidden.current = true;
96-
setIsHovered(false);
97-
};
98-
99-
document.addEventListener('visibilitychange', unsetHoveredWhenDocumentIsHidden);
86+
const newHoverState = type === 'enter';
87+
isHoveredRef.current = newHoverState;
88+
isVisibilityHidden.current = false;
10089

101-
return () => document.removeEventListener('visibilitychange', unsetHoveredWhenDocumentIsHidden);
102-
}, []);
90+
updateIsHovered(newHoverState);
91+
},
92+
[shouldFreezeCapture, updateIsHovered],
93+
);
10394

104-
const child = useMemo(() => getReturnValue(children, !isScrollingRef.current && isHovered), [children, isHovered]);
95+
const child = useMemo(() => getReturnValue(children, isHovered), [children, isHovered]);
10596

106-
const {onMouseEnter, onMouseLeave, onMouseMove, onBlur} = child.props as OnMouseEvents;
97+
const {onMouseEnter, onMouseLeave, onBlur} = child.props as OnMouseEvents;
10798

108-
const hoverAndForwardOnMouseEnter = useCallback(
109-
(e: MouseEvent) => {
110-
isVisibiltyHidden.current = false;
111-
updateIsHovered(true);
99+
return cloneElement(child, {
100+
ref: mergeRefs(elementRef, outerRef, child.ref),
101+
onMouseEnter: (e: MouseEvent) => {
102+
handleMouseEvents('enter')();
112103
onMouseEnter?.(e);
113104
},
114-
[updateIsHovered, onMouseEnter],
115-
);
116-
117-
const unhoverAndForwardOnMouseLeave = useCallback(
118-
(e: MouseEvent) => {
119-
updateIsHovered(false);
105+
onMouseLeave: (e: MouseEvent) => {
106+
handleMouseEvents('leave')();
120107
onMouseLeave?.(e);
121108
},
122-
[updateIsHovered, onMouseLeave],
123-
);
124-
125-
const unhoverAndForwardOnBlur = useCallback(
126-
(event: MouseEvent) => {
127-
// Check if the blur event occurred due to clicking outside the element
128-
// and the wrapperView contains the element that caused the blur and reset isHovered
129-
if (!elementRef.current?.contains(event.target as Node) && !elementRef.current?.contains(event.relatedTarget as Node) && !shouldFreezeCapture) {
130-
setIsHovered(false);
131-
}
132-
133-
onBlur?.(event);
134-
},
135-
[onBlur, shouldFreezeCapture],
136-
);
137-
138-
const handleAndForwardOnMouseMove = useCallback(
139-
(e: MouseEvent) => {
140-
isVisibiltyHidden.current = false;
141-
updateIsHovered(true);
142-
onMouseMove?.(e);
109+
onBlur: (e: MouseEvent) => {
110+
handleMouseEvents('blur')();
111+
onBlur?.(e);
143112
},
144-
[updateIsHovered, onMouseMove],
145-
);
146-
147-
return cloneElement(child, {
148-
ref: mergeRefs(elementRef, outerRef, child.ref),
149-
onMouseEnter: hoverAndForwardOnMouseEnter,
150-
onMouseLeave: unhoverAndForwardOnMouseLeave,
151-
onBlur: unhoverAndForwardOnBlur,
152-
...(isVisibiltyHidden.current ? {onMouseMove: handleAndForwardOnMouseMove} : {}),
153113
});
154114
}
155115

0 commit comments

Comments
 (0)