blob: c820cfec8447fb158a507bfb67f504f1d6fbb59d [file] [log] [blame]
// Copyright 2024 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import {assert} from 'chrome://resources/js/assert.js';
import {AsyncDirective, directive, html, PartType} from 'chrome://resources/lit/v3_0/lit.rollup.js';
import type {PartInfo, TemplateResult} from 'chrome://resources/lit/v3_0/lit.rollup.js';
// Directive to render some items in an array asynchronously. Initially
// renders `initialCount` items, and renders remaining items asynchronously
// in chunking mode, where each chunk is rendered on a subsequent animation
// frame. Chunk size is initialized to `initialCount` and increases by
// `initialCount` when frames render more quickly than the target, and halves if
// frames render more slowly than the target (20fps).
class AsyncMapDirective<T> extends AsyncDirective {
template: (item: T) => TemplateResult = _item => html``;
initialCount: number = -1;
items: T[] = [];
private chunkSize_: number = -1;
private renderedItems_: T[] = [];
private renderStartTime_: number = 0;
private targetElapsedTime_: number = 50; // 20fps
private requestId_: number|null = null;
private timeout_: number|null = null;
constructor(partInfo: PartInfo) {
super(partInfo);
assert(
partInfo.type === PartType.CHILD,
'asyncMap() can only be used in text expressions');
}
render(
items: T[], template: ((item: T) => TemplateResult),
initialCount: number) {
// Clear any outstanding timeout or animation frame.
if (this.timeout_) {
clearTimeout(this.timeout_);
this.timeout_ = null;
}
if (this.requestId_) {
cancelAnimationFrame(this.requestId_);
this.requestId_ = null;
}
this.renderStartTime_ = 0;
this.template = template;
this.items = items;
assert(initialCount > 0);
this.initialCount = initialCount;
if (this.chunkSize_ === -1) {
this.chunkSize_ = this.initialCount;
}
// Don't unnecessarily fully remove items. This will create a larger
// change in the template. Instead, update the number of items already
// rendered + the current chunk size initially.
const count =
Math.min(items.length, this.renderedItems_.length + this.chunkSize_);
this.renderedItems_ = this.items.slice(0, count);
if (count < items.length) {
this.timeout_ = setTimeout(() => this.renderInChunks_(), 0);
}
return this.renderItems_();
}
private renderItems_(): TemplateResult[] {
return this.renderedItems_.map(item => this.template(item));
}
private async renderInChunks_() {
this.timeout_ = null;
let length = this.renderedItems_.length;
const arrayRef = this.items;
while (length < arrayRef.length) {
await new Promise<void>((resolve) => {
this.requestId_ = requestAnimationFrame(() => {
if (this.requestId_) {
cancelAnimationFrame(this.requestId_);
this.requestId_ = null;
}
resolve();
});
});
if (this.items !== arrayRef) {
return; // value updated, no longer our loop
}
// Adjust the chunk size if needed.
if (this.renderStartTime_ > 0) {
const elapsed = performance.now() - this.renderStartTime_;
// Additive increase, multiplicative decrease
if (elapsed < this.targetElapsedTime_) {
this.chunkSize_ += this.initialCount;
} else {
this.chunkSize_ =
Math.max(this.initialCount, Math.floor(this.chunkSize_ / 2));
}
}
const newLength = Math.min(length + this.chunkSize_, arrayRef.length);
this.renderedItems_.push(...this.items.slice(length, newLength));
length = newLength;
this.renderStartTime_ = performance.now();
this.setValue(this.renderItems_());
}
}
}
export interface AsyncMapDirectiveFn {
<T>(
items: T[],
template: (item: T) => TemplateResult,
initialCount: number,
): unknown;
}
export const asyncMap = directive(AsyncMapDirective) as AsyncMapDirectiveFn;