Skip to content
LaravelBlog

livewire/livewire: Close asynchronous morph timing gaps

LaravelBlogBot
LaravelBlogBot

Contributed by calebporzio

Part of the livewire v4.4.7 release

This pull request closes a class of asynchronous timing gaps in Livewire v4's client-side rendering pipeline. Until now, Livewire guessed when the DOM morph had finished using a chain of queueMicrotask() calls. Once slot fragments and streamed islands started contributing their own awaited morphs, DOM-dependent work — server-dispatched JavaScript, browser events, and resolved action promises — could run before the newly rendered elements and their listeners existed. This change makes the ordering explicit: every morph is awaited, a new onMorphed boundary fires after all morph work completes, and DOM-dependent effects run from that boundary. For teams upgrading from v3.8.10, most of this is invisible scaffolding — but it eliminates setTimeout()-style workarounds, changes when await $wire.action() resolves, and introduces hooks that only exist in v4.

The new onMorphed boundary

The interceptMessage() lifecycle now has a dedicated point where all DOM work is guaranteed to be finished. Hook execution order for a successful request is:

  1. onSuccess — immediately after the server response
  2. onSync — after state is merged
  3. onEffect — after effects are processed
  4. onMorph — during the awaited DOM morph phase
  5. onMorphed — after all morph work completes
  6. onFinish — after onMorphed
  7. onRender — in the next requestAnimationFrame

The docs now position onMorph as an advanced hook for contributing your own awaited DOM work. Most code that simply needs to read the updated DOM should use onMorphed, which runs after the component, island, and slot morphs have all completed but before action promises resolve and onFinish runs:

$wire.interceptMessage(({ message, onSuccess }) => {
    onSuccess(({ payload, onMorphed }) => {
        onMorphed(() => {
            // Component, island, and slot morphs are all complete here
        })
    })
})

Server JavaScript and events are now morph-safe

Server-driven effects used to fire from an effect hook wrapped in microtasks — a timing guess. They now run from the onMorphed boundary, making this previously unreliable pattern safe:

public function reveal()
{
    $this->show = true; // renders a new [dusk=target] element

    $this->js("document.querySelector('[dusk=target]').textContent = 'evaluated'");
}

The target element is rendered by the same response, so the old timing could evaluate the script before it existed in the DOM. The same guarantee applies to browser events: $this->dispatch('opened')->el('#target') now reliably reaches an element that appears in the same update.

At mount time, a new component.initialized hook fires after a component's initial effects have been processed — preserving startup ordering for mount-time $this->js() calls and dispatches — but before Livewire initializes the component's descendants:

Livewire.hook('component.initialized', ({ component }) => {
    // initial effects (js, scripts, dispatches) have been applied
})

Streams and slots are awaited end to end

Two asynchronous paths are now fully awaited. First, onStream handlers may be async; the pipeline awaits each one before processing the next stream chunk. Second, slot fragment morphs render sequentially with for...of + await instead of being kicked off concurrently.

The practical consequence: action promises now cover the entire UI update. From the new islands test:

<button x-on:click="
    await $wire.increment();
    result = document.querySelector('[dusk=island-count]').textContent.trim()
">Increment</button>

@island(name: 'counter')
    <div wire:transition>Count: {{ $count }}</div>
@endisland

Previously, the promise could resolve while the streamed island was still morphing, so result would read the stale Count: 0. The same fix applies to transitioned slot content inside child components: awaited actions now resolve only after the slot displays its server-rendered content.

What did not change: @script timing

A deliberate part of this PR is what stayed put. @script keeps its established pre-morph phase, so scripts shipped inside newly rendered content still register Alpine data, directives, and other behavior before those elements initialize. A conditionally added @script that registers Alpine.data('timingTest', ...) for an element appearing in the same response continues to work exactly as before — a new browser test locks this in. Likewise, script-defined $js actions are still registered before xjs invokes them, and @assets remain awaited during payload interception, before component response handling begins.

Upgrade impact

Review three things when moving from v3.8.10:

  • Awaited actions resolve later. await $wire.action() now waits for streamed islands and slot morphs to finish. Anything that depended on the old earlier resolution was racing the DOM.
  • Retire timing hacks. setTimeout() / queueMicrotask() delays that waited for the morph can be replaced with an onMorphed hook or by relying on $this->js() directly.
  • New API surface. onMorphed and component.initialized do not exist in v3.8.10, so interceptors using them require v4. Write onStream handlers to tolerate being awaited — return a promise only when you have real async work.

Five new behavior-observing browser tests were verified failing before each production patch and passing after. Post-patch verification covered the Scripts/Assets, JS Evaluation, and Events suites (55 tests, 93 assertions) plus the JavaScript suite (110 passing, one pre-existing skip), confirming no regressions.

Takeaways:

  • onMorphed runs after all awaited morph work and before action promises resolve — use it for DOM-dependent logic instead of onMorph.
  • $this->js(), xjs, and server dispatches now run post-morph and can target DOM rendered by the same response.
  • The new component.initialized hook preserves mount-time startup ordering.
  • onStream handlers can be async and are awaited before the next chunk is processed.
  • @script remains pre-morph; existing Alpine registration patterns need no changes.

Sources

More from this release

Related Articles