From 8274571d04ce7aed76c00a6178c1ed8cf2a1664f Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Tue, 22 Sep 2026 16:47:34 +0100 Subject: [PATCH] Stop the escaping safe analysis from retaining every analyzed node --- CHANGELOG | 1 + src/NodeVisitor/SafeAnalysisNodeVisitor.php | 52 +++++++-------------- tests/NodeVisitor/SafeAnalysisTest.php | 51 ++++++++++++++++++++ 3 files changed, 70 insertions(+), 34 deletions(-) create mode 100644 tests/NodeVisitor/SafeAnalysisTest.php diff --git a/CHANGELOG b/CHANGELOG index 202697ce0..692295745 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,6 @@ # 3.30.0 (2026-XX-XX) + * Fix the escaping safe analysis retaining every compiled template node for the lifetime of the environment * 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/NodeVisitor/SafeAnalysisNodeVisitor.php b/src/NodeVisitor/SafeAnalysisNodeVisitor.php index b1aea0f36..b5d88e303 100644 --- a/src/NodeVisitor/SafeAnalysisNodeVisitor.php +++ b/src/NodeVisitor/SafeAnalysisNodeVisitor.php @@ -29,9 +29,17 @@ use Twig\Node\Node; */ final class SafeAnalysisNodeVisitor implements NodeVisitorInterface { - private $data = []; + /** + * @var \WeakMap + */ + private \WeakMap $data; private $safeVars = []; + public function __construct() + { + $this->data = new \WeakMap(); + } + public function setSafeVars(array $safeVars): void { $this->safeVars = $safeVars; @@ -42,47 +50,23 @@ final class SafeAnalysisNodeVisitor implements NodeVisitorInterface */ public function getSafe(Node $node) { - $hash = spl_object_id($node); - if (!isset($this->data[$hash])) { - return []; + $safe = $this->data[$node] ?? []; + + if (\in_array('html_attr', $safe, true)) { + $safe[] = 'html'; + $safe[] = 'html_attr_relaxed'; } - foreach ($this->data[$hash] as $bucket) { - if ($bucket['key'] !== $node) { - continue; - } - - if (\in_array('html_attr', $bucket['value'], true)) { - $bucket['value'][] = 'html'; - $bucket['value'][] = 'html_attr_relaxed'; - } - - if (\in_array('html_attr_relaxed', $bucket['value'], true)) { - $bucket['value'][] = 'html'; - } - - return $bucket['value']; + if (\in_array('html_attr_relaxed', $safe, true)) { + $safe[] = 'html'; } - return []; + return $safe; } private function setSafe(Node $node, array $safe): void { - $hash = spl_object_id($node); - if (isset($this->data[$hash])) { - foreach ($this->data[$hash] as &$bucket) { - if ($bucket['key'] === $node) { - $bucket['value'] = $safe; - - return; - } - } - } - $this->data[$hash][] = [ - 'key' => $node, - 'value' => $safe, - ]; + $this->data[$node] = $safe; } public function enterNode(Node $node, Environment $env): Node diff --git a/tests/NodeVisitor/SafeAnalysisTest.php b/tests/NodeVisitor/SafeAnalysisTest.php new file mode 100644 index 000000000..9dbf539dd --- /dev/null +++ b/tests/NodeVisitor/SafeAnalysisTest.php @@ -0,0 +1,51 @@ + false, 'autoescape' => 'html']); + $source = new Source('{{ "a"|upper }}{{ foo.bar }}{{ [1, 2]|join("-") }}{{ b ? "y" : "n" }}', 'index'); + + $ast = $env->parse($env->tokenize($source)); + + $refs = []; + $collect = static function (Node $node) use (&$collect, &$refs) { + $refs[] = \WeakReference::create($node); + foreach ($node as $child) { + $collect($child); + } + }; + $collect($ast); + $this->assertNotEmpty($refs); + + unset($ast, $collect); + gc_collect_cycles(); + + $retained = 0; + foreach ($refs as $ref) { + if (null !== $ref->get()) { + ++$retained; + } + } + + $this->assertSame(0, $retained, \sprintf('%d of %d AST nodes are still referenced after compilation.', $retained, \count($refs))); + } +}