livewire/livewire: Fix: Slot content no longer disappears on lazy and deferred components (#10272)
Contributed by calebporzio
Part of the livewire v4.4.7 release
Pull request #10272 fixes a bug in Livewire v4 where slot content passed into a lazy or deferred component vanished the moment the component loaded. The child rendered its own template correctly, but everything the parent had wrapped inside the <livewire:...> tag came out empty. For anyone upgrading from v3.8.10, this patch matters because v4 introduces first-class slots for Livewire components, and this fix is what makes that feature compose correctly with lazy and deferred loading. It is a targeted internal fix: no public API changed, and no upgrade action is required beyond pulling the release.
The bug: slots render empty on lazy components
Here is the failing pattern from the issue:
<div>
<livewire:child lazy>
<h1>This disappears</h1>
</livewire:child>
</div>
with the child rendering:
<div id="child">
{{ $slot }}
</div>
The expected behavior: a placeholder appears immediately, then once __lazyLoad fires, the child renders with the <h1> inside. The actual behavior: the child appeared, but {{ $slot }} produced nothing. The same happened with defer.
Root cause: content lost between dehydrate and hydrate
On a normal mount, slot content flows through cleanly: the parent captures the nested HTML, withSlots() attaches it to the child, the child renders {{ $slot }}, and Slot::toHtml() outputs it wrapped in fragment markers.
Lazy loading short-circuits that flow. SupportLazyLoading::mount() calls skipMount() and skipRender() before the child ever renders, so the slot content is attached but never turned into output. At that point, SupportSlots::dehydrate() wrote only slot metadata into the component memo — the slot name, componentId, and parentId — not the HTML itself. Later, when __lazyLoad fired and the child hydrated, Livewire reconstructed empty PlaceholderSlot objects from that metadata. Placeholders are perfect for keeping morphing stable, but they hold no content, so {{ $slot }} rendered an empty string. The memo was never designed to carry HTML, and the content was lost in transit.
The fix: persist slot content for exactly one round trip
dehydrate() now checks whether the render was skipped. If so — meaning we are serializing the lazy placeholder snapshot — it stores the slot's content alongside the existing metadata:
if ($slot instanceof Slot && $this->storeGet('skipRender', false)) {
$entry['content'] = $slot->content;
}
On the hydrate side, memo entries are partitioned by whether they carry content. Entries with content are restored as full Slot objects through a new withHydratedSlots() method on HandlesSlots; entries without it still become PlaceholderSlots, exactly as before:
[$hydrated, $placeholders] = collect($memo['slots'] ?? [])
->partition(fn ($s) => isset($s['content']));
So when __lazyLoad triggers the child's first real render, {{ $slot }} has actual HTML to print. Crucially, this happens only once: on that render, the render is no longer skipped, so the next dehydration goes back to metadata-only, and the parent delivers fresh slot content through slotFragments as it does for every other re-render. The memo entry effectively goes from:
['name' => 'default', 'componentId' => 'abc123', 'parentId' => 'xyz789']
to:
['name' => 'default', 'componentId' => 'abc123', 'parentId' => 'xyz789', 'content' => '<h1>This disappears</h1>']
Three files changed: SupportSlots.php (conditional content persistence and partitioned hydration), HandlesSlots.php (the new withHydratedSlots() builder), and a new browser test, test_slot_content_is_preserved_on_lazy_component.
Why not solve this in JavaScript?
The alternative approach was to emit the slot HTML as a slotFragments effect on the lazy placeholder and handle it client-side — stashing the fragments until __lazyLoad morphs in the real DOM, then replaying them. That works, but it introduces a brand-new JS timing pattern (stash, wait for morph, retry) and renders slot content in two passes, which can flash empty before filling in.
This fix keeps everything in PHP inside the existing render cycle. The slot content hitches a ride in the snapshot for a single request, and after that the normal fragment machinery takes over. No JavaScript changes, no timing edge cases, no visual flicker.
Upgrade impact
- Coming from v3.8.10: v3 had no first-class slot mechanism for Livewire component tags, so there is no old behavior to migrate. If you previously passed data into lazy children via props instead, slots are now a viable — and working — option.
- If you hit this bug in a v4 pre-release and worked around it (passing props, conditionally rendering content after load, or dropping
lazy), you can revert to plain slot usage. - Payload note: the
__lazyLoadsnapshot now embeds slot HTML for that one request. You will only notice with very large slot bodies. - Non-lazy slot behavior is untouched; the placeholder path is preserved for normal child re-renders.
#[Defer]components get the same fix through the same code path.
Takeaways
- Fixed: slot content passed to
lazyordefercomponents no longer disappears after loading (#10272, fixes #10255). - Mechanism: slot HTML is persisted in the snapshot only while the render is skipped, then restored as full
Slotobjects on__lazyLoadhydration. - Scope: internal-only change (
SupportSlots,HandlesSlots); no public API or template changes. - Action required: none — combining slots with
#[Lazy]/#[Defer]now just works, and it is covered by a browser test.