@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:
| item | what happens |
|---|---|
| a string, number or boolean | a text node |
null | nothing. undefined is refused, so a typo cannot render as silence |
| a node | inserted as it is |
h() output, html output | the element, with its reactive parts bound |
| an array or any iterable | each item in order |
a document array (createArray), a mutable array (mutableArray) | a list that follows every edit |
| a scope, a cell or a derived value | its 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:
| written | means |
|---|---|
hidden | the attribute set to true |
id=plain | the 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 movesRegister 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 checkedready 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:
| name | what it is |
|---|---|
url | a read-only cell: the path, query and hash showing now, relative to base |
key | a 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 LightCommentNo block comment on this export.
LightDocument
class LightDocumentNo block comment on this export.
LightElement
class LightElementAn 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 LightNodeNo block comment on this export.
LightText
class LightTextNo 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: () => LightDocumentA 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) => ElementLikeMake an element through the document the current mount renders into.
Params
tag: the element namenamespace: an XML namespace, forsvgand 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) => TextLikeMake 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 getFirstAsk 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[]) => unknownMake an element, or a component's mounter.
Params
tag: an element name, an existing node to use as the element, or a component functionprops: attributes by bare name, properties by$name($value,$onclick, a$styleobject), each static or a scope, cell or derived value;childrenas an alternative to the rest arguments;eachon a component to mount it once per item of a listchildren: what goes inside: primitives, nodes,h()output, iterables, scopes and cells, components.nullis skipped andundefinedis 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[]) => unknownBind the template parser to an h.
Params
h: the element factory the markup callsoptions: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[]) => unknownMarkup 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) => HydratedNo block comment on this export.
hydrating
hydrating: () => booleanWhether 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) => booleanIs this already the mounter h makes of a component, rather than a bare function?
Params
value: anything an application would handmount,render,hydrateorattach
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[]) => unknownOne 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) => RemoveMount an item under a target.
Params
elem: an element, or anything withinsertBefore,removeChildandreplaceChilditem: null, a primitive, a node,h()output, an iterable, a document array, a mutable array, a scope, cell or derived value, or a mounterbefore: the anchor: a remove function whosegetFirstanswer the item goes before. Omitted, the item goes at the endcontext: 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: whattoHtmlwrote, or any markup in that shapedocument: 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: anythingmounttakesoptions: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) => voidWrite an attribute the way the binding does.
Params
element: the elementname: the attributevalue:null,undefinedandfalseremove it;truesets it empty; anything else is written as text
Example
setAttribute(input, 'disabled', busy.get());template
template: (spec: TemplateElement, edits: readonly TemplateEdit[]) => TemplateThe static shape of a subtree, made once per document and instanced per use.
Params
spec: the element, its literal attributes and its static children, nestededits: 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[]) => stringMarkup 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) => () => voidRun 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 withgetandeffectcallback: 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) => RouterMake 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 nowindow(/when left off, ignored in a browser),base, a path every URL is under (''when left off, no trailing slash), andentries, 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.
| reason | fix |
|---|---|
not-a-child | Call removeChild on the node's own parent, which parentNode names. |
not-a-child | Pass 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 rebuiltrecipes/ui: A page with themes, contexts, control flow, a popup and a suspend, built by vite and driven in a real browserrecipes/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 pagerecipes/ssg: A routed site written out as files, served by anything, and taken over in place when the browser gets to itrecipes/dom: the package's own recipe