livewire/livewire: Fix assets after a stylesheet never being loaded
Contributed by stebrl
Part of the livewire v4.4.7 release
Coming to Livewire v4 from v3.8.10, this is a bug worth knowing about before you ship: when Livewire injects @assets blocks during an update request, any asset that directly follows a non-script asset — almost always a stylesheet — was silently dropped. PR #10482 fixes this on the 4.x branch (a rebase of the same change submitted in #10458) with a two-line adjustment to how the asset injector iterates the parsed <head>. Nothing about the @assets API changes, but if you use it, the fix can quietly re-enable scripts that have never been running on the deferred parts of your app.
What changed
The fix lives in js/features/supportScriptsAndAssets.js, the feature responsible for merging <head> assets that arrive with a Livewire update into the current page. The loop that applies those assets iterated a live DOM collection:
for (let child of newHead.children) {
await runAssetSynchronously(child)
}
It now snapshots the collection first:
// Take a static copy of the children. Appending a non-script asset moves it
// out of this document, and iterating the live collection while it shrinks
// would skip the asset right after it...
let children = [...newHead.children]
for (let child of children) {
await runAssetSynchronously(child)
}
That is the entire runtime change, plus a new browser test, test_assets_after_a_stylesheet_are_not_skipped, that locks the behaviour in.
The symptom: the stylesheet loads, the script never runs
This code path only runs for assets injected by an update request — in practice, components rendered conditionally, into a modal, or behind a toggle:
@assets
<link rel="stylesheet" href="/plugin.css">
<script src="/plugin.js"></script>
@endassets
Before the fix, the <link> was applied but the <script> was skipped. Nothing throws and the component renders fine; only whatever the script provided is missing — window.Plugin is not defined, an editor that never initialises, a picker that stays a plain input.
Bundler output makes this especially painful, because a @vite() directive expands to preload links followed by the module script: the links load, the script that does the actual work is skipped. The existing tests happened to declare the script before the stylesheet — the one ordering that worked — which is why this survived as long as it did.
Root cause: a live collection that shrinks under the iterator
newHead is the head of a parsed document adopted into the live one. runAssetSynchronously() clones script tags (leaving the original in place), but appends every other element directly to the document head — and appending moves the node out of newHead. Because newHead.children is a live HTMLCollection, it shrinks by one as each non-script element is moved, and every remaining element shifts down an index. The loop's next index then lands one element too far.
With [link, script], the link is moved, the script shifts to index 0, the loop asks for index 1 and finds nothing. The try/catch around the loop body exists for asset load errors, so the skip is completely silent. Snapshotting the children with a spread breaks the coupling between iteration and DOM mutation.
What this means for your upgrade
There are no API, config, or template changes to make — the fix is internal to the injector. The impact is behavioural:
- Scripts that were being dropped will now load and execute. If you worked around the bug — reordering the block so the script came first, or manually re-including the script elsewhere on the page — those workarounds are now redundant. Watch for double-initialisation if you also bootstrapped the plugin by hand.
- This is not a regression introduced by the upgrade itself. It was originally found in a production app on 4.3.3 and is latent in the injector logic, so it is something you carry across the upgrade rather than pick up from it.
- Make sure the v4 release you adopt includes this patch. Any build without it will keep skipping the asset that follows a stylesheet during update injection.
How it's verified
The new browser test renders a deferred nested component whose @assets block is a stylesheet followed by a script, loads it via a wire:click toggle, and asserts that window.assetAfterStylesheetLoaded === true — a flag only the second script sets. Against the unpatched base commit the test times out waiting for the script; with the fix it passes. The wider suites are green too: 1,329 unit tests, 101 JavaScript tests, and the SupportScriptsAndAssets, SupportNavigate, SupportLazyLoading, SupportJsModules, and SupportAutoInjectedAssets browser suites all pass unchanged.
Takeaways:
- If a
@assetsblock lists a stylesheet before a script, pre-fix v4 silently skipped the script when the block arrived with an update; the fix loads it. - The cause was iterating a live
HTMLCollectionwhile elements were being moved out of it; the fix is a static[...newHead.children]snapshot. - Initial page renders were never affected — only update-injected assets from deferred, modal, or toggled components.
- After upgrading, remove ordering workarounds and check for double-initialisation of scripts that now actually run.
- Confirm the release you upgrade to includes PR #10482.