Skip to content

Regression (4.0.0-beta4): form-associated custom element value not sent when it is a standalone (non-form) trigger #3871

Description

@gregjotau

Summary

In htmx 4, a form-associated custom element (FACE) that is used as a standalone trigger (i.e. it has a name, is not inside a <form>, and fires its own request) no longer has its value included in the request. This worked in 4.0.0-beta3 and broke in 4.0.0-beta4 (changelog: "Improved non-form input and checkbox handling"), and is still broken in 4.0.0-beta5.

This silently drops a parameter that used to be sent, which is hard to diagnose (no console error — the server just sees a missing param).

htmx version

  • ✅ Works: 4.0.0-beta3
  • ❌ Broken: 4.0.0-beta4, 4.0.0-beta5

Minimal reproducible example

A standards-compliant form-associated custom element (no framework needed) that exposes .value and calls ElementInternals.setFormValue, used directly as the htmx trigger:

<!doctype html>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-beta5"></script>
<script>
  class MyControl extends HTMLElement {
    static formAssociated = true;
    #internals = this.attachInternals();
    connectedCallback() { this.value = 'hello'; }
    get value() { return this.#value; }
    set value(v) { this.#value = v; this.#internals.setFormValue(v); }
  }
  customElements.define('my-control', MyControl);
</script>

<!-- standalone trigger: has a name, NOT inside a <form> -->
<my-control name="foo" hx-post="/echo" hx-trigger="click">click me</my-control>

Click the element and inspect the outgoing request body.

  • beta3: body contains foo=hello
  • beta4 / beta5: body is empty — foo is missing ❌

(Our real-world case is a Web Awesome <wa-combobox name="tenantId"> company switcher in a page header, outside any form. It stopped sending tenantId after upgrading.)

Expected behavior

The triggering element's own value is included, as documented ("an element that causes a request will include its value if it has one") and as in beta3.

Actual behavior

The parameter is omitted. No error is raised client-side.

Root cause

In 4.0.0-beta3, the non-form branch of #collectFormData appended the triggering element's own name/value directly:

// beta3
if (!form && elt.name) {
    if (validate && elt.reportValidity && !elt.reportValidity()) return
    formData.append(elt.name, elt.value)   // reads the host element's own value
    included.add(elt);
}

A FACE exposes .name/.value on the host, so this worked.

In 4.0.0-beta4/beta5, that branch now delegates to #addInputValues:

// beta5
if (!form) {
    if (validate && elt.reportValidity && !elt.reportValidity()) return
    this.#addInputValues(elt, included, formData, isGet);
}

// ...
#addInputValues(elt, included, formData, isGet) {
    let tag = elt.tagName;
    let inputs = [];
    if (tag === 'BUTTON') {
        inputs = [elt];
    } else if (['INPUT', 'SELECT', 'TEXTAREA', 'FIELDSET'].includes(tag) || !isGet) {
        inputs = this.#queryEltAndDescendants(elt, 'input, select, textarea');
    }
    for (let input of inputs) {
        if (!input.name || input.matches(':disabled') || included.has(input)) continue;
        // ...append input.name / input.value
    }
}

For a custom element the tagName is e.g. MY-CONTROL, which does not match input, select, textarea, and a FACE keeps its value in ElementInternals rather than a light-DOM <input>. So #queryEltAndDescendants(elt, 'input, select, textarea') finds nothing and the element's own value is never appended. The host element's .value is no longer consulted at all.

Note this is asymmetric: a FACE inside a <form> still works, because that path uses new FormData(form), which does collect FACE values via setFormValue. Only the standalone-trigger path regressed.

Suggested fix

In the non-form path, fall back to the triggering element's own name/value when it isn't a native form control but exposes a value (covers form-associated custom elements), e.g. preserve the beta3 behavior for the trigger itself:

if (elt.name && !['INPUT','SELECT','TEXTAREA'].includes(elt.tagName) && !included.has(elt) && elt.value !== undefined) {
    formData.append(elt.name, elt.value);
    included.add(elt);
}

(or otherwise consult elt.value / the element's ElementInternals form value for the triggering element).

Environment

  • htmx 4.0.0-beta5 (also beta4)
  • Reproduced in current Chrome/Firefox; not browser-specific (logic is in htmx).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions