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:

committed by
Chromium LUCI CQ

parent
3c949d0885
commit
184b3f7556
8
DEPS
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",
|
||||
|
138
chrome/browser/resources/extensions/async_map_directive.ts
Normal file
138
chrome/browser/resources/extensions/async_map_directive.ts
Normal file
@ -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",
|
||||
|
175
chrome/test/data/webui/extensions/async_map_directive_test.ts
Normal file
175
chrome/test/data/webui/extensions/async_map_directive_test.ts
Normal file
@ -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()");
|
||||
}
|
||||
|
5
third_party/lit/v3_0/BUILD.gn
vendored
5
third_party/lit/v3_0/BUILD.gn
vendored
@ -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(
|
||||
|
4
third_party/lit/v3_0/lit.ts
vendored
4
third_party/lit/v3_0/lit.ts
vendored
@ -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';
|
||||
|
10
third_party/lit/v3_0/rollup.config.mjs
vendored
10
third_party/lit/v3_0/rollup.config.mjs
vendored
@ -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;
|
||||
|
5
third_party/node/lit_include.txt
vendored
5
third_party/node/lit_include.txt
vendored
@ -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
|
||||
|
2
third_party/node/node_modules.tar.gz.sha1
vendored
2
third_party/node/node_modules.tar.gz.sha1
vendored
@ -1 +1 @@
|
||||
a998f33a6d26e2a5bb38180bca2c03eacaf0d1a6
|
||||
8eb0a40003464becccc4da796843e35116d0aa2e
|
||||
|
Reference in New Issue
Block a user