feature #4925 Resolve block chains against the render context (fabpot)

This PR was merged into the 3.x branch.

Discussion
----------

Resolve block chains against the render context

While working on the Symfony PR for #4917, I realize that the performance was worse with the Twig's way. #4924 fixes part of the performance "regression". This one closes the gap.

`BlockChain` currently freezes each template's lineage by cloning it, so the block map and `parent()` both resolve `{% extends %}` against the constructor context.

This PR changes that to resolve the chain against the render context instead. `hasBlock()` and `getBlockNames()` take a context, like `TemplateWrapper` already does, and the third constructor argument becomes a default the render context can override. A lineage whose templates all have a fixed parent (none, or constant) is resolved once and cached, so the common case costs nothing; a dynamic `{% extends %}` re-resolves per call (not use by Symfony anyway).

This fixes also an inconsistency: a chained template with a dynamic parent now behaves exactly as it does when rendered directly. It also removes all changes in the "core" logic of Template.

```
                       construct    render   1 chain + 40 renders
Symfony's engine today   13.44us    0.78us         50.3us
#4917                    ~70us      0.96us        ~117us
this PR                   0.61us    0.84us         53.0us
```

Commits
-------

897717d78f Resolve block chains against the render context instead of freezing lineages
This commit is contained in:
Fabien Potencier
2026-09-12 09:49:05 +02:00
11 changed files with 274 additions and 256 deletions
+17 -3
View File
@@ -108,9 +108,23 @@ templates share the composed block set, only combine templates that are trusted
to call one another's blocks. Chained blocks have the same top-level macro
import limitation as blocks rendered directly.
The optional third constructor argument provides variables used to resolve
dynamic parent expressions. Parent hierarchies are fixed when the chain is
created; create another chain when these variables change.
Every method takes the render context, and the whole chain is resolved against
it, so a template using a dynamic ``{% extends %}`` behaves exactly as it would
when rendered directly. The optional third constructor argument provides
default variables for that resolution, which the render context can override::
$blocks = new BlockChain($twig, ['application_theme.html.twig'], [
'layout' => 'wide_layout.html.twig',
]);
// resolved against wide_layout.html.twig
$blocks->getBlockNames();
// resolved against narrow_layout.html.twig
$blocks->getBlockNames(['layout' => 'narrow_layout.html.twig']);
Pass the same context to ``hasBlock()``, ``getBlockNames()`` and
``renderBlock()`` to keep them consistent.
Streaming Templates
-------------------
+132 -48
View File
@@ -18,21 +18,29 @@ use Twig\Error\RuntimeError;
*/
final class BlockChain
{
/** @var list<Template> */
private array $templates = [];
/** @var list<Template> */
private array $lineage = [];
/** @var array<string, array{Template, string}> */
private array $blocks;
private Template $template;
private array $blocks = [];
/**
* Whether the lineage can no longer move, making every further resolution pointless.
*/
private bool $fixed = false;
/**
* @param iterable<string|TemplateWrapper> $templates Templates ordered from highest to lowest precedence
* @param array<string, mixed> $context Default variables used to resolve dynamic parent expressions
*/
public function __construct(
private Environment $env,
iterable $templates,
array $context = [],
private array $context = [],
) {
$resolution = new BlockResolutionContext($env, $context + $env->getGlobals());
$blocks = [];
foreach ($templates as $template) {
if (\is_string($template)) {
$template = $env->load($template);
@@ -41,67 +49,143 @@ final class BlockChain
throw new \TypeError(\sprintf('Block chain templates must be strings or "%s" instances, "%s" given.', TemplateWrapper::class, get_debug_type($template)));
}
$current = $template->unwrap()->freezeLineage($resolution);
$this->template ??= $current;
do {
foreach ($current->getBlocks() as $name => $block) {
if (isset($blocks[$name])) {
continue;
}
if (!\is_array($block) || !isset($block[0], $block[1]) || !$block[0] instanceof Template || !\is_string($block[1])) {
throw new \LogicException('A block must be a method on a \Twig\Template instance.');
}
$template = $template->unwrap();
if (!$template->isOwnedBy($env)) {
throw new \LogicException('A block chain cannot contain templates from different Twig environments.');
}
$resolution->assertOwns($block[0]);
$blocks[$name] = $block;
}
} while (false !== $current = $resolution->getParent($current));
$this->templates[] = $template;
}
if (!isset($this->template)) {
if (!$this->templates) {
throw new \InvalidArgumentException('A block chain requires at least one template.');
}
$this->blocks = $blocks;
}
public function hasBlock(string $name): bool
{
return isset($this->blocks[$name]);
}
/**
* @param array<string, mixed> $context
*/
public function hasBlock(string $name, array $context = []): bool
{
$blocks = $this->fixed ? $this->blocks : $this->resolveBlocks($context + $this->context + $this->env->getGlobals());
return isset($blocks[$name]);
}
/**
* @param array<string, mixed> $context
*
* @return string[]
*/
public function getBlockNames(): array
public function getBlockNames(array $context = []): array
{
return array_keys($this->blocks);
return array_keys($this->fixed ? $this->blocks : $this->resolveBlocks($context + $this->context + $this->env->getGlobals()));
}
/**
* @param array<string, mixed> $context
*
* @return iterable<scalar|\Stringable|null>
*/
public function streamBlock(string $name, array $context = []): iterable
{
yield from $this->getBlock($name)->yieldBlock($name, $context + $this->env->getGlobals(), $this->blocks);
}
public function renderBlock(string $name, array $context = []): string
{
return $this->getBlock($name)->renderBlock($name, $context + $this->env->getGlobals(), $this->blocks);
}
public function displayBlock(string $name, array $context = []): void
{
$this->getBlock($name)->displayBlock($name, $context + $this->env->getGlobals(), $this->blocks);
}
private function getBlock(string $name): Template
{
if (isset($this->blocks[$name])) {
return $this->blocks[$name][0];
$context += $this->context + $this->env->getGlobals();
$blocks = $this->fixed ? $this->blocks : $this->resolveBlocks($context);
if (!isset($blocks[$name])) {
$this->throwUnknownBlock($name);
}
throw new RuntimeError(\sprintf('Block "%s" on template "%s" does not exist.', $name, $this->template->getTemplateName()), -1, $this->template->getSourceContext());
yield from $this->templates[0]->yieldBlock($name, $context, $blocks);
}
/**
* @param array<string, mixed> $context
*/
public function renderBlock(string $name, array $context = []): string
{
$context += $this->context + $this->env->getGlobals();
$blocks = $this->fixed ? $this->blocks : $this->resolveBlocks($context);
if (!isset($blocks[$name])) {
$this->throwUnknownBlock($name);
}
return $this->templates[0]->renderBlock($name, $context, $blocks);
}
/**
* @param array<string, mixed> $context
*/
public function displayBlock(string $name, array $context = []): void
{
$context += $this->context + $this->env->getGlobals();
$blocks = $this->fixed ? $this->blocks : $this->resolveBlocks($context);
if (!isset($blocks[$name])) {
$this->throwUnknownBlock($name);
}
$this->templates[0]->displayBlock($name, $context, $blocks);
}
/**
* @param array<string, mixed> $context
*
* @return array<string, array{Template, string}>
*/
private function resolveBlocks(array $context): array
{
[$lineage, $this->fixed] = $this->resolveLineage($context);
if ($lineage === $this->lineage) {
return $this->blocks;
}
$blocks = [];
foreach ($lineage as $template) {
foreach ($template->getBlocks() as $name => $block) {
$blocks[$name] ??= $block;
}
}
$this->lineage = $lineage;
return $this->blocks = $blocks;
}
/**
* @param array<string, mixed> $context
*
* @return array{list<Template>, bool}
*/
private function resolveLineage(array $context): array
{
$lineage = [];
$fixed = true;
foreach ($this->templates as $template) {
$seen = [];
do {
if (isset($seen[$id = spl_object_id($template)])) {
throw new \LogicException(\sprintf('Circular template inheritance detected while building a block chain from "%s".', $template->getTemplateName()));
}
$seen[$id] = true;
$lineage[] = $template;
$parent = $template->getParent($context);
$fixed = $fixed && $template->hasFixedParent();
// a dynamic parent expression can evaluate to a template from another environment
$template = $parent instanceof TemplateWrapper ? $parent->unwrap() : $parent;
if (false !== $template && !$template->isOwnedBy($this->env)) {
throw new \LogicException('A block chain cannot contain templates from different Twig environments.');
}
} while (false !== $template);
}
return [$lineage, $fixed];
}
private function throwUnknownBlock(string $name): never
{
throw new RuntimeError(\sprintf('Block "%s" on template "%s" does not exist.', $name, $this->templates[0]->getTemplateName()), -1, $this->templates[0]->getSourceContext());
}
}
-91
View File
@@ -1,91 +0,0 @@
<?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;
/**
* @internal
*/
final class BlockResolutionContext
{
/** @var \SplObjectStorage<Template, Template|false> */
private \SplObjectStorage $parents;
/** @var \SplObjectStorage<Template, Template> */
private \SplObjectStorage $frozen;
/** @var \SplObjectStorage<Template, true> */
private \SplObjectStorage $freezing;
public function __construct(
private Environment $env,
private array $context,
) {
$this->parents = new \SplObjectStorage();
$this->frozen = new \SplObjectStorage();
$this->freezing = new \SplObjectStorage();
}
public function getParent(Template $template): Template|false
{
if ($this->parents->offsetExists($template)) {
return $this->parents[$template];
}
$parent = $template->getParent($this->context);
if ($parent instanceof TemplateWrapper) {
$parent = $parent->unwrap();
}
return $this->parents[$template] = $parent;
}
public function setParent(Template $template, Template|false $parent): void
{
$this->parents[$template] = $parent;
}
public function assertOwns(Template $template): void
{
if (!$template->isOwnedBy($this->env)) {
throw new \LogicException('A block chain cannot contain templates from different Twig environments.');
}
}
public function isFrozen(Template $template): bool
{
return $this->frozen->offsetExists($template);
}
public function getFrozen(Template $template): Template
{
return $this->frozen[$template];
}
public function setFrozen(Template $template, Template $frozen): void
{
$this->frozen[$template] = $frozen;
}
public function beginFreeze(Template $template): void
{
if ($this->freezing->offsetExists($template)) {
throw new \LogicException(\sprintf('Circular template inheritance detected while building a block chain from "%s".', $template->getTemplateName()));
}
$this->freezing[$template] = true;
}
public function endFreeze(Template $template): void
{
$this->freezing->offsetUnset($template);
}
}
+1 -1
View File
@@ -37,7 +37,7 @@ class BlockNode extends Node
->write(" */\n")
->write(\sprintf("public function block_%s(array \$context, array \$blocks = []): iterable\n", $this->getAttribute('name')), "{\n")
->indent()
->write("\$macros = null === \$this->macroImportSource ? \$this->macros : \$this->rebindMacroImports(\$this->macroImportSource->macros);\n")
->write("\$macros = \$this->macros;\n")
;
$compiler
+1 -1
View File
@@ -115,7 +115,7 @@ class MacroNode extends Node
->raw("): string|Markup {\n")
->indent()
->addDebugInfo($this)
->write("\$macros = null === \$this->macroImportSource ? \$this->macros : \$this->rebindMacroImports(\$this->macroImportSource->macros);\n")
->write("\$macros = \$this->macros;\n")
->write("\$context = [\n")
->indent()
;
+4 -61
View File
@@ -39,7 +39,6 @@ abstract class Template
protected $traitAliases = [];
protected $extensions = [];
protected $sandbox;
protected ?self $macroImportSource = null;
private $useYield;
private ?MacroNamespace $macroNamespace = null;
@@ -79,11 +78,6 @@ abstract class Template
public function getParent(array $context): self|TemplateWrapper|false
{
if (null !== $this->parent) {
// only block chain clones set macroImportSource; they never run yield(), where the sandbox check normally happens
if (null !== $this->macroImportSource) {
$this->ensureSecurityChecked();
}
return $this->parent;
}
@@ -379,42 +373,12 @@ abstract class Template
}
/**
* @internal
* Returns whether getParent() has stopped depending on the context, which
* only ever happens for a template with no parent or with a constant one.
*/
public function freezeLineage(BlockResolutionContext $resolution): self
public function hasFixedParent(): bool
{
$resolution->assertOwns($this);
if ($resolution->isFrozen($this)) {
return $resolution->getFrozen($this);
}
$resolution->beginFreeze($this);
try {
if (false === $parent = $resolution->getParent($this)) {
$resolution->setFrozen($this, $this);
return $this;
}
$template = clone $this;
foreach ($template->blocks as &$block) {
if ($block[0] === $this) {
$block[0] = $template;
}
}
unset($block);
$template->macroNamespace = null;
// Keep module-level imports live while rebinding self imports to the clone.
$template->macroImportSource = $this;
$template->parent = $frozenParent = $parent->freezeLineage($resolution);
$resolution->setParent($template, $frozenParent);
$resolution->setFrozen($this, $template);
return $template;
} finally {
$resolution->endFreeze($this);
}
return null !== $this->parent;
}
/**
@@ -561,27 +525,6 @@ abstract class Template
return [];
}
/**
* @param array<string, MacroNamespace> $macros
*
* @return array<string, MacroNamespace>
*/
protected function rebindMacroImports(array $macros): array
{
if (null === $this->macroImportSource) {
return $macros;
}
$imported = $this->macroImportSource->getMacroNamespace();
foreach ($macros as $name => $namespace) {
if ($namespace === $imported) {
$macros[$name] = $this->getMacroNamespace();
}
}
return $macros;
}
/**
* Runs the sandbox security check against the current sandbox state.
*
+94 -47
View File
@@ -54,7 +54,7 @@ class BlockChainTest extends TestCase
* @dataProvider yieldModes
*/
#[DataProvider('yieldModes')]
public function testNestedBlocksUseTheEffectiveNamespaceAndParentUsesTheFrozenLineage(bool $useYield): void
public function testNestedBlocksUseTheComposedSetAndParentUsesTheRenderContextLineage(bool $useYield): void
{
$twig = new Environment(new ArrayLoader([
'theme' => '{% extends parent %}{% block field %}theme/{{ parent() }}/{{ block("suffix") }}{% endblock %}',
@@ -65,7 +65,8 @@ class BlockChainTest extends TestCase
$chain = new BlockChain($twig, ['theme', 'suffix'], ['parent' => 'parent1']);
$this->assertSame('theme/parent1/suffix', $chain->renderBlock('field', ['parent' => 'parent2']));
$this->assertSame('theme/parent1/suffix', $chain->renderBlock('field'));
$this->assertSame('theme/parent2/suffix', $chain->renderBlock('field', ['parent' => 'parent2']));
}
public function testExplicitTemplateBlockCallsResolveOutsideTheChainNamespace(): void
@@ -81,7 +82,41 @@ class BlockChainTest extends TestCase
$this->assertSame('explicit', $chain->renderBlock('field'));
}
public function testFreezingAChainDoesNotChangeTheLoadedTemplate(): void
public function testTheConstructorContextIsADefaultTheRenderContextCanOverride(): void
{
$twig = new Environment(new ArrayLoader([
'theme' => '{% extends parent %}{% block field %}{{ parent() }}{% endblock %}',
'parent1' => '{% block field %}one{% endblock %}',
'parent2' => '{% block field %}two{% endblock %}',
]), ['autoescape' => false, 'use_yield' => true]);
$chain = new BlockChain($twig, ['theme'], ['parent' => 'parent1']);
$this->assertSame('one', $chain->renderBlock('field'));
$this->assertSame('two', $chain->renderBlock('field', ['parent' => 'parent2']));
$this->assertSame('one', $chain->renderBlock('field'));
}
public function testIntrospectionAndRenderingAgreeOnTheSameContext(): void
{
$twig = new Environment(new ArrayLoader([
'theme' => '{% extends parent %}{% block field %}{{ parent() }}{% endblock %}',
'parent1' => '{% block field %}one{% endblock %}{% block only1 %}{% endblock %}',
'parent2' => '{% block field %}two{% endblock %}{% block only2 %}{% endblock %}',
]), ['autoescape' => false, 'use_yield' => true]);
$chain = new BlockChain($twig, ['theme'], ['parent' => 'parent1']);
$this->assertSame(['field', 'only1'], $chain->getBlockNames());
$this->assertTrue($chain->hasBlock('only1'));
$this->assertFalse($chain->hasBlock('only2'));
$context = ['parent' => 'parent2'];
$this->assertSame(['field', 'only2'], $chain->getBlockNames($context));
$this->assertFalse($chain->hasBlock('only1', $context));
$this->assertTrue($chain->hasBlock('only2', $context));
$this->assertSame('two', $chain->renderBlock('field', $context));
}
public function testRenderingThroughTheChainDoesNotChangeTheLoadedTemplate(): void
{
$twig = new Environment(new ArrayLoader([
'theme' => '{% extends parent %}{% block field %}{{ parent() }}{% endblock %}',
@@ -91,8 +126,9 @@ class BlockChainTest extends TestCase
$template = $twig->load('theme');
$chain = new BlockChain($twig, [$template], ['parent' => 'parent1']);
$this->assertSame('one', $chain->renderBlock('field', ['parent' => 'parent2']));
$this->assertSame('one', $chain->renderBlock('field'));
$this->assertSame('two', $template->renderBlock('field', ['parent' => 'parent2']));
$this->assertSame('one', $chain->renderBlock('field'));
}
public function testStructuralContextIncludesEnvironmentGlobals(): void
@@ -183,11 +219,13 @@ class BlockChainTest extends TestCase
$this->blocks = ['field' => [new \stdClass(), 'block_field']];
}
};
$chain = new BlockChain($twig, [new TemplateWrapper($twig, $template)]);
$this->assertSame(['field'], $chain->getBlockNames());
$this->expectException(\LogicException::class);
$this->expectExceptionMessage('A block must be a method on a \Twig\Template instance.');
new BlockChain($twig, [new TemplateWrapper($twig, $template)]);
$chain->renderBlock('field');
}
public function testRejectsTemplatesThatAreNotStringsOrWrappers(): void
@@ -227,24 +265,26 @@ class BlockChainTest extends TestCase
{
$twig = new Environment(new ArrayLoader(['theme' => '{% extends parent %}']));
$other = new Environment(new ArrayLoader(['parent' => '']));
$chain = new BlockChain($twig, ['theme'], ['parent' => $other->load('parent')]);
$this->expectException(\LogicException::class);
$this->expectExceptionMessage('A block chain cannot contain templates from different Twig environments.');
new BlockChain($twig, ['theme'], ['parent' => $other->load('parent')]);
$chain->getBlockNames();
}
public function testDynamicParentSecurityIsCheckedDuringConstruction(): void
public function testDynamicParentSecurityIsCheckedWhenTheLineageIsResolved(): void
{
$twig = new Environment(new ArrayLoader([
'theme' => '{% extends parent|upper %}',
'PARENT' => '',
]));
$twig->addExtension(new SandboxExtension(new SecurityPolicy(['extends']), true));
$chain = new BlockChain($twig, ['theme'], ['parent' => 'parent']);
$this->expectException(SecurityNotAllowedFilterError::class);
new BlockChain($twig, ['theme'], ['parent' => 'parent']);
$chain->getBlockNames();
}
public function testDefiningTemplateSecurityIsCheckedDuringRendering(): void
@@ -282,7 +322,7 @@ class BlockChainTest extends TestCase
* @dataProvider yieldModes
*/
#[DataProvider('yieldModes')]
public function testSandboxPolicyChangesAreCheckedOnFrozenIntermediateParents(bool $useYield): void
public function testSandboxPolicyChangesAreCheckedOnIntermediateParents(bool $useYield): void
{
$twig = new Environment(new ArrayLoader([
'theme' => '{% extends "middle" %}{% block field %}{{ parent() }}{% endblock %}',
@@ -356,7 +396,7 @@ class BlockChainTest extends TestCase
$this->assertSame('one', $chain->renderBlock('field'));
}
public function testInheritedMacroLookupUsesTheFrozenLineage(): void
public function testInheritedMacroLookupUsesTheRenderContextLineage(): void
{
$twig = new Environment(new ArrayLoader([
'theme' => '{% extends parent %}{% block field %}{{ _self.label() }}{% endblock %}',
@@ -366,14 +406,15 @@ class BlockChainTest extends TestCase
$chain = new BlockChain($twig, ['theme'], ['parent' => 'parent1']);
$this->assertSame('one', $chain->renderBlock('field', ['parent' => 'parent2']));
$this->assertSame('one', $chain->renderBlock('field'));
$this->assertSame('two', $chain->renderBlock('field', ['parent' => 'parent2']));
}
/**
* @dataProvider yieldModes
*/
#[DataProvider('yieldModes')]
public function testSelfMacroImportsInitializedAfterConstructionUseTheFrozenLineage(bool $useYield): void
public function testSelfMacroImportsUseTheRenderContextLineage(bool $useYield): void
{
$twig = new Environment(new ArrayLoader([
'theme' => '{% extends parent %}{% import _self as own %}{% block field %}{{ own.label() }}{% endblock %}',
@@ -383,50 +424,32 @@ class BlockChainTest extends TestCase
$chain = new BlockChain($twig, ['theme'], ['parent' => 'parent1']);
$this->assertSame('two', $twig->render('theme', ['parent' => 'parent2']));
$this->assertSame('one', $chain->renderBlock('field', ['parent' => 'parent2']));
$this->assertSame('one', $chain->renderBlock('field'));
$this->assertSame('two', $chain->renderBlock('field', ['parent' => 'parent2']));
}
/**
* @dataProvider yieldModes
*/
#[DataProvider('yieldModes')]
public function testPreWarmedSelfMacroImportsUseTheFrozenLineage(bool $useYield): void
public function testFromSelfImportsResolveThroughTheLineage(bool $useYield): void
{
$twig = new Environment(new ArrayLoader([
'theme' => '{% extends parent %}{% import _self as own %}{% block field %}{{ own.label() }}{% endblock %}',
'parent1' => '{% macro label() %}one{% endmacro %}{{ block("field") }}',
'parent2' => '{% macro label() %}two{% endmacro %}{{ block("field") }}',
'theme' => '{% extends "parent" %}{% from _self import label %}{% macro wrapped() %}{{ label() }}{% endmacro %}{% block field %}{{ label() }}/{{ _self.wrapped() }}{% endblock %}',
'parent' => '{% macro label() %}one{% endmacro %}',
]), ['autoescape' => false, 'use_yield' => $useYield]);
$this->assertSame('two', $twig->render('theme', ['parent' => 'parent2']));
$this->assertSame('', $twig->render('theme'));
$chain = new BlockChain($twig, ['theme'], ['parent' => 'parent1']);
$chain = new BlockChain($twig, ['theme']);
$this->assertSame('one', $chain->renderBlock('field', ['parent' => 'parent2']));
$this->assertSame('one/one', $chain->renderBlock('field'));
}
/**
* @dataProvider yieldModes
*/
#[DataProvider('yieldModes')]
public function testFromSelfImportsUseTheFrozenLineage(bool $useYield): void
{
$twig = new Environment(new ArrayLoader([
'theme' => '{% extends parent %}{% from _self import label %}{% macro wrapped() %}{{ label() }}{% endmacro %}{% block field %}{{ label() }}/{{ _self.wrapped() }}{% endblock %}',
'parent1' => '{% macro label() %}one{% endmacro %}',
'parent2' => '{% macro label() %}two{% endmacro %}',
]), ['autoescape' => false, 'use_yield' => $useYield]);
$this->assertSame('', $twig->render('theme', ['parent' => 'parent2']));
$chain = new BlockChain($twig, ['theme'], ['parent' => 'parent1']);
$this->assertSame('one/one', $chain->renderBlock('field', ['parent' => 'parent2']));
}
/**
* @dataProvider yieldModes
*/
#[DataProvider('yieldModes')]
public function testImportedMacroNamespacesStayOutsideTheFrozenLineage(bool $useYield): void
public function testImportedMacroNamespacesKeepTheirOwnLineage(bool $useYield): void
{
$twig = new Environment(new ArrayLoader([
'theme' => '{% extends parent %}{% import parent as inherited %}{% block field %}{{ inherited.label() }}{% endblock %}',
@@ -470,17 +493,40 @@ class BlockChainTest extends TestCase
* @dataProvider yieldModes
*/
#[DataProvider('yieldModes')]
public function testSelfMacroImportsInMacroBodiesUseTheFrozenLineage(bool $useYield): void
public function testSelfMacroImportsInMacroBodiesResolveThroughTheLineage(bool $useYield): void
{
$twig = new Environment(new ArrayLoader([
'theme' => '{% extends "parent" %}{% import _self as own %}{% macro wrapped() %}{{ own.label() }}{% endmacro %}{% block field %}{{ _self.wrapped() }}{% endblock %}',
'parent' => '{% macro label() %}one{% endmacro %}',
]), ['autoescape' => false, 'use_yield' => $useYield]);
$chain = new BlockChain($twig, ['theme']);
$this->assertSame('', $twig->render('theme'));
$this->assertSame('one', $chain->renderBlock('field'));
}
/**
* @dataProvider yieldModes
*/
#[DataProvider('yieldModes')]
public function testMacroBodiesCannotResolveADynamicParentJustLikeADirectRender(bool $useYield): void
{
$twig = new Environment(new ArrayLoader([
'theme' => '{% extends parent %}{% import _self as own %}{% macro wrapped() %}{{ own.label() }}{% endmacro %}{% block field %}{{ _self.wrapped() }}{% endblock %}',
'parent1' => '{% macro label() %}one{% endmacro %}',
'parent2' => '{% macro label() %}two{% endmacro %}',
'parent' => '{% macro label() %}one{% endmacro %}{{ block("field") }}',
]), ['autoescape' => false, 'use_yield' => $useYield]);
$chain = new BlockChain($twig, ['theme'], ['parent' => 'parent1']);
$chain = new BlockChain($twig, ['theme'], ['parent' => 'parent']);
$this->assertSame('', $twig->render('theme', ['parent' => 'parent2']));
$this->assertSame('one', $chain->renderBlock('field', ['parent' => 'parent2']));
// a macro body only receives the macro arguments, so the "parent" variable is out of reach either way
$this->expectException(RuntimeError::class);
try {
$twig->render('theme', ['parent' => 'parent']);
$this->fail('Rendering the template directly must fail.');
} catch (RuntimeError) {
}
$chain->renderBlock('field');
}
/**
@@ -581,17 +627,18 @@ class BlockChainTest extends TestCase
$chain->renderBlock('missing');
}
public function testCircularInheritanceIsRejectedDuringConstruction(): void
public function testCircularInheritanceIsRejected(): void
{
$twig = new Environment(new ArrayLoader([
'one' => '{% extends "two" %}',
'two' => '{% extends "one" %}',
]));
$chain = new BlockChain($twig, ['one']);
$this->expectException(\LogicException::class);
$this->expectExceptionMessage('Circular template inheritance detected while building a block chain from "one".');
new BlockChain($twig, ['one']);
$chain->getBlockNames();
}
public function testRequiresAtLeastOneTemplate(): void
+1 -1
View File
@@ -47,7 +47,7 @@ class BlockTest extends NodeTestCase
*/
public function block_foo(array \$context, array \$blocks = []): iterable
{
\$macros = null === \$this->macroImportSource ? \$this->macros : \$this->rebindMacroImports(\$this->macroImportSource->macros);
\$macros = \$this->macros;
yield "foo";
yield from [];
}
+2 -2
View File
@@ -65,7 +65,7 @@ class MacroTest extends NodeTestCase
yield 'with use_yield = true' => [self::createNode(), <<<EOF
new \\Twig\\TwigMacro("foo", function (\$foo = null, \$bar = "Foo", \$_underscore = null, ...\$varargs): string|Markup {
// line 1
\$macros = null === \$this->macroImportSource ? \$this->macros : \$this->rebindMacroImports(\$this->macroImportSource->macros);
\$macros = \$this->macros;
\$context = [
"foo" => \$foo,
"bar" => \$bar,
@@ -86,7 +86,7 @@ EOF, new Environment(new ArrayLoader(), ['use_yield' => true]),
yield 'with use_yield = false' => [self::createNode(), <<<EOF
new \\Twig\\TwigMacro("foo", function (\$foo = null, \$bar = "Foo", \$_underscore = null, ...\$varargs): string|Markup {
// line 1
\$macros = null === \$this->macroImportSource ? \$this->macros : \$this->rebindMacroImports(\$this->macroImportSource->macros);
\$macros = \$this->macros;
\$context = [
"foo" => \$foo,
"bar" => \$bar,
+1 -1
View File
@@ -64,7 +64,7 @@ protected function loadDeclaredMacros(): array
return [
"foo" => new \\Twig\\TwigMacro("foo", function (\$foo = null, ...\$varargs): string|Markup {
// line 1
\$macros = null === \$this->macroImportSource ? \$this->macros : \$this->rebindMacroImports(\$this->macroImportSource->macros);
\$macros = \$this->macros;
\$context = [
"foo" => \$foo,
"varargs" => \$varargs,
+21
View File
@@ -253,6 +253,27 @@ class TemplateTest extends TestCase
$this->assertSame($level, $actualLevel);
}
public function testHasFixedParentOnlyReportsLineagesThatCanNoLongerMove(): void
{
$twig = new Environment(new ArrayLoader([
'no_parent' => '',
'constant_parent' => '{% extends "no_parent" %}',
'dynamic_parent' => '{% extends parent %}',
]));
$this->assertTrue($twig->load('no_parent')->unwrap()->hasFixedParent());
$constant = $twig->load('constant_parent')->unwrap();
$this->assertFalse($constant->hasFixedParent());
$constant->getParent([]);
$this->assertTrue($constant->hasFixedParent());
$dynamic = $twig->load('dynamic_parent')->unwrap();
$this->assertFalse($dynamic->hasFixedParent());
$dynamic->getParent(['parent' => 'no_parent']);
$this->assertFalse($dynamic->hasFixedParent());
}
public function testGetAttributeOnArrayWithConfusableKey(): void
{
$twig = new Environment(new ArrayLoader());