feat(client): stable list reconciliation key for morph (B3, #22)#46
Conversation
Idiomorph matches DOM nodes strictly by their real `id` attribute (createIdMaps/populateIdMapWithTree in vendor/idiomorph.js) and has no config option for a custom key, so a reordered list without ids gets matched by position and can misattribute a patch (and the DOM state that comes with it - focus, in-flight input, a running animation) onto the wrong item. Adds ComponentClient._bridgeListKeys(), run immediately before every Idiomorph.morph() call in update()/rollback(). It walks the live old subtree and the incoming HTML for elements carrying a data-key attribute and assigns id="cf-key-<value>" to any that lack a real id (elements with a real id are left untouched), so idiomorph's existing id-based matching reconciles them correctly regardless of position. See docs/LIST_RECONCILIATION_KEY.md for the template-author-facing data-key convention and documented tradeoffs (synthesized ids are left in the DOM rather than stripped; the incoming HTML is rewritten with a small regex rather than a full parse). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pi1LT1PQ8qo9GcyLeDLiut
Adversarial review — PR #46 (
|
Addresses two findings from the adversarial /review pass on this PR: - The "already has a real id" check used a plain word-boundary regex (/\bid\s*=/), which false-positives on any attribute merely ending in "id=" (data-id, aria-id, ...) since the hyphen before "id" already counts as a word boundary. That silently skipped synthesizing an id for such elements, defeating the feature with no warning. Anchored both the id-detection and data-key-detection regexes to whitespace/ start-of-attributes instead, so only the actual attribute name can match, not an arbitrary suffix of a longer one. - A key parsed out of a single-quoted data-key='...' value can legally contain an unescaped ", which would prematurely close the new double-quoted id="cf-key-<value>" attribute and corrupt the tag. Escape " to " before re-embedding. Also hoists the (now correctly anchored) regexes to module scope instead of rebuilding one of them on every call, and adds regression tests for both cases plus a doc footnote. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pi1LT1PQ8qo9GcyLeDLiut
|
Addressed both findings from the review above in c24e811: anchored the |
…reconciliation-key # Conflicts: # src/component_framework/static/component_framework/js/component-client.js
|
Merged Heads up: once #45 (B4) also merges, this branch will very likely need one more conflict-resolution pass, since #45 touches the same |
Epic B (DOM Morphing & Rendering Fidelity, #22) is complete: Idiomorph-based morphing (#43), focus/scroll/in-flight input preservation (#44), stable data-key list reconciliation (#46), and a data-no-morph escape hatch for JS-owned regions (#45). Combined with Epic A (#21, HMAC state signing + locked fields + CSRF/CSWSH audit), this closes out the 0.6.0b - Hardening Foundation milestone. Backfills missing CHANGELOG entries for B1/B2/B4 (only B3 had one) and corrects README claims left over from before the morph work landed (the known-limitations bullet, the architecture-overview paragraph, and the roadmap's 0.6.0b line, which had mis-attributed Epic D's 422-re-render item). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pi1LT1PQ8qo9GcyLeDLiut
Closes part of #22 (Epic B — DOM Morphing & Rendering Fidelity). Implements checklist item B3 — Stable list reconciliation key (
data-key).What this is
Idiomorph (vendored, unmodified, at
vendor/idiomorph.js) matches DOM nodes strictly by their realidattribute —createIdMaps/populateIdMapWithTreedoroot.querySelectorAll("[id]")/getAttribute("id"). There is no config option for a custom reconciliation key. So when a component re-renders a reordered list and the items don't carry anid, idiomorph falls back to matching by tag/position, which can misattribute a patch (and the DOM state that travels with it — focus, in-flight input, a running CSS animation) onto whichever item now happens to sit in that slot.What changed
ComponentClient._bridgeListKeys(oldElement, html)incomponent-client.js, run immediately before bothIdiomorph.morph()call sites (update()androllback()):oldElement.querySelectorAll('[data-key]')) and assignsid="cf-key-<value>"to any element that doesn't already have a realid.idare always left untouched.data-key-tagged items correctly across reorders, insertions, and removals — no fork ofvendor/idiomorph.jsneeded.docs/LIST_RECONCILIATION_KEY.md— thedata-keyconvention for template authors, plus documented tradeoffs.CHANGELOG.mdentry under[Unreleased].tests/js/list-key.test.mjs(7 tests), written TDD-first (confirmed failing onclient._bridgeListKeys is not a function/ unmatched assertions before implementation).Design decisions
data-key, per the issue's literal checklist wording (**B3** Stable list reconciliation key (data-key)).id="cf-key-<value>"rather than using the raw key value directly as theid, to avoid collisions with unrelated real ids elsewhere in the document.cf-is already this codebase's convention for internal hooks (_cfHandler, thecf-optimistic-pulseCSS animation), so this is consistent.id) from the new render onto the surviving node, so leaving it in place keeps both sides carrying the same id into the next update pass "for free," and idiomorph's own focus-restoration logic (saveAndRestoreFocus) depends onidstaying present across the morph. The tradeoff —document.getElementById('cf-key-42')can now resolve to that list item — is documented indocs/LIST_RECONCILIATION_KEY.mdalong with the mitigation (the prefix, and advice to use real ids ifgetElementByIdcollision matters).Testing
node --test "tests/js/**/*.test.mjs"— 20/20 passing (13 pre-existing + 7 new).uv run pytest— 496 passed, no regressions.uv run ruff check ./uv run ruff format --check .— clean.Out of scope
Per B3's literal checklist wording, this does not touch B2 (focus/scroll/in-flight-input preservation — already partially covered by idiomorph itself), B4 (a "don't morph this" escape hatch), or B5 (server-side change tracking spike).
vendor/idiomorph.jsis untouched.🤖 Generated with Claude Code