Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
88 changes: 88 additions & 0 deletions docs/CLIENT_MORPHING.md
Original file line number Diff line number Diff line change
@@ -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
<div id="component-abc123" data-component="dashboard" data-state='{"range": "7d"}'>
<button data-event="set_range" data-payload='{"range": "30d"}'>30 days</button>

<!-- Idiomorph will never morph this element or its descendants, no matter
what the server sends back for this position in the tree. -->
<div data-no-morph id="price-chart">
<canvas></canvas>
</div>
</div>
```

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. `<div data-no-morph id="price-chart"></div>`) — 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.
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,15 @@
* // data-optimistic='{"count": 1}'>+</button>
* // </div>
*
* 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:
*
* <div data-no-morph>
* <!-- idiomorph will never touch this element or its descendants -->
* </div>
*
* @module component-client
*/

Expand Down Expand Up @@ -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-<field>` attribute name.
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading