From c0ba78de8d624a0679e50864d56a0c99d7dc0d90 Mon Sep 17 00:00:00 2001 From: Dominic Date: Tue, 24 Dec 2024 10:37:21 +0200 Subject: [PATCH] fix: scrolling jank --- .../src/listBox/index.js | 226 ++++++++++-------- .../src/listBox/itemBuffer.js | 51 ++-- 2 files changed, 152 insertions(+), 125 deletions(-) diff --git a/demos/supabase-infinite-scroll/src/listBox/index.js b/demos/supabase-infinite-scroll/src/listBox/index.js index 96a83ed5..f83768f6 100644 --- a/demos/supabase-infinite-scroll/src/listBox/index.js +++ b/demos/supabase-infinite-scroll/src/listBox/index.js @@ -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); } @@ -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(); -}); \ No newline at end of file + if (scrollTimeout) { + cancelAnimationFrame(scrollTimeout); + } + }; +} diff --git a/demos/supabase-infinite-scroll/src/listBox/itemBuffer.js b/demos/supabase-infinite-scroll/src/listBox/itemBuffer.js index 76cff7ee..7c43b280 100644 --- a/demos/supabase-infinite-scroll/src/listBox/itemBuffer.js +++ b/demos/supabase-infinite-scroll/src/listBox/itemBuffer.js @@ -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], }; -} \ No newline at end of file +}