Files
Twig/extra/html-extra/Cva.php
Hugo Alliaume 9e50ff7083 Speed up applying html_cva() recipes that resolve to classes already 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.
2026-10-16 08:54:29 +02:00

146 lines
4.3 KiB
PHP

<?php
/*
* This file is part of Twig.
*
* (c) Fabien Potencier
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Twig\Extra\Html;
/**
* Class Variant Authority (CVA) resolver.
*
* @author Mathéo Daninos <matheo.daninos@gmail.com>
*/
final class Cva
{
private const MAX_CACHED_CLASS_LISTS = 1000;
/**
* @var array<string, string>
*/
private static array $classListCache = [];
/**
* @var list<string|null>
*/
private array $base;
/**
* @param string|list<string|null> $base The base classes to apply to the component
*/
public function __construct(
string|array $base = [],
/**
* The variants to apply based on recipes.
*
* Format: [variantCategory => [variantName => classes]]
*
* Example:
* 'colors' => [
* 'primary' => 'bleu-8000',
* 'danger' => 'red-800 text-bold',
* ],
* 'size' => [...],
*
* @var array<string, array<string, string|list<string>>>
*/
private array $variants = [],
/**
* The compound variants to apply based on recipes.
*
* Format: [variantsCategory => ['variantName', 'variantName'], class: classes]
*
* Example:
* [
* 'colors' => ['primary'],
* 'size' => ['small'],
* 'class' => 'text-red-500',
* ],
* [
* 'size' => ['large'],
* 'class' => 'font-weight-500',
* ]
*
* @var array<array<string, string|array<string>>>
*/
private array $compoundVariants = [],
/**
* The default variants to apply if specific recipes aren't provided.
*
* Format: [variantCategory => variantName]
*
* Example:
* 'colors' => 'primary',
*
* @var array<string, string>
*/
private array $defaultVariants = [],
) {
$this->base = (array) $base;
}
public function apply(array $recipes, ?string ...$additionalClasses): string
{
$classes = $this->base;
// Resolve recipes against variants
foreach ($recipes as $recipeName => $recipeValue) {
if (\is_bool($recipeValue)) {
$recipeValue = $recipeValue ? 'true' : 'false';
}
$recipeClasses = $this->variants[$recipeName][$recipeValue] ?? [];
$classes = [...$classes, ...(array) $recipeClasses];
}
// Resolve compound variants
foreach ($this->compoundVariants as $compound) {
$compoundClasses = $this->resolveCompoundVariant($compound, $recipes) ?? [];
$classes = [...$classes, ...$compoundClasses];
}
// Apply default variants if specific recipes aren't provided
foreach ($this->defaultVariants as $defaultVariantName => $defaultVariantValue) {
if (!isset($recipes[$defaultVariantName])) {
$variantClasses = $this->variants[$defaultVariantName][$defaultVariantValue] ?? [];
$classes = [...$classes, ...(array) $variantClasses];
}
}
$classes = [...$classes, ...array_values($additionalClasses)];
$classes = implode(' ', array_filter($classes, 'is_string'));
if (isset(self::$classListCache[$classes])) {
return self::$classListCache[$classes];
}
if (\count(self::$classListCache) >= self::MAX_CACHED_CLASS_LISTS) {
self::$classListCache = [];
}
$uniqueClasses = array_unique(preg_split('#\s+#', $classes, -1, \PREG_SPLIT_NO_EMPTY) ?: []);
return self::$classListCache[$classes] = implode(' ', $uniqueClasses);
}
private function resolveCompoundVariant(array $compound, array $recipes): array
{
foreach ($compound as $compoundName => $compoundValues) {
if ('class' === $compoundName) {
continue;
}
if (!isset($recipes[$compoundName]) || !\in_array($recipes[$compoundName], (array) $compoundValues, true)) {
return [];
}
}
return (array) ($compound['class'] ?? []);
}
}