Skip to content

[LiveComponent] Cache the attribute-method lookups per component class - #3777

Merged
Kocal merged 1 commit into
symfony:3.xfrom
Kocal:perf/twig-component-attribute-methods-cache
Aug 20, 2026
Merged

Kocal merged 1 commit into
symfony:3.xfrom
Kocal:perf/twig-component-attribute-methods-cache

Conversation

@Kocal

@Kocal Kocal commented Aug 15, 2026

Copy link
Copy Markdown
Member
Q A
Bug fix? no
New feature? no
Deprecations? no
Documentation? no
Issues -
License MIT

liveListeners(), preDehydrateMethods(), postHydrateMethods(), preReRenderMethods() and isActionAllowed() each rebuild a ReflectionClass and walk every public method of the component, on every render and every request, even though what they return only depends on the component class.

Memoize the resolved methods per class and attribute. An earlier revision of this branch put the cache in AsTwigComponent, where those helpers live, but they have no other consumer than AsLiveComponent, so the cache belongs here and TwigComponent is left untouched.

Timings use the call counts from the review: a page holding five LiveComponents calls liveListeners() and preDehydrateMethods() once per component on the initial render, and a single component update calls each of the five once.

initial render, 5 components   18.1 us -> 2.9 us
one component update            8.2 us -> 1.0 us

Sixteen microseconds off a page render isn't going to show up in a profile; the ratio is what it is because reflection simply isn't needed more than once per class.

Benchmarked from the repository root with symfony php bench.php:

<?php
require __DIR__.'/src/LiveComponent/vendor/autoload.php';

use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\Attribute\LiveListener;
use Symfony\UX\LiveComponent\Attribute\PostHydrate;
use Symfony\UX\LiveComponent\Attribute\PreDehydrate;
use Symfony\UX\LiveComponent\Attribute\PreReRender;

#[AsLiveComponent('Bench')]
class BenchComponent
{
    #[PreDehydrate] public function onPreDehydrate() {}
    #[PostHydrate] public function onPostHydrate() {}
    #[PreReRender] public function onPreReRender() {}
    #[LiveAction] public function save() {}
    #[LiveListener('foo:bar')] public function onFooBar() {}

    public function noise1() {} public function noise2() {} public function noise3() {}
    public function noise4() {} public function noise5() {} public function noise6() {}
}

$component = new BenchComponent();

$render = static function () use ($component) {
    for ($i = 0; $i < 5; ++$i) {
        AsLiveComponent::liveListeners($component);
        AsLiveComponent::preDehydrateMethods($component);
    }
};
$update = static function () use ($component) {
    AsLiveComponent::liveListeners($component);
    AsLiveComponent::preDehydrateMethods($component);
    AsLiveComponent::postHydrateMethods($component);
    AsLiveComponent::preReRenderMethods($component);
    AsLiveComponent::isActionAllowed($component, 'save');
};

foreach (['initial render' => $render, 'one update' => $update] as $label => $fn) {
    $fn();
    $start = hrtime(true);
    for ($i = 0; $i < 2000; ++$i) { $fn(); }
    printf("%-16s %6.1f us\n", $label, (hrtime(true) - $start) / 1e3 / 2000);
}

Blackfire:

Analysis, implementation and benchmarks by Claude Opus 5.

@Kocal
Kocal requested review from kbond and smnandre August 15, 2026 05:28
@Kocal Kocal self-assigned this Aug 15, 2026
@smnandre

Copy link
Copy Markdown
Member
// Mimics 20 requests for a page holding 20 live components.
for ($i = 0; $i < 20 * 20; ++$i) {

Not a very realistic mimic :)

Assuming 20 independent LiveComponents rendered once on the initial page:

Helper Initial render One component update
liveListeners() 20 1
preDehydrateMethods() 20 1
postHydrateMethods() 0 1
preReRenderMethods() 0 1
isActionAllowed() 0 1 for a custom LiveAction, otherwise 0

And I'm not sure to get the "n requests in parralel" is doing here.

But if there are some gain for 2/5 of them, why not 👍

Now, i'm not very happy we re-contaminate TwigComponent with code made for LiveComponent, and i'd rather we have these methods in the AsLiveComponent attribute directly if possible..

@Kocal
Kocal force-pushed the perf/twig-component-attribute-methods-cache branch 5 times, most recently from 48bed26 to 3297487 Compare August 16, 2026 12:23
@Kocal Kocal changed the title [TwigComponent] Cache attribute-method reflection lookups per class [LiveComponent] Cache the attribute-method lookups per component class Aug 16, 2026
@Kocal

Kocal commented Aug 16, 2026

Copy link
Copy Markdown
Member Author

Benchmarks updated with a more real-world usage, now only LiveComponent is modified

| Q              | A
| -------------- | ---
| Bug fix?       | no
| New feature?   | no
| Deprecations?  | no
| Documentation? | no
| Issues         | -
| License        | MIT

`liveListeners()`, `preDehydrateMethods()`, `postHydrateMethods()`,
`preReRenderMethods()` and `isActionAllowed()` each rebuild a
`ReflectionClass` and walk every public method of the component, on every
render and every request. What they return only depends on the component
class.

Memoize the resolved methods per class and attribute. An earlier revision of
this branch put the cache in `AsTwigComponent`, where those helpers live, but
they have no other consumer than `AsLiveComponent`, so the cache belongs here
and TwigComponent is left untouched.

Timings use the call counts from the review: a page holding five
LiveComponents calls `liveListeners()` and `preDehydrateMethods()` once per
component on the initial render, and a single component update calls each of
the five once.

    initial render, 5 components   18.1 us -> 2.9 us
    one component update            8.2 us -> 1.0 us

Sixteen microseconds off a page render is not going to show up in a profile;
the ratio is what it is because reflection is simply not needed more than
once per class.

Benchmarked from the repository root with `symfony php bench.php`:

```php
<?php
require __DIR__.'/src/LiveComponent/vendor/autoload.php';

use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\Attribute\LiveListener;
use Symfony\UX\LiveComponent\Attribute\PostHydrate;
use Symfony\UX\LiveComponent\Attribute\PreDehydrate;
use Symfony\UX\LiveComponent\Attribute\PreReRender;

#[AsLiveComponent('Bench')]
class BenchComponent
{
    #[PreDehydrate] public function onPreDehydrate() {}
    #[PostHydrate] public function onPostHydrate() {}
    #[PreReRender] public function onPreReRender() {}
    #[LiveAction] public function save() {}
    #[LiveListener('foo:bar')] public function onFooBar() {}

    public function noise1() {} public function noise2() {} public function noise3() {}
    public function noise4() {} public function noise5() {} public function noise6() {}
}

$component = new BenchComponent();

$render = static function () use ($component) {
    for ($i = 0; $i < 5; ++$i) {
        AsLiveComponent::liveListeners($component);
        AsLiveComponent::preDehydrateMethods($component);
    }
};
$update = static function () use ($component) {
    AsLiveComponent::liveListeners($component);
    AsLiveComponent::preDehydrateMethods($component);
    AsLiveComponent::postHydrateMethods($component);
    AsLiveComponent::preReRenderMethods($component);
    AsLiveComponent::isActionAllowed($component, 'save');
};

foreach (['initial render' => $render, 'one update' => $update] as $label => $fn) {
    $fn();
    $start = hrtime(true);
    for ($i = 0; $i < 2000; ++$i) { $fn(); }
    printf("%-16s %6.1f us\n", $label, (hrtime(true) - $start) / 1e3 / 2000);
}
```

Analysis, implementation and benchmarks by Claude Opus 5.
@Kocal
Kocal force-pushed the perf/twig-component-attribute-methods-cache branch from 3297487 to 1d886b3 Compare August 20, 2026 07:07
@Kocal
Kocal merged commit 4d84b54 into symfony:3.x Aug 20, 2026
34 checks passed
@Kocal
Kocal deleted the perf/twig-component-attribute-methods-cache branch August 20, 2026 07:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants