Skip to content

Commit 668ad5e

Browse files
authored
WEBDEV-7939 Upgrade infinite-scroller to new version with CSS-transform-based virtualization (#582)
* Install new infinite scroller * Update scrollToPage and template for new scroller behavior * Debounce page fetches while scrolling rapidly * Fix demo app bug with jumping to pages * Upgrade infinite scroller alpha version * Correct page change events and improve page jump accuracy * Upgrade infinite-scroller alpha version * Cache whether items use waveform thumbnails, to mitigate flickering * Upgrade infinite-scroller alpha version * Upgrade scroller alpha version * Upgrade off alpha version
1 parent fe51cde commit 668ad5e

5 files changed

Lines changed: 185 additions & 43 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
"@internetarchive/histogram-date-range": "^1.4.2",
3232
"@internetarchive/ia-dropdown": "^2.0.0",
3333
"@internetarchive/iaux-item-metadata": "^1.0.5",
34-
"@internetarchive/infinite-scroller": "^1.0.1",
34+
"@internetarchive/infinite-scroller": "^2.0.0",
3535
"@internetarchive/modal-manager": "^2.0.5",
3636
"@internetarchive/search-service": "^2.7.1",
3737
"@internetarchive/shared-resize-observer": "^0.2.0",

src/app-root.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,9 @@ export class AppRoot extends LitElement {
112112
const page = this.currentPage ?? 1;
113113
if (page > 1) {
114114
this.collectionBrowser.goToPage(page);
115+
} else {
116+
// Ensure we reset the initial page
117+
this.collectionBrowser.initialPageNumber = 1;
115118
}
116119
}
117120

src/collection-browser.ts

Lines changed: 147 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -421,19 +421,69 @@ export class CollectionBrowser
421421
const model = this.dataSource.getTileModelAt(index);
422422
/**
423423
* If we encounter a model we don't have yet and we're not in the middle of an
424-
* automated scroll, fetch the page and just return undefined.
424+
* automated scroll, schedule a fetch for the missing page and return undefined.
425425
* The datasource will be updated once the page is loaded and the cell will be rendered.
426426
*
427427
* We disable it during the automated scroll since we don't want to fetch pages for intervening cells the
428428
* user may never see.
429429
*/
430430
if (!model && !this.isScrollingToCell && this.dataSource.queryInitialized) {
431431
const pageNumber = Math.floor(index / this.pageSize) + 1;
432-
this.dataSource.fetchPage(pageNumber);
432+
this.scheduleDeferredPageFetch(pageNumber);
433433
}
434434
return model;
435435
}
436436

437+
/**
438+
* Debounce delay for page fetches initiated by new cells becoming visible.
439+
* Tuned so quick scrolling through unloaded regions doesn't send rapid-fire
440+
* search requests for every page we pass through, but to still feel responsive
441+
* when the scroll ends.
442+
*/
443+
private static readonly DEFERRED_FETCH_DELAY_MS = 150;
444+
445+
private deferredFetchTimer = 0;
446+
447+
/**
448+
* Schedules a fetch for the given page, debounced to ensure we don't
449+
* rapid-fire fetches while scrolling through pages quickly.
450+
*
451+
* If there's no pending fetch timer yet, it will fire a fetch immediately.
452+
* Otherwise, it will reset any existing timer. In either case, a deferred
453+
* fetch for the visible pages is scheduled after a brief delay to account
454+
* for whatever pages we land on after scrolling.
455+
*/
456+
private scheduleDeferredPageFetch(pageNumber: number): void {
457+
if (!this.deferredFetchTimer) {
458+
this.dataSource.fetchPage(pageNumber);
459+
} else {
460+
window.clearTimeout(this.deferredFetchTimer);
461+
}
462+
463+
this.deferredFetchTimer = window.setTimeout(() => {
464+
this.deferredFetchTimer = 0;
465+
this.fetchVisiblePages();
466+
}, CollectionBrowser.DEFERRED_FETCH_DELAY_MS);
467+
}
468+
469+
/**
470+
* Fetch each currently-visible page whose first cell still has no
471+
* loaded model.
472+
*/
473+
private fetchVisiblePages(): void {
474+
const visibleIndices = this.infiniteScroller?.getVisibleCellIndices() ?? [];
475+
const visiblePages = new Set(
476+
visibleIndices.map(i => Math.floor(i / this.pageSize) + 1),
477+
);
478+
479+
for (const page of visiblePages) {
480+
const firstCellOfPage = (page - 1) * this.pageSize;
481+
if (!this.dataSource.getTileModelAt(firstCellOfPage)) {
482+
this.dataSource.fetchPage(page);
483+
}
484+
}
485+
}
486+
437487
// this is the total number of tiles we expect if
438488
// the data returned is a full page worth
439489
// this is useful for putting in placeholders for the expected number of tiles
@@ -866,6 +916,8 @@ export class CollectionBrowser
866916
class=${this.infiniteScrollerClasses}
867917
itemCount=${this.placeholderType ? 0 : nothing}
868918
ariaLandmarkLabel="Search results"
919+
.estimatedCellHeight=${this.estimatedTileHeight}
920+
.minBufferMarginCells=${this.pageSize}
869921
.cellProvider=${this}
870922
.placeholderCellTemplate=${this.placeholderCellTemplate}
871923
@scrollThresholdReached=${this.scrollThresholdReached}
@@ -887,6 +939,25 @@ export class CollectionBrowser
887939
});
888940
}
889941

