Skip to content

Commit a147610

Browse files
committed
feat: improve overlay component performance and functionality
- Replaced useDebouncedCallback with requestAnimationFrame for smoother updates in the overlay component. - Added functionality to identify scrollable parent elements, enhancing the overlay's responsiveness to scroll events. - Updated cleanup logic to ensure proper removal of event listeners and prevent memory leaks. - Refactored styles handling to improve visibility management of overlay content based on placement and style props. - Enhanced withOverlay HOC to restore original position style on cleanup, ensuring consistent behavior.
1 parent 50e4ffb commit a147610

3 files changed

Lines changed: 114 additions & 42 deletions

File tree

packages/lib/components/common/overlay.tsx

Lines changed: 97 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import React, {
1010
useState,
1111
} from 'react';
1212
import ResizeObserver from 'resize-observer-polyfill';
13-
import { useDebouncedCallback } from 'use-debounce';
1413
import { OverlayProps } from './overlay-model';
1514
import './overlay.scss';
1615
import { OverlayContext, OverlayContextModel } from './withOverlay';
@@ -45,6 +44,8 @@ const Overlay: React.FunctionComponent<OverlayProps> = ({
4544
const overlayRef = useRef<HTMLDivElement | null>(null);
4645
const overlayContentRef = useRef<HTMLDivElement | null>(null);
4746
const observer = useRef<ResizeObserver | null>(null);
47+
const rafIdRef = useRef<number | null>(null);
48+
const scrollListenersRef = useRef<Set<HTMLElement | Document>>(new Set());
4849
const [retriggerStyleCal, setRetriggerStyleCal] = useState<number>(0);
4950

5051
/**
@@ -55,9 +56,15 @@ const Overlay: React.FunctionComponent<OverlayProps> = ({
5556
width: number;
5657
} | null>(null);
5758

58-
const retrigger = useDebouncedCallback(() => {
59-
setRetriggerStyleCal(new Date().getTime());
60-
}, 5);
59+
const retrigger = useCallback(() => {
60+
if (rafIdRef.current !== null) {
61+
cancelAnimationFrame(rafIdRef.current);
62+
}
63+
rafIdRef.current = requestAnimationFrame(() => {
64+
setRetriggerStyleCal(prev => prev + 1);
65+
rafIdRef.current = null;
66+
});
67+
}, []);
6168

6269
const overlayWrapperClass = useMemo(() => {
6370
return classNames(['rc-overlay-wrapper'], {
@@ -136,18 +143,65 @@ const Overlay: React.FunctionComponent<OverlayProps> = ({
136143
return {} as CSSProperties;
137144
}, [placementReference, retriggerStyleCal, overlayDimensions, placement, context, leftOffset, placementOffset]);
138145

139-
// event handlers
146+
/**
147+
* Finds all scrollable parent elements of the placement reference
148+
*/
149+
const getScrollableParents = useCallback((element: HTMLElement | null): (HTMLElement | Document)[] => {
150+
const scrollableParents: (HTMLElement | Document)[] = [document];
151+
152+
if (!element) return scrollableParents;
153+
154+
let current: HTMLElement | null = element;
155+
while (current && current !== document.body && current !== document.documentElement) {
156+
const style = window.getComputedStyle(current);
157+
const overflowY = style.overflowY;
158+
const overflowX = style.overflowX;
159+
const isScrollable =
160+
(overflowY === 'auto' || overflowY === 'scroll') ||
161+
(overflowX === 'auto' || overflowX === 'scroll');
162+
163+
if (isScrollable && current.scrollHeight > current.clientHeight) {
164+
scrollableParents.push(current);
165+
}
166+
167+
current = current.parentElement;
168+
}
169+
170+
return scrollableParents;
171+
}, []);
172+
173+
/**
174+
* Synchronizes the position of the overlay content with the scroll position
175+
* Uses requestAnimationFrame for smooth, performant updates
176+
*/
177+
const handleScroll = useCallback(() => {
178+
retrigger();
179+
}, [retrigger]);
140180

141181
const closeProcess = useCallback(() => {
142-
document.removeEventListener('scroll', handleWindowScroll);
182+
if (rafIdRef.current !== null) {
183+
cancelAnimationFrame(rafIdRef.current);
184+
rafIdRef.current = null;
185+
}
186+
187+
const eventOptions = { capture: true, passive: true } as AddEventListenerOptions;
188+
scrollListenersRef.current.forEach(listener => {
189+
if (listener === document) {
190+
document.removeEventListener('scroll', handleScroll, eventOptions);
191+
} else {
192+
(listener as HTMLElement).removeEventListener('scroll', handleScroll, eventOptions);
193+
}
194+
});
195+
scrollListenersRef.current.clear();
196+
143197
observer.current?.disconnect();
144198
onClose?.();
145199
setHideOverlay(true);
146200

147201
if (hideDocumentOverflow) {
148202
// document.body.style.overflow = 'auto';
149203
}
150-
}, []);
204+
}, [handleScroll, onClose, hideDocumentOverflow]);
151205

