Skip to content

Commit bead9df

Browse files
committed
Improve outer block reference mechanism
Just passing along blocks to the embedded templates will quickly end up in recursive loops, not using the correct blocks etc. Blocks are still passed along but their name is being randomized. A BlockStack object is introduced which keeps track of the location of a block definition and the relation to each embedded component, and that randomized name, so that when the component instance is rendered the correct block definition can be accessed via the special outerBlocks variable (which refers to the BlockStack).
1 parent c086fff commit bead9df

34 files changed

+583
-72
lines changed

src/TwigComponent/src/BlockStack.php

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Symfony\UX\TwigComponent;
6+
7+
use Twig\Template;
8+
9+
/**
10+
* @author Bart Vanderstukken <bart.vanderstukken@gmail.com>
11+
*
12+
* @internal
13+
*/
14+
final class BlockStack
15+
{
16+
private const OUTER_BLOCK_PREFIX = 'outer__';
17+
public const OUTER_BLOCK_FALLBACK_NAME = self::OUTER_BLOCK_PREFIX.'block_fallback';
18+
19+
/**
20+
* @var array<string, array<int, array<int, string>>>
21+
*/
22+
private array $stack;
23+
24+
public function convert(array $blocks, int $targetEmbeddedTemplateIndex): array
25+
{
26+
$newBlocks = [];
27+
foreach ($blocks as $blockName => $block) {
28+
// Keep already converted outer blocks untouched
29+
if (str_starts_with($blockName, self::OUTER_BLOCK_PREFIX)) {
30+
$newBlocks[$blockName] = $block;
31+
continue;
32+
}
33+
34+
// Determine the location of the block where it is defined in the host Template
35+
// Each component has its own embedded template. That template's index uniquely
36+
// identifies the block definition.
37+
$hostEmbeddedTemplateIndex = $this->findHostEmbeddedTemplateIndex();
38+
39+
if (0 === $hostEmbeddedTemplateIndex) {
40+
// If there is no embedded template index, that means we're in a normal level template.
41+
// It wouldn't make sense to make these available as outer blocks,
42+
// since the block already printed in place.
43+
continue;
44+
}
45+
46+
// Change name of outer blocks to something unique so blocks of nested components aren't overridden,
47+
// which otherwise might cause a recursion loop when nesting components.
48+
$newName = self::OUTER_BLOCK_PREFIX.$blockName.'_'.mt_rand();
49+
$newBlocks[$newName] = $block;
50+
51+
// That index combined with the index of the embedded template where the block can be used
52+
// allows us to remember the link between the original name and the new randomized name.
53+
// That way we can map a call like `block(outerBlocks.block_name)` to the randomized name.
54+
$this->stack[$blockName][$targetEmbeddedTemplateIndex][$hostEmbeddedTemplateIndex] = $newName;
55+
}
56+
57+
return $newBlocks;
58+
}
59+
60+
public function __call(string $name, array $arguments)
61+
{
62+
$callingEmbeddedTemplateIndex = $this->findCallingEmbeddedTemplateIndex();
63+
$hostEmbeddedTemplateIndex = $this->findHostEmbeddedTemplateIndexFromCaller();
64+
65+
return $this->stack[$name][$callingEmbeddedTemplateIndex][$hostEmbeddedTemplateIndex] ?? self::OUTER_BLOCK_FALLBACK_NAME;
66+
}
67+
68+
private function findHostEmbeddedTemplateIndex(): int
69+
{
70+
$backtrace = debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS | \DEBUG_BACKTRACE_PROVIDE_OBJECT);
71+
72+
$componentTemplateClassName = null;
73+
74+
foreach ($backtrace as $trace) {
75+
if (isset($trace['object']) && $trace['object'] instanceof Template) {
76+
$classname = $trace['object']::class;
77+
$templateIndex = $this->getTemplateIndexFromTemplateClassname($classname);
78+
if ($templateIndex) {
79+
// If there's no template index, then we're in a component template
80+
// and we need to go up until we find the embedded template
81+
// (which will have the block definitions).
82+
return $templateIndex;
83+
}
84+
}
85+
}
86+
87+
return 0;
88+
}
89+
90+
private function findCallingEmbeddedTemplateIndex(): int
91+
{
92+
$backtrace = debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS | \DEBUG_BACKTRACE_PROVIDE_OBJECT);
93+
94+
foreach ($backtrace as $trace) {
95+
if (isset($trace['object']) && $trace['object'] instanceof Template) {
96+
return $this->getTemplateIndexFromTemplateClassname($trace['object']::class);
97+
}
98+
}
99+
}
100+
101+
private function findHostEmbeddedTemplateIndexFromCaller(): int
102+
{
103+
$backtrace = debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS | \DEBUG_BACKTRACE_PROVIDE_OBJECT);
104+
105+
$blockCallerStack = [];
106+
$renderer = null;
107+
108+
foreach ($backtrace as $trace) {
109+
if (isset($trace['object']) && $trace['object'] instanceof Template) {
110+
$classname = $trace['object']::class;
111+
$templateIndex = $this->getTemplateIndexFromTemplateClassname($classname);
112+
if (null === $renderer) {
113+
if ($templateIndex) {
114+
// This class is an embedded template
115+
// Next class is either the renderer or a previous template that's passing blocks through.
116+
$blockCallerStack[$classname] = $classname;
117+
continue;
118+
}
119+
// If it's not an embedded template anymore, we've reached the renderer
120+
// From now on we'll travel back up the hierachy
121+
$renderer = $classname;
122+
continue;
123+
}
124+
if ($classname === $renderer || isset($blockCallerStack[$classname])) {
125+
continue;
126+
}
127+
128+
if (!$templateIndex) {
129+
continue;
130+
}
131+
132+
// This is the first template that's not part of the callstack,
133+
// so it's the template that has the outer block definition
134+
return $templateIndex;
135+
}
136+
}
137+
138+
// If the component is not an embedded one, just return 0, so the fallback content (aka nothing) is used
139+
return 0;
140+
}
141+
142+
private function getTemplateIndexFromTemplateClassname(string $classname): int
143+
{
144+
return (int) substr($classname, strrpos($classname, '___') + 3);
145+
}
146+
}

