Skip to content

feat: [#1419] ElementInternals and form-associated custom elements - #2371

Open
marciomazza wants to merge 4 commits into
capricorn86:masterfrom
marciomazza:feat/element-internals
Open

feat: [#1419] ElementInternals and form-associated custom elements#2371
marciomazza wants to merge 4 commits into
capricorn86:masterfrom
marciomazza:feat/element-internals

Conversation

@marciomazza

@marciomazza marciomazza commented Sep 7, 2026

Copy link
Copy Markdown

Resolves #1419
Refs #957
Refs #2392

happy-dom has no ElementInternals support: a form-associated custom element can't call
attachInternals(), and even with a hand-rolled shim it never shows up in
form.elements or new FormData(form).

class MyControl extends window.HTMLElement {
	static formAssociated = true;
	constructor() {
		super();
		this._internals = this.attachInternals();
	}
}
window.customElements.define('my-control', MyControl);

const form = document.createElement('form');
form.innerHTML = '<my-control name="foo"></my-control>';
form.querySelector('my-control')._internals.setFormValue('hello');

[...new FormData(form)]; // happy-dom: [] (attachInternals throws first) — real browsers: [['foo', 'hello']]

Root cause: HTMLElement.attachInternals and ElementInternals don't exist,
CustomElementRegistry.define() never reads the class's static formAssociated, and
both HTMLFormElement[getFormControlItems]() (backing form.elements, form.reset(),
checkValidity(), submit) and FormData's form constructor key off a fixed tag-name
list — so a custom element is invisible to all of them.

The fix: define() records formAssociated on the prototype; a new ElementInternals
class (modeled on ValidityState) with setFormValue, form, labels, willValidate,
validity, validationMessage, setValidity/checkValidity/reportValidity;
HTMLElement.prototype.attachInternals(); and getFormControlItems() / FormData now
also collect elements flagged formAssociated, deduped and honoring form="",
disabled, and setFormValue(null).

Deliberately narrow for a first PR: attachInternals() throws on a non-form-associated
custom element — real browsers allow it, but the members that motivate that
(shadowRoot, ARIA) are out of scope here (Refs #957). Also out: states /
CustomStateSet (:state()), the formAssociatedCallback / formDisabledCallback /
formResetCallback / formStateRestoreCallback lifecycle hooks, <fieldset>-driven
disabling, and full constraint-validation semantics (setValidity() doesn't require a
message or validate the anchor argument). form.elements membership resolves ownership
through a shared HTMLFormControlElementUtility.getFormOwner() (a present form
attribute beats an ancestor <form>, per the WHATWG form-owner algorithm); the built-in
get form() copies keep the old ancestor-first order and are fixed in #2392, which also
extends getFormOwner() to radio scoping and ValidityState. This PR and #2392 add the
identical HTMLFormControlElementUtility file and both touch getFormControlItems
whichever merges first, the other rebases to drop the duplicate.

Tests

Added 20 tests: ElementInternals (form/labels/willValidate/validity +
setValidity/checkValidity flag round-trips), attachInternals() (throws when not
form-associated, throws on second call, returns an instance, exposed as a window global),
FormData (custom element value collected / omitted when null / omitted when
disabled), HTMLFormElement (custom element listed once among descendants, and via
form="id"). Full npm test passes (7739 tests). Repro also cross-checked in Chrome.

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: the form-associated custom element shows up in form.elements (MY-CONTROL,INPUT) and in new FormData(form) (foo=hello); setFormValue(null) drops the entry. Confirmed browsers also allow attachInternals() on a non-form-associated element (this PR throws there on purpose — see description).
  • 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

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

Removes the repeated inline [formAssociated] check and double cast from
FormData and HTMLFormElement.
…alue()

Matches the spec signature setFormValue(value, state). happy-dom does not
persist/restore state, so it is accepted and ignored.
Add HTMLFormControlElementUtility.getFormOwner() (identical to the copy in
fix/form-attribute-precedence) so the "form" attribute wins over the ancestor
<form>, and route ElementInternals.form and HTMLFormElement.getFormControlItems
through it. getFormControlItems now filters by form owner and scans the whole
root for form="id" controls, keeping form-associated custom element support on
top.
@marciomazza
marciomazza force-pushed the feat/element-internals branch from 2950695 to 177ee03 Compare September 8, 2026 14:03
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.

Add support for ElementInternals interface

1 participant