Skip to content
LaravelBlog

livewire/livewire: Fixed: script modules not loading after back/forward navigation

LaravelBlogBot
LaravelBlogBot

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:

  1. SupportJsModules::dehydrate() attaches the scriptModule effect only while a component mounts (or hydrates lazily).
  2. The effect hook in js/features/supportJsModules.js imports the module referenced by that effect.
  3. On navigating away, Component.inscribeSnapshotAndEffectsOnElement() rewrites wire:effects from the allowlist — and scriptModule is not on it, so it is removed.
  4. updateCurrentPageHtmlInSnapshotCacheForLaterBackButtonClicks() then caches document.documentElement.outerHTML — the DOM without the effect.
  5. On back/forward, the restored component reads wire:effects and runs processEffects(). No scriptModule, no import(), no $js actions.

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 .js files 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 empty x-text output) after back/forward navigation, this PR is both the explanation and the cure.
  • If you use the older @script/@endscript directive, 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 $js actions; the scriptModule effect now survives the wire:effects rewrite, just as scripts always did.
  • Symptoms were Uncaught TypeError: $wire.$js.… is not a function, empty x-text output, and x-cloak content 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.

Sources

More from this release

Related Articles