Skip to content

[TwigComponent] Reduce per-render CPU cost of static components - #3692

Open
javiereguiluz wants to merge 3 commits into
symfony:3.xfrom
javiereguiluz:twig-component-render-perf
Open

[TwigComponent] Reduce per-render CPU cost of static components#3692
javiereguiluz wants to merge 3 commits into
symfony:3.xfrom
javiereguiluz:twig-component-render-perf

Conversation

@javiereguiluz

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

Pages that render hundreds of small components (e.g. an EasyAdmin index page renders ~630 components of 15 types: icons, table cells, action-menu items, ...) spend a significant share of their CPU time inside TwigComponent's own render pipeline rather than in the templates. Profiling such a workload showed three recurring per-render costs that this PR removes, without changing any observable behavior: the rendered HTML is byte-identical before/after each commit.

This PR contains three independent commits:

1. Cache ComponentMetadata instances in ComponentFactory

metadataFor() constructed a new ComponentMetadata on every call, and it is called twice per render (create() and ComponentRenderer::preRender()). The class is immutable, so instances are now cached per component name (~1,260 fewer allocations on the page above). The cache is cleared in reset() like the other per-request memo caches.

2. Skip event allocation/dispatch when nobody listens

Every render dispatched 5 freshly-allocated events (PreCreateForRender, PreMount, PostMount, PreRender, PostRender) even with zero listeners registered, the common case in production when the LiveComponent bridge is not installed (~3,150 event allocations + dispatches per request on the page above).

When the injected dispatcher implements the introspectable Symfony\Component\EventDispatcher\EventDispatcherInterface, hasListeners() is now checked (a cheap !empty() array lookup) before creating and dispatching each event:

  • the check is done per dispatch call, so listeners registered at runtime keep working;
  • any other PSR-14 dispatcher keeps the previous unconditional behavior;
  • PreRenderEvent is still always created, because it carries the resolved template and variables to the compiled code; only its dispatch is skipped;
  • when PostMountEvent is skipped, the extra metadata defaults to [], exactly as an undispatched event would report.

The LiveComponent test suite (whose bridge subscribes to these events) passes unchanged against this branch.

3. Build the render variables in a single pass in preRender()

The variables array was spread-copied twice per render: once to create the PreRenderEvent, then a second full [...spread] to append this, computed, outerScope, __props and __context. The second copy is replaced with in-place assignments, the variables are only re-read from the event when it was actually dispatched, and the array_diff_key() call is skipped when there is no outer context (the {{ component() }} function path).

Benchmark

Throw-away Symfony app (prod env, no profiler, OPcache on, Xdebug off) rendering a page of 642 components of 15 types modeled on a real EasyAdmin index-page profile: 225 class-backed icons, 161 nested anonymous items with {% props %} defaults, 100 anonymous table cells with attributes, 23 dropdowns with named blocks, 40 renders via the component() function, etc. Numbers are the median of 5 processes × 30 measured iterations each (5 warmup), hrtime() around a full page render, on PHP 8.5 / Apple Silicon.

Step (cumulative) p50 / page Δ vs 3.x
3.x baseline 6.16 ms
+ metadata cache 6.02 ms −2.3 %
+ event guards 5.54 ms −10.1 %
+ single-pass variables 5.36 ms −13.1 %

The rendered HTML is byte-identical (same SHA-1) for every step, and both the TwigComponent (299 tests) and LiveComponent (387 tests) suites pass.

metadataFor() constructed a new ComponentMetadata on every call, and it is
called twice per component render (create() and ComponentRenderer::preRender()).
ComponentMetadata is immutable, so instances can be safely reused. On a page
rendering ~630 components this removes ~1.260 allocations per request.

The cache is cleared in reset() alongside the other per-request memo caches.
Every component render dispatched 5 freshly-allocated events
(PreCreateForRender, PreMount, PostMount, PreRender, PostRender) even when no
listener is registered, which is the common case in production without the
LiveComponent bridge. On a page with ~630 components that is ~3.150 event
allocations + dispatches per request.

