alpinejs / alpinejs/alpine

Forced `x-model` select sync displays the first option when the model matches no option (dependent selects; regression in `3.16.0` via #4769)

Open
#4,885 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
HTML
Stars
31.9k
Forks
1.4k
Avg merge
2d 14h
Merged PRs (30d)
17

Description

Alpine.js version

v3.16.1 (regression introduced in v3.16.0; not reproducible on v3.15.12)

Browser and operating system

Chrome on macOS (browser-independent — follows the HTML default option-selection algorithm)

Describe the issue you're experiencing

#4769's forced sync is correct for the case it targeted — a standalone select whose options are rendered by x-for, where the model should match one of the rendered options. But for dependent selects (a "category" select driving a "type" select's options), "model matches no option" is not a desync — it's the designed state after every parent change: the child renders fresh options when its model is deliberately reset, and a placeholder held by an explicit :selected binding tells the user to choose.

For dependent child selects, the new MutationObserver sync calls updateSelect() with the unmatched model value, which sets option.selected = false on every option — including the placeholder option authored with the :selected attribute binding. With nothing selected in the dependent select, the browser displays the first enabled option (the default disabled hidden placeholder gets excluded). The select now shows a value the model doesn't reflect.

Initial render is unaffected when the parent select starts at a placeholder — with no options rendered yet there is no childList mutation, so the :selected placeholder binding behaves correctly on first load. The regression fires the first time x-for renders options into the select, i.e. the moment the user picks a parent value.

Two user-facing failure modes follow:

The first option of each dependent select becomes effectively unselectable. It already appears selected, so choosing it is a no-op — no change event ever fires and the model stays null. Picking a different option and switching back is the only way to select it. Additionally, forms look complete but won't/can't be submitted because any submit button gated on the dependent model (:disabled, validation logic) stays disabled while every visible field shows a value. Users see a filled-in form with a dead button and no indication of what's missing.

Because only one option per group is affected, this presents as intermittent in production — it broke a document-upload form for us, where "which uploads fail" turned out to correlate perfectly with "which document type is listed first in its category." On ≤3.15.12 the :selected binding on the placeholder kept it displayed, so the UI stayed truthful.

Code snippets to reproduce the issue
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.16.1/dist/cdn.min.js"></script>

<div x-data="{ category: null, type: null, types: { fruit: ['Apple', 'Banana'], veg: ['Carrot'] } }">
    <select x-model="category">
        <option :selected="category === null" disabled hidden value>Select category</option>
        <option value="fruit">Fruit</option>
        <option value="veg">Veg</option>
    </select>

    <select x-model="type">
        <option :selected="type === null" disabled hidden value>Select type</option>
        <template x-for="t in types[category]" :key="t">
            <option :value="t" x-text="t"></option>
        </template>
    </select>

    <button :disabled="category === null || type === null">Submit</button>

    <p>Model <code>type</code>: <span x-text="String(type)"></span></p>
</div>

Steps to reproduce this issue:

  1. On first load, both selects correctly display their placeholders and Submit is disabled — the honest state. (The :selected bindings work at this point because no x-for options have rendered yet, so the new MutationObserver has never fired.)
  2. Choose Fruit in the first select. The second select now displays "Apple", but the model still reads type: null: no change event fires, and the placeholder's :selected="type === null" binding (currently true) has been overridden by the forced sync. The form looks complete, yet Submit stays disabled.
  3. Try to select "Apple" — it's a no-op, since it already appears selected. Select "Banana" and switch back to "Apple", and only then does the model update and Submit enable.
  4. Swap the CDN URL to alpinejs@3.15.12 and repeat: after picking Fruit, the second select correctly keeps showing "Select type" until you choose an option.
Screenshots/screen recordings

No response

How do you expect it to work?

When the forced sync runs and the model value matches no option, the select should not end up displaying an arbitrary option: either respect the author's :selected binding (the ≤3.15.12 behavior) or leave the select with no visible selection (selectedIndex = -1). The sync should repair genuine desyncs — the standalone case #4769 targeted — without treating the designed empty state of a dependent select as one. The displayed option, the model, and the enablement of anything gated on the model should never silently diverge.

Please confirm (incomplete submissions will not be addressed)
  • I have provided easy and step-by-step instructions to reproduce the bug.
  • I have provided code samples as text and NOT images.
  • I understand my bug report will be removed if I haven't met the criteria above.

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Reproduce the dependent-select case with the provided HTML, then trace the MutationObserver forced sync and updateSelect() behavior introduced by #4769. Compare v3.16.1 with v3.15.12 and verify that genuine standalone select synchronization still works while an unmatched dependent model preserves the placeholder or leaves no option visibly selected.

Written by the indexing model from the issue text.

Assessment

Tech stack
javascript
Domain
frontend
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
68/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.