Skip to content

Don't re-apply x-cloak to rendered elements during morph - #4922

Merged
calebporzio merged 1 commit into
mainfrom
x-cloak-morph-measurements
Sep 29, 2026
Merged

calebporzio merged 1 commit into
mainfrom
x-cloak-morph-measurements

Conversation

@calebporzio

Copy link
Copy Markdown
Collaborator

The problem

Measuring an element from a MutationObserver during a morph (e.g. a chat measuring its message list after Livewire re-renders) reads 0px if that element, or one of its ancestors, uses x-cloak. Swapping x-cloak for wire:cloak makes the measurements correct.

The cause is that morph re-applies x-cloak to elements that are already rendered and uncloaked:

  1. The server HTML still has x-cloak on the element (the live DOM lost it after Alpine initialized).
  2. Before patching an existing element, morph seeds the incoming element with Alpine.cloneNode(from, to), which runs the x-cloak directive on it.
  3. x-cloak only removes itself in a queueMicrotask, so the incoming element still has x-cloak when patchAttributes() runs, and morph copies it back onto the live element.
  4. The live element is display: none until that microtask fires. Any MutationObserver callback that runs in that window sees the element, and everything inside it, at 0px.

wire:cloak doesn't hit this because Livewire strips it in an Alpine.interceptInit() callback, which runs synchronously while the incoming element is being cloned. By the time morph patches attributes, wire:cloak is already gone.

The fix

While cloning, strip x-cloak from the incoming element synchronously:

directive('cloak', el => {
    if (isCloning) return el.removeAttribute(prefix('cloak'))

    queueMicrotask(() => mutateDom(() => el.removeAttribute(prefix('cloak'))))
})

The cloned element is detached server HTML that's never painted, so there's nothing to hide and no need for mutateDom(). Morph then sees no x-cloak on the incoming element and leaves the live one alone. If the live element somehow still has x-cloak, morph removes it, which is also how wire:cloak behaves.

Normal (non-morph) initialization is untouched: x-cloak still removes itself in a microtask.

Measurements

Repro: two identical Livewire chats side by side, one using x-cloak, one using wire:cloak (on the list and on each message). Each has a MutationObserver that records offsetHeight of the list and its first message on every callback. Clicking "Send" three times gives:

x-cloak (before) x-cloak (after) wire:cloak
Send #1 0px → 162px 122px → 162px 162px
Send #2 0px → 210px 170px → 210px 210px
Send #3 0px → 258px 218px → 258px 258px

(The intermediate after-fix readings are the new message being inserted before Alpine initializes it. See the note below.)

Before: every morph briefly re-cloaks the whole x-cloak list, so the observer reads 0px for the list and the first message.

Before: the x-cloak chat measures 0px for the list and the first message on every morph

After: already-rendered elements are never re-cloaked, and measurements match wire:cloak.

After: the x-cloak chat reports real heights on every morph, matching wire:cloak

Tests

Added morphing does not re-apply x-cloak to an already initialized element to morph.spec.js. It morphs an initialized <p> against HTML that still has x-cloak and records the element's height from a MutationObserver:

  • Without the fix: expected [ 0, 19 ] to not include 0
  • With the fix: passes

The full Cypress suite passes (629 passing, 0 failing).

Related, not fixed here

While digging I found a separate ordering issue that affects x-cloak and wire:cloak equally. During a morph, any mutateDom() call triggers flushObserver(), which takes Alpine's pending mutation records and processes them in a later queueMicrotask. That microtask runs after the browser's MutationObserver notification. So nodes newly inserted by a morph (before its last mutateDom() call) aren't initialized yet when other observers run, and a cloaked new node still measures 0px. Swapping the order of the two chats in the repro makes the wire:cloak chat's new message read 0px instead. That belongs in mutation.js, so I'm leaving it for a separate PR.

🤖 Generated with Claude Code

Morph seeds each incoming element with Alpine.cloneNode() before patching
attributes. x-cloak only removes itself in a microtask, so the incoming
element still has x-cloak when attributes are patched, and it gets copied
back onto the already-initialized live element. The element is then
display: none until the microtask runs, so anything measuring it in that
window (e.g. a MutationObserver callback) reads 0px.

Strip x-cloak synchronously while cloning, like wire:cloak's interceptor
already does. The cloned element is detached, so there's nothing to hide.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@calebporzio
calebporzio merged commit da60871 into main Sep 29, 2026
2 checks passed
@calebporzio
calebporzio deleted the x-cloak-morph-measurements branch September 29, 2026 19:11
@ekwoka

ekwoka commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

Was the logic behind having it queue a microtask in the first place reevaluated to ensure it's still necessary?

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.

2 participants