942+
/**
943+
* Best-effort hint of how tall a single rendered tile is, by display mode.
944+
* The scroller uses this to better estimate the size its initial scroll
945+
* spacer and buffer position before real cell heights are measured.
946+
* Should roughly match the placeholder heights since the initial render
947+
* of a new page generally shows placeholders only anyway.
948+
*/
949+
private get estimatedTileHeight(): number {
950+
switch (this.displayMode) {
951+
case 'list-detail':
952+
return 80;
953+
case 'list-compact':
954+
return 45;
955+
case 'grid':
956+
default:
957+
return 225;
958+
}
959+
}
960+
890961
/**
891962
* Template for the sort & filtering bar that appears atop the search results.
892963
*/
@@ -1098,7 +1169,7 @@ export class CollectionBrowser
10981169
if ((this.currentPage ?? 1) > 1) {
10991170
this.goToPage(1);
11001171
}
1101-
this.currentPage = 1;
1172+
this.setCurrentPage(1);
11021173
}
11031174

11041175
/**
@@ -1871,6 +1942,11 @@ export class CollectionBrowser
18711942
window.removeEventListener('popstate', this.boundNavigationHandler);
18721943
}
18731944

1945+
if (this.deferredFetchTimer) {
1946+
window.clearTimeout(this.deferredFetchTimer);
1947+
this.deferredFetchTimer = 0;
1948+
}
1949+
18741950
this.leftColIntersectionObserver?.disconnect();
18751951
this.facetsIntersectionObserver?.disconnect();
18761952
window.removeEventListener('resize', this.updateLeftColumnHeight);
@@ -2123,27 +2199,45 @@ export class CollectionBrowser
21232199
private visibleCellsChanged(
21242200
e: CustomEvent<{ visibleCellIndices: number[] }>,
21252201
) {
2202+
this.updateVisiblePage(e.detail.visibleCellIndices);
2203+
}
2204+
2205+
/**
2206+
* Recomputes the current page from the given set of visible cell indices
2207+
* and emits `visiblePageChanged` if the page actually changed.
2208+
*/
2209+
private updateVisiblePage(visibleCellIndices: number[]): void {
21262210
if (this.isScrollingToCell) return;
2127-
const { visibleCellIndices } = e.detail;
21282211
if (visibleCellIndices.length === 0) return;
21292212

2213+
// The indices aren't necessarily sorted, so sort them here to ensure our
2214+
// calculations below find the right cell/page.
2215+
const sorted = [...visibleCellIndices].sort((a, b) => a - b);
2216+
21302217
// For page determination, do not count more than a single page of visible cells,
21312218
// since otherwise patrons using very tall screens will be treated as one page
21322219
// further than they actually are.
21332220
const lastIndexWithinCurrentPage =
2134-
Math.min(this.pageSize, visibleCellIndices.length) - 1;
2135-
const lastVisibleCellIndex = visibleCellIndices[lastIndexWithinCurrentPage];
2221+
Math.min(this.pageSize, sorted.length) - 1;
2222+
const lastVisibleCellIndex = sorted[lastIndexWithinCurrentPage];
21362223
const lastVisibleCellPage =
21372224
Math.floor(lastVisibleCellIndex / this.pageSize) + 1;
2138-
if (this.currentPage !== lastVisibleCellPage) {
2139-
this.currentPage = lastVisibleCellPage;
2140-
}
2141-
const event = new CustomEvent('visiblePageChanged', {
2142-
detail: {
2143-
pageNumber: lastVisibleCellPage,
2144-
},
2145-
});
2146-
this.dispatchEvent(event);
2225+
2226+
this.setCurrentPage(lastVisibleCellPage);
2227+
}
2228+
2229+
/**
2230+
* Sets the current page number and emits a `visiblePageChanged`
2231+
* event if the new page differs from the previous one.
2232+
*/
2233+
private setCurrentPage(pageNumber: number): void {
2234+
if (this.currentPage === pageNumber) return;
2235+
this.currentPage = pageNumber;
2236+
this.dispatchEvent(
2237+
new CustomEvent('visiblePageChanged', {
2238+
detail: { pageNumber },
2239+
}),
2240+
);
21472241
}
21482242

