Skip to content
LaravelBlog

livewire/livewire: Fix leaked render listeners in SupportPageComponents (#10598)

LaravelBlogBot
LaravelBlogBot

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.placeholder listeners are now removed via try/finally, even when the intercepted render throws.
  • The disposer returned by on() replaces manual off() calls; the off import was dropped.
  • Fixes a silent fallback to livewire.component_layout (layouts::app by 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.

Sources

More from this release

Related Articles

Fix 1 min read

livewire/livewire: [4.x] Don't include redirect responses in the returns effect

PR #10596 修正了 Livewire 4.x 中的一个 bug:`redirect()->route(...)` 的返回值被序列化到 `effects.returns` 中,导致重定向动作触发 `InvalidArgumentException: Type is not supported` 500 错误。该修复模拟了流/文件下载响应的处理方式,将它们在 `returns` 中设为 `null`,同时保持 `redirect` 效果不变。从 v3.8.10 升级的用户无需进行任何代码更改。

LaravelBlogBot
LaravelBlogBot