Skip to content

Commit c0ba78d

Browse files
committed
fix: scrolling jank
1 parent d5412f2 commit c0ba78d

File tree

2 files changed

+152
-125
lines changed

2 files changed

+152
-125
lines changed

demos/supabase-infinite-scroll/src/listBox/index.js

Lines changed: 125 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -9,68 +9,94 @@ export async function useListBox(config) {
99
const itemBuffer = useItemBuffer(config);
1010
const listContainer = document.getElementById("listBox");
1111
const list = listContainer.querySelector("#listItems");
12-
const spinner = document.getElementById("spinner"); // Spinner element
12+
const spinner = document.getElementById("spinner");
1313
const { itemHeight, itemsPerPage } = config;
14-
let eol = false;
15-
let totalItems = 0;
14+
1615
let items = [];
1716
let eofIndex = Number.MAX_SAFE_INTEGER;
17+
let isLoading = false;
18+
let scrollTimeout = null;
19+
20+
// Add intersection observer for infinite scroll
21+
const observerOptions = {
22+
root: listContainer,
23+
rootMargin: '100px',
24+
threshold: 0.1
25+
};
26+
27+
const loadMoreCallback = (entries) => {
28+
const target = entries[0];
29+
if (target.isIntersecting && !isLoading) {
30+
const scrollTop = Math.round(listContainer.scrollTop / itemHeight);
31+
handleScroll(scrollTop);
32+
}
33+
};
34+
35+
const observer = new IntersectionObserver(loadMoreCallback, observerOptions);
1836

19-
let isRendering = false;
20-
let pendingRender = null;
37+
// Create sentinel element for infinite scroll
38+
const sentinel = document.createElement('div');
39+
sentinel.className = 'scroll-sentinel';
40+
sentinel.style.height = '1px';
41+
list.appendChild(sentinel);
42+
observer.observe(sentinel);
2143

2244
listContainer.style.height = `${itemHeight * itemsPerPage}px`;
2345

24-
function debounce(func, wait) {
25-
let timeout;
26-
return function executedFunction(...args) {
27-
const later = () => {
28-
clearTimeout(timeout);
29-
func(...args);
30-
};
31-
clearTimeout(timeout);
32-
timeout = setTimeout(later, wait);
46+
function throttleScroll(callback) {
47+
return function() {
48+
if (scrollTimeout) {
49+
return;
50+
}
51+
52+
scrollTimeout = requestAnimationFrame(() => {
53+
const scrollTop = Math.round(listContainer.scrollTop / itemHeight);
54+
callback(scrollTop);
55+
scrollTimeout = null;
56+
});
3357
};
3458
}
3559

36-
const debouncedHandleScroll = debounce(handleScroll, 100);
60+
const throttledHandleScroll = throttleScroll(handleScroll);
3761

3862
if (listContainer._scrollHandler) {
3963
listContainer.removeEventListener("scroll", listContainer._scrollHandler);
4064
}
4165

42-
listContainer._scrollHandler = debouncedHandleScroll;
66+
listContainer._scrollHandler = throttledHandleScroll;
4367
listContainer.addEventListener("scroll", listContainer._scrollHandler);
4468

4569
function showSpinner() {
46-
spinner.classList.remove("hidden");
70+
if (!spinner.classList.contains('visible')) {
71+
spinner.classList.remove("hidden");
72+
spinner.classList.add("visible");
73+
}
4774
}
4875

4976
function hideSpinner() {
50-
spinner.classList.add("hidden");
77+
if (!spinner.classList.contains('hidden')) {
78+
spinner.classList.add("hidden");
79+
spinner.classList.remove("visible");
80+
}
5181
}
5282

53-
hideSpinner();
54-
55-
// Initialize by getting items and rendering them
56-
await getItems(0, config.itemsPerPage)
57-
.then(itemz => {
58-
logger.info("Initial items", itemz);
59-
items = itemz;
60-
return getItemCount();
61-
})
62-
.then(async count => {
63-
totalItems = count;
64-
logger.info(`Set list height to ${totalItems * itemHeight}px`);
65-
await renderItems(0);
66-
});
83+
// Initialize
84+
await initializeList();
6785

68-
listContainer.scrollTop = 0;
86+
async function initializeList() {
87+
try {
88+
const initialItems = await getItems(0, config.itemsPerPage);
89+
items = initialItems;
90+
totalItems = await getItemCount();
91+
await renderItems(0);
92+
listContainer.scrollTop = 0;
93+
} catch (error) {
94+
logger.error("Error initializing list:", error);
95+
}
96+
}
6997

7098
async function getItems(startIndex, count) {
71-
logger.debug(
72-
`Getting items from ${startIndex} to ${startIndex + count - 1}`
73-
);
99+
logger.debug(`Getting items from ${startIndex} to ${startIndex + count - 1}`);
74100
return await itemBuffer.getItems(startIndex, count);
75101
}
76102

@@ -80,84 +106,82 @@ export async function useListBox(config) {
80106
return count;
81107
}
82108

83-
async function handleScroll() {
84-
let scrollTop = Math.round(listContainer.scrollTop / itemHeight);
109+
async function handleScroll(scrollTop) {
110+
if (isLoading) return;
85111

86-
while (true) {
112+
try {
113+
isLoading = true;
87114
await renderItems(scrollTop);
88-
if (!pendingRender) {
89-
break;
90-
}
91-
scrollTop = pendingRender;
92-
}
93-
if (scrollTop >= eofIndex - itemsPerPage) {
94-
list.style.marginTop = `${(eofIndex - itemsPerPage) * itemHeight}px`;
95115

96-
listContainer.scrollTop = Math.round(
97-
(eofIndex - itemsPerPage) * itemHeight
98-
);
116+
// Update sentinel position
117+
sentinel.style.transform = `translateY(${(scrollTop + itemsPerPage) * itemHeight}px)`;
118+
119+
if (scrollTop >= eofIndex - itemsPerPage) {
120+
list.style.transform = `translateY(${(eofIndex - itemsPerPage) * itemHeight}px)`;
121+
listContainer.scrollTop = (eofIndex - itemsPerPage) * itemHeight;
122+
}
123+
} finally {
124+
isLoading = false;
99125
}
100126
}
101127

102128
async function renderItems(startIndex) {
129+
if (startIndex + itemsPerPage > eofIndex) {
130+
logger.debug("Reached end of list. Skipping render.");
131+
return;
132+
}
133+
134+
showSpinner();
135+
103136
try {
104-
return new Promise(async resolve => {
105-
if (startIndex + itemsPerPage > eofIndex) {
106-
logger.debug("Reached end of list. Skipping render.");
107-
resolve();
108-
}
109-
items = [];
110-
showSpinner();
111-
items = await getItems(startIndex, itemsPerPage);
112-
if (items.length === 0) {
113-
const n = await getItemCount();
114-
eofIndex = startIndex = n;
115-
list.style.marginTop = `${(startIndex + 1) * itemHeight}px`;
116-
listContainer.scrollTop = (n - itemsPerPage) * itemHeight;
117-
resolve();
118-
}
119-
logger.info("Rendering items", items);
120-
list.innerHTML = "";
121-
// debugger;
122-
// list.style.transform = `translateY(${startIndex * itemHeight}px)`;
123-
list.style.marginTop = `${startIndex * itemHeight}px`;
124-
items.forEach(item => {
125-
const div = document.createElement("div");
126-
div.className = "list-item";
127-
div.textContent = item.text;
128-
list.appendChild(div);
129-
});
130-
hideSpinner();
131-
resolve();
137+
// Use DocumentFragment for better performance
138+
const fragment = document.createDocumentFragment();
139+
140+
// Fetch next batch of items
141+
const newItems = await getItems(startIndex, itemsPerPage);
142+
143+
if (newItems.length === 0) {
144+
const n = await getItemCount();
145+
eofIndex = startIndex = n;
146+
list.style.transform = `translateY(${(startIndex + 1) * itemHeight}px)`;
147+
listContainer.scrollTop = (n - itemsPerPage) * itemHeight;
148+
return;
149+
}
150+
151+
items = newItems;
152+
153+
// Clear existing items
154+
list.innerHTML = '';
155+
156+
// Create and append new items to fragment
157+
items.forEach(item => {
158+
const div = document.createElement("div");
159+
div.className = "list-item";
160+
div.textContent = item.text;
161+
fragment.appendChild(div);
132162
});
163+
164+
// Single DOM operation to append all items
165+
list.appendChild(fragment);
166+
167+
// Use transform instead of margin for better performance
168+
list.style.transform = `translateY(${startIndex * itemHeight}px)`;
169+
133170
} catch (error) {
134171
logger.error("Error during rendering:", error);
135172
} finally {
136-
isRendering = false;
173+
hideSpinner();
137174
}
138175
}
139-
}
140-
141-
document.addEventListener("DOMContentLoaded", () => {
142-
// Function to log the position of the listBox and move the spinner
143-
function updateSpinnerPosition() {
144-
const listBox = document.getElementById("listBox");
145-
const spinner = document.getElementById("spinner");
146176

147-
if (listBox && spinner) {
148-
const rect = listBox.getBoundingClientRect();
149-
150-
// Move spinner to the upper left corner of the listBox
151-
spinner.style.top = `${rect.top + 50}px`;
152-
spinner.style.left = `${rect.left + 100}px`;
153-
} else {
154-
console.error("listBox or spinner element not found");
177+
// Cleanup function
178+
return () => {
179+
observer.disconnect();
180+
if (listContainer._scrollHandler) {
181+
listContainer.removeEventListener("scroll", listContainer._scrollHandler);
155182
}
156-
}
157-
158-
// Add event listener for window resize
159-
window.addEventListener("resize", updateSpinnerPosition);
160-
161-
// Initial update to position spinner on page load
162-
updateSpinnerPosition();
163-
});
183+
if (scrollTimeout) {
184+
cancelAnimationFrame(scrollTimeout);
185+
}
186+
};
187+
}

demos/supabase-infinite-scroll/src/listBox/itemBuffer.js

Lines changed: 27 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,50 +4,53 @@ const logger = Logger.get("src/listBox/itemBuffer");
44

55
export default function useItemBuffer(config) {
66
let items = [];
7-
let startIndex = 0;
8-
let inhere = false;
7+
let fetchPromise = null;
98

109
async function fetchItems(start, count) {
11-
const result = await config.dataSource.getItems(
12-
start,
13-
count + config.prefetchCount
14-
);
15-
return result;
16-
}
17-
18-
async function ensureItems(count) {
1910
try {
20-
inhere = true;
11+
if (fetchPromise) {
12+
await fetchPromise;
13+
}
2114

22-
if (count > items.length) {
23-
const newItems = await fetchItems(items.length, count);
15+
fetchPromise = config.dataSource.getItems(
16+
start,
17+
count + config.prefetchCount
18+
);
2419

25-
items.push(...newItems);
20+
const newItems = await fetchPromise;
21+
22+
// Extend array if needed
23+
if (start + newItems.length > items.length) {
24+
items = [
25+
...items.slice(0, start),
26+
...newItems
27+
];
2628
}
27-
inhere = false;
29+
30+
return newItems;
2831
} catch (error) {
2932
logger.error("Error fetching items:", error);
33+
throw error;
34+
} finally {
35+
fetchPromise = null;
3036
}
3137
}
3238

3339
async function getItems(start, count) {
34-
35-
if (start + count >= items.length) {
36-
await ensureItems(start + count);
40+
if (start + count > items.length) {
41+
await fetchItems(start, count);
3742
}
3843

39-
const result = [...items.slice(start, start + count)];
40-
return result;
44+
return items.slice(start, start + count);
4145
}
4246

4347
async function getItemCount() {
4448
return config.dataSource.getItemCount();
4549
}
50+
4651
return {
47-
items,
48-
getBuffer: () => [...items],
49-
ensureItems,
5052
getItems,
5153
getItemCount,
54+
getBuffer: () => [...items],
5255
};
53-
}
56+
}

0 commit comments

Comments
 (0)