ComponentFactory::make() declares a new anonymous class on every call (memory leak)
まだ誰も着手していません。
評価
- 難易度
- 3/5
- 見積もり時間
- 1〜2日
- 初心者へのやさしさ
- 72/100
- issue の種類
- バグ
- 明瞭さ
- 明確に書かれている
- 活発さ
- 静か
- 技術スタック
- php
- 領域
- backend, performance
調査の方向性
src/ComponentFactory.php から始め、requirePath() とコンパイル済みファイルの require を通る make() の両方の経路を追跡します。VoltLeakTest シナリオで問題を再現し、繰り返しレンダリングした場合と両方の API で宣言されたクラスを確認します。変更されていないコンポーネントを繰り返し解決しても、レンダリング中に別の匿名クラスが宣言されず、レンダリングが引き続き正常に動作し、コンポーネントへの変更が検出されれば完了です。
索引モデルが issue の本文から書いたものです。
説明
Volt Version
1.10.5 — src/ComponentFactory.php is byte-identical in v1.11.2, so this is unfixed on the current release
Laravel Version
13.24.0 (livewire/livewire 3.7.15, pestphp/pest 4.4.5 / PHPUnit 12.5.16)
PHP Version
8.4.16, no OPcache in CLI
Database Driver & Version
SQLite :memory: (not relevant to the issue)
Description
ComponentFactory::make() re-requires the component file on every call, so every call declares a new anonymous class. PHP never frees class entries, and each leaked class pins a CompileContext through its static $__context. Anything that resolves the same Volt component more than once in a single PHP process accumulates them.
In src/ComponentFactory.php both branches use a plain require, so both the class API and the functional API are affected:
// class API — requires the .blade.php source, which contains `new class extends Component`
$this->requirePath(CompileContext::instance()->path = $path); // -> require $__path;
// functional API — requires the compiled file, which also contains `new class extends Component`
require $file->path();
Livewire does cache the resolution, but in Livewire\Mechanisms\ComponentRegistry::$aliases — an instance property of a container singleton. A new container means a cold cache, which means another make(), which means another class.
The most visible victim is the test suite, because Laravel builds a fresh container per test: every test leaks one class per Volt component it renders.
Measured impact
A private application with 108 Volt components and a ~4,000-test Feature suite (serial, single process):
| peak memory | declared classes | |
|---|---|---|
| as-is | 509 MB | 6,193 |
with make() memoized |
405 MB | 4,749 |
1,552 leaked anonymous Volt classes, ~110 MB — about 22 % of the suite's peak memory. The suite had started dying with Allowed memory size of 536870912 bytes exhausted at seemingly random points, which is how we found this: the process sits right at the limit, so the failure point moves with test order and no single test is at fault.
For the measurement I pre-declared a Livewire\Volt\ComponentFactory identical to the shipped one plus a process-wide memo keyed on $componentName.'|'.$path. Nothing else changed, all tests stayed green.
Suggested fix
Memoize the resolved class name in ComponentFactory:
protected static array $resolved = [];
public function make(string $componentName, string $path): string
{
$key = $componentName.'|'.$path.'|'.@filemtime($path);
return static::$resolved[$key] ??= $this->makeFresh($componentName, $path);
}
Including filemtime($path) in the key keeps local development working: editing a component still yields a fresh class, it just stops re-declaring one for an unchanged file.
Note that swapping require for require_once is not sufficient on its own — on the second call the file would not execute, static::$latestCreatedComponentClass would stay null, and make() would fall through (class API) or return null (functional API). The class name has to be remembered explicitly.
One open question
The same reasoning should apply to any long-running process that resolves Volt components against more than one container instance — Octane sandboxes the container per request, so a ComponentRegistry resolved inside the sandbox would start cold on every request. I have only measured the test-suite case and have not confirmed that, but it seemed worth flagging.
Steps To Reproduce
- Create
resources/views/livewire/counter.blade.php:
<?php
use Livewire\Volt\Component;
new class extends Component
{
public int $count = 0;
public function increment(): void
{
$this->count++;
}
}; ?>
<div>
<span>{{ $count }}</span>
<button wire:click="increment">+</button>
</div>
- Add a test that renders it repeatedly — one PHP process, a fresh container per test:
use Livewire\Volt\Volt;
use PHPUnit\Framework\Attributes\DataProvider;
final class VoltLeakTest extends \Tests\TestCase
{
public static function iterations(): array
{
return array_map(fn (int $i) => [$i], range(1, 20));
}
#[DataProvider('iterations')]
public function test_rendering_the_same_component_declares_a_new_class_every_time(int $i): void
{
$before = get_declared_classes();
Volt::test('counter')->assertSee('0');
$new = array_values(array_diff(get_declared_classes(), $before));
fwrite(STDERR, sprintf("test %2d: +%d class | %s\n", $i, count($new), implode(', ', $new) ?: '-'));
$this->assertTrue(true);
}
}
- Run it. Every test after the first declares exactly one more anonymous class for the same unchanged component (paths shortened):
test 2: +1 class | Livewire\Volt\Component@anonymous .../views/counter.blade.php:5$2fe
test 3: +1 class | Livewire\Volt\Component@anonymous .../views/counter.blade.php:5$2ff
test 4: +1 class | Livewire\Volt\Component@anonymous .../views/counter.blade.php:5$300
...
test 19: +1 class | Livewire\Volt\Component@anonymous .../views/counter.blade.php:5$30f
test 20: +1 class | Livewire\Volt\Component@anonymous .../views/counter.blade.php:5$310
One component, one identical render, a fresh class every single time.
- 主要言語
- PHP
- スター
- 422
- フォーク
- 35
- PR マージ指標
- 30日以内にマージされた PR はありません
環境構築
- Dockerfile・Docker Compose ファイルなし
- プルリクエストのテンプレートあり
- コントリビューションガイドを読む
はじめの一歩
- issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
- 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
- リポジトリをフォークし、ブランチを切って変更します。
- issue 番号を参照したプルリクエストを送ります。
livewire/volt のほかの issue
-
難易度 3/5 1〜2日 初心者へのやさしさ 48/100
-
難易度 3/5 1〜2日 初心者へのやさしさ 45/100
-
難易度 4/5 3〜5日 初心者へのやさしさ 35/100
-
難易度 5/5 1週間以上 初心者へのやさしさ 38/100
-
難易度 4/5 3〜5日 初心者へのやさしさ 35/100
似ている issue
-
sync-en
難易度 2/5 1〜3時間 初心者へのやさしさ 76/100
メンテナーはふだん 1 日以内に返信
-
sync-en
難易度 2/5 1〜3時間 初心者へのやさしさ 72/100
メンテナーはふだん 3 日以内に返信
-
Перевод устарел
難易度 2/5 1〜3時間 初心者へのやさしさ 72/100
-
難易度 2/5 1〜3時間 初心者へのやさしさ 72/100
-
新版升级一下对应的依赖可以么オープン
難易度 2/5 1〜3時間 初心者へのやさしさ 62/100