src/TwigComponent/src/ComponentRenderer.php

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,13 @@ public function embeddedContext(string $name, array $props, array $context): arr
7171
{
7272
$context[PreRenderEvent::EMBEDDED] = true;
7373

74-
return $this->preRender($this->factory->create($name, $props), $context)->getVariables();
74+
$embeddedContext = $this->preRender($this->factory->create($name, $props), $context)->getVariables();
75+
76+
if (!isset($embeddedContext['outerBlocks'])) {
77+
$embeddedContext['outerBlocks'] = new BlockStack();
78+
}
79+
80+
return $embeddedContext;
7581
}
7682

7783
private function preRender(MountedComponent $mounted, array $context = []): PreRenderEvent

src/TwigComponent/src/Twig/ComponentNode.php

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ public function compile(Compiler $compiler): void
3535
$compiler->addDebugInfo($this);
3636

3737
$compiler
38-
->raw('$props = $this->extensions[')
38+
->write('$embeddedContext = $this->extensions[')
3939
->string(ComponentExtension::class)
4040
->raw(']->embeddedContext(')
4141
->string($this->getAttribute('component'))
@@ -47,8 +47,15 @@ public function compile(Compiler $compiler): void
4747
->raw(");\n")
4848
;
4949

50+
$compiler->write('$embeddedBlocks = $embeddedContext[')
51+
->string('outerBlocks')
52+
->raw(']->convert($blocks, ')
53+
->string($this->getAttribute('index'))
54+
->raw(");\n")
55+
;
56+
5057
$this->addGetTemplate($compiler);
51-
$compiler->raw('->display($props, $blocks);');
58+
$compiler->raw('->display($embeddedContext, $embeddedBlocks);');
5259
$compiler->raw("\n");
5360
}
5461
}

src/TwigComponent/src/Twig/ComponentTokenParser.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
namespace Symfony\UX\TwigComponent\Twig;
1313

