aweft

@aweftjs/dom

The DOM binding: state from @aweftjs/core onto real nodes, with no virtual tree. h makes an element the moment it runs, a slot binds straight to a node, and a list binds straight to the commits its array produces. The same page mounts in a browser, renders to markup with no browser, and takes over that markup in place.

Quickstart

import { mutable } from '@aweftjs/core';
import { html, mount } from '@aweftjs/dom';

const count = mutable(0);

mount(document.body, html`
	<button $onclick=${() => count.set(count.get() + 1)}>
		Button clicked ${count} times
	</button>
	<button $onclick=${() => count.set(0)}>Reset</button>
`);

mount returns the function that unmounts. Everything mount takes:

itemwhat happens
a string, number or booleana text node
nullnothing. undefined is refused, so a typo cannot render as silence
a nodeinserted as it is
h() output, html outputthe element, with its reactive parts bound
an array or any iterableeach item in order
a document array (createArray), a mutable array (mutableArray)a list that follows every edit
a scope, a cell or a derived valueits value, replaced in place as it changes
a component's mounter (h(Component))the component's result

An item may be mounted, unmounted and mounted again, which is what a slot component whose branches are written out as markup hands the binding: the branch is built once and mounted every time it comes back. Unmounting takes what the mount put in back out, so the element is left holding the static nodes h built and the next mount renders once (design 204).

The one exception is a list. A list edit takes a row's nodes out itself and then tells the row its nodes are already gone, which is what keeps a list edit to one write. So an element the page holds, put into a list, removed by an edit and put back, keeps what its last mount left in it, and the second mount renders its reactive parts on top: <em>one</em> comes back as <em>oneone</em>. Build list items per row, with a component under each, and there is no element to hand back.

Elements: h and html

h(tag, props, ...children) is what html compiles to at runtime, and what a JSX transform targets. A bare prop name is an attribute; $name is a property on the element, which is how value, checked, textContent, and every event handler ($onclick) are set. $style takes an object and writes each key onto element.style.

h('input', { type: 'text', $value: draft, $oninput: (e) => draft.set(e.target.value) });
h('div', { class: active.bool('on', 'off'), $style: { color, fontWeight: 'bold' } }, 'label');

Any prop or child may be a scope, cell or derived value, and follows it. A null, undefined or false attribute value removes the attribute; true sets it empty. An element with no reactive parts is returned as itself, so const box = h('div', { class: 'box' }) is a node you can hand to the browser.

html is a tag for template literals. A tag may be an expression (<${Component}>), </> closes the innermost open element, <!-- ... --> is a comment and goes, and ${value} between tags is a child. Whitespace works as in JSX: lines are trimmed, blank lines go, and a line break inside text is one space. Close void elements yourself (<br/>).

Inside a tag, these are all the forms there are:

writtenmeans
hiddenthe attribute set to true
id=plainthe unquoted text up to the next space or >
class="row ${tone}"the quoted parts as one value, derived when a cell is among them
title=${t}, $onclick=${fn}the expression itself, followed if it is a cell
=${props} or ${props}a spread: every key of the object becomes a prop

A spread is the one form with two spellings. =${props} is the explicit one; a hole on its own, with no name in front of it, means the same thing. Either way it must be an object, and a page that hands it anything else gets a spread in a tag must be an object in development.

htm(h, { join }) binds the parser to another h; join says how a quoted attribute of several parts becomes one value, by default the parts concatenated, and derived when a cell is among them.

Components

A component is a function called once, with its props, when it mounts:

const Timer = ({ children }, cleanup, mounted, pending) => {
	const seconds = timer(1000);
	cleanup(() => console.log('gone'));
	mounted(() => console.log('in the document, descendants included'));
	return h('p', {}, 'up for ', seconds, 's ', ...children);
};

mount(document.body, h(Timer, {}, 'and counting'));

props.children is always an array. cleanup takes functions to run after the component and everything below it is unmounted; calling it after that runs them at once. mounted takes functions to run once the component's nodes are in the document, children before parents, and may only be called while mounting. pending takes a promise render will wait for (below). A component may return anything mount takes, including another component or a mounter of its own.

The body runs from a queue the mount drains once its own walk is done, never from inside a reconciliation, so a component that edits state while mounting cannot corrupt the tree. A body that throws names the component in the error, the rest of the queue still runs, and the error reaches whoever made the change.