When the injected dispatcher implements the introspectable
Symfony\Component\EventDispatcher\EventDispatcherInterface, hasListeners() is
now checked (a cheap array lookup) before creating and dispatching each event.
The PreRenderEvent object is still always created because it carries the
resolved template and variables; only its dispatch is skipped. For any other
PSR-14 dispatcher the previous unconditional behavior is kept.
@javiereguiluz javiereguiluz added this to the 3.0 milestone Jul 4, 2026
{
$event = new PreMountEvent($component, $data, $componentMetadata);
$this->eventDispatcher->dispatch($event);
if (null === $this->introspectableDispatcher || $this->introspectableDispatcher->hasListeners(PreMountEvent::class)) {

@Kocal Kocal Jul 4, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think we can check if we have listeners for events for a specific component class?

I mean, if we have 100 anonymous components (that don't have listener AFAIK), and one component with listener, we will still dispatch the events right?

Note that we have two specials component ux:icon and ux:map, and I think they listen to some events... will it still work? 🤔

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added a test related to events and static Twig components. hasListeners() is checked per event class, not per component, so once any listener is registered, events are dispatched for every component render, inlcuding anonymous. Also, hasListeners() is evaluated at dispatch time (not cached at construction), since the listeners are added at runtime.

I don't use ux:map, but I use ux:icon and I tested the changes in a page using 628 components (225 of them are icons) and it worked as expected.

The variables array was spread-copied twice per render: once to create the
PreRenderEvent and a second time to append this/computed/outerScope/__props/
__context. The second copy is replaced by in-place assignments, the variables
are only re-read from the event when it was actually dispatched, and the
array_diff_key() call is skipped when there is no outer context (the
component() function path).
@javiereguiluz
javiereguiluz force-pushed the twig-component-render-perf branch from 20ee4ff to e03d2fa Compare July 5, 2026 08:41
@Kocal
Kocal requested review from kbond and smnandre July 17, 2026 18:37
@carsonbot carsonbot added Status: Reviewed Has been reviewed by a maintainer and removed Status: Needs Review Needs to be reviewed labels Jul 17, 2026
if ($mappedName = $this->classMap[$name] ?? null) {
if ($config = $this->config[$mappedName] ?? null) {
return new ComponentMetadata($config);
return $this->metadata[$name] = new ComponentMetadata($config);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return $this->metadata[$name] = new ComponentMetadata($config);
return $this->metadata[$mappedName] = new ComponentMetadata($config);

Not 100% sure but this may deserve some thinking

];

return new ComponentMetadata($this->config[$name]);
return $this->metadata[$name] = new ComponentMetadata($this->config[$name]);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are keeping data twice here.. as array in $this->config[$name] and as instance in $metadata.

Should we replace one by the other ?

/**
* @return array The (possibly modified) mount data
*/
private function preMount(object $component, array $data, ComponentMetadata $componentMetadata): array

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what about using a void + reference, as in mount() method ?

{
$event = new PreMountEvent($component, $data, $componentMetadata);
$this->eventDispatcher->dispatch($event);
if (null === $this->introspectableDispatcher || $this->introspectableDispatcher->hasListeners(PreMountEvent::class)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we calculate this once and store result ? So we could avoid the entire method call ?

if (true === $this->skipPostMount) {
    $this->postMount(...) 
}

And in postMount we can always toggle it on first call ?

foreach ($componentMetadata->getPostMounts() as $postMount) {
if (null !== $newData = $component->$postMount($data)) {
$event->setData($data = $newData);
$data = $newData;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This may create problems for the profiler .. but this must not be a reason to reject your great optimizations here.

@Kocal

Kocal commented Aug 13, 2026

Copy link
Copy Markdown
Member

Any updates here?

@Kocal Kocal added Status: Waiting feedback Needs feedback from the author and removed Status: Reviewed Has been reviewed by a maintainer labels Aug 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants