feat(client): adopt Idiomorph morph swap in component-client.js (B1, #22)#43
Conversation
) Replaces the full innerHTML/outerHTML replace in update() and rollback() with Idiomorph.morph() (vendored, v0.7.4, 0BSD) so unchanged DOM nodes — and their attached listeners, focus, and (per idiomorph's default restoreFocus behavior) in-flight input selection — survive a component patch instead of being torn down and recreated. - Vendor idiomorph's ESM build unmodified at static/component_framework/js/vendor/idiomorph.js, pinned and attributed with source/version/license. - update(): morphs the element in place instead of parsing into a detached node and replaceWith()-ing it; empty-HTML handling unchanged. - rollback(): morphs to the snapshot's outerHTML instead of replaceWith(); as a side effect this also restores event listeners on rollback, which the prior replaceWith()-based implementation silently did not (fresh parsed nodes have no listeners) — not a new scope item, just a natural consequence of the same technique swap. - New tests/js/morph.test.mjs verifies the integration seam (that update()/rollback() now delegate to Idiomorph.morph() instead of replaceWith), consistent with this repo's existing no-jsdom, hand-stub test philosophy — idiomorph's own algorithm has its own upstream tests. Scoped to B1 only (this epic's explicit "critical path... enabler" item, Effort M, no deps) out of Epic B's B1-B5 checklist in #22. B2 (focus/ scroll/in-flight input preservation), B3 (stable list keys), and B4 (JS- owned region escape hatch) all explicitly depend on B1 landing first; B5 is marked "(spike)... likely post-1.0" in the issue body. Each should be its own follow-on /epic:run phase. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pi1LT1PQ8qo9GcyLeDLiut
Review: PR #43 — Adopt Idiomorph morph swap (B1, #22)OverviewReplaces Code quality / style
Specific suggestions (non-blocking)
Potential issues / risks
Test coverageGood: the 5 new tests correctly target the integration seam (that VerdictNo blocking findings. The four "specific suggestions" above are worth a look but none require changes before merge — they're either follow-up-phase material (B2-B4) or low-probability edge cases with no test regression today. |
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
Scope
Issue #22 is Epic B's tracking issue (checklist B1-B5), not a single leaf phase. Per the epic's own critical-path note ("B1 unblocks Epics C, D, G") and the dev-plan doc's dependency table (B2/B3/B4 all depend on B1; B5 is an explicit post-1.0 spike), this PR scopes to B1 only:
B2 (focus/scroll/in-flight input preservation), B3 (stable list reconciliation key), B4 (JS-owned region escape hatch), and B5 (server-side diff spike) are unaddressed here and should each be their own follow-on phase.
What changed
static/component_framework/js/vendor/idiomorph.js. No build tooling was introduced — this project ships hand-written vanilla ES modules with no bundler, and idiomorph's ESM dist file is a self-contained, dependency-free import, consistent with that.update()now callsIdiomorph.morph(element, html, { morphStyle: 'outerHTML' })in place, instead of parsing the server HTML into a detached node andelement.replaceWith()-ing it. Nodes idiomorph can match up (same tag/id) keep their identity — and therefore their already-bound event listeners, focus, and (per idiomorph's defaultrestoreFocus: true) input selection.rollback()does the same against the snapshot'souterHTML. Side effect worth flagging: the oldreplaceWith()-based rollback silently lost all event listeners on the restored node (parsed-from-string nodes have none), so a component would go dead after a failed request until the next full update. Morphing fixes that as a natural consequence of the same technique swap — not claimed as a separate deliverable.tests/js/morph.test.mjs(5 new tests) verifies the integration seam — thatupdate()/rollback()delegate toIdiomorph.morph()with the right arguments instead of doing a full replace — rather than re-testing idiomorph's own morphing algorithm, which has its own upstream test suite. This matches the existing no-jsdom, hand-stub philosophy documented intests/js/optimistic.test.mjs.Verification
node --test "tests/js/**/*.test.mjs"— 13 passed (5 new + 8 existing, no regressions)pytest tests/— 496 passedruff check ./ruff format --check .— cleanty check src/— 17 diagnostics, unchanged from baseline (this PR touches no Python source)Out-of-scope follow-up noticed (not fixed here)
just test-jsreports 0 tests locally (acmd.exe-glob-expansion quirk in the justfile recipe, pre-existing onmasterbefore this PR — verified viagit stash). CI'sci.ymltest-jsjob callsnode --test "tests/js/**/*.test.mjs"directly (not throughjust), so this doesn't affect CI, only localjust test-jsruns on Windows.