14+
use Symfony\UX\TwigComponent\BlockStack;
1415
use Symfony\UX\TwigComponent\ComponentFactory;
1516
use Twig\Node\Expression\AbstractExpression;
1617
use Twig\Node\Expression\ArrayExpression;
@@ -61,6 +62,17 @@ public function parse(Token $token): Node
6162
new Token(Token::NAME_TYPE, 'extends', $token->getLine()),
6263
$parentToken,
6364
new Token(Token::BLOCK_END_TYPE, '', $token->getLine()),
65+
66+
// Add an empty block which can act as a fallback for when an outer
67+
// block is referenced that is not passed in from the embedded component
68+
// See BlockStack::__call()
69+
new Token(Token::BLOCK_START_TYPE, '', $token->getLine()),
70+
new Token(Token::NAME_TYPE, 'block', $token->getLine()),
71+
new Token(Token::NAME_TYPE, BlockStack::OUTER_BLOCK_FALLBACK_NAME, $token->getLine()),
72+
new Token(Token::BLOCK_END_TYPE, '', $token->getLine()),
73+
new Token(Token::BLOCK_START_TYPE, '', $token->getLine()),
74+
new Token(Token::NAME_TYPE, 'endblock', $token->getLine()),
75+
new Token(Token::BLOCK_END_TYPE, '', $token->getLine()),
6476
]);
6577

6678
$module = $this->parser->parse($stream, fn (Token $token) => $token->test("end{$this->getTag()}"), true);

src/TwigComponent/tests/Fixtures/Component/DivComponent.php

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,4 @@
1919
#[AsTwigComponent]
2020
final class DivComponent
2121
{
22-
public string $divComponentName = 'foo';
23-
24-
public function someFunction(): string
25-
{
26-
return 'calling DivComponent';
27-
}
2822
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\UX\TwigComponent\Tests\Fixtures\Component;
13+
14+
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
15+
16+
/**
17+
* @author Bart Vanderstukken <bart.vanderstukken@gmail.com>
18+
*/
19+
#[AsTwigComponent]
20+
final class DivComponent1
21+
{
22+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\UX\TwigComponent\Tests\Fixtures\Component;
13+
14+
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
15+
16+
/**
17+
* @author Bart Vanderstukken <bart.vanderstukken@gmail.com>
18+
*/
19+
#[AsTwigComponent]
20+
final class DivComponent2
21+
{
22+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\UX\TwigComponent\Tests\Fixtures\Component;
13+
14+
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
15+
16+
/**
17+
* @author Bart Vanderstukken <bart.vanderstukken@gmail.com>
18+
*/
19+
#[AsTwigComponent]
20+
final class DivComponent3
21+
{
22+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\UX\TwigComponent\Tests\Fixtures\Component;
13+
14+
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
15+
16+
/**
17+
* @author Bart Vanderstukken <bart.vanderstukken@gmail.com>
18+
*/
19+
#[AsTwigComponent]
20+
final class DivComponent4
21+
{
22+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\UX\TwigComponent\Tests\Fixtures\Component;
13+
14+
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
15+
16+
/**
17+
* @author Bart Vanderstukken <bart.vanderstukken@gmail.com>
18+
*/
19+
#[AsTwigComponent]
20+
final class DivComponent5
21+
{
22+
public string $divComponentName = 'foo';
23+
24+
public function someFunction(): string
25+
{
26+
return 'calling DivComponent';
27+
}
28+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\UX\TwigComponent\Tests\Fixtures\Component;
13+
14+
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
15+
16+
/**
17+
* @author Bart Vanderstukken <bart.vanderstukken@gmail.com>
18+
*/
19+
#[AsTwigComponent]
20+
final class DivComponentNoPass
21+
{
22+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\UX\TwigComponent\Tests\Fixtures\Component;
13+
14+
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
15+
16+
/**
17+
* @author Bart Vanderstukken <bart.vanderstukken@gmail.com>
18+
*/
19+
#[AsTwigComponent]
20+
final class DivComponentWrapper2
21+
{
22+
}

0 commit comments

Comments
 (0)