Repository navigation
Conversation
…y seen
`Cva::apply()` splits the resolved classes with `preg_split()` and deduplicates them with `array_unique()` on every call. Components call `html_cva()` on every render and apply the same few variant combinations, so most of that work is repeated: on an admin dashboard built with the Symfony UX Toolkit Shadcn kit, the 486 calls per request resolve to only 38 distinct class strings (92% repeats), around 550 bytes and 37 classes each. Splitting and deduplicating them is about 1.5 µs out of a 2 µs call.
The result now goes into a static array keyed by the resolved class string, the only thing it depends on. The array is static because `html_cva()` creates a new `Cva` on every call. It holds at most 1,000 entries and is emptied when full.
- `Cva::apply()` time per dashboard request (hrtime around the method, cache emptied between requests like PHP-FPM, 6 interleaved runs of 20 requests each side): **1.27-1.35 ms** -> **0.69-0.73 ms** (-46%); with the cache kept between requests, as in a long-running worker: **0.58-0.61 ms**
- standalone benchmark (500 buttons using the Shadcn button recipe, 24 distinct variant/size pairs, cache emptied between renders, 5 interleaved runs each side, identical output): **510-517 ms** -> **432-434 ms** (-16%)
- Blackfire (5 samples of 5 requests): wall time **2.52 s** -> **2.40 s**
On the whole dashboard request, this saves about 0.6 ms out of 46 ms (~1.3%).
```php
<?php
// Run from extra/html-extra: php bench.php
use Twig\Environment;
use Twig\Extra\Html\Cva;
use Twig\Extra\Html\HtmlExtension;
use Twig\Loader\ArrayLoader;
require __DIR__.'/vendor/autoload.php';
// The button recipe of the Symfony UX Toolkit Shadcn kit
$twig = new Environment(new ArrayLoader([
'button.html.twig' => <<<'TWIG'
{%- set style = html_cva(
base: "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
variants: {
variant: {
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
outline: 'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
ghost: 'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50',
destructive: 'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2',
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2',
icon: 'size-8',
'icon-xs': "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
'icon-sm': 'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
'icon-lg': 'size-9',
},
},
) -%}
<button class="{{ style.apply({variant: variant, size: size}) }}">{{ label }}</button>
TWIG,
// 500 buttons, 24 distinct variant/size pairs
'page.html.twig' => <<<'TWIG'
{% for i in 1..500 %}{{ include('button.html.twig', {variant: ['default', 'outline', 'secondary', 'ghost', 'destructive', 'link'][i % 6], size: ['default', 'sm', 'lg', 'icon'][i % 4], label: 'Button ' ~ i}) }}{% endfor %}
TWIG,
]));
$twig->addExtension(new HtmlExtension());
// PHP-FPM empties static properties at the end of every request
$render = static function () use ($twig): string {
if (property_exists(Cva::class, 'classListCache')) {
(new \ReflectionProperty(Cva::class, 'classListCache'))->setValue(null, []);
}
return $twig->render('page.html.twig');
};
$html = $render();
$times = [];
for ($run = 0; $run < 7; ++$run) {
$start = hrtime(true);
for ($i = 0; $i < 100; ++$i) {
$render();
}
$times[] = (hrtime(true) - $start) / 1e6;
}
sort($times);
printf("100 renders of 500 buttons: 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/57d327e3-02f6-4d6d-b27c-a39d3ce0438e/graph
- after: https://app.blackfire.io/envs/5f4f9a62-eaa0-45ee-b7b3-a1b879f550e9/profiles/f63c01a9-adab-48bd-a524-67d576ab5ebf/graph
- comparison: https://app.blackfire.io/envs/5f4f9a62-eaa0-45ee-b7b3-a1b879f550e9/profiles/compare/57d327e3-02f6-4d6d-b27c-a39d3ce0438e...f63c01a9-adab-48bd-a524-67d576ab5ebf/graph
This dashboard is available at https://github.com/Kocal/sf-ux-perfs-twig-components.
Kocal
added a commit
to Kocal/sf-ux-perfs-twig-components
that referenced
this pull request
Oct 7, 2026
…quests together
stof
approved these changes
Oct 7, 2026
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.
Cva::apply()splits the resolved classes withpreg_split()and deduplicates them witharray_unique()on every call. Components callhtml_cva()on every render and apply the same few variant combinations, so most of that work is repeated: on an admin dashboard built with the Symfony UX Toolkit Shadcn kit, the 486 calls per request resolve to only 38 distinct class strings (92% repeats), around 550 bytes and 37 classes each. Splitting and deduplicating them is about 1.5 µs out of a 2 µs call.The result now goes into a static array keyed by the resolved class string, the only thing it depends on. The array is static because
html_cva()creates a newCvaon every call. It holds at most 1,000 entries and is emptied when full.Cva::apply()time per dashboard request (hrtime around the method, cache emptied between requests like PHP-FPM, 6 interleaved runs of 20 requests each side): 1.27-1.35 ms -> 0.69-0.73 ms (-46%); with the cache kept between requests, as in a long-running worker: 0.58-0.61 msOn the whole dashboard request, this saves about 0.6 ms out of 46 ms (~1.3%).
Blackfire profiles (dashboard page):
This dashboard is available at https://github.com/Kocal/sf-ux-perfs-twig-components.