livewire/livewire: Fixed: script modules not loading after back/forward navigation
Contributed by stebrl
Part of the livewire v4.4.7 release
Livewire 4 gives every component a natural home for JavaScript: add a colocated <name>.js file next to a multi-file component, or a <script> block inside a single-file component, and register $js() actions your template can call. Pull request #10481 fixes a bug where all of that JavaScript silently vanished after a browser back/forward navigation. The change is small and entirely internal, and for anyone upgrading from v3.8.10 it simply means the v4 line you are moving to now handles back/forward navigation correctly for these components — there is no API change and nothing to migrate.
What changed
Livewire stores component metadata on the element in a wire:effects attribute. When you click a wire:navigate link, Livewire rewrites that attribute before caching the page's HTML for the back button, using an allowlist of effect keys: listeners, url, and scripts. The scriptModule key — the one responsible for importing a component's script module — was not on that list, so it got dropped from the cached markup. This PR carries scriptModule over alongside the others, and adds a browser test proving a $js action still works after navigating away and coming back.
The symptom
This bug only fired on back/forward navigation; direct visits and regular wire:navigate clicks were fine. After pressing the browser's back button, the first call to a component action failed hard:
Uncaught TypeError: $wire.$js.calculateTotal is not a function
What made it nasty in production was that the page looked almost right. Server-rendered markup is restored as-is; only the client-driven parts fail. An x-text expression calling a $js helper rendered an empty string, a row behind x-cloak stayed hidden, and anything else registered in the module died too — including a $wire.intercept hook collecting form state on save, which turned a cosmetic bug into potential data loss.
A minimal example of the affected setup:
{{-- Checkout.blade.php --}}
<div>
<button wire:click="$js.calculateTotal">Recalculate</button>
<a href="/checkout/confirm" wire:navigate>Continue</a>
</div>
// Checkout.js — colocated next to Checkout.php and Checkout.blade.php
$js('calculateTotal', () => {
this.$wire.recalculate()
})
Click "Continue", press Back, click "Recalculate": before this fix, that throws.
Why it happened
The chain, as traced in the PR:
SupportJsModules::dehydrate()attaches thescriptModuleeffect only while a component mounts (or hydrates lazily).- The
effecthook injs/features/supportJsModules.jsimports the module referenced by that effect. - On navigating away,
Component.inscribeSnapshotAndEffectsOnElement()rewriteswire:effectsfrom the allowlist — andscriptModuleis not on it, so it is removed. updateCurrentPageHtmlInSnapshotCacheForLaterBackButtonClicks()then cachesdocument.documentElement.outerHTML— the DOM without the effect.- On back/forward, the restored component reads
wire:effectsand runsprocessEffects(). NoscriptModule, noimport(), no$jsactions.
Tellingly, the older @script channel (scripts) was already on the allowlist, with a test covering exactly this scenario. The newer module channel was simply missed when it was added.
The fix, in code
The change in js/component.js carries the effect across the same way scripts already was:
// We need to re-load the component's script module, otherwise its $js
// actions are gone after a back/forward navigation...
if (Object.prototype.hasOwnProperty.call(this.originalEffects, 'scriptModule') && this.originalEffects.scriptModule) {
effects.scriptModule = this.originalEffects.scriptModule
}
originalEffects is captured once in the constructor and left untouched by mergeNewSnapshot(), so the module reference is still available when the element is rewritten. Restoring the effect lets the existing loader run, and because the restored component stores the effect in its own originalEffects, repeated back/forward trips keep working. Since browsers cache module imports by URL, the module is reused rather than refetched.
Upgrade impact from v3.8.10
Nothing in your code changes — this is a pure bug fix. Three practical notes for the upgrade:
- Colocated
.jsfiles and SFC<script>blocks are a v4 feature, so this bug cannot bite you on v3; it is one more reason the v4 release you ship should include #10481. - If you have already been testing v4 and seen
$wire.$js.… is not a function(or mysteriously emptyx-textoutput) after back/forward navigation, this PR is both the explanation and the cure. - If you use the older
@script/@endscriptdirective, that channel was unaffected all along.
The new test, SupportJsModules\BrowserTest::test_script_module_survives_a_back_navigation, is worth a mention: it deliberately calls a $js action after coming back rather than asserting on text the module wrote before navigating away. Because wire:navigate caches the mutated HTML, that text is restored either way, and such a test would pass even if the module never loaded — a subtle trap the assertion sidesteps.
Takeaways
- Back/forward navigation no longer wipes a component's
$jsactions; thescriptModuleeffect now survives thewire:effectsrewrite, just asscriptsalways did. - Symptoms were
Uncaught TypeError: $wire.$js.… is not a function, emptyx-textoutput, andx-cloakcontent staying hidden — only after back/forward navigation. - No API, config, or migration changes; upgrade to a v4 release containing PR #10481.
- The fix repeats safely across multiple navigations and reuses the browser's module cache, so there is no refetch cost.