Created
December 27, 2023 08:33
-
-
Save lifeart/769f86a82e0bf6198c23bc4f527306da to your computer and use it in GitHub Desktop.
test-vm
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// @ts-check | |
// https://codepen.io/lifeart/pen/abMzEZm?editors=0110 | |
// https://github.com/glimmerjs/glimmer-vm/issues/1540 | |
/* | |
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. | |
*/ | |
let rowId = 1; | |
// List of DOM operations for each tag | |
const opsForTag: WeakMap<Cell | MergedCell, Array<tagOp>> = new WeakMap(); | |
// REVISION replacement, we use a set of tags to revalidate | |
const tagsToRevalidate: Set<Cell> = new Set(); | |
// List of derived tags for each cell | |
const relatedTags: WeakMap<Cell, Set<MergedCell>> = new WeakMap(); | |
console.info({ | |
opsForTag, | |
tagsToRevalidate, | |
relatedTags, | |
}); | |
// we have only 2 types of cells | |
type AnyCell = Cell | MergedCell; | |
let currentTracker: Set<Cell> | null = null; | |
let isRendering = false; | |
function tracker() { | |
return new Set<Cell>(); | |
} | |
// "data" cell, it's value can be updated, and it's used to create derived cells | |
class Cell<T extends unknown = unknown> { | |
_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); | |
} | |
} | |
function listDependentCells(cells: Array<AnyCell>, cell: MergedCell) { | |
const msg = [cell._debugName, 'depends on:']; | |
cells.forEach((cell) => { | |
msg.push(cell._debugName); | |
}); | |
return msg.join(' '); | |
} | |
function bindAllCellsToTag(cells: Set<Cell>, 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 | |
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 | |
type tagOp = (...values: unknown[]) => void; | |
// this is runtime function, it's called when we need to update DOM for a specific tag | |
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 | |
function cellFor<T extends object, K extends keyof T>(obj: T, key: K): Cell<T[K]> { | |
const cellValue = new Cell<T[K]>(obj[key], `${obj.constructor.name}.${String(key)}`); | |
Object.defineProperty(obj, key, { | |
get() { | |
return cellValue.value; | |
}, | |
set(val) { | |
cellValue.update(val); | |
}, | |
}); | |
return cellValue; | |
} | |
// this function creates opcode for a tag, it's called when we need to update DOM for a specific tag | |
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); | |
} | |
}; | |
} | |
function formula(fn: () => unknown) { | |
return new MergedCell(fn, 'formula'); | |
} | |
/* | |
Here is sample application, it's a list of items, each item has an id and a label. | |
We have a "selected" cell, which is used to highlight the selected item. | |
We have a "remove" link, which is used to remove an item from the list. | |
We have a "remove" link text, which is used to update the text of the link. | |
*/ | |
interface Item { | |
id: number; | |
label: string; | |
} | |
type DestructorFn = () => void; | |
type Destructors = Array<DestructorFn>; | |
type ComponentReturnType = { | |
nodes: Node[]; | |
destructors: Destructors; | |
index: number; | |
}; | |
const selectedCell = new Cell(0, 'selectedCell'); | |
function ButtonComponent( | |
{ onClick, text, slot, id }: { onClick: () => void; text: string; slot?: Node; id?: string }, | |
outlet: HTMLElement | |
) { | |
const button = document.createElement('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, | |
}; | |
} | |
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, | |
}; | |
} | |
type ComponentRenderTarget = HTMLElement | DocumentFragment | ComponentReturnType; | |
function targetFor(outlet: ComponentRenderTarget): HTMLElement | DocumentFragment { | |
if (outlet instanceof HTMLElement || outlet instanceof DocumentFragment) { | |
return outlet; | |
} else { | |
return outlet.nodes[0] as HTMLElement; | |
} | |
} | |
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); | |
} | |
} | |
function ifCondition( | |
cell: Cell<boolean>, | |
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 LabelWrapperComponent( | |
{ isVisible }: { isVisible: Cell<boolean> }, | |
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, | |
}; | |
} | |
function maybeUpdatingOpcode<T extends Node>( | |
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 || ''; | |
} | |
} | |
function TagComponent( | |
{ | |
name, | |
className, | |
events, | |
slot, | |
text, | |
}: { | |
name: string; | |
className: string | Cell | MergedCell; | |
events?: Record<string, EventListener>; | |
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)); | |
} | |
}); | |
} | |
const slotNode = slot || document.createTextNode(''); | |
maybeUpdatingOpcode(destructors, element, 'className', className); | |
if (typeof text !== undefined) { | |
maybeUpdatingOpcode(destructors, slotNode, 'textContent', text); | |
} | |
element.appendChild(slotNode); | |
targetFor(outlet).appendChild(element); | |
return { | |
nodes: [element], | |
destructors, | |
index: 0, | |
}; | |
} | |
function RowComponent({ item, app }: { item: Item; app: MyApp }, outlet: ComponentRenderTarget) { | |
// create cells for the item | |
const id = item.id; | |
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: { | |
click: onRowClick, | |
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', | |
text: cell2, | |
slot: document.createElement('a'), | |
}, | |
rootNode | |
); | |
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 removeLinkSlot = document.createTextNode(''); | |
const destructors: Array<() => void> = [ | |
...rootComponent.destructors, | |
...labelCmp.destructors, | |
...idCell.destructors, | |
...labelCell.destructors, | |
...removeCell.destructors, | |
...emptyCell.destructors, | |
]; | |
maybeUpdatingOpcode( | |
destructors, | |
removeLinkSlot, | |
'textContent', | |
formula(() => { | |
return `[x]`; | |
}) | |
); | |
const rmBtn = ButtonComponent( | |
{ | |
onClick() { | |
app.removeItem(item); | |
}, | |
slot: removeLinkSlot, | |
text: ``, | |
}, | |
removeCell.nodes[0] as HTMLElement | |
); | |
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 | |
}; | |
} | |
/* | |
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. | |
*/ | |
class ListComponent { | |
parent: HTMLElement; | |
app: MyApp; | |
keyMap: Map<string, ReturnType<typeof RowComponent>> = new Map(); | |
nodes: Node[] = []; | |
destructors: Array<() => void> = []; | |
index = 0; | |
constructor({ app, items }: { app: MyApp; items: Item[] }, outlet: HTMLElement) { | |
const table = createTable(); | |
this.nodes = [table]; | |
this.app = app; | |
this.parent = table; | |
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<typeof RowComponent>, 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; | |
} | |
} | |
class MyApp { | |
items: Item[]; | |
list: ListComponent; | |
children: ComponentReturnType[] = []; | |
buttonWrapper() { | |
const div = document.createElement('div'); | |
div.className = 'col-sm-6 smallpad'; | |
return div; | |
} | |
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_10_000_Items(), | |
text: 'Create 10 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 = buildData(1000); | |
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); | |
/* benchmark icon preload span end */ | |
this.children.push(this.list); | |
} | |
syncList() { | |
this.list.syncList(this.items); | |
} | |
removeItem(item: Item) { | |
const key = this.list.keyForItem(item); | |
this.items = this.items.filter((i) => i.id !== item.id); | |
this.list.destroyListItem(key); | |
} | |
create_1_000_Items() { | |
this.items = buildData(1000); | |
this.syncList(); | |
} | |
append_1_000_Items() { | |
this.items = [...this.items, ...buildData(1000)]; | |
this.syncList(); | |
} | |
create_10_000_Items() { | |
this.items = buildData(10000); | |
this.syncList(); | |
} | |
swapRows() { | |
this.items = swapRows(this.items); | |
this.syncList(); | |
} | |
clear() { | |
this.items = []; | |
this.syncList(); | |
} | |
// Update every 10th row | |
updateEvery_10th_row() { | |
updateData(this.items, 10); | |
this.syncList(); | |
} | |
} | |
const App = new MyApp(); | |
// Let's create a list of items and render it | |
// run the update loop | |
update(); | |
// this is our update loop | |
async function update() { | |
while (true) { | |
let resolve: (value: unknown) => void; | |
const promise = new Promise((res) => { | |
resolve = res; | |
}); | |
requestAnimationFrame(() => { | |
const sharedTags = new Set<MergedCell>(); | |
isRendering = true; | |
tagsToRevalidate.forEach((tag) => { | |
executeTag(tag); | |
relatedTags.get(tag)?.forEach((tag) => { | |
sharedTags.add(tag); | |
}); | |
}); | |
sharedTags.forEach((tag) => { | |
executeTag(tag); | |
}); | |
tagsToRevalidate.clear(); | |
isRendering = false; | |
resolve(void 0); | |
}); | |
await promise; | |
await new Promise((resolve) => setTimeout(resolve)); | |
} | |
} | |
function addEventListener(node: Node, eventName: string, fn: EventListener) { | |
node.addEventListener(eventName, fn); | |
return () => { | |
node.removeEventListener(eventName, fn); | |
}; | |
} | |
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; | |
} | |
function _random(max: number) { | |
return (Math.random() * max) | 0; | |
} | |
export function buildData(count = 1000) { | |
const adjectives = [ | |
'pretty', | |
'large', | |
'big', | |
'small', | |
'tall', | |
'short', | |
'long', | |
'handsome', | |
'plain', | |
'quaint', | |
'clean', | |
'elegant', | |
'easy', | |
'angry', | |
'crazy', | |
'helpful', | |
'mushy', | |
'odd', | |
'unsightly', | |
'adorable', | |
'important', | |
'inexpensive', | |
'cheap', | |
'expensive', | |
'fancy', | |
], | |
colours = [ | |
'red', | |
'yellow', | |
'blue', | |
'green', | |
'pink', | |
'brown', | |
'purple', | |
'brown', | |
'white', | |
'black', | |
'orange', | |
], | |
nouns = [ | |
'table', | |
'chair', | |
'house', | |
'bbq', | |
'desk', | |
'car', | |
'pony', | |
'cookie', | |
'sandwich', | |
'burger', | |
'pizza', | |
'mouse', | |
'keyboard', | |
], | |
data = []; | |
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; | |
} | |
function swapRows(data: Item[]): Item[] { | |
const newData: Item[] = [...data]; | |
if (newData.length > 998) { | |
const temp = newData[1]; | |
newData[1] = newData[998] as Item; | |
newData[998] = temp as Item; | |
} | |
return newData; | |
} | |
function updateData(data: Item[], mod = 10): Item[] { | |
for (let i = 0; i < data.length; i += mod) { | |
let item = data[i] as Item; | |
item.label = item.label + ' !!!'; | |
} | |
return data; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment