Skip to content

fix: x-model.blur cleanup crash when element removed from DOM#4780

Open
manwithacat wants to merge 1 commit intoalpinejs:mainfrom
manwithacat:fix-x-model-blur-cleanup-crash
Open

fix: x-model.blur cleanup crash when element removed from DOM#4780
manwithacat wants to merge 1 commit intoalpinejs:mainfrom
manwithacat:fix-x-model-blur-cleanup-crash

Conversation

@manwithacat
Copy link

@manwithacat manwithacat commented Mar 23, 2026

Problem

When an <input x-model.blur> inside a <form> is removed from the DOM via Livewire morphing, Alpine.morph(), or direct DOM manipulation, the cleanup callback introduced in #4729 throws:

TypeError: Cannot read properties of null (reading '_x_pendingModelUpdates')

This happens because el.form returns null for elements that have been detached from the DOM, and the cleanup closure reads el.form at teardown time rather than at registration time.

Reported in Discussion #4738 — confirmed by multiple users in production with Livewire.

Why x-if alone doesn't crash

Alpine's x-if runs attribute cleanup before detaching the element from the DOM, so el.form is still valid at that point. The crash only occurs when the element is removed outside Alpine's control — via Livewire morphing, Alpine.morph(), or direct el.remove(). In those paths, the MutationObserver fires cleanup after the element is already detached, and el.form returns null.

Fix

Capture let form = el.form at registration time (line 87 of x-model.js). All subsequent references — including the cleanup closure — use the captured form variable instead of re-reading the live DOM property.

 if (el.form) {
+    let form = el.form
     let syncCallback = () => syncValue({ target: el })
-    if (!el.form._x_pendingModelUpdates) el.form._x_pendingModelUpdates = []
-    el.form._x_pendingModelUpdates.push(syncCallback)
-    cleanup(() => el.form._x_pendingModelUpdates.splice(...))
+    if (!form._x_pendingModelUpdates) form._x_pendingModelUpdates = []
+    form._x_pendingModelUpdates.push(syncCallback)
+    cleanup(() => form._x_pendingModelUpdates.splice(...))
 }

Tests

Three new Cypress tests in x-model.spec.js:

  1. Core crash casex-model.blur input inside a form, removed via x-if. Verifies cleanup completes and Alpine remains functional.
  2. Without form — Same scenario without a parent form. Verifies no regression.
  3. Sibling removal + form submit — Two x-model.blur inputs in a form, one removed, then form submitted. Verifies the form's _x_pendingModelUpdates array isn't corrupted.

All 36 x-model tests pass (33 existing + 3 new).

A note on testing the crash path

The real crash requires the element to be detached from the DOM before cleanup runs — which is the Livewire/morph path, not the x-if path. Cypress (and Playwright) test the x-if path, where Alpine runs cleanup before detachment, so el.form is still valid and the TypeError doesn't fire during the test.

However, the tests are still valuable: they exercise the exact code path that was changed (the x-model.blur cleanup with a form), and they verify the behavioral contract — that removing and re-adding x-model.blur inputs doesn't break Alpine's reactivity or corrupt _x_pendingModelUpdates. The fix itself is correct regardless of cleanup ordering: capturing the form reference at registration time is always safer than re-reading a live DOM property in a deferred callback.

For a visual demonstration of the actual crash (using el.remove() to simulate Livewire morph), see the interactive demo below.

Interactive demo

Side-by-side comparison loading builds from the same base commit (1735d0bf), one buggy, one fixed. Uses el.remove() to trigger the real crash path:

https://manwithacat.github.io/alpine/

Scope

Only the el.form references inside the .blur pending-update block (lines 86–93) are affected. The other el.form usage at line 136 (reset listener) is safe because on() captures listenerTarget internally.

When an element with x-model.blur is removed from the DOM (e.g., via
x-if or Livewire morphing), the cleanup callback reads el.form which
returns null for detached elements. This causes a TypeError on
null._x_pendingModelUpdates.

Fix: capture `let form = el.form` at registration time so the cleanup
closure uses a stable reference that remains valid after detachment.

Adds three Cypress tests:
- x-model.blur input removed via x-if (core crash case)
- x-model.blur input without form removed via x-if
- form submit after sibling x-model.blur input removed

Ref: alpinejs#4738
Regression from: alpinejs#4729
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.

1 participant