accounts: infinite scroll for performance

Found out the bottleneck when ~2000 or more elements are loaded isn't
the search or sort or anything, but the DOM. An infinite scroll
implementation is added, where elements are added to the DOM as you
scroll. May still be a little buggy, and can't yet cope with screen
resizes. Also, the "shown" indicator is broken.
This commit is contained in:
Harvey Tindall
2025-05-22 21:08:25 +01:00
parent 3299398806
commit d09ee59a1a
7 changed files with 216 additions and 67 deletions
+142 -16
View File
@@ -1,5 +1,5 @@
import { _get, _post, addLoader, removeLoader } from "./common";
import { Search, SearchConfiguration, SearchableItems } from "./search";
import { _get, _post, addLoader, removeLoader, throttle } from "./common";
import { Search, SearchConfiguration } from "./search";
declare var window: GlobalWindow;
@@ -93,7 +93,22 @@ export interface PaginatedListConfig {
export abstract class PaginatedList {
protected _c: PaginatedListConfig;
// Container to append items to.
protected _container: HTMLElement;
// List of visible IDs (i.e. those set with setVisibility).
protected _visible: string[];
protected _scroll = {
rowHeight: 0,
screenHeight: 0,
// Render this many screen's worth of content below the viewport.
renderNExtraScreensWorth: 3,
rowsOnPage: 0,
rendered: 0,
initialRenderCount: 0,
scrollLoading: false
};
protected _search: Search;
protected _counter: RecordCounter;
@@ -148,7 +163,6 @@ export abstract class PaginatedList {
this.loadMore(() => removeLoader(this._keepSearchingButton, true));
}; */
// Since this.reload doesn't exist, we need an arrow function to wrap it.
// FIXME: Make sure it works though!
this._c.refreshButton.onclick = () => this.reload();
}
@@ -198,7 +212,80 @@ export abstract class PaginatedList {
};
// Sets the elements with "name"s in "elements" as visible or not.
public abstract setVisibility: (elements: string[], visible: boolean) => void;
setVisibilityNaive = (elements: string[], visible: boolean) => {
let timer = this._search.timeSearches ? performance.now() : null;
if (visible) this._visible = elements;
else this._visible = this._search.ordering.filter(v => !elements.includes(v));
const frag = document.createDocumentFragment()
for (let i = 0; i < this._visible.length; i++) {
frag.appendChild(this._search.items[this._visible[i]].asElement())
}
this._container.replaceChildren(frag);
if (this._search.timeSearches) {
const totalTime = performance.now() - timer;
console.log(`setVisibility took ${totalTime}ms`);
}
}
// FIXME: Call on window resize/zoom
// FIXME: On reload, load enough pages to fill required space.
// FIXME: Might have broken _counter.shown!
// Sets the elements with "name"s in "elements" as visible or not.
// appendedItems==true implies "elements" is the previously rendered elements plus some new ones on the end. Knowing this means the page's infinite scroll doesn't have to be reset.
setVisibility = (elements: string[], visible: boolean, appendedItems: boolean = false) => {
let timer = this._search.timeSearches ? performance.now() : null;
if (visible) this._visible = elements;
else this._visible = this._search.ordering.filter(v => !elements.includes(v));
if (this._visible.length == 0) return;
this._scroll.screenHeight = Math.max(
document.documentElement.clientHeight,
window.innerHeight || 0
);
if (!appendedItems) {
// Wipe old elements and render 1 new one, so we can take the element height.
this._container.replaceChildren(this._search.items[this._visible[0]].asElement())
}
this.computeScrollInfo();
let baseIndex = 1;
if (appendedItems) {
baseIndex = this._scroll.rendered;
}
const frag = document.createDocumentFragment()
for (let i = baseIndex; i < this._scroll.initialRenderCount; i++) {
frag.appendChild(this._search.items[this._visible[i]].asElement())
}
this._scroll.rendered = Math.max(baseIndex, this._scroll.initialRenderCount);
// appendChild over replaceChildren because there's already elements on the DOM
this._container.appendChild(frag);
if (this._search.timeSearches) {
const totalTime = performance.now() - timer;
console.log(`setVisibility took ${totalTime}ms`);
}
}
// Computes required scroll info, requiring one on-DOM item. Should be computed on page resize and this._visible change.
computeScrollInfo = () => {
this._scroll.rowHeight = this._search.items[this._visible[0]].asElement().offsetHeight;
// We want to have _scroll.renderNScreensWorth*_scroll.screenHeight or more elements rendered always.
this._scroll.rowsOnPage = Math.floor(this._scroll.screenHeight / this._scroll.rowHeight);
// Initial render of min(_visible.length, max(rowsOnPage*renderNExtraScreensWorth, itemsPerPage)), skipping 1 as we already did it.
this._scroll.initialRenderCount = Math.min(this._visible.length, Math.max((this._scroll.renderNExtraScreensWorth+1)*this._scroll.rowsOnPage, this._c.itemsPerPage));
}
// returns the item index to render up to for the given scroll position.
// might return a value greater than this._visible.length, indicating a need for a page load.
maximumItemsToRender = (scrollY: number): number => {
const bottomScroll = scrollY + ((this._scroll.renderNExtraScreensWorth+1)*this._scroll.screenHeight);
const bottomIdx = Math.floor(bottomScroll / this._scroll.rowsOnPage);
return bottomIdx;
}
// Removes all elements, and reloads the first page.
// FIXME: Share more code between reload and loadMore, and go over the logic, it's messy.
@@ -309,26 +396,65 @@ export abstract class PaginatedList {
}
this._search.onSearchBoxChange(true, loadAll);
} else {
this.setVisibility(this._search.ordering, true);
// Since results come to us ordered already, we can assume "ordering"
// will be identical to pre-page-load but with extra elements at the end,
// allowing infinite scroll to continue
this.setVisibility(this._search.ordering, true, true);
this._search.setNotFoundPanelVisibility(false);
}
if (this._c.pageLoadCallback) this._c.pageLoadCallback(req);
if (callback) callback(req);
}, true)
}
// Should be assigned to window.onscroll whenever the list is in view.
detectScroll = () => {
if (!this._hasLoaded || this.lastPage) return;
// console.log(window.innerHeight + document.documentElement.scrollTop, document.scrollingElement.scrollHeight);
if (Math.abs(window.innerHeight + document.documentElement.scrollTop - document.scrollingElement.scrollHeight) < 50) {
// window.notifications.customSuccess("scroll", "Reached bottom.");
// Wait .5s between loads
if (this._lastLoad + 500 > Date.now()) return;
this.loadMore(null, false);
loadNItems = (n: number) => {
const cb = () => {
if (this._counter.loaded > n) return;
this.loadMore(cb, false);
}
cb();
}
// As reloading can disrupt long-scrolling, this function will only do it if you're at the top of the page, essentially.
public reloadIfNotInScroll = () => {
if (this.maximumItemsToRender(window.scrollY) < this._scroll.initialRenderCount) {
return this.reload();
}
}
_detectScroll = () => {
if (!this._hasLoaded || this._scroll.scrollLoading) return;
if (this._visible.length == 0) return;
const endIdx = this.maximumItemsToRender(window.scrollY);
// If you've scrolled back up, do nothing
if (endIdx <= this._scroll.rendered) return;
const realEndIdx = Math.min(endIdx, this._visible.length);
const frag = document.createDocumentFragment();
for (let i = this._scroll.rendered; i < realEndIdx; i++) {
frag.appendChild(this._search.items[this._visible[i]].asElement());
}
this._scroll.rendered = realEndIdx;
this._container.appendChild(frag);
if (endIdx >= this._visible.length) {
if (this.lastPage || this._lastLoad + 500 > Date.now()) return;
this._scroll.scrollLoading = true;
const cb = () => {
if (this._visible.length < endIdx && !this.lastPage) {
this.loadMore(cb, false)
return;
}
this._scroll.scrollLoading = false;
this._detectScroll();
};
cb();
return;
}
}
// Should be assigned to window.onscroll whenever the list is in view.
detectScroll = throttle(this._detectScroll, 200);
}