An assert reports a defect and does not recover: what was inserted before the throw stays in the page, the caller of mount gets no remove function, and an array edited from the delivery that threw keeps its edit. Fix the defect rather than catching the assert.

each mounts a component once per item, the item in props.each:

const Todo = ({ each: todo }) => h('li', { class: observer(todo).path('done').bool('done', '') },
	observer(todo).path('title'));

h('ul', {}, h(Todo, { each: state.todos }));

A component under each renders the same node shape on every call. The first row is built and remembered; every row after it is a clone of that one with this row's values written in, which is what makes a long list cheap. So the tags, their order, the prop keys and which children are present have to be the same for every item. What changes per row is values: text, attribute and property values, and what a scope or cell drives.

A function is a value like any other, and it belongs to its row. Each row's body is called with a props object of its own, so a handler written in the body reads that row's props.each whenever it fires, whether it goes on a nested component or straight onto an element as $onclick (design 205).

Most of this is unchecked, and breaking it is quiet. A tag that varies keeps the first row's tag, and a prop key or a child that only some rows have is dropped. Branch on the item inside a value, not around the markup:

// Good: one shape, a value that varies.
({ each: todo }) => h('li', { class: observer(todo).path('done').bool('done', '') }, todo.title);

// Wrong: two shapes from one component. Every row takes the first row's tag, silently.
({ each: todo }) => (todo.done ? h('s', {}, todo.title) : h('li', {}, todo.title));

The prop keys have to match too, including the keys inside a $style object: a row that names fewer of them than the first row did keeps the first row's, because a clone carries the style it was cloned from. And what h returns inside such a body is only good for handing to another h or returning; a body that keeps it, reads a property off it or mounts into it is not using h to build the row, and the list quietly stops cloning when it can tell.

Two shapes means two components, each with its own each, or one component whose body mounts the varying part as a child. Two breaks are caught. A row whose body returned something other than the element h built for it, such as text or a pair, runs its body a second time, and the list stops cloning from then on: its rows are correct and slower. A row that called h a different number of times than the first row asserts, naming both counts, because its values no longer line up with anything.

Lists

A document array or a mutable array delivers every edit as the edit: a push inserts one node, a splice removes one, a swap inside one atomic block moves the two row nodes themselves, so an input inside keeps what it holds. Emptying the whole list when it is the parent's whole content is one write. A plain array in a cell is diffed by reference when the cell is replaced: rows that are the same value stay, others are made or removed.

state.todos.push(createObject({ title: 'ship', done: false }));   // one insert
atomic(() => { const t = todos[0]; todos[0] = todos[1]; todos[1] = t; }); // two moves

Register a scope where you read it, on the row rather than the document root: observer(row) .path('title') costs nothing on writes elsewhere.

Static render

const markup = await render(h(App, { url }));

render mounts the item into a light document this package ships, waits until nothing a component declared pending is still pending, and returns the markup. Every dynamic part (a scope, a component, each item of a list) is bracketed with <!--[--> and <!--]-->, which is how hydrate finds it. No doctype is added. Inside render, h and createElement make nodes in the light document, so a component works unchanged. render(item, { context }) hands context to every mounter below, as mount does.

A component that fetches declares it: pending(fetch(url).then((r) => r.json()).then(fill)). A component that forgets renders its loading state into the page, which is visible. The continuation runs outside the mount: set state in it and let the component's bindings build the nodes, because h called from the continuation itself has no render document to make them in.

Hydration

hydrate(document.body, h(App, { url: location.pathname }));
hydrate(document.body, () => h('main', {}, 'ready'));

hydrate takes what makes the item: a component call, h(App, props), or a function that makes it, which is mounted as a component with no props. It cannot take the element itself. Only a mount records which nodes the binding made, so an element built before the call was made outside every mount and has nothing recorded about it; handed straight in, or returned by the maker, it is refused, and the refusal names the maker form as the fix.

hydrate mounts that item over markup render wrote and adopts the server's nodes in place: a matching element keeps its identity and gains the properties and listeners the client gives it, text is adopted, lists keep working. Nothing is wiped and nothing flashes.

A mismatch between the markup and what the client renders is a defect: it asserts in development, and in a release build the region that differs is replaced with what the client built while the rest is kept. Only the top-level item is refused for being built outside the mount. Deeper in the tree a node the application made outside the mount, by h or by the page, is inserted as it is and stands in for the server's node of the same tag, which is how a component keeps a canvas or a map it owns. Markup must parse back into the tree render wrote: write tbody yourself, and avoid whitespace-only text where the browser drops it.

hydrate waits for what the page is still loading. The pairing walk stays open until every promise a component declared pending during the hydration has settled, so a page whose content arrives one microtask later still claims the markup the server wrote for it. Until then nothing has been checked and nothing the server sent has been taken away: what is on screen is the server's markup, untouched.

const page = hydrate(document.body, h(App, { url: location.pathname }));
await page.ready;                       // every pending load settled, the markup checked

ready is on the remove function hydrate answers, and it resolves for a page with nothing pending before the call has even returned. It never rejects: a mismatch found after the wait is thrown on a fresh task, where the host reports it. Removing the hydration while a load is in flight closes the walk and checks nothing, because the page it was pairing has gone.

A component that waits with pending and shows a loading state runs again on the client, and showing that state over the server's finished markup is a mismatch, whatever the wait does after. Either render what you know (give the resolved value as a prop from data the page embeds, h(Status, { known }), which is what recipes/dom/ shows) or show nothing while a hydration is open: hydrating() answers whether the mount running right now is claiming server nodes, and @aweftjs/ui's suspend uses it to keep its fallback off the page during one.

An empty text node is inserted rather than paired. '' renders to no characters, so the markup holds no node to pair it with, and a form's error line that is empty until something goes wrong is the ordinary case. The node goes in where the cursor is and the cell behind it writes into a node that is really on the page.

One live hydration per target: a second hydrate over the same target asserts, because it would claim the nodes the first one holds. Remove the first, then hydrate again.

The light tree

createDocument() makes a document with no browser behind it: createElement, createTextNode, createComment, body, head, attributes, style, classList, listeners that are kept and dispatched only by dispatchEvent. toHtml(node) serializes. It applies no auto-closing or implied elements.

parseHtml(markup, document) reads markup back into light nodes and answers the top-level nodes it read, not a document. Put them somewhere yourself, which is what makes a page renderable, parseable and hydratable in a test with no browser:

const document = createDocument();
for (const node of parseHtml(markup, document)) document.body.appendChild(node);
hydrate(document.body, h(App, {}));

It is also where h makes a node when nobody has said where: inside a mount the mount says, outside one the page's document does, and where there is no page the light tree registers itself as the answer. Nothing imports the light tree to arrange that: it registers the no-page fallback from its own file, and html is built with a pure annotation, so a bundler that follows both can leave the tree and the template parser out of a page that writes neither.

A bundle without the light tree has no fallback, so a program with no page document mounts, renders, or makes a document with createDocument() before it calls h at module scope. h with no mount, no page and no registration asserts no document to make nodes in: mount or render into one rather than guessing.

Mount targets and mounters

The target of mount is any element, or anything with insertBefore, removeChild and replaceChild, so a component can intercept what its children mount by handing them an object of its own. mount(target, item, before, context) places the item before another mount's first node (before is that mount's remove function) and hands context down unchanged to every mounter below. A component may return a mounter, (elem, item, before, context) => remove, and call mount itself with a context of its own; before(getFirst) answers the node to insert before. The binding never reads the context.

createElement, createTextNode, setAttribute and watch are the pieces a custom h needs: they create through whichever document the current mount renders into and apply the binding's rules for attributes. watch(value, fn) is effect for a value that may or may not be reactive: a plain value is delivered once, a cell's value now and after every commit.

The light tree's classes are exported as types (LightDocument, LightElement, LightNode, LightText, LightComment), with the host shapes NodeLike, ElementLike, TextLike, CommentLike, ParentLike and DocumentLike, and H for the h that htm binds.

The router

import { createRouter } from '@aweftjs/dom/router';

const router = createRouter({ base: '/docs' });
const stop = router.links(document.body);

router.url.effect((url) => console.log('now at', url));
router.push('/guide/install');

A subpath export, so a page that never routes never loads it. createRouter({ url, base }) gives you:

namewhat it is
urla read-only cell: the path, query and hash showing now, relative to base
keya read-only cell naming the history entry. The same key comes back with back and forward
push(url), replace(url), back()the three ways to move. Both take a path relative to base; a whole URL, or a path that already carries base, is a loud assert
links(root)takes over same-origin anchor clicks under root, and returns its unsubscribe
saved(), restore()the scroll position kept for the entry showing now, and putting the page back on it
stop()stop listening for entry changes

