livewire/livewire: Suspend component trees until script modules load (#10631)
Contributed by calebporzio
Part of the livewire v4.4.7 release
Pull request #10631 fixes one of the most consistently reported breakages in Livewire 4: components with co-located <script> tags could throw Alpine Expression Error: audioUploader is not defined because Alpine evaluated the component's markup before its script module finished loading. The fix establishes a single invariant — a component's subtree is never Alpine-initialized until its script module has settled — built on a new Alpine.deferInit() primitive that ships in the now-bundled Alpine v3.17.0. If you're upgrading from v3.8.10, the change is almost entirely transparent; the one behavioral edge involves scripts that reached into Alpine state at the top level, covered below.
Why co-located scripts were racing Alpine
A component's co-located <script> compiles to an ES module fetched with a dynamic import(). Alpine, meanwhile, walks the DOM synchronously. That race is unwinnable as stated: even a byte-for-byte cached module settles no earlier than the next microtask — after the walk has already evaluated the component's markup. So this perfectly ordinary component throws:
<div x-data="audioUploader">
<span x-text="message"></span>
</div>
<script>
Alpine.data('audioUploader', () => ({ message: 'Loaded' }))
</script>
The failure appeared everywhere a component enters the page: initial load, dynamic mounts, lazy hydration, islands, and wire:navigate. The classic signature — "the first instance always breaks, later instances work" — came from the first instance's late run() registering the name globally for everyone after it. This PR is a from-scratch successor to #10295/#10619/#10623 and fixes #9591, #9737, and #9830.
The fix: one invariant, one choke point
The effect hook starts the import() immediately, then hands a readiness promise to Alpine.deferInit(component.el, ready). While the module loads, the component's tree is visible but uninitialized, and the rest of the page carries on. Alpine owns the suspension mechanics (clone mirroring, mutation replay, removal, nesting); Livewire only declares the prerequisite. Because the same choke point sits under every entry path, a few lines fix them all: initial load and back/forward restores, dynamically added children mid-morph, lazy hydration, islands and slots, and wire:navigate. Sixteen new browser tests pin the behavior across these paths, including fail-open cases.
The new script-timing contract
The fix turns an accident of racing into a documented contract: a component's script runs when its markup is in the DOM, but before Alpine initializes it. Registrations — Alpine.data() providers, $js actions, custom directives — are guaranteed to exist before any expression in the markup evaluates, and $wire.$el plus plain DOM queries work. For lazy hydration, the script is gated on the response's morph landing, so it sees real markup rather than the placeholder. x-cloak is honored through the loading window.
The one edge: anything produced by initializing the markup — $refs, Alpine component state — doesn't exist yet at script top-level. Use an init() hook instead:
<div x-data="audioUploader">
<span x-text="message" x-ref="badge"></span>
</div>
<script>
Alpine.data('audioUploader', () => ({
message: 'Loaded',
init() {
// Runs during Alpine initialization, when $refs exist
this.$refs.badge.classList.add('is-ready')
},
}))
// Safe here — the markup is in the DOM:
$wire.$el.querySelector('span')
</script>
This contract is documented in a new "When scripts run" section of docs/javascript.md.
Failure and interruption semantics
The design fails open. A module that 404s or a script that throws logs a console.error naming the component, then the tree initializes without it — the page stays alive and sibling components are untouched, because a finally guarantees the suspension always releases. The morph gate that holds lazy scripts until their markup lands resolves on onMorphed, with backstops on onCancel/onFailure/onError/onFinish, so no component stays suspended however a request ends. A component removed mid-load never runs its script. Adversarial review also hardened two subtleties: the gate is keyed to the exact message that carried the effect (a concurrent response can't open it early), and wire: directive triggers now fire exactly once, on the resume pass.
What this means for your upgrade
- Alpine is bumped to v3.17.0 (from ^3.16.3).
Alpine.deferInitis feature-detected: with an older bundled Alpine, the loader degrades to the previous racy behavior rather than breaking, but you want 3.17 for the fix. CSP builds pick it up automatically. - Components without co-located JS are byte-identical — every new branch is guarded on the
scriptModuleeffect. - Scripts that relied on racy timing — reaching for
$refsor Alpine state at the top level and getting lucky — should move toinit()orawait $wire.$nextTick(). - The
$jspromise-proxy in$wire.jsremains as a safety net; one accepted edge is that$jscalls made before a module that then fails stay unsettled (the failure is logged loudly). - A follow-up PR layers eager module delivery (
modulepreloadlinks and prefetching) on top, shrinking the suspended window to near zero; this PR is the correctness story.
Takeaways
- Co-located scripts no longer race Alpine: a component's tree stays visible but uninitialized until its module settles, on every entry path.
- Contract: scripts run with markup in the DOM but before Alpine init — use
init()or$wire.$nextTick()for$refsand component state. - Broken modules fail open: a logged error, then the component initializes anyway; siblings are unaffected.
- Requires the bundled Alpine v3.17.0 (with
deferInit); older Alpine degrades gracefully. x-cloakis honored during the loading window, and components removed mid-flight never execute their scripts.