Skip to content

fix: [#2397] <input type=radio checked> added via innerHTML does not uncheck its group - #2398

Open
marciomazza wants to merge 5 commits into
capricorn86:masterfrom
marciomazza:fix/radio-group-mutual-exclusion
Open

fix: [#2397] <input type=radio checked> added via innerHTML does not uncheck its group#2398
marciomazza wants to merge 5 commits into
capricorn86:masterfrom
marciomazza:fix/radio-group-mutual-exclusion

Conversation

@marciomazza

Copy link
Copy Markdown

Description

Resolves #2397

A checked <input type="radio"> added to a group via innerHTML, insertAdjacentHTML,
DOMParser, <template>, a shadow root, or setAttribute('checked', '') does not
uncheck the other radios in the group.

document.body.innerHTML =
  '<input type="radio" name="x" checked id="a">' + '<input type="radio" name="x" checked id="b">';

document.getElementById("a").checked; // happy-dom: true — real browsers: false

Root cause: the radio button group mutual-exclusion algorithm only ran from the checked
IDL setter (#setChecked). Parsing, tree insertion, and setAttribute never triggered
it.

Fix: extract the sibling-unchecking loop into #reconcileRadioButtonGroup() and also run
it from [onSetAttribute] (when checked is added, or type/name is parsed after
checked in the start tag) and [connectedToNode] (a radio parsed into a detached
fragment reconciles on connect; when several checked members of a group connect together,
the last in tree order wins, as in browsers). The checked content attribute drives
checkedness only on the absent→present transition and only while checked has not been
set via the IDL property, so re-setting an already-present attribute is a no-op and a
radio unchecked by the group is not re-checked by its attribute until a form reset.

Deliberate deviation: exclusion is eager, so a detached <template> / DocumentFragment
briefly shows only the last checked radio (the state after connecting matches browsers).
Drive-by: the old loop unchecked every same-name radio, not just the checked ones, and
never cleared their :checked query cache.

Tests

Fails before / passes after for the repro. Added 12 tests: exclusion after HTML parsing
(last-checked wins, per-group, per-form and per-shadow-root scoping, reconciling against a
radio already checked in a live container), after insertAdjacentHTML, after
setAttribute('checked'), on connecting a subtree with several checked members (last in
tree order wins; a radio inserted before an already-connected one still wins), a
re-set/removed-and-re-added attribute not overriding a group-unchecked radio, a
form.reset() restoring one from its attribute, and a cached :checked query staying
fresh. Full npm test passes (302 files / 7731 in packages/happy-dom).

Details

Verified in Chrome 152 (headless, DevTools console) and Firefox (about:blank console):
after innerHTML with two checked radios only the last stays checked; after
b.checked = true then a.setAttribute('checked', ''), a stays unchecked and b
checked; after form.reset(), a is restored from its checked attribute. Safari not
checked (no macOS available); Blink + Gecko agree.

AI

I used Claude Code to write this, over multiple iterations. I guided and reviewed
it myself at every step.

Before submitting the PR, please make sure you do the following:

  • Read the contributing guidelines.
  • It's really useful if your PR references an issue where it is discussed ahead of time.
  • Please check Allow edits by maintainers to make review process faster. Note that this option is not available for repositories that are owned by Github organizations.

Tests

  • Make sure to add tests for your changes. Run your test in a real browser to make sure that the test tests what a real browser would do (e.g. by running the code in the browser console).
    • Ran the repro in Chrome 152 (headless) and Firefox consoles: after innerHTML with two checked radios only the last stays checked; after b.checked = true then a.setAttribute('checked', ''), a stays unchecked; after form.reset(), a is restored from its attribute. Matches the fixed behavior.
  • Run the tests with npm test locally to make sure that all tests pass before submitting the PR.

Title

  • The title of the pull request should be in the format of "type: [#issue] description". The type can be feat, fix, chore or BREAKING CHANGE. The issue is optional and can be omitted if the pull request does not relate to an issue.
  • The title should be concise and descriptive. The title will be used when generating release notes. Make sure that the title is easily understood by users of the library.

AI tools

  • Please disclose in the PR description that you used AI tools to generate code. This is important for transparency and to ensure that the generated code meets the quality standards of the project.

…tribute changes

A checked `<input type=radio>` added via innerHTML, insertAdjacentHTML, DOMParser,
templates or setAttribute('checked') did not uncheck the other radio buttons in its
group; only assigning the IDL `checked` property did.

Extract the sibling-unchecking loop from #setChecked into
#uncheckOtherRadioButtonsInGroup() and run it from the attribute pipeline
(onSetAttribute, for `checked` added or `type`/`name` parsed after it) and from
connectedToNode (radio parsed into a detached fragment first). The `checked` attribute
drives checkedness only on the absent->present transition and only while not overridden
via the IDL property, so re-setting an already-present attribute is a no-op and a radio
unchecked by mutual exclusion is not re-checked by its attribute until a form reset,
matching Chrome and Firefox.
Rename #uncheckOtherRadioButtonsInGroup and clear this element's own
query-selector cache inside it, so every entry point (attribute parse,
connect, IDL setter) gets it without repeating the call.
When a subtree with several checked members of a radio group is connected,
connectedToNode walked the group per child and the first checked one won.
Browsers keep the last in tree order. Each connecting radio now steps aside
for a later checked sibling that is still mid-insertion (isConnected false),
and a checked radio inserted before an already-connected one still wins.

Extract the group lookup into #getRadioButtonGroup().
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Checked <input type="radio"> added via innerHTML does not uncheck the rest of its group

1 participant