From 4d5d233776d840b6272d7b94e85e459d804f35d3 Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Tue, 22 Sep 2026 21:30:14 +0100 Subject: [PATCH 01/14] Compile the generator guard as an unreachable yield instead of a yield from --- CHANGELOG | 1 + src/Node/BlockNode.php | 2 +- src/Node/CaptureNode.php | 2 +- src/Node/ModuleNode.php | 2 +- tests/Node/BlockTest.php | 2 +- tests/Node/MacroTest.php | 4 ++-- tests/Node/MacrosTest.php | 2 +- tests/Node/ModuleTest.php | 2 +- tests/Node/SetTest.php | 4 ++-- 9 files changed, 11 insertions(+), 10 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index de9dd304e..fdf14d885 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -2,6 +2,7 @@ * Fix the escaping safe analysis retaining every compiled template node for the lifetime of the environment * Speed up loading a template that the environment has already loaded + * Speed up rendering by compiling a cheaper generator guard into templates * Fix `IntlExtension` letting the pattern derived from a date formatter prototype override an explicit locale * Fix `IntlExtension` not honoring the locale of a date formatter prototype configured with no date and time styles * Speed up macro calls diff --git a/src/Node/BlockNode.php b/src/Node/BlockNode.php index b4f939cf6..b736b2d4c 100644 --- a/src/Node/BlockNode.php +++ b/src/Node/BlockNode.php @@ -42,7 +42,7 @@ class BlockNode extends Node $compiler ->subcompile($this->getNode('body')) - ->write("yield from [];\n") + ->write("return; yield;\n") ->outdent() ->write("}\n\n") ; diff --git a/src/Node/CaptureNode.php b/src/Node/CaptureNode.php index 3b7f0b6d8..2f4ef5a79 100644 --- a/src/Node/CaptureNode.php +++ b/src/Node/CaptureNode.php @@ -39,7 +39,7 @@ class CaptureNode extends Node ->raw("(function () use (&\$context, \$macros, \$blocks) {\n") ->indent() ->subcompile($this->getNode('body')) - ->write("yield from [];\n") + ->write("return; yield;\n") ->outdent() ->write('})()') ; diff --git a/src/Node/ModuleNode.php b/src/Node/ModuleNode.php index 5c27a10ea..8f2283475 100644 --- a/src/Node/ModuleNode.php +++ b/src/Node/ModuleNode.php @@ -379,7 +379,7 @@ final class ModuleNode extends Node implements CoercesChildrenToStringInterface $compiler->subcompile($this->getNode('display_end')); if (!$this->hasNode('parent')) { - $compiler->write("yield from [];\n"); + $compiler->write("return; yield;\n"); } $compiler diff --git a/tests/Node/BlockTest.php b/tests/Node/BlockTest.php index 8606484ba..0006fb16e 100644 --- a/tests/Node/BlockTest.php +++ b/tests/Node/BlockTest.php @@ -49,7 +49,7 @@ public function block_foo(array \$context, array \$blocks = []): iterable { \$macros = \$this->macros; yield "foo"; - yield from []; + return; yield; } EOF, new Environment(new ArrayLoader()), ]; diff --git a/tests/Node/MacroTest.php b/tests/Node/MacroTest.php index 29a0b5cd8..08940c845 100644 --- a/tests/Node/MacroTest.php +++ b/tests/Node/MacroTest.php @@ -77,7 +77,7 @@ new \\Twig\\TwigMacro("foo", function (\$foo = null, \$bar = "Foo", \$_underscor return ('' === \$tmp = implode('', iterator_to_array((function () use (&\$context, \$macros, \$blocks) { yield "foo"; - yield from []; + return; yield; })(), false))) ? '' : new Markup(\$tmp, \$this->env->getCharset()); }, ["foo" => true, "bar" => true, "_underscore" => true], false) EOF, new Environment(new ArrayLoader(), ['use_yield' => true]), @@ -98,7 +98,7 @@ new \\Twig\\TwigMacro("foo", function (\$foo = null, \$bar = "Foo", \$_underscor return ('' === \$tmp = \\Twig\\Extension\\CoreExtension::captureOutput((function () use (&\$context, \$macros, \$blocks) { yield "foo"; - yield from []; + return; yield; })())) ? '' : new Markup(\$tmp, \$this->env->getCharset()); }, ["foo" => true, "bar" => true, "_underscore" => true], false) EOF, new Environment(new ArrayLoader(), ['use_yield' => false]), diff --git a/tests/Node/MacrosTest.php b/tests/Node/MacrosTest.php index 7a5687e69..1d2687027 100644 --- a/tests/Node/MacrosTest.php +++ b/tests/Node/MacrosTest.php @@ -74,7 +74,7 @@ protected function loadDeclaredMacros(): array return ('' === \$tmp = implode('', iterator_to_array((function () use (&\$context, \$macros, \$blocks) { yield "foo"; - yield from []; + return; yield; })(), false))) ? '' : new Markup(\$tmp, \$this->env->getCharset()); }, ["foo" => true], false), ]; diff --git a/tests/Node/ModuleTest.php b/tests/Node/ModuleTest.php index 670309367..8988eef83 100644 --- a/tests/Node/ModuleTest.php +++ b/tests/Node/ModuleTest.php @@ -159,7 +159,7 @@ class __TwigTemplate_%x extends Template \$macros = \$this->macros; // line 1 yield "foo"; - yield from []; + return; yield; } /** diff --git a/tests/Node/SetTest.php b/tests/Node/SetTest.php index 4cfd85aca..4230578ee 100644 --- a/tests/Node/SetTest.php +++ b/tests/Node/SetTest.php @@ -65,7 +65,7 @@ EOF // line 1 \$context["foo"] = ('' === \$tmp = implode('', iterator_to_array((function () use (&\$context, \$macros, \$blocks) { yield "foo"; - yield from []; + return; yield; })(), false))) ? '' : new Markup(\$tmp, \$this->env->getCharset()); EOF, new Environment(new ArrayLoader(), ['use_yield' => true]), ]; @@ -74,7 +74,7 @@ EOF, new Environment(new ArrayLoader(), ['use_yield' => true]), // line 1 $context["foo"] = ('' === $tmp = \Twig\Extension\CoreExtension::captureOutput((function () use (&$context, $macros, $blocks) { yield "foo"; - yield from []; + return; yield; })())) ? '' : new Markup($tmp, $this->env->getCharset()); EOF, new Environment(new ArrayLoader(), ['use_yield' => false]), ]; From 16be8af8f5d1be6405ed88d2d10e52f41090f1f6 Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Tue, 22 Sep 2026 21:49:25 +0100 Subject: [PATCH 02/14] Reuse the already resolved callable when compiling call arguments --- CHANGELOG | 1 + src/Node/Expression/CallExpression.php | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index fdf14d885..66ed574dc 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -3,6 +3,7 @@ * Fix the escaping safe analysis retaining every compiled template node for the lifetime of the environment * Speed up loading a template that the environment has already loaded * Speed up rendering by compiling a cheaper generator guard into templates + * Speed up compiling filter, function, and test calls * Fix `IntlExtension` letting the pattern derived from a date formatter prototype override an explicit locale * Fix `IntlExtension` not honoring the locale of a date formatter prototype configured with no date and time styles * Speed up macro calls diff --git a/src/Node/Expression/CallExpression.php b/src/Node/Expression/CallExpression.php index 94036c1d3..2a55ced49 100644 --- a/src/Node/Expression/CallExpression.php +++ b/src/Node/Expression/CallExpression.php @@ -125,7 +125,7 @@ abstract class CallExpression extends AbstractExpression } if ($this->hasNode('arguments')) { - $arguments = (new CallableArgumentsExtractor($this, $this->getTwigCallable()))->extractArguments($this->getNode('arguments')); + $arguments = (new CallableArgumentsExtractor($this, $this->getAttribute('twig_callable')))->extractArguments($this->getNode('arguments')); foreach ($arguments as $node) { if (!$first) { $compiler->raw(', '); From d3d13f9b1a5845df5862b8ac4c8719fb061c71e7 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Thu, 24 Sep 2026 18:47:14 +0200 Subject: [PATCH 03/14] Speed up adding extensions to an environment --- CHANGELOG | 1 + src/Environment.php | 18 +++++++++--------- tests/EnvironmentTest.php | 38 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 9 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 66ed574dc..080673f47 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,6 @@ # 3.30.0 (2026-XX-XX) + * Speed up adding extensions to an environment * Fix the escaping safe analysis retaining every compiled template node for the lifetime of the environment * Speed up loading a template that the environment has already loaded * Speed up rendering by compiling a cheaper generator guard into templates diff --git a/src/Environment.php b/src/Environment.php index 105689902..c0fdfc32c 100644 --- a/src/Environment.php +++ b/src/Environment.php @@ -169,7 +169,7 @@ class Environment public function enableDebug() { $this->debug = true; - $this->updateOptionsHash(); + $this->optionsHash = null; } /** @@ -180,7 +180,7 @@ class Environment public function disableDebug() { $this->debug = false; - $this->updateOptionsHash(); + $this->optionsHash = null; } /** @@ -231,7 +231,7 @@ class Environment public function enableStrictVariables() { $this->strictVariables = true; - $this->updateOptionsHash(); + $this->optionsHash = null; } /** @@ -242,7 +242,7 @@ class Environment public function disableStrictVariables() { $this->strictVariables = false; - $this->updateOptionsHash(); + $this->optionsHash = null; } /** @@ -324,7 +324,7 @@ class Environment */ public function getTemplateClass(string $name, ?int $index = null): string { - $key = ($this->hotCache[$name] ?? $this->getLoader()->getCacheKey($name)).$this->optionsHash; + $key = ($this->hotCache[$name] ?? $this->getLoader()->getCacheKey($name)).($this->optionsHash ??= $this->getOptionsHash()); return '__TwigTemplate_'.hash(\PHP_VERSION_ID < 80100 ? 'sha256' : 'xxh128', $key).(null === $index ? '' : '___'.$index); } @@ -701,7 +701,7 @@ class Environment public function addExtension(ExtensionInterface $extension) { $this->extensionSet->addExtension($extension); - $this->updateOptionsHash(); + $this->optionsHash = null; } /** @@ -712,7 +712,7 @@ class Environment public function setExtensions(array $extensions) { $this->extensionSet->setExtensions($extensions); - $this->updateOptionsHash(); + $this->optionsHash = null; } /** @@ -952,9 +952,9 @@ class Environment return $this->extensionSet->getExpressionParsers(); } - private function updateOptionsHash(): void + private function getOptionsHash(): string { - $this->optionsHash = implode(':', [ + return implode(':', [ $this->extensionSet->getSignature(), \PHP_MAJOR_VERSION, \PHP_MINOR_VERSION, diff --git a/tests/EnvironmentTest.php b/tests/EnvironmentTest.php index 9c9818fbf..cd9511e86 100644 --- a/tests/EnvironmentTest.php +++ b/tests/EnvironmentTest.php @@ -655,6 +655,44 @@ EOF FilesystemHelper::removeDir($dir); } } + + public function testTemplateClassFollowsExtensionsAddedAfterItWasComputed(): void + { + $twig = new Environment(new ArrayLoader(['index' => ''])); + $cls = $twig->getTemplateClass('index'); + + $twig->addExtension(new EnvironmentTest_Extension_WithGlobals()); + $this->assertNotSame($cls, $cls = $twig->getTemplateClass('index')); + + $twig->setExtensions([new EnvironmentTest_ExtensionWithoutRuntime()]); + $this->assertNotSame($cls, $cls = $twig->getTemplateClass('index')); + + $other = new Environment(new ArrayLoader(['index' => ''])); + $other->setExtensions([new EnvironmentTest_Extension_WithGlobals(), new EnvironmentTest_ExtensionWithoutRuntime()]); + $this->assertSame($cls, $other->getTemplateClass('index')); + } + + public function testTemplateClassFollowsDebugAndStrictVariablesChanges(): void + { + $twig = new Environment(new ArrayLoader(['index' => ''])); + $cls = $twig->getTemplateClass('index'); + + $twig->enableDebug(); + $debugCls = $twig->getTemplateClass('index'); + $this->assertNotSame($cls, $debugCls); + $this->assertSame((new Environment(new ArrayLoader(['index' => '']), ['debug' => true]))->getTemplateClass('index'), $debugCls); + + $twig->disableDebug(); + $this->assertSame($cls, $twig->getTemplateClass('index')); + + $twig->enableStrictVariables(); + $strictCls = $twig->getTemplateClass('index'); + $this->assertNotSame($cls, $strictCls); + $this->assertSame((new Environment(new ArrayLoader(['index' => '']), ['strict_variables' => true]))->getTemplateClass('index'), $strictCls); + + $twig->disableStrictVariables(); + $this->assertSame($cls, $twig->getTemplateClass('index')); + } } class EnvironmentTest_Extension_WithGlobals extends AbstractExtension From 4d073d938ae57b916cf91883bbf9f9d1ba4436d7 Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Fri, 25 Sep 2026 07:34:01 +0200 Subject: [PATCH 04/14] Drop a comment restating the code and clarify the fully named macro call fast path --- src/NodeVisitor/EscaperNodeVisitor.php | 1 - src/TwigMacro.php | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/NodeVisitor/EscaperNodeVisitor.php b/src/NodeVisitor/EscaperNodeVisitor.php index 954979a4a..09a04b446 100644 --- a/src/NodeVisitor/EscaperNodeVisitor.php +++ b/src/NodeVisitor/EscaperNodeVisitor.php @@ -52,7 +52,6 @@ final class EscaperNodeVisitor implements NodeVisitorInterface if ($env->hasExtension(EscaperExtension::class) && $defaultStrategy = $env->getExtension(EscaperExtension::class)->getDefaultStrategy($node->getTemplateName())) { $this->defaultStrategy = $defaultStrategy; } - // the compiled template exposes the strategy it was compiled with $node->setAttribute('strategy', \is_string($this->defaultStrategy) ? $this->defaultStrategy : false); $this->safeVars = []; $this->blocks = []; diff --git a/src/TwigMacro.php b/src/TwigMacro.php index 222f6d12b..c620fe82e 100644 --- a/src/TwigMacro.php +++ b/src/TwigMacro.php @@ -100,7 +100,7 @@ final class TwigMacro return ($this->body)(...$arguments); } - // The keys are then exactly the declared names. + // Every declared argument is named: none can be missing, unknown, duplicated, or misordered. if (\count($arguments) === \count($this->arguments) && !array_diff_key($arguments, $this->argumentIndexes)) { return ($this->body)(...$this->renameArguments($arguments)); } From 9c3d58e6389b3694f27edd57b367c0badab2612f Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Fri, 25 Sep 2026 07:34:01 +0200 Subject: [PATCH 05/14] Fix coding standards --- tests/NodeVisitor/SafeAnalysisTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/NodeVisitor/SafeAnalysisTest.php b/tests/NodeVisitor/SafeAnalysisTest.php index 9dbf539dd..b9c2ba976 100644 --- a/tests/NodeVisitor/SafeAnalysisTest.php +++ b/tests/NodeVisitor/SafeAnalysisTest.php @@ -19,7 +19,7 @@ use Twig\Source; class SafeAnalysisTest extends TestCase { - public function testCompilingDoesNotRetainTheAst() + public function testCompilingDoesNotRetainTheAst(): void { $env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => 'html']); $source = new Source('{{ "a"|upper }}{{ foo.bar }}{{ [1, 2]|join("-") }}{{ b ? "y" : "n" }}', 'index'); From c8d782ea0b93399e1dccb71f85e5d628e3388c7a Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Fri, 25 Sep 2026 07:33:53 +0200 Subject: [PATCH 06/14] Deprecate calling TemplateWrapper::unwrap() without arguments as of 3.30 and list the fix in the CHANGELOG --- CHANGELOG | 1 + src/TemplateWrapper.php | 2 +- tests/TemplateWrapperTest.php | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 080673f47..ecc59c5a4 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -8,6 +8,7 @@ * Fix `IntlExtension` letting the pattern derived from a date formatter prototype override an explicit locale * Fix `IntlExtension` not honoring the locale of a date formatter prototype configured with no date and time styles * Speed up macro calls + * Fix `TemplateWrapper::unwrap()` failing when called without arguments, which is now deprecated * Add `TemplateWrapper::getDefaultEscapeStrategy()` to know the escaping strategy a template was compiled with * Report a clear error when `random`, `reverse`, `shuffle`, and `split` receive a string that is not valid UTF-8 * Fix the deprecation about omitting parentheses when calling a macro being reported twice for the same call diff --git a/src/TemplateWrapper.php b/src/TemplateWrapper.php index e08fd2cd8..7fbd99765 100644 --- a/src/TemplateWrapper.php +++ b/src/TemplateWrapper.php @@ -124,7 +124,7 @@ final class TemplateWrapper public function unwrap(?Environment $env = null): Template { if (null === $env) { - trigger_deprecation('twig/twig', '3.29', 'Calling "%s()" without arguments is deprecated, pass the Twig environment instead.', __METHOD__); + trigger_deprecation('twig/twig', '3.30', 'Calling "%s()" without arguments is deprecated, pass the Twig environment instead.', __METHOD__); } elseif (!$this->isOwnedBy($env)) { throw new RuntimeError(\sprintf('A "%s" can only be used with the "%s" that created it.', self::class, Environment::class)); } diff --git a/tests/TemplateWrapperTest.php b/tests/TemplateWrapperTest.php index 6b1d2d732..37d63c234 100644 --- a/tests/TemplateWrapperTest.php +++ b/tests/TemplateWrapperTest.php @@ -56,7 +56,7 @@ class TemplateWrapperTest extends TestCase $twig = new Environment(new ArrayLoader(['index' => 'content'])); $wrapper = $twig->load('index'); - $this->expectDeprecation('Since twig/twig 3.29: Calling "Twig\TemplateWrapper::unwrap()" without arguments is deprecated, pass the Twig environment instead.'); + $this->expectDeprecation('Since twig/twig 3.30: Calling "Twig\TemplateWrapper::unwrap()" without arguments is deprecated, pass the Twig environment instead.'); $this->assertInstanceOf(Template::class, $wrapper->unwrap()); } From 10fe9808bb5abdd57046cc4e47cbf8624c98714e Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Fri, 25 Sep 2026 07:43:00 +0200 Subject: [PATCH 07/14] Compare the date formatter prototype pattern with the derived one once per pattern --- extra/intl-extra/IntlExtension.php | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/extra/intl-extra/IntlExtension.php b/extra/intl-extra/IntlExtension.php index 4bb3b343f..efb876be4 100644 --- a/extra/intl-extra/IntlExtension.php +++ b/extra/intl-extra/IntlExtension.php @@ -186,7 +186,10 @@ final class IntlExtension extends AbstractExtension private $listFormatters = []; private $dateFormatterPrototype; private $numberFormatterPrototype; - private array $prototypeDerivedPatterns = []; + /** + * @var array + */ + private array $prototypePatterns = []; public function __construct(?\IntlDateFormatter $dateFormatterPrototype = null, ?\NumberFormatter $numberFormatterPrototype = null) { @@ -552,11 +555,19 @@ final class IntlExtension extends AbstractExtension private function prototypePattern(): string { $pattern = $this->dateFormatterPrototype->getPattern(); + if (!\is_string($pattern) || '' === $pattern) { + return ''; + } + + return $this->prototypePatterns[$pattern] ??= $pattern === $this->derivedPrototypePattern() ? '' : $pattern; + } + + private function derivedPrototypePattern(): ?string + { $dateType = $this->dateFormatterPrototype->getDateType(); $timeType = $this->dateFormatterPrototype->getTimeType(); - - if (!\is_string($pattern) || '' === $pattern || false === $dateType || false === $timeType) { - return \is_string($pattern) ? $pattern : ''; + if (false === $dateType || false === $timeType) { + return null; } $locale = $this->prototypeLocale() ?: \Locale::getDefault(); @@ -566,10 +577,9 @@ final class IntlExtension extends AbstractExtension $locale .= '@calendar='.$calendar->getType(); } - $key = $locale.'|'.$dateType.'|'.$timeType; - $this->prototypeDerivedPatterns[$key] ??= (new \IntlDateFormatter($locale, $dateType, $timeType))->getPattern(); + $pattern = (new \IntlDateFormatter($locale, $dateType, $timeType))->getPattern(); - return $pattern === $this->prototypeDerivedPatterns[$key] ? '' : $pattern; + return \is_string($pattern) ? $pattern : null; } private function createNumberFormatter(?string $locale, string $style, array $attrs = []): \NumberFormatter From e3f0d6fb4cd24aafb0d756166781db2715eddd92 Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Fri, 25 Sep 2026 07:34:36 +0200 Subject: [PATCH 08/14] Stop looking up a macro twice in its own namespace when it is declared by a parent --- src/MacroNamespace.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/MacroNamespace.php b/src/MacroNamespace.php index 13ca706bc..cd9ac06fc 100644 --- a/src/MacroNamespace.php +++ b/src/MacroNamespace.php @@ -57,7 +57,7 @@ final class MacroNamespace return $macro->callLegacy($arguments, $source, $line); } - if (null === $macro = $this->resolve($name, $context)) { + if (null === $macro = $this->getParent($context)?->resolve($name, $context)) { if (!str_starts_with($name, 'macro_') || null === $macro = $this->resolve($bareName = substr($name, \strlen('macro_')), $context)) { throw new RuntimeError(\sprintf('Macro "%s" is not defined in template "%s".', $name, $this->template->getTemplateName()), $line, $source); } From 81cc196cae0950764c96bcbe37c93f46d8cb16c7 Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Fri, 25 Sep 2026 07:50:37 +0200 Subject: [PATCH 09/14] Bump version --- src/Environment.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Environment.php b/src/Environment.php index c0fdfc32c..9eccd112f 100644 --- a/src/Environment.php +++ b/src/Environment.php @@ -43,11 +43,11 @@ use Twig\TokenParser\TokenParserInterface; */ class Environment { - public const VERSION = '3.29.1-DEV'; - public const VERSION_ID = 32901; + public const VERSION = '3.30.0-DEV'; + public const VERSION_ID = 33000; public const MAJOR_VERSION = 3; - public const MINOR_VERSION = 29; - public const RELEASE_VERSION = 1; + public const MINOR_VERSION = 30; + public const RELEASE_VERSION = 0; public const EXTRA_VERSION = 'DEV'; private $charset; From 00861d50abdf4a3577d3c3198c0efd061edd4554 Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Fri, 25 Sep 2026 07:33:47 +0200 Subject: [PATCH 10/14] Fix split trailing newline --- CHANGELOG | 2 ++ src/Extension/CoreExtension.php | 8 ++++---- .../filters/characters_trailing_newline.test | 14 ++++++++++++++ 3 files changed, 20 insertions(+), 4 deletions(-) create mode 100644 tests/Fixtures/filters/characters_trailing_newline.test diff --git a/CHANGELOG b/CHANGELOG index ecc59c5a4..ef8f11937 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,7 @@ # 3.30.0 (2026-XX-XX) + * Fix `split`, `random`, and `shuffle` merging a trailing newline into the last character of a string + * Speed up splitting a string into characters in `split`, `random`, and `shuffle` * Speed up adding extensions to an environment * Fix the escaping safe analysis retaining every compiled template node for the lifetime of the environment * Speed up loading a template that the environment has already loaded diff --git a/src/Extension/CoreExtension.php b/src/Extension/CoreExtension.php index d62f733b2..f5f5e3952 100644 --- a/src/Extension/CoreExtension.php +++ b/src/Extension/CoreExtension.php @@ -1263,19 +1263,19 @@ final class CoreExtension extends AbstractExtension } /** - * Unicode version of str_split(): splits at every position except after the start and before the end. + * Unicode version of str_split(), an empty string giving a single empty character. * - * @return list + * @return non-empty-list * * @throws RuntimeError When the string cannot be split into characters */ private static function splitIntoCharacters(string $string, string $name): array { - if (false === $characters = preg_split('/(? "ab\n"] +--EXPECT-- +"\nba" +["a","b","\n"] +3 +1 From 3417f483bf4c7be5d0f0b78e706779d145fc4697 Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Fri, 25 Sep 2026 08:18:29 +0200 Subject: [PATCH 11/14] Fetch the escaper runtime once in the constructor of templates that escape --- CHANGELOG | 1 + src/Extension/EscaperExtension.php | 5 +- src/Node/Expression/Filter/EscapeFilter.php | 51 +++++++++++++ src/Node/ModuleNode.php | 13 +++- src/NodeVisitor/EscaperNodeVisitor.php | 22 +++++- tests/Node/Expression/Filter/EscapeTest.php | 35 +++++++++ tests/NodeVisitor/EscaperTest.php | 83 +++++++++++++++++++++ 7 files changed, 206 insertions(+), 4 deletions(-) create mode 100644 src/Node/Expression/Filter/EscapeFilter.php create mode 100644 tests/Node/Expression/Filter/EscapeTest.php create mode 100644 tests/NodeVisitor/EscaperTest.php diff --git a/CHANGELOG b/CHANGELOG index ef8f11937..7a2218ba7 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -2,6 +2,7 @@ * Fix `split`, `random`, and `shuffle` merging a trailing newline into the last character of a string * Speed up splitting a string into characters in `split`, `random`, and `shuffle` + * Speed up escaping by fetching the escaper runtime once per template * Speed up adding extensions to an environment * Fix the escaping safe analysis retaining every compiled template node for the lifetime of the environment * Speed up loading a template that the environment has already loaded diff --git a/src/Extension/EscaperExtension.php b/src/Extension/EscaperExtension.php index f666e3fdb..f044dffff 100644 --- a/src/Extension/EscaperExtension.php +++ b/src/Extension/EscaperExtension.php @@ -14,6 +14,7 @@ namespace Twig\Extension; use Twig\Environment; use Twig\FileExtensionEscapingStrategy; use Twig\Node\Expression\ConstantExpression; +use Twig\Node\Expression\Filter\EscapeFilter; use Twig\Node\Expression\Filter\RawFilter; use Twig\Node\Node; use Twig\NodeVisitor\EscaperNodeVisitor; @@ -51,8 +52,8 @@ final class EscaperExtension extends AbstractExtension public function getFilters(): array { return [ - new TwigFilter('escape', [EscaperRuntime::class, 'escape'], ['is_safe_callback' => [self::class, 'escapeFilterIsSafe']]), - new TwigFilter('e', [EscaperRuntime::class, 'escape'], ['is_safe_callback' => [self::class, 'escapeFilterIsSafe']]), + new TwigFilter('escape', [EscaperRuntime::class, 'escape'], ['is_safe_callback' => [self::class, 'escapeFilterIsSafe'], 'node_class' => EscapeFilter::class]), + new TwigFilter('e', [EscaperRuntime::class, 'escape'], ['is_safe_callback' => [self::class, 'escapeFilterIsSafe'], 'node_class' => EscapeFilter::class]), new TwigFilter('raw', null, ['is_safe' => ['all'], 'node_class' => RawFilter::class]), ]; } diff --git a/src/Node/Expression/Filter/EscapeFilter.php b/src/Node/Expression/Filter/EscapeFilter.php new file mode 100644 index 000000000..de29830a0 --- /dev/null +++ b/src/Node/Expression/Filter/EscapeFilter.php @@ -0,0 +1,51 @@ +setAttribute('template_escaper', false); + } + + protected function compileCallable(Compiler $compiler): void + { + if (!$this->getAttribute('template_escaper')) { + parent::compileCallable($compiler); + + return; + } + + $compiler->raw('$this->escaper->escape'); + $this->compileArguments($compiler); + } +} diff --git a/src/Node/ModuleNode.php b/src/Node/ModuleNode.php index 8f2283475..c39e3bcb1 100644 --- a/src/Node/ModuleNode.php +++ b/src/Node/ModuleNode.php @@ -74,6 +74,7 @@ final class ModuleNode extends Node implements CoercesChildrenToStringInterface 'index' => null, 'embedded_templates' => $embeddedTemplates, 'strategy' => false, + 'escaper' => false, ], 1); // populate the template name of all node children @@ -201,8 +202,14 @@ final class ModuleNode extends Node implements CoercesChildrenToStringInterface ->write("/**\n") ->write(" * @var array\n") ->write(" */\n") - ->write("private array \$macros = [];\n\n") + ->write("private array \$macros = [];\n") ; + + if ($this->getAttribute('escaper')) { + $compiler->write("private \\Twig\\Runtime\\EscaperRuntime \$escaper;\n"); + } + + $compiler->raw("\n"); } protected function compileConstructor(Compiler $compiler): void @@ -215,6 +222,10 @@ final class ModuleNode extends Node implements CoercesChildrenToStringInterface ->write("\$this->source = \$this->getSourceContext();\n\n") ; + if ($this->getAttribute('escaper')) { + $compiler->write("\$this->escaper = \$env->getRuntime('Twig\\Runtime\\EscaperRuntime');\n\n"); + } + // parent if (!$this->hasNode('parent')) { $compiler->write("\$this->parent = false;\n\n"); diff --git a/src/NodeVisitor/EscaperNodeVisitor.php b/src/NodeVisitor/EscaperNodeVisitor.php index 09a04b446..6063d8586 100644 --- a/src/NodeVisitor/EscaperNodeVisitor.php +++ b/src/NodeVisitor/EscaperNodeVisitor.php @@ -18,6 +18,7 @@ use Twig\Node\BlockNode; use Twig\Node\BlockReferenceNode; use Twig\Node\Expression\AbstractExpression; use Twig\Node\Expression\ConstantExpression; +use Twig\Node\Expression\Filter\EscapeFilter; use Twig\Node\Expression\FilterExpression; use Twig\Node\Expression\OperatorEscapeInterface; use Twig\Node\ImportNode; @@ -40,6 +41,7 @@ final class EscaperNodeVisitor implements NodeVisitorInterface private $traverser; private $defaultStrategy = false; private $safeVars = []; + private bool $usesEscaper = false; public function __construct() { @@ -55,6 +57,7 @@ final class EscaperNodeVisitor implements NodeVisitorInterface $node->setAttribute('strategy', \is_string($this->defaultStrategy) ? $this->defaultStrategy : false); $this->safeVars = []; $this->blocks = []; + $this->usesEscaper = false; } elseif ($node instanceof AutoEscapeNode) { $this->statusStack[] = $node->getAttribute('value'); } elseif ($node instanceof BlockNode) { @@ -69,10 +72,15 @@ final class EscaperNodeVisitor implements NodeVisitorInterface public function leaveNode(Node $node, Environment $env): ?Node { if ($node instanceof ModuleNode) { + $node->setAttribute('escaper', $this->usesEscaper); $this->defaultStrategy = false; $this->safeVars = []; $this->blocks = []; } elseif ($node instanceof FilterExpression) { + if ($node instanceof EscapeFilter) { + $this->useTemplateEscaper($node); + } + return $this->preEscapeFilterNode($node, $env); } elseif ($node instanceof PrintNode && false !== $type = $this->needEscaping()) { $expression = $node->getNode('expr'); @@ -175,8 +183,20 @@ final class EscaperNodeVisitor implements NodeVisitorInterface $line = $node->getTemplateLine(); $filter = $env->getFilter('escape'); $args = new Nodes([new ConstantExpression($type, $line), new ConstantExpression(null, $line), new ConstantExpression(true, $line)]); + $class = $filter->getNodeClass(); + $expression = new $class($node, $filter, $args, $line); - return new FilterExpression($node, $filter, $args, $line); + if ($expression instanceof EscapeFilter) { + $this->useTemplateEscaper($expression); + } + + return $expression; + } + + private function useTemplateEscaper(EscapeFilter $filter): void + { + $filter->setAttribute('template_escaper', true); + $this->usesEscaper = true; } public function getPriority(): int diff --git a/tests/Node/Expression/Filter/EscapeTest.php b/tests/Node/Expression/Filter/EscapeTest.php new file mode 100644 index 000000000..a3bb28ddc --- /dev/null +++ b/tests/Node/Expression/Filter/EscapeTest.php @@ -0,0 +1,35 @@ +getFilter('escape'), $arguments, 1); + yield 'not flagged by the escaper node visitor' => [$node, '$this->env->getRuntime(\'Twig\Runtime\EscaperRuntime\')->escape("foo", "html")', $env]; + + $node = new EscapeFilter(new ConstantExpression('foo', 1), $env->getFilter('escape'), $arguments, 1); + $node->setAttribute('template_escaper', true); + yield 'flagged by the escaper node visitor' => [$node, '$this->escaper->escape("foo", "html")', $env]; + } +} diff --git a/tests/NodeVisitor/EscaperTest.php b/tests/NodeVisitor/EscaperTest.php new file mode 100644 index 000000000..35e9d071c --- /dev/null +++ b/tests/NodeVisitor/EscaperTest.php @@ -0,0 +1,83 @@ + $template, 'embedded' => '{% block content %}{% endblock %}']), ['autoescape' => $autoescape]); + + $this->assertSame($escaperClasses, substr_count($env->compileSource($env->getLoader()->getSourceContext('index')), 'private \Twig\Runtime\EscaperRuntime $escaper;')); + $this->assertSame($expected, $env->render('index', ['foo' => '
'])); + } + + public static function provideTemplates(): iterable + { + yield 'autoescaped print' => ['html', '{{ foo }}', 1, '<br>']; + yield 'print marked as safe' => ['html', '{{ foo|raw }}', 0, '
']; + yield 'autoescaping disabled' => [false, '{{ foo }}{# not autoescaped #}', 0, '
']; + yield 'explicit escape filter' => [false, '{{ foo|e }}', 1, '<br>']; + yield 'autoescape tag' => [false, '{% autoescape "html" %}{{ foo }}{% endautoescape %}', 1, '<br>']; + yield 'escaping in an embedded template only' => [false, '{% embed "embedded" %}{% block content %}{{ foo|e }}{% endblock %}{% endembed %}', 1, '<br>']; + } + + public function testEscapeFilterAddedByALaterVisitorStillEscapes(): void + { + $env = new Environment(new ArrayLoader(['index' => '{{ "
" }}']), ['autoescape' => false]); + $env->addExtension(new class extends AbstractExtension { + public function getNodeVisitors(): array + { + return [new class implements NodeVisitorInterface { + public function enterNode(Node $node, Environment $env): Node + { + return $node; + } + + public function leaveNode(Node $node, Environment $env): ?Node + { + if (!$node instanceof PrintNode) { + return $node; + } + + $filter = $env->getFilter('escape'); + $class = $filter->getNodeClass(); + + return new PrintNode(new $class($node->getNode('expr'), $filter, new Nodes([new ConstantExpression('html', 1)]), 1), 1); + } + + public function getPriority(): int + { + return 10; + } + }]; + } + }); + + $this->assertSame('<br>', $env->render('index')); + } +} From 8c737079b726af72ff8ef3c595be9f6a810ea1ef Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Fri, 25 Sep 2026 15:20:01 +0200 Subject: [PATCH 12/14] Prepare the 3.30.0 release --- CHANGELOG | 2 +- src/Environment.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 7a2218ba7..09d38e601 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,4 +1,4 @@ -# 3.30.0 (2026-XX-XX) +# 3.30.0 (2026-09-25) * Fix `split`, `random`, and `shuffle` merging a trailing newline into the last character of a string * Speed up splitting a string into characters in `split`, `random`, and `shuffle` diff --git a/src/Environment.php b/src/Environment.php index 9eccd112f..7062ecb14 100644 --- a/src/Environment.php +++ b/src/Environment.php @@ -43,12 +43,12 @@ use Twig\TokenParser\TokenParserInterface; */ class Environment { - public const VERSION = '3.30.0-DEV'; + public const VERSION = '3.30.0'; public const VERSION_ID = 33000; public const MAJOR_VERSION = 3; public const MINOR_VERSION = 30; public const RELEASE_VERSION = 0; - public const EXTRA_VERSION = 'DEV'; + public const EXTRA_VERSION = ''; private $charset; private $loader; From 2558df70b6cc580bb4a4950a9c90f5ab06b896a4 Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Fri, 25 Sep 2026 15:20:29 +0200 Subject: [PATCH 13/14] Bump version --- CHANGELOG | 4 ++++ src/Environment.php | 8 ++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 09d38e601..34ddeb824 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,7 @@ +# 3.30.1 (2026-XX-XX) + + * n/a + # 3.30.0 (2026-09-25) * Fix `split`, `random`, and `shuffle` merging a trailing newline into the last character of a string diff --git a/src/Environment.php b/src/Environment.php index 7062ecb14..ddd779629 100644 --- a/src/Environment.php +++ b/src/Environment.php @@ -43,12 +43,12 @@ use Twig\TokenParser\TokenParserInterface; */ class Environment { - public const VERSION = '3.30.0'; - public const VERSION_ID = 33000; + public const VERSION = '3.30.1-DEV'; + public const VERSION_ID = 33001; public const MAJOR_VERSION = 3; public const MINOR_VERSION = 30; - public const RELEASE_VERSION = 0; - public const EXTRA_VERSION = ''; + public const RELEASE_VERSION = 1; + public const EXTRA_VERSION = 'DEV'; private $charset; private $loader; From ddd9045b5c8934c8ca9a30aeeeab1ea7bdfd715d Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Thu, 24 Sep 2026 23:19:18 +0200 Subject: [PATCH 14/14] Skip the property checker call and memoize the class constant probe when reading an object attribute --- CHANGELOG | 2 +- src/Extension/CoreExtension.php | 12 ++++++------ tests/TemplateTest.php | 28 ++++++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 34ddeb824..07baad014 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,6 @@ # 3.30.1 (2026-XX-XX) - * n/a + * Speed up reading object attributes backed by getters or class constants # 3.30.0 (2026-09-25) diff --git a/src/Extension/CoreExtension.php b/src/Extension/CoreExtension.php index f5f5e3952..9157bfa78 100644 --- a/src/Extension/CoreExtension.php +++ b/src/Extension/CoreExtension.php @@ -1867,7 +1867,7 @@ final class CoreExtension extends AbstractExtension static $propertyCheckers = []; if (isset($object->$item) - || ($propertyCheckers[$object::class][$item] ??= self::getPropertyChecker($object::class, $item))($object, $item) + || (($propertyChecker = $propertyCheckers[$object::class][$item] ??= self::getPropertyChecker($object::class, $item)) && $propertyChecker($object, $item)) ) { if ($isDefinedTest) { return true; @@ -1884,7 +1884,9 @@ final class CoreExtension extends AbstractExtension return ((array) $object)[$item]; } - if (\defined($object::class.'::'.$item)) { + static $constants = []; + + if ($constants[$object::class][$item] ??= \defined($object::class.'::'.$item)) { if ($isDefinedTest) { return true; } @@ -2266,7 +2268,7 @@ final class CoreExtension extends AbstractExtension return new GetAttrExpression($args[0], $args[1], $args[2] ?? null, Template::ANY_CALL, $line); } - private static function getPropertyChecker(string $class, string $property): \Closure + private static function getPropertyChecker(string $class, string $property): \Closure|false { static $classReflectors = []; @@ -2281,9 +2283,7 @@ final class CoreExtension extends AbstractExtension $property = $class->getProperty($property); if (!$property->isPublic() || $property->isStatic()) { - static $false; - - return $false ??= static fn () => false; + return false; } return static fn ($object) => $property->isInitialized($object); diff --git a/tests/TemplateTest.php b/tests/TemplateTest.php index fee648b24..f8e12324b 100644 --- a/tests/TemplateTest.php +++ b/tests/TemplateTest.php @@ -798,6 +798,34 @@ class TemplateTest extends TestCase // 0 should be in the method cache now, so this should fail $this->assertNull(CoreExtension::getAttribute($twig, $template->getSourceContext(), $getIsObject, 0)); } + + public function testRepeatedObjectAttributeLookupsResolveConsistently(): void + { + $twig = new Environment(new ArrayLoader(), ['strict_variables' => true]); + $source = new Source('', 'index.twig'); + $object = new TemplateAttributeResolution(); + + for ($i = 0; $i < 2; ++$i) { + $this->assertSame('constant', CoreExtension::getAttribute($twig, $source, $object, 'NAME')); + $this->assertTrue(CoreExtension::getAttribute($twig, $source, $object, 'NAME', [], Template::ANY_CALL, true)); + $this->assertSame('getter', CoreExtension::getAttribute($twig, $source, $object, 'hidden')); + $this->assertTrue(CoreExtension::getAttribute($twig, $source, $object, 'hidden', [], Template::ANY_CALL, true)); + $this->assertFalse(CoreExtension::getAttribute($twig, $source, $object, 'secret', [], Template::ANY_CALL, true)); + } + } +} + +class TemplateAttributeResolution +{ + public const NAME = 'constant'; + + private $hidden = 'property'; + private $secret = 'property'; + + public function getHidden() + { + return 'getter'; + } } class TemplateForTest extends Template