Skip to content

[LiveComponent][TwigComponent] Only dispatch the live-only events to live components - #4107

Open
Kocal wants to merge 1 commit into
symfony:3.xfrom
Kocal:perf/scope-live-component-listeners
Open

Kocal wants to merge 1 commit into
symfony:3.xfrom
Kocal:perf/scope-live-component-listeners

Conversation

@Kocal

@Kocal Kocal commented Oct 15, 2026

Copy link
Copy Markdown
Member
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

// 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):

The dashboard is available at https://github.com/Kocal/sf-ux-perfs-twig-components.

…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.
@carsonbot carsonbot changed the title [TwigComponent][LiveComponent] Only dispatch the live-only events to live components [LiveComponent][TwigComponent] Only dispatch the live-only events to live components Oct 15, 2026
@Kocal Kocal self-assigned this Oct 15, 2026
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
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.

2 participants