From 5163041851a103d4add25f045a4171fea25dcc11 Mon Sep 17 00:00:00 2001 From: Hugo Alliaume Date: Tue, 6 Oct 2026 23:57:39 +0200 Subject: [PATCH] Speed up the `html_attr_relaxed` escaping strategy for strings that need no escaping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `html_attr_relaxed` is the strategy `html_attr()` from twig/html-extra uses to escape attribute names, and Symfony UX components use it the same way. Names almost never contain a character that needs escaping, yet every call ran a UTF-8 validity check (`preg_match('//u')`) and a `preg_replace_callback()` with a closure regardless. When the charset is UTF-8 and every byte is one of the characters the strategy leaves untouched, a single `strspn()` call now detects it and the string is returned as is. Other charsets still go through the conversion, since safe-looking bytes can encode other characters there (in UTF-16BE, "ab" is U+6162). `html_attr`, mostly used on values, keeps its own `case` and pays nothing for it. - `html_attr_relaxed` on safe names: **338-411 ns** per call before, **156-189 ns** after (about 2.2x) - `html_attr_relaxed` on names that need escaping (rare): **554-642 ns** before, **632-865 ns** after, the cost of the extra `strspn()` - `html_attr`: unchanged - end to end, 100 renders of 300 Symfony UX components: **294-302 ms** before, **279-282 ms** after (about 4.5%) ```php dropdown#toggle', 'Hello "world"', 'éàü', 'bold']], ]; foreach ($cases as [$strategy, $label, $strings]) { $times = []; for ($run = 0; $run < 7; ++$run) { $start = hrtime(true); for ($i = 0; $i < 20000; ++$i) { foreach ($strings as $string) { $escaper->escape($string, $strategy); } } $times[] = (hrtime(true) - $start) / 1e6; } sort($times); $escaped = array_map(static fn (string $string): string => $escaper->escape($string, $strategy), $strings); printf("%-17s %-16s %3.0f ns/escape | sha1 %s\n", $strategy, $label, $times[3] * 1e6 / 20000 / count($strings), sha1(implode('|', $escaped))); } ``` Blackfire profiles (300 Symfony UX components rendered 100 times): - Before: https://app.blackfire.io/envs/5f4f9a62-eaa0-45ee-b7b3-a1b879f550e9/profiles/2c2141c7-5f2d-4be5-9864-9e131dae962b/graph - After: https://app.blackfire.io/envs/5f4f9a62-eaa0-45ee-b7b3-a1b879f550e9/profiles/85def003-180c-4fc6-94c1-f63fdd26a139/graph - Comparison: https://app.blackfire.io/envs/5f4f9a62-eaa0-45ee-b7b3-a1b879f550e9/profiles/compare/2c2141c7-5f2d-4be5-9864-9e131dae962b...85def003-180c-4fc6-94c1-f63fdd26a139/graph Blackfire's per-call instrumentation flattens this kind of gain (it only shows **4.86 s** -> **4.79 s**), which is why the raw numbers above matter. This came out of profiling Symfony UX components on https://github.com/Kocal/sf-ux-perfs-twig-components. --- CHANGELOG | 1 + src/Runtime/EscaperRuntime.php | 9 ++++++++- tests/Runtime/EscaperRuntimeTest.php | 6 ++++++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index 3285bcab97a..efa00e5e0cc 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -20,6 +20,7 @@ * Add a way to mark a `Twig\Markup` instance as safe for some escaping strategies only * Make `Environment::getExtension()` return the runtime of an extension class registered with `AttributeExtension` * Make `Environment::getRuntime()` fall back to the registered extension of the same class + * Speed up the `html_attr_relaxed` escaping strategy for strings that need no escaping # 3.30.0 (2026-09-25) diff --git a/src/Runtime/EscaperRuntime.php b/src/Runtime/EscaperRuntime.php index a2b0df06d37..33021f525ae 100644 --- a/src/Runtime/EscaperRuntime.php +++ b/src/Runtime/EscaperRuntime.php @@ -29,6 +29,8 @@ final class EscaperRuntime implements RuntimeExtensionInterface 'html_attr_relaxed' => ['html'], ]; + private const HTML_ATTR_RELAXED_SAFE_CHARS = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789,.-_:@[]'; + /** @var array */ private $escapers = []; @@ -271,8 +273,13 @@ public function escape($string, string $strategy = 'html', ?string $charset = nu return $string; - case 'html_attr': case 'html_attr_relaxed': + // Attribute names, which this strategy is meant for, rarely need escaping: skip the regular expressions then + if ('UTF-8' === $charset && \strlen($string) === strspn($string, self::HTML_ATTR_RELAXED_SAFE_CHARS)) { + return $string; + } + // no break + case 'html_attr': if ('UTF-8' !== $charset) { $string = $this->convertEncoding($string, 'UTF-8', $charset); } diff --git a/tests/Runtime/EscaperRuntimeTest.php b/tests/Runtime/EscaperRuntimeTest.php index db84d9b7652..88b885e437c 100644 --- a/tests/Runtime/EscaperRuntimeTest.php +++ b/tests/Runtime/EscaperRuntimeTest.php @@ -188,6 +188,12 @@ public function testHtmlAttributeRelaxedEscapingConvertsSpecialChars(): void } } + public function testHtmlAttributeRelaxedEscapingStillEscapesSafeLookingBytesInOtherCharsets(): void + { + // In UTF-16BE, the bytes of "ab" are the single character U+6162, which must be escaped + $this->assertSame(mb_convert_encoding('慢', 'UTF-16BE', 'UTF-8'), (new EscaperRuntime('UTF-16BE'))->escape('ab', 'html_attr_relaxed')); + } + public function testJavascriptEscapingConvertsSpecialChars(): void { foreach ($this->jsSpecialChars as $key => $value) {