defuss-morph 0.2.0 โ 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/CHANGELOG.md +34 -0
- package/README.md +281 -218
- package/dist/all.js +454 -200
- package/dist/all.js.map +15 -11
- package/dist/all.min.js +1 -1
- package/dist/all.min.js.map +1 -1
- package/dist/index.cjs +344 -121
- package/dist/index.d.ts +308 -26
- package/dist/index.mjs +334 -122
- package/dist/stats.json +9 -9
- package/package.json +46 -13
- package/skills/defuss-morph/SKILL.md +101 -0
- package/skills/defuss-morph/references/api.md +86 -0
- package/skills/defuss-morph/references/integrations.md +116 -0
- package/skills/defuss-morph/references/patterns.md +190 -0
- package/skills/defuss-morph/references/pitfalls.md +29 -0
package/README.md
CHANGED
|
@@ -1,85 +1,58 @@
|
|
|
1
1
|
# defuss-morph
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
5
|
+
Website with a live playground: [morph.defuss.org](https://morph.defuss.org/)
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
What it does:
|
|
8
8
|
|
|
9
|
-
|
|
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
|
-
|
|
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; JSX handlers listen on the element itself, so propagation is exactly native (order, `stopPropagation()`, `currentTarget`, non-bubbling events like `toggle`, `load`, `error`)
|
|
19
|
-
- ๐ Form-state preservation (see one limitation below w/ solution)
|
|
20
|
-
- ๐ฌ HTML comments are kept and morphed like text โ `morph(el, el.innerHTML)` changes no node
|
|
21
|
-
- โก No compile/transpile step required (but optionally available)
|
|
22
|
-
- ๐ชถ Extremely lightweight โ just ~6 KiB minified and gzipped
|
|
23
|
-
- ๐
Includes support for beautiful transitions (fade, slide, shake, custom styles)
|
|
24
|
-
- ๐งฉ Supports partial updates (diff mode) for streaming/AI-driven UIs
|
|
25
|
-
- โ
~97%+ test coverage with unit tests and real browser E2E tests
|
|
26
|
-
- ๐ฆ Written in TypeScript
|
|
21
|
+
## Quick start from the CDN
|
|
27
22
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
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:
|
|
31
24
|
|
|
32
25
|
```html
|
|
33
26
|
<body>
|
|
34
27
|
<div id="app">Old content</div>
|
|
35
28
|
|
|
36
|
-
<!-- right before the closing </body> tag, include the CDN bundle -->
|
|
37
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>
|
|
38
35
|
</body>
|
|
39
36
|
```
|
|
40
37
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
```html
|
|
44
|
-
<script>
|
|
45
|
-
const { morph } = window.df$;
|
|
46
|
-
|
|
47
|
-
morph(document.getElementById("app"), "<p>Hello from the CDN</p>", {
|
|
48
|
-
transition: { type: "fade", duration: 200 }
|
|
49
|
-
});
|
|
50
|
-
</script>
|
|
51
|
-
```
|
|
38
|
+
An existing `df$` object or function (for example from defuss-query) is kept and extended with the API instead of being replaced.
|
|
52
39
|
|
|
53
|
-
|
|
54
|
-
- **Drawbacks:** Doesn't work offline, single-point-of-failure, no bundler optimizations.
|
|
55
|
-
|
|
56
|
-
## Quick, modern CDN-based setup using ESM imports
|
|
57
|
-
|
|
58
|
-
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:
|
|
59
41
|
|
|
60
42
|
```html
|
|
61
|
-
<
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
<!-- right before the closing </body> tag, import via ESM -->
|
|
65
|
-
<script type="module">
|
|
66
|
-
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";
|
|
67
45
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
});
|
|
71
|
-
</script>
|
|
72
|
-
</body>
|
|
46
|
+
morph(document.getElementById("app"), "<p>Hello from the CDN</p>");
|
|
47
|
+
</script>
|
|
73
48
|
```
|
|
74
49
|
|
|
75
|
-
|
|
76
|
-
- **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.
|
|
77
51
|
|
|
78
|
-
##
|
|
52
|
+
## Without a CDN
|
|
79
53
|
|
|
80
|
-
Download
|
|
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:
|
|
81
55
|
|
|
82
|
-
Example:
|
|
83
56
|
```text
|
|
84
57
|
project/
|
|
85
58
|
โโโ assets/
|
|
@@ -89,94 +62,69 @@ project/
|
|
|
89
62
|
โโโ index.html
|
|
90
63
|
```
|
|
91
64
|
|
|
92
|
-
Then include the local bundle in your HTML, using either the modern ESM import:
|
|
93
|
-
|
|
94
65
|
```html
|
|
95
|
-
<
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
<script type="module">
|
|
99
|
-
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";
|
|
100
68
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
});
|
|
104
|
-
</script>
|
|
105
|
-
</body>
|
|
69
|
+
morph(document.getElementById("app"), "<p>Hello from local assets</p>");
|
|
70
|
+
</script>
|
|
106
71
|
```
|
|
107
72
|
|
|
108
|
-
This
|
|
109
|
-
|
|
110
|
-
- **Good for:** Offline-support, air-gapped environments, and locking the exact version you ship.
|
|
111
|
-
- **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.
|
|
112
74
|
|
|
113
|
-
##
|
|
75
|
+
## With a package manager
|
|
114
76
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
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.
|
|
118
78
|
|
|
119
79
|
```bash
|
|
120
80
|
bun add defuss-morph
|
|
121
81
|
# or: npm install defuss-morph
|
|
122
82
|
```
|
|
123
83
|
|
|
124
|
-
Then, in `.ts(x)` or `.js(x)` files:
|
|
125
|
-
|
|
126
84
|
```ts
|
|
127
85
|
import { morph } from "defuss-morph";
|
|
128
86
|
|
|
129
87
|
const appEl = document.getElementById("app")!;
|
|
130
88
|
|
|
131
|
-
// only what actually changed is patched; untouched nodes keep
|
|
132
|
-
// their identity, focus, selection, scroll position and event state
|
|
133
89
|
morph(appEl, `<ul>
|
|
134
90
|
<li key="a">Alpha</li>
|
|
135
91
|
<li key="b">Beta</li>
|
|
136
|
-
</ul
|
|
92
|
+
</ul>`);
|
|
137
93
|
|
|
94
|
+
// b moves in front of a; neither node is recreated, so both keep
|
|
95
|
+
// their focus, selection, scroll position and listeners
|
|
138
96
|
morph(appEl, `<ul>
|
|
139
97
|
<li key="b">Beta (updated)</li>
|
|
140
98
|
<li key="a">Alpha</li>
|
|
141
|
-
</ul
|
|
142
|
-
```
|
|
143
|
-
|
|
144
|
-
If you have `bun` installed, you can serve your project directory statically with a single command:
|
|
145
|
-
|
|
146
|
-
```bash
|
|
147
|
-
bunx serve .
|
|
99
|
+
</ul>`);
|
|
148
100
|
```
|
|
149
101
|
|
|
150
102
|
## API
|
|
151
103
|
|
|
152
|
-
### `morph(el
|
|
104
|
+
### `morph(el, newContent, options?)`
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
morph(el: Element, newContent: RenderInput, options?: MorphOptions): void | Promise<void>
|
|
108
|
+
```
|
|
153
109
|
|
|
154
|
-
|
|
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.
|
|
155
111
|
|
|
156
|
-
- HTML strings are parsed with the element's own document (`el.ownerDocument`), so
|
|
157
|
-
-
|
|
158
|
-
-
|
|
159
|
-
-
|
|
160
|
-
-
|
|
161
|
-
-
|
|
162
|
-
- 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.
|
|
163
118
|
|
|
164
119
|
### Morphing JSX (VNodes)
|
|
165
120
|
|
|
166
|
-
`morph()` accepts
|
|
167
|
-
algorithm. Strings are parsed with the native `DOMParser`; VNodes
|
|
168
|
-
(`{ type, attributes, children }` objects, as produced by any JSX factory)
|
|
169
|
-
are morphed directly. An HTML comment is `{ type: "#comment", value }`
|
|
170
|
-
(`COMMENT_TYPE`):
|
|
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`).
|
|
171
122
|
|
|
172
123
|
```tsx
|
|
173
124
|
import { morph } from "defuss-morph";
|
|
174
125
|
|
|
175
|
-
//
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
// JSX / VNodes (any factory producing { type, attributes, children } works โ
|
|
179
|
-
// 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
|
|
180
128
|
morph(
|
|
181
129
|
document.getElementById("app")!,
|
|
182
130
|
<ul>
|
|
@@ -186,40 +134,122 @@ morph(
|
|
|
186
134
|
);
|
|
187
135
|
```
|
|
188
136
|
|
|
189
|
-
Unlike an HTML string, a VNode
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
+
```
|
|
194
188
|
|
|
195
|
-
|
|
196
|
-
|
|
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).
|
|
197
229
|
|
|
198
230
|
### Partial updates (diff mode)
|
|
199
231
|
|
|
200
|
-
By default `morph()` reconciles the
|
|
201
|
-
describe, is removed. With `{ diff: true }` you send only a **change-set** โ a
|
|
202
|
-
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`.
|
|
203
233
|
|
|
204
234
|
Current DOM:
|
|
235
|
+
|
|
205
236
|
```html
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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>
|
|
211
242
|
```
|
|
212
243
|
|
|
213
|
-
The change-set
|
|
244
|
+
The change-set names only the nodes that change:
|
|
214
245
|
|
|
215
246
|
```ts
|
|
216
247
|
const listEl = document.getElementById("listEl")!;
|
|
217
|
-
|
|
248
|
+
|
|
249
|
+
morph(listEl, `
|
|
218
250
|
<li key="b">B (updated)</li>
|
|
219
251
|
<li key="d">D (new)</li>
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
morph(listEl, changeSetHtml, { diff: true });
|
|
252
|
+
`, { diff: true });
|
|
223
253
|
```
|
|
224
254
|
|
|
225
255
|
New DOM:
|
|
@@ -227,150 +257,183 @@ New DOM:
|
|
|
227
257
|
```html
|
|
228
258
|
<ul id="listEl">
|
|
229
259
|
<li key="a" data-x="1">A</li> <!-- untouched -->
|
|
230
|
-
<li key="b">B (updated)</li> <!-- patched in place, attributes
|
|
260
|
+
<li key="b">B (updated)</li> <!-- patched in place, attributes merged -->
|
|
231
261
|
<li key="c">C</li> <!-- untouched -->
|
|
232
262
|
<li key="d">D (new)</li> <!-- appended -->
|
|
233
263
|
</ul>
|
|
234
264
|
```
|
|
235
265
|
|
|
236
|
-
|
|
237
|
-
listeners all kept) even though the change-set never mentioned them; `b` is
|
|
238
|
-
patched โ its text updates and declared attributes merge while undeclared ones
|
|
239
|
-
survive โ and `d` is appended. Diff mode **never removes and never moves**
|
|
240
|
-
anything, so addressing stays unambiguous:
|
|
241
|
-
|
|
242
|
-
- Patch items must be addressed by `key`/`id` โ plain text or key-less
|
|
243
|
-
elements throw (there is no honest way to address "some `<div>`").
|
|
244
|
-
- A tag is only required for *new* (unmatched) items.
|
|
245
|
-
- Deleting is not expressible in a diff change-set โ use a regular full
|
|
246
|
-
`morph()` for that.
|
|
247
|
-
- A declared `children` replaces the node's children; if children are not
|
|
248
|
-
declared, they stay untouched (via VNodes, `children: []` clears explicitly).
|
|
249
|
-
- A tag change on an addressed node applies as an in-place replacement.
|
|
250
|
-
- Top-level comments in a change-set are skipped (they have no address, and
|
|
251
|
-
appending would duplicate them per patch); comments *inside* items morph.
|
|
252
|
-
|
|
253
|
-
Great for streaming/AI-driven UIs: patch one table row or status badge without
|
|
254
|
-
re-sending (or risking) the rest of the subtree. Runnable demo:
|
|
255
|
-
[`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:
|
|
256
267
|
|
|
257
|
-
|
|
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.
|
|
258
274
|
|
|
259
|
-
`
|
|
260
|
-
|
|
275
|
+
Runnable demo: [`examples/diff-mode.html`](examples/diff-mode.html).
|
|
276
|
+
|
|
277
|
+
### Transitions
|
|
261
278
|
|
|
262
279
|
```ts
|
|
263
280
|
await morph(appEl, "<p>Faded in</p>", {
|
|
264
|
-
transition: { type: "fade", duration: 200 }, // slide-left | slide-right | shake |
|
|
281
|
+
transition: { type: "fade", duration: 200 }, // slide-left | slide-right | shake | none
|
|
265
282
|
});
|
|
266
283
|
```
|
|
267
284
|
|
|
268
|
-
|
|
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`). |
|
|
269
293
|
|
|
270
|
-
Overlapping updates
|
|
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).
|
|
271
295
|
|
|
272
296
|
### Low-level primitives
|
|
273
297
|
|
|
274
|
-
The building blocks are exported for framework integration
|
|
298
|
+
The building blocks are exported for framework integration; defuss-query is built on them:
|
|
275
299
|
|
|
276
300
|
```ts
|
|
277
301
|
import {
|
|
278
|
-
updateDomWithVdom, // guarded VNode
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
// morph removes or replaces an element
|
|
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
|
|
287
310
|
} from "defuss-morph";
|
|
288
311
|
|
|
289
|
-
// the third `globals` argument is optional everywhere
|
|
290
|
-
// 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
|
|
291
314
|
updateDomWithVdom(el, [{ type: "p", attributes: {}, children: ["hi"] }]);
|
|
292
315
|
```
|
|
293
316
|
|
|
294
|
-
##
|
|
317
|
+
## Integrations
|
|
295
318
|
|
|
296
|
-
|
|
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).
|
|
297
320
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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. |
|
|
301
327
|
|
|
302
|
-
|
|
303
|
-
</script>
|
|
304
|
-
```
|
|
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.
|
|
305
329
|
|
|
306
|
-
|
|
330
|
+
## Agent skill
|
|
307
331
|
|
|
308
|
-
|
|
309
|
-
<script type="module" src="https://cdn.jsdelivr.net/npm/defuss-morph@latest/dist/all.min.js"></script>
|
|
310
|
-
<script type="module">
|
|
311
|
-
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/`).
|
|
312
333
|
|
|
313
|
-
|
|
334
|
+
```bash
|
|
335
|
+
# Claude Code
|
|
336
|
+
claude plugin marketplace add kyr0/defuss-morph
|
|
337
|
+
claude plugin install defuss-morph@defuss-morph
|
|
314
338
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
]); // globals optional: derived from the element's own document
|
|
319
|
-
</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
|
|
320
342
|
```
|
|
321
343
|
|
|
322
|
-
|
|
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.
|
|
323
345
|
|
|
324
|
-
|
|
346
|
+
## Limitations of HTML strings
|
|
325
347
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
Runnable zero-build pages (serve the package directory statically, e.g. `bunx serve .`):
|
|
329
|
-
|
|
330
|
-
| Example | What it shows |
|
|
331
|
-
| --- | --- |
|
|
332
|
-
| [`examples/cdn-global.html`](examples/cdn-global.html) | The `df$` global end-to-end: morphing, keyed lists, controlled form state, delegated events, transitions |
|
|
333
|
-
| [`examples/controlled-form-state.html`](examples/controlled-form-state.html) | The "HTML can't uncheck" limitation and its fix via `df$.updateDomWithVdom` |
|
|
334
|
-
| [`examples/event-listener-preservation.html`](examples/event-listener-preservation.html) | Native + delegated listeners surviving morphs; handler cleanup on removal |
|
|
335
|
-
| [`examples/transition-await.html`](examples/transition-await.html) | Transitions: latest-wins content, `await`/queue patterns for sequencing animations |
|
|
336
|
-
| [`examples/diff-mode.html`](examples/diff-mode.html) | Diff mode: partial change-sets by key/id โ merge-patch, append, untouched siblings |
|
|
337
|
-
|
|
338
|
-
## One limitation when morphing from HTML strings
|
|
348
|
+
Markup that does not mention something means "leave it alone". Three rules follow from that.
|
|
339
349
|
|
|
340
|
-
**HTML cannot unset `checked
|
|
341
|
-
|
|
342
|
-
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.
|
|
343
351
|
|
|
344
352
|
```ts
|
|
345
353
|
import { morph } from "defuss-morph";
|
|
346
354
|
|
|
347
355
|
const appEl = document.getElementById("app")!;
|
|
348
356
|
|
|
349
|
-
|
|
350
|
-
morph(appEl, `<input type="checkbox" id="c"
|
|
351
|
-
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"
|
|
352
359
|
|
|
353
|
-
|
|
354
|
-
morph(appEl, [{ attributes: { id: "c", checked: false } }], { diff: true });
|
|
360
|
+
morph(appEl, [{ attributes: { id: "c", checked: false } }], { diff: true }); // unchecked
|
|
355
361
|
```
|
|
356
362
|
|
|
357
|
-
|
|
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 |
|
|
358
386
|
|
|
359
387
|
## Size
|
|
360
388
|
|
|
389
|
+
`make build` measures the built files and rewrites this table (and the website's figures) from `dist/stats.json`:
|
|
390
|
+
|
|
361
391
|
<!-- bundle-size:start -->
|
|
362
392
|
| File | Size | Gzipped | Purpose |
|
|
363
393
|
| --- | ---: | ---: | --- |
|
|
364
|
-
| `index.mjs` |
|
|
365
|
-
| `index.cjs` |
|
|
366
|
-
| `all.js` |
|
|
367
|
-
| `all.min.js` |
|
|
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 |
|
|
368
398
|
<!-- bundle-size:end -->
|
|
369
399
|
|
|
370
|
-
`index.cjs` (CommonJS)
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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).
|
|
374
437
|
|
|
375
438
|
## License
|
|
376
439
|
|