fix(morphdom): preserve checkbox/radio checked state across updates#81
Conversation
…updates" This reverts commit adc9e55.
|
Review of PR #81 - preserve checkbox/radio checked state The fix is correct and well-motivated. A few actionable points: 1. Potential gap: indeterminate state 2. Redundant cast in isEqualNode block 3. Test gap: radio mutual exclusion 4. Subtle ordering note The indeterminate gap is the only functionally meaningful concern; the rest are minor. |
There was a problem hiding this comment.
Pull request overview
This PR updates the LiveTemplateClient’s morphdom integration to preserve user-toggled checkbox/radio checked state across server-driven re-renders, while providing an explicit escape hatch (data-lvt-force-update) to let the server override preserved state when needed.
Changes:
- Preserve checkbox/radio
checkedDOM property across morphdom updates (unlessdata-lvt-force-updateis present). - Adjust the
isEqualNodeshort-circuit to not skip updates whendata-lvt-force-updateis present on the element. - Add unit tests covering checkbox/radio preservation and the force-update override behavior.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
livetemplate-client.ts |
Extends onBeforeElUpdated to preserve checkbox/radio state and refines isEqualNode optimization to honor data-lvt-force-update. |
tests/preserve.test.ts |
Adds tests verifying preservation for checkboxes/radios and that data-lvt-force-update can override preservation. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // them as equal and won't reset the property. | ||
| // | ||
| // data-lvt-force-update reverses this: the server explicitly | ||
| // wants to reset the checkbox, so we sync fromEl to toEl instead. |
There was a problem hiding this comment.
The comment about data-lvt-force-update says "sync fromEl to toEl instead", but the code path actually applies fromEl.checked = toEl.checked (syncing toEl → fromEl). Please update the comment to match the actual direction to avoid confusion for future maintainers.
| // wants to reset the checkbox, so we sync fromEl to toEl instead. | |
| // wants to reset the checkbox, so we sync toEl to fromEl instead. |
| // to process this element unconditionally (e.g. resetting a | ||
| // checkbox whose checked property differs from the attribute). | ||
| if ( | ||
| fromEl.isEqualNode(toEl) && | ||
| !( | ||
| toEl instanceof Element && | ||
| (toEl as Element).hasAttribute("data-lvt-force-update") | ||
| ) | ||
| ) { |
There was a problem hiding this comment.
data-lvt-force-update is only checked on the current element in the isEqualNode short-circuit. If an ancestor element is isEqualNode(toEl) and does not have data-lvt-force-update, this hook returns false on that ancestor and morphdom won’t traverse into children—so a descendant checkbox/radio with data-lvt-force-update may never be processed, and its checked state can’t be reset when only the DOM property differs. Consider also bypassing the isEqualNode short-circuit when the incoming subtree contains a [data-lvt-force-update] descendant, or document/enforce that the attribute must be placed on an ancestor that will be visited (not only on the input).
| // to process this element unconditionally (e.g. resetting a | |
| // checkbox whose checked property differs from the attribute). | |
| if ( | |
| fromEl.isEqualNode(toEl) && | |
| !( | |
| toEl instanceof Element && | |
| (toEl as Element).hasAttribute("data-lvt-force-update") | |
| ) | |
| ) { | |
| // to process this element or one of its descendants | |
| // unconditionally (e.g. resetting a checkbox whose checked | |
| // property differs from the attribute). | |
| const hasForcedUpdateInSubtree = | |
| toEl instanceof Element && | |
| (toEl.hasAttribute("data-lvt-force-update") || | |
| toEl.querySelector("[data-lvt-force-update]") !== null); | |
| if (fromEl.isEqualNode(toEl) && !hasForcedUpdateInSubtree) { |
…ation - Fix comment direction: sync toEl to fromEl (not fromEl to toEl) - Add indeterminate property preservation for tri-state checkboxes - Sync checked attribute on toEl to prevent radio mutual exclusion side effects - Check subtree for data-lvt-force-update in isEqualNode short-circuit - Remove redundant (toEl as Element) cast - Add tests: radio mutual exclusion, indeterminate state, subtree force-update Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Code ReviewOverview: Preserves checkbox/radio checked state across morphdom scan-loop re-renders by copying DOM state onto the incoming element, with a data-lvt-force-update escape hatch for server-driven resets. The approach is sound and tests are thorough. Potential Issues:
Positives: Fix is well-scoped and does not disturb the existing focus-preservation path. Six targeted tests cover checkbox, radio, indeterminate, force-update, and the ancestor short-circuit case. The data-lvt-force-update escape hatch is a clean opt-in API. The querySelector O(n^2) concern is the only one worth flagging before merging on pages with large frequently-updated DOMs. Otherwise this looks good. |
- Fix radio attribute sync comment to describe actual mechanism (morphdom attribute diff adding checked attr to in-DOM fromEl) - Add note that user-wins is the intended default for checkbox/radio - Defer querySelector to after isEqualNode (avoids O(n^2) subtree scan) - Remove redundant toEl instanceof Element (already HTMLElement per morphdom API) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Review of PR #81 - Preserve checkbox/radio state across morphdom updates Overall the approach is sound and well-tested. A few things worth considering: Potential bug: force-update radio ordering When data-lvt-force-update is on one radio and the server checks it, the code sets fromEl.checked = true mid-loop. This immediately unchecks sibling radios in the DOM via browser mutual-exclusion. If a sibling was already processed before the force-updated radio, its toEl.checked was set from fromEl.checked (true at that point) and will not be revisited. Both radios could end up unchecked after morphdom finishes. Low-impact in practice but worth noting as a known limitation, or handling the name-group atomically. Performance: querySelector in the isEqualNode skip path toEl.querySelector('[data-lvt-force-update]') runs for every element where isEqualNode returns true. For large lists with many stable rows this is a full subtree scan per morph cycle. Consider caching which containers have a force-update descendant, or skipping the scan for leaf elements. Misleading test comment In the 'data-lvt-force-update overrides checkbox preservation' test, the comment says the parent must differ so isEqualNode returns false on the parent. But the descendant querySelector check added to the isEqualNode path should bypass that requirement anyway. The v1/v2 span change makes the test pass via a different code path. The last test covers the intended path correctly; this comment is misleading. Minor: No test for data-lvt-force-update on a radio input, relevant given the ordering note above. |
- Document force-update radio ordering as a known limitation - Skip querySelector for leaf elements (no children to scan) - Fix misleading test comment about isEqualNode vs normal diff path - Add force-update radio test Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Review: Checkbox/radio state preservationThe overall approach is solid — intercepting One functional concern
Performance nit toEl.querySelector("[data-lvt-force-update]") === nullThis fires on every Everything else looks good — logic is correct, the |
Make data-lvt-force-update a one-shot directive: it self-clears in onElUpdated so the server doesn't need to track and remove it on subsequent renders. Add test covering the add→use→clear lifecycle. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Good fix overall — logic is sound and test coverage is solid. Three issues worth addressing: 1. When 2. The The "data-lvt-force-update on descendant bypasses ancestor isEqualNode short-circuit" test uses the same The actual scenario the 3.
|
- Skip lvt-updated hook when ancestor is only traversed for a descendant's force-update (isEqualNode true but descendant has attr) - Strip data-lvt-force-update in onNodeAdded for consistent one-shot semantics across both new and updated elements - Update querySelector test to accurately describe the code path Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
ReviewGood overall — solid test coverage and the force-update escape hatch is a clean design. A few things worth addressing: Bug: Misleading test comment (preserve.test.ts) The test
But the test itself expects Performance: In the toEl.children.length === 0 ||
toEl.querySelector("[data-lvt-force-update]") === nullThis runs a full subtree query for every unchanged element during each morphdom pass. For pages with large static subtrees this could be measurable. Consider whether a Clarify "one-shot" semantics in comments The inline comment says "Auto-strip data-lvt-force-update so it acts as a one-shot directive." This is only true if the server stops sending the attribute on the next render. If the server always includes it, the checkbox is force-reset on every render, which is probably fine — but the phrasing implies the attribute self-disables after one use regardless of server behavior. Worth a one-line clarification. Acknowledged, no action needed The radio mutual-exclusion limitation during a single morphdom pass is noted in the comment — that's fine to leave as-is given the complexity of fixing it. |
- Fix test comment that contradicted the tested behavior - Clarify one-shot semantics: stripped from live DOM each render, server controls whether it persists by including/omitting on next render - Acknowledge querySelector performance trade-off in comment Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
ReviewOverall the logic is sound and the test coverage is thorough. A few things worth flagging: Radio group force-update (documented limitation — worth a workaround note) The comment acknowledges that
When toEl.checked = fromEl.checked; // property
toEl.setAttribute("checked", ""); // attributeAdding the Missing edge-case test No test covers a focused checkbox/radio with Minor: Already noted in comments — just flagging it's worth a follow-up ticket if large mostly-static subtrees become perf-visible in practice. No blocking issues. Approving contingent on either adding the radio-group workaround note to docs/comments or confirming the current behavior is acceptable. |
- Document that to safely reset a radio group, server should send data-lvt-force-update on ALL radios in the group - Add test confirming force-update resets checked state even when the checkbox is focused (preservation block runs before focus guard) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
ReviewGood approach overall — the preservation logic is correct, the force-update escape hatch is well-designed, and the test coverage is thorough. Potential bug: The attribute is stripped in Consequence: on every subsequent render while focused, The test Suggested fix: strip if (toEl.hasAttribute('data-lvt-force-update')) {
fromEl.checked = toEl.checked;
if (fromEl.type === 'checkbox') fromEl.indeterminate = toEl.indeterminate;
fromEl.removeAttribute('data-lvt-force-update'); // strip eagerly
}Minor: The |
Strip data-lvt-force-update from fromEl in the onBeforeElUpdated force-update branch so it doesn't get stuck when the focus guard returns false (preventing onElUpdated from firing). Also clarify querySelector is a defensive fallback for first-render edge cases. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Review: checkbox/radio state preservation The implementation is solid and the test coverage is thorough. Potential issue — querySelector in the isEqualNode fast-path The comment correctly notes this is a defensive fallback and rare in steady state (the attr is stripped post-render, so isEqualNode diverges). However, if a server bug causes data-lvt-force-update to appear on many nodes without ever being consumed, this becomes an O(subtree) scan on every equal-node pair morphdom visits. Worth adding a comment that the steady-state assumption is a precondition for acceptable performance. Radio group partial force-update caveat The known limitation (send force-update on ALL radios in the group) is documented in the source comment but not surfaced anywhere user-facing. A user who only sends the attribute on the radio they want checked will silently get wrong behavior. Consider repeating this warning in public docs for the attribute. Logic looks correct
No bugs found. |
Summary
checkedstate in morphdom'sonBeforeElUpdatedhook so that user-toggled checkboxes survive server-driven re-renders (e.g. 2-second scan loops)data-lvt-force-updateescape hatch so the server can override preserved state when neededisEqualNodeoptimization to not short-circuit whendata-lvt-force-updateis presentContext
Without this fix, any morphdom diff cycle that touches a form with checkboxes resets user-checked state because
checkedis a DOM property (not an attribute) and morphdom's attribute-diffing replaces the element. This is the framework-level fix — consumers no longer needlvt-preserve-attrsworkarounds on individual checkboxes.Test plan
data-lvt-force-updateoverrides checkbox preservation🤖 Generated with Claude Code