Skip to content

[TwigComponent] Skip mount hooks on anonymous components when nobody listens to mount events - #4048

Merged
Kocal merged 1 commit into
symfony:3.xfrom
Kocal:perf/twig-component-per-render-overhead
Oct 9, 2026
Merged

Kocal merged 1 commit into
symfony:3.xfrom
Kocal:perf/twig-component-per-render-overhead

Conversation

@Kocal

@Kocal Kocal commented Oct 7, 2026 •

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

Every component goes through ComponentFactory::mountFromObject(), which calls preMount(), mount() and postMount(), each checking for listeners and hooks, reads a few ComponentMetadata accessors that go through get(), and fetches the escaper with Environment::getRuntime(). Anonymous components, which UX Toolkit kits are made of, have no hooks and no properties to set, so when nobody listens to PreMountEvent and PostMountEvent, none of that work can change anything. It costs about 0.4 µs per component.

  • anonymous components are mounted directly when nobody listens to the mount events; the PreMountEvent listener check is reused by preMount(), so the regular path, taken for instance when LiveComponent is installed, does not check twice

  • ComponentMetadata accessors read the configuration directly instead of going through get()

  • ComponentFactory keeps the EscaperRuntime instead of fetching it for every component

  • standalone benchmark below (300 anonymous components, 100 renders, 4 interleaved runs each, identical output): 293-296 ms before, 274-277 ms after (-6.5%)

  • an admin dashboard built with the Shadcn kit, 1,767 components per request: preMount() and postMount() go from 1,767 calls per request to 0; Blackfire (5 samples of 5 requests) goes from 2.48 s to 2.37 s wall time (-4.4%); raw CPU median per request goes from 48.2-50.4 ms to 46.5-49.0 ms (noisy machine, 6 interleaved runs of 60 requests each side)

<?php

// Run from src/TwigComponent: 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\TwigComponent\TwigComponentBundle;

require getcwd().'/vendor/autoload.php';

$dir = sys_get_temp_dir().'/ux-twig-component-bench-'.bin2hex(random_bytes(4));
mkdir($dir.'/templates/components', 0777, true);
file_put_contents($dir.'/templates/components/Cell.html.twig', <<<'TWIG'
    {% props variant = 'default' %}
    <td data-variant="{{ variant }}" {{ attributes.defaults({class: 'p-2 align-middle'}) }}>{% block content %}{% endblock %}</td>
    TWIG);
file_put_contents($dir.'/templates/components/Badge.html.twig', <<<'TWIG'
    {% props label %}
    <span {{ attributes.defaults({class: 'badge'}) }}>{{ label }}</span>
    TWIG);
// 25 rows of 6 embedded cells, each holding a self-closing badge: 300 components
file_put_contents($dir.'/templates/table.html.twig', <<<'TWIG'
    {% for row in 1..25 %}<tr>
        {% for col in 1..6 %}<twig:Cell variant="{{ col is odd ? 'odd' : 'even' }}" class="cell-{{ col }}"><twig:Badge label="{{ row }}-{{ col }}" data-row="{{ row }}" /></twig:Cell>{% endfor %}
    </tr>{% endfor %}
    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();
    }

    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');
$html = $twig->render('table.html.twig');

$times = [];
for ($run = 0; $run < 7; ++$run) {
    $start = hrtime(true);
    for ($i = 0; $i < 100; ++$i) {
        $twig->render('table.html.twig');
    }
    $times[] = (hrtime(true) - $start) / 1e6;
}
sort($times);
printf("100 renders of 300 components: median %.0f ms (min %.0f, max %.0f) | sha1 %s\n", $times[3], $times[0], $times[6], sha1($html));

Blackfire profiles (dashboard page):

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

@Kocal Kocal self-assigned this Oct 7, 2026
…listens to mount events

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

Every component goes through `ComponentFactory::mountFromObject()`, which calls `preMount()`, `mount()` and `postMount()`, each checking for listeners and hooks, reads a few `ComponentMetadata` accessors that go through `get()`, and fetches the escaper with `Environment::getRuntime()`. Anonymous components, which UX Toolkit kits are made of, have no hooks and no properties to set, so when nobody listens to `PreMountEvent` and `PostMountEvent`, none of that work can change anything. It costs about 0.4 µs per component.

