Skip to content

Commit b06640e

Browse files
committed
feature #920 [TwigComponent] Support passing blocks to nested embedded components (sneakyvv)
This PR was squashed before being merged into the 2.x branch. Discussion ---------- [TwigComponent] Support passing blocks to nested embedded components | Q | A | ------------- | --- | Bug fix? | yes | New feature? | no | Tickets | Fix #844 | License | MIT The fix for #844, and a better solution then a manual passthrough workaround, is literally just passing the blocks from the "host Template" to the embedded Template, ~~so that the blocks are merged with the embedded template's blocks, overriding them~~ but altering their name and using a special `outerBloocks` variable to map the block name to the new name (see #920 (comment)). This means that the example from #844 (comment) (tweaked a bit) ```twig {# anywhere #} {% set name = 'Fabien' %} <twig:DivComponent> Hello {{ name }}! </twig:DivComponent> ``` ```twig {# DivComponent.html.twig #} <twig:GenericElement element="div" class="divComponent"> {{ block(outerBlocks.content) }} </twig:GenericElement> ``` ```twig {# GenericElement.html.twig #} <{{ element }}{{ attributes }}> {%- block content -%}{%- endblock -%} </{{ element }}> ``` Results in ```twig <div class="divComponent">Hello Fabien!</div> ``` See the tests for more elaborate examples showing the access to context variables, multi-level overrides, etc. Commits ------- f4b064c [TwigComponent] Support passing blocks to nested embedded components
2 parents 45877a0 + f4b064c commit b06640e

39 files changed

+725
-6
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 template.
41+
// It wouldn't make sense to make these available as outer blocks,
42+
// since the block is already printed in place.
43+
continue;
44+
}
45+
46+
// Change the 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+
// The host index combined with the index of the embedded template where the block can be used (target)
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 hierarchy.
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 & 3 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,9 +47,15 @@ public function compile(Compiler $compiler): void
4747
->raw(");\n")
4848
;
4949

50-
$this->addGetTemplate($compiler);
50+
$compiler->write('$embeddedBlocks = $embeddedContext[')
51+
->string('outerBlocks')
52+
->raw(']->convert($blocks, ')
53+
->raw($this->getAttribute('index'))
54+
->raw(");\n")
55+
;
5156

52-
$compiler->raw('->display($props);');
57+
$this->addGetTemplate($compiler);
58+
$compiler->raw('->display($embeddedContext, $embeddedBlocks);');
5359
$compiler->raw("\n");
5460
}
5561
}

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);
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 DivComponent
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 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 DivComponent6
21+
{
22+
}

0 commit comments

Comments
 (0)