From b07d799cfbd61218555363024d19526b85329bff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Andr=C3=A9?= Date: Sat, 20 Jan 2024 15:50:44 +0100 Subject: [PATCH] [TwigComponent] Twig "use_yield" compatibility --- src/TwigComponent/composer.json | 2 +- src/TwigComponent/src/Twig/ComponentNode.php | 55 +++++++------ src/TwigComponent/src/Twig/PropsNode.php | 21 ++++- ...d_component_blocks_with_fallback.html.twig | 1 - .../tags/embedded_component.html.twig | 3 +- .../tests/Integration/ComponentLexerTest.php | 1 - .../Integration/EmbeddedComponentTest.php | 79 +++++++++++-------- 7 files changed, 101 insertions(+), 61 deletions(-) diff --git a/src/TwigComponent/composer.json b/src/TwigComponent/composer.json index 766b8a60add..77f5a324ea8 100644 --- a/src/TwigComponent/composer.json +++ b/src/TwigComponent/composer.json @@ -31,7 +31,7 @@ "symfony/deprecation-contracts": "^2.2|^3.0", "symfony/event-dispatcher": "^5.4|^6.0|^7.0", "symfony/property-access": "^5.4|^6.0|^7.0", - "twig/twig": "~3.8.0" + "twig/twig": "^3.8" }, "require-dev": { "symfony/console": "^5.4|^6.0|^7.0", diff --git a/src/TwigComponent/src/Twig/ComponentNode.php b/src/TwigComponent/src/Twig/ComponentNode.php index fbaea805106..2f2ee7444c7 100644 --- a/src/TwigComponent/src/Twig/ComponentNode.php +++ b/src/TwigComponent/src/Twig/ComponentNode.php @@ -12,10 +12,13 @@ namespace Symfony\UX\TwigComponent\Twig; use Symfony\UX\TwigComponent\BlockStack; +use Twig\Attribute\YieldReady; use Twig\Compiler; +use Twig\Environment; use Twig\Extension\CoreExtension; use Twig\Node\Expression\AbstractExpression; use Twig\Node\Node; +use Twig\Node\NodeOutputInterface; /** * @author Fabien Potencier @@ -23,7 +26,8 @@ * * @internal */ -final class ComponentNode extends Node +#[YieldReady] +final class ComponentNode extends Node implements NodeOutputInterface { public function __construct(string $component, string $embeddedTemplateName, int $embeddedTemplateIndex, ?AbstractExpression $props, bool $only, int $lineno, string $tag) { @@ -64,24 +68,25 @@ public function compile(Compiler $compiler): void ->string($this->getAttribute('component')) ->raw(', ') ->raw($twig_to_array) - ->raw('(') - ; + ->raw('('); $this->writeProps($compiler) ->raw(')') - ->raw(");\n") - ; + ->raw(");\n"); $compiler ->write('if (null !== $preRendered) {') ->raw("\n") - ->indent() - ->write('echo $preRendered;') - ->raw("\n") + ->indent(); + if (method_exists(Environment::class, 'useYield')) { + $compiler->write('yield from $preRendered; '); + } else { + $compiler->write('echo $preRendered; '); + } + $compiler->raw("\n") ->outdent() ->write('} else {') ->raw("\n") - ->indent() - ; + ->indent(); /* * Block 2) Create the component & return render info @@ -97,8 +102,7 @@ public function compile(Compiler $compiler): void ->string($this->getAttribute('component')) ->raw(', ') ->raw($twig_to_array) - ->raw('(') - ; + ->raw('('); $this->writeProps($compiler) ->raw('), ') ->raw($this->getAttribute('only') ? '[]' : '$context') @@ -106,8 +110,7 @@ public function compile(Compiler $compiler): void ->string(TemplateNameParser::parse($this->getAttribute('embedded_template'))) ->raw(', ') ->raw($this->getAttribute('embedded_index')) - ->raw(");\n") - ; + ->raw(");\n"); $compiler ->write('$embeddedContext = $preRenderEvent->getVariables();') ->raw("\n") @@ -121,8 +124,7 @@ public function compile(Compiler $compiler): void // happens to contain a {% component %} tag. So we don't need to worry // about trying to allow a specific embedded template to be targeted. ->write('$embeddedContext["__parent__"] = $preRenderEvent->getTemplate();') - ->raw("\n") - ; + ->raw("\n"); /* * Block 3) Add & update the block stack @@ -143,14 +145,17 @@ public function compile(Compiler $compiler): void ->string('outerBlocks') ->raw(']->convert($blocks, ') ->raw($this->getAttribute('embedded_index')) - ->raw(");\n") - ; + ->raw(");\n"); /* * Block 4) Render the component template * * This will actually render the child component template. */ + if (method_exists(Environment::class, 'useYield') && $compiler->getEnvironment()->useYield()) { + $compiler + ->write('yield from '); + } $compiler ->write('$this->loadTemplate(') ->string($this->getAttribute('embedded_template')) @@ -160,10 +165,16 @@ public function compile(Compiler $compiler): void ->repr($this->getTemplateLine()) ->raw(', ') ->string($this->getAttribute('embedded_index')) - ->raw(')') - ->raw('->display($embeddedContext, $embeddedBlocks);') - ->raw("\n") - ; + ->raw(')'); + + if (method_exists(Environment::class, 'useYield') && $compiler->getEnvironment()->useYield()) { + $compiler->raw('->unwrap()->yield('); + } else { + $compiler->raw('->display('); + } + $compiler + ->raw('$embeddedContext, $embeddedBlocks') + ->raw(");\n"); $compiler->write('$this->extensions[') ->string(ComponentExtension::class) diff --git a/src/TwigComponent/src/Twig/PropsNode.php b/src/TwigComponent/src/Twig/PropsNode.php index 81ae3d1351e..b33a804d9bb 100644 --- a/src/TwigComponent/src/Twig/PropsNode.php +++ b/src/TwigComponent/src/Twig/PropsNode.php @@ -11,6 +11,7 @@ namespace Symfony\UX\TwigComponent\Twig; +use Twig\Attribute\YieldReady; use Twig\Compiler; use Twig\Node\Node; @@ -19,6 +20,7 @@ * * @internal */ +#[YieldReady] class PropsNode extends Node { public function __construct(array $propsNames, array $values, $lineno = 0, ?string $tag = null) @@ -47,22 +49,31 @@ public function compile(Compiler $compiler): void $compiler ->write('$propsNames[] = \''.$name.'\';') + ->write("\n") ->write('$context[\'attributes\'] = $context[\'attributes\']->remove(\''.$name.'\');') + ->write("\n") ->write('if (!isset($context[\''.$name.'\'])) {'); if (!$this->hasNode($name)) { $compiler + ->indent() ->write('throw new \Twig\Error\RuntimeError("'.$name.' should be defined for component '.$this->getTemplateName().'");') - ->write('}'); + ->write("\n") + ->outdent() + ->write('}') + ->write("\n"); continue; } $compiler + ->indent() ->write('$context[\''.$name.'\'] = ') ->subcompile($this->getNode($name)) ->raw(";\n") - ->write('}'); + ->outdent() + ->write('}') + ->write("\n"); } $compiler @@ -70,12 +81,18 @@ public function compile(Compiler $compiler): void ->raw("\n") ->write('foreach ($context as $key => $value) {') ->raw("\n") + ->indent() ->write('if (in_array($key, $attributesKeys) && !in_array($key, $propsNames)) {') ->raw("\n") + ->indent() ->raw('unset($context[$key]);') ->raw("\n") + ->outdent() ->write('}') + ->raw("\n") + ->outdent() ->write('}') + ->raw("\n") ; // overwrite the context value if a props with a similar name and a default value exist diff --git a/src/TwigComponent/tests/Fixtures/templates/non_embedded_component_blocks_with_fallback.html.twig b/src/TwigComponent/tests/Fixtures/templates/non_embedded_component_blocks_with_fallback.html.twig index 7ae3a12c60b..a4fcf12fdbb 100644 --- a/src/TwigComponent/tests/Fixtures/templates/non_embedded_component_blocks_with_fallback.html.twig +++ b/src/TwigComponent/tests/Fixtures/templates/non_embedded_component_blocks_with_fallback.html.twig @@ -1,3 +1,2 @@ - Override contentOverride foo diff --git a/src/TwigComponent/tests/Fixtures/templates/tags/embedded_component.html.twig b/src/TwigComponent/tests/Fixtures/templates/tags/embedded_component.html.twig index fcce917f9db..87a6bdc96ba 100644 --- a/src/TwigComponent/tests/Fixtures/templates/tags/embedded_component.html.twig +++ b/src/TwigComponent/tests/Fixtures/templates/tags/embedded_component.html.twig @@ -1,8 +1,7 @@ custom th ({{ parent() }}) custom td ({{ parent() }}) - My footer - \ No newline at end of file + diff --git a/src/TwigComponent/tests/Integration/ComponentLexerTest.php b/src/TwigComponent/tests/Integration/ComponentLexerTest.php index 7ed6c6c15c7..45342775b4b 100644 --- a/src/TwigComponent/tests/Integration/ComponentLexerTest.php +++ b/src/TwigComponent/tests/Integration/ComponentLexerTest.php @@ -32,7 +32,6 @@ public function testComponentSyntaxOpenTags(): void public function testComponentSyntaxSelfCloseTags(): void { $output = self::getContainer()->get(Environment::class)->render('tags/self_close_tag.html.twig'); - $this->assertStringContainsString('propA: 1', $output); $this->assertStringContainsString('propB: hello', $output); } diff --git a/src/TwigComponent/tests/Integration/EmbeddedComponentTest.php b/src/TwigComponent/tests/Integration/EmbeddedComponentTest.php index c0cd887d923..e7a392d9ac3 100644 --- a/src/TwigComponent/tests/Integration/EmbeddedComponentTest.php +++ b/src/TwigComponent/tests/Integration/EmbeddedComponentTest.php @@ -20,14 +20,15 @@ final class EmbeddedComponentTest extends KernelTestCase { /** - * Rule 1: A block is not passed into an embedded component, since it would be rendered in place ànd in the component's template. + * Rule 1: A block is not passed into an embedded component, since it would be rendered in place ànd in the + * component's template. */ public function testABlockIsNotPassedIntoAnEmbeddedComponent(): void { - $output = self::getContainer()->get(Environment::class)->render('embedded_component_blocks_basic.html.twig'); + $output = self::render('embedded_component_blocks_basic.html.twig'); // rule 1 - $this->assertStringContainsString('
This block is rendered in place, since there\'s no extend happening
', $output); + $this->assertStringContainsStringIgnoringIndentation('
This block is rendered in place, since there\'s no extend happening
', $output); $this->assertStringNotContainsString('
Hello Fabien!This block is rendered in place, since there\'s no extend happening
', $output); } @@ -38,16 +39,17 @@ public function testAnEmbeddedComponentHasContextAccess(): void { $this->assertStringContainsStringIgnoringIndentation( '
Hello Fabien!', - self::getContainer()->get(Environment::class)->render('embedded_component_blocks_basic.html.twig') + self::render('embedded_component_blocks_basic.html.twig') ); } /** - * Rule 3: A block is only passed one level down, via the display() function on the embedded Template that's representing a component instance. + * Rule 3: A block is only passed one level down, via the display() function on the embedded Template that's + * representing a component instance. */ public function testABlockIsOnlyPassedOneLevelDown(): void { - $output = self::getContainer()->get(Environment::class)->render('embedded_component_blocks_no_pass.html.twig'); + $output = self::render('embedded_component_blocks_no_pass.html.twig'); $this->assertStringContainsStringIgnoringIndentation('
The Generic Element could have some default content, although it does not make sense in this example.The Generic Element default foo block
', $output); $this->assertStringNotContainsString('Hello world!', $output); @@ -55,13 +57,14 @@ public function testABlockIsOnlyPassedOneLevelDown(): void } /** - * Rule 4: Inside that component's template you can use it, but NOT within a nested component. The latter is repeating rule 1. + * Rule 4: Inside that component's template you can use it, but NOT within a nested component. The latter is + * repeating rule 1. */ public function testABlockIsNotPassedToNestedComponents(): void { $this->assertStringContainsStringIgnoringIndentation( 'Hello world!
The Generic Element default foo block
', - self::getContainer()->get(Environment::class)->render('embedded_component_blocks_example1.html.twig') + self::render('embedded_component_blocks_example1.html.twig') ); } @@ -76,30 +79,32 @@ public function testBlockCanBeUsedWithinNestedViaTheOuterBlocks(): void { $this->assertStringContainsStringIgnoringIndentation( 'Hello world!
Hello world!Override foo & Override foo
', - self::getContainer()->get(Environment::class)->render('embedded_component_blocks_outer_blocks.html.twig') + self::render('embedded_component_blocks_outer_blocks.html.twig') ); } /** - * Rule 5 bis: A block inside an extending template can be use inside a component in that template and is NOT rendered in the original location. + * Rule 5 bis: A block inside an extending template can be use inside a component in that template and is NOT + * rendered in the original location. */ public function testBlockCanBeUsedViaTheOuterBlocks(): void { - $output = self::getContainer()->get(Environment::class)->render('embedded_component_blocks_outer_blocks_extended_template.html.twig'); + $output = self::render('embedded_component_blocks_outer_blocks_extended_template.html.twig'); + $this->assertStringContainsStringIgnoringIndentation('
Hello world!
', $output); $this->assertStringNotContainsString("Hello world!\nassertStringContainsStringIgnoringIndentation( '
Hello world!The Generic Element default foo block + Override foo
', - self::getContainer()->get(Environment::class)->render('embedded_component_blocks_outer_blocks_parent.html.twig') + self::render('embedded_component_blocks_outer_blocks_parent.html.twig') ); } @@ -110,76 +115,86 @@ public function testDeepNesting(): void { $this->assertStringContainsStringIgnoringIndentation( '
Content 1
Content 2
Content 3Override foo3
Override foo2
Override foo1
', - self::getContainer()->get(Environment::class)->render('embedded_component_blocks_complex_nesting.html.twig') + self::render('embedded_component_blocks_complex_nesting.html.twig') ); } /** - * Rule 10: Missing outer blocks use a fallback block so that nothing is rendered, and no unknown block error occurs. + * Rule 10: Missing outer blocks use a fallback block so that nothing is rendered, and no unknown block error + * occurs. */ public function testItCanHandleMissingOuterBlocks(): void { $this->assertStringContainsStringIgnoringIndentation( '
Content 1
Content 2
Content 3Override foo3
Override foo1
', - self::getContainer()->get(Environment::class)->render('embedded_component_blocks_complex_nesting2.html.twig') + self::render('embedded_component_blocks_complex_nesting2.html.twig'), ); } /** * Rule 11: To pass blocks down multiple levels, each level needs to define the block, * so it can be used for its parent block (of the nested component). - * Not defining a block (and not passing said block along) will be considered as a missing block (see rule 10). + * Not defining a block (and not passing said block along) will be considered as a missing block (see rule + * 10). */ public function testPassingDownBlocksMultipleLevelsNeedsToBeDoneManually(): void { $this->assertStringContainsStringIgnoringIndentation( '
DIV CONTENT: WRAPPER CONTENT: Content from wrapperI\'m fixing foo content
DIV CONTENT: WRAPPER CONTENT: Content from wrapperI don\'t have a foo block, so it will be considered empty.
', - self::getContainer()->get(Environment::class)->render('embedded_component_blocks_complex_nesting_deep.html.twig') + self::render('embedded_component_blocks_complex_nesting_deep.html.twig') ); } /** * Rule 12: Blocks defined within an embedded component can access the context of the block they are replacing. - * Rule 13: Blocks defined within an embedded component can access the context of components up the hierarchy (up to their own level) via "outerScope". + * Rule 13: Blocks defined within an embedded component can access the context of components up the hierarchy (up + * to their own level) via "outerScope". */ public function testBlockDefinitionCanAccessTheContextOfTheDestinationBlocks(): void { $this->assertStringContainsStringIgnoringIndentation( '
I can access my own properties: foo.I can access the id of the Generic Element: symfonyIsAwesome.This refers to the Generic Element: calling GenericElement.To access my own functions I can use outerScope.this: calling DivComponent.I have access to outer context variables like Fabien.I can access the id from Generic Element as well: symfonyIsAwesome.I can access the properties from DivComponent as well: foo.And of course the properties from DivComponentWrapper: bar.The less obvious thing is that at this level "this" refers to the component where the content block is used, i.e. the Generic Element.Therefore, functions through this will be calling GenericElement.Calls to outerScope.this will be calling DivComponent.Even I can access the id from Generic Element as well: symfonyIsAwesome.Even I can access the properties from DivComponent as well: foo.Even I can access the properties from DivComponentWrapper as well: bar.Even I can access the functions of DivComponent via outerScope.this: calling DivComponent.Since we are nesting two levels deep, calls to outerScope.outerScope.this will be calling DivComponentWrapper.The Generic Element default foo block
', - self::getContainer()->get(Environment::class)->render('embedded_component_blocks_context.html.twig') + self::render('embedded_component_blocks_context.html.twig') ); } public function testAccessingTheHierarchyTooHighThrowsAnException(): void { $this->expectExceptionMessage('Key "this" for array with keys "app, __embedded" does not exist.'); - self::getContainer()->get(Environment::class)->render('embedded_component_hierarchy_exception.html.twig'); + self::render('embedded_component_hierarchy_exception.html.twig'); } public function testANonEmbeddedComponentRendersOuterBlocksEmpty(): void { $this->assertStringContainsStringIgnoringIndentation( '
', - self::getContainer()->get(Environment::class)->render('non_embedded_component_blocks.html.twig') + self::render('non_embedded_component_blocks.html.twig'), ); } public function testANonEmbeddedComponentCanRenderParentBlocksAsFallback(): void { - $output = self::getContainer()->get(Environment::class)->render('non_embedded_component_blocks_with_fallback.html.twig'); $this->assertStringContainsStringIgnoringIndentation( '
The Generic Element could have some default content, although it does not make sense in this example.The Generic Element default foo block
', - $output - ); - - $this->assertStringContainsStringIgnoringIndentation( - '
Override contentOverride foo
', - $output + self::render('non_embedded_component_blocks_with_fallback.html.twig'), ); } private function assertStringContainsStringIgnoringIndentation(string $needle, string $haystack): void { - $this->assertStringContainsString($needle, str_replace(["\n", ' '], '', $haystack)); + $needle = trim(preg_replace('#(\s+)#u', '', $needle)); + $haystack = trim(preg_replace('#(\s+)#u', '', $haystack)); + $this->assertStringContainsString(trim($needle), trim($haystack)); + } + + private static function render(string $template): string + { + /** @var Environment $twig */ + $twig = self::getContainer()->get(Environment::class); + $twig->setCache(false); + $twig->enableAutoReload(); + $twig->enableDebug(); + + return $twig->render($template); } }