- anonymous components are mounted directly when nobody listens to the mount events; the `PreMountEvent` listener check is reused by `preMount()`, so the regular path, taken for instance when LiveComponent is installed, does not check twice
- `ComponentMetadata` accessors read the configuration directly instead of going through `get()`
- `ComponentFactory` keeps the `EscaperRuntime` instead of fetching it for every component

- standalone benchmark below (300 anonymous components, 100 renders, 4 interleaved runs each, identical output): **293-296 ms** before, **274-277 ms** after (-6.5%)
- an admin dashboard built with the Shadcn kit, 1,767 components per request: `preMount()` and `postMount()` go from 1,767 calls per request to 0; Blackfire (5 samples of 5 requests) goes from **2.48 s** to **2.37 s** wall time (-4.4%); raw CPU median per request goes from **48.2-50.4 ms** to **46.5-49.0 ms** (noisy machine, 6 interleaved runs of 60 requests each side)

```php
<?php

// Run from src/TwigComponent: 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\TwigComponent\TwigComponentBundle;

require getcwd().'/vendor/autoload.php';

$dir = sys_get_temp_dir().'/ux-twig-component-bench-'.bin2hex(random_bytes(4));
mkdir($dir.'/templates/components', 0777, true);
file_put_contents($dir.'/templates/components/Cell.html.twig', <<<'TWIG'
    {% props variant = 'default' %}
    <td data-variant="{{ variant }}" {{ attributes.defaults({class: 'p-2 align-middle'}) }}>{% block content %}{% endblock %}</td>
    TWIG);
file_put_contents($dir.'/templates/components/Badge.html.twig', <<<'TWIG'
    {% props label %}
    <span {{ attributes.defaults({class: 'badge'}) }}>{{ label }}</span>
    TWIG);
// 25 rows of 6 embedded cells, each holding a self-closing badge: 300 components
file_put_contents($dir.'/templates/table.html.twig', <<<'TWIG'
    {% for row in 1..25 %}<tr>
        {% for col in 1..6 %}<twig:Cell variant="{{ col is odd ? 'odd' : 'even' }}" class="cell-{{ col }}"><twig:Badge label="{{ row }}-{{ col }}" data-row="{{ row }}" /></twig:Cell>{% endfor %}
    </tr>{% endfor %}
    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();
    }

    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');
$html = $twig->render('table.html.twig');

$times = [];
for ($run = 0; $run < 7; ++$run) {
    $start = hrtime(true);
    for ($i = 0; $i < 100; ++$i) {
        $twig->render('table.html.twig');
    }
    $times[] = (hrtime(true) - $start) / 1e6;
}
sort($times);
printf("100 renders of 300 components: median %.0f ms (min %.0f, max %.0f) | sha1 %s\n", $times[3], $times[0], $times[6], sha1($html));
```

Blackfire profiles (dashboard page):
- Before: https://app.blackfire.io/envs/5f4f9a62-eaa0-45ee-b7b3-a1b879f550e9/profiles/a28b57f5-dff1-4e21-bd75-bb859a840bf8/graph
- After: https://app.blackfire.io/envs/5f4f9a62-eaa0-45ee-b7b3-a1b879f550e9/profiles/a2ac9218-9141-4b33-9d0c-b5e1f069a01c/graph
- Comparison: https://app.blackfire.io/envs/5f4f9a62-eaa0-45ee-b7b3-a1b879f550e9/profiles/compare/a28b57f5-dff1-4e21-bd75-bb859a840bf8...a2ac9218-9141-4b33-9d0c-b5e1f069a01c/graph

This dashboard is available at https://github.com/Kocal/sf-ux-perfs-twig-components.
@Kocal
Kocal force-pushed the perf/twig-component-per-render-overhead branch from 21fc885 to cd3a0ef Compare October 7, 2026 05:39
@Kocal
Kocal requested review from kbond and smnandre October 8, 2026 05:36
@carsonbot carsonbot added Status: Reviewed Has been reviewed by a maintainer and removed Status: Needs Review Needs to be reviewed labels Oct 9, 2026
@Kocal
Kocal merged commit b013fd9 into symfony:3.x Oct 9, 2026
39 checks passed
@Kocal
Kocal deleted the perf/twig-component-per-render-overhead branch October 9, 2026 20:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Performance Status: Reviewed Has been reviewed by a maintainer TwigComponent

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants