Skip to content

[TwigComponent] Support passing blocks to nested embedded components #920

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jun 23, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions src/TwigComponent/src/BlockStack.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
<?php

declare(strict_types=1);

namespace Symfony\UX\TwigComponent;

use Twig\Template;

/**
* @author Bart Vanderstukken <bart.vanderstukken@gmail.com>
*
* @internal
*/
final class BlockStack
{
private const OUTER_BLOCK_PREFIX = 'outer__';
public const OUTER_BLOCK_FALLBACK_NAME = self::OUTER_BLOCK_PREFIX.'block_fallback';

/**
* @var array<string, array<int, array<int, string>>>
*/
private array $stack;

public function convert(array $blocks, int $targetEmbeddedTemplateIndex): array
{
$newBlocks = [];
foreach ($blocks as $blockName => $block) {
// Keep already converted outer blocks untouched
if (str_starts_with($blockName, self::OUTER_BLOCK_PREFIX)) {
$newBlocks[$blockName] = $block;
continue;
}

// Determine the location of the block where it is defined in the host Template.
// Each component has its own embedded template. That template's index uniquely
// identifies the block definition.
$hostEmbeddedTemplateIndex = $this->findHostEmbeddedTemplateIndex();

if (0 === $hostEmbeddedTemplateIndex) {
// If there is no embedded template index, that means we're in a normal template.
// It wouldn't make sense to make these available as outer blocks,
// since the block is already printed in place.
continue;
}

// Change the name of outer blocks to something unique so blocks of nested components aren't overridden,
// which otherwise might cause a recursion loop when nesting components.
$newName = self::OUTER_BLOCK_PREFIX.$blockName.'_'.mt_rand();
$newBlocks[$newName] = $block;

// The host index combined with the index of the embedded template where the block can be used (target)
// allows us to remember the link between the original name and the new randomized name.
// That way we can map a call like `block(outerBlocks.block_name)` to the randomized name.
$this->stack[$blockName][$targetEmbeddedTemplateIndex][$hostEmbeddedTemplateIndex] = $newName;
}

return $newBlocks;
}

public function __call(string $name, array $arguments)
{
$callingEmbeddedTemplateIndex = $this->findCallingEmbeddedTemplateIndex();
$hostEmbeddedTemplateIndex = $this->findHostEmbeddedTemplateIndexFromCaller();

return $this->stack[$name][$callingEmbeddedTemplateIndex][$hostEmbeddedTemplateIndex] ?? self::OUTER_BLOCK_FALLBACK_NAME;
}

private function findHostEmbeddedTemplateIndex(): int
{
$backtrace = debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS | \DEBUG_BACKTRACE_PROVIDE_OBJECT);

$componentTemplateClassName = null;

foreach ($backtrace as $trace) {
if (isset($trace['object']) && $trace['object'] instanceof Template) {
$classname = $trace['object']::class;
$templateIndex = $this->getTemplateIndexFromTemplateClassname($classname);
if ($templateIndex) {
// If there's no template index, then we're in a component template
// and we need to go up until we find the embedded template
// (which will have the block definitions).
return $templateIndex;
}
}
}

return 0;
}

private function findCallingEmbeddedTemplateIndex(): int
{
$backtrace = debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS | \DEBUG_BACKTRACE_PROVIDE_OBJECT);

foreach ($backtrace as $trace) {
if (isset($trace['object']) && $trace['object'] instanceof Template) {
return $this->getTemplateIndexFromTemplateClassname($trace['object']::class);
}
}
}

private function findHostEmbeddedTemplateIndexFromCaller(): int
{
$backtrace = debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS | \DEBUG_BACKTRACE_PROVIDE_OBJECT);

$blockCallerStack = [];
$renderer = null;

foreach ($backtrace as $trace) {
if (isset($trace['object']) && $trace['object'] instanceof Template) {
$classname = $trace['object']::class;
$templateIndex = $this->getTemplateIndexFromTemplateClassname($classname);
if (null === $renderer) {
if ($templateIndex) {
// This class is an embedded template.
// Next class is either the renderer or a previous template that's passing blocks through.
$blockCallerStack[$classname] = $classname;
continue;
}
// If it's not an embedded template anymore, we've reached the renderer.
// From now on we'll travel back up the hierarchy.
$renderer = $classname;
continue;
}
if ($classname === $renderer || isset($blockCallerStack[$classname])) {
continue;
}

if (!$templateIndex) {
continue;
}

// This is the first template that's not part of the callstack,
// so it's the template that has the outer block definition.
return $templateIndex;
}
}

// If the component is not an embedded one, just return 0, so the fallback content (aka nothing) is used.
return 0;
}

private function getTemplateIndexFromTemplateClassname(string $classname): int
{
return (int) substr($classname, strrpos($classname, '___') + 3);
}
}
8 changes: 7 additions & 1 deletion src/TwigComponent/src/ComponentRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,13 @@ public function embeddedContext(string $name, array $props, array $context): arr
{
$context[PreRenderEvent::EMBEDDED] = true;

return $this->preRender($this->factory->create($name, $props), $context)->getVariables();
$embeddedContext = $this->preRender($this->factory->create($name, $props), $context)->getVariables();

if (!isset($embeddedContext['outerBlocks'])) {
$embeddedContext['outerBlocks'] = new BlockStack();
}

return $embeddedContext;
}

