aweft

recipes/dom/main.ts

One file of recipes/dom, at commit 9a5bb24.

// A todo page, built on dom, that checks what it did to the tree.
//
// The job is an ordinary one: a list of todos in a document, a filter, a count, a form. What
// makes it a proof rather than a demo is that every edit asserts the exact DOM operations it
// caused against the recording host, the page renders to markup with no browser, and that
// markup is hydrated in place with the server nodes kept. If the binding ever did more work
// than an edit calls for, or rebuilt what it should adopt, this program would exit nonzero.
//
// Run: node recipes/dom/main.ts

import assert from 'node:assert/strict';

import { atomic, createArray, createObject, mutable, observer } from '@aweftjs/core';
import { recordingDocument } from '@aweftjs/testing';
import type { Cleanup, LightElement, Mounted, Pending } from '@aweftjs/dom';
import { createDocument, getFirst, h, html, hydrate, mount, render, toHtml } from '@aweftjs/dom';

// --- the application -------------------------------------------------------------------

interface Todo extends Record<string, unknown> {
	title: string;
	done: boolean;
}

interface State extends Record<string, unknown> {
	todos: Todo[];
	filter: 'all' | 'open';
}

const todo = (title: string, done = false): Todo => createObject<Todo>({ title, done });

const Item = ({ each: item }: { each: Todo }) => {
	const done = observer(item).path('done');
	const title = observer(item).path('title');
	// The box is named by the title beside it (the access rules, design 265).
	return h('li', { class: done.bool('done', null) },
		h('input', { type: 'checkbox', 'aria-label': title, $checked: done, $onchange: () => { item.done = !item.done; } }),
		' ', title,
	);
};

const Footer = ({ state }: { state: State }) => {
	const open = observer(state).path('todos').map((todos) => (todos as Todo[]).filter((t) => !t.done).length);
	return html`<footer>${open} open of ${observer(state).path('todos').map((t) => (t as Todo[]).length)}</footer>`;
};

const App = ({ state }: { state: State }) => {
	const draft = mutable('');
	const add = () => {
		if (draft.get() === '') return;
		state.todos.push(todo(draft.get()));
		draft.set('');
	};
	return html`
		<main>
			<form $onsubmit=${add}>
				<input $value=${draft} $oninput=${(e: { target: { value: string } }) => draft.set(e.target.value)} />
			</form>
			<ul><${Item} each=${state.todos} /></ul>
			<${Footer} state=${state} />
		</main>
	`;
};

// --- the checks -----------------------------------------------------------------------------

const state = createObject<State>({ todos: createArray<Todo>([todo('write the proof'), todo('run it', true)]), filter: 'all' });
const { document, ops } = recordingDocument();
const stop = mount(document.body, h(App, { state }));
const main = (document.body as LightElement).children[0]!;
const ul = main.children[1]!;
assert.equal(ul.children.length, 2);
assert.equal(main.children[2]!.textContent, '1 open of 2');

// Adding a todo is one insert into the list, after the row is built off-document.
ops.length = 0;
state.todos.push(todo('ship'));
assert.deepEqual(ops.filter((op) => op.includes('<ul>')), ['insert <li> into <ul> before end']);
assert.equal(main.children[2]!.textContent, '2 open of 3');

// Toggling a todo touches the row's class and checkbox, and no node moves.
ops.length = 0;
ul.children[0]!.children[0]!.dispatchEvent({ type: 'change' });
assert.ok(ops.every((op) => op.startsWith('attr') || op.startsWith('unattr') || op.startsWith('text')), ops.join(' | '));
assert.equal(ul.children[0]!.getAttribute('class'), 'done');
assert.equal(main.children[2]!.textContent, '1 open of 3');

// A swap in one block moves the two rows and keeps them: the checkbox is the same node.
const firstBox = ul.children[0]!.children[0]!;
ops.length = 0;
atomic(() => {
	const t = state.todos[0]!;
	state.todos[0] = state.todos[2]!;
	state.todos[2] = t;
});
assert.ok(ops.every((op) => op.startsWith('insert <li> into <ul>') || op.startsWith('remove <li> from <ul>')), ops.join(' | '));
assert.equal(ul.children[2]!.children[0], firstBox);
assert.equal(ul.children[0]!.textContent, ' ship');

// Removing one is one remove; emptying the whole list is one write.
ops.length = 0;
state.todos.splice(1, 1);
assert.deepEqual(ops.filter((op) => op.includes('<ul>')), ['remove <li> from <ul>']);
ops.length = 0;
state.todos.splice(0, state.todos.length);
assert.deepEqual(ops.filter((op) => op.includes('<ul>')), ['clear <ul>']);
assert.equal(main.children[2]!.textContent, '0 open of 0');
state.todos.push(todo('after the clear'));
assert.equal(ul.children.length, 1);

stop();
assert.equal(toHtml(document.body), '<body></body>');

// --- static render, then hydration in place ---------------------------------------------------

// Content that arrives later: the server declares it pending and waits; the client is handed
// the value the server got, so both render the same thing and hydration has nothing to heal.
const Status = ({ known }: { known: string | null }, _c: Cleanup, _m: Mounted, pending: Pending) => {
	const text = mutable(known ?? 'loading');
	if (known === null) pending(new Promise<void>((resolve) => setTimeout(() => { text.set('loaded'); resolve(); }, 5)));
	return h('p', { id: 'status' }, text);
};

const page = createObject<State>({ todos: createArray<Todo>([todo('render me'), todo('and me', true)]), filter: 'all' });
const Page = ({ known }: { known: string | null }) => [h(App, { state: page }), h(Status, { known })];
const markup = await render(h(Page, { known: null }));
assert.ok(markup.includes('<p id="status"><!--[-->loaded<!--]--></p>'), 'render waited for the pending content');
assert.ok(markup.includes('<li class="done">'), 'the done row rendered its class');

const client = createDocument();
client.body.innerHTML = markup;
const serverMain = client.body.children[0]!;
const serverRow = serverMain.children[1]!.children[0]!;
const serverStatus = client.body.children[1]!;

const stopHydrated = hydrate(client.body, h(Page, { known: 'loaded' }));
assert.equal(client.body.children[0], serverMain, 'the main element was adopted');
assert.equal(serverMain.children[1]!.children[0], serverRow, 'the first row was adopted');
assert.equal(client.body.children[1], serverStatus, 'the status element was adopted');
assert.equal(serverStatus.textContent, 'loaded');
assert.equal(stopHydrated(getFirst)!.nodeName, '#comment');

// The adopted page is live: an edit reaches the adopted nodes.
page.todos.push(todo('added after hydration'));
assert.equal(serverMain.children[1]!.children.length, 3);
serverRow.children[0]!.dispatchEvent({ type: 'change' });
assert.equal(serverRow.getAttribute('class'), 'done');
assert.equal(serverMain.children[2]!.textContent, '1 open of 3');

stopHydrated();
assert.equal(toHtml(client.body), '<body></body>');

console.log('dom proof: mounted with exact operations, rendered with pending content, hydrated in place; 26 checks passed');