diff --git a/benchmark/benchmarks/krausest/browser.js b/benchmark/benchmarks/krausest/browser.js index 06211ad38c..35b37ccd8d 100644 --- a/benchmark/benchmarks/krausest/browser.js +++ b/benchmark/benchmarks/krausest/browser.js @@ -1,5 +1,5 @@ import render from './lib/index'; export default async function run() { - await render(document.body, true); + await render(); } diff --git a/benchmark/benchmarks/krausest/lib/components/Application.hbs b/benchmark/benchmarks/krausest/lib/components/Application.hbs deleted file mode 100644 index ef116217ff..0000000000 --- a/benchmark/benchmarks/krausest/lib/components/Application.hbs +++ /dev/null @@ -1,57 +0,0 @@ -
-
-
-
-

Glimmer-VM

-
-
-
-
- - Create 1,000 rows - -
-
- - Create 5,000 rows - -
-
- - Append 1,000 rows - -
-
- - Update every 10th row - -
-
- - Clear - -
-
- - Swap Rows - -
-
-
-
-
- - - - {{#each this.items as |item|}} - - {{/each}} - -
- -
\ No newline at end of file diff --git a/benchmark/benchmarks/krausest/lib/components/Application.hbs.d.ts b/benchmark/benchmarks/krausest/lib/components/Application.hbs.d.ts deleted file mode 100644 index b6a3edc840..0000000000 --- a/benchmark/benchmarks/krausest/lib/components/Application.hbs.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -declare const template: import('../../../../../packages/@glimmer/interfaces').SerializedTemplateWithLazyBlock; -export default template; diff --git a/benchmark/benchmarks/krausest/lib/components/Application.ts b/benchmark/benchmarks/krausest/lib/components/Application.ts index e841942489..dcef90f00d 100644 --- a/benchmark/benchmarks/krausest/lib/components/Application.ts +++ b/benchmark/benchmarks/krausest/lib/components/Application.ts @@ -1,55 +1,156 @@ -import { swapRows, type Item, updateData, buildData } from '@/utils/data'; -import { createCell } from '@glimmer-workspace/benchmark-env'; -import { fn } from '@glimmer/runtime'; -export default class Application { - cell!: ReturnType; - selectedItemCell!: ReturnType; - constructor() { - this.cell = createCell(this, 'cell', []); - this.selectedItemCell = createCell(this, 'selectedItem', null); +import type { Item } from '@/utils/data'; +import { buildData, swapRows, updateData } from '@/utils/data'; + +import { ListComponent } from './list'; +import { Cell, tagsToRevalidate } from '@/utils/reactive'; +import type { ComponentReturnType } from '@/utils/component'; +import { ButtonComponent } from '@/components/ButtonComponent'; +import { bindUpdatingOpcode } from '@/utils/vm'; +export class Application { + _items = new Cell([], 'items'); + get items() { + return this._items.value; } - fn = fn; - eq = (a: Item | null, b: Item | null) => { - return a === b; - }; - get selectedItem() { - return this.selectedItemCell.get() as Item | null; + set items(value: Item[]) { + this._items.update(value); } - set selectedItem(value: Item | null) { - this.selectedItemCell.set(value); + list: ListComponent; + children: ComponentReturnType[] = []; + selectedCell = new Cell(0, 'selectedCell'); + buttonWrapper() { + const div = document.createElement('div'); + div.className = 'col-sm-6 smallpad'; + return div; } - get items() { - return this.cell.get() as Item[]; + constructor() { + /* benchmark bootstrap start */ + const container = document.createElement('container'); + container.className = 'container'; + const jumbotron = document.createElement('div'); + jumbotron.className = 'jumbotron'; + const row1 = document.createElement('div'); + row1.className = 'row'; + const leftColumn = document.createElement('div'); + leftColumn.className = 'col-md-6'; + const rightColumn = document.createElement('div'); + rightColumn.className = 'col-md-6'; + const h1 = document.createElement('h1'); + h1.textContent = 'GlimmerCore'; + const row2 = document.createElement('div'); + row2.className = 'row'; + + const btnW1 = this.buttonWrapper(); + const btnW2 = this.buttonWrapper(); + const btnW3 = this.buttonWrapper(); + const btnW4 = this.buttonWrapper(); + const btnW5 = this.buttonWrapper(); + const btnW6 = this.buttonWrapper(); + + /**/ container.appendChild(jumbotron); + /* */ jumbotron.appendChild(row1); + /* */ row1.appendChild(leftColumn); + /* */ leftColumn.appendChild(h1); + /* */ row1.appendChild(rightColumn); + /* */ rightColumn.appendChild(row2); + /* */ row2.appendChild(btnW1); + /* */ row2.appendChild(btnW2); + /* */ row2.appendChild(btnW3); + /* */ row2.appendChild(btnW4); + /* */ row2.appendChild(btnW5); + /* */ row2.appendChild(btnW6); + /* benchmark bootstrap end */ + + this.children.push( + ButtonComponent( + { + onClick: () => this.create_1_000_Items(), + text: 'Create 1000 items', + id: 'run', + }, + btnW1 + ), + ButtonComponent( + { + onClick: () => this.create_5_000_Items(), + text: 'Create 5 000 items', + id: 'runlots', + }, + btnW2 + ), + ButtonComponent( + { + onClick: () => this.append_1_000_Items(), + text: 'Append 1000 rows', + id: 'add', + }, + btnW3 + ), + ButtonComponent( + { + onClick: () => this.updateEvery_10th_row(), + text: 'Update every 10th row', + id: 'update', + }, + btnW4 + ), + ButtonComponent( + { + onClick: () => this.clear(), + text: 'Clear', + id: 'clear', + }, + btnW5 + ), + ButtonComponent( + { + onClick: () => this.swapRows(), + text: 'Swap rows', + id: 'swaprows', + }, + btnW6 + ) + ); + + this.items = []; + this.list = new ListComponent({ app: this, items: this.items }, container); + + /* benchmark icon preload span start */ + const preloadSpan = document.createElement('span'); + preloadSpan.className = 'preloadicon glyphicon glyphicon-remove'; + preloadSpan.setAttribute('aria-hidden', 'true'); + container.appendChild(preloadSpan); + document.body.appendChild(container); + /* benchmark icon preload span end */ + + tagsToRevalidate; + + bindUpdatingOpcode(this._items, () => { + this.list.syncList(this.items); + }); + + this.children.push(this.list); } - set items(value: Item[]) { - this.cell.set(value); + removeItem(item: Item) { + const key = this.list.keyForItem(item); + this.items = this.items.filter((i) => i.id !== item.id); + this.list.destroyListItem(key); } - select = (item: Item) => { - this.selectedItem = item; - }; - create = () => { + create_1_000_Items() { this.items = buildData(1000); - }; - runLots = () => { + } + append_1_000_Items() { + this.items = [...this.items, ...buildData(1000)]; + } + create_5_000_Items() { this.items = buildData(5000); - }; - add = () => { - this.items = this.items.concat(buildData(1000)); - }; - update = () => { - this.items = updateData(this.items); - }; - clear = () => { - this.items = []; - this.selectedItem = null; - }; - swapRows = () => { + } + swapRows() { this.items = swapRows(this.items); - }; - remove = (item: Item) => { - this.items = this.items.filter((el) => el !== item); - if (this.selectedItem === item) { - this.selectedItem = null; - } - }; + } + clear() { + this.items = []; + } + updateEvery_10th_row() { + updateData(this.items, 10); + } } diff --git a/benchmark/benchmarks/krausest/lib/components/BsButton.hbs b/benchmark/benchmarks/krausest/lib/components/BsButton.hbs deleted file mode 100644 index 19170b8fc2..0000000000 --- a/benchmark/benchmarks/krausest/lib/components/BsButton.hbs +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/benchmark/benchmarks/krausest/lib/components/BsButton.hbs.d.ts b/benchmark/benchmarks/krausest/lib/components/BsButton.hbs.d.ts deleted file mode 100644 index b6a3edc840..0000000000 --- a/benchmark/benchmarks/krausest/lib/components/BsButton.hbs.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -declare const template: import('../../../../../packages/@glimmer/interfaces').SerializedTemplateWithLazyBlock; -export default template; diff --git a/benchmark/benchmarks/krausest/lib/components/ButtonComponent.ts b/benchmark/benchmarks/krausest/lib/components/ButtonComponent.ts new file mode 100644 index 0000000000..8c9dd6f0cd --- /dev/null +++ b/benchmark/benchmarks/krausest/lib/components/ButtonComponent.ts @@ -0,0 +1,27 @@ +import { addEventListener } from '@/utils/component'; + +export function ButtonComponent( + { onClick, text, slot, id }: { onClick: () => void; text: string; slot?: Node; id?: string }, + outlet: HTMLElement +) { + const button = document.createElement('button'); + button.setAttribute('class', 'btn btn-primary btn-block'); + button.type = 'button'; + + const textNode = document.createTextNode(text); + if (id) { + button.setAttribute('id', id); + } + button.appendChild(textNode); + if (slot) { + button.appendChild(slot); + } + + outlet.appendChild(button); + + return { + nodes: [button], + destructors: [addEventListener(button, 'click', onClick)], + index: 0, + }; +} diff --git a/benchmark/benchmarks/krausest/lib/components/LabelComponent.ts b/benchmark/benchmarks/krausest/lib/components/LabelComponent.ts new file mode 100644 index 0000000000..716a4226a8 --- /dev/null +++ b/benchmark/benchmarks/krausest/lib/components/LabelComponent.ts @@ -0,0 +1,41 @@ +import { ifCondition } from '@/components/if'; +import type { ComponentRenderTarget } from '@/utils/component'; +import type { AnyCell, Cell } from '@/utils/reactive'; +import { bindUpdatingOpcode } from '@/utils/vm'; + +export function LabelComponent({ text }: { text: string | AnyCell }, outlet: HTMLElement) { + const span = document.createElement('span'); + const destructors = []; + if (typeof text !== 'string') { + destructors.push( + bindUpdatingOpcode(text, (text) => { + span.textContent = String(text); + }) + ); + } else { + span.textContent = text; + } + outlet.appendChild(span); + return { + nodes: [span], + destructors, + index: 0, + }; +} + +export function LabelWrapperComponent( + { isVisible }: { isVisible: Cell }, + outlet: ComponentRenderTarget +) { + const hoveredDiv = document.createElement('div'); + const div = document.createElement('div'); + + LabelComponent({ text: '🗿' }, hoveredDiv); + LabelComponent({ text: '😄' }, div); + + return { + nodes: [div], + destructors: [ifCondition(isVisible, outlet, hoveredDiv, div)], + index: 0, + }; +} diff --git a/benchmark/benchmarks/krausest/lib/components/Row.hbs b/benchmark/benchmarks/krausest/lib/components/Row.hbs deleted file mode 100644 index 83317551cc..0000000000 --- a/benchmark/benchmarks/krausest/lib/components/Row.hbs +++ /dev/null @@ -1,10 +0,0 @@ - - {{@item.id}} - {{@item.label}} - - - - - - - \ No newline at end of file diff --git a/benchmark/benchmarks/krausest/lib/components/Row.hbs.d.ts b/benchmark/benchmarks/krausest/lib/components/Row.hbs.d.ts deleted file mode 100644 index b6a3edc840..0000000000 --- a/benchmark/benchmarks/krausest/lib/components/Row.hbs.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -declare const template: import('../../../../../packages/@glimmer/interfaces').SerializedTemplateWithLazyBlock; -export default template; diff --git a/benchmark/benchmarks/krausest/lib/components/Row.ts b/benchmark/benchmarks/krausest/lib/components/Row.ts deleted file mode 100644 index a49b1cc67a..0000000000 --- a/benchmark/benchmarks/krausest/lib/components/Row.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { Item } from '@/utils/data'; - -type RowArgs = { - item: Item; - select: () => void; - remove: (item: Item) => void; -}; - -export default class Row { - args!: RowArgs; - constructor(args: RowArgs) { - this.args = args; - } - onRemove = () => { - this.args.remove(this.args.item); - }; - onSelect = () => { - this.args.select(); - }; -} diff --git a/benchmark/benchmarks/krausest/lib/components/RowComponent.ts b/benchmark/benchmarks/krausest/lib/components/RowComponent.ts new file mode 100644 index 0000000000..7eb8dd8659 --- /dev/null +++ b/benchmark/benchmarks/krausest/lib/components/RowComponent.ts @@ -0,0 +1,140 @@ +import type { Application } from '@/components/Application'; +// import { ButtonComponent } from "@/components/ButtonComponent"; +// import { LabelWrapperComponent } from "@/components/LabelComponent"; +import { TagComponent } from '@/components/TagComponent'; +import { targetFor, type ComponentRenderTarget, type Destructors } from '@/utils/component'; +import type { Item } from '@/utils/data'; +import { Cell, cellFor, formula } from '@/utils/reactive'; +// import { maybeUpdatingPropertyOpcode } from "@/utils/vm"; + +export function RowComponent( + { item, app }: { item: Item; app: Application }, + outlet: ComponentRenderTarget +) { + // create cells for the item + const id = item.id; + const selectedCell = app.selectedCell; + const cell2 = cellFor(item, 'label'); + // const isVisible = new Cell(false, 'isVisible'); + + // classic event listener + const onRowClick = () => { + if (selectedCell.value === id) { + return selectedCell.update(0); + } else { + selectedCell.update(id); + } + }; + // const onMouseEnter = () => { + // isVisible.update(true); + // }; + // const onMouseLeave = () => { + // isVisible.update(false); + // }; + + // Create the row and cells + const rootComponent = TagComponent( + { + name: 'tr', + className: formula(() => { + return id === selectedCell.value ? 'danger' : ''; + }), + // events: { + // mouseenter: onMouseEnter, + // mouseleave: onMouseLeave, + // }, + }, + outlet + ); + + const rootNode = rootComponent.nodes[0] as HTMLElement; + + const idCell = TagComponent( + { + name: 'td', + className: 'col-md-1', + text: String(id), + }, + rootNode + ); + + const labelCell = TagComponent( + { + name: 'td', + className: 'col-md-4', + }, + rootNode + ); + + const selectLink = TagComponent( + { + name: 'a', + attributes: { + 'data-test-select': 'true', + }, + events: { + click: onRowClick, + }, + text: cell2, + }, + labelCell.nodes[0] as HTMLElement + ); + + const removeCell = TagComponent( + { + name: 'td', + className: 'col-md-1', + }, + rootNode + ); + + const emptyCell = TagComponent( + { + name: 'td', + className: 'col-md-6', + }, + rootNode + ); + + // const labelCmp = LabelWrapperComponent({ isVisible }, emptyCell.nodes[0] as HTMLElement); + + const destructors: Destructors = [ + ...rootComponent.destructors, + ...selectLink.destructors, + // ...labelCmp.destructors, + ...idCell.destructors, + ...labelCell.destructors, + ...removeCell.destructors, + ...emptyCell.destructors, + ]; + + const rmBtn = TagComponent( + { + name: 'a', + attributes: { 'data-test-remove': 'true' }, + events: { click: () => app.removeItem(item) }, + }, + removeCell.nodes[0] as HTMLElement + ); + + const rmSpan = document.createElement('span'); + rmSpan.className = 'preloadicon glyphicon glyphicon-remove'; + rmSpan.setAttribute('aria-hidden', 'true'); + (rmBtn.nodes[0] as HTMLElement).appendChild(rmSpan); + + rmBtn.destructors.forEach((destructor) => { + destructors.push(destructor); + }); + + const nodes = [...rootComponent.nodes]; + + nodes.forEach((node) => { + targetFor(outlet).appendChild(node); + }); + + return { + nodes, // Bounds of the row / + destructors, // Destructors for opcodes and event listeners + index: 0, // Index of the row in the list + }; +} diff --git a/benchmark/benchmarks/krausest/lib/components/TagComponent.ts b/benchmark/benchmarks/krausest/lib/components/TagComponent.ts new file mode 100644 index 0000000000..8f1677d446 --- /dev/null +++ b/benchmark/benchmarks/krausest/lib/components/TagComponent.ts @@ -0,0 +1,63 @@ +import type { ComponentRenderTarget, Destructors } from '@/utils/component'; +import { addEventListener, targetFor } from '@/utils/component'; +import type { Cell, MergedCell } from '@/utils/reactive'; +import { maybeUpdatingAttributeOpcode, maybeUpdatingPropertyOpcode } from '@/utils/vm'; + +export function TagComponent( + { + name, + className, + events, + slot, + attributes, + text, + }: { + name: string; + className?: string | Cell | MergedCell; + attributes?: Record; + events?: Record; + text?: string | Cell | MergedCell; + slot?: Node; + }, + outlet: ComponentRenderTarget +) { + const element = document.createElement(name); + const destructors: Destructors = []; + if (events) { + Object.keys(events).forEach((eventName) => { + const fn = events[eventName]; + if (fn) { + destructors.push(addEventListener(element, eventName, fn)); + } + }); + } + + if (attributes) { + Object.keys(attributes).forEach((attributeName) => { + const value = attributes[attributeName]; + if (value) { + maybeUpdatingAttributeOpcode(destructors, element, attributeName, value); + } + }); + } + + const slotNode = slot || document.createTextNode(''); + + if (typeof className !== undefined) { + maybeUpdatingPropertyOpcode(destructors, element, 'className', className); + } + + if (typeof text !== undefined) { + maybeUpdatingPropertyOpcode(destructors, slotNode, 'textContent', text); + } + + element.appendChild(slotNode); + + targetFor(outlet).appendChild(element); + + return { + nodes: [element], + destructors, + index: 0, + }; +} diff --git a/benchmark/benchmarks/krausest/lib/components/if.ts b/benchmark/benchmarks/krausest/lib/components/if.ts new file mode 100644 index 0000000000..9c182eeb59 --- /dev/null +++ b/benchmark/benchmarks/krausest/lib/components/if.ts @@ -0,0 +1,35 @@ +import { targetFor, type ComponentRenderTarget } from '@/utils/component'; +import type { Cell } from '@/utils/reactive'; +import { bindUpdatingOpcode } from '@/utils/vm'; + +export function ifCondition( + cell: Cell, + outlet: ComponentRenderTarget, + trueBranch: HTMLElement | null, + falseBranch: HTMLElement | null +) { + const placeholder = document.createComment('placeholder'); + const target = targetFor(outlet); + target.appendChild(placeholder); + return bindUpdatingOpcode(cell, (value) => { + if (value === true) { + dropFirstApplySecond(target, placeholder, falseBranch, trueBranch); + } else { + dropFirstApplySecond(target, placeholder, trueBranch, falseBranch); + } + }); +} + +function dropFirstApplySecond( + target: HTMLElement | DocumentFragment, + placeholder: Comment, + first: HTMLElement | null, + second: HTMLElement | null +) { + if (first && first.isConnected) { + target.removeChild(first); + } + if (second && !second.isConnected) { + target.insertBefore(second, placeholder); + } +} diff --git a/benchmark/benchmarks/krausest/lib/components/list.ts b/benchmark/benchmarks/krausest/lib/components/list.ts new file mode 100644 index 0000000000..f089adeb58 --- /dev/null +++ b/benchmark/benchmarks/krausest/lib/components/list.ts @@ -0,0 +1,98 @@ +import type { Application } from '@/components/Application'; +import { RowComponent } from '@/components/RowComponent'; +import type { Item } from '@/utils/data'; + +/* + This is a list manager, it's used to render and sync a list of items. + It's a proof of concept, it's not optimized, it's not a final API. + + Based on Glimmer-VM list update logic. +*/ + +export class ListComponent { + parent: HTMLElement; + app: Application; + keyMap: Map> = new Map(); + nodes: Node[] = []; + destructors: Array<() => void> = []; + index = 0; + constructor({ app, items }: { app: Application; items: Item[] }, outlet: HTMLElement) { + const table = createTable(); + this.nodes = [table]; + this.app = app; + this.parent = table.childNodes[0] as HTMLElement; + this.syncList(items); + outlet.appendChild(table); + } + keyForItem(item: Item) { + return String(item.id); + } + syncList(items: Item[]) { + const existingKeys = new Set(this.keyMap.keys()); + const amountOfKeys = existingKeys.size; + let targetNode = amountOfKeys > 0 ? this.parent : document.createDocumentFragment(); + const rowsToMove: Array<[ReturnType, number]> = []; + let seenKeys = 0; + items.forEach((item, index) => { + if (seenKeys === amountOfKeys && !(targetNode instanceof DocumentFragment)) { + // optimization for appending items case + targetNode = document.createDocumentFragment(); + } + const key = this.keyForItem(item); + const maybeRow = this.keyMap.get(key); + if (!maybeRow) { + const row = RowComponent({ item, app: this.app }, targetNode); + row.index = index; + this.keyMap.set(key, row); + } else { + seenKeys++; + existingKeys.delete(key); + if (maybeRow.index !== index) { + rowsToMove.push([maybeRow, index]); + } + } + }); + // iterate over existing keys and remove them + existingKeys.forEach((key) => { + this.destroyListItem(key); + }); + // iterate over rows to move and move them + rowsToMove.forEach(([row, index]) => { + const nextItem = items[index + 1]; + if (nextItem === undefined) { + row.index = index; + row.nodes.forEach((node) => this.parent.appendChild(node)); + } else { + const nextKey = this.keyForItem(nextItem); + const nextRow = this.keyMap.get(nextKey); + const firstNode = row.nodes[0]; + if (nextRow && firstNode) { + nextRow.nodes.forEach((node) => this.parent.insertBefore(firstNode, node)); + } + row.index = index; + } + }); + if (targetNode instanceof DocumentFragment) { + this.parent.appendChild(targetNode); + } + return this; + } + destroyListItem(key: string) { + const row = this.keyMap.get(key)!; + row.destructors.forEach((fn) => fn()); + this.keyMap.delete(key); + row.nodes.forEach((node) => { + node.parentElement?.removeChild(node); + }); + return this; + } +} + +function createTable() { + const table = document.createElement('table'); + const tbody = document.createElement('tbody'); + table.className = 'table table-hover table-striped test-data'; + tbody.setAttribute('id', 'tbody'); + table.appendChild(tbody); + return table; +} diff --git a/benchmark/benchmarks/krausest/lib/index.ts b/benchmark/benchmarks/krausest/lib/index.ts index f7ac21134f..52787a1745 100644 --- a/benchmark/benchmarks/krausest/lib/index.ts +++ b/benchmark/benchmarks/krausest/lib/index.ts @@ -1,27 +1,24 @@ -import { createBenchmark } from '@glimmer-workspace/benchmark-env'; +import { createBenchmark } from '@/utils/benchmark'; -import Application from '@/components/Application'; -import ApplicationTemplate from '@/components/Application.hbs'; -import Row from '@/components/Row'; -import RowTemplate from '@/components/Row.hbs'; -import ButtonTemplate from '@/components/BsButton.hbs'; import { enforcePaintEvent, ButtonSelectors, emitDomClickEvent, waitForIdle } from '@/utils/compat'; -export default async function render(element: HTMLElement, isInteractive: boolean) { - const benchmark = createBenchmark(); +// @ts-check +// https://codepen.io/lifeart/pen/abMzEZm?editors=0110 +// https://github.com/glimmerjs/glimmer-vm/issues/1540 - benchmark.templateOnlyComponent('BsButton', ButtonTemplate); - benchmark.basicComponent('Row', RowTemplate, Row); - benchmark.basicComponent('Application', ApplicationTemplate, Application); +export default async function render() { + const benchmark = createBenchmark(); // starting app await waitForIdle(); - const app = await benchmark.render('Application', {}, element, isInteractive); + const app = await benchmark.render(); await waitForIdle(); + // return; + await app('render1000Items1', () => { emitDomClickEvent(ButtonSelectors.Create1000); }); diff --git a/benchmark/benchmarks/krausest/lib/utils/benchmark.ts b/benchmark/benchmarks/krausest/lib/utils/benchmark.ts new file mode 100644 index 0000000000..3a8e4e7cb0 --- /dev/null +++ b/benchmark/benchmarks/krausest/lib/utils/benchmark.ts @@ -0,0 +1,28 @@ +import { Application } from '@/components/Application'; +import { measureRender } from '@/utils/measure-render'; +import { setResolveRender } from '@/utils/runtime'; + +export function createBenchmark() { + return { + async render() { + await measureRender('render', 'renderStart', 'renderEnd', () => { + new Application(); + }); + + performance.measure('load', 'navigationStart', 'renderStart'); + + return async (name: string, update: () => void) => { + await measureRender( + name, + name + 'Start', + name + 'End', + () => + new Promise((resolve) => { + setResolveRender(resolve); + update(); + }) + ); + }; + }, + }; +} diff --git a/benchmark/benchmarks/krausest/lib/utils/component.ts b/benchmark/benchmarks/krausest/lib/utils/component.ts new file mode 100644 index 0000000000..d8a1ace6bd --- /dev/null +++ b/benchmark/benchmarks/krausest/lib/utils/component.ts @@ -0,0 +1,24 @@ +export type ComponentRenderTarget = HTMLElement | DocumentFragment | ComponentReturnType; + +export function targetFor(outlet: ComponentRenderTarget): HTMLElement | DocumentFragment { + if (outlet instanceof HTMLElement || outlet instanceof DocumentFragment) { + return outlet; + } else { + return outlet.nodes[0] as HTMLElement; + } +} + +export type DestructorFn = () => void; +export type Destructors = Array; +export type ComponentReturnType = { + nodes: Node[]; + destructors: Destructors; + index: number; +}; + +export function addEventListener(node: Node, eventName: string, fn: EventListener) { + node.addEventListener(eventName, fn); + return () => { + node.removeEventListener(eventName, fn); + }; +} diff --git a/benchmark/benchmarks/krausest/lib/utils/data.ts b/benchmark/benchmarks/krausest/lib/utils/data.ts index e163a842b7..1940f0b72e 100644 --- a/benchmark/benchmarks/krausest/lib/utils/data.ts +++ b/benchmark/benchmarks/krausest/lib/utils/data.ts @@ -1,22 +1,6 @@ -import { createCell } from '@glimmer-workspace/benchmark-env'; - -export class Item { - /** @type {number} */ - id; - - /** @type {string} */ - _label = createCell(this, 'label', ''); - - constructor(id: number, label: string) { - this.id = id; - this.label = label; - } - get label() { - return this._label.get(); - } - set label(value: string) { - this._label.set(value); - } +export interface Item { + id: number; + label: string; } function _random(max: number) { @@ -82,17 +66,19 @@ export function buildData(count = 1000) { 'keyboard', ], data = []; - for (let i = 0; i < count; i++) - data.push( - new Item( - rowId++, - adjectives[_random(adjectives.length)] + - ' ' + - colours[_random(colours.length)] + - ' ' + - nouns[_random(nouns.length)] - ) - ); + for (let i = 0; i < count; i++) { + const label = + adjectives[_random(adjectives.length)] + + ' ' + + colours[_random(colours.length)] + + ' ' + + nouns[_random(nouns.length)]; + + data.push({ + id: rowId++, + label, + }); + } return data; } diff --git a/benchmark/benchmarks/krausest/lib/utils/measure-render.ts b/benchmark/benchmarks/krausest/lib/utils/measure-render.ts new file mode 100644 index 0000000000..77b9c1ba52 --- /dev/null +++ b/benchmark/benchmarks/krausest/lib/utils/measure-render.ts @@ -0,0 +1,20 @@ +export async function measureRender( + name: string, + startMark: string, + endMark: string, + render: () => Promise | void +) { + const endObserved = new Promise((resolve) => { + new PerformanceObserver((entries, observer) => { + if (entries.getEntriesByName(endMark, 'mark').length > 0) { + resolve(); + observer.disconnect(); + } + }).observe({ type: 'mark' }); + }); + performance.mark(startMark); + await render(); + performance.mark(endMark); + await endObserved; + performance.measure(name, startMark, endMark); +} diff --git a/benchmark/benchmarks/krausest/lib/utils/reactive.ts b/benchmark/benchmarks/krausest/lib/utils/reactive.ts new file mode 100644 index 0000000000..f64c95db6f --- /dev/null +++ b/benchmark/benchmarks/krausest/lib/utils/reactive.ts @@ -0,0 +1,144 @@ +/* + This is a proof of concept for a new approach to reactive programming. + It's related to Glimmer-VM's `@tracked` system, but without invalidation step. + We explicitly update DOM only when it's needed and only if tags are changed. +*/ + +import { scheduleRevalidate } from '@/utils/runtime'; + +// List of DOM operations for each tag +export const opsForTag: WeakMap> = new WeakMap(); +// REVISION replacement, we use a set of tags to revalidate +export const tagsToRevalidate: Set = new Set(); +// List of derived tags for each cell +export const relatedTags: WeakMap> = new WeakMap(); + +// console.info({ +// opsForTag, +// tagsToRevalidate, +// relatedTags, +// }); + +// we have only 2 types of cells +export type AnyCell = Cell | MergedCell; + +let currentTracker: Set | null = null; +let _isRendering = false; + +export function isRendering() { + return _isRendering; +} +export function setIsRendering(value: boolean) { + _isRendering = value; +} + +function tracker() { + return new Set(); +} + +// "data" cell, it's value can be updated, and it's used to create derived cells +export class Cell { + _value!: T; + _debugName?: string | undefined; + constructor(value: T, debugName?: string) { + this._value = value; + this._debugName = debugName; + } + get value() { + if (currentTracker !== null) { + currentTracker.add(this); + } + return this._value; + } + update(value: T) { + this._value = value; + tagsToRevalidate.add(this); + scheduleRevalidate(); + } +} + +export function listDependentCells(cells: Array, cell: MergedCell) { + const msg = [cell._debugName, 'depends on:']; + cells.forEach((cell) => { + msg.push(cell._debugName); + }); + return msg.join(' '); +} + +function bindAllCellsToTag(cells: Set, tag: MergedCell) { + cells.forEach((cell) => { + const tags = relatedTags.get(cell) || new Set(); + tags.add(tag); + relatedTags.set(cell, tags); + }); + // console.info(listDependentCells(Array.from(cells), tag)); +} + +// "derived" cell, it's value is calculated from other cells, and it's value can't be updated +export class MergedCell { + fn: () => unknown; + isConst = false; + _debugName?: string | undefined; + constructor(fn: () => unknown, debugName?: string) { + this.fn = fn; + this._debugName = debugName; + } + get value() { + if (this.isConst) { + return this.fn(); + } + if (null === currentTracker && !_isRendering) { + currentTracker = tracker(); + try { + return this.fn(); + } finally { + if (currentTracker.size > 0) { + bindAllCellsToTag(currentTracker, this); + } else { + this.isConst = true; + } + currentTracker = null; + } + } else { + return this.fn(); + } + } +} + +// this function is called when we need to update DOM, values represented by tags are changed +export type tagOp = (...values: unknown[]) => void; + +// this is runtime function, it's called when we need to update DOM for a specific tag +export function executeTag(tag: Cell | MergedCell) { + try { + const ops = opsForTag.get(tag) || []; + const value = tag.value; + ops.forEach((op) => { + try { + op(value); + } catch (e: any) { + console.error(`Error executing tag op: ${e.toString()}`); + } + }); + } catch (e: any) { + console.error(`Error executing tag: ${e.toString()}`); + } +} + +// this is function to create a reactive cell from an object property +export function cellFor(obj: T, key: K): Cell { + const cellValue = new Cell(obj[key], `${obj.constructor.name}.${String(key)}`); + Object.defineProperty(obj, key, { + get() { + return cellValue.value; + }, + set(val) { + cellValue.update(val); + }, + }); + return cellValue; +} + +export function formula(fn: () => unknown) { + return new MergedCell(fn, 'formula'); +} diff --git a/benchmark/benchmarks/krausest/lib/utils/runtime.ts b/benchmark/benchmarks/krausest/lib/utils/runtime.ts new file mode 100644 index 0000000000..c750fdddcd --- /dev/null +++ b/benchmark/benchmarks/krausest/lib/utils/runtime.ts @@ -0,0 +1,45 @@ +import { + setIsRendering, + type MergedCell, + tagsToRevalidate, + executeTag, + relatedTags, +} from '@/utils/reactive'; + +let revalidateScheduled = false; +type voidFn = () => void; +let resolveRender: undefined | voidFn = undefined; + +export function setResolveRender(value: () => void) { + resolveRender = value; +} + +export function scheduleRevalidate() { + if (!revalidateScheduled) { + revalidateScheduled = true; + Promise.resolve().then(() => { + syncDom(); + if (resolveRender !== undefined) { + resolveRender(); + resolveRender = undefined; + } + revalidateScheduled = false; + }); + } +} + +export function syncDom() { + const sharedTags = new Set(); + setIsRendering(true); + tagsToRevalidate.forEach((tag) => { + executeTag(tag); + relatedTags.get(tag)?.forEach((tag) => { + sharedTags.add(tag); + }); + }); + sharedTags.forEach((tag) => { + executeTag(tag); + }); + tagsToRevalidate.clear(); + setIsRendering(false); +} diff --git a/benchmark/benchmarks/krausest/lib/utils/vm.ts b/benchmark/benchmarks/krausest/lib/utils/vm.ts new file mode 100644 index 0000000000..d5068a340b --- /dev/null +++ b/benchmark/benchmarks/krausest/lib/utils/vm.ts @@ -0,0 +1,51 @@ +import { MergedCell, Cell, opsForTag, type AnyCell, type tagOp } from './reactive'; + +export function maybeUpdatingAttributeOpcode( + destructors: Array<() => void>, + node: T, + name: string, + value: undefined | null | string | Cell | MergedCell +) { + if (value instanceof Cell || value instanceof MergedCell) { + destructors.push( + bindUpdatingOpcode(value, (value) => { + node.setAttribute(name, String(value ?? '')); + }) + ); + } else { + node.setAttribute(name, String(value ?? '')); + } +} + +export function maybeUpdatingPropertyOpcode( + destructors: Array<() => void>, + node: T, + property: keyof T, + value: undefined | null | string | Cell | MergedCell +) { + if (value instanceof Cell || value instanceof MergedCell) { + destructors.push( + bindUpdatingOpcode(value, (value) => { + (node as any)[property] = value; + }) + ); + } else { + (node as any)[property] = value || ''; + } +} + +// this function creates opcode for a tag, it's called when we need to update DOM for a specific tag +export function bindUpdatingOpcode(tag: AnyCell, op: tagOp) { + const ops = opsForTag.get(tag) || []; + // apply the op to the current value + op(tag.value); + ops.push(op); + opsForTag.set(tag, ops); + return () => { + // console.info(`Removing Updating Opcode for ${tag._debugName}`); + const index = ops.indexOf(op); + if (index > -1) { + ops.splice(index, 1); + } + }; +} diff --git a/benchmark/benchmarks/krausest/vite.config.mts b/benchmark/benchmarks/krausest/vite.config.mts index ae34fe4b7b..bed691189e 100644 --- a/benchmark/benchmarks/krausest/vite.config.mts +++ b/benchmark/benchmarks/krausest/vite.config.mts @@ -1,6 +1,6 @@ -import fs from 'node:fs'; +// import fs from 'node:fs'; -import { precompile } from '@glimmer/compiler'; +// import { precompile } from '@glimmer/compiler'; import { defineConfig, type Plugin } from 'vite'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -47,9 +47,9 @@ function benchmark(): Plugin { /** @type {string | undefined} */ let result: string | undefined; if (id.endsWith('.hbs')) { - const source = fs.readFileSync(id, 'utf8'); - const compiled = precompile(source); - result = `export default ${compiled};`; + // const source = fs.readFileSync(id, 'utf8'); + // // const compiled = precompile(source); + // result = `export default ${compiled};`; } return result; }, diff --git a/bin/setup-bench.mjs b/bin/setup-bench.mjs index eee3ffd1d8..d7278b1d41 100644 --- a/bin/setup-bench.mjs +++ b/bin/setup-bench.mjs @@ -65,11 +65,11 @@ const pwdRaw = await $`pwd`; const pwd = pwdRaw.toString().trim(); // we use benchmark from current commit, very useful if we need to tweak it -const benchmarkFolder = 'benchmark'; +// const benchmarkFolder = 'benchmark'; // remove node_modules from benchmark folder, maybe we could figure out better option to distribute bench source -await $`rm -rf ${join(pwd, benchmarkFolder, 'node_modules')}`; -await $`rm -rf ${join(pwd, benchmarkFolder, 'benchmarks', 'krausest', 'node_modules')}`; +// await $`rm -rf ${join(pwd, benchmarkFolder, 'node_modules')}`; +// await $`rm -rf ${join(pwd, benchmarkFolder, 'benchmarks', 'krausest', 'node_modules')}`; await $`rm -rf ${CONTROL_DIR}`; await $`rm -rf ${EXPERIMENT_DIR}`; @@ -78,7 +78,7 @@ await $`mkdir ${EXPERIMENT_DIR}`; const isMacOs = os.platform() === 'darwin'; -const BENCHMARK_FOLDER = join(pwd, benchmarkFolder); +// const BENCHMARK_FOLDER = join(pwd, benchmarkFolder); const rawUpstreamUrl = await $`git ls-remote --get-url upstream`; const rawOriginUrl = await $`git ls-remote --get-url origin`; @@ -107,8 +107,8 @@ await within(async () => { await cd(EXPERIMENT_DIR); await $`git clone ${originUrlStr} .`; await $`git checkout ${experimentBranchName}`; - await $`rm -rf ./benchmark`; - await $`cp -r ${BENCHMARK_FOLDER} ./benchmark`; + // await $`rm -rf ./benchmark`; + // await $`cp -r ${BENCHMARK_FOLDER} ./benchmark`; console.info('installing experiment source'); await $`pnpm install --no-frozen-lockfile`.quiet(); @@ -134,8 +134,8 @@ await within(async () => { await cd(CONTROL_DIR); await $`git clone ${upstreamUrlStr} .`; await $`git checkout ${controlBranchName}`; - await $`rm -rf ./benchmark`; - await $`cp -r ${BENCHMARK_FOLDER} ./benchmark`; + // await $`rm -rf ./benchmark`; + // await $`cp -r ${BENCHMARK_FOLDER} ./benchmark`; console.info('installing control source'); await $`pnpm install --no-frozen-lockfile`.quiet(); diff --git a/packages/@glimmer-workspace/benchmark-env/lib/benchmark/render-benchmark.ts b/packages/@glimmer-workspace/benchmark-env/lib/benchmark/render-benchmark.ts index d58e263f61..971c4f5276 100644 --- a/packages/@glimmer-workspace/benchmark-env/lib/benchmark/render-benchmark.ts +++ b/packages/@glimmer-workspace/benchmark-env/lib/benchmark/render-benchmark.ts @@ -51,7 +51,7 @@ export default async function renderBenchmark( } }); }); - + // performance.measure('load', 'navigationStart', 'renderStart'); return async (name, update) => {