Skip to content

Commit 0611d09

Browse files
SergeySergey
authored andcommitted
fix calendar year scroll memory leak
1 parent f8aa765 commit 0611d09

10 files changed

Lines changed: 192 additions & 97 deletions

File tree

apps/core/src/features/calendar/CalendarYearScroll/CalendarYear/CalendarYear.tsx

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,61 @@
11
import { Text } from '@gravity-ui/uikit';
22
import { CalendarYear as CalendarYearModel, CalendarMonth as CalendarMonthModel } from '@repo/models';
33
import { ICalendarYear, YearID } from '@repo/types';
4-
import { memo } from 'react';
4+
import { memo, useMemo } from 'react';
55

66
import CalendarMonth from './CalendarMonth';
77

88
import s from './CalendarYear.module.scss';
99

10-
const MAX_CACHE_SIZE = 100;
11-
const modelCache = new Map<string, ICalendarYear>();
10+
const MAX_YEARS_CACHE_SIZE = 100;
11+
const MAX_MONTHS_CACHE_SIZE = MAX_YEARS_CACHE_SIZE * 12;
12+
13+
const yearModelCache = new Map<string, ICalendarYear>();
14+
const monthModelCache = new Map<string, CalendarMonthModel>();
1215

1316
const getYearModel = (yearId: string): CalendarYearModel => {
14-
const existing = modelCache.get(yearId);
17+
const existing = yearModelCache.get(yearId);
1518

1619
if (existing) {
17-
modelCache.set(yearId, existing);
18-
1920
return existing;
2021
}
2122

2223
const model = new CalendarYearModel(yearId);
2324

24-
modelCache.set(yearId, model);
25+
yearModelCache.set(yearId, model);
26+
27+
if (yearModelCache.size > MAX_YEARS_CACHE_SIZE) {
28+
const [oldestKey] = yearModelCache.keys();
29+
30+
yearModelCache.delete(oldestKey);
31+
}
32+
33+
return model;
34+
};
35+
36+
const getMonthModel = (monthId: string): CalendarMonthModel => {
37+
const existing = monthModelCache.get(monthId);
38+
39+
if (existing) {
40+
return existing;
41+
}
42+
43+
const model = new CalendarMonthModel(monthId);
44+
45+
monthModelCache.set(monthId, model);
2546

26-
if (modelCache.size > MAX_CACHE_SIZE) {
27-
const [oldestKey] = modelCache.keys();
47+
if (monthModelCache.size > MAX_MONTHS_CACHE_SIZE) {
48+
const [oldestKey] = monthModelCache.keys();
2849

29-
modelCache.delete(oldestKey);
50+
monthModelCache.delete(oldestKey);
3051
}
3152

3253
return model;
3354
};
3455

3556
const CalendarYear: React.FC<{ yearId: YearID }> = ({ yearId }) => {
3657
const model = getYearModel(yearId);
37-
const monthsModels = model.getMonthsKeys().map((monthId) => new CalendarMonthModel(monthId));
58+
const monthsModels = useMemo(() => model.getMonthsKeys().map(getMonthModel), [model]);
3859

3960
return (
4061
<div className={s.root}>
Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,33 @@
11
import { VirtualListVertical } from '@repo/ui';
22
import { yearID } from '@repo/utils/calendar';
3-
import React, { CSSProperties, useCallback } from 'react';
3+
import React, { CSSProperties, memo, useCallback, useMemo } from 'react';
44
import { Temporal } from 'temporal-polyfill';
55

66
import CalendarYear from './CalendarYear';
77
import { useContainerHeight } from './hooks/useContainerHeight';
88

99
import s from './CalendarYearScroll.module.scss';
1010

11-
const NOW = Temporal.Now.plainDateTimeISO();
12-
const START_YEAR = NOW.year;
1311
const TOTAL_YEARS = 500;
1412
const MIDDLE_INDEX = Math.floor(TOTAL_YEARS / 2);
1513

16-
const getYearFromIndex = (index: number) => {
17-
return START_YEAR + (index - MIDDLE_INDEX);
18-
};
19-
2014
const CalendarYearScroll: React.FC = () => {
2115
const { containerRef, containerHeight } = useContainerHeight();
2216

23-
const renderItem = useCallback((index: number, style: CSSProperties) => {
24-
return (
25-
<div style={style} data-index={index}>
26-
<CalendarYear yearId={yearID(getYearFromIndex(index))} />
27-
</div>
28-
);
29-
}, []);
17+
const startYear = useMemo(() => Temporal.Now.plainDateTimeISO().year, []);
18+
19+
const getYearFromIndex = useCallback((index: number) => startYear + (index - MIDDLE_INDEX), [startYear]);
20+
21+
const renderItem = useCallback(
22+
(index: number, style: CSSProperties) => {
23+
return (
24+
<div style={style} data-index={index}>
25+
<CalendarYear yearId={yearID(getYearFromIndex(index))} />
26+
</div>
27+
);
28+
},
29+
[getYearFromIndex],
30+
);
3031

3132
return (
3233
<div ref={containerRef} className={s.root}>
@@ -44,4 +45,4 @@ const CalendarYearScroll: React.FC = () => {
4445
);
4546
};
4647

47-
export default CalendarYearScroll;
48+
export default memo(CalendarYearScroll);

apps/core/src/features/calendar/CalendarYearScroll/hooks/useContainerHeight.tsx

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,28 @@
11
import { debounce } from '@repo/utils/debounce';
2-
import { useEffect, useState, useRef, useMemo } from 'react';
2+
import { useEffect, useState, useRef } from 'react';
33

44
export const useContainerHeight = (debounceDelay = 100) => {
55
const containerRef = useRef<HTMLDivElement>(null);
66
const [containerHeight, setContainerHeight] = useState(0);
77

8-
const handleResize = useMemo(
9-
() =>
10-
debounce((height: number) => {
11-
if (height > 0 && height !== containerHeight) {
12-
setContainerHeight(height);
13-
}
14-
}, debounceDelay),
15-
[debounceDelay, containerHeight],
16-
);
17-
188
useEffect(() => {
199
if (!containerRef.current) {
2010
return;
2111
}
2212

13+
const debouncedSetHeight = debounce((height: number) => {
14+
setContainerHeight((prev) => {
15+
if (height > 0 && height !== prev) {
16+
return height;
17+
}
18+
19+
return prev;
20+
});
21+
}, debounceDelay);
22+
2323
const resizeObserver = new ResizeObserver((entries) => {
2424
for (const entry of entries) {
25-
const newHeight = entry.contentRect.height;
26-
27-
handleResize(newHeight);
25+
debouncedSetHeight(entry.contentRect.height);
2826
}
2927
});
3028

@@ -36,7 +34,10 @@ export const useContainerHeight = (debounceDelay = 100) => {
3634
setContainerHeight(initialHeight);
3735
}
3836

39-
return () => resizeObserver.disconnect();
37+
return () => {
38+
resizeObserver.disconnect();
39+
debouncedSetHeight.cancel();
40+
};
4041
// eslint-disable-next-line react-hooks/exhaustive-deps
4142
}, []);
4243

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
.root {
2+
position: absolute;
3+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { CSSProperties, memo, ReactNode } from 'react';
2+
3+
import s from './VirtualListItem.module.scss';
4+
5+
type VirtualItemProps = {
6+
index: number;
7+
style: CSSProperties;
8+
itemHeight: number;
9+
renderItem: (index: number, style: CSSProperties) => ReactNode;
10+
};
11+
12+
const VirtualListItem = ({ index, style, itemHeight, renderItem }: VirtualItemProps) => {
13+
return (
14+
<div data-index={index} className={s.root} style={style}>
15+
{renderItem(index, {
16+
height: itemHeight,
17+
width: '100%',
18+
})}
19+
</div>
20+
);
21+
};
22+
23+
export default memo(VirtualListItem, (prev, next) => {
24+
return prev.index === next.index && prev.itemHeight === next.itemHeight;
25+
});
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default as VirtualListItem } from './VirtualListItem';

packages/ui/src/components/VirtualListVertical/VirtualListVertical.module.scss

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,3 @@
66
position: relative;
77
}
88
}
9-
10-
.item {
11-
position: absolute;
12-
}

packages/ui/src/components/VirtualListVertical/VirtualListVertical.tsx

Lines changed: 31 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import clsx from 'clsx';
2-
import { useRef, useState, UIEvent, useCallback, memo, useEffect, useMemo, CSSProperties, ReactNode } from 'react';
2+
import { memo, CSSProperties, ReactNode } from 'react';
3+
4+
import { VirtualListItem } from './VirtualListItem';
5+
import { useVirtualList } from './hooks/useVirtualList';
36

47
import s from './VirtualListVertical.module.scss';
58

@@ -26,54 +29,27 @@ const VirtualListVertical: React.FC<VirtualListVerticalProps> = ({
2629
renderItem,
2730
initialScrollOffset = 0,
2831
}) => {
29-
const containerRef = useRef<HTMLDivElement>(null);
30-
const prevScrollTop = useRef(initialScrollOffset);
31-
const [scrollTop, setScrollTop] = useState(initialScrollOffset);
32-
const totalHeight = itemCount * itemHeight;
33-
34-
const onScroll = useCallback((e: UIEvent<HTMLDivElement>) => {
35-
const target = e.currentTarget;
36-
const newScrollTop = target.scrollTop;
37-
38-
prevScrollTop.current = newScrollTop;
39-
setScrollTop(newScrollTop);
40-
}, []);
41-
42-
const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan);
43-
const endIndex = Math.min(itemCount - 1, Math.ceil((scrollTop + height) / itemHeight) + overscan);
44-
45-
const items = useMemo(() => {
46-
const items = [];
32+
const { containerRef, onScroll, startIndex, endIndex, totalHeight } = useVirtualList({
33+
itemCount,
34+
itemHeight,
35+
height,
36+
overscan,
37+
initialScrollOffset,
38+
});
4739

48-
for (let i = startIndex; i <= endIndex; i++) {
49-
items.push(
50-
<div
51-
key={i}
52-
data-index={i}
53-
className={s.item}
54-
style={{
55-
top: i * itemHeight,
56-
height: itemHeight,
57-
width: '100%',
58-
}}
59-
>
60-
{renderItem(i, {
61-
height: itemHeight,
62-
width: '100%',
63-
})}
64-
</div>,
65-
);
66-
}
40+
const items = [];
6741

68-
return items;
69-
}, [startIndex, endIndex, itemHeight, renderItem]);
42+
for (let i = startIndex; i <= endIndex; i++) {
43+
const style: CSSProperties = {
44+
height: itemHeight,
45+
width: '100%',
46+
position: 'absolute',
47+
top: i * itemHeight,
48+
left: 0,
49+
};
7050

71-
useEffect(() => {
72-
containerRef.current?.scrollTo({
73-
top: initialScrollOffset,
74-
behavior: 'instant',
75-
});
76-
}, [initialScrollOffset]);
51+
items.push(<VirtualListItem key={i} index={i} style={style} itemHeight={itemHeight} renderItem={renderItem} />);
52+
}
7753

7854
return (
7955
<div
@@ -83,9 +59,17 @@ const VirtualListVertical: React.FC<VirtualListVerticalProps> = ({
8359
style={{
8460
height,
8561
width,
62+
position: 'relative',
63+
overflow: 'auto',
8664
}}
8765
>
88-
<div style={{ height: totalHeight }} className={clsx(s.root__inner, classNameInner)}>
66+
<div
67+
style={{
68+
height: totalHeight,
69+
position: 'relative',
70+
}}
71+
className={clsx(s.root__inner, classNameInner)}
72+
>
8973
{items}
9074
</div>
9175
</div>
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { useRef, useState, useCallback, useEffect, UIEvent } from 'react';
2+
3+
type UseVirtualListParams = {
4+
itemCount: number;
5+
itemHeight: number;
6+
height: number;
7+
overscan?: number;
8+
initialScrollOffset?: number;
9+
};
10+
11+
export function useVirtualList({
12+
itemCount,
13+
itemHeight,
14+
height,
15+
overscan = 5,
16+
initialScrollOffset = 0,
17+
}: UseVirtualListParams) {
18+
const containerRef = useRef<HTMLDivElement>(null);
19+
const [scrollTop, setScrollTop] = useState(initialScrollOffset);
20+
const visibleRangeRef = useRef({ start: 0, end: 0 });
21+
22+
const totalHeight = itemCount * itemHeight;
23+
24+
const onScroll = useCallback((e: UIEvent<HTMLDivElement>) => {
25+
setScrollTop(e.currentTarget.scrollTop);
26+
}, []);
27+
28+
const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan);
29+
const endIndex = Math.min(itemCount - 1, Math.ceil((scrollTop + height) / itemHeight) + overscan);
30+
31+
visibleRangeRef.current = { start: startIndex, end: endIndex };
32+
33+
useEffect(() => {
34+
if (containerRef.current && initialScrollOffset > 0) {
35+
containerRef.current.scrollTop = initialScrollOffset;
36+
}
37+
}, [initialScrollOffset]);
38+
39+
return {
40+
containerRef,
41+
onScroll,
42+
startIndex,
43+
endIndex,
44+
totalHeight,
45+
visibleRangeRef,
46+
};
47+
}

0 commit comments

Comments
 (0)