Skip to content

Speed up applying html_cva() recipes that resolve to classes already seen - #4984

Open
Kocal wants to merge 1 commit into
twigphp:3.xfrom
Kocal:perf/cva-apply-memo
Open

Kocal wants to merge 1 commit into
twigphp:3.xfrom
Kocal:perf/cva-apply-memo

Conversation

@Kocal

@Kocal Kocal commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

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

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

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

…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants