npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@aweftjs/dom

v0.1.1

Published

The DOM binding: mount, static render and hydration as modes of one path.

Readme

@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 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:

| 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.