Repository navigation
Conversation
…live components
| Q | A
| -------------- | ---
| Bug fix? | no
| New feature? | no
| Deprecations? | no
| Documentation? | no
| Issues |
| License | MIT
LiveComponent listens to the TwigComponent events with 7 listeners, and 5 of them only act on live components. They still run for every component, live or not.
Their presence also turns off two fast paths of TwigComponent for every component: mounting an anonymous component directly, and building its render variables without a copy. An admin dashboard rendering 1,860 components, 28 of them live, pays this cost on each of them.
A listener can now declare, with the internal `ux.twig_component.scoped_listener` tag, that it only acts on the components whose metadata holds a given key. When the container is built, TwigComponent finds the events whose listeners all share such a scope. It dispatches these events only for the components in that scope. Any other listener of such an event, registered in the container or added at runtime, brings back the dispatch for every component, in the same order as before. Applications listening to these events themselves see no change.
LiveComponent tags the 4 services whose listeners only act on live components, so `PreCreateForRenderEvent`, `PostMountEvent` and `PreRenderEvent` are no longer dispatched for the other components. `DataModelPropsSubscriber` (`PreMountEvent`) also handles the components rendered inside a live one, and `ResetDeterministicIdSubscriber` (`PostRenderEvent`) runs after every top-level component, so they stay unscoped. An older TwigComponent ignores the tag and an older LiveComponent sets none, so every combination of versions keeps working as today.
- standalone benchmark below (100 renders of 333 nested anonymous components with LiveComponent installed, 3 interleaved runs each side, identical output): **399-459 ms** -> **358-362 ms** (-10%)
- admin dashboard, CPU median per request (4 interleaved runs of 20 requests each side, identical HTML): the page goes from **61.4-69.3 ms** to **55.3-57.2 ms** (-9%), the page with 100 rows from **147.8-152.7 ms** to **135.5-140.4 ms** (-8%); re-rendering its live posts table goes from **45.8-47.2 ms** to **28.6-33.1 ms** after ticking rows (-37%), and from **50.6-52.8 ms** to **33.1-34.2 ms** after typing in its search box (-35%)
- Blackfire, 5 requests for the dashboard: listener calls **46,525** -> **19,045**, `AddLiveAttributesSubscriber::onPreRender()` calls **9,300** -> **140**, wall time **3.28 s** -> **2.85 s**
The benchmark needs the TwigComponent of this branch in `vendor/symfony/ux-twig-component`.
```php
<?php
// Run from src/LiveComponent: php bench.php
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Bundle\TwigBundle\TwigBundle;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
use Symfony\Component\HttpKernel\Kernel;
use Symfony\UX\LiveComponent\LiveComponentBundle;
use Symfony\UX\StimulusBundle\StimulusBundle;
use Symfony\UX\TwigComponent\TwigComponentBundle;
require getcwd().'/vendor/autoload.php';
$dir = sys_get_temp_dir().'/ux-live-component-bench-'.bin2hex(random_bytes(4));
mkdir($dir.'/templates/components', 0777, true);
foreach (['Card' => 'section', 'Table' => 'table', 'Row' => 'tr', 'Cell' => 'td'] as $name => $tag) {
file_put_contents("$dir/templates/components/$name.html.twig", "<$tag {{ attributes }}>{% block content %}{% endblock %}</$tag>");
}
file_put_contents($dir.'/templates/components/Badge.html.twig', <<<'TWIG'
{% props label %}
<span {{ attributes.defaults({class: 'badge'}) }}>{{ label }}</span>
TWIG);
// A card holding a table of 25 rows of 6 cells, each cell holding a badge: 333 nested components, none of them live
file_put_contents($dir.'/templates/page.html.twig', <<<'TWIG'
<twig:Card class="card"><twig:Table>
{% for row in 1..25 %}<twig:Row>
{% for col in 1..6 %}<twig:Cell class="cell-{{ col }}"><twig:Badge label="{{ row }}-{{ col }}" /></twig:Cell>{% endfor %}
</twig:Row>{% endfor %}
</twig:Table></twig:Card>
TWIG);
$kernel = new class('prod', false, $dir) extends Kernel {
use MicroKernelTrait;
public function __construct(string $environment, bool $debug, private string $dir)
{
parent::__construct($environment, $debug);
}
public function registerBundles(): iterable
{
yield new FrameworkBundle();
yield new TwigBundle();
yield new TwigComponentBundle();
yield new StimulusBundle();
yield new LiveComponentBundle();
}
public function getProjectDir(): string
{
return $this->dir;
}
protected function configureContainer(ContainerConfigurator $container): void
{
$container->extension('framework', ['secret' => 'S3CRET', 'test' => true, 'http_method_override' => false]);
$container->extension('twig_component', ['anonymous_template_directory' => 'components', 'defaults' => []]);
}
};
$kernel->boot();
$twig = $kernel->getContainer()->get('test.service_container')->get('twig');
// A controller renders its template some 30 frames deep (kernel, listeners, controller...)
$deep = static function (int $depth, callable $fn) use (&$deep) {
return $depth > 0 ? $deep($depth - 1, $fn) : $fn();
};
$html = $deep(30, static fn () => $twig->render('page.html.twig'));
$times = [];
for ($run = 0; $run < 7; ++$run) {
$start = hrtime(true);
for ($i = 0; $i < 100; ++$i) {
$deep(30, static fn () => $twig->render('page.html.twig'));
}
$times[] = (hrtime(true) - $start) / 1e6;
}
sort($times);
printf("100 renders of 333 nested components, LiveComponent installed: median %.0f ms (min %.0f, max %.0f) | sha1 %s\n", $times[3], $times[0], $times[6], sha1($html));
```
Blackfire profiles (dashboard, 5 requests):
- before: https://app.blackfire.io/envs/5f4f9a62-eaa0-45ee-b7b3-a1b879f550e9/profiles/71d46d56-de4d-4c6a-8e83-7137fcbe4516/graph
- after: https://app.blackfire.io/envs/5f4f9a62-eaa0-45ee-b7b3-a1b879f550e9/profiles/5f0d7f80-16e6-42a5-9ac2-96f6e1e84eec/graph
- comparison: https://app.blackfire.io/envs/5f4f9a62-eaa0-45ee-b7b3-a1b879f550e9/profiles/compare/71d46d56-de4d-4c6a-8e83-7137fcbe4516...5f0d7f80-16e6-42a5-9ac2-96f6e1e84eec/graph
The dashboard is available at https://github.com/Kocal/sf-ux-perfs-twig-components.
Kocal
added a commit
that referenced
this pull request
Oct 15, 2026
…ctor test | Q | A | -------------- | --- | Bug fix? | no | New feature? | no | Deprecations? | no | Documentation? | no | Issues | | License | MIT `TwigComponentDataCollectorTest::testLateCollectWithCollectedData()` expects a positive render time between a `PreRenderEvent` and a `PostRenderEvent` logged back to back. Both events get their time from `microtime(true)`, which often returns the same value for two calls that close. Repeating the sequence of the test 10,000 times gives a render time of `0.0` in 7,472 runs. The test now waits 1 µs between the two events, and the same 10,000 runs never give `0.0`. The failure showed up at random in the TwigComponent suite while working on #4107, in 4 of 6 full runs.
Kocal
added a commit
that referenced
this pull request
Oct 15, 2026
… data collector test (Kocal) This PR was merged into the 3.x branch. Discussion ---------- [TwigComponent] Fix the flaky render time assertion of the data collector test | Q | A | -------------- | --- | Bug fix? | no | New feature? | no | Deprecations? | no | Documentation? | no | Issues | | License | MIT `TwigComponentDataCollectorTest::testLateCollectWithCollectedData()` expects a positive render time between a `PreRenderEvent` and a `PostRenderEvent` logged back to back. Both events get their time from `microtime(true)`, which often returns the same value for two calls that close. Repeating the sequence of the test 10,000 times gives a render time of `0.0` in 7,472 runs. The test now waits 1 µs between the two events, and the same 10,000 runs never give `0.0`. The failure showed up at random in the TwigComponent suite while working on #4107, in 4 of 6 full runs. Commits ------- cb49547 [TwigComponent] Fix the flaky render time assertion of the data collector test
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
LiveComponent listens to the TwigComponent events with 7 listeners, and 5 of them only act on live components. They still run for every component, live or not.
Their presence also turns off two fast paths of TwigComponent for every component: mounting an anonymous component directly, and building its render variables without a copy. An admin dashboard rendering 1,860 components, 28 of them live, pays this cost on each of them.
A listener can now declare, with the internal
ux.twig_component.scoped_listenertag, that it only acts on the components whose metadata holds a given key. When the container is built, TwigComponent finds the events whose listeners all share such a scope. It dispatches these events only for the components in that scope. Any other listener of such an event, registered in the container or added at runtime, brings back the dispatch for every component, in the same order as before. Applications listening to these events themselves see no change.LiveComponent tags the 4 services whose listeners only act on live components, so
PreCreateForRenderEvent,PostMountEventandPreRenderEventare no longer dispatched for the other components.DataModelPropsSubscriber(PreMountEvent) also handles the components rendered inside a live one, andResetDeterministicIdSubscriber(PostRenderEvent) runs after every top-level component, so they stay unscoped. An older TwigComponent ignores the tag and an older LiveComponent sets none, so every combination of versions keeps working as today.AddLiveAttributesSubscriber::onPreRender()calls 9,300 -> 140, wall time 3.28 s -> 2.85 sThe benchmark needs the TwigComponent of this branch in
vendor/symfony/ux-twig-component.Blackfire profiles (dashboard, 5 requests):
The dashboard is available at https://github.com/Kocal/sf-ux-perfs-twig-components.