Infinite Scroll Implementation in Vanilla-Grid
This document explains how infinite scroll is implemented in Vanilla-Grid, including prefetch thresholds, load chaining, known-total resolution, scrollbar interactions, and integration with virtualization.
1. Feature Scope
Infinite scroll provides:
- Progressive page loading via callback (
onLoadMore(skip, pageSize)) - Automatic prefetch when near the end of loaded rows
- A push-based total-row-count state model: whichever
DataManageris attached reports the total for its own reasons (viasetTotalRowCount()); the grid never pulls/polls for it (see §7). - Runtime state APIs (
getLoadedRowCount,getTotalRowCount,hasMoreRows) - A position-independent busy signal — the
vn-grid-loading-morecontainer class (undelayed) and the built-in "loading more rows" banner (after theshimmerDelaygrace period) — so the grid answers "am I still loading?" on its own, at any scroll position and with no toolbar in the page (see §10)
Relevant options:
infiniteScroll.enabled(defaultfalse)infiniteScroll.pageSize(default1000)infiniteScroll.onLoadMoreinfiniteScroll.onLoadMoreError— optional(error, { failureCount, retryDelayMs })callback fired on every failed page fetch (see §5.4)infiniteScroll.onLoadMoreSuccess— optional(rows, { loadedRowCount, hasMoreRows })callback fired on every successful page append.appendRows()itself dispatches no event, andLOADEDonly covers the initial load / reload path, so this (and its auto-wiredvn-grid-load-more-succeededevent) is the only signal for scroll-triggered pagination — row-count/status UIs must listen here to stay in sync (see §5.4)infiniteScroll.shimmerDelay(default500) — grace period in ms before skeleton rows appear; set to0for immediate shimmer; set to a negative number to disableonTotalRowCountChanged— top-level grid option (not nested underinfiniteScroll, since total-row-count state applies to any grid a DataManager is attached to, not just infinite-scroll ones). Fired whenever the total changes;countisnullwhen unknown.
2. Internal State Model
Core state fields:
_isLoadingMore: boolean— prevents concurrent load requests_hasMoreData: boolean— tracks whether more pages are expected_loadedRowCount: number— rows fetched so far; drives the nextskip_totalRowCount: number | null— current known total for the active query, pushed by the attached DataManager viasetTotalRowCount();nullwhen unknown. Staleness (a superseded/aborted count request) is resolved per-manager, per-request by each DataManager's own version counter — not by any grid-level generation guard (see §7)._suppressPrefetch: boolean— temporary recursion guard during append_loadMoreShimmerTimer: number | null— grace-periodsetTimeoutref (cleared bydestroy())_loadMoreSkeletonVisible: boolean— tracks whether load-more skeleton rows are in the DOM_loadMoreBannerEl: HTMLElement | null— the lazily-created "loading more rows" banner (§10); lives on the container, destroyed bydestroy()_loadMoreFailureCount: number— consecutive failed page fetches; drives the retry backoff (§5.4)_loadMoreRetryAt: number— timestamp gate; non-forced load calls return until it passes (§5.4)
3. Initialization and Reset Behavior
Constructor sets defaults and callback references.
setRows(rows) always sets _loadedRowCount = rows.length first, regardless of
infiniteScroll — getLoadedRowCount() (and callers built on it, like
<vn-grid-toolbar-status>'s "Loaded X/Y" summary) must report a correct count
even for a plain, non-paginated load, where the DataManager returns every
matching row in one shot.
It then resets the remaining infinite-scroll runtime state when infiniteScroll
is enabled:
_hasMoreData = true_isLoadingMore = false- Cancel
_loadMoreShimmerTimer; set_loadMoreSkeletonVisible = false - If
_totalRowCountexists,_hasMoreData = _loadedRowCount < _totalRowCount
When infiniteScroll is disabled, _hasMoreData is simply forced to false
— a single-shot load has nothing left to fetch for the current query.
This ensures a fresh baseline whenever host code replaces data.
setRows(rows) also unconditionally resets the viewport scroll position to the top:
viewport.scrollTop = 0lastScrollTop = 0lastProgrammaticScrollTop = 0lastProgrammaticScrollAt = 0
Rationale: when a server-side sort (or any other operation) fully replaces the dataset via setRows, the browser may clamp the stale scrollTop to the new (smaller) scroll range instead of resetting to zero. Without an explicit reset, renderVisibleRows starts from the clamped position, showing rows from the middle of the new dataset rather than row 0. This is especially visible with tall viewports and large datasets loaded over multiple infinite-scroll pages.
The tracking variables (lastScrollTop, lastProgrammaticScrollTop, lastProgrammaticScrollAt) are zeroed in tandem so the browser's asynchronous scroll event fired after the write is treated as a zero-delta no-op and does not trigger a redundant re-render or confuse the momentum guard.
Note:
appendRows(used by the infinite-scroll load-more path) does not reset scroll position, since appending more rows must preserve the user's current scroll position.
_resetInfiniteScrollState() (invoked by reloadDataManager() before a
filter/sort/reload re-query) clears the same bookkeeping — _loadedRowCount = 0,
empty rows/displayRows, _totalRowCount = null, backoff counters — and sets
_hasMoreData = !!infiniteScroll. It is not forced true: a
non-infinite-scroll (static/in-memory) grid never has more to scroll-load, so
seeding it false during the reset→requery window keeps hasMoreRows() from
briefly returning true and flashing a (scroll to load more) status hint (in
<vn-grid-toolbar-status>) while the query is in flight. Infinite-scroll grids
still start the window true, exactly as a fresh setRows() does.
4. Prefetch Trigger in Render Loop
Prefetch is initiated from renderVisibleRows(...) after virtualization updates.
Guard conditions required:
infiniteScrollis true_hasMoreDatais true_isLoadingMoreis false_isViewportResizingis false_scrollbarDraggingis false_suppressPrefetchis false
Threshold logic:
prefetchThreshold = max(1, floor(pageSize * 0.3))remainingRows = totalRows - endIndex- if
remainingRows < prefetchThreshold, call_loadMoreIfNeeded()
So prefetch starts when roughly the last 30% of the currently loaded page is reached.
5. Core Load Pipeline (_loadMoreIfNeeded)
_loadMoreIfNeeded(force = false) does the actual fetch orchestration.
5.1 Entry guards
Returns immediately if any of these is true:
!infiniteScroll!_hasMoreData_isLoadingMore!onLoadMore
Additionally, when not forced:
- skip while dragging scrollbar (
_scrollbarDragging) - skip while prefetch suppression is active (
_suppressPrefetch) - skip while the failure backoff gate is closed (
Date.now() < _loadMoreRetryAt, see §5.4)
5.2 Fetch + append flow
- Call
_setLoadingMore(true)— sets_isLoadingMoreand adds thevn-grid-loading-morecontainer class (§10) - If
loadMoreShimmerDelay >= 0, schedule_showLoadMoreBanner()+_showLoadMoreSkeletons()after the delay - Await
onLoadMore(_loadedRowCount, pageSize) - On resolve:
- cancel shimmer timer; call
_hideLoadMoreSkeletons() - if returned rows are non-empty:
- set
_suppressPrefetch = true - call
appendRows(newRows) - increment
_loadedRowCount += newRows.length - clear
_suppressPrefetch
- set
- cancel shimmer timer; call
- Update
_hasMoreData:- if
_totalRowCountexists: compare against_loadedRowCount - else if returned rows
< pageSize: assume end reached (false)
- if
- If result is empty/non-array:
_hasMoreData = false - Invoke the optional
infiniteScroll.onLoadMoreSuccess(newRows, { loadedRowCount, hasMoreRows })callback (fires even whennewRowsis empty).appendRows()itself dispatches no DOM event, so on<vn-grid>this is auto-wired to also dispatchvn-grid-load-more-succeeded(VanillaGridEvents.LOAD_MORE_SUCCEEDED) — the only signal that reflects an incremental, scroll-triggered append (LOADEDonly fires for the initial load / reload path).
Errors cancel the shimmer timer, hide skeleton rows, are logged, and do not crash the grid; retry is possible on future triggers (subject to the failure backoff, §5.4). A successful load resets the backoff (_loadMoreFailureCount = 0, _loadMoreRetryAt = 0). Whatever the outcome, .finally() calls _setLoadingMore(false), which clears the class and the banner along with the flag.
infiniteScroll.shimmerDelay gates both built-in affordances — the banner and the tail skeleton rows — so a fast backend paints neither, and a negative value ("never show") means no built-in affordance at all. The vn-grid-loading-more class is never delayed.
5.3 End-of-track page chaining
In finally, if _isHoldingScrollbarAtBottom() is true and the failure backoff gate is open:
- Set scrollTop to maximum
- Update scrollbar thumb
- Re-enter
_loadMoreIfNeeded(true)
This allows chained page loading while the user keeps dragging at the end. The backoff check prevents the chain from turning into a request storm when the thumb is held at the bottom against a failing backend.
5.4 Failure backoff (_loadMoreFailureCount / _loadMoreRetryAt)
Every rejected onLoadMore fetch:
- Increments
_loadMoreFailureCount. - Computes an exponential backoff:
min(30000, 1000 × 2^(failureCount − 1))— 1 s, 2 s, 4 s, … capped at 30 s. - Sets
_loadMoreRetryAt = Date.now() + backoff. Non-forced calls return immediately while the gate is closed; user-forced retries (force === true, e.g. dropping the scrollbar thumb at the bottom) bypass the gate but still count failures. - Invokes the optional
infiniteScroll.onLoadMoreError(error, { failureCount, retryDelayMs })callback. On<vn-grid>this is auto-wired to dispatch thevn-grid-load-more-failedCustomEvent (VanillaGridEvents.LOAD_MORE_FAILED, detail{ error, failureCount, retryDelayMs }) so hosts can surface an error row or toast.
The grid never gives up: _hasMoreData stays true and retries continue at the capped interval indefinitely. The backoff state resets on a successful load, on _resetInfiniteScrollState() (i.e. reloadDataManager()), and on every fresh full data load.
Both continuation handlers (.then / .catch / .finally) bail out early when the grid has been destroyed (_disposed).
6. Bottom Detection Helpers
_isNearBottomOfLoadedData()
Computes near-bottom using both:
- data-based max (
displayRows.length * rowHeight - viewportHeight) - DOM-based max (
viewport.scrollHeight - viewportHeight)
Uses the greater of both and a threshold of rowHeight * 3.
_isHoldingScrollbarAtBottom()
Returns true when any condition indicates active bottom hold:
- thumb is at the track end
- pointer Y is at track bottom fallback region
_isNearBottomOfLoadedData()is true
This keeps behavior stable even when track metrics change during drag.
7. Total-Row-Count State (Push Model)
The grid does not pull or poll for the total row count. setTotalRowCount(count)
is a plain state setter: whichever DataManager is currently attached calls it
(indirectly, via _fireTotalRowCountChanged() → the manager's
onTotalRowCountChanged field, wired automatically by
VanillaGridElement.setDataManager()) whenever it determines the total for the
current query — typically at the end of its own fetchRows(). This replaced
a pull model where the grid itself gated a single resolve attempt behind a
grid-owned "attempted once" flag plus a generation counter — a design that
broke whenever two independent reload paths fired for the same user action
(e.g. clearColumnFiltersAndSorting(), which triggers both a DataManager
onSortChanged reload and its own reloadDataManager()): the shared flag
could be "spent" by whichever reload's in-flight count request got aborted
first, silently discarding the real total from the reload that actually won.
setTotalRowCount(count):
- Normalizes
count(Number.isFinite→Math.trunc, elsenull). - No-ops (no event fired) if the normalized value is unchanged from
_totalRowCount— including the unset→unset case. - Updates
_totalRowCountand, when finite, recomputes_hasMoreData = _loadedRowCount < _totalRowCount. - Invokes
onTotalRowCountChanged(count)if configured. - Refreshes the scroll indicator (
_updateScrollIndicator()).
Concurrent firing (load start, not after render) is now a DataManager
concern, not the grid's. ODataDataManager.fetchRows(), for example, kicks
off its own count request concurrently with the row fetch — see
docs/vanilla-grid/03-data-manager-implementation.md.
Staleness is resolved per-manager, per-request — not per-grid-generation.
There is no grid-level generation counter analogous to the old
_totalRowsGeneration. Each DataManager subclass that makes a network round
trip for the count (e.g. ODataDataManager) tracks its own monotonic version,
bumped on every fetchRows() call; an in-flight count request captures that
version and only calls _fireTotalRowCountChanged() if its captured version
still matches the manager's current version when it resolves. Because the
version lives on the manager and increments on every fetch (not on an
externally-orchestrated reset), two concurrent reloads for the same grid never
have a shared flag to race over — whichever fetch is actually current always
gets its own honest attempt, and a superseded one is discarded regardless of
why it was superseded (aborted, errored, or just slow).
A manager with no network round trip for its count (e.g. StaticDataManager,
or a custom manager whose row response already bundles the total) simply calls
_fireTotalRowCountChanged() synchronously inside fetchRows() — there is no
staleness window to guard against.
8. Scroll Indicator Integration
_updateScrollIndicator() includes infinite-scroll awareness:
- uses
totalRowCountwhen available - still tracks currently loaded row range (
firstRow,lastRow) - passes
loadedRowCount,totalRowCount,displayedRowCount, and format helpers toformatScrollIndicator(...)
Default text format:
{firstRow} - {lastRow} {messages.of} {displayTotalRows}
Where displayTotalRows prefers known total when resolved.
9. Interaction with Selection and Sorting
appendRows(...)auto-selects new rows when sticky select-all is active.appendRows(...)reapplies sort only in client-sort mode; server-sort assumes backend order.- Infinite loading therefore composes with both selection and sorting without special host logic.
10. Load-More Busy Signals
A page fetch produces two distinct affordances: positional skeleton rows at the tail of the data, and a position-independent busy state (container class + banner). The split exists because the user can scroll away from the tail mid-fetch — at which point the skeletons say nothing, and only the busy state can answer "is this grid still loading?".
_setLoadingMore(loading) — the busy-state choke point
Defined in interaction.feature.js. It is the only place _isLoadingMore is written during a fetch, and it keeps two things in lockstep:
_isLoadingMoreitself (also read byisBusy()and by<vn-grid-toolbar>'sbusystate);- the
vn-grid-loading-moreclass on.vn-grid-table-container— a paint hook hosts can style.
Called with true immediately before onLoadMore() and with false in the promise's .finally(), which already covers the success, error and _disposed paths. setRows() and _resetInfiniteScrollState() also route through it so a reload cannot leave the class orphaned.
The class is state; the banner is presentation. The class tracks _isLoadingMore exactly — it is the same fact isBusy() reports, so delaying it would make it lie, and hosts who want an undelayed indicator need an honest hook to hang it on. The banner is raised on the shimmerDelay timer instead (see below) and only dropped by _setLoadingMore(false), which keeps the invariant that no affordance outlives the fetch however the fetch ended.
vn-grid-loading-more is deliberately not vn-grid-shimmer-loading. The latter is toggled from isLoading and means the dataset you are looking at is being replaced — rows are wiped, scrolling is frozen. A load-more append leaves every visible row valid and leaves scrolling unblocked, so it gets its own class.
The banner (_ensureLoadMoreBanner() / _showLoadMoreBanner() / _hideLoadMoreBanner() / _destroyLoadMoreBanner())
Defined in rendering.feature.js; lifecycle mirrors the freeze guide (lazy-create, toggle via a class, destroy on teardown).
- Markup:
<div class="vn-grid-load-more-banner" role="status" aria-live="polite">holding an animated<span class="vn-grid-load-more-banner-shimmer">accent and a<span class="vn-grid-load-more-banner-text">label. Shown by addingvn-grid-load-more-banner-visible. - Appended to
this.container(.vn-grid-table-container), which is always present and alreadyposition: relative— not to_scrollWrapper, which only exists whenlayout.customScrollbar !== false. The grid must be able to answer "am I loading?" standalone, with no<vn-grid-toolbar>in the page. - Absolutely positioned at the bottom-centre of the container,
pointer-events: none,z-index: 10(below.vn-grid-scroll-indicator's11), clearing the 14 px horizontal scrollbar track. It never occludes the rows it sits over — during a load-more append those rows are still valid. - Label comes from
messages.loadingMore(default'Loading more rows…') and is re-read on every show, so a locale switch between two loads is picked up without recreating the element. - The accent gradient is fixed rather than inherited from the theme's
.vn-grid-skeleton-shimmer: those colours are tuned to sit on the theme's row background and would be invisible on the banner's dark pill invn-grid-carbon-darkandvn-grid-glow-dark. Like the pill (and.vn-grid-scroll-indicatorbefore it), the banner is theme-independent, so no theme needs a rule for it. - Raised from the
shimmerDelaytimer, alongside the tail skeletons, so a fast backend never paints it. Without that gate it would strobe once per prefetch on a quick backend — worse for the banner than for the skeletons, because it sits at a fixed screen position in the user's peripheral vision while the skeleton rows at the tail are usually off-screen when a fetch is that short. It is called next to_showLoadMoreSkeletons()rather than from inside it, because that function bails out when there is no body or no columns and the banner has no such dependency. - Across the failure-backoff gaps (§5.4) it still blinks off between retries, since
_isLoadingMoreis false while the grid waits. A text pill flickering between retries is a non-event; masking the whole grid on the same signal would not be.
_showLoadMoreSkeletons()
When a page fetch takes longer than infiniteScroll.shimmerDelay ms, skeleton rows are appended at the tail of the data.
Inserts skeleton
<tr>rows with classvn-grid-skeleton-row vn-grid-load-more-skeleton-rowby appending to<tbody>, i.e. after the bottom spacer row — the true end of the dataset.The anchor matters. Inserting before the spacer places the block immediately after the virtual pool window, which coincides with the end of the data only while the user is sitting at the tail. Scroll up and the two diverge: the pool window moves up, the bottom spacer grows, and the skeleton block travels with the pool into the middle of the dataset — invisible, and directly in the path of
_rotatePool(), whose owninsertBefore(rowEl, bottomSpacerRow)then lands recycled data rows below the skeletons, interleaving the two. Appending puts the block past that anchor, so rotation can no longer split it.Appending also keeps the extra
skeletonCount × rowHeightof DOM height entirely past the last data row, so_updateSpacerHeights()' per-row offsets stay exact and only the bottom-edgedomMaxScrollTopdiffers from the data-only maximum — whichhandleScrollalready models.Uses the same
vn-grid-skeleton-cell/vn-grid-skeleton-shimmermarkup as the initial-load shimmer so theming is automatic.Row count:
min(pageSize, max(3, ceil(viewportHeight / rowHeight))).Sets
_loadMoreSkeletonVisible = true.After insertion, scrolls the viewport down so the skeleton rows are visible — but only when
_isNearBottomOfLoadedData()still holds. The user may have scrolled up during the grace period to re-read earlier rows; firing the scroll unconditionally would yank them back to the bottom when the timer expires. The programmatic scroll, when it does run, is marked (lastProgrammaticScrollTop,lastProgrammaticScrollAt) to prevent the momentum guard from rejecting it. Scheduled throughVanillaGridScheduler(readscrollHeight, then writescrollTop) with arequestAnimationFramefallback.
_hideLoadMoreSkeletons()
- Queries
.vn-grid-load-more-skeleton-rowelements and removes them. - Sets
_loadMoreSkeletonVisible = false. - Called before
appendRows()so that real rows replace skeleton rows without a flash.
Grace period
A setTimeout of infiniteScroll.shimmerDelay ms is started at the beginning of each fetch. If the promise resolves before the timer fires (fast backend), clearTimeout ensures neither the banner nor the skeletons ever appear. If the timer fires first, both show until the response arrives.
A negative shimmerDelay is the "never show" sentinel: the timer is not scheduled at all, so the grid paints no built-in load-more affordance and a host that wants one builds it off the vn-grid-loading-more class.
Drag-to-bottom visibility
When the user drags the custom scrollbar thumb to the bottom, _loadMoreIfNeeded(true) is triggered and the shimmer timer starts. After the grace period, _showLoadMoreSkeletons() inserts skeleton rows which grow viewport.scrollHeight beyond the data-only maxScrollTop.
Without a special guard, the pointermove handler would keep resetting viewport.scrollTop to fraction × metrics.maxScrollTop (computed from displayRows.length only), placing the skeleton rows just out of view below the viewport edge and making the grid look frozen.
To fix this, _onScrollbarThumbPointerMove uses the actual DOM maximum when the thumb is pinned at the bottom:
if (newThumbTop >= metrics.usableTrack - 1) {
const domMax = Math.max(0, viewport.scrollHeight - viewportHeight);
if (domMax > newScrollTop) newScrollTop = domMax;
}
This keeps the viewport scrolled far enough to show skeleton rows for the entire duration of the drag-hold, giving consistent shimmer visibility regardless of input method (wheel, swipe, or drag). The near-bottom gate on the auto-scroll above does not interfere: a thumb pinned at the end of the track satisfies _isNearBottomOfLoadedData().
Cleanup on reset
_invalidatePool() cancels any pending timer, resets _loadMoreSkeletonVisible, and hides the banner — every caller (showLoadingSkeletons(), and therefore setLoading(true); showEmpty(); showError()) is a state where "loading more rows" no longer applies. Skeleton rows themselves go with the cleared <tbody>. destroy() removes the banner element and drops the vn-grid-loading-more class.
11. Failure Safety and Reentrancy Guards
Important protections:
_isLoadingMoreblocks concurrent fetches_suppressPrefetchprevents append-triggered prefetch loops- drag-related guards avoid fetch storms while users scrub scrollbar
- fallback end detection prevents stalls near bottom
- async errors cancel shimmer and are logged; next trigger can retry
- shimmer timer is always cancelled/cleared in
.then(),.catch(), and_invalidatePool() - the busy state (
_isLoadingMore+vn-grid-loading-more+ banner) is cleared in.finally(), so success, failure and disposal all release it
12. Example Host Configuration
const grid = new VanillaGrid({
infiniteScroll: {
enabled: true,
pageSize: 500,
onLoadMore: async (skip, top) => {
const res = await fetch(`/api/items?$skip=${skip}&$top=${top}`);
return res.json();
},
},
// Top-level, not nested under infiniteScroll — fires whenever the total
// changes. A host driving the grid directly (no DataManager) pushes the
// total itself, e.g. after its own $count fetch: grid.setTotalRowCount(total).
onTotalRowCountChanged: (count) => {
console.log('Known total:', count);
}
});
13. Web Component/DataManager Wiring
<vn-grid> integration (vanilla-grid-element.js) auto-wires infinite loading when a DataManager is present:
- if
infiniteScrollis enabled and no explicitonLoadMoreis provided,onLoadMoreis set todm.fetchMoreRows(skip, pageSize, context) - page size defaults from
dm.getPageSize()if not explicitly configured setDataManager()unconditionally wiresdataManager.onTotalRowCountChangedto callgrid.setTotalRowCount(count), so any manager that calls_fireTotalRowCountChanged()automatically updates the grid's total — no per-grid opt-in needed (this wiring is passive state sync, not a reload trigger, so it is independent ofsetAutoReloadOnConfigChange())
This allows declarative infinite scrolling with minimal custom glue code.