Skip to content

x-modelable loses a change made right after a morph, and leaks a binding on every morph #4928

Open

Description

@mauritskorse

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:

  1. 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.
  2. 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, put count behind 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>
  1. Click "Add one": the count shows 1.
  2. Click "Morph, then add one": the count should show 2, but it stays at 1.
  3. 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

  1. tevinch commented on Oct 13, 2026

    @tevinch

    The deferred binding explains both symptoms in your reproduction: cloneNode finishes clone mode before the x-modelable microtask runs, so the discarded comparison node creates a live entanglement against the shared state.

    Wrapping the directive callback in skipDuringClone prevents 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions