-
-
Notifications
You must be signed in to change notification settings - Fork 413
[LiveComponent] data-model on radio and checkbox inside a Twig Component overwrites all value attributes with the current prop value #3412
Description
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
"" == falseloose 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 }}