diff --git a/README.md b/README.md
index f4c2df6..6ea88f8 100644
--- a/README.md
+++ b/README.md
@@ -260,6 +260,7 @@ Full docs (generated from docstrings via [pdoc](https://pdoc.dev/), versioned pe
| [State signing](docs/STATE_SIGNING.md) | HMAC state setup per adapter, key rotation |
| [Locked fields](docs/LOCKED_FIELDS.md) | Server-trusted state fields, threat model |
| [CSRF & CSWSH guide](docs/SECURITY_CSRF.md) | Per-adapter CSRF audit, WebSocket hijacking guidance |
+| [Client-side DOM morphing](docs/CLIENT_MORPHING.md) | Idiomorph integration, `data-no-morph` escape hatch for JS-owned regions |
| [E-commerce example](docs/examples/ecommerce.md) | Real-time cart + product walkthrough |
| [Multi-step wizard](docs/examples/wizard.md) | FastAPI wizard recipe |
diff --git a/docs/CLIENT_MORPHING.md b/docs/CLIENT_MORPHING.md
new file mode 100644
index 0000000..02b010d
--- /dev/null
+++ b/docs/CLIENT_MORPHING.md
@@ -0,0 +1,88 @@
+# Client-Side DOM Morphing
+
+`component-client.js` reconciles the DOM with each server render using
+[Idiomorph](https://github.com/bigskysoftware/idiomorph) (vendored unmodified
+at `static/component_framework/js/vendor/idiomorph.js`), instead of a full
+`innerHTML`/`outerHTML` replace. `update()` (the authoritative server render)
+and `rollback()` (restoring a snapshot on request failure) both morph the
+component element in place — nodes unaffected by a patch keep their identity,
+including attached event listeners, focus, scroll position, and any in-flight
+input.
+
+## Opting an element out of morphing: `data-no-morph`
+
+Some DOM regions inside a component are **owned by other JavaScript**, not by
+the server render — a third-party widget, a chart/canvas a library draws
+into, a manually-mounted rich-text editor, a map instance, and so on.
+Idiomorph reconciling that markup on every patch would fight the library (or
+destroy state the library keeps on the DOM nodes themselves).
+
+Mark the region's root element with `data-no-morph` and the client will never
+touch it, or anything inside it, on either `update()` or `rollback()`:
+
+```html
+
+```
+
+The attribute's presence is what matters — any value (or none) works.
+
+## Semantics
+
+- **The element and everything inside it is frozen against morphing.**
+ Idiomorph's `beforeNodeMorphed` callback returns `false` for a
+ `data-no-morph` element (checked via `closest('[data-no-morph]')`, so
+ descendants are covered too), which makes idiomorph skip that node
+ entirely — it copies no attributes onto it and does not recurse into its
+ children. The subtree is left exactly as the DOM has it, byte-for-byte.
+- **It also survives being dropped from the server response.** If the
+ incoming server HTML has no corresponding node at that position anymore,
+ idiomorph would normally remove the old one. The client's
+ `beforeNodeRemoved` callback returns `false` for a `data-no-morph` node, so
+ it stays attached instead of being deleted.
+- **Your server template can keep rendering the element's placeholder markup
+ as usual** (e.g. ``) — the client
+ never looks at what the server sent for that node's position, so what you
+ render there is purely for readability/SSR fallback, not functional.
+
+## Caveats
+
+- **Don't put `data-no-morph` on the component root.** The root element is
+ what `update()`/`rollback()` morph against; marking it ignored means the
+ component never reconciles with the server again.
+- **This is a client-side-only concern.** The server still re-renders the
+ region's markup on every dispatch like any other part of the component;
+ `data-no-morph` only controls whether the *client* applies that markup to
+ the live DOM. If a library needs to persist data across renders, keep that
+ data in the library's own state (or the DOM nodes it owns), not in
+ server-rendered markup you expect to come back unchanged.
+- **Scope it tightly.** Only mark the smallest element that actually needs
+ protecting — server-driven interactivity (`[data-event]` triggers, nested
+ `[data-component]` children) inside a `data-no-morph` region will never be
+ updated by a server patch, since idiomorph never visits that subtree at
+ all.
+- **Treat `data-no-morph` as author-controlled markup only — never render it
+ from unsanitized user input.** Unlike most `data-*` attributes in this
+ framework, this one's entire purpose is to make the client permanently stop
+ applying authoritative server corrections to a subtree. If an attacker
+ could get this attribute injected into their own rendered content (e.g. via
+ a template that unsafely interpolates user text), they could pin stale or
+ malicious markup against all future patches. This is the same output-
+ escaping discipline the framework already expects everywhere else — it's
+ called out here because the failure mode (a silently frozen DOM) is easy to
+ miss in review.
+- **The "survives removal" guarantee has one narrow edge case.** Idiomorph's
+ removal path checks its internal id-map *before* consulting
+ `beforeNodeRemoved`: a node participating in id-based match/move-to-pantry
+ handling can be relocated without this callback ever running. This only
+ matters if a `data-no-morph` element's root also happens to be matched by
+ `id` against another position in the tree — an unusual combination — but in
+ that corner case the element is not guaranteed to survive being dropped
+ from the server response the way the rest of this document describes.
diff --git a/src/component_framework/static/component_framework/js/component-client.js b/src/component_framework/static/component_framework/js/component-client.js
index 0efc905..4b54277 100644
--- a/src/component_framework/static/component_framework/js/component-client.js
+++ b/src/component_framework/static/component_framework/js/component-client.js
@@ -39,6 +39,15 @@
* // data-optimistic='{"count": 1}'>+
* //
*
+ * JS-owned regions inside a component's rendered subtree (a third-party
+ * widget, a manually-mounted JS library instance, a canvas the component
+ * doesn't want touched on every patch) can opt out of morphing entirely by
+ * carrying a `data-no-morph` attribute:
+ *
+ *
+ *
+ *
+ *
* @module component-client
*/
@@ -528,6 +537,64 @@ class ComponentClient {
return null;
}
+ /**
+ * Build the `Idiomorph.morph()` config shared by `update()` and
+ * `rollback()`.
+ *
+ * Wires two Epic B (#22) concerns into one shared config:
+ * - **B2**: `ignoreActiveValue: true` preserves in-flight input — without
+ * it, idiomorph overwrites the currently-focused element's `value`
+ * with whatever the server rendered, clobbering keystrokes made while
+ * the request was in flight. (Focus itself is preserved for free via
+ * idiomorph's own `restoreFocus` default — see `vendor/idiomorph.js`.)
+ * Scroll position, also a B2 concern, is deliberately NOT part of this
+ * config — idiomorph has no concept of scroll at all, so it's handled
+ * separately via explicit capture/restore around each morph call (see
+ * `_captureScrollPositions()`/`_restoreScrollPositions()`).
+ * - **B4**: the "don't morph this" escape hatch — any element carrying a
+ * `data-no-morph` attribute, and by extension everything inside it, is
+ * left completely untouched by the morph, so JS-owned DOM regions (a
+ * third-party widget, a manually-mounted JS library instance, a
+ * canvas, etc.) survive server patches unchanged. `beforeNodeMorphed`
+ * returning `false` makes idiomorph skip the node entirely — it
+ * neither copies attributes onto it nor recurses into its children, so
+ * descendants are protected too without needing their own check.
+ * `beforeNodeRemoved` returning `false` additionally stops idiomorph
+ * from deleting an ignored node outright when the incoming server HTML
+ * has no corresponding node at that position.
+ *
+ * @returns {object} The config object to pass as `Idiomorph.morph()`'s
+ * third argument.
+ */
+ _morphConfig() {
+ return {
+ morphStyle: 'outerHTML',
+ ignoreActiveValue: true,
+ callbacks: {
+ beforeNodeMorphed: (oldNode) => !this._isIgnoredNode(oldNode),
+ beforeNodeRemoved: (node) => !this._isIgnoredNode(node),
+ },
+ };
+ }
+
+ /**
+ * Whether `node` (or one of its ancestors) carries the `data-no-morph`
+ * escape-hatch attribute.
+ *
+ * Uses `closest()` rather than checking `node` alone so the whole subtree
+ * rooted at a `data-no-morph` element is protected, not just that
+ * element's own attributes. Non-element nodes (e.g. text nodes passed to
+ * idiomorph's callbacks) don't expose `closest()`; treat those as never
+ * ignored rather than throwing.
+ *
+ * @param {Node} node - The node idiomorph is about to morph or remove.
+ * @returns {boolean} True if the node must be left untouched.
+ */
+ _isIgnoredNode(node) {
+ if (!node || typeof node.closest !== 'function') return false;
+ return node.closest('[data-no-morph]') !== null;
+ }
+
/**
* Convert a camelCase or snake_case key to kebab-case for use in a
* `data-optimistic-` attribute name.
@@ -627,27 +694,6 @@ class ComponentClient {
});
}
- /**
- * Idiomorph config shared by `update()` and `rollback()` (B2, #22).
- *
- * `restoreFocus` already defaults to `true` upstream (see
- * `vendor/idiomorph.js`): if the focused input/textarea gets recreated
- * rather than patched in place, idiomorph re-finds it by `id` and restores
- * focus + selection. That alone is *not* sufficient to preserve in-flight
- * input, though — without `ignoreActiveValue`, idiomorph still overwrites
- * the focused element's `value` with whatever the server rendered,
- * clobbering keystrokes the user made while the request was in flight.
- * `ignoreActiveValue: true` skips only that value sync for the
- * currently-focused element; every other attribute/child still morphs
- * normally, so server-driven changes (e.g. a validation error class) still
- * apply around the input the user is actively editing.
- *
- * @returns {{morphStyle: string, ignoreActiveValue: boolean}}
- */
- _morphConfig() {
- return { morphStyle: 'outerHTML', ignoreActiveValue: true };
- }
-
/**
* Capture scroll offsets that would otherwise be lost when idiomorph
* recreates a scrollable element instead of patching it in place.
diff --git a/tests/js/ignore-region.test.mjs b/tests/js/ignore-region.test.mjs
new file mode 100644
index 0000000..a4dbe90
--- /dev/null
+++ b/tests/js/ignore-region.test.mjs
@@ -0,0 +1,286 @@
+/**
+ * Node-native tests for the "don't morph this" escape hatch in
+ * component-client.js (Epic B, B4 — #22).
+ *
+ * Run with: node --test "tests/js/**\/*.test.mjs"
+ * (`just test-js` has a pre-existing cmd.exe glob-expansion bug on Windows;
+ * use the raw node command instead — see PR #43.)
+ *
+ * Convention: an element carrying the `data-no-morph` attribute (any value,
+ * presence is enough) tells the client "idiomorph must never touch this
+ * element or its descendants" — for a third-party widget, a manually-mounted
+ * JS library instance, a canvas, etc. that a component doesn't want
+ * clobbered on every server patch.
+ *
+ * Scope note (consistent with morph.test.mjs's stated philosophy): idiomorph
+ * itself ships its own upstream test suite and is vendored unmodified, so we
+ * don't re-verify idiomorph's own internals here. Instead we verify the
+ * *integration seam* — that update()/rollback() wire a `beforeNodeMorphed`
+ * (and `beforeNodeRemoved`) callback into the Idiomorph.morph() config, and
+ * that the callback correctly identifies `data-no-morph` elements and their
+ * descendants. The resulting behaviour when idiomorph *runs* those callbacks
+ * was confirmed by direct source read of vendor/idiomorph.js:
+ *
+ * - morphNode() (~line 645): `if (ctx.callbacks.beforeNodeMorphed(oldNode,
+ * newContent) === false) { return oldNode; }` — the function returns
+ * immediately, *before* morphAttributes() or morphChildren() are called
+ * and *before* afterNodeMorphed() fires. So returning `false` protects
+ * the node's own attributes AND skips recursing into its children
+ * entirely — the whole subtree is left untouched, not just the root's
+ * attributes.
+ * - removeNode() (~line 528): `if (ctx.callbacks.beforeNodeRemoved(node)
+ * === false) return;` — the node is not removed from the DOM if the
+ * incoming server HTML has no matching node at that position.
+ */
+
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+
+// --- Minimal DOM stubs (installed before importing the module) ------------
+
+/**
+ * A tiny Element-ish stub that supports enough of the real DOM API for
+ * `closest('[data-no-morph]')`-style ancestor lookups, without pulling in a
+ * full DOM implementation (this repo has no jsdom dependency — see
+ * morph.test.mjs / optimistic.test.mjs).
+ */
+function makeNode({ attrs = {}, parent = null } = {}) {
+ return {
+ nodeType: 1,
+ _attrs: { ...attrs },
+ _parent: parent,
+ hasAttribute(name) {
+ return name in this._attrs;
+ },
+ getAttribute(name) {
+ return name in this._attrs ? this._attrs[name] : null;
+ },
+ closest(selector) {
+ const match = /^\[([\w-]+)\]$/.exec(selector);
+ const attrName = match ? match[1] : null;
+ let node = this;
+ while (node) {
+ if (attrName && node.hasAttribute(attrName)) return node;
+ node = node._parent;
+ }
+ return null;
+ },
+ };
+}
+
+// A Text-node-like stub: no `closest`, mirroring real DOM Text nodes.
+function makeTextNode() {
+ return { nodeType: 3 };
+}
+
+function makeEl(dataset = {}, extra = {}) {
+ return {
+ dataset,
+ id: extra.id ?? 'c1',
+ tagName: extra.tagName ?? 'DIV',
+ outerHTML: extra.outerHTML ?? '',
+ _attrs: {},
+ _replacedWith: null,
+ setAttribute(k, v) {
+ this._attrs[k] = v;
+ },
+ getAttribute(k) {
+ return k in this._attrs ? this._attrs[k] : null;
+ },
+ getAttributeNames() {
+ return Object.keys(this._attrs);
+ },
+ removeAttribute(k) {
+ delete this._attrs[k];
+ },
+ replaceWith(n) {
+ this._replacedWith = n;
+ },
+ closest() {
+ return null;
+ },
+ querySelectorAll() {
+ return [];
+ },
+ matches() {
+ return false;
+ },
+ };
+}
+
+const registry = {};
+globalThis.document = {
+ querySelector: () => null,
+ cookie: '',
+ getElementById: (id) => registry[id] ?? null,
+ addEventListener: () => {},
+ createElement: () => makeEl({}),
+};
+
+const { ComponentClient } = await import(
+ '../../src/component_framework/static/component_framework/js/component-client.js'
+);
+const { Idiomorph } = await import(
+ '../../src/component_framework/static/component_framework/js/vendor/idiomorph.js'
+);
+
+/** Capture the config idiomorph would have been called with. */
+function captureMorphConfig(run) {
+ let morphArgs = null;
+ const originalMorph = Idiomorph.morph;
+ Idiomorph.morph = (...args) => {
+ morphArgs = args;
+ };
+ try {
+ run();
+ } finally {
+ Idiomorph.morph = originalMorph;
+ }
+ return morphArgs;
+}
+
+// --- Tests ------------------------------------------------------------------
+
+test('update() wires a beforeNodeMorphed callback into the Idiomorph.morph config', () => {
+ const client = new ComponentClient();
+ const el = makeEl({ state: '{"count":1}' });
+ registry.c1 = el;
+
+ const morphArgs = captureMorphConfig(() => {
+ client.update(el, '2
', '{"count":2}');
+ });
+
+ assert.ok(morphArgs, 'Idiomorph.morph should have been called');
+ assert.equal(
+ typeof morphArgs[2].callbacks?.beforeNodeMorphed,
+ 'function',
+ 'a beforeNodeMorphed callback must be present in the morph config',
+ );
+ delete registry.c1;
+});
+
+test('beforeNodeMorphed returns false for an element carrying data-no-morph', () => {
+ const client = new ComponentClient();
+ const el = makeEl({ state: '{"count":1}' });
+ registry.c1 = el;
+
+ const morphArgs = captureMorphConfig(() => {
+ client.update(el, '2
', '{"count":2}');
+ });
+
+ const ignored = makeNode({ attrs: { 'data-no-morph': '' } });
+ const newContent = makeNode({});
+ assert.equal(
+ morphArgs[2].callbacks.beforeNodeMorphed(ignored, newContent),
+ false,
+ 'a data-no-morph element must be skipped by the morph',
+ );
+ delete registry.c1;
+});
+
+test('beforeNodeMorphed also protects descendants of an ignored ancestor', () => {
+ const client = new ComponentClient();
+ const el = makeEl({ state: '{"count":1}' });
+ registry.c1 = el;
+
+ const morphArgs = captureMorphConfig(() => {
+ client.update(el, '2
', '{"count":2}');
+ });
+
+ const ignoredAncestor = makeNode({ attrs: { 'data-no-morph': '' } });
+ const child = makeNode({ parent: ignoredAncestor });
+ const grandchild = makeNode({ parent: child });
+
+ assert.equal(morphArgs[2].callbacks.beforeNodeMorphed(child, makeNode({})), false);
+ assert.equal(morphArgs[2].callbacks.beforeNodeMorphed(grandchild, makeNode({})), false);
+ delete registry.c1;
+});
+
+test('beforeNodeMorphed does not skip ordinary elements', () => {
+ const client = new ComponentClient();
+ const el = makeEl({ state: '{"count":1}' });
+ registry.c1 = el;
+
+ const morphArgs = captureMorphConfig(() => {
+ client.update(el, '2
', '{"count":2}');
+ });
+
+ const ordinary = makeNode({});
+ assert.notEqual(morphArgs[2].callbacks.beforeNodeMorphed(ordinary, makeNode({})), false);
+ delete registry.c1;
+});
+
+test('beforeNodeMorphed does not throw for a text node (no closest())', () => {
+ const client = new ComponentClient();
+ const el = makeEl({ state: '{"count":1}' });
+ registry.c1 = el;
+
+ const morphArgs = captureMorphConfig(() => {
+ client.update(el, '2
', '{"count":2}');
+ });
+
+ const text = makeTextNode();
+ assert.doesNotThrow(() => morphArgs[2].callbacks.beforeNodeMorphed(text, makeNode({})));
+ assert.notEqual(morphArgs[2].callbacks.beforeNodeMorphed(text, makeNode({})), false);
+ delete registry.c1;
+});
+
+test('update() wires a beforeNodeRemoved callback that protects data-no-morph nodes from removal', () => {
+ const client = new ComponentClient();
+ const el = makeEl({ state: '{"count":1}' });
+ registry.c1 = el;
+
+ const morphArgs = captureMorphConfig(() => {
+ client.update(el, '2
', '{"count":2}');
+ });
+
+ assert.equal(
+ typeof morphArgs[2].callbacks?.beforeNodeRemoved,
+ 'function',
+ 'a beforeNodeRemoved callback must be present in the morph config',
+ );
+
+ const ignored = makeNode({ attrs: { 'data-no-morph': '' } });
+ assert.equal(
+ morphArgs[2].callbacks.beforeNodeRemoved(ignored),
+ false,
+ 'a data-no-morph node must not be removed even if absent from new content',
+ );
+
+ const ordinary = makeNode({});
+ assert.notEqual(morphArgs[2].callbacks.beforeNodeRemoved(ordinary), false);
+ delete registry.c1;
+});
+
+test('rollback() wires the same ignore-aware callbacks into the Idiomorph.morph config', () => {
+ const client = new ComponentClient();
+ const el = makeEl(
+ { state: '{"count":2}' },
+ { outerHTML: '1
' },
+ );
+ registry.c1 = el;
+ client._snapshot(el, 'c1');
+
+ const morphArgs = captureMorphConfig(() => {
+ client.rollback('c1');
+ });
+
+ assert.ok(morphArgs, 'Idiomorph.morph should have been called');
+ const ignored = makeNode({ attrs: { 'data-no-morph': '' } });
+ assert.equal(morphArgs[2].callbacks.beforeNodeMorphed(ignored, makeNode({})), false);
+ assert.equal(morphArgs[2].callbacks.beforeNodeRemoved(ignored), false);
+ delete registry.c1;
+});
+
+test('morphStyle is still outerHTML after adding the ignore-region callbacks', () => {
+ const client = new ComponentClient();
+ const el = makeEl({ state: '{"count":1}' });
+ registry.c1 = el;
+
+ const morphArgs = captureMorphConfig(() => {
+ client.update(el, '2
', '{"count":2}');
+ });
+
+ assert.equal(morphArgs[2].morphStyle, 'outerHTML');
+ delete registry.c1;
+});