v0.2.04.2 kB gzip · one peer: defuss-morph

jQuery-shaped DOM code, morphed instead of rebuilt. An update that

keeps nodes by key keeps your listeners keeps what users typed patches attributes in place awaits its transition

df$ selects real elements with native CSS selectors and returns them as a real Array. Content writes such as html(), text() and empty() go through the defuss-morph engine, which diffs the new content against the live DOM and patches it in place. Events and forms stay native: addEventListener and FormData.

Built by Aron Homberg (kyr0) (opens in a new tab). Open source (MIT (opens in a new tab))

<script type="module" src="https://cdn.jsdelivr.net/npm/defuss-morph@latest/dist/all.min.js"></script>
<script type="module" src="https://cdn.jsdelivr.net/npm/defuss-query@latest/dist/all.min.js"></script>
<script type="module">
  // html() runs defuss-morph, not innerHTML: the list is
  // patched in place, both <li> are kept and reordered
  df$("#list").html('<li key="b">B</li><li key="a">A</li>');
  df$("#list li")
    .addClass("ready")
    .on("click", (e) => { df$(e.currentTarget).text("Saved"); });
</script>

One callable df$

Call it to select: CSS selectors, markup, VNodes, elements or collections. Read it as a namespace: df$.morph() and every other morph export sit on the same function.

Morph, not innerHTML

Content writes are diffed against the live DOM. Keyed and id-matched nodes are moved, not recreated, so their listeners and typed values survive, and focus stays on an element that keeps its place.

Real Arrays, fixed membership

A selection is an Array subclass: map, some and for...of just work. Its members are fixed when it is created; traversal returns a new selection, setters return the same one.

Native events

on() is addEventListener on each target, so stopPropagation(), non-bubbling events and currentTarget behave as usual. When morph removes an element, its listeners go with it.

Native forms

form() returns the browser's own FormData with duplicate names, files and the submitter; serialize() URL-encodes its string entries. No hand-rolled form walker.

Small and import-safe

The CDN bundle is 4.2 kB gzipped, imports nothing and does not embed morph. The npm entry writes no global and reads no document on import.

Live demo

A task board that re-renders on every change

Every change renders the whole list again as VNodes with df$("#tasks").morph(rows). Shuffle, sort or reverse it: the number on each row names its DOM element and does not change, because morph moves the keyed rows instead of rebuilding them. The inspector counts the elements each render kept, created and removed. The frame runs the bundles this site serves, with shadcn styles and no other script.

Try a reorder with the fade transition: morph() returns a Promise when you pass transition.

Playground

Run a chain, then see what it kept

Each snippet runs on this page's df$ against a fresh copy of the sandbox below. The report lists what the snippet returned and which sandbox elements survived, were created or were removed. Compare the morph reorder with the innerHTML one.

Snippets

snippet.js
Pick a snippet.
How it works

A facade over one engine

defuss-query adds no second reconciler and no second event system. It selects with the platform, writes content through defuss-morph and does the exact inserts and removals morph has no primitive for. The design rationale is in ARCH.md (opens in a new tab).

Call df$ to select

Every call returns a DfQuery: an Array subclass, deduplicated, in encounter order.

df$(".card")                 // querySelectorAlldf$("button", dialog)        // with a context rootdf$("<li>New</li>")         // parse markup into nodesdf$({ type: "b", children: ["x"] }) // render a VNodedf$(element) / df$(nodeList) // wrap targetsdf$(() => start())           // run when the DOM is ready

Read df$ as a namespace

The morph API is copied onto the function, plus three reserved slots.

df$.morph(el, "<p>Hi</p>")   // the engine itselfdf$.renderMarkup(vnode)       // any other morph exportdf$.fn                        // DfQuery.prototypedf$.dom                       // the structural adapterdf$.queryVersion              // the package version string

The write path of html()

  1. Parse

    A markup string becomes VNodes through morph's parser; standalone <tr>, <td> or <option> get their table or select context. VNodes and DOM nodes are snapshotted as VNodes.

  2. Diff

    morph() compares the VNodes with each element's live children. Nodes matched by key or id are moved; attributes are patched in place.

  3. Clean up

    Elements morph removes run its cleanup, and a hook defuss-query registers removes their on() listeners. Matched elements keep theirs.

  4. Return

    Without transition the call is synchronous and returns the selection for chaining. With one it returns a Promise of the selection, after every element's transition settled.

Three ways to load it

EntryForContract
dist/all.min.jsCDN, module scriptUpgrades the morph global window.df$ into the callable. No imports, morph not embedded.
dist/global.min.jsClassic scriptThe same installation as a classic script, for loaders that need deterministic execution.
defuss-querynpm, bundlers, serversImports the defuss-morph peer, writes no global, reads no document on import. ESM and CommonJS.
defuss-query/coreYour own morphcreateDf$(morph) builds a df$ around the morph instance you pass.

Load morph before query. The other order works with morph 0.1.1 or later: selectors work at once, content writes once morph has loaded.

API

The whole surface on one page

The declarations ship in the package (dist/index.d.ts); the agent-facing reference is SKILL.md (opens in a new tab), which ships in the npm package too.

A string starting with < (after whitespace) is markup; any other string is a selector. Collections must contain EventTargets; anything else throws a TypeError.

CallReturnsSemantics
df$(selector, context?)DfQuery<Element>querySelectorAll on context (Document, Element or DocumentFragment), default the document. Tag names give precise types: df$("button") is DfQuery<HTMLButtonElement>.
df$("<markup>"), df$(vnode)DfQuery<Node>New detached nodes; several roots allowed.
df$(target | targets)DfQuery<T>Wraps an EventTarget (element, document, window) or an iterable or array-like of them.
df$(fn)DfQuery<Document>Runs fn(df$) on DOMContentLoaded, or in a microtask when the document is already parsed.
df$(), df$(null)DfQueryAn empty selection.
[i], .length, .get(i?), .toArray()item or arrayArray access; get() and toArray() return copies, get(-1) counts from the end.
.eq(i), .first(), .last()new selectionOne item; negative indices count from the end.
.each(fn)thisjQuery order (index, element) with this bound to the element; returning false stops.
.find(css), .filter(css | fn), .is(css)selection, booleanDescendants, matching subset, any match. .find(fn) keeps the native Array behavior.
.parent(), .children(css?), .closest(css), .next(), .prev()new selectionElement traversal, deduplicated in encounter order.
.map(), .slice(), .flat()plain ArraySpecies-based Array methods return ordinary arrays.

Small, and measured

Sizes are make metrics output for the 0.2.0 build, which this page and the demo load from assets/. The release gate (scripts/verify.ts) fails when a browser bundle gzips above 4,608 bytes, imports a module or embeds the morph engine.

4,203 B gzip3,714 B Brotli, 11,342 B raw: all.min.js
1runtime peer dependency, defuss-morph; the CDN bundle does not embed it
0imports in the browser bundles, checked by the release gate
4runs of the DOM suite in real Chromium: three shipped bundles and the source
make metrics
all.js 30219 bytes 8638 gzip 7453 brotli
all.min.js 11342 bytes 4203 gzip 3714 brotli
global.min.js 11078 bytes 4112 gzip 3626 brotli
Measured with zlib gzip level 9 and Brotli defaults; stats.json holds the same numbers.
Install

Add it to a page or a project

Two script tags for a page, or the npm package with its defuss-morph peer for a build. Or hand one prompt to your coding agent.

In a page

Load morph first, then query. Module scripts run in document order, so the application script after them sees df$.

<script type="module" src="https://cdn.jsdelivr.net/npm/defuss-morph@latest/dist/all.min.js"></script><script type="module" src="https://cdn.jsdelivr.net/npm/defuss-query@latest/dist/all.min.js"></script><script type="module">  df$("#app").html("<p>Hello</p>");</script>
<script type="module">  await import("https://cdn.jsdelivr.net/npm/defuss-morph@latest/dist/all.min.js");  const { df$ } = await import("https://cdn.jsdelivr.net/npm/defuss-query@latest/dist/all.min.js");  df$("#app").html("<p>Hello</p>");</script>

In a project

The npm entry imports the defuss-morph peer (^0.2.0) and has no side effects on import, so server-side imports are safe.

bun add defuss-query defuss-morph
import df$, { type DfQuery } from "defuss-query";const buttons: DfQuery<HTMLButtonElement> = df$("button");buttons.prop("disabled", false).on("click", function (event) {  console.log(this.disabled, event.clientX);});

With a coding agent

Paste this prompt into your coding agent. The package ships SKILL.md, the API reference written for agents.

Use defuss-query for the DOM code in this app: https://github.com/kyr0/defuss-query
Install defuss-query and defuss-morph, then read node_modules/defuss-query/SKILL.md and follow it.
Write content with html(), text() or morph() instead of innerHTML, render lists as keyed VNodes, and put user input only into text() or VNode text.
Use on() and off() for listeners and form() for form data.
send

Give your agent the defuss-vae skills too: it then plans, tests and reviews against a verifier instead of declaring itself done. In Claude Code, the plugin adds the commit and Stop-hook gate. Needs python3 3.9 or later, git and make.

npx skills add kyr0/defuss-vae --skill '*'
claude plugin marketplace add kyr0/defuss-vaeclaude plugin install defuss-vae@defuss-vae
Portrait of Aron Homberg

Tired of inconsistent agent results and long reviews?

Build more consistent agents with defuss-vae (opens in a new tab), Aron's verification-first skill set: plans, tests and reviews run against a verifier.

Talk to Aron on LinkedIn (opens in a new tab)

For a remote training or mentoring session for you or your engineering team.

Reintroduce jQuery simplicity, drop the inefficiencies

Native selectors, real Arrays, native events and forms, and content writes that patch the DOM instead of replacing it.

MIT licensed · 0.2.0 · one peer dependency