defuss-morph 0.1.1 โ†’ 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,84 +1,58 @@
1
1
  # defuss-morph
2
2
 
3
- The [defuss](https://github.com/kyr0/defuss) DOM morphing engine as a standalone, dependency-free package.
3
+ Morph an HTML string or JSX into an existing DOM tree. `defuss-morph` compares the new markup with the live DOM and patches only the difference with native DOM calls, so a node that exists before and after stays the same node: focus, the text in an input and its event listeners survive the update. It is the DOM morphing engine of [defuss](https://github.com/kyr0/defuss), packaged on its own with no dependencies.
4
4
 
5
- The defuss DOM morphing algorithm is a standalone package: morph an HTML string or JSX object into an existing DOM tree using native APIs (`DOMParser`) and a smart DOM diffing algorithm.
5
+ Website with a live playground: [morph.defuss.org](https://morph.defuss.org/)
6
6
 
7
- ## TL;DR
7
+ What it does:
8
8
 
9
- You don't need any complex framework like React, Vue, Solid, Angular, Svelte etc. to do DOM morphing. You don't even need a virtual DOM. You can simply get hold of a DOM element reference, and pass it new HTML or JSX to morph it into, and it works isomorphically everywhere - even without any complex transpilation or build step. Just include one `<script>` tag and you are ready to go.
9
+ - Key-aware and id-aware matching: matched nodes are moved, never recreated.
10
+ - In-place attribute patching for elements with the same tag.
11
+ - Uncontrolled form state survives: an input keeps its typed `value` and `checked` state unless the new markup sets them.
12
+ - JSX handlers (`onClick`, ...) listen on the element itself, so propagation is native (order, `stopPropagation()`, `currentTarget`, non-bubbling events such as `toggle`, `load`, `error`). Handlers of removed elements are torn down.
13
+ - HTML comments are matched and patched like text: `morph(el, el.innerHTML)` changes no node.
14
+ - Partial updates (diff mode) for streamed or AI-generated changes.
15
+ - Store-driven rendering: `reactive()`, the `<Reactive>` component and defuss-query's `df$(selector).reactive()` re-render a region whenever a store changes, by morphing it.
16
+ - A JSX runtime (`jsxImportSource: "defuss-morph"`, or `h()` without a build step), refs and the forwardRef pattern.
17
+ - Transitions: fade, slide-left, slide-right, shake or your own styles.
18
+ - Works next to any framework or none, in the browser and on the server (happy-dom). No virtual DOM, no build step.
19
+ - ESM and CommonJS builds with type declarations, plus a one-file bundle for the CDN.
10
20
 
11
- Features:
12
- - ๐Ÿ”„ Patch-rendering via DOM-diff; HTML + JSX supported
13
- - ๐Ÿค Works with any other framework or library, and without any
14
- - ๐Ÿ’ฏ Runs isomorphic in the browser and on the server
15
- - ๐ŸŽ๏ธ Fast! Uses native DOM APIs
16
- - ๐ŸŽฏ Stable key/id-aware node matching
17
- - ๐Ÿ“ฆ CDN-served and packaged with ESM + CJS
18
- - ๐Ÿงน Event handler preservation w/ supports delegated event listeners
19
- - ๐Ÿ“ Form-state preservation (see one limitation below w/ solution)
20
- - โšก No compile/transpile step required (but optionally available)
21
- - ๐Ÿชถ Extremely lightweight โ€” just ~6 KiB minified and gzipped
22
- - ๐Ÿ’… Includes support for beautiful transitions (fade, slide, shake, custom styles)
23
- - ๐Ÿงฉ Supports partial updates (diff mode) for streaming/AI-driven UIs
24
- - โœ… ~97%+ test coverage with unit tests and real browser E2E tests
25
- - ๐ŸŸฆ Written in TypeScript
21
+ ## Quick start from the CDN
26
22
 
27
- ## Quick, traditional CDN-based setup
28
-
29
- The traditional way using `window.df$` globally exported API:
23
+ The CDN bundle registers the whole API as the global `window.df$` when it loads:
30
24
 
31
25
  ```html
32
26
  <body>
33
27
  <div id="app">Old content</div>
34
28
 
35
- <!-- right before the closing </body> tag, include the CDN bundle -->
36
29
  <script type="module" src="https://cdn.jsdelivr.net/npm/defuss-morph@latest/dist/all.min.js"></script>
30
+ <script type="module">
31
+ df$.morph(document.getElementById("app"), "<p>Hello from the CDN</p>", {
32
+ transition: { type: "fade", duration: 200 },
33
+ });
34
+ </script>
37
35
  </body>
38
36
  ```
39
37
 
40
- Then run:
41
-
42
- ```html
43
- <script>
44
- const { morph } = window.df$;
45
-
46
- morph(document.getElementById("app"), "<p>Hello from the CDN</p>", {
47
- transition: { type: "fade", duration: 200 }
48
- });
49
- </script>
50
- ```
38
+ An existing `df$` object or function (for example from defuss-query) is kept and extended with the API instead of being replaced.
51
39
 
52
- - **Good for:** Quick, powerful AI-prototyping, demos, and testing in the browser.
53
- - **Drawbacks:** Doesn't work offline, single-point-of-failure, no bundler optimizations.
54
-
55
- ## Quick, modern CDN-based setup using ESM imports
56
-
57
- The modern way, using direct ESM imports from the CDN โ€” no globals required:
40
+ The same file also works as an ES module, without the global:
58
41
 
59
42
  ```html
60
- <body>
61
- <div id="app">Old content</div>
62
-
63
- <!-- right before the closing </body> tag, import via ESM -->
64
- <script type="module">
65
- import { morph } from "https://cdn.jsdelivr.net/npm/defuss-morph@latest/dist/all.min.js";
43
+ <script type="module">
44
+ import { morph } from "https://cdn.jsdelivr.net/npm/defuss-morph@latest/dist/all.min.js";
66
45
 
67
- morph(document.getElementById("app"), "<p>Hello from the CDN</p>", {
68
- transition: { type: "fade", duration: 200 }
69
- });
70
- </script>
71
- </body>
46
+ morph(document.getElementById("app"), "<p>Hello from the CDN</p>");
47
+ </script>
72
48
  ```
73
49
 
74
- - **Good for:** Quick, powerful AI-prototyping, demos, and testing in the browser, with explicit imports and no global namespace pollution.
75
- - **Drawbacks:** Also doesn't work offline, still single-point-of-failure, still no bundler optimizations.
50
+ Good for prototypes, demos and AI-generated pages. The page then depends on the CDN being reachable; replace `@latest` with a version to pin it.
76
51
 
77
- ## Quick, non-CDN setup
52
+ ## Without a CDN
78
53
 
79
- Download this package as a ZIP-file (either a versioned release or the latest `main` branch): [Download latest `defuss-morph` as ZIP](https://github.com/kyr0/defuss-morph/archive/refs/heads/main.zip). Extract it and copy the `dist` folder to your project assets files.
54
+ Download the repository as a ZIP ([`main`](https://github.com/kyr0/defuss-morph/archive/refs/heads/main.zip)), or the built file from the [website](https://morph.defuss.org/#install), and copy `dist/all.min.js` into your assets:
80
55
 
81
- Example:
82
56
  ```text
83
57
  project/
84
58
  โ”œโ”€โ”€ assets/
@@ -88,93 +62,69 @@ project/
88
62
  โ””โ”€โ”€ index.html
89
63
  ```
90
64
 
91
- Then include the local bundle in your HTML, using either the modern ESM import:
92
-
93
65
  ```html
94
- <body>
95
- <div id="app">Old content</div>
96
-
97
- <script type="module">
98
- import { morph } from "./assets/js/defuss-morph/all.min.js";
66
+ <script type="module">
67
+ import { morph } from "./assets/js/defuss-morph/all.min.js";
99
68
 
100
- morph(document.getElementById("app"), "<p>Hello from local assets</p>", {
101
- transition: { type: "fade", duration: 200 }
102
- });
103
- </script>
104
- </body>
69
+ morph(document.getElementById("app"), "<p>Hello from local assets</p>");
70
+ </script>
105
71
  ```
106
72
 
107
- This also works with the traditional `window.df$` global (see above).
108
-
109
- - **Good for:** Offline-support, air-gapped environments, and locking the exact version you ship.
110
- - **Drawbacks:** Manual updates โ€” you need to re-download the ZIP to get a new version, and still no bundler optimizations.
73
+ This works offline and pins the version you ship; updating means downloading again.
111
74
 
112
- ## For production: Install via package manager
75
+ ## With a package manager
113
76
 
114
- The most professional approach is to use a package manager: This allows for version-pinning, offline-support, and allows for optimized production builds using tree-shaking and minification.
115
-
116
- We recommend [`bun`](https://bun.sh) only as a package manager and simple script runner.
77
+ For applications with a bundler: the version is pinned in your lockfile and the bundler can tree-shake. The package entries (`index.mjs`, `index.cjs`) register no global.
117
78
 
118
79
  ```bash
119
80
  bun add defuss-morph
120
81
  # or: npm install defuss-morph
121
82
  ```
122
83
 
123
- Then, in `.ts(x)` or `.js(x)` files:
124
-
125
84
  ```ts
126
85
  import { morph } from "defuss-morph";
127
86
 
128
87
  const appEl = document.getElementById("app")!;
129
88
 
130
- // only what actually changed is patched; untouched nodes keep
131
- // their identity, focus, selection, scroll position and event state
132
89
  morph(appEl, `<ul>
133
90
  <li key="a">Alpha</li>
134
91
  <li key="b">Beta</li>
135
- </ul>`, { transition: { type: "fade", duration: 200 } });
92
+ </ul>`);
136
93
 
94
+ // b moves in front of a; neither node is recreated, so both keep
95
+ // their focus, selection, scroll position and listeners
137
96
  morph(appEl, `<ul>
138
97
  <li key="b">Beta (updated)</li>
139
98
  <li key="a">Alpha</li>
140
- </ul>`, { transition: { type: "slide-left", duration: 200 } });
141
- ```
142
-
143
- If you have `bun` installed, you can serve your project directory statically with a single command:
144
-
145
- ```bash
146
- bunx serve .
99
+ </ul>`);
147
100
  ```
148
101
 
149
102
  ## API
150
103
 
151
- ### `morph(el: Element, newContent: string | VNode | VNode[]): void`
104
+ ### `morph(el, newContent, options?)`
105
+
106
+ ```ts
107
+ morph(el: Element, newContent: RenderInput, options?: MorphOptions): void | Promise<void>
108
+ ```
152
109
 
153
- Morphs the children of `el` to match the given HTML string or JSX/VNode content.
110
+ Makes the children of `el` match `newContent`: an HTML string, a VNode, an array of VNodes or plain text. Without a `transition` option the morph is synchronous and returns `undefined`; with one it returns a Promise that resolves when the transition has finished.
154
111
 
155
- - HTML strings are parsed with the element's own document (`el.ownerDocument`), so it works isomorphically (browser, happy-dom, ...) and across multiple documents/windows. VNode/JSX input is used directly.
156
- - Matching is **key-aware** (`key` attribute) and **id-aware**; matched nodes are *moved*, never replaced, preserving DOM identity and element state.
157
- - Same-tag elements are patched in place (attributes and children).
158
- - **Event listeners survive**: because node identity is preserved, native `addEventListener` listeners keep working. Handlers registered via `registerDelegatedEvent` (or defuss JSX `onClick` props) are preserved too โ€” an HTML string cannot declare handlers, so existing ones are treated like uncontrolled form state. Handlers of *removed* elements are cleaned up.
159
- - Uncontrolled form state survives: an `<input>`'s live value is kept unless the new HTML explicitly sets `value`.
160
- - Plain text input morphs as a text node.
161
- - Morphs triggered from within an active morph (e.g. from lifecycle callbacks) on the same or an ancestor element are queued latest-wins and replayed after the active morph finishes.
112
+ - HTML strings are parsed with the `DOMParser` of the element's own document (`el.ownerDocument`), so the call works in a browser, in happy-dom and across windows. Fragments are parsed in template mode, so context-dependent tags such as `<tr>`, `<td>` and `<option>` survive at the top level.
113
+ - Children are matched by `key`, then by `id`, then by tag. Only nodes out of order move (swapping two rows moves two, removing one moves none). A matched node is moved with `moveBefore()`, which keeps focus, the selection and other live state (where the browser lacks it, `insertBefore()` moves it and `morph()` puts focus and the text selection back), and patched in place; a child without a match is created; unclaimed nodes are removed.
114
+ - The `key` is stored on the element for matching and is not written to the DOM as an attribute.
115
+ - An element with an open shadow root is morphed inside the shadow root. Custom elements (a hyphen in the tag name) keep their light DOM as the target, for slotted content. A `<template>` is morphed in its `.content`, where the HTML parser puts its markup.
116
+ - Native listeners added with `addEventListener` stay on surviving nodes. An HTML string cannot declare handlers, so registered JSX handlers stay as well, the same rule as for uncontrolled form state.
117
+ - A morph started from inside another morph on the same element or a descendant (for example from a lifecycle callback) is queued; the latest request runs when the active morph returns.
162
118
 
163
119
  ### Morphing JSX (VNodes)
164
120
 
165
- `morph()` accepts **either an HTML string or JSX / VNodes** โ€” same API, same
166
- algorithm. Strings are parsed with the native `DOMParser`; VNodes
167
- (`{ type, attributes, children }` objects, as produced by any JSX factory)
168
- are morphed directly:
121
+ `morph()` accepts JSX or VNodes with the same algorithm. A VNode is `{ type, attributes, children }`, as any JSX factory produces it; an HTML comment is `{ type: "#comment", value }` (`COMMENT_TYPE`).
169
122
 
170
123
  ```tsx
171
124
  import { morph } from "defuss-morph";
172
125
 
173
- // HTML string:
174
- morph(document.getElementById("app")!, `<p>Hi</p>`);
175
-
176
- // JSX / VNodes (any factory producing { type, attributes, children } works โ€”
177
- // e.g. defuss: import { jsx } from "defuss"; const vdom = <p>Hi</p>;)
126
+ // JSX compiled with jsxImportSource: "defuss-morph" (see JSX runtime below);
127
+ // defuss's own jsx() produces the same VNodes
178
128
  morph(
179
129
  document.getElementById("app")!,
180
130
  <ul>
@@ -184,40 +134,122 @@ morph(
184
134
  );
185
135
  ```
186
136
 
187
- Unlike an HTML string, a VNode can carry real JavaScript values: explicit
188
- booleans (`checked: false`), live values (`value: ""`), event handlers
189
- (`onClick={...}`, registered as delegated events) and refs. In other words โ€”
190
- **controlled state and JSX event props only work through VNodes**, never
191
- through HTML strings.
137
+ Unlike an HTML string, a VNode carries JavaScript values: explicit booleans (`checked: false`), live values (`value: ""`), event handlers (`onClick={...}`), refs and the lifecycle hooks `onMount` and `onUnmount`. Controlled state and JSX event props only work through VNodes. `onMount` runs in a microtask after the element is inserted, and again after each morph that patches it with an `onMount`. `onUnmount` runs once when the element leaves the document, with the handler of the latest render, and receives the element.
138
+
139
+ ### JSX runtime
140
+
141
+ defuss-morph ships the runtime a JSX compiler calls. Set the import source once:
142
+
143
+ ```json
144
+ { "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "defuss-morph" } }
145
+ ```
146
+
147
+ TypeScript, Bun, esbuild and Vite read these options. `defuss-morph/jsx-runtime` and `defuss-morph/jsx-dev-runtime` resolve to the package entry, so a bundle holds one copy of morph. The runtime returns plain VNodes and calls function components at once: there is no component tree and there are no hooks, so a component runs again on every render. A `key` given to a component lands on the element it returns, so `items.map((item) => <Row key={item.id} item={item} />)` is keyed.
148
+
149
+ Without a build step, `h(type, props, ...children)` builds the same VNodes: `h("button", { onClick: save }, "Save")`.
150
+
151
+ ### Reactive rendering
152
+
153
+ `reactive(config, target)` renders `config.render()` into `target` at once, and morphs it in again whenever a store changes. Nodes that stay keep their focus, typed input and listeners, so rendering the whole region again is safe.
154
+
155
+ ```ts
156
+ import { reactive } from "defuss-morph";
157
+ import { createStore } from "defuss-store";
158
+
159
+ const cart = createStore({ count: 1 });
160
+ const dispose = reactive({ store: cart, render: () => `<p>${cart.value.count} items</p>` }, cartEl);
161
+
162
+ cart.set("count", 2); // the same <p>, now "2 items"
163
+ dispose(); // unsubscribes and runs config.cleanup once
164
+ ```
165
+
166
+ | Config | Meaning |
167
+ | --- | --- |
168
+ | `store` | One store or an array; a change in any of them renders again. A store is anything with `subscribe(listener)` that returns an unsubscribe function (or an object with `unsubscribe()`), such as [defuss-store](https://www.npmjs.com/package/defuss-store)'s `createStore()` and `computed()`. |
169
+ | `render` | Returns JSX, VNodes or an HTML string. Keep it pure: it may run more often than the stores change. |
170
+ | `cleanup` | Runs once when the region is disposed. |
171
+ | `signal` | An `AbortSignal`; aborting disposes. |
172
+
173
+ `target` is an element, a ref (read on every render; it must hold an element when `reactive()` is called) or a list of elements such as a NodeList. The first render runs before the subscriptions start; a change renders as soon as the store notifies, which defuss-store does synchronously.
174
+
175
+ In JSX, `<Reactive>` does the same for its own wrapper element:
176
+
177
+ ```tsx
178
+ import { Reactive, morph } from "defuss-morph";
179
+
180
+ const counter = createStore({ n: 0 });
181
+
182
+ morph(appEl, (
183
+ <Reactive tag="section" class="counter" store={counter} render={() => (
184
+ <button onClick={() => counter.set("n", counter.value.n + 1)}>clicked {counter.value.n}</button>
185
+ )} />
186
+ ));
187
+ ```
192
188
 
193
- Under the hood, `morph()` delegates to `updateDomWithVdom` (the entry point
194
- `defuss` itself uses), which is exported for direct/framework use.
189
+ The wrapper (`tag`, default `div`) gets every other prop, including `ref`, and the first `render()` result as its children. Once morph has mounted it, `<Reactive>` subscribes; a parent re-render moves the subscription to the latest `render` and `store`; when the wrapper leaves the document it unsubscribes and runs `cleanup`.
190
+
191
+ With [defuss-query](https://www.npmjs.com/package/defuss-query), `installReactive(df$)` adds `.reactive(config)` to every selection, for markup that is already in the page. The method returns the selection; end the binding with `signal`:
192
+
193
+ ```js
194
+ import df$ from "defuss-query";
195
+ import { installReactive } from "defuss-morph";
196
+
197
+ installReactive(df$);
198
+ df$("#cart-total").reactive({ store: cart, render: () => `${cart.value.count} items`, signal: controller.signal });
199
+ ```
200
+
201
+ The CDN bundle installs it itself when it loads after defuss-query; loaded before it (defuss-query's recommended order), call `df$.installReactive(df$)` once.
202
+
203
+ ### Refs and the forwardRef pattern
204
+
205
+ `createRef()` returns `{ current: null, state: undefined }`. morph sets `current` to the element that carries `ref={...}` while it renders, and a morph that keeps the element keeps the ref on that node. In a component body, `current` still holds the previous render's element, so read it in handlers, in `onMount` or after `morph()` returns.
206
+
207
+ There is no `forwardRef()` function: a component takes an optional `ref` prop with a default, passes it to its root and puts its API on `ref.state`. Alone, the component uses its own ref; a parent that passes one calls the API.
208
+
209
+ ```tsx
210
+ import { Reactive, type Ref, createRef, morph } from "defuss-morph";
211
+ import { createStore } from "defuss-store";
212
+
213
+ const createStepper = () => {
214
+ const count = createStore({ n: 0 });
215
+ return { count, reset: () => count.set("n", 0) };
216
+ };
217
+
218
+ const Stepper = ({ ref = createRef<HTMLElement, ReturnType<typeof createStepper>>() }: { ref?: Ref<HTMLElement, ReturnType<typeof createStepper>> }) => {
219
+ const api = (ref.state ??= createStepper()); // state on the ref outlives a parent re-render
220
+ return <Reactive ref={ref} store={api.count} render={() => <output>{api.count.value.n}</output>} />;
221
+ };
222
+
223
+ const stepper = createRef<HTMLElement, ReturnType<typeof createStepper>>();
224
+ morph(appEl, <Stepper ref={stepper} />);
225
+ stepper.state?.reset();
226
+ ```
227
+
228
+ The website runs each pattern with its source next to it: [morph.defuss.org/#patterns](https://morph.defuss.org/#patterns).
195
229
 
196
230
  ### Partial updates (diff mode)
197
231
 
198
- By default `morph()` reconciles the **complete** next state: anything you don't
199
- describe, is removed. With `{ diff: true }` you send only a **change-set** โ€” a
200
- partial update addressed by `key` (preferred) or `id`:
232
+ By default `morph()` reconciles the complete next state: anything the input does not describe is removed. With `{ diff: true }` the input is a change-set for the direct children of `el`, each item addressed by `key` (preferred) or `id`.
201
233
 
202
234
  Current DOM:
235
+
203
236
  ```html
204
- <ul id="listEl">
205
- <li key="a" data-x="1">A</li>
206
- <li key="b">B</li>
207
- <li key="c">C</li>
208
- </ul>
237
+ <ul id="listEl">
238
+ <li key="a" data-x="1">A</li>
239
+ <li key="b">B</li>
240
+ <li key="c">C</li>
241
+ </ul>
209
242
  ```
210
243
 
211
- The change-set: only the nodes that change, each addressed by `key`/`id`:
244
+ The change-set names only the nodes that change:
212
245
 
213
246
  ```ts
214
247
  const listEl = document.getElementById("listEl")!;
215
- const changeSetHtml = `
248
+
249
+ morph(listEl, `
216
250
  <li key="b">B (updated)</li>
217
251
  <li key="d">D (new)</li>
218
- `;
219
-
220
- morph(listEl, changeSetHtml, { diff: true });
252
+ `, { diff: true });
221
253
  ```
222
254
 
223
255
  New DOM:
@@ -225,146 +257,183 @@ New DOM:
225
257
  ```html
226
258
  <ul id="listEl">
227
259
  <li key="a" data-x="1">A</li> <!-- untouched -->
228
- <li key="b">B (updated)</li> <!-- patched in place, attributes MERGED -->
260
+ <li key="b">B (updated)</li> <!-- patched in place, attributes merged -->
229
261
  <li key="c">C</li> <!-- untouched -->
230
262
  <li key="d">D (new)</li> <!-- appended -->
231
263
  </ul>
232
264
  ```
233
265
 
234
- Result: `a` and `c` are **untouched** (identity, `data-x`, order, focus,
235
- listeners all kept) even though the change-set never mentioned them; `b` is
236
- patched โ€” its text updates and declared attributes merge while undeclared ones
237
- survive โ€” and `d` is appended. Diff mode **never removes and never moves**
238
- anything, so addressing stays unambiguous:
239
-
240
- - Patch items must be addressed by `key`/`id` โ€” plain text or key-less
241
- elements throw (there is no honest way to address "some `<div>`").
242
- - A tag is only required for *new* (unmatched) items.
243
- - Deleting is not expressible in a diff change-set โ€” use a regular full
244
- `morph()` for that.
245
- - A declared `children` replaces the node's children; if children are not
246
- declared, they stay untouched (via VNodes, `children: []` clears explicitly).
247
- - A tag change on an addressed node applies as an in-place replacement.
248
-
249
- Great for streaming/AI-driven UIs: patch one table row or status badge without
250
- re-sending (or risking) the rest of the subtree. Runnable demo:
251
- [`examples/diff-mode.html`](examples/diff-mode.html).
266
+ `a` and `c` keep identity, `data-x`, order, focus and listeners although the change-set never mentions them. `b` is patched: its text changes, declared attributes are merged in and undeclared ones stay. `d` is appended. Diff mode never removes and never moves anything, so addressing stays unambiguous:
252
267
 
253
- ### Transitions
268
+ - Items must carry a `key` or `id`; plain text and items without either throw an `Error`.
269
+ - Only new (unmatched) items need a tag. A VNode item without `type` borrows the tag of the node it addresses.
270
+ - Deleting or reordering needs a regular full `morph()`.
271
+ - Declared children are morphed into the node; without declared children the node's children stay untouched (with VNodes, `children: []` clears them).
272
+ - A different tag on an addressed node replaces that node.
273
+ - Top-level comments in a change-set are skipped; comments inside items are morphed.
254
274
 
255
- `morph()` accepts an optional transition, turning it into an async operation
256
- (mirroring defuss' `updateDom` transition semantics):
275
+ Runnable demo: [`examples/diff-mode.html`](examples/diff-mode.html).
276
+
277
+ ### Transitions
257
278
 
258
279
  ```ts
259
280
  await morph(appEl, "<p>Faded in</p>", {
260
- transition: { type: "fade", duration: 200 }, // slide-left | slide-right | shake | custom styles
281
+ transition: { type: "fade", duration: 200 }, // slide-left | slide-right | shake | none
261
282
  });
262
283
  ```
263
284
 
264
- Without a `transition` option, `morph()` is fully synchronous.
285
+ | Option | Default | Meaning |
286
+ | --- | --- | --- |
287
+ | `type` | `"fade"` | `fade` cross-fades a snapshot of the old content; the others animate out, morph, then animate in. `none` keeps the call synchronous. |
288
+ | `duration` | `300` | Milliseconds per phase. |
289
+ | `easing` | `"ease-in-out"` | Any CSS timing function. |
290
+ | `delay` | `0` | Milliseconds before the transition starts. |
291
+ | `target` | `"parent"` | Animate the parent or the element itself (`"self"`). With `"parent"`, an element without a parent is morphed without animation. |
292
+ | `styles` | by type | Your own `{ enter, enterActive, exit, exitActive }` style maps (not used by `fade`). |
265
293
 
266
- Overlapping updates are **latest-wins**: a plain `morph()` issued while a transition is in flight applies immediately and also rewrites the transition's pending content, so the transition completes with the latest HTML โ€” never stale content. `await` the returned promise (or chain morphs into a queue) when you want the *animations* to play sequentially โ€” see [`examples/transition-await.html`](examples/transition-await.html).
294
+ Overlapping updates resolve to the latest content: a plain `morph()` issued while a transition runs applies at once and also replaces the content that transition will apply, so the transition never ends on stale markup. `await` the returned Promise, or chain morphs, to play animations one after another; see [`examples/transition-await.html`](examples/transition-await.html).
267
295
 
268
296
  ### Low-level primitives
269
297
 
270
- The building blocks are exported for framework integration (used by `defuss` itself):
298
+ The building blocks are exported for framework integration; defuss-query is built on them:
271
299
 
272
300
  ```ts
273
301
  import {
274
- updateDomWithVdom, // guarded VNode -> DOM morph entry point (globals optional,
275
- // 4th arg mode: "replace" | "diff")
276
- replaceDomWithVdom, // full replace (no patching)
277
- htmlStringToVNodes, // HTML string -> VNode[]
278
- domNodeToVNode, // DOM Node -> VNode
279
- getRenderer, // VNode -> DOM renderer factory
280
- registerDelegatedEvent, removeDelegatedEvent, clearDelegatedEventsDeep, // ...
302
+ updateDomWithVdom, // guarded VNode morph entry point; 4th argument mode: "replace" | "diff"
303
+ replaceDomWithVdom, // remove all children (with handler teardown), render from scratch
304
+ htmlStringToVNodes, // HTML string to VNode[]
305
+ domNodeToVNode, // DOM node to VNode
306
+ getRenderer, // VNode to DOM renderer
307
+ registerDelegatedEvent, removeDelegatedEvent, clearDelegatedEventsDeep,
308
+ onClearDelegatedEvents, // hook: release your own listeners whenever morph
309
+ // removes or replaces an element
281
310
  } from "defuss-morph";
282
311
 
283
- // the third `globals` argument is optional everywhere โ€” it is derived
284
- // from the element's own document when omitted:
312
+ // the third `globals` argument is optional everywhere; it is derived
313
+ // from the element's own document when omitted
285
314
  updateDomWithVdom(el, [{ type: "p", attributes: {}, children: ["hi"] }]);
286
315
  ```
287
316
 
288
- ## Bundle size
317
+ ## Integrations
289
318
 
290
- Direct jsDelivr ESM loading (`<script type="module">`), no build step needed:
319
+ defuss-morph renders; the other defuss packages each do one job around it. The website demonstrates each one live: [morph.defuss.org/#integrations](https://morph.defuss.org/#integrations).
291
320
 
292
- ```html
293
- <script type="module">
294
- import { morph } from "https://cdn.jsdelivr.net/npm/defuss-morph@latest/dist/all.min.js";
321
+ | Package | With defuss-morph |
322
+ | --- | --- |
323
+ | [defuss-query](https://www.npmjs.com/package/defuss-query) | `df$`, a jQuery-shaped facade: `.html()`, `.text()` and `.morph()` morph; `installReactive(df$)` adds `.reactive()`. |
324
+ | [defuss-store](https://www.npmjs.com/package/defuss-store) | Stores for `reactive()` and `<Reactive>`; `computed()` re-renders a region only when its result changes. |
325
+ | [defuss-i18n](https://i18n.defuss.org/) | Translations as `<template>`s beside the markup; `setLocale()` morphs the translated parts and keeps typed input. |
326
+ | [defuss-dom-router](https://dom-router.defuss.org/) | Resolves URLs and orders navigations; a view's `commit()` calls `morph()`. Key a view's root by record, so another record gets fresh fields. |
295
327
 
296
- morph(document.getElementById("app"), "<p>Hello from the CDN</p>");
297
- </script>
298
- ```
328
+ Use one copy of defuss-morph per page: with a bundler that happens by itself; ES modules without one share it through an import map. defuss-i18n 0.2.0 declares older peer ranges (`defuss-morph ^0.1.1`, `defuss-query ^0.1.0`); it works with defuss-morph 0.3.0 and defuss-query 0.2.0 in this repository's tests.
299
329
 
300
- Or via the `df$` global โ€” declared by the CDN bundle on load, no import statement required. `df$` carries the **full public API** (`morph`, `updateDomWithVdom`, `registerDelegatedEvent`, transitions, โ€ฆ); a pre-existing `df$` object is preserved and extended:
330
+ ## Agent skill
301
331
 
302
- ```html
303
- <script type="module" src="https://cdn.jsdelivr.net/npm/defuss-morph@latest/dist/all.min.js"></script>
304
- <script type="module">
305
- const { morph, updateDomWithVdom } = window.df$;
332
+ `skills/defuss-morph/` is an Agent Skill: what a coding agent needs to use defuss-morph without reading its source, namely when to morph, the rules, the four patterns, the integrations and the pitfalls. It ships in the npm package too (`node_modules/defuss-morph/skills/`).
306
333
 
307
- morph(document.getElementById("app"), "<p>Hello from the CDN</p>");
334
+ ```bash
335
+ # Claude Code
336
+ claude plugin marketplace add kyr0/defuss-morph
337
+ claude plugin install defuss-morph@defuss-morph
308
338
 
309
- // explicit (controlled) form state โ€” HTML can't express "unchecked":
310
- updateDomWithVdom(document.getElementById("app"), [
311
- { type: "input", attributes: { type: "checkbox", checked: false }, children: [] },
312
- ]); // globals optional: derived from the element's own document
313
- </script>
339
+ # Codex, Cursor, Gemini CLI, GitHub Copilot, Windsurf and others (skills CLI)
340
+ npx skills add kyr0/defuss-morph --skill defuss-morph # asks which agents
341
+ npx skills add kyr0/defuss-morph --skill defuss-morph --agent codex
314
342
  ```
315
343
 
316
- A complete, runnable page lives at [`examples/cdn-global.html`](examples/cdn-global.html).
344
+ Start a new session afterwards. The agent loads the skill when a task matches its description; to call it by name, write `/defuss-morph:defuss-morph` in Claude Code or `$defuss-morph` in Codex. `npx skills update` and `claude plugin marketplace update defuss-morph` fetch new versions.
317
345
 
318
- The `df$` global is only registered by the CDN bundle (`all.js` / `all.min.js`). The library entries (`index.mjs` / `index.cjs`, used by bundlers, Node and SSR) are side-effect-free.
346
+ ## Limitations of HTML strings
319
347
 
320
- ## Examples
321
-
322
- Runnable zero-build pages (serve the package directory statically, e.g. `bunx serve .`):
323
-
324
- | Example | What it shows |
325
- | --- | --- |
326
- | [`examples/cdn-global.html`](examples/cdn-global.html) | The `df$` global end-to-end: morphing, keyed lists, controlled form state, delegated events, transitions |
327
- | [`examples/controlled-form-state.html`](examples/controlled-form-state.html) | The "HTML can't uncheck" limitation and its fix via `df$.updateDomWithVdom` |
328
- | [`examples/event-listener-preservation.html`](examples/event-listener-preservation.html) | Native + delegated listeners surviving morphs; handler cleanup on removal |
329
- | [`examples/transition-await.html`](examples/transition-await.html) | Transitions: latest-wins content, `await`/queue patterns for sequencing animations |
330
- | [`examples/diff-mode.html`](examples/diff-mode.html) | Diff mode: partial change-sets by key/id โ€” merge-patch, append, untouched siblings |
331
-
332
- ## One limitation when morphing from HTML strings
348
+ Markup that does not mention something means "leave it alone". Three rules follow from that.
333
349
 
334
- **HTML cannot unset `checked`/live form values**: since an HTML string cannot declare "explicitly unchecked", absent form attributes are treated as uncontrolled and the live state is preserved. This is deliberate โ€” it's what makes form state survive unrelated morphs.
335
-
336
- When you *do* need explicit control, use [diff mode](#partial-updates-diff-mode) with a VNode patch item: address the node by `id` (or `key`) and send **only the new value** โ€” a VNode can express an explicit `false`, and since the addressed node already exists, not even a tag is needed (it is borrowed from the match):
350
+ **HTML cannot unset `checked` or a live `value`.** An HTML string cannot say "explicitly unchecked", so absent form attributes are treated as uncontrolled and the live state is kept. That is what lets form state survive unrelated morphs. To set the state explicitly, send a VNode in diff mode: address the node by `id` (or `key`) and send only the new value. The node exists, so no tag is needed.
337
351
 
338
352
  ```ts
339
353
  import { morph } from "defuss-morph";
340
354
 
341
355
  const appEl = document.getElementById("app")!;
342
356
 
343
- // โŒ stays checked โ€” "no checked attribute" means "don't touch"
344
- morph(appEl, `<input type="checkbox" id="c" checked>`); // checked in HTML
345
- morph(appEl, `<input type="checkbox" id="c">`); // still checked!
357
+ morph(appEl, `<input type="checkbox" id="c" checked>`);
358
+ morph(appEl, `<input type="checkbox" id="c">`); // still checked: "no checked attribute" means "don't touch"
346
359
 
347
- // โœ… diff mode: address by id, set the new value โ€” done
348
- morph(appEl, [{ attributes: { id: "c", checked: false } }], { diff: true });
360
+ morph(appEl, [{ attributes: { id: "c", checked: false } }], { diff: true }); // unchecked
349
361
  ```
350
362
 
351
- The same rule applies to an `<input>`'s live `value`. See the runnable demos [`examples/controlled-form-state.html`](examples/controlled-form-state.html) and [`examples/diff-mode.html`](examples/diff-mode.html).
363
+ Runnable demos: [`examples/controlled-form-state.html`](examples/controlled-form-state.html) and [`examples/diff-mode.html`](examples/diff-mode.html).
364
+
365
+ **Inline `on*` attributes are never removed.** An attribute such as `onclick="..."` stays when a later morph leaves it out. Attach behaviour with `addEventListener` or JSX handlers instead.
366
+
367
+ **Diff mode cannot delete.** A change-set only patches and appends.
368
+
369
+ ## Security
370
+
371
+ `morph()` renders its input; it does not sanitize it. Elements are created with `document.createElement`, so a `<script>` inside an HTML string runs when it is morphed in (with `innerHTML` it would not), and attributes such as `onclick` are applied as given. Never morph untrusted HTML without sanitizing it first.
372
+
373
+ ## Examples
374
+
375
+ Zero-build pages that load the freshly built `dist/all.js` (the two reactive ones load defuss-store and defuss-query from jsDelivr). `make start` serves them at `http://127.0.0.1:4173/examples/<name>.html`; `make e2e` runs each one's scenario in Chromium:
376
+
377
+ | Example | What it shows |
378
+ | --- | --- |
379
+ | [`examples/cdn-global.html`](examples/cdn-global.html) | The `df$` global end to end: morphing, keyed lists, controlled form state, delegated events, transitions |
380
+ | [`examples/controlled-form-state.html`](examples/controlled-form-state.html) | The "HTML can't uncheck" limitation and its fix with `df$.updateDomWithVdom` |
381
+ | [`examples/event-listener-preservation.html`](examples/event-listener-preservation.html) | Native and delegated listeners surviving morphs; handler cleanup on removal |
382
+ | [`examples/transition-await.html`](examples/transition-await.html) | Transitions: latest content wins, `await` and queue patterns for sequencing animations |
383
+ | [`examples/diff-mode.html`](examples/diff-mode.html) | Diff mode: partial change-sets by key or id, merge-patch, append, untouched siblings |
384
+ | [`examples/reactive-html.html`](examples/reactive-html.html) | `reactive()` with HTML strings and defuss-store; `df$(selector).reactive()` with defuss-query, ended by an `AbortSignal` |
385
+ | [`examples/reactive-jsx.html`](examples/reactive-jsx.html) | JSX without a build step through `h()`: `<Reactive>`, a ref and the forwardRef pattern |
352
386
 
353
387
  ## Size
354
388
 
389
+ `make build` measures the built files and rewrites this table (and the website's figures) from `dist/stats.json`:
390
+
355
391
  <!-- bundle-size:start -->
356
392
  | File | Size | Gzipped | Purpose |
357
393
  | --- | ---: | ---: | --- |
358
- | `index.mjs` | 40.2 kB | 9.5 kB | ESM/library build; used when installing via npm/bun |
359
- | `index.cjs` | 41.2 kB | 9.7 kB | CommonJS build |
360
- | `all.js` | 41.6 kB | 9.8 kB | UMD build; for CDN-based usage with debugging |
361
- | `all.min.js` | 19.0 kB | **6.8 kB** | Minified UMD build; for CDN-based usage without debugging (Pareto-optimal when no bundler is used) |
394
+ | `index.mjs` | 47.7 kB | 12.1 kB | ESM/library build; used when installing via npm/bun |
395
+ | `index.cjs` | 49.0 kB | 12.3 kB | CommonJS build |
396
+ | `all.js` | 49.3 kB | 12.3 kB | Self-contained ESM bundle for CDN and `<script>` use, readable for debugging; registers `df$` |
397
+ | `all.min.js` | 23.2 kB | **8.5 kB** | Minified `all.js`; the file to load from a CDN when no bundler is used |
362
398
  <!-- bundle-size:end -->
363
399
 
364
- `index.cjs` (CommonJS) is kept for `require()` compatibility on older tool
365
- chains (CJS jest configs, bundlers resolving `main`). On modern Node
366
- (โ‰ฅ 20.19 / 22.12, which can `require()` ESM directly) it is redundant โ€” but at
367
- ~1 kB it is cheap insurance.
400
+ `index.cjs` serves `require()` on older toolchains (CommonJS Jest configurations, bundlers that resolve `main`). Node 20.19 and 22.12 or newer can `require()` the ESM build directly.
401
+
402
+ ## Benchmark
403
+
404
+ The website's Benchmark section times typical operations on a table of rows, after [js-framework-benchmark](https://github.com/krausest/js-framework-benchmark), in the visitor's browser. It creates, replaces, appends and clears 1,000 rows, updates every 10th row, selects one row, swaps two, removes one, creates 10,000 rows, and re-renders a 10-row view 500 times, one changed row each time, the work of a `reactive()` region. Each operation runs four ways:
405
+
406
+ - `innerHTML`, the baseline
407
+ - `morph()` of the whole list, with keys
408
+ - `morph()` of the whole list, without keys
409
+ - diff mode, sending only the changed rows (n/a for replace, swap, remove and clear: diff mode never removes or moves)
410
+
411
+ `make bench` runs the same suite (`docs/demos/bench.ts`) in headless Chromium, Firefox and WebKit, with HTML strings and with VNodes. It prints a table per browser and input, writes `output/bench.json`, and writes the run into the website's recorded table with its date, machine and browser versions. `make coverage` writes the unit test coverage into the website the same way.
412
+
413
+ A cell is the median of 7 timed runs (3 for 10,000 rows), after 2 untimed ones. A run times the update and the layout it causes; building the input is not timed. After every run the table is checked against the expected rows. This README states no numbers, because they depend on the machine and the browser; the website's recorded table names the run it shows.
414
+
415
+ ## Development
416
+
417
+ Requirements: [Bun](https://bun.sh) (version pinned in `packageManager`) and Node.js 20 or newer. `make` lists every target.
418
+
419
+ ```bash
420
+ make setup # bun install --frozen-lockfile, Playwright Chromium
421
+ make build # pkgroll, CDN bundle, minify, sizes, website copies, dist consistency check
422
+ make verify # what CI runs: lint, test, coverage, e2e
423
+ make start # dev server: website at http://127.0.0.1:4173/docs/, examples under /examples/
424
+ ```
425
+
426
+ The dev server (`scripts/serve.mjs`) serves the repository read-only with caching off, so after `make build` a reload shows the new build. It runs in the background: `make status` prints its URL, `make log` its request log, `make stop` ends it, and `PORT=8080 make start` picks another port.
427
+
428
+ | Target | What it checks |
429
+ | --- | --- |
430
+ | `make lint` | oxlint with warnings as errors, then a strict `tsc` type check |
431
+ | `make test` | Every test file in real Chromium and real Firefox (Playwright), and all but the browser-only ones in happy-dom |
432
+ | `make coverage` | The happy-dom and Chromium runs with merged V8 coverage (V8 cannot measure in Firefox); writes the website's coverage rings |
433
+ | `make bench` | The website's benchmark in headless Chromium, Firefox and WebKit, with HTML strings and VNodes; tables on stdout, `output/bench.json` and the website's recorded run |
434
+ | `make e2e` | Packs the package, installs the tarball in a clean project and uses it from Node (ESM and CommonJS, happy-dom, with defuss-store), then drives the website, every demo on it and every example page in Chromium, and the built library in Chromium and Firefox; results land in `output/` |
435
+
436
+ `dist/` is committed so the ZIP download and jsDelivr's GitHub URLs serve the built files; `make build` rewrites it, and `docs/` (the website, served by GitHub Pages at morph.defuss.org) with it: the demos in `docs/demos/` are bundled into `docs/assets/demos.js` (and the frames' `router-demo.js` and `bench.js`), the sibling packages are copied from `node_modules` into `docs/assets/vendor/`, and every code block names the demo source it shows. After `npm publish`, `make purge-cdn` refreshes jsDelivr's `@latest` cache. Design notes: [ARCH.md](ARCH.md). Release notes: [CHANGELOG.md](CHANGELOG.md).
368
437
 
369
438
  ## License
370
439