0

Extensions: Add an asyncMap() directive to replace dom-repeat

Add a new AsyncMapDirective class in chrome://extensions, along with
corresponding tests. The new directive internally uses map() to display
a list of items, and supports |initialCount| and |filter| parameters.
These parameters are used by some dom-repeats in the extensions list
currently; these dom-repeats will be migrated to use the new directive
in a followup.

Also adding a test, and adding the relevant code to the lit bundle.
lit.rollup.js minified size increases 1.9kB with this change:
Before: 17557 bytes
After: 19446 bytes

Bug: 40943652
Change-Id: I0644fe90da1b584e43ce64eb2bbdebda922fbc2d
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/5941684
Code-Coverage: findit-for-me@appspot.gserviceaccount.com <findit-for-me@appspot.gserviceaccount.com>
Commit-Queue: Rebekah Potter <rbpotter@chromium.org>
Reviewed-by: Demetrios Papadopoulos <dpapad@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1372729}
This commit is contained in:
rbpotter
2024-10-23 15:43:26 +00:00
committed by Chromium LUCI CQ
parent 3c949d0885
commit 184b3f7556
12 changed files with 347 additions and 7 deletions

8
DEPS

@ -763,10 +763,10 @@ deps = {
'condition': 'non_git_source',
'objects': [
{
'object_name': 'a998f33a6d26e2a5bb38180bca2c03eacaf0d1a6',
'sha256sum': '96f51f598c2e039ef55c539e54cf9c61366e6fba400063da89da1f65ef24d8f4',
'size_bytes': 8882240,
'generation': 1729207266099094,
'object_name': '8eb0a40003464becccc4da796843e35116d0aa2e',
'sha256sum': 'cb5af9a19b3ec0a01717022bbaa3bd0974f21e18db5397c097af4a54c198080e',
'size_bytes': 8877615,
'generation': 1729559018744319,
'output_file': 'node_modules.tar.gz',
},
],

@ -52,6 +52,7 @@ build_webui("build") {
]
non_web_component_files = [
"async_map_directive.ts",
"drag_and_drop_handler.ts",
"extensions.ts",
"item_mixin.ts",

@ -0,0 +1,138 @@
// 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, noChange, PartType} from 'chrome://resources/lit/v3_0/lit.rollup.js';
import type {ChildPart, DirectiveParameters, 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).
// Also supports passing a filter function, to only render items in the array
// that match the filter (i.e. items for which filter(item) === true).
// Dispatches a 'rendered-items-changed' event, with a `detail` property set
// to the total number of rendered items, each time the rendered items are
// updated.
class AsyncMapDirective<T> extends AsyncDirective {
template: (item: T) => TemplateResult = _item => html``;
initialCount: number = -1;
items: T[] = [];
filter: ((item: T) => boolean)|null = null;
private chunkSize_: number = -1;
private filteredItems_: T[] = [];
private renderedItems_: T[] = [];
private renderStartTime_: number = 0;
private targetElapsedTime_: number = 50; // 20fps
private eventTarget_: EventTarget|null = null;
private requestId_: number|null = null;
constructor(partInfo: PartInfo) {
super(partInfo);
assert(
partInfo.type === PartType.CHILD,
'asyncMap() can only be used in text expressions');
}
override update(part: ChildPart, [
items,
template,
initialCount,
filter,
]: DirectiveParameters<this>) {
this.eventTarget_ = part.parentNode instanceof ShadowRoot ?
part.parentNode.host :
part.parentNode;
return this.render(items, template, initialCount, filter);
}
render(
items: T[], template: ((item: T) => TemplateResult), initialCount: number,
filter: (((item: T) => boolean)|null) = null) {
if (items === this.items && filter === this.filter) {
return noChange;
}
if (!this.isConnected) {
return html``;
}
this.template = template;
this.items = items;
this.filter = filter;
this.filteredItems_ = filter ? items.filter(i => filter(i)) : items;
assert(initialCount > 0);
this.initialCount = initialCount;
if (this.chunkSize_ === -1) {
this.chunkSize_ = this.initialCount;
}
this.renderedItems_ = this.filteredItems_.slice(0, this.initialCount);
const initialRender = this.renderItems_();
this.renderInChunks_();
return initialRender;
}
private renderItems_(): TemplateResult {
// Notify interested parties. Async so that rendering the new items is
// done before the event is fired.
const numItems = this.renderedItems_.length;
setTimeout(() => {
if (this.eventTarget_) {
this.eventTarget_.dispatchEvent(new CustomEvent(
'rendered-items-changed',
{bubbles: true, composed: true, detail: numItems}));
}
}, 0);
this.renderStartTime_ = performance.now();
return html`${this.renderedItems_.map(item => this.template(item))}`;
}
private async renderInChunks_() {
let length = this.renderedItems_.length;
const arrayRef = this.filteredItems_;
while (length < arrayRef.length) {
await new Promise<void>((resolve) => {
this.requestId_ = requestAnimationFrame(() => {
if (this.requestId_) {
cancelAnimationFrame(this.requestId_);
}
resolve();
});
});
if (!this.isConnected || this.filteredItems_ !== arrayRef) {
return; // value updated, no longer our loop
}
const elapsed = performance.now() - this.renderStartTime_;
// Additive increase, multiplicative decrease
if (elapsed < this.targetElapsedTime_) {
this.chunkSize_ += this.initialCount;
} else {
this.chunkSize_ = Math.max(1, Math.floor(this.chunkSize_ / 2));
}
const newLength = Math.min(length + this.chunkSize_, arrayRef.length);
this.renderedItems_.push(...this.filteredItems_.slice(length, newLength));
length = newLength;
this.setValue(this.renderItems_());
}
}
}
export interface AsyncMapDirectiveFn {
<T>(
items: T[],
template: (item: T) => TemplateResult,
initialCount: number,
filter?: ((item: T) => boolean)|null,
): unknown;
}
export const asyncMap = directive(AsyncMapDirective) as AsyncMapDirectiveFn;

@ -14,6 +14,7 @@ export {ActivityLogHistoryElement, ActivityLogPageState} from './activity_log/ac
export {ActivityGroup, ActivityLogHistoryItemElement} from './activity_log/activity_log_history_item.js';
export {ActivityLogStreamElement} from './activity_log/activity_log_stream.js';
export {ActivityLogStreamItemElement, ARG_URL_PLACEHOLDER, StreamItem} from './activity_log/activity_log_stream_item.js';
export {asyncMap} from './async_map_directive.js';
export {CodeSectionElement} from './code_section.js';
export {ExtensionsDetailViewElement} from './detail_view.js';
export {ErrorPageDelegate, ExtensionsErrorPageElement} from './error_page.js';

@ -37,6 +37,7 @@ build_webui_tests("build") {
"navigation_helper_test.ts",
"options_dialog_test.ts",
"pack_dialog_test.ts",
"async_map_directive_test.ts",
"review_panel_test.ts",
"runtime_host_permissions_test.ts",
"runtime_hosts_dialog_test.ts",

@ -0,0 +1,175 @@
// 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.
/** @fileoverview Suite of tests for the RepeatDirective class. */
import 'chrome://extensions/extensions.js';
import {PromiseResolver} from '//resources/js/promise_resolver.js';
import {asyncMap} from 'chrome://extensions/extensions.js';
import {CrLitElement, html} from 'chrome://resources/lit/v3_0/lit.rollup.js';
import {assertEquals, assertGT, assertNotEquals} from 'chrome://webui-test/chai_assert.js';
suite('AsyncMapDirectiveTest', function() {
let initialCount: number = 3;
let testElement: TestElement;
class TestElement extends CrLitElement {
static get is() {
return 'test-element';
}
override render() {
return html`
<div @rendered-items-changed="${this.onRenderedItemsChanged_}">
${
asyncMap(
this.items, item => html`<div class="item">${item}</div>`,
initialCount, this.filter)}
</div>
`;
}
static override get properties() {
return {
items: {type: Array},
filter: {type: Object},
};
}
items: string[] = [
'One',
'Two',
'Three',
'Four',
'Five',
'Six',
'Seven',
'Eight',
'Nine',
'Ten',
'Eleven',
'Twelve',
];
filter: ((item: string) => boolean)|null = null;
private itemsRendered_: number[] = [];
private allItemsRendered_: PromiseResolver<number[]> =
new PromiseResolver<number[]>();
private onRenderedItemsChanged_(e: CustomEvent<number>) {
this.itemsRendered_.push(e.detail);
const matchingItems = this.filter === null ?
this.items :
this.items.filter(item => this.filter!(item));
if (e.detail === matchingItems.length) {
this.allItemsRendered_.resolve(this.itemsRendered_);
}
}
allItemsRendered(): Promise<number[]> {
return this.allItemsRendered_.promise;
}
reset() {
this.allItemsRendered_ = new PromiseResolver<number[]>();
this.itemsRendered_ = [];
}
splice(start: number, deleteCount: number, insertions: string[] = []) {
this.items.splice(start, deleteCount, ...insertions);
this.requestUpdate();
}
}
customElements.define(TestElement.is, TestElement);
setup(function() {
document.body.innerHTML = window.trustedTypes!.emptyHTML;
});
test('Basic', async () => {
testElement = document.createElement('test-element') as TestElement;
document.body.appendChild(testElement);
let itemsRendered = await testElement.allItemsRendered();
// Additive increase means that we render 3 items, then at most 6 on the
// second cycle. So we should have at least 3 calls.
assertGT(itemsRendered.length, 2);
assertEquals(3, itemsRendered[0]);
assertEquals(12, itemsRendered[itemsRendered.length - 1]);
// Make sure the items are actually in the DOM.
assertEquals(12, testElement.shadowRoot!.querySelectorAll('.item').length);
// Test applying a filter.
testElement.reset();
testElement.filter = (item: string) => item.startsWith('T');
itemsRendered = await testElement.allItemsRendered();
assertEquals(4, itemsRendered[itemsRendered.length - 1]);
let items = testElement.shadowRoot!.querySelectorAll('.item');
assertEquals(4, items.length);
assertEquals('Two', items[0]!.textContent!);
assertEquals('Three', items[1]!.textContent!);
assertEquals('Ten', items[2]!.textContent!);
assertEquals('Twelve', items[3]!.textContent!);
// Filter with no matches
testElement.reset();
testElement.filter = (item: string) => item.startsWith('Z');
itemsRendered = await testElement.allItemsRendered();
assertEquals(1, itemsRendered.length);
assertEquals(0, itemsRendered[0]);
assertEquals(0, testElement.shadowRoot!.querySelectorAll('.item').length);
// Clear the filter.
testElement.reset();
testElement.filter = null;
itemsRendered = await testElement.allItemsRendered();
assertEquals(12, itemsRendered[itemsRendered.length - 1]);
items = testElement.shadowRoot!.querySelectorAll('.item');
assertEquals(12, items.length);
});
test('Different initial count', async () => {
initialCount = 6;
testElement = document.createElement('test-element') as TestElement;
document.body.appendChild(testElement);
const itemsRendered = await testElement.allItemsRendered();
assertNotEquals(0, itemsRendered.length);
assertEquals(6, itemsRendered[0]);
assertEquals(12, itemsRendered[itemsRendered.length - 1]);
// Make sure the items are actually in the DOM.
assertEquals(12, testElement.shadowRoot!.querySelectorAll('.item').length);
});
test('Modify list', async () => {
// Verifies the list updates correctly when the test element updates the
// list.
testElement = document.createElement('test-element') as TestElement;
document.body.appendChild(testElement);
let itemsRendered = await testElement.allItemsRendered();
assertEquals(12, itemsRendered[itemsRendered.length - 1]);
assertEquals(12, testElement.shadowRoot!.querySelectorAll('.item').length);
// Set a new array.
testElement.reset();
testElement.items = ['Hello', 'World', 'Goodbye'];
itemsRendered = await testElement.allItemsRendered();
assertEquals(3, itemsRendered[itemsRendered.length - 1]);
const renderedItems = testElement.shadowRoot!.querySelectorAll('.item');
assertEquals(3, renderedItems.length);
assertEquals('Hello', renderedItems[0]!.textContent);
assertEquals('World', renderedItems[1]!.textContent);
assertEquals('Goodbye', renderedItems[2]!.textContent);
// Correctly render no items.
testElement.reset();
testElement.items = [];
itemsRendered = await testElement.allItemsRendered();
assertEquals(1, itemsRendered.length);
assertEquals(0, itemsRendered[0]);
assertEquals(0, testElement.shadowRoot!.querySelectorAll('.item').length);
});
});

@ -38,6 +38,10 @@ IN_PROC_BROWSER_TEST_F(CrExtensionsTest, ActivityLogStreamItem) {
RunTest("extensions/activity_log_stream_item_test.js", "mocha.run()");
}
IN_PROC_BROWSER_TEST_F(CrExtensionsTest, AsyncMapDirective) {
RunTest("extensions/async_map_directive_test.js", "mocha.run()");
}
IN_PROC_BROWSER_TEST_F(CrExtensionsTest, ToggleRow) {
RunTest("extensions/toggle_row_test.js", "mocha.run()");
}

@ -155,6 +155,11 @@ ts_library("build_ts") {
path_mappings = [
"lit/index.js|" +
rebase_path("${node_modules}/lit/index.d.ts", target_gen_dir),
"lit-html/async-directive.js|" +
rebase_path("${node_modules}/lit-html/async-directive.d.ts",
target_gen_dir),
"lit-html/directive.js|" +
rebase_path("${node_modules}/lit-html/directive.d.ts", target_gen_dir),
"lit-html|" +
rebase_path("${node_modules}/lit-html/lit-html.d.ts", target_gen_dir),
"@lit/reactive-element|" + rebase_path(

@ -2,5 +2,7 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
export {css, CSSResultGroup, html, LitElement, nothing, render, PropertyValues, TemplateResult} from 'lit/index.js';
export {css, CSSResultGroup, html, LitElement, noChange, nothing, render, PropertyValues, TemplateResult} from 'lit/index.js';
export {directive, DirectiveParameters, PartInfo, PartType} from 'lit-html/directive.js';
export {AsyncDirective, ChildPart} from 'lit-html/async-directive.js';
export {CrLitElement} from './cr_lit_element.js';

@ -24,6 +24,10 @@ function plugin() {
// URL mappings from bare import URLs to file paths.
const redirects = new Map([
['lit/index.js', path.join(pathToNodeModules, 'lit/index.js')],
['lit-html/async-directive.js',
path.join(pathToNodeModules, 'lit-html/async-directive.js')],
['lit-html/directive.js',
path.join(pathToNodeModules, 'lit-html/directive.js')],
['lit-element/lit-element.js',
path.join(pathToNodeModules, 'lit-element/lit-element.js')],
['lit-html/is-server.js',
@ -37,6 +41,12 @@ function plugin() {
name: 'lit-path-resolver-plugin',
resolveId(source, origin) {
// Ensure all lit-html imports are de-duped so that this file is not
// included in the bundle twice.
if (source.endsWith('lit-html.js')) {
return path.join(pathToNodeModules, 'lit-html/lit-html.js');
}
if (source.startsWith('.')) {
// Let Rollup handle relative paths.
return null;

@ -7,8 +7,11 @@
# Lit JS files.
/lit-element/LICENSE
/lit-element/lit-element.js
/lit-html/is-server.js
/lit-html/async-directive.js
/lit-html/directive.js
/lit-html/directive-helpers.js
/lit-html/lit-html.js
/lit-html/is-server.js
/lit/index.js
/@lit/reactive-element/css-tag.js
/@lit/reactive-element/reactive-element.js

@ -1 +1 @@
a998f33a6d26e2a5bb38180bca2c03eacaf0d1a6
8eb0a40003464becccc4da796843e35116d0aa2e