#4030·htmx

HTMX 4 morphing leaves checkbox and radio state stale

Author: gwkCreated Sep 7, 2026Updated Sep 8, 2026

I recently migrated to HTMX 4. While building a new feature I encountered a problem with checkboxes that Claude Fable diagnosed as a morph problem. While I'm hesitant to post AI-generated bug reports I think this is a real one and both Fable and Astra think they fixed it.

I have verified that this does in fact address our immediate issue. I also had fable and astra test it using playwright, on both Chromium and WebKit.

The situation where I encountered this is a checkbox that triggers an update endpoint, where the control itself specifies hx-swap "none" but then the endpoint returns an HX-Trigger for a larger containing region.

At bottom is the diff of the change for my local htmx copy. If you want me to open a PR I'm happy to; I didn't go that far because I don't feel entirely confident of my own understanding yet.

Description

After a checkbox has been clicked, morphing server HTML onto the existing input can update its checked attribute without updating its live .checked property. The checkbox can therefore display a different state from the server response.

Reproduction

  1. Render <div id="region"><input id="flag" type="checkbox"></div>.
  2. Click the checkbox so it becomes checked.
  3. Apply outerMorph to the region using the original HTML, which has no checked attribute.

Expected: the reused checkbox becomes unchecked. Actual: it remains checked. The same issue occurs when a later response removes a previously added checked attribute.

Cause

Once an input's checked state has been changed through interaction, changing its HTML attribute no longer synchronizes its live state. The morph implementation copies attributes but does not explicitly synchronize .checked.

There is a second issue: isEqualNode() compares DOM markup, not live input properties. Morphing can skip an entire unchanged subtree containing a checkbox whose live state differs from the incoming HTML.

Fix

  • After copying attributes onto a reused checkbox or radio input, assign oldNode.checked = oldNode.defaultChecked to apply the resulting checked attribute to its live state.
  • Visit subtrees containing checkbox or radio inputs even when isEqualNode() reports identical markup, as already done for templates.
  • Retain the existing morph skip controls and preserve input identity and focus.
@@ -2172,12 +2172,17 @@ var htmx = (() => {
             if (!this.#triggerExtensions(oldNode, "htmx:before:morph:node", {oldNode, newNode})) return;
                 
             this.#copyAttributes(oldNode, newNode);
+            // Once interacted with, a checkable input's live state no longer follows its checked attribute.
+            if (oldNode instanceof HTMLInputElement && (oldNode.type === 'checkbox' || oldNode.type === 'radio')) {
+                oldNode.checked = oldNode.defaultChecked;
+            }
             if (oldNode instanceof HTMLTextAreaElement && document.activeElement !== oldNode && oldNode.defaultValue != newNode.defaultValue) {
                 oldNode.value = newNode.value;
             }
             let skipChildren = this.config.morphSkipChildren && oldNode.matches?.(this.config.morphSkipChildren);
-            // isEqualNode does not detect template content diff so always morph templates
-            if (!skipChildren && (!oldNode.isEqualNode(newNode) || newNode.tagName === 'TEMPLATE' || newNode.querySelector?.('template'))) {
+            // isEqualNode detects neither template content nor live checked state; visit these even if markup is equal.
+            if (!skipChildren && (!oldNode.isEqualNode(newNode) || newNode.tagName === 'TEMPLATE' ||
+                newNode.querySelector?.('template, input[type="checkbox"], input[type="radio"]'))) {
                 this.#morphChildren(ctx, oldNode, newNode);
             }
         }