21492243
// we only want to scroll on the very first query change
@@ -2243,10 +2337,10 @@ export class CollectionBrowser
22432337
this.selectedCreatorFilter = restorationState.selectedCreatorFilter ?? null;
22442338
this.selectedFacets = restorationState.selectedFacets;
22452339
if (!this.suppressURLQuery) this.baseQuery = restorationState.baseQuery;
2246-
this.currentPage = restorationState.currentPage ?? 1;
2340+
this.setCurrentPage(restorationState.currentPage ?? 1);
22472341
this.minSelectedDate = restorationState.minSelectedDate;
22482342
this.maxSelectedDate = restorationState.maxSelectedDate;
2249-
if (this.currentPage > 1) {
2343+
if (this.currentPage && this.currentPage > 1) {
22502344
this.goToPage(this.currentPage);
22512345
}
22522346
}
@@ -2323,25 +2417,43 @@ export class CollectionBrowser
23232417
});
23242418
}
23252419

2326-
private scrollToPage(pageNumber: number): Promise<void> {
2327-
return new Promise(resolve => {
2328-
const cellIndexToScrollTo = this.pageSize * (pageNumber - 1);
2329-
// without this setTimeout, Safari just pauses until the `fetchPage` is complete
2330-
// then scrolls to the cell
2331-
setTimeout(() => {
2332-
this.isScrollingToCell = true;
2333-
this.infiniteScroller?.scrollToCell(cellIndexToScrollTo, true);
2334-
// This timeout is to give the scroll animation time to finish
2335-
// then updating the infinite scroller once we're done scrolling
2336-
// There's no scroll animation completion callback so we're
2337-
// giving it 0.5s to finish.
2338-
setTimeout(() => {
2339-
this.isScrollingToCell = false;
2340-
this.infiniteScroller?.refreshAllVisibleCells();
2341-
resolve();
2342-
}, 500);
2343-
}, 0);
2420+
private async scrollToPage(pageNumber: number): Promise<void> {
2421+
const cellIndexToScrollTo = this.pageSize * (pageNumber - 1);
2422+
2423+
// Wait for the infinite scroller be rendered before proceeding
2424+
let waitAttempts = 0;
2425+
while (!this.infiniteScroller && waitAttempts < 20) {
2426+
await this.updateComplete;
2427+
waitAttempts++;
2428+
}
2429+
if (!this.infiniteScroller) return;
2430+
2431+
// The scroller have its default `itemCount=0`, so propagate our estimated
2432+
// tile count before jumping to the desired page.
2433+
if (this.infiniteScroller.itemCount < this.estimatedTileCount) {
2434+
this.infiniteScroller.itemCount = this.estimatedTileCount;
2435+
await this.updateComplete;
2436+
}
2437+
2438+
// Without this setTimeout(0), Safari just pauses until the `fetchPage`
2439+
// is complete then scrolls to the cell.
2440+
await new Promise<void>(resolve => {
2441+
setTimeout(resolve, 0);
23442442
});
2443+
2444+
this.isScrollingToCell = true;
2445+
const scrolled = await this.infiniteScroller.scrollToCell(
2446+
cellIndexToScrollTo,
2447+
true,
2448+
);
2449+
this.isScrollingToCell = false;
2450+
this.infiniteScroller.refreshAllVisibleCells();
2451+
2452+
// After we finish scrolling, recompute the visible page from the new state
2453+
// so that it doesn't fall out of sync.
2454+
if (scrolled) {
2455+
this.updateVisiblePage(this.infiniteScroller.getVisibleCellIndices());
2456+
}
23452457
}
23462458

23472459
/**

src/tiles/item-image.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
import { css, CSSResultGroup, html, LitElement, nothing } from 'lit';
1+
import {
2+
css,
3+
CSSResultGroup,
4+
html,
5+
LitElement,
6+
nothing,
7+
PropertyValues,
8+
} from 'lit';
29
import { customElement, property, query, state } from 'lit/decorators.js';
310
import { ClassInfo, classMap } from 'lit/directives/class-map.js';
411

@@ -12,6 +19,14 @@ import { searchIcon } from '../assets/img/icons/mediatype/search';
1219

1320
@customElement('item-image')
1421
export class ItemImage extends LitElement {
22+
/**
23+
* Map to cache which identifiers have waveform-style thumbnails, so that
24+
* they can have their waveform styling applied immediately, rather than
25+
* waiting for the image content to load before applying it (which can
26+
* cause noticeable flicker when such tiles refresh).
27+
*/
28+
private static readonly waveformByIdentifier = new Map<string, boolean>();
29+
1530
@property({ type: Object }) model?: TileModel;
1631

1732
@property({ type: String }) baseImageUrl?: string;
@@ -30,6 +45,15 @@ export class ItemImage extends LitElement {
3045

3146
@query('img') private baseImage!: HTMLImageElement;
3247

48+
protected willUpdate(changed: PropertyValues): void {
49+
if (changed.has('model')) {
50+
// If this identifier is known to have a waveform image, then set isWaveform upfront
51+
const identifier = this.model?.identifier;
52+
this.isWaveform =
53+
ItemImage.waveformByIdentifier.get(identifier as string) === true;
54+
}
55+
}
56+
3357
render() {
3458
return html`
3559
<div class=${classMap(this.itemBaseClass)}>${this.imageTemplate}</div>
@@ -149,6 +173,9 @@ export class ItemImage extends LitElement {
149173
this.baseImage.naturalWidth / this.baseImage.naturalHeight === 4
150174
) {
151175
this.isWaveform = true;
176+
if (this.model?.identifier) {
177+
ItemImage.waveformByIdentifier.set(this.model.identifier, true);
178+
}
152179
}
153180
}
154181

yarn.lock

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -269,12 +269,12 @@
269269
dependencies:
270270
lit-html "^1.2.1"
271271

272-
"@internetarchive/infinite-scroller@^1.0.1":
273-
version "1.0.1"
274-
resolved "https://registry.npmjs.org/@internetarchive/infinite-scroller/-/infinite-scroller-1.0.1.tgz"
275-
integrity sha512-wzl7bQLidNZ4uXgP0H8AA3HoAXxueoHaVcpgQMXR7bspHKGvgLy8EvLbprBps1dFNK5yZRH9PHdLRtJgpcWKtQ==
272+
"@internetarchive/infinite-scroller@^2.0.0":
273+
version "2.0.0"
274+
resolved "https://registry.yarnpkg.com/@internetarchive/infinite-scroller/-/infinite-scroller-2.0.0.tgz#b7fbc9ad94c12225653b3718174d0712838638be"
275+
integrity sha512-PGfi0eqdSfsVd3xS2W9abY+iAJQPcw/Z8x9kWYARhiwot35eAMWxFzCXm5FaRTTjEQjZFEOBdXrQFNlO8GBNsQ==
276276
dependencies:
277-
lit "^2.0.2"
277+
lit "^2.8.0 || ^3.3.2"
278278

279279
"@internetarchive/lazy-loader-service@^0.2.0":
280280
version "0.2.0"
@@ -4056,7 +4056,7 @@ lit-html@^3.3.0:
40564056
lit-element "^4.2.0"
40574057
lit-html "^3.3.0"
40584058

4059-
lit@^2.0.2, lit@^2.2.7, lit@^2.3.0, lit@^2.8.0:
4059+
lit@^2.2.7, lit@^2.3.0, lit@^2.8.0:
40604060
version "2.8.0"
40614061
resolved "https://registry.npmjs.org/lit/-/lit-2.8.0.tgz"
40624062
integrity sha512-4Sc3OFX9QHOJaHbmTMk28SYgVxLN3ePDjg7hofEft2zWlehFL3LiAuapWc4U/kYwMYJSh2hTCPZ6/LIC7ii0MA==

0 commit comments

Comments
 (0)