livewire / livewire/volt

ComponentFactory::make() declares a new anonymous class on every call (memory leak)

Open
#157 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
PHP
Stars
423
Forks
36
PR merge metrics
No merged PRs in 30d

Description

### 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-`require`s 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`](https://github.com/livewire/volt/blob/v1.11.2/src/ComponentFactory.php) both branches use a plain `require`, so both the class API and the functional API are affected:

```php
// 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`:

```php
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

1. Create `resources/views/livewire/counter.blade.php`:

```php
count++;
}
}; ?>


{{ $count }}
+

```

2. Add a test that renders it repeatedly — one PHP process, a fresh container per test:

```php
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);
}
}
```

3. 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.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with src/ComponentFactory.php and trace both make() paths through requirePath() and the compiled-file require. Reproduce the issue with the VoltLeakTest scenario, checking declared classes across repeated renders and both APIs. Done means repeated resolution of an unchanged component no longer declares another anonymous class while rendering still works and changes to the component are detected.

Written by the indexing model from the issue text.

Assessment

Tech stack
php
Domain
backend, performance
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
72/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.