livewire/livewire: Fix leaked render listeners in SupportPageComponents (#10598)
Contributed by ghabriel25
Part of the livewire v4.4.7 release
Pull request #10598 patches a subtle listener leak in Livewire's page-component machinery. When Livewire renders a full-page component, it briefly subscribes to the render and render.placeholder events to capture the component's layout configuration. Those temporary listeners were only removed when the render path completed successfully — so an exception, or a render that was skipped entirely, left them registered on the event bus. From there, later requests could silently fall back to the default layout instead of the one the component asked for. The fix guarantees cleanup with a try/finally, and it ships with regression tests. If you're upgrading from v3.8.10, this lands as part of the 4.x line and requires no changes in your application code.
What changed in #10598
The affected method is SupportPageComponents::interceptTheRenderOfTheComponentAndRetreiveTheLayoutConfiguration(). It registers a temporary handler on the event bus, runs a callback that triggers the component's render, and then removes the handler again. Before this PR, that removal only happened on the happy path:
on('render', $handler);
on('render.placeholder', $handler);
$callback();
off('render', $handler);
off('render.placeholder', $handler);
After this PR, the code relies on the fact that on() returns a disposer closure, and wraps the callback in try/finally:
$render = on('render', $handler);
$renderPlaceholder = on('render.placeholder', $handler);
try {
$callback();
} finally {
$render();
$renderPlaceholder();
}
The finally block runs whether the callback succeeds, throws, or unwinds for any other reason, so the listeners are always detached. The off import is gone from the file, and the interception flow itself — what the handler captures and how the layout config is returned — is completely unchanged.
The bug: render listeners that outlived their request
There were two realistic paths that skipped the cleanup. First, the $callback() could throw — any exception raised while resolving or rendering the page component left the listeners dangling. Second, the render path could be skipped entirely, for example via shouldSkipRender or a mount that short-circuits before anything renders. In both cases, the temporary handlers stayed registered on the EventBus, which is resolved from the container as a singleton — meaning leaked listeners are not guaranteed to die with the failed request in long-running setups.
The handler itself is wrapped in once(), so once it is spent it becomes a no-op. On a later request that does reach the page-component path, that stale entry contributes nothing, and the interception ends with a null layout config. The caller then falls back:
$layoutConfig = intercept(...) ?: new PageComponentConfig;
PageComponentConfig reads config('livewire.component_layout'), which defaults to layouts::app. In other words: a failure in one request could quietly change which layout wraps your page components in a later one, with no exception pointing at the cause. This matches the behavior reported in issue #10597.
What this looks like in your app
Consider a standard full-page component with an explicit layout:
use Livewire\Attributes\Layout;
use Livewire\Component;
class Dashboard extends Component
{
public function mount()
{
abort_unless(auth()->user()?->can('viewDashboard'), 403);
}
#[Layout('layouts.panel')]
public function render()
{
return view('livewire.dashboard');
}
}
If a request to this route blew up before render completed — or render was skipped — the interceptor's listeners could remain behind. A subsequent visit might then render inside the default layout from your config instead of layouts.panel:
// config/livewire.php
'component_layout' => 'layouts::panel', // your intended fallback
The symptom is maddeningly indirect: the component's layout attribute appears to be ignored, but only sometimes, and usually after an error page. After this fix, the interception always cleans up after itself, so the layout captured on each request is the one that request's component actually declared.
Upgrade impact
This is an internal-only fix — there are no public API changes, and the success-path behavior is byte-for-byte identical. Two regression tests were added to lock it in: one verifies that the render and render.placeholder listener lists on the EventBus are unchanged after the callback throws, and another verifies the fallback honors livewire.component_layout when no layout is captured.
- If you never hit the exception or skipped-render path, you'll notice nothing.
- If you saw mysterious default-layout renders after error pages or skipped renders — particularly under long-running workers like Octane — upgrading should resolve it.
- If you're coming from v3.8.10, take this as part of the normal 4.x upgrade; there's no migration step specific to this change.
Takeaways
- Temporary
render/render.placeholderlisteners are now removed viatry/finally, even when the intercepted render throws. - The disposer returned by
on()replaces manualoff()calls; theoffimport was dropped. - Fixes a silent fallback to
livewire.component_layout(layouts::appby default) after failed or skipped renders. - Two regression tests cover both the cleanup and the fallback behavior.
- No public API changes — safe to include with the rest of your 4.x upgrade.