Web Development Bug Report

Web component input missing from FormData: form-associated custom elements and ElementInternals

We replaced a phone number input with a design-system web component. The form looked identical, but the server received no phone field and required validation never fired. Inputs inside shadow DOM do not belong to the outer form. How formAssociated and ElementInternals fix submission, validation and reset.

Hannah Okafor Hannah Okafor @hannahokafor · April 04, 2026 · 8 min read · 25 Views
Banner: web component input never appeared in FormData, form-associated custom elements and ElementInternals

Our design system team shipped <ds-phone-input>, a web component with a country picker and formatting. The checkout team replaced a plain <input type="tel" name="phone"> with it. In the browser, the form looked the same and typing worked. After deployment, the order service started rejecting orders:

422 Unprocessable Entity
{"errors": {"phone": ["The phone field is required."]}}

And the browser never showed the "Please fill out this field" message, although the component had a required attribute.

What the form actually sent

In the console, on the checkout page:

const form = document.querySelector('form#checkout');
console.log([...new FormData(form).entries()]);
[["email", "a.customer@example.com"], ["address", "1 Example Street"], ["postcode", "1000"]]

No phone. The component's markup in the elements panel:

<ds-phone-input name="phone" required>
  #shadow-root (open)
    <select part="country">...</select>
    <input part="number" type="tel" autocomplete="tel-national">
</ds-phone-input>

The real <input> lived inside the component's shadow root. It had no name attribute, and even if it had, it would not count.

Confirming the boundary

const host = document.querySelector('ds-phone-input');
const inner = host.shadowRoot.querySelector('input');
console.log(inner.form);          // null
console.log(form.elements.length); // 4: email, address, postcode, submit button

The inner input's form property was null: it was not associated with the outer form at all.

What we ruled out

  • A missing name. We added name to the inner input in a test build; it still did not appear in FormData.
  • JavaScript submission code. The checkout used a native form submission handled by the browser, and new FormData(form) reproduced the issue in isolation.
  • Framework hydration. The page was server-rendered HTML with no framework controlling the form.

Root cause

Form controls inside a shadow root are not associated with forms outside it. Shadow DOM is a boundary for form association: the outer <form> only collects controls in its own tree (and those pointing to it with the form attribute in the same tree). The custom element itself was not a form-associated element, so neither the component nor its inner input contributed a value, validity or participation in reset.

Two ways a custom element can relate to forms

Approach How it works Problems
Light DOM: render a real <input> as a child of the custom element The input is in the outer tree, so the form sees it Styles and structure are exposed, harder to encapsulate
Shadow DOM plus form-associated custom element The element declares static formAssociated = true and reports its value through ElementInternals Needs explicit code for value, validity and reset

Form-associated custom elements

A custom element class with static formAssociated = true behaves like a built-in form control:

  • it can have a name and appears in form.elements;
  • attachInternals() returns an ElementInternals object;
  • internals.setFormValue(value) sets what the form submits for this element;
  • internals.setValidity(flags, message, anchor) participates in constraint validation, including :invalid styling and the browser's validation bubble;
  • lifecycle callbacks such as formResetCallback and formDisabledCallback let the element react to the form.

Form-associated custom elements are supported in all current major browsers, including Safari since 16.4.

Who is affected

Any design system or component library that wraps native inputs in shadow DOM: text fields, selects, date pickers, toggles and rich editors. Frameworks that compile to custom elements (Lit, Stencil, Angular elements, Svelte custom elements) all follow the same platform rule. Pages that submit with JavaScript by reading individual inputs are not affected, which is why the bug often goes unnoticed until a form is switched to native submission or FormData.

Environment

Component Version / value
Component <ds-phone-input>, Lit 3, open shadow root
Page Server-rendered checkout, native form submission
Browsers Current Chrome, Firefox, Safari, Edge
Server Laravel form request validation, phone required

Make the component form-associated

import { LitElement, html } from 'lit';

export class DsPhoneInput extends LitElement {
  static formAssociated = true;
  static properties = {
    name: { type: String, reflect: true },
    required: { type: Boolean, reflect: true },
    value: { type: String },
  };

  #internals = this.attachInternals();

  constructor() {
    super();
    this.value = '';
  }

  render() {
    return html`
      <select part="country" @change=${this.#onInput}>${/* country options */ ''}</select>
      <input part="number" type="tel" autocomplete="tel-national"
             .value=${this.value} @input=${this.#onInput}>`;
  }

  firstUpdated() {
    this.#sync();
  }

  #onInput() {
    const country = this.renderRoot.querySelector('select').value;
    const number = this.renderRoot.querySelector('input').value.trim();
    this.value = number ? `${country}${number}` : '';
    this.#sync();
    this.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
  }

  #sync() {
    const input = this.renderRoot.querySelector('input');
    this.#internals.setFormValue(this.value);
    if (this.required && !this.value) {
      this.#internals.setValidity({ valueMissing: true }, 'Please enter a phone number.', input);
    } else {
      this.#internals.setValidity({});
    }
  }

  formResetCallback() {
    this.value = '';
    this.renderRoot.querySelector('input').value = '';
    this.#sync();
  }

  formStateRestoreCallback(state) {
    this.value = state ?? '';
  }

  get form() { return this.#internals.form; }
  get validity() { return this.#internals.validity; }
  checkValidity() { return this.#internals.checkValidity(); }
  reportValidity() { return this.#internals.reportValidity(); }
}

customElements.define('ds-phone-input', DsPhoneInput);

Key points:

  • setFormValue is called on every change, and once at startup, so even an untouched field reports an empty value and its validity.
  • setValidity with an anchor element makes the browser show the validation bubble next to the inner input.
  • Exposing form, validity, checkValidity and reportValidity makes the element behave like a native control for page scripts too.

Labels and accessibility

A <label for="phone"> outside the component cannot point at an input inside the shadow root. We forward the accessible name: the component reads the label text through internals.labels and sets aria-label on the inner input. Newer platform features for cross-root ARIA references are still evolving, so the explicit forwarding is the portable option.

Alternatives and trade-offs

  • Render the input in the light DOM with a slot. Simplest form integration, but the checkout team could then restyle or break the internal structure.
  • A hidden <input> in the light DOM that mirrors the value. It works for submission, but validation and reset need extra code and the two values can drift apart.

Disabled state

A form-associated element is also disabled when its <fieldset> is disabled. The component implements formDisabledCallback(disabled) to disable its inner controls, so a disabled fieldset around the shipping address really disables the phone input too.

How to test the fix

A component test with a real browser (Web Test Runner with Playwright) covers submission, validation and reset:

it('participates in the form', async () => {
  const el = await fixture(html`<form><ds-phone-input name="phone" required></ds-phone-input></form>`);
  const form = el;
  const input = form.querySelector('ds-phone-input');
  expect(form.checkValidity()).to.be.false;
  input.renderRoot.querySelector('input').value = '612345678';
  input.renderRoot.querySelector('input').dispatchEvent(new Event('input'));
  expect(new FormData(form).get('phone')).to.equal('+31612345678');
  form.reset();
  expect(new FormData(form).get('phone')).to.equal('');
});

Confirming in production

After the component release, 422 responses for missing phone numbers dropped to the background rate of genuinely incomplete submissions. Empty required fields now showed the browser's validation message before submission. Autofill continued to work, because the inner input keeps its autocomplete value.

Regression guard

  • Every design-system component that represents a form value has a shared "form contract" test: appears in FormData, participates in checkValidity, resets with the form, respects disabled.
  • A Storybook story for each form component includes a real <form> with a submit button.

Security implications

None for the bug itself. Validation in the browser is a usability feature; the server still validates the phone field, which is what caught the problem. Do not rely on client-side validity for anything that matters.

Prevention checklist

  • Remember that shadow DOM inputs do not belong to outer forms.
  • Use static formAssociated = true and ElementInternals for form controls with shadow DOM.
  • Call setFormValue and setValidity on every change and at startup.
  • Implement reset, disabled and restore callbacks.
  • Test components inside a real <form>, not only in isolation.

References

Add a label

#web-components #custom-elements #elementinternals #forms #shadow-dom
Written by
Hannah Okafor @hannahokafor

I am a frontend engineer who has worked on dashboards, booking flows and design systems, mostly in React and TypeScript. I care about the parts of the web platform that frameworks hide: forms, cookies, rendering cost and what the browser actually does with our code. The bugs I write about usually passed every review and only appeared with production data: a map with a million points, a name with an accent in a PDF, a cookie that one subdomain never sees. Each post shows a minimal reproduction, why the browser or framework behaves that way, and a fix with a test.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.