Skip to content

Commit 82a9ec9

Browse files
authored
history: create specialized list component/hooks (#1470)
1 parent 0b4b7ad commit 82a9ec9

File tree

5 files changed

+213
-25
lines changed

5 files changed

+213
-25
lines changed

special-pages/pages/history/app/components/App.jsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
1-
import { Fragment, h } from 'preact';
1+
import { h } from 'preact';
22
import styles from './App.module.css';
3-
import { useTypedTranslation } from '../types.js';
43
import { useEnv } from '../../../../shared/components/EnvironmentProvider.js';
54
import { Header } from './Header.js';
65
import { useSignal } from '@preact/signals';
76
import { Results } from './Results.js';
87

98
export function App() {
10-
const { t } = useTypedTranslation();
119
const { isDarkMode } = useEnv();
1210
const results = useSignal({
1311
info: {
@@ -25,7 +23,7 @@ export function App() {
2523
<h1 class={styles.pageTitle}>History</h1>
2624
</aside>
2725
<main class={styles.main}>
28-
<Results results={results} />
26+
<Results />
2927
</main>
3028
</div>
3129
);
Lines changed: 30 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,40 @@
11
import { h } from 'preact';
2+
import { OVERSCAN_AMOUNT } from '../constants.js';
3+
import styles from './VirtualizedList.module.css';
4+
import { VisibleItems } from './VirtualizedList.js';
5+
6+
const DEMO_ITEMS_COUNT = 4000;
7+
const DEMO_ITEM_HEIGHT = 32;
28

39
/**
4-
* @param {object} props
5-
* @param {import("@preact/signals").Signal<import('../../types/history').HistoryQueryResponse>} props.results
10+
*
611
*/
7-
export function Results({ results }) {
12+
export function Results() {
13+
const results = {
14+
value: {
15+
items: Array.from({ length: DEMO_ITEMS_COUNT }, (_, index) => ({
16+
id: `item-${index + 1}`,
17+
title: `Title ${index + 1}`,
18+
})),
19+
heights: Array.from({ length: DEMO_ITEMS_COUNT }, () => DEMO_ITEM_HEIGHT), // Assuming a fixed height of 50 for each item
20+
},
21+
};
22+
const totalHeight = results.value.heights.reduce((acc, item) => acc + item, 0);
823
return (
9-
<div>
10-
<p>Params:</p>
11-
<pre>
12-
<code>{JSON.stringify(results.value.info)}</code>
13-
</pre>
14-
<br />
15-
<hr />
16-
<br />
17-
<p>Results:</p>
18-
<ul>
19-
{results.value.value.map((item) => {
24+
<ul class={styles.container} style={{ height: totalHeight + 'px' }}>
25+
<VisibleItems
26+
scrollingElement={'main'}
27+
items={results.value.items}
28+
heights={results.value.heights}
29+
overscan={OVERSCAN_AMOUNT}
30+
renderItem={({ item, cssClassName, style, index }) => {
2031
return (
21-
<li>
22-
<pre>
23-
<code>{JSON.stringify(item, null, 2)}</code>
24-
</pre>
32+
<li key={item.id} data-id={item.id} class={cssClassName} style={style}>
33+
<div style={{ height: results.value.heights[index] + 'px', border: '1px dotted black' }}>{item.title}</div>
2534
</li>
2635
);
27-
})}
28-
</ul>
29-
</div>
36+
}}
37+
/>
38+
</ul>
3039
);
3140
}
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import { Fragment, h } from 'preact';
2+
import { memo } from 'preact/compat';
3+
import styles from './VirtualizedList.module.css';
4+
import { useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks';
5+
6+
/**
7+
* @template T
8+
* @typedef RenderProps
9+
* @property {T} item
10+
* @property {number} index
11+
* @property {string} cssClassName
12+
* @property {number} itemTopOffset
13+
* @property {Record<string, any>} style - inline styles to apply to your element
14+
*
15+
*/
16+
17+
/**
18+
* @template T
19+
* @param {object} props
20+
* @param {T[]} props.items
21+
* @param {number[]} props.heights - a list of known heights for every item. This prevents needing to measure on the fly
22+
* @param {number} props.overscan - how many items should be loaded before and after the current set on screen
23+
* @param {string} props.scrollingElement - a CSS selector matching a parent element that will scroll
24+
* @param {(arg: RenderProps<T>) => import("preact").ComponentChild} props.renderItem - A function to render individual items.
25+
*/
26+
export function VirtualizedList({ items, heights, overscan, scrollingElement, renderItem }) {
27+
const { start, end } = useVisibleRows(items, heights, scrollingElement, overscan);
28+
const subset = items.slice(start, end + 1);
29+
return (
30+
<Fragment>
31+
{subset.map((item, rowIndex) => {
32+
const originalIndex = start + rowIndex;
33+
const itemTopOffset = heights.slice(0, originalIndex).reduce((acc, item) => acc + item, 0);
34+
return renderItem({
35+
item,
36+
index: originalIndex,
37+
cssClassName: styles.listItem,
38+
itemTopOffset,
39+
style: {
40+
transform: `translateY(${itemTopOffset}px)`,
41+
},
42+
});
43+
})}
44+
</Fragment>
45+
);
46+
}
47+
48+
export const VisibleItems = memo(VirtualizedList);
49+
50+
/**
51+
* @param {Array} rows - The array of rows to be virtually rendered. Each row represents an item in the list.
52+
* @param {number[]} heights - index lookup for known element heights
53+
* @param {string} scrollerSelector - A CSS selector for tracking the scrollable area
54+
* @param {number} overscan - how many items to fetch outside the window
55+
* @return {Object} An object containing the calculated `start` and `end` indices of the visible rows.
56+
*/
57+
function useVisibleRows(rows, heights, scrollerSelector, overscan = 5) {
58+
// set the start/end indexes of the elements
59+
const [{ start, end }, setVisibleRange] = useState({ start: 0, end: 1 });
60+
61+
// hold a mutable value that we update on resize
62+
const mainScrollerRef = useRef(/** @type {Element|null} */ (null));
63+
const scrollingSize = useRef(/** @type {number|null} */ (null));
64+
65+
/**
66+
* When called, make the expensive calls to `getBoundingClientRect` to measure things
67+
*/
68+
function updateGlobals() {
69+
if (!mainScrollerRef.current) return;
70+
const rec = mainScrollerRef.current.getBoundingClientRect();
71+
scrollingSize.current = rec.height;
72+
}
73+
74+
/**
75+
* decide which the start/end indexes should be, based on scroll position.
76+
* NOTE: this is called on scroll, so must not incur expensive checks/measurements - math only!
77+
*/
78+
function setVisibleRowsForOffset() {
79+
if (!mainScrollerRef.current) return console.warn('cannot access mainScroller ref');
80+
if (scrollingSize.current === null) return console.warn('need height');
81+
const scrollY = mainScrollerRef.current?.scrollTop ?? 0;
82+
const next = calcVisibleRows(heights || [], scrollingSize.current, scrollY);
83+
84+
const withOverScan = {
85+
start: Math.max(next.startIndex - overscan, 0),
86+
end: next.endIndex + overscan,
87+
};
88+
89+
// don't set state if the offset didn't change
90+
setVisibleRange((prev) => {
91+
if (withOverScan.start !== prev.start || withOverScan.end !== prev.end) {
92+
// todo: find a better place to emit this!
93+
window.dispatchEvent(new CustomEvent('range-change', { detail: { start: withOverScan.start, end: withOverScan.end } }));
94+
return { start: withOverScan.start, end: withOverScan.end };
95+
}
96+
return prev;
97+
});
98+
}
99+
100+
useLayoutEffect(() => {
101+
mainScrollerRef.current = document.querySelector(scrollerSelector) || document.documentElement;
102+
if (!mainScrollerRef.current) console.warn('missing elements');
103+
104+
// always update globals first
105+
updateGlobals();
106+
107+
// and set visible rows once the size is known
108+
setVisibleRowsForOffset();
109+
110+
const controller = new AbortController();
111+
112+
// when the main area is scrolled, update the visible offset for the rows.
113+
mainScrollerRef.current?.addEventListener('scroll', setVisibleRowsForOffset, { signal: controller.signal });
114+
115+
return () => {
116+
controller.abort();
117+
};
118+
}, [rows, heights, scrollerSelector]);
119+
120+
useEffect(() => {
121+
let lastWindowHeight = window.innerHeight;
122+
function handler() {
123+
if (lastWindowHeight === window.innerHeight) return;
124+
lastWindowHeight = window.innerHeight;
125+
updateGlobals();
126+
setVisibleRowsForOffset();
127+
}
128+
window.addEventListener('resize', handler);
129+
return () => {
130+
return window.removeEventListener('resize', handler);
131+
};
132+
}, [heights, rows]);
133+
134+
return { start, end };
135+
}
136+
137+
/**
138+
* @param {number[]} heights - an array of integers that represents a 1:1 mapping to `rows` - each value is pixels
139+
* @param {number} space - the height in pixels that we have to fill
140+
* @param {number} scrollOffset - the y offset in pixels representing scrolling
141+
* @return {{startIndex: number, endIndex: number}}
142+
*/
143+
function calcVisibleRows(heights, space, scrollOffset) {
144+
let startIndex = 0;
145+
let endIndex = 0;
146+
let currentHeight = 0;
147+
148+
// Adjust startIndex for the scrollOffset
149+
for (let i = 0; i < heights.length; i++) {
150+
if (currentHeight + heights[i] > scrollOffset) {
151+
startIndex = i;
152+
break;
153+
}
154+
currentHeight += heights[i];
155+
}
156+
157+
// Start calculating endIndex from the adjusted startIndex
158+
currentHeight = 0;
159+
for (let i = startIndex; i < heights.length; i++) {
160+
if (currentHeight + heights[i] > space) {
161+
endIndex = i;
162+
break;
163+
}
164+
currentHeight += heights[i];
165+
endIndex = i;
166+
}
167+
168+
return { startIndex, endIndex };
169+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
.container {
2+
position: relative;
3+
}
4+
5+
.listItem {
6+
display: block;
7+
width: 100%;
8+
position: absolute;
9+
padding: 0;
10+
margin: 0;
11+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const OVERSCAN_AMOUNT = 5;

0 commit comments

Comments
 (0)