livewire/livewire: Start fetching script modules before the page needs them
Contributed by calebporzio
Part of the livewire v4.4.7 release
Livewire 4 already guarantees script-module correctness: since #10631, a component's tree suspends until its module has been imported and run, so markup never appears before its script is ready. PR #10632 attacks the cost of that guarantee rather than the guarantee itself. It closes the gap between "the server knows a module is coming" and "the browser starts fetching it" using two new mechanisms: modulepreload links injected into full-page heads, and a childScriptModules effect on update responses. Both are pure latency optimizations — every component still imports and runs its own module when it mounts, so a missed preload simply means a slightly longer suspended window, never incorrect behavior.
The waterfall this removes
Before this change, a module's fetch started at the end of a serial chain: the browser parses the HTML, livewire.js boots, discovers the component's scriptModule effect, and only then calls import(). With #10631's suspension in place, that whole chain sits on the critical path before a component's module can run — meaning the slowest step (the network fetch) started last. This PR flips that so the fetch begins at the earliest moment the server can announce it.
modulepreload links on full-page loads
On an initial page render, SupportJsModules::dehydrate() already knows a mounting component's module URL and its version hash (derived from the file's modification time). It now registers that URL through the existing rendered-assets head injection:
<link rel="modulepreload" href="/livewire/js/testns---alpine-data.js?v=88fca3e1">
Because the link sits in the head, the browser's preload scanner starts the fetch while still parsing HTML — long before Livewire boots and discovers the effect on its own. The parse → boot → discover → fetch waterfall becomes a parallel fetch, and the module is typically downloaded and compiled before DOMContentLoaded. The suspended window then collapses to microtasks, resuming before first paint in the common case.
Two cases benefit without extra work. Lazy placeholders skip the scriptModule effect (the real view hasn't rendered, so @assets hasn't run), but the page render still knows the module is coming — the placeholder mount emits the preload link, warming it for the eventual __lazyLoad hydration. And wire:navigate merges the new page's head assets before initializing it, so preload links ride along there too.
Update requests are deliberately excluded: rendered assets would otherwise ride along inside update payloads, and new children on updates are handled by the next mechanism instead.
childScriptModules on update responses
When an update mounts a child, the response itself is the discovery — there is nothing to preload in advance. But the server learns about the new child before the client sees any of its HTML. Each child mount with a script module is now collected into a request-scoped static (cleared on flush-state), and the responding component's dehydrate drains it into an effect:
{ "childScriptModules": [{ "name": "testns::alpine-data", "hash": 3939658634 }] }
The client handles it at the top of the effect step, fire-and-forget:
if (effects.childScriptModules) {
effects.childScriptModules.forEach(({ name, hash }) => {
import(/* @vite-ignore */ modulePath(name, hash)).catch(() => {})
})
}
The fetch now overlaps state sync and the morph, instead of starting only when the child's root element is reached mid-morph. The collector drains on announcement, so a subsequent update that mounts nothing new announces nothing — verified by the test suite.
Lazy hydration and the nested case
Lazy-hydrating components drain the same collector: when __lazyLoad mounts the real component tree, any children with modules are named on the response. This covers the previously awkward nested case — a module component inside a lazy module component — which would otherwise fetch as a waterfall (the lazy module downloads, runs, renders its child, and only then the child's module starts downloading). Now both fetches begin the moment the hydration response lands.
Upgrade impact
For someone coming from v3.8.10, script-module support is new on the 4.x line, and this PR only makes it faster — there are no public API, configuration, or behavioral changes to adopt. The changes are additive: full-page heads gain modulepreload links, and effects may carry a childScriptModules key alongside the existing scriptModule key. If asset injection is customized or a head link is absent for any reason, components simply fall back to the old discover-then-import path. One edge case worth knowing: because module URLs are hash-versioned, if a module file changes between a lazy placeholder's page render and its hydration, the preload link and the real mount will reference two hashes and produce two fetches — harmless, and inherent to hash-versioned URLs.
Takeaways
- Full-page loads now emit
<link rel="modulepreload">for every mounting component's module, so fetching starts during HTML parsing rather than afterlivewire.jsboots. - Updates that mount children announce their modules via a
childScriptModuleseffect, letting the clientimport()them in parallel with state sync and the morph. - Lazy placeholders and lazy hydration are covered too, including nested module-inside-lazy-module trees.
- Nothing here is load-bearing: a missed preload only means a slightly longer suspended window, never broken behavior.
- No upgrade action is required from v3.8.10 — the changes are additive and self-healing.