From e0ca2927233795e02264139ef09b599e16d32190 Mon Sep 17 00:00:00 2001 From: Francis Secada Date: Mon, 20 Jul 2026 16:56:27 -0400 Subject: [PATCH 1/2] feat(client): JS-owned region escape hatch for morph (B4, #22) Add a data-no-morph attribute that lets component authors mark a subtree as owned by other JavaScript (third-party widgets, canvases, manually mounted library instances) so idiomorph never touches it. Wires beforeNodeMorphed/beforeNodeRemoved callbacks into the Idiomorph.morph() config shared by update() and rollback(), confirmed against idiomorph's source semantics: returning false from beforeNodeMorphed skips a node's attributes and children entirely (protects the whole subtree, not just the root), and false from beforeNodeRemoved keeps the node attached even if the server's HTML has no matching node at that position. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Pi1LT1PQ8qo9GcyLeDLiut --- README.md | 1 + docs/CLIENT_MORPHING.md | 70 +++++ .../js/component-client.js | 61 +++- tests/js/ignore-region.test.mjs | 286 ++++++++++++++++++ 4 files changed, 416 insertions(+), 2 deletions(-) create mode 100644 docs/CLIENT_MORPHING.md create mode 100644 tests/js/ignore-region.test.mjs 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..e379775 --- /dev/null +++ b/docs/CLIENT_MORPHING.md @@ -0,0 +1,70 @@ +# 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. 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 862e5f2..8618eaa 100644 --- a/src/component_framework/static/component_framework/js/component-client.js +++ b/src/component_framework/static/component_framework/js/component-client.js @@ -33,6 +33,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 */ @@ -292,7 +301,7 @@ class ComponentClient { const element = document.getElementById(componentId); if (!element) return; - Idiomorph.morph(element, snapshot.html, { morphStyle: 'outerHTML' }); + Idiomorph.morph(element, snapshot.html, this._morphConfig()); this.bind(element); this._snapshots.delete(componentId); @@ -322,7 +331,7 @@ class ComponentClient { return; } - Idiomorph.morph(element, html, { morphStyle: 'outerHTML' }); + Idiomorph.morph(element, html, this._morphConfig()); // Persist state so subsequent dispatches send the correct value back. if (state !== null) { @@ -476,6 +485,54 @@ class ComponentClient { return null; } + /** + * Build the `Idiomorph.morph()` config shared by `update()` and + * `rollback()`. + * + * Wires the "don't morph this" escape hatch (B4, #22): 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', + 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. 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; +}); From 91d2f7e7fc927dd0114c9973e92b367c97b43f03 Mon Sep 17 00:00:00 2001 From: Francis Secada Date: Mon, 20 Jul 2026 17:31:00 -0400 Subject: [PATCH 2/2] docs(client-morphing): address /review's XSS-hygiene and pantry-edge-case caveats (B4, #22) --- docs/CLIENT_MORPHING.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/CLIENT_MORPHING.md b/docs/CLIENT_MORPHING.md index e379775..02b010d 100644 --- a/docs/CLIENT_MORPHING.md +++ b/docs/CLIENT_MORPHING.md @@ -68,3 +68,21 @@ The attribute's presence is what matters — any value (or none) works. `[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.