Skip to content

Instantly share code, notes, and snippets.

@lifeart
Created December 27, 2023 08:33
Show Gist options
  • Save lifeart/769f86a82e0bf6198c23bc4f527306da to your computer and use it in GitHub Desktop.
Save lifeart/769f86a82e0bf6198c23bc4f527306da to your computer and use it in GitHub Desktop.
test-vm
// @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