Skip to content
LaravelBlog

livewire/livewire: Fix: lazy islands no longer stall behind reactive/modelable children (#10650)

LaravelBlogBot
LaravelBlogBot

Contributed by ghabriel25

Part of the livewire v4.4.7 release

If you're moving up to the v4 line from v3.8.10, islands are one of the headline features waiting for you — and one early composition had a nasty edge case: render two or more visible lazy islands alongside a nested child marked #[Reactive] or #[Modelable], and only the first island would ever hydrate. The rest would sit on their loading placeholders forever. Pull request #10650 fixes this with a one-line guard in the JavaScript request layer. It restores independent hydration for concurrent lazy islands, fixes the same stall for island polls and island-scoped actions, and leaves normal reactive/modelable behavior completely untouched.

What was broken

Here's the failing setup, straight from the minimized reproduction in #10644:

@island(name: 'one', lazy: true, always: true)
    @placeholder <div>Loading one...</div> @endplaceholder
    <div>One</div>
@endisland

@island(name: 'two', lazy: true, always: true)
    @placeholder <div>Loading two...</div> @endplaceholder
    <div>Two</div>
@endisland

<livewire:nested :$editing /> {{-- child with a #[Reactive] prop --}}

Nothing exotic here: two always-visible lazy islands and one nested child bound with :$editing. Remove the nested child and both islands hydrate fine. With it, only island one mounts; island two shows Loading two... indefinitely. The same stall occurred with a wire:model.live child declaring #[Modelable]. This was a regression introduced by #10529, not a long-standing island limitation.

Why it happened

The root cause lives in how the client batches messages, and it's worth walking through step by step:

  1. Each visible lazy island fires its own __lazyLoadIsland action. Island actions are scoped per-island, so islands one and two produce two separate pending messages for the same component.
  2. The reactive/modelable partition interceptor in js/request/interactions.js bundles a child $commit into any outgoing message whose component has bound children. Island one's message compiles into a request alongside the child commit; island two's message then bundles the same child message, and the merge logic in compileRequest pulls island two into island one's request.
  3. That yields one request containing two messages for the same component. The response handler matches payloads to messages by component id, so each island message processes both payloads — and under the sequential island morph handling introduced in #10529, the second island's hydration never lands.
  4. Worse, the bundled child commit was pure waste for island-only messages anyway. Island calls skip the parent's full render on the server — SupportIslands::call() renders only the island, and __lazyLoadIsland returns early before touching component state — so the child stub is never re-rendered and its reactive props could never receive new values from the bundle.

In short: an optimization meant to keep reactive children in sync was coupling concurrent island messages together for no benefit.

The fix

The PR considers three approaches. Narrowing the skip to __lazyLoadIsland calls only would leave the stall reachable through any two concurrent island-scoped messages (two polling islands, for example). Reworking the compileRequest merge logic would address the collision at its root but is more invasive. The chosen fix skips child bundling for island-only messages entirely, reusing the existing Message.hasActionForComponent() helper:

// js/request/interactions.js
interceptPartition(({ message, compileRequest }) => {
    // Island-only messages never trigger a full parent render on the server,
    // so bound children can't receive fresh props from them. Bundling would
    // only couple concurrent island requests together (stalling, for example,
    // the second of two visible lazy island loads)...
    if (! message.hasActionForComponent()) return

    let component = message.component
    // ...existing child-bundling logic, unchanged
})

Because the guard applies to any message without a component action — not just lazy loads — it fixes the whole class of stalls: concurrent lazy hydration, island polls, and island-scoped actions alike.

What this means for your upgrade

Since islands don't exist in v3, this bug can't affect your v3.8.10 app — but if you've adopted early v4 releases, this patch un-breaks one of the most natural island compositions. After upgrading you get:

  • Multiple visible lazy islands hydrate independently again, even with reactive or modelable children present.
  • Normal component updates unchanged. Regular (non-island) updates still bundle reactive/modelable children exactly as before; the PR adds browser tests asserting the toggle and typing sync flows still work.
  • Island ordering behavior untouched.

One subtle behavioral note: actions fired from inside an island no longer sync reactive children in the same request. For children rendered outside islands, that sync was always a no-op render — the parent template never re-renders during an island request, so the child could never receive new prop values. The one case that genuinely loses live syncing is a reactive child nested inside an island, which is consistent with islands' scoped-render semantics and was previously untested. If you relied on it, trigger a regular component update instead.

Takeaways

  • Fixed: multiple visible lazy islands now hydrate even when a #[Reactive] or #[Modelable] child is rendered alongside them (regression from #10529).
  • Root cause: child $commit bundling coupled concurrent island messages for the same component into a single request, where duplicate messages stalled hydration.
  • Fix: island-only messages skip reactive/modelable child bundling — this also protects island polls and island-scoped actions.
  • Edge case: a reactive child rendered inside an island no longer receives live prop syncs from actions in that island; use a full component update if you need that.
  • No action required on upgrade — normal updates and island ordering behave exactly as before.

Sources

More from this release

Related Articles