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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **Stable list reconciliation key (Epic B, B3 — #22)** — template authors
can mark reorderable list items with `data-key="<id>"` (e.g. `<li
data-key="42">`). `component-client.js` bridges this onto a synthesized
`id="cf-key-<id>"` (on both the live DOM and the incoming server render)
immediately before every `Idiomorph.morph()` call, so idiomorph's
id-based node matching reconciles reordered items by identity — preserving
focus, scroll position, in-flight input, and running CSS animations within
each item — instead of misattributing patches by position. See
`docs/LIST_RECONCILIATION_KEY.md`.

## [0.5.1b0] - 2026-07-01

### Added
Expand Down
67 changes: 67 additions & 0 deletions docs/LIST_RECONCILIATION_KEY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# List Reconciliation Key (`data-key`)

When a component re-renders a list, `component-client.js` morphs the new
markup onto the live DOM in place via
[Idiomorph](https://github.com/bigskysoftware/idiomorph) instead of a full
`innerHTML` replace (see the [README](../README.md) and Epic B, #22). Morphing
preserves node identity — and with it focus, scroll position, in-flight
input, CSS transitions/animations — for nodes idiomorph can match up between
renders.

Idiomorph matches nodes strictly by their real `id` attribute. It has no
config option for a custom reconciliation key. That's fine for content that
doesn't move around, but if a list gets **reordered** between renders (e.g.
sorting, or an item added at the front instead of the end) and the items
don't carry an `id`, idiomorph falls back to matching by tag/position — which
can misattribute a patch meant for one item onto a different item that now
happens to sit in the same slot, discarding that item's DOM state (focus, a
mid-typed value, a running CSS animation) in the process.

## The convention

Give each reorderable list item a stable `data-key`, matching whatever
identifies that item in your data (a primary key, slug, etc.):

```html
<ul id="component-abc123" data-component="task_list" data-state='{"tasks": [...]}'>
<li data-key="42">Buy milk</li>
<li data-key="17">Walk the dog</li>
</ul>
```

Before every `Idiomorph.morph()` call, `ComponentClient._bridgeListKeys()`
walks both the live (old) subtree and the incoming server-rendered HTML and
assigns `id="cf-key-<value>"` to any `[data-key]` element that doesn't
already have a real `id`. Idiomorph's own id-based matching then reconciles
`data-key="42"` in the old DOM with `data-key="42"` in the new render,
regardless of where each ended up in the list — reordering, insertions, and
removals all resolve by identity instead of by position.

Elements that already declare a real `id` (e.g. `<li id="task-42"
data-key="42">`) are left untouched — `data-key` only fills the gap when
there's no `id` to match on.

## Notes and limitations

- **The synthesized `id` is not stripped after the morph.** Idiomorph copies
matched attributes (including `id`) from the new render onto the surviving
node, so leaving `cf-key-<value>` in place keeps both sides of the next
update carrying the same id, and idiomorph's own focus-restoration logic
depends on `id` staying present across the morph. The tradeoff:
`document.getElementById('cf-key-42')` will resolve to that list item
elsewhere in the page. Keep `data-key` values unique within the page (not
just within the list) if you rely on `getElementById` elsewhere, or give
the element its own real `id` instead of relying on the synthesized one.
- **The incoming HTML is rewritten with a small regex, not a full HTML
parser** (it hasn't been parsed into a DOM tree yet when the bridge runs —
idiomorph does that internally). Keep `data-key` values to plain
identifiers (ids, slugs) and avoid a literal `>` inside another attribute
on the same tag (e.g. an inline JSON `data-payload` containing `>`), which
can confuse the tag boundary. Attribute-name matching is anchored to
whitespace/start-of-attributes (not just a word boundary), so an unrelated
attribute like `data-id` or `aria-id` won't be mistaken for a real `id`;
and a literal `"` inside a single-quoted `data-key='...'` value is escaped
before being re-embedded in the synthesized `id`.
- This only addresses **identity for matching**, not sort order — idiomorph
still needs to move/reuse the nodes into their new positions, which it
already does once it can tell old and new nodes apart by id.
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@
* instantly. A `[data-loading]` attribute is toggled for the request duration.
* Ship `component-framework.css` (or your own rules) to style these hooks.
*
* List items that get reordered between renders should carry a stable
* `data-key` (e.g. `<li data-key="42">`) so Idiomorph — which matches nodes
* by their real `id`, not a custom key — can still tell that item apart from
* its siblings and reconcile it (rather than the DOM node at its old
* position) after a reorder. See `_bridgeListKeys()` below.
*
* Usage:
* import { componentClient } from './component-client.js';
*
Expand Down Expand Up @@ -76,6 +82,41 @@ function findComponentElement(target) {
return target.closest('[data-component]') || null;
}

/**
* Attribute template authors use to mark a stable per-item identity within a
* list that gets re-rendered (e.g. `<li data-key="42">...</li>`). See
* `ComponentClient._bridgeListKeys()`.
*
* @type {string}
*/
const LIST_KEY_ATTR = 'data-key';

/**
* Prefix used when synthesizing an `id` from a `data-key` value, so the
* synthesized id can't collide with an unrelated real `id` elsewhere in the
* document. Consistent with this codebase's other `cf-` prefixed hooks
* (`_cfHandler`, the `cf-optimistic-pulse` CSS animation).
*
* @type {string}
*/
const LIST_KEY_ID_PREFIX = 'cf-key';

// Matches one opening HTML tag, e.g. `<li data-key="a" class="x">`.
const TAG_PATTERN = /<([a-zA-Z][\w:-]*)\b([^>]*)>/g;

// Matches a `data-key="..."` / `data-key='...'` attribute *as a whole
// attribute* — anchored on whitespace or the start of the attribute list
// immediately before it, not just a word boundary, so an unrelated
// attribute that merely *ends* with "data-key" (e.g. `sub-data-key="x"`)
// can't false-positive match.
const LIST_KEY_ATTR_PATTERN = new RegExp(`(?:^|\\s)${LIST_KEY_ATTR}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`);

// Same anchoring rationale for detecting a pre-existing real `id`: a plain
// `\bid\s*=` would false-positive on `data-id="..."` or `aria-id="..."`
// (the hyphen before "id" is a non-word character, so `\b` matches there
// too) and wrongly skip synthesizing an id for that element.
const ID_ATTR_PATTERN = /(?:^|\s)id\s*=/;

// ---------------------------------------------------------------------------
// ComponentClient
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -293,8 +334,9 @@ class ComponentClient {
const element = document.getElementById(componentId);
if (!element) return;

const html = this._bridgeListKeys(element, snapshot.html);
const scrollPositions = this._captureScrollPositions(element);
Idiomorph.morph(element, snapshot.html, this._morphConfig());
Idiomorph.morph(element, html, this._morphConfig());
this._restoreScrollPositions(element, scrollPositions);
this.bind(element);

Expand Down Expand Up @@ -329,8 +371,9 @@ class ComponentClient {
return;
}

const morphHtml = this._bridgeListKeys(element, html);
const scrollPositions = this._captureScrollPositions(element);
Idiomorph.morph(element, html, this._morphConfig());
Idiomorph.morph(element, morphHtml, this._morphConfig());
this._restoreScrollPositions(element, scrollPositions);

// Persist state so subsequent dispatches send the correct value back.
Expand Down Expand Up @@ -518,6 +561,72 @@ class ComponentClient {
}
}

/**
* Bridge `data-key`-carrying elements to a stable `id` so Idiomorph's
* node-matching engine — which matches strictly by the real `id`
* attribute (see `createIdMaps`/`populateIdMapWithTree` in
* `vendor/idiomorph.js`; there is no config option for a custom key) —
* reconciles reordered list items by identity instead of misattributing
* patches across them by position.
*
* Run immediately before every `Idiomorph.morph()` call, this:
* 1. Walks the *live* `oldElement` subtree with real DOM APIs and sets
* `id="cf-key-<value>"` on any `[data-key]` descendant that doesn't
* already have a real `id`.
* 2. Rewrites the *incoming* HTML the same way. It can't be walked with
* DOM APIs yet — Idiomorph parses the string itself — so this uses a
* small tag-attribute regex instead. This is a best-effort text
* rewrite: a `data-key` value or a sibling attribute value containing
* a literal `>` (e.g. inside a JSON `data-payload`) can confuse the
* regex; keep `data-key` values simple (plain ids/slugs).
*
* Elements that already declare a real `id` are always left untouched —
* this only fills the gap for items that don't have one.
*
* The synthesized ids are intentionally *not* stripped after the morph.
* Idiomorph copies matched attributes (including `id`) from the incoming
* content onto the surviving node, so leaving them keeps both sides
* carrying the same id into the next update pass, and Idiomorph's own
* focus-restoration logic (`saveAndRestoreFocus`) depends on `id`
* remaining a stable, present attribute across the morph. The tradeoff:
* `document.getElementById()` elsewhere in the app can now resolve a
* `cf-key-<value>` id. The prefix keeps that from colliding with
* unrelated real ids; pick `data-key` values that are unique to the list.
*
* @param {Element} oldElement - Live component root about to be morphed.
* @param {string} html - Incoming HTML that will be handed to Idiomorph.
* @returns {string} `html`, with reconciliation ids bridged in.
*/
_bridgeListKeys(oldElement, html) {
if (oldElement && typeof oldElement.querySelectorAll === 'function') {
for (const el of oldElement.querySelectorAll(`[${LIST_KEY_ATTR}]`)) {
if (!el.getAttribute('id')) {
const key = el.getAttribute(LIST_KEY_ATTR);
if (key) el.setAttribute('id', `${LIST_KEY_ID_PREFIX}-${key}`);
}
}
}

if (!html) return html;

return html.replace(TAG_PATTERN, (tag, tagName, attrs) => {
const keyMatch = attrs.match(LIST_KEY_ATTR_PATTERN);
if (!keyMatch) return tag;
if (ID_ATTR_PATTERN.test(attrs)) return tag;

const key = keyMatch[1] ?? keyMatch[2];
if (!key) return tag;

// The key may have come from a single-quoted `data-key='...'` value
// that legally contains an unescaped `"`; escape it before embedding
// it in the double-quoted `id` we're injecting, so it can't prematurely
// close that attribute and corrupt the tag.
const safeKey = key.replace(/"/g, '&quot;');

return `<${tagName} id="${LIST_KEY_ID_PREFIX}-${safeKey}"${attrs}>`;
});
}

/**
* Idiomorph config shared by `update()` and `rollback()` (B2, #22).
*
Expand Down
Loading
Loading