Skip to content

Fix x-model setting .value instead of .checked on checkboxes#4779

Merged
calebporzio merged 3 commits intomainfrom
fix/checkbox-model-value
Mar 24, 2026
Merged

Fix x-model setting .value instead of .checked on checkboxes#4779
calebporzio merged 3 commits intomainfrom
fix/checkbox-model-value

Conversation

@ganyicz
Copy link
Collaborator

@ganyicz ganyicz commented Mar 22, 2026

The scenario

Checkbox with x-model with a dotted expression that evaluates to undefined.

<div x-data="{form: { agree: undefined } }">
    <input type="checkbox" name="agree" x-model="form.agree"> <!-- value="on" → value="" -->
</div>

When this form is submitted, the server receives agree="" instead of agree="on".

In Laravel, this causes $request->boolean('agree') to return false even when the checkbox is checked.

The problem

x-model on checkboxes should only ever control .checked — never .value.

However currently, that depends on the type of received value:

  • strings and integers set the value attribute
  • everything else sets checked attribute

This happens because x-model uses x-bind:value under the hood, which is where this behavior comes from. The problem is that for undefined nested model keys we change the value to '' before routing it to x-bind:value.

// x-model.js
el._x_forceModelUpdate = (value) => {
    // ↓ This sets the value to '' for x-model="form.agree" === undefined ↓
    if (value === undefined && typeof expression === 'string' && expression.match(/\./)) value = ''

    // @todo: This is nasty
    window.fromModel = true
    mutateDom(() => bind(el, 'value', value))
    delete window.fromModel
}

The solution

For checkboxes and radios, set .checked directly in x-model instead of routing through bind().

This also removes the nasty window.fromModel hack.

el._x_forceModelUpdate = (value) => {
    if (value === undefined && typeof expression === 'string' && expression.match(/\./)) value = ''

    mutateDom(() => {
        if (isCheckbox(el)) {
            if (Array.isArray(value)) {
                el.checked = value.some(val => val == el.value)
            } else {
                el.checked = !!value
            }
        } else if (isRadio(el)) {
            if (typeof value === 'boolean') {
                el.checked = safeParseBoolean(el.value) === value
            } else {
                el.checked = el.value == value
            }
        } else {
            bind(el, 'value', value)
        }
    })
}

Breaking changes

This will only cause a breaking change if the user relied on the incorrect behavior:

<div x-data="{ value: 'on' }">
    <input type="checkbox" value="agree" x-model="value">
</div>

Before: <input type="checkbox" value="on">
After: <input type="checkbox" value="agree" checked>

Fixes livewire/flux#2497

@ganyicz ganyicz marked this pull request as draft March 22, 2026 22:11
@ganyicz
Copy link
Collaborator Author

ganyicz commented Mar 23, 2026

PR Review: #4779 — Fix x-model setting .value instead of .checked on checkboxes

Type: Bug fix
Verdict: Merge

What's happening (plain English)

  1. You have a checkbox with x-model="form.agree" where form.agree is undefined
  2. Because the expression contains a dot (.), _x_forceModelUpdate coerces undefined to '' (empty string) — this is intentional for text inputs
  3. It then calls bind(el, 'value', '') which routes to bindInputValue
  4. In bindInputValue, the checkbox branch checks: is '' an integer? No. Is it an array, boolean, null, or undefined? No — it's a string. So it sets el.value = String('') = ''
  5. This overwrites the checkbox's default value="on" to value=""
  6. When the form is submitted, the server receives agree="" instead of agree="on", which breaks things like Laravel's $request->boolean('agree')

The core issue: x-model was routing checkbox/radio updates through bind(el, 'value', ...), which conflates "set the form submission value" with "set the checked state." The window.fromModel hack tried to disambiguate this but couldn't prevent the string/integer value-overwrite paths from firing first.

