Skip to content

Commit

Permalink
fix: scrolling jank
Browse files Browse the repository at this point in the history
  • Loading branch information
DominicGBauer committed Dec 24, 2024
1 parent d5412f2 commit c0ba78d
Show file tree
Hide file tree
Showing 2 changed files with 152 additions and 125 deletions.
226 changes: 125 additions & 101 deletions demos/supabase-infinite-scroll/src/listBox/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,68 +9,94 @@ export async function useListBox(config) {
const itemBuffer = useItemBuffer(config);
const listContainer = document.getElementById("listBox");
const list = listContainer.querySelector("#listItems");
const spinner = document.getElementById("spinner"); // Spinner element
const spinner = document.getElementById("spinner");
const { itemHeight, itemsPerPage } = config;
let eol = false;
let totalItems = 0;

let items = [];
let eofIndex = Number.MAX_SAFE_INTEGER;
let isLoading = false;
let scrollTimeout = null;

// Add intersection observer for infinite scroll
const observerOptions = {
root: listContainer,
rootMargin: '100px',
threshold: 0.1
};

const loadMoreCallback = (entries) => {
const target = entries[0];
if (target.isIntersecting && !isLoading) {
const scrollTop = Math.round(listContainer.scrollTop / itemHeight);
handleScroll(scrollTop);
}
};

const observer = new IntersectionObserver(loadMoreCallback, observerOptions);

let isRendering = false;
let pendingRender = null;
// Create sentinel element for infinite scroll
const sentinel = document.createElement('div');
sentinel.className = 'scroll-sentinel';
sentinel.style.height = '1px';
list.appendChild(sentinel);
observer.observe(sentinel);

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

function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
function throttleScroll(callback) {
return function() {
if (scrollTimeout) {
return;
}

scrollTimeout = requestAnimationFrame(() => {
const scrollTop = Math.round(listContainer.scrollTop / itemHeight);
callback(scrollTop);
scrollTimeout = null;
});
};
}

const debouncedHandleScroll = debounce(handleScroll, 100);
const throttledHandleScroll = throttleScroll(handleScroll);

if (listContainer._scrollHandler) {
listContainer.removeEventListener("scroll", listContainer._scrollHandler);
}

listContainer._scrollHandler = debouncedHandleScroll;
listContainer._scrollHandler = throttledHandleScroll;
listContainer.addEventListener("scroll", listContainer._scrollHandler);

function showSpinner() {
spinner.classList.remove("hidden");
if (!spinner.classList.contains('visible')) {
spinner.classList.remove("hidden");
spinner.classList.add("visible");
}
}

function hideSpinner() {
spinner.classList.add("hidden");
if (!spinner.classList.contains('hidden')) {
spinner.classList.add("hidden");
spinner.classList.remove("visible");
}
}

hideSpinner();

// Initialize by getting items and rendering them
await getItems(0, config.itemsPerPage)
.then(itemz => {
logger.info("Initial items", itemz);
items = itemz;
return getItemCount();
})
.then(async count => {
totalItems = count;
logger.info(`Set list height to ${totalItems * itemHeight}px`);
await renderItems(0);
});
// Initialize
await initializeList();

listContainer.scrollTop = 0;
async function initializeList() {
try {
const initialItems = await getItems(0, config.itemsPerPage);
items = initialItems;
totalItems = await getItemCount();
await renderItems(0);
listContainer.scrollTop = 0;
} catch (error) {
logger.error("Error initializing list:", error);
}
}

async function getItems(startIndex, count) {
logger.debug(
`Getting items from ${startIndex} to ${startIndex + count - 1}`
);
logger.debug(`Getting items from ${startIndex} to ${startIndex + count - 1}`);
return await itemBuffer.getItems(startIndex, count);
}

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

async function handleScroll() {
let scrollTop = Math.round(listContainer.scrollTop / itemHeight);
async function handleScroll(scrollTop) {
if (isLoading) return;

while (true) {
try {
isLoading = true;
await renderItems(scrollTop);
if (!pendingRender) {
break;
}
scrollTop = pendingRender;
}
if (scrollTop >= eofIndex - itemsPerPage) {
list.style.marginTop = `${(eofIndex - itemsPerPage) * itemHeight}px`;

listContainer.scrollTop = Math.round(
(eofIndex - itemsPerPage) * itemHeight
);
// Update sentinel position
sentinel.style.transform = `translateY(${(scrollTop + itemsPerPage) * itemHeight}px)`;

if (scrollTop >= eofIndex - itemsPerPage) {
list.style.transform = `translateY(${(eofIndex - itemsPerPage) * itemHeight}px)`;
listContainer.scrollTop = (eofIndex - itemsPerPage) * itemHeight;
}
} finally {
isLoading = false;
}
}

async function renderItems(startIndex) {
if (startIndex + itemsPerPage > eofIndex) {
logger.debug("Reached end of list. Skipping render.");
return;
}

showSpinner();

try {
return new Promise(async resolve => {
if (startIndex + itemsPerPage > eofIndex) {
logger.debug("Reached end of list. Skipping render.");
resolve();
}
items = [];
showSpinner();
items = await getItems(startIndex, itemsPerPage);
if (items.length === 0) {
const n = await getItemCount();
eofIndex = startIndex = n;
list.style.marginTop = `${(startIndex + 1) * itemHeight}px`;
listContainer.scrollTop = (n - itemsPerPage) * itemHeight;
resolve();
}
logger.info("Rendering items", items);
list.innerHTML = "";
// debugger;
// list.style.transform = `translateY(${startIndex * itemHeight}px)`;
list.style.marginTop = `${startIndex * itemHeight}px`;
items.forEach(item => {
const div = document.createElement("div");
div.className = "list-item";
div.textContent = item.text;
list.appendChild(div);
});
hideSpinner();
resolve();
// Use DocumentFragment for better performance
const fragment = document.createDocumentFragment();

// Fetch next batch of items
const newItems = await getItems(startIndex, itemsPerPage);

if (newItems.length === 0) {
const n = await getItemCount();
eofIndex = startIndex = n;
list.style.transform = `translateY(${(startIndex + 1) * itemHeight}px)`;
listContainer.scrollTop = (n - itemsPerPage) * itemHeight;
return;
}

items = newItems;

// Clear existing items
list.innerHTML = '';

// Create and append new items to fragment
items.forEach(item => {
const div = document.createElement("div");
div.className = "list-item";
div.textContent = item.text;
fragment.appendChild(div);
});

// Single DOM operation to append all items
list.appendChild(fragment);

// Use transform instead of margin for better performance
list.style.transform = `translateY(${startIndex * itemHeight}px)`;

} catch (error) {
logger.error("Error during rendering:", error);
} finally {
isRendering = false;
hideSpinner();
}
}
}

document.addEventListener("DOMContentLoaded", () => {
// Function to log the position of the listBox and move the spinner
function updateSpinnerPosition() {
const listBox = document.getElementById("listBox");
const spinner = document.getElementById("spinner");

if (listBox && spinner) {
const rect = listBox.getBoundingClientRect();

// Move spinner to the upper left corner of the listBox
spinner.style.top = `${rect.top + 50}px`;
spinner.style.left = `${rect.left + 100}px`;
} else {
console.error("listBox or spinner element not found");
// Cleanup function
return () => {
observer.disconnect();
if (listContainer._scrollHandler) {
listContainer.removeEventListener("scroll", listContainer._scrollHandler);
}
}

// Add event listener for window resize
window.addEventListener("resize", updateSpinnerPosition);

// Initial update to position spinner on page load
updateSpinnerPosition();
});
if (scrollTimeout) {
cancelAnimationFrame(scrollTimeout);
}
};
}
51 changes: 27 additions & 24 deletions demos/supabase-infinite-scroll/src/listBox/itemBuffer.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,50 +4,53 @@ const logger = Logger.get("src/listBox/itemBuffer");

export default function useItemBuffer(config) {
let items = [];
let startIndex = 0;
let inhere = false;
let fetchPromise = null;

async function fetchItems(start, count) {
const result = await config.dataSource.getItems(
start,
count + config.prefetchCount
);
return result;
}

async function ensureItems(count) {
try {
inhere = true;
if (fetchPromise) {
await fetchPromise;
}

if (count > items.length) {
const newItems = await fetchItems(items.length, count);
fetchPromise = config.dataSource.getItems(
start,
count + config.prefetchCount
);

items.push(...newItems);
const newItems = await fetchPromise;

// Extend array if needed
if (start + newItems.length > items.length) {
items = [
...items.slice(0, start),
...newItems
];
}
inhere = false;

return newItems;
} catch (error) {
logger.error("Error fetching items:", error);
throw error;
} finally {
fetchPromise = null;
}
}

async function getItems(start, count) {

if (start + count >= items.length) {
await ensureItems(start + count);
if (start + count > items.length) {
await fetchItems(start, count);
}

const result = [...items.slice(start, start + count)];
return result;
return items.slice(start, start + count);
}

async function getItemCount() {
return config.dataSource.getItemCount();
}

return {
items,
getBuffer: () => [...items],
ensureItems,
getItems,
getItemCount,
getBuffer: () => [...items],
};
}
}

0 comments on commit c0ba78d

Please sign in to comment.