Repository navigation
x-modelable loses a change made right after a morph, and leaks a binding on every morph #4928
Description
Alpine.js version
v3.17.4
Browser and operating system
Chromium 141 on Linux
Describe the issue you're experiencing
A change made to an x-modelable value right after a morph is lost, and every morph leaks one binding. Both come from the copy of the element that Alpine.morph() clones.
Alpine.morph() calls Alpine.cloneNode(from, to) on every element it patches. cloneNode initialises the copy's directives inside dontRegisterReactiveSideEffects(), and x-data's clone interceptor hands the copy the live element's data stack.
On the copy, x-model sees isCloning and skips its listener, but it still sets el._x_model. x-modelable queues a microtask that entangles el._x_model with its inner value. That microtask runs after cloneNode() has returned, when isCloning is false again and effects register for real. So the copy gets a live entanglement over the live component's data. That has two consequences:
- A change made after the morph is lost. The entanglement's first run copies the outer value into the inner one. If the component changed its inner value after the morph, but before that microtask ran, the change is overwritten. The live element's own entanglement then finds both sides equal and does nothing.
- The binding leaks. Its
cleanup()is registered on the copy, which is thrown away without being destroyed, so it is never released. Each morph leaves one more entanglement on the component. To measure it, putcountbehind a getter that counts its reads. One change reads it 4 times on a fresh page, 6 times after one morph, 14 after five and 24 after ten.
Livewire morphs a component on every update, so every x-modelable element inside a Livewire component gains one entanglement per request. We hit the first consequence with a select-all control. It adds a new page's rows to its x-modelable value from a MutationObserver on its own attributes. After paging, those rows were replaced by the value from before the morph.
alpinejs/alpine#3393 made x-model skip its listener on a clone, for the same reason: Livewire clones Alpine components when it updates the DOM. x-modelable has no such check. alpinejs/alpine#4903, in 3.17.4, dequeues an entanglement's effect once it is released; the copy's entanglement is never released.
Code snippets to reproduce the issue
<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/morph@3.17.4/dist/cdn.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.17.4/dist/cdn.min.js"></script>
<div x-data="{ count: 0 }">
<div id="counter" x-data="{ value: 0 }" x-modelable="value" x-model="count"></div>
<button type="button" onclick="addOne(false)">Add one</button>
<button type="button" onclick="addOne(true)">Morph, then add one</button>
<p>count: <span x-text="count"></span></p>
</div>
<script>
function addOne(morphFirst) {
const counter = document.getElementById('counter')
// An identical copy, as Livewire morphs a component on every update.
if (morphFirst) Alpine.morph(counter, counter.outerHTML)
Alpine.$data(counter).value++
}
</script>- Click "Add one": the count shows 1.
- Click "Morph, then add one": the count should show 2, but it stays at 1.
- Click "Add one": the count shows 2.
Screenshots/screen recordings
No response
How do you expect it to work?
"Morph, then add one" should add one, as "Add one" does, and a morph should leave no entanglement behind. Skipping the entanglement on the copy fixes both.
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.
Activity
tevinch commentedon Oct 13, 2026
The deferred binding explains both symptoms in your reproduction:
cloneNodefinishes clone mode before thex-modelablemicrotask runs, so the discarded comparison node creates a live entanglement against the shared state.Wrapping the directive callback in
skipDuringCloneprevents that microtask from being queued. The source diff is three lines; it leaves ordinary component initialization and cleanup intact.I reproduced the failures with 3.17.4 and unchanged main. With the correction, the counter keeps every increment, and a separate pagination fixture retains selected IDs through 11 real attribute-observer callbacks, deselection/reselection, disabled automatic selection, and component removal/recreation. The repeated-binding check stays constant after 20 morphs. All 12 Playwright cases and the upstream Vitest suite (204 passed, one skipped) pass.
The runnable source example and regression tests include the exact checks and build steps. These are standalone Alpine/Morph checks; I haven't verified your Livewire application or run the official Cypress suite. Livewire's bundled Alpine would need the source correction; adding another Alpine instance alongside it would not be appropriate.