152206
/**
153207
*
@@ -166,7 +220,7 @@ const Overlay: React.FunctionComponent<OverlayProps> = ({
166220
if (context?.childClosing) {
167221
closeProcess();
168222
}
169-
}, [context?.childClosing]);
223+
}, [context?.childClosing, closeProcess]);
170224

171225
/**
172226
* Closes the overlay when click outside of the overlay content
@@ -181,16 +235,7 @@ const Overlay: React.FunctionComponent<OverlayProps> = ({
181235
closeProcess();
182236
}
183237
},
184-
[overlayContentRef]
185-
);
186-
187-
/**
188-
* Synchronizes the position of the overlay content with the scroll position
189-
* (do not auto-close overlays on scroll).
190-
*/
191-
const handleWindowScroll = useDebouncedCallback(
192-
() => setRetriggerStyleCal(new Date().getTime()),
193-
10
238+
[closeProcess]
194239
);
195240

196241
// onMount process
@@ -201,36 +246,58 @@ const Overlay: React.FunctionComponent<OverlayProps> = ({
201246
document.body.style.overflow = 'hidden';
202247
}
203248

204-
document.addEventListener('scroll', handleWindowScroll);
249+
const scrollableParents = getScrollableParents(placementReference?.current || null);
250+
const eventOptions = { capture: true, passive: true } as AddEventListenerOptions;
251+
252+
scrollableParents.forEach(parent => {
253+
if (parent === document) {
254+
document.addEventListener('scroll', handleScroll, eventOptions);
255+
} else {
256+
(parent as HTMLElement).addEventListener('scroll', handleScroll, eventOptions);
257+
}
258+
scrollListenersRef.current.add(parent);
259+
});
205260

206261
if (overlayAnimation) {
207262
setHideOverlay(false);
208263
}
209264

210-
// cleanup
211265
return () => {
212-
document.removeEventListener('scroll', handleWindowScroll);
266+
if (rafIdRef.current !== null) {
267+
cancelAnimationFrame(rafIdRef.current);
268+
rafIdRef.current = null;
269+
}
270+
271+
scrollListenersRef.current.forEach(listener => {
272+
if (listener === document) {
273+
document.removeEventListener('scroll', handleScroll, eventOptions);
274+
} else {
275+
(listener as HTMLElement).removeEventListener('scroll', handleScroll, eventOptions);
276+
}
277+
});
278+
scrollListenersRef.current.clear();
279+
213280
if (hideDocumentOverflow) {
214281
document.body.style.overflow = originalOverflow;
215282
}
216-
// observer.current?.disconnect();
217283
};
218-
}, [hideDocumentOverflow, handleWindowScroll, overlayAnimation]);
284+
}, [hideDocumentOverflow, handleScroll, overlayAnimation, placementReference, getScrollableParents]);
219285

220286
const onRef = useCallback((node: HTMLDivElement) => {
221287
const ele = node as HTMLDivElement;
222288
if (ele) {
223289
overlayRef.current = ele;
224290

291+
if (observer.current) {
292+
observer.current.disconnect();
293+
}
294+
225295
observer.current = new ResizeObserver(retrigger);
226-
227296
observer.current.observe(ele);
228297

229298
onOpen?.();
230-
// setTimeout(() => {
231-
// }, 50);
232299
}
233-
}, []);
300+
}, [retrigger, onOpen]);
234301

235302
const onOverlayRef = useCallback((node: HTMLDivElement) => {
236303
const ele = node as HTMLDivElement;
@@ -249,18 +316,18 @@ const Overlay: React.FunctionComponent<OverlayProps> = ({
249316
* we would want to hide the overlay content until the overlay is positioned correctly.
250317
*/
251318
const customPlacementStyle = useMemo<CSSProperties>(() => {
252-
if (placement && placementStyle) {
319+
if (placement && placementStyle && Object.keys(placementStyle).length > 0) {
253320
return placementStyle;
254321
}
255322

256-
if (placement && !placementStyle) {
323+
if (placement && (!placementStyle || Object.keys(placementStyle).length === 0)) {
257324
return {
258325
visibility: 'hidden',
259326
};
260327
}
261328

262329
return {};
263-
}, [JSON.stringify(placementStyle), placement]);
330+
}, [placementStyle, placement]);
264331

265332
return !disableBackdrop ? (
266333
<div

packages/lib/components/common/withOverlay.tsx

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,15 @@ const withOverlay = function <T extends OverlayModel<U>, U>(
7373
useEffect(() => {
7474
if (containedToParent?.current) {
7575
portalContainer.current = containedToParent.current;
76+
const originalPosition = portalContainer.current.style.position;
7677
portalContainer.current.style.position = 'relative';
7778
setPortalWrapperCreated(true);
78-
return undefined;
79+
80+
return () => {
81+
if (portalContainer.current) {
82+
portalContainer.current.style.position = originalPosition || '';
83+
}
84+
};
7985
} else {
8086
overlayRef.current = document.createElement('div');
8187
overlayRef.current.className = `${classPrefix.current}-portal-wrapper`;
@@ -84,28 +90,27 @@ const withOverlay = function <T extends OverlayModel<U>, U>(
8490
setPortalWrapperCreated(true);
8591

8692
return () => {
87-
document.body.removeChild(overlayRef.current as HTMLElement);
93+
if (overlayRef.current && document.body.contains(overlayRef.current)) {
94+
document.body.removeChild(overlayRef.current);
95+
}
96+
overlayRef.current = null;
8897
};
8998
}
90-
}, []);
99+
}, [containedToParent]);
91100

92101
const handleClose = useCallback(() => {
93102
setIsClosing(true);
94103

95104
if (!disableAnimation) {
96105
setTimeout(() => {
97106
setPortalWrapperCreated(false);
98-
if (onClose) {
99-
onClose();
100-
}
107+
onClose?.();
101108
}, 250);
102109
} else {
103110
setPortalWrapperCreated(false);
104-
if (onClose) {
105-
onClose();
106-
}
111+
onClose?.();
107112
}
108-
}, []);
113+
}, [disableAnimation, onClose]);
109114

110115
const handleChildClose = useCallback(() => setChildInvokedClose(true), []);
111116

packages/lib/components/dropdown/dropdown-menu.module.scss

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,15 @@
2424

2525
&.dark {
2626
@extend %shadow-medium-dark;
27-
background: linear-gradient(135deg, theme.$dark-control-bg 0%, rgba(31, 41, 55, 0.8) 100%);
27+
background: theme.$dark-control-bg;
2828
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.3),
2929
0 10px 10px -5px rgba(0, 0, 0, 0.2),
3030
0 0 0 1px rgba(75, 85, 99, 0.2);
3131
border: 1px solid rgba(75, 85, 99, 0.25);
3232
}
3333

3434
&:not(.dark) {
35-
background: linear-gradient(135deg, theme.$white 0%, rgba(249, 250, 251, 0.8) 100%);
35+
background: theme.$white;
3636
position: relative;
3737

3838
// Polished glow effect for opening animation

0 commit comments

Comments
 (0)