private function preRender(MountedComponent $mounted, array $context = []): PreRenderEvent
Expand Down
12 changes: 9 additions & 3 deletions src/TwigComponent/src/Twig/ComponentNode.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public function compile(Compiler $compiler): void
$compiler->addDebugInfo($this);

$compiler
->raw('$props = $this->extensions[')
->write('$embeddedContext = $this->extensions[')
->string(ComponentExtension::class)
->raw(']->embeddedContext(')
->string($this->getAttribute('component'))
Expand All @@ -47,9 +47,15 @@ public function compile(Compiler $compiler): void
->raw(");\n")
;

$this->addGetTemplate($compiler);
$compiler->write('$embeddedBlocks = $embeddedContext[')
->string('outerBlocks')
->raw(']->convert($blocks, ')
->raw($this->getAttribute('index'))
->raw(");\n")
;

$compiler->raw('->display($props);');
$this->addGetTemplate($compiler);
$compiler->raw('->display($embeddedContext, $embeddedBlocks);');
$compiler->raw("\n");
}
}
12 changes: 12 additions & 0 deletions src/TwigComponent/src/Twig/ComponentTokenParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

namespace Symfony\UX\TwigComponent\Twig;

use Symfony\UX\TwigComponent\BlockStack;
use Symfony\UX\TwigComponent\ComponentFactory;
use Twig\Node\Expression\AbstractExpression;
use Twig\Node\Expression\ArrayExpression;
Expand Down Expand Up @@ -61,6 +62,17 @@ public function parse(Token $token): Node
new Token(Token::NAME_TYPE, 'extends', $token->getLine()),
$parentToken,
new Token(Token::BLOCK_END_TYPE, '', $token->getLine()),

// Add an empty block which can act as a fallback for when an outer
// block is referenced that is not passed in from the embedded component.
// See BlockStack::__call()
new Token(Token::BLOCK_START_TYPE, '', $token->getLine()),
new Token(Token::NAME_TYPE, 'block', $token->getLine()),
new Token(Token::NAME_TYPE, BlockStack::OUTER_BLOCK_FALLBACK_NAME, $token->getLine()),
new Token(Token::BLOCK_END_TYPE, '', $token->getLine()),
new Token(Token::BLOCK_START_TYPE, '', $token->getLine()),
new Token(Token::NAME_TYPE, 'endblock', $token->getLine()),
new Token(Token::BLOCK_END_TYPE, '', $token->getLine()),
]);

$module = $this->parser->parse($stream, fn (Token $token) => $token->test("end{$this->getTag()}"), true);
Expand Down
22 changes: 22 additions & 0 deletions src/TwigComponent/tests/Fixtures/Component/DivComponent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\UX\TwigComponent\Tests\Fixtures\Component;

use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;

/**
* @author Bart Vanderstukken <bart.vanderstukken@gmail.com>
*/
#[AsTwigComponent]
final class DivComponent
{
}
22 changes: 22 additions & 0 deletions src/TwigComponent/tests/Fixtures/Component/DivComponent1.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\UX\TwigComponent\Tests\Fixtures\Component;

use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;

/**
* @author Bart Vanderstukken <bart.vanderstukken@gmail.com>
*/
#[AsTwigComponent]
final class DivComponent1
{
}
22 changes: 22 additions & 0 deletions src/TwigComponent/tests/Fixtures/Component/DivComponent2.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\UX\TwigComponent\Tests\Fixtures\Component;

use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;

/**
* @author Bart Vanderstukken <bart.vanderstukken@gmail.com>
*/
#[AsTwigComponent]
final class DivComponent2
{
}
22 changes: 22 additions & 0 deletions src/TwigComponent/tests/Fixtures/Component/DivComponent3.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\UX\TwigComponent\Tests\Fixtures\Component;

use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;

/**
* @author Bart Vanderstukken <bart.vanderstukken@gmail.com>
*/
#[AsTwigComponent]
final class DivComponent3
{
}
22 changes: 22 additions & 0 deletions src/TwigComponent/tests/Fixtures/Component/DivComponent4.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\UX\TwigComponent\Tests\Fixtures\Component;

use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;

/**
* @author Bart Vanderstukken <bart.vanderstukken@gmail.com>
*/
#[AsTwigComponent]
final class DivComponent4
{
}
28 changes: 28 additions & 0 deletions src/TwigComponent/tests/Fixtures/Component/DivComponent5.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\UX\TwigComponent\Tests\Fixtures\Component;

use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;

/**
* @author Bart Vanderstukken <bart.vanderstukken@gmail.com>
*/
#[AsTwigComponent]
final class DivComponent5
{
public string $divComponentName = 'foo';

public function someFunction(): string
{
return 'calling DivComponent';
}
}
22 changes: 22 additions & 0 deletions src/TwigComponent/tests/Fixtures/Component/DivComponent6.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\UX\TwigComponent\Tests\Fixtures\Component;

use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;

/**
* @author Bart Vanderstukken <bart.vanderstukken@gmail.com>
*/
#[AsTwigComponent]
final class DivComponent6
{
}
Loading