bug #4924 Resolve constant parent templates once instead of on every lookup (fabpot)

This PR was merged into the 3.x branch.

Discussion
----------

Resolve constant parent templates once instead of on every lookup

Currently, `Template::$parent` is only memoized by the compiled `doDisplay()`. Anything that reaches a template through `getParent()` without rendering it (`TemplateWrapper::hasBlock()`, `getBlockNames()`, `renderBlock()`, `yieldParentBlock()`, `MacroNamespace::getParent()`) re-evaluates `doGetParent()` and re-runs the sandbox check on every call, even when the parent is a string literal.

`doGetParent()` now does `$this->parent ??= $this->load("name", $line)` for a constant parent, which is what `doDisplay()` already does one line later. Dynamic parents are untouched and still resolve per context.

This also fixes a bug: `getParent()` loaded a constant parent with line `-1`, so a missing parent reported through `hasBlock()`/`getBlockNames()` lost its line number, while `render()` reported the `{% extends %}` line. All three now agree.

Commits
-------

c53b6468c9 Resolve constant parent templates once instead of on every lookup
This commit is contained in:
Fabien Potencier
2026-09-11 06:23:22 -07:00
4 changed files with 25 additions and 10 deletions
+1
View File
@@ -8,6 +8,7 @@
* Fix `TemplateWrapper::streamBlock()`, `TemplateWrapper::hasBlock()`, and `TemplateWrapper::getBlockNames()` omitting environment globals
* Fix exceptions from dynamic parent expressions escaping without template context
* Add the `BlockChain` class to compose blocks from multiple templates without using template internals
* Fix `TemplateWrapper::hasBlock()` and `TemplateWrapper::getBlockNames()` losing the `extends` line when the parent template does not exist
* Fix an output buffer leak when a parent block rendered in an expression throws in non-yield mode
* Add the `HtmlExtension::htmlAttrValue()` method to resolve a single HTML attribute value the way the `html_attr` function renders it
* Fix `html_attr` JSON encoding a `Stringable` value in a `data-*` attribute instead of using its string representation
+10 -9
View File
@@ -143,17 +143,18 @@ final class ModuleNode extends Node implements CoercesChildrenToStringInterface
;
if ($parent instanceof ConstantExpression) {
$compiler->subcompile($parent);
} else {
$compiler
->raw('$this->load(')
->subcompile($parent)
->raw(', ')
->repr($parent->getTemplateLine())
->raw(')')
;
// a constant parent never depends on the context, so resolve it once
$compiler->raw('$this->parent ??= ');
}
$compiler
->raw('$this->load(')
->subcompile($parent)
->raw(', ')
->repr($parent->getTemplateLine())
->raw(')')
;
$compiler
->raw(";\n")
->outdent()
+1 -1
View File
@@ -232,7 +232,7 @@ class __TwigTemplate_%x extends Template
protected function doGetParent(array \$context): bool|string|Template|TemplateWrapper
{
// line 1
return "layout.twig";
return \$this->parent ??= \$this->load("layout.twig", 1);
}
protected function doDisplay(array \$context, array \$blocks = []): iterable
+13
View File
@@ -139,6 +139,19 @@ class TemplateWrapperTest extends TestCase
yield 'exception thrown by parent expression' => ['{% extends boom() %}', new TwigFunction('boom', static function (): never { throw new \DomainException('kaboom'); }), \DomainException::class];
}
public function testBlockIntrospectionReportsTheExtendsLineForAMissingParent(): void
{
$twig = new Environment(new ArrayLoader(['index' => "\n\n{% extends 'missing' %}"]));
try {
$twig->load('index')->hasBlock('foo');
$this->fail('Introspecting a template with a missing parent must fail.');
} catch (LoaderError $e) {
$this->assertSame('Template "missing" is not defined.', $e->getRawMessage());
$this->assertSame(3, $e->getTemplateLine());
}
}
public function testRenderBlock(): void
{
$twig = new Environment(new ArrayLoader([