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; +});