Other approaches considered

  1. Just guard the value = '' coercion for checkboxes/radios — e.g., if (!isCheckbox(el) && !isRadio(el)) value = ''. This would fix the immediate bug but keeps the window.fromModel hack and the architectural confusion of routing checked-state through bind(el, 'value', ...). Worse fix long-term.

  2. The PR's approach: handle checkbox/radio directly in _x_forceModelUpdate — cleanly separates concerns. x-model on checkboxes/radios sets .checked; x-bind:value on checkboxes/radios sets .value. The window.fromModel global is eliminated. This is the right approach.

  3. Fix inside bindInputValue only — add a "from model" parameter instead of the global. Still conflates the two responsibilities; just uses a cleaner mechanism. Inferior to the PR's approach.

Changes Made

No changes made. The PR is clean as submitted.

Test Results

  • x-model.spec.js: 34/34 passing (includes the new test)
  • x-bind.spec.js: 26/26 passing (checkbox/radio value binding, combined x-model + :value)
  • Regression verification: Reverted the fix, rebuilt, confirmed the new test fails on main — the test correctly isolates the bug
  • CI: Both builds passing

Code Review

The fix (x-model.js:158-174): Checkbox and radio handling is moved from bindInputValue (via the window.fromModel path) directly into _x_forceModelUpdate. The logic is identical to what was scattered across bind.js:

  • Checkbox+array: el.checked = value.some(val => val == el.value) — same as the old checkedAttrLooseCompare (which is literally ==)
  • Checkbox+non-array: el.checked = !!value
  • Radio+boolean: el.checked = safeParseBoolean(el.value) === value
  • Radio+non-boolean: el.checked = el.value == value

The cleanup (bind.js:49-57): Removes the window.fromModel block from the radio branch. This code is now dead since _x_forceModelUpdate no longer calls bind() for radios.

_x_bindings side effect: Previously, bind(el, 'value', modelValue) would set el._x_bindings['value'] to the model value for checkboxes/radios. This no longer happens. I verified that nothing in Alpine core or plugins calls getBinding(el, 'value') or extractProp(el, 'value') on checkboxes/radios — the only callers are combobox.js and listbox.js in the UI package.

Style: No semicolons, no const, matches existing patterns. The safeParseBoolean, isCheckbox, and isRadio imports were already present.

Minor behavior changes (correctly documented by contributor):

  • x-model bound to a string on a checkbox previously set el.value to that string. Now it sets el.checked = !!value. The old behavior was wrong — x-model should control checked state, not submission value.
  • Same for integers (Number.isInteger path). x-model="count" where count is 5 now checks the checkbox instead of setting el.value = 5.

These are technically breaking but the old behavior was a bug, not a feature.

Security

No security concerns identified. No expression evaluation changes, no x-html involvement.

Verdict

Merge. The fix is architecturally sound — it draws a clean line between x-model (controls .checked) and x-bind:value (controls .value) for checkboxes and radios. The window.fromModel hack is eliminated. The test is well-written and correctly targets the root cause. The behavior changes for string/integer model values on checkboxes are improvements, not regressions. All existing tests pass.


Reviewed by Claude

@ganyicz
Copy link
Collaborator Author

ganyicz commented Mar 23, 2026

Clarification on _x_bindings side effect:

Worth noting one subtle public API change: previously, _x_forceModelUpdate called bind(el, 'value', value) which stored the model value in el._x_bindings['value']. This meant Alpine.bound(checkboxEl, 'value') would return the model state (e.g., true, false, ['a', 'b']) rather than the form submission value ('on', 'agree', etc.).

After this PR, bind() is bypassed for checkboxes/radios, so Alpine.bound(checkboxEl, 'value') falls through to reading the HTML attribute instead.

The old behavior was arguably incorrect — Alpine.bound(el, 'value') should return the bound value of the value attribute, not the model state. The model state is accessible via el._x_model.get(). But if any external code (plugins, Livewire, etc.) happens to call Alpine.bound() on a checkbox expecting the model value, it would get a different result.

Low risk in practice, but flagging it for awareness.

@ganyicz ganyicz marked this pull request as ready for review March 23, 2026 09:39
@calebporzio calebporzio merged commit c829163 into main Mar 24, 2026
2 checks passed
@calebporzio calebporzio deleted the fix/checkbox-model-value branch March 24, 2026 13:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Checkbox: always submits empty value in native HTML forms

2 participants