One history path: popstate, pushState and replaceState. history.scrollRestoration is manual and positions are kept per entry key in sessionStorage, saved before a navigation leaves an entry. The router restores nothing on its own: it answers saved() and does what restore() says, and when to use them is the component layer's decision.

links leaves a click alone when a modifier key is held, when it is not the primary button, when something already prevented it, when the anchor has target (other than _self), download or data-no-route, when the href is cross-origin, another scheme, or outside base, and when the href is this same page with a different hash on it. That last one is the browser's work: it scrolls to the target and writes the entry, and the router picks the new URL up like any other entry change.

One router per page. A second one over the same history mints keys from the same counter and writes into the same scroll slots, so the two would trade entry keys and restore each other's positions. Make one and hand it to whatever needs it.

With no window it is the same router over a stack in memory. push, replace and back all still move url, which is what lets a static render open the page a URL names and a headless test drive a whole navigation. links returns a working unsubscribe that removed nothing, saved() answers null and restore() answers false.

What it never decides

What a component renders, how a site walks its pages, head tags, themes, and what any of the router's URLs mean. Storage and transport are not here: a document array on the page is the same document array a store persists or a link shares.

Boundaries

@aweftjs/dom imports @aweftjs/core and nothing else. The complete program using all of the above, with its DOM operations asserted one by one against the recording host in @aweftjs/testing, is recipes/dom/.

Known limits

A hydration builds a tree it then throws away. h makes an element the moment it runs, and the pairing walk copies each fresh element's attributes and properties onto the server's node and drops the fresh one, so taking over a page costs one createElement per element on it and every node those calls returned is garbage by the end of the mount. On a page of a few dozen elements that is a fraction of a frame; a page of hundreds has not been measured. Making it zero means claiming the server's node before building a fresh one, which changes the mounting model and the node factory every path here shares.

The design notes

A design NNN above is the note of that number in docs/design/, which says what was decided, why, what it costs, and what would reverse it.

API

Every export of @aweftjs/dom, its signature as the compiler resolves it, and its block comment.

@aweftjs/dom

Cleanup

type Cleanup = (...fns: (() => void)[]) => void;

No block comment on this export.

CommentLike

interface CommentLike extends NodeLike { data: string; }

No block comment on this export.

Component

type Component<P = Record<string, unknown>> = (props: P & { children: unknown[]; each?: unknown; }, cleanup: Cleanup, mounted: Mounted, pending: Pending) => unknown;

A component: called once with its props, and what it returns is mounted.

DocumentLike

interface DocumentLike { createElement(tag: string): ElementLike; createElementNS(namespace: string, tag: string): ElementLike; createTextNode(data: string): TextLike; createComment(data: string): CommentLike; }

Where nodes come from. The page's document, or the light tree.

ElementLike

interface ElementLike extends NodeLike, ParentLike { readonly localName: string; readonly namespaceURI: string | null; setAttribute(name: string, value: string): void; getAttribute(name: string): string | null; hasAttribute(name: string): boolean; removeAttribute(name: string): void; getAttributeNames(): string[]; readonly firstChild: NodeLike | null; textContent: string | null; [property: string]: unknown; }

No block comment on this export.

H

type H = (tag: unknown, props: Record<string, unknown> | null, ...children: unknown[]) => unknown;

No block comment on this export.

Hydrated

type Hydrated = Remove & { readonly ready: Promise<void>; };

What hydrate answers: the remove function, and when the pairing walk finished.

LightComment

class LightComment

No block comment on this export.

LightDocument

class LightDocument

No block comment on this export.

LightElement

class LightElement

An element in the light tree, named the way a browser names one: tagName and nodeName are the uppercase name for an HTML element and localName is the lowercase one, while an element in another namespace keeps the case it was made with in all three. Match on localName.

LightNode

class LightNode

No block comment on this export.

LightText

class LightText

No block comment on this export.

Mounted

type Mounted = (...fns: (() => void)[]) => void;

No block comment on this export.

Mounter

type Mounter = (elem: ParentLike, item: unknown, before: Remove, context: unknown) => Remove;

A component that mounts itself: what h makes of a component, and what one may return.

NodeLike

interface NodeLike { readonly nodeType: number; readonly nodeName: string; parentNode: ParentLike | null; readonly nextSibling: NodeLike | null; readonly previousSibling: NodeLike | null; readonly firstChild: NodeLike | null; readonly lastChild: NodeLike | null; readonly ownerDocument?: DocumentLike | null; textContent: string | null; }

No block comment on this export.

ParentLike

interface ParentLike { insertBefore(node: NodeLike, before: NodeLike | null): unknown; removeChild(node: NodeLike): unknown; replaceChild(node: NodeLike, old: NodeLike): unknown; readonly firstChild?: NodeLike | null; textContent?: string | null; readonly ownerDocument?: DocumentLike | null; }

What a mount target must offer: the three operations the mounter uses on a parent.

Pending

type Pending = (promise: Promise<unknown>) => void;

No block comment on this export.

Remove

type Remove = (arg?: typeof getFirst) => NodeLike | null | undefined;

What mount returns: call it to unmount, or with getFirst for the first live node.

Template

type Template = (values: readonly unknown[]) => unknown;

One instance of a template: what h would have returned for the same subtree.

TemplateAttributes

type TemplateAttributes = Readonly<Record<string, string | number | boolean>>;

The attributes of one element in a template: only values that were literal in the source.

TemplateChild

type TemplateChild = string | TemplateElement;

What sits inside an element in a template: text, or another element.

TemplateEdit

type TemplateEdit = readonly [ 'props', path: readonly number[] ] | readonly [ 'child', path: readonly number[], before: number ];

Where something varies. path is the child indices from the root to the element it belongs to; [] is the root itself. A props edit takes an object of properties and attributes; a child edit takes one item, and before is the index of the static child it goes in front of, or -1 for the end.

TemplateElement

type TemplateElement = readonly [ tag: string, attributes: TemplateAttributes | null, ...children: TemplateChild[] ];

One element in a template: its name, its literal attributes, and its static children.

TextLike

interface TextLike extends NodeLike { data: string; splitText(offset: number): TextLike; }

No block comment on this export.

createDocument

createDocument: () => LightDocument

A document with no browser behind it.

Returns a light document with documentElement, head and body, and the factories a mount needs. Nodes made by different light documents mix freely.

Example

const doc = createDocument();
mount(doc.body, h('p', {}, 'hello'));
toHtml(doc.body);  // '<body><p>hello</p></body>'

createElement

createElement: (tag: string, namespace?: string | undefined) => ElementLike

Make an element through the document the current mount renders into.

Params

  • tag: the element name

  • namespace: an XML namespace, for svg and the like; omitted for HTML

Returns a fresh, unattached element. Outside any mount it comes from the page's document, or from a light document where there is no page.

Throws an assert, loud in development and stripped in a release build, when there is no page and no mount is running, so there is no document to make the node in.

Example

const circle = createElement('circle', 'http://www.w3.org/2000/svg');

createTextNode

createTextNode: (text: string) => TextLike

Make a text node through the document the current mount renders into.

Params

  • text: its content

Returns a fresh, unattached text node.

Throws an assert, loud in development and stripped in a release build, when there is no page and no mount is running, so there is no document to make the node in.

Example

const label = createTextNode('hello');

getFirst

getFirst: typeof getFirst

Ask a remove function for the first live node of its mount instead of removing it.

mount returns a function; called with nothing it unmounts, called with getFirst it answers the first node the mount put in the document, or the node after it when the mount has none. A component that returns a mounter uses it on the before it was handed.

Example

const anchor = before(getFirst);

h

h: (tag: unknown, props?: Record<string, unknown> | null, ...children: unknown[]) => unknown

Make an element, or a component's mounter.

Params

  • tag: an element name, an existing node to use as the element, or a component function

  • props: attributes by bare name, properties by $name ($value, $onclick, a $style object), each static or a scope, cell or derived value; children as an alternative to the rest arguments; each on a component to mount it once per item of a list

  • children: what goes inside: primitives, nodes, h() output, iterables, scopes and cells, components. null is skipped and undefined is refused

Returns the element itself when nothing in it is reactive, so it can be used as a node; otherwise a value mount binds. For a component, a mounter to hand to mount.

Throws an assert, loud in development and stripped in a release build, for a null tag, a tag that is neither an element name nor a component, undefined as a child, children given both ways, or an object written as an attribute.

Example

mount(document.body, h('button', { $onclick: () => count.set(count.get() + 1) }, 'clicked ', count));

htm

htm: (h: H, options?: { join?: ((parts: unknown[]) => unknown) | undefined; }) => (strings: TemplateStringsArray, ...values: unknown[]) => unknown

Bind the template parser to an h.

Params

  • h: the element factory the markup calls

  • options: join, how a quoted attribute of several parts becomes one value; by default plain parts concatenate and a part that is a scope or cell makes the whole value derived

Returns a tag function for template literals.

Throws the tag function asserts, loud in development and stripped in a release build, on markup it cannot read: an unclosed tag, an attribute with no value, a comment or a quote left open, or a closing tag that matches nothing.

Example

const html = htm(myH);
html`<p class="note ${tone}">${text}</p>`

html

html: (strings: TemplateStringsArray, ...values: unknown[]) => unknown

Markup in a template literal, bound to h.

Annotated pure so a bundler drops the binding, and the parser behind it, out of a page that never writes html. Nothing else in the package reaches htm.

Example

mount(document.body, html`<button $onclick=${add}>clicked ${count} times</button>`);

hydrate

hydrate: (target: ParentLike, item: unknown, context?: unknown) => Hydrated

No block comment on this export.

hydrating

hydrating: () => boolean

Whether the mount running right now is claiming server nodes. A hoisted template asks, because a claimed node has to be one the binding made rather than a clone.

isComponentCall

isComponentCall: (value: unknown) => boolean

Is this already the mounter h makes of a component, rather than a bare function?

Params

  • value: anything an application would hand mount, render, hydrate or attach

Returns true for what h(Component, props) answers, false for everything else, a bare function included. Whoever wants a bare function mounted as a component with no props asks first, because wrapping a component call a second time would call it with a component's arguments. hydrate asks (design 157), and so does ssg's attach.

Example

const mounted = typeof item === 'function' && !isComponentCall(item) ? h(item) : item;

joined

joined: (parts: unknown[]) => unknown

One attribute value out of several parts.

A quoted attribute in markup may mix text and expressions, and this is what the pieces become. build emits a call to it for a compiled template, so markup that was parsed and markup that was compiled make the same value out of the same pieces.

Params

  • parts: the pieces in order, each a plain value or a scope, cell or derived value

Returns the pieces joined as text when all of them are plain, and otherwise a derived value that joins them again whenever one of them changes.

Example

setAttribute(el, 'class', joined(['note ', tone]));

mount

mount: (elem: ParentLike, item: unknown, before?: Remove | undefined, context?: unknown) => Remove

Mount an item under a target.

Params

  • elem: an element, or anything with insertBefore, removeChild and replaceChild

  • item: null, a primitive, a node, h() output, an iterable, a document array, a mutable array, a scope, cell or derived value, or a mounter

  • before: the anchor: a remove function whose getFirst answer the item goes before. Omitted, the item goes at the end

  • context: an opaque value handed to every mounter below, and to nothing else

Returns the remove function. Call it to unmount; call it with getFirst for the first live node. undefined is refused: hide something with null.

Throws an assert, loud in development and stripped in a release build. undefined as the item, null as the anchor, a plain object, a node already mounted elsewhere and a target with no document are all caller mistakes rather than data the mounter refuses.

Example

const stop = mount(document.body, h('p', {}, 'hello ', name));
stop();

parseHtml

parseHtml: (markup: string, document?: LightDocument) => LightNode[]

Parse markup into light nodes.

Params

  • markup: what toHtml wrote, or any markup in that shape

  • document: the light document the nodes belong to; a fresh one when omitted

Returns the top-level nodes in order. Tags, attributes, comments and entities are read; the browser's implied elements and auto-closing rules are not applied, so give it markup that needs neither.

Example

const [page] = parseHtml(html);
hydrate(doc.body, App);

render

render: (item: unknown, options?: { context?: unknown; }) => Promise<string>

Render an item to markup, with no browser.

Params

  • item: anything mount takes

  • options: context, the opaque value every mounter below receives

Returns the markup, once nothing a component declared pending is still pending. Every dynamic part (a scope, a component, each item of a list) is bracketed with <!--[--> and <!--]--> so hydrate can find it. No doctype; the page adds its own.

Throws whatever mounting the item throws, which for a caller mistake is an assert, loud in development and stripped in a release build.

Example

const page = await render(h(App, { url }));

setAttribute

setAttribute: (element: ElementLike, name: string, value: unknown) => void

Write an attribute the way the binding does.

Params

  • element: the element

  • name: the attribute

  • value: null, undefined and false remove it; true sets it empty; anything else is written as text

Example

setAttribute(input, 'disabled', busy.get());

template

template: (spec: TemplateElement, edits: readonly TemplateEdit[]) => Template

