Skip to content

[LiveComponent] data-model on radio and checkbox inside a Twig Component overwrites all value attributes with the current prop value #3412

@nullodyssey

Description

@nullodyssey

Problem

Two related bugs affect data-model bindings on Twig Components wrapping <input> elements.

Bug 1 — value overwritten for radio and checkbox

When using data-model on a Twig Component that renders a <input type="radio"> or <input type="checkbox">, the PHP rendering pipeline overwrites the value attribute of every input in the group with the current LiveProp value, instead of preserving each input's individual value.

As a result, the JS sync (setValueOnElement) computes element.value == propValue as true for every input, checking all of them simultaneously on re-render.

Expected: value is preserved per input; only the matching input(s) get checked.
Actual: all inputs get value="{currentPropValue}", making them indistinguishable.

Bug 2 — [] array notation crashes with PropertyAccessor

The documentation recommends using data-model="selected[]" for checkbox groups bound to an array prop. When used on a native <input> this works fine (JS handles [] transparently). When used on a Twig Component, DataModelPropsSubscriber passes the full string selected[] to PropertyAccessorInterface::getValue(), which throws:

Could not parse property path "selected[]". Unexpected token "[" at position 8.

The [] suffix is a JS convention for array models. The PHP layer should strip it before accessing the property.


Neither bug affects native <input> tags — only when the input is wrapped in a Twig Component.


Affected types

Type JS behavior PHP injection Result
radio checked = element.value == value overwrites value broken
checkbox (group with explicit value) checked = value.some(v => v == element.value) overwrites value broken
checkbox (boolean, no explicit value) checked = value injects value="1" or value="" broken — always checked due to "1" == true JS loose equality
file returns early, ignores value injects into value no visible effect
text, email, number, textarea element.value = value injects into value correct (intended behavior)

Steps to reproduce

Input Twig Component (UX Toolkit Shadcn UI)

<input
    class="{{ ('flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 ' ~ attributes.render('class'))|tailwind_merge }}"
    {{ attributes }}
>

Bug 1 — Radio group

#[AsLiveComponent]
class MyComponent
{
    use DefaultActionTrait;

    #[LiveProp(writable: true)]
    public string $choice = 'a';
}
{% for option in ['a', 'b', 'c'] %}
    <twig:Input type="radio" data-model="choice" value="{{ option }}" />
{% endfor %}

Rendered HTML: all three radios have value="a" instead of a, b, c.

Bug 1 — Checkbox group

#[LiveProp(writable: true)]
public array $selected = ['paris'];
{% for city in ['paris', 'lyon', 'nantes'] %}
    <twig:Input type="checkbox" data-model="selected" value="{{ city }}" />
{% endfor %}

Rendered HTML: all checkboxes get value="Array" (PHP array cast to string).

Bug 1 — Boolean checkbox

#[LiveProp(writable: true)]
public bool $active = false;
<twig:Input type="checkbox" data-model="active" />

Rendered HTML: value="" (PHP false""). JS evaluates "" == false → true (loose equality) → checkbox is always rendered checked regardless of prop value.

Bug 2 — [] notation crash

{% for city in ['paris', 'lyon', 'nantes'] %}
    <twig:Input type="checkbox" data-model="selected[]" value="{{ city }}" />
{% endfor %}

Throws immediately on render:

Could not parse property path "selected[]". Unexpected token "[" at position 8.

Root cause

Bug 1

DataModelPropsSubscriber::onPreMount() fires on PreMountEvent for every Twig Component with data-model. ModelBindingParser resolves data-model="choice" as ['child' => 'value', 'parent' => 'choice'], so the subscriber unconditionally injects the current prop into $data['value'] before the component template renders:

// DataModelPropsSubscriber.php:68
$data[$childModel] = $this->propertyAccessor->getValue($parentMountedComponent->getComponent(), $parentModel);

No check for type="radio" or type="checkbox". By the time {{ attributes }} runs inside the child component template, value is already overwritten.

The JS layer (live_controller.js:290-315) does handle these types correctly — but since all element.value attributes are identical after the PHP corruption, the logic produces wrong results:

  • For radios: all get checked = true
  • For checkbox groups: all comparisons fail ("Array" == "paris"false) — nothing gets checked
  • For boolean checkboxes: always checked due to "" == false loose equality

Bug 2

ModelBindingParser::parseBinding() uses explode(':', $bindingString) to split the binding string. For selected[], there is no :, so it produces ['child' => 'value', 'parent' => 'selected[]']. The full string selected[] is then passed to PropertyAccessor::getValue(), which only understands standard Symfony property paths and throws on [.

This does not affect native <input> tags because those bypass PreMountEvent entirely — the [] suffix is handled exclusively by the JS layer.


Suggested fix

Both bugs can be fixed in DataModelPropsSubscriber::onPreMount():

foreach ($bindings as $binding) {
    $childModel = $binding['child'];
    // Fix Bug 2: strip the JS array notation suffix before passing to PropertyAccessor.
    $parentModel = rtrim($binding['parent'], '[]');
    $propValue = $this->propertyAccessor->getValue($parentMountedComponent->getComponent(), $parentModel);

    // Fix Bug 1: for radio/checkbox, compute 'checked' instead of overwriting 'value'.
    if ('value' === $childModel && isset($data['type']) && \in_array($data['type'], ['radio', 'checkbox'], true)) {
        if ('checkbox' === $data['type'] && !isset($data['value'])) {
            // Boolean checkbox: no explicit value, prop drives checked directly.
            $data['checked'] = (bool) $propValue;
        } elseif (\is_array($propValue)) {
            // Checkbox group: check if this input's value is included in the array.
            $data['checked'] = \in_array($data['value'] ?? null, $propValue, strict: false);
        } else {
            // Radio or single-value checkbox: strict comparison.
            $data['checked'] = ($data['value'] ?? null) === $propValue;
        }
        continue;
    }

    $data[$childModel] = $propValue;
}

This mirrors the existing JS behavior in setValueOnElement() (lines 293–301 of live_controller.js) and handles all three radio/checkbox use cases consistently.


Environment

  • symfony/ux-live-component
  • PHP 8.x, Symfony 7.x
  • Reproducible with any Twig Component that wraps <input> and uses {{ attributes }}

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions