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

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions