bigskysoftware / bigskysoftware/htmx

HTMX 4 morphing leaves checkbox and radio state stale

Open
#4,030 2 comments 0 reactions 0 assignees View on GitHub
Dominant language
JavaScript
Stars
49.4k
Forks
1.7k
Avg merge
3d 22h
Merged PRs (30d)
30

Description

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 `

`.
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);
}
}
```

Contributor guide

Open the contributing guide

Research direction

Start at the morph node handling around line 2172, including outerMorph, #copyAttributes, and #morphChildren. Reproduce the checkbox and radio cases with Playwright in Chromium and WebKit; done means live checked state follows the incoming HTML, subtree traversal handles skipped markup, and input identity, focus, and morph skip controls remain preserved.

Written by the indexing model from the issue text.

Assessment

Tech stack
html, javascript
Domain
frontend
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
72/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.