The static shape of a subtree, made once per document and instanced per use.

Params

  • spec: the element, its literal attributes and its static children, nested

  • edits: where something varies, in the order a source evaluates the values: for each element its properties first, then its children, and a nested element's whole subtree where that child sits

Returns a function taking one value per edit, in the same order, and returning what h would have returned. Hand that straight to mount. The values are applied in a different order from the one they are given in: every element's children before its own properties, so a property that rewrites the element's content still wins.

Throws the instancing function asserts, loud in development and stripped in a release build, for an edit path the element tree does not have, one element's child edits listed in two places, or undefined where a value goes.

Example

const row = template(['li', { class: 'row' }, ['span', null]], [['child', [0], -1]]);
mount(list, row([title]));

toHtml

toHtml: (node: NodeLike | readonly NodeLike[]) => string

Markup for a light node, or for a list of them.

Params

  • node: a light node, or an array of light nodes serialized in order

Returns the markup, with text and attribute values escaped, void elements unclosed and script and style contents raw. No doctype; a page adds its own.

Example

toHtml(doc.body.childNodes);

watch

watch: (value: unknown, callback: (value: unknown) => void) => () => void

Run a callback for a value now and, when the value is a scope, cell or derived value, again after every change.

Params

  • value: a plain value, or anything with get and effect

  • callback: what to run with each value

Returns the unsubscribe. For a plain value it does nothing.

Example

const stop = watch(props.title, (title) => { element.title = String(title); });

@aweftjs/dom/router

Entries

interface Entries { current(): string; state(): unknown; push(state: unknown, href: string): void; replace(state: unknown, href: string): void; back(): void; listen(fn: () => void): () => void; }

Where the URL and its history entries live (design 282). The browser's history is the usual one; a router inside a sandbox frame runs over an object that reads and writes a document shared across the wall. The router stamps its own entry key into the state it pushes and reads it back through state, so an implementation keeps whatever state it is handed, per entry, and answers it for the entry showing now.

LinkRoot

interface LinkRoot { addEventListener(type: string, listener: (event: never) => void): void; removeEventListener(type: string, listener: (event: never) => void): void; }

What links attaches to: the element clicks are watched on.

Router

interface Router { readonly url: Derived<string>; readonly key: Derived<string>; readonly base: string; push(url: string): void; replace(url: string): void; back(): void; links(root: LinkRoot): () => void; saved(): ScrollPosition | null; restore(): boolean; stop(): void; }

The URL, the history entry, and the ways to move.

RouterOptions

interface RouterOptions { readonly url?: string; readonly base?: string; readonly entries?: Entries; }

What createRouter takes.

ScrollPosition

interface ScrollPosition { readonly x: number; readonly y: number; }

Where a page was scrolled to, in CSS pixels from the top left.

createRouter

createRouter: (options?: RouterOptions) => Router

Make a router over the page's history, or over a stack in memory where there is no page.

Params

  • options: url, where to start with no window (/ when left off, ignored in a browser), base, a path every URL is under ('' when left off, no trailing slash), and entries, where the URL and its history entries live when they are not the window's (design 282); scroll, storage and clicks still come from the window when there is one, and an anchor click is then resolved against the entries' own URL

Returns the router. url and key are read-only cells; write to them and they throw, because push and replace are how a router moves.

Throws an assert, loud in development and stripped in a release build, when base does not start with /, which is the mistake that silently routes nothing.

Example

const router = createRouter({ base: '/docs' });
const stop = router.links(document.body);
router.push('/guide/install');
router.url.get();  // '/guide/install'

Refusals

Every error this package raises carries a reason to switch on and a fix that says what to do. These are its reasons, from errors.txt.

reasonfix
not-a-childCall removeChild on the node's own parent, which parentNode names.
not-a-childPass one of this node's children as the reference, or null to append.

Recipes

The programs in the stack's gate that use this package, each a job someone would have.

  • recipes/todo-list: A list you add to, toggle, filter and reorder, with the list following each edit rather than being rebuilt

  • recipes/ui: A page with themes, contexts, control flow, a popup and a suspend, built by vite and driven in a real browser

  • recipes/routed-site: A site with real URLs: nested pages, a page with a parameter, a page that arrives later, a dialog the back button dismisses, and a title per page

  • recipes/ssg: A routed site written out as files, served by anything, and taken over in place when the browser gets to it

  • recipes/dom: the package's own recipe