From b548097b4f6cb0d48fff688a801236bcf8de339c Mon Sep 17 00:00:00 2001 From: andreybotanic Date: Fri, 20 Mar 2026 15:18:44 +0700 Subject: [PATCH 1/3] Support component tag names from expressions and component objects --- src/TwigComponent/doc/index.rst | 21 ++++++ src/TwigComponent/src/ComponentFactory.php | 5 ++ .../TwigComponentExtension.php | 1 + src/TwigComponent/src/Twig/ComponentNode.php | 68 +++++++++++++++++-- .../src/Twig/ComponentRuntime.php | 7 ++ .../src/Twig/ComponentTokenParser.php | 20 +++--- .../Component/DynamicNameComponent1.php | 19 ++++++ .../Component/DynamicNameComponent2.php | 19 ++++++ .../dynamic_name_component_1.html.twig | 1 + .../dynamic_name_component_2.html.twig | 1 + .../Integration/ComponentExtensionTest.php | 50 ++++++++++++++ .../Integration/Twig/ComponentParserTest.php | 10 +++ 12 files changed, 209 insertions(+), 13 deletions(-) create mode 100644 src/TwigComponent/tests/Fixtures/Component/DynamicNameComponent1.php create mode 100644 src/TwigComponent/tests/Fixtures/Component/DynamicNameComponent2.php create mode 100644 src/TwigComponent/tests/Fixtures/templates/components/dynamic_name_component_1.html.twig create mode 100644 src/TwigComponent/tests/Fixtures/templates/components/dynamic_name_component_2.html.twig diff --git a/src/TwigComponent/doc/index.rst b/src/TwigComponent/doc/index.rst index 97e6f895794..8ca35a13ff3 100644 --- a/src/TwigComponent/doc/index.rst +++ b/src/TwigComponent/doc/index.rst @@ -856,6 +856,27 @@ There is also a non-HTML syntax that can be used: {% block footer %}... footer content{% endblock %} {% endcomponent %} +The ``{% component %}`` tag also accepts dynamic expressions: + +.. code-block:: html+twig + + {% set prefix = 'DynamicNameComponent' %} + + {% for i in 1..2 %} + {% component (prefix ~ i) %}{% endcomponent %} + {% endfor %} + +If you pass a variable that contains a component object, its class will be +used to resolve the component name: + +.. code-block:: html+twig + + {% component componentObject %}{% endcomponent %} + +Where ``componentObject`` must be an instance of a class-based component (e.g. +``#[AsTwigComponent]``/``#[AsLiveComponent]``). Anonymous components cannot be +resolved from an object expression. + .. _embedded-components-context: Context / Variables Inside of Blocks diff --git a/src/TwigComponent/src/ComponentFactory.php b/src/TwigComponent/src/ComponentFactory.php index ad46cabcd30..a1ddc0f5e18 100644 --- a/src/TwigComponent/src/ComponentFactory.php +++ b/src/TwigComponent/src/ComponentFactory.php @@ -145,6 +145,11 @@ public function get(string $name): object return $this->components->get($metadata->getName()); } + public function isComponentClass(string $class): bool + { + return isset($this->classMap[$class]); + } + private function mount(object $component, array &$data, ComponentMetadata $componentMetadata): void { if ($component instanceof AnonymousComponent) { diff --git a/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php b/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php index fef77b449ae..40182109eac 100644 --- a/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php +++ b/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php @@ -126,6 +126,7 @@ static function (ChildDefinition $definition, AsTwigComponent $attribute) { ->setArguments([ new Reference('ux.twig_component.component_renderer'), new ServiceLocatorArgument(new TaggedIteratorArgument('ux.twig_component.twig_renderer', indexAttribute: 'key', needsIndexes: true)), + new Reference('ux.twig_component.component_factory'), ]) ->addTag('twig.runtime') ; diff --git a/src/TwigComponent/src/Twig/ComponentNode.php b/src/TwigComponent/src/Twig/ComponentNode.php index 96ef9c7ca0b..d993ff166c8 100644 --- a/src/TwigComponent/src/Twig/ComponentNode.php +++ b/src/TwigComponent/src/Twig/ComponentNode.php @@ -15,7 +15,9 @@ use Twig\Attribute\YieldReady; use Twig\Compiler; use Twig\Environment; +use Twig\Error\SyntaxError; use Twig\Extension\CoreExtension; +use Twig\Node\Expression\NameExpression; use Twig\Node\Expression\AbstractExpression; use Twig\Node\Node; use Twig\Node\NodeOutputInterface; @@ -30,9 +32,9 @@ #[YieldReady] final class ComponentNode extends Node implements NodeOutputInterface { - public function __construct(string $component, string $embeddedTemplateName, int $embeddedTemplateIndex, ?AbstractExpression $props, bool $only, int $lineno) + public function __construct(AbstractExpression $component, string $embeddedTemplateName, int $embeddedTemplateIndex, ?AbstractExpression $props, bool $only, int $lineno) { - $nodes = []; + $nodes = ['component' => $component]; if (null !== $props) { $nodes['props'] = $props; } @@ -42,7 +44,6 @@ public function __construct(string $component, string $embeddedTemplateName, int $this->setAttribute('only', $only); $this->setAttribute('embedded_template', $embeddedTemplateName); $this->setAttribute('embedded_index', $embeddedTemplateIndex); - $this->setAttribute('component', $component); } public function compile(Compiler $compiler): void @@ -65,6 +66,63 @@ public function compile(Compiler $compiler): void ->string(ComponentRuntime::class) ->raw(");\n"); + $componentNameValue = $compiler->getVarName(); + $componentName = $compiler->getVarName(); + + $compiler + ->write(\sprintf('$%s = ', $componentNameValue)) + ; + + if ($this->getNode('component') instanceof NameExpression) { + $name = $this->getNode('component')->getAttribute('name'); + $compiler->raw(\sprintf('\\array_key_exists(%s, $context) ? $context[%s] : %s', var_export($name, true), var_export($name, true), var_export($name, true))); + } else { + $compiler->subcompile($this->getNode('component')); + } + + $compiler->raw(";\n"); + + $compiler + ->write(\sprintf('if (\\is_object($%s)) {', $componentNameValue)) + ->raw("\n") + ->indent() + ->write(\sprintf('if (!$%s->isObjectComponent($%s)) {', $componentRuntime, $componentNameValue)) + ->raw("\n") + ->indent() + ->write('throw new ') + ->raw('\\'.SyntaxError::class) + ->raw('(sprintf(') + ->string('The component expression passed to "{%% component %%}" must evaluate to a component name (string/scalar/Stringable) or a component object. Got object "%s".') + ->raw(', ') + ->raw(\sprintf('$%s::class', $componentNameValue)) + ->raw('), ') + ->repr($this->getTemplateLine()) + ->raw(", \$this->getSourceContext());\n") + ->outdent() + ->write("}\n") + ->write(\sprintf('$%s = $%s::class;', $componentName, $componentNameValue)) + ->raw("\n") + ->outdent() + ->write(\sprintf('} elseif (\\is_scalar($%s) || $%s instanceof \\Stringable) {', $componentNameValue, $componentNameValue)) + ->raw("\n") + ->indent() + ->write(\sprintf('$%s = (string) $%s;', $componentName, $componentNameValue)) + ->raw("\n") + ->outdent() + ->write("} else {\n") + ->indent() + ->write('throw new ') + ->raw('\\'.SyntaxError::class) + ->raw('(sprintf(') + ->string('The component expression passed to "{%% component %%}" must evaluate to a component name (string/scalar/Stringable) or a component object. Got "%s".') + ->raw(', \\get_debug_type(') + ->raw(\sprintf('$%s', $componentNameValue)) + ->raw(')), ') + ->repr($this->getTemplateLine()) + ->raw(", \$this->getSourceContext());\n") + ->outdent() + ->write("}\n"); + /* * Block 1) PreCreateForRender handling * @@ -73,7 +131,7 @@ public function compile(Compiler $compiler): void */ $compiler ->write(\sprintf('$preRendered = $%s->preRender(', $componentRuntime)) - ->string($this->getAttribute('component')) + ->raw(\sprintf('$%s', $componentName)) ->raw(', ') ->raw($twig_to_array) ->raw('('); @@ -105,7 +163,7 @@ public function compile(Compiler $compiler): void */ $compiler ->write(\sprintf('$preRenderEvent = $%s->startEmbedComponent(', $componentRuntime)) - ->string($this->getAttribute('component')) + ->raw(\sprintf('$%s', $componentName)) ->raw(', ') ->raw($twig_to_array) ->raw('('); diff --git a/src/TwigComponent/src/Twig/ComponentRuntime.php b/src/TwigComponent/src/Twig/ComponentRuntime.php index f770ef32162..d496cd1e0cc 100644 --- a/src/TwigComponent/src/Twig/ComponentRuntime.php +++ b/src/TwigComponent/src/Twig/ComponentRuntime.php @@ -12,6 +12,7 @@ namespace Symfony\UX\TwigComponent\Twig; use Symfony\Component\DependencyInjection\ServiceLocator; +use Symfony\UX\TwigComponent\ComponentFactory; use Symfony\UX\TwigComponent\ComponentRenderer; use Symfony\UX\TwigComponent\Event\PreRenderEvent; @@ -26,6 +27,7 @@ final class ComponentRuntime public function __construct( private readonly ComponentRenderer $renderer, private readonly ServiceLocator $renderers, + private readonly ComponentFactory $componentFactory, ) { } @@ -59,4 +61,9 @@ public function startEmbedComponent(string $name, array $props, array $context, { return $this->renderer->startEmbeddedComponentRender($name, $props, $context, $hostTemplateName, $index); } + + public function isObjectComponent(object $component): bool + { + return $this->componentFactory->isComponentClass($component::class); + } } diff --git a/src/TwigComponent/src/Twig/ComponentTokenParser.php b/src/TwigComponent/src/Twig/ComponentTokenParser.php index ec543ee70e2..09043caad2e 100644 --- a/src/TwigComponent/src/Twig/ComponentTokenParser.php +++ b/src/TwigComponent/src/Twig/ComponentTokenParser.php @@ -37,12 +37,12 @@ public function parse(Token $token): Node if (method_exists($this->parser, 'parseExpression')) { // Since Twig 3.21 - $componentName = $this->componentName($this->parser->parseExpression()); + $componentNameExpression = $this->componentNameExpression($this->parser->parseExpression()); } else { - $componentName = $this->componentName($this->parser->getExpressionParser()->parseExpression()); + $componentNameExpression = $this->componentNameExpression($this->parser->getExpressionParser()->parseExpression()); } - if (null === $componentName) { + if (null === $componentNameExpression) { throw new SyntaxError('Could not parse component name.', $stream->getCurrent()->getLine(), $stream->getSourceContext()); } @@ -78,7 +78,7 @@ public function parse(Token $token): Node $stream->expect(Token::BLOCK_END_TYPE); - return new ComponentNode($componentName, $module->getTemplateName(), $module->getAttribute('index'), $propsExpression, $only, $token->getLine()); + return new ComponentNode($componentNameExpression, $module->getTemplateName(), $module->getAttribute('index'), $propsExpression, $only, $token->getLine()); } public function getTag(): string @@ -86,17 +86,21 @@ public function getTag(): string return 'component'; } - private function componentName(AbstractExpression $expression): ?string + private function componentNameExpression(AbstractExpression $expression): ?AbstractExpression { if ($expression instanceof ConstantExpression) { // using {% component 'name' %} - return $expression->getAttribute('value'); + return $expression; } if ($expression instanceof NameExpression) { // using {% component name %} - return $expression->getAttribute('name'); + return $expression; } - return null; + if ($expression instanceof ArrayExpression) { + return null; + } + + return $expression; } /** diff --git a/src/TwigComponent/tests/Fixtures/Component/DynamicNameComponent1.php b/src/TwigComponent/tests/Fixtures/Component/DynamicNameComponent1.php new file mode 100644 index 00000000000..06757f6a505 --- /dev/null +++ b/src/TwigComponent/tests/Fixtures/Component/DynamicNameComponent1.php @@ -0,0 +1,19 @@ + + * + * 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; + +#[AsTwigComponent('DynamicNameComponent1', template: 'components/dynamic_name_component_1.html.twig')] +final class DynamicNameComponent1 +{ +} diff --git a/src/TwigComponent/tests/Fixtures/Component/DynamicNameComponent2.php b/src/TwigComponent/tests/Fixtures/Component/DynamicNameComponent2.php new file mode 100644 index 00000000000..5b7817d8a3f --- /dev/null +++ b/src/TwigComponent/tests/Fixtures/Component/DynamicNameComponent2.php @@ -0,0 +1,19 @@ + + * + * 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; + +#[AsTwigComponent('DynamicNameComponent2', template: 'components/dynamic_name_component_2.html.twig')] +final class DynamicNameComponent2 +{ +} diff --git a/src/TwigComponent/tests/Fixtures/templates/components/dynamic_name_component_1.html.twig b/src/TwigComponent/tests/Fixtures/templates/components/dynamic_name_component_1.html.twig new file mode 100644 index 00000000000..6e754d19454 --- /dev/null +++ b/src/TwigComponent/tests/Fixtures/templates/components/dynamic_name_component_1.html.twig @@ -0,0 +1 @@ +DynamicNameComponent1 rendered diff --git a/src/TwigComponent/tests/Fixtures/templates/components/dynamic_name_component_2.html.twig b/src/TwigComponent/tests/Fixtures/templates/components/dynamic_name_component_2.html.twig new file mode 100644 index 00000000000..a7650ffe570 --- /dev/null +++ b/src/TwigComponent/tests/Fixtures/templates/components/dynamic_name_component_2.html.twig @@ -0,0 +1 @@ +DynamicNameComponent2 rendered diff --git a/src/TwigComponent/tests/Integration/ComponentExtensionTest.php b/src/TwigComponent/tests/Integration/ComponentExtensionTest.php index d474950c90f..cfe52a05406 100644 --- a/src/TwigComponent/tests/Integration/ComponentExtensionTest.php +++ b/src/TwigComponent/tests/Integration/ComponentExtensionTest.php @@ -13,9 +13,12 @@ use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\UX\TwigComponent\Tests\Fixtures\Component\DynamicNameComponent1; +use Symfony\UX\TwigComponent\Tests\Fixtures\Component\DynamicNameComponent2; use Symfony\UX\TwigComponent\Tests\Fixtures\User; use Twig\Environment; use Twig\Error\RuntimeError; +use Twig\Error\SyntaxError; /** * @author Kevin Bond @@ -157,6 +160,53 @@ public function testCanRenderEmbeddedComponent() $this->assertStringContainsString('custom td (1)', $output); } + public function testCanRenderEmbeddedComponentWithDynamicNameBuiltInLoop() + { + $environment = self::getContainer()->get(Environment::class); + $template = $environment->createTemplate('{% set prefix = "DynamicNameComponent" %}{% for i in 1..2 %}{% component (prefix ~ i) %}{% endcomponent %}{% endfor %}'); + + $output = $template->render(); + + $this->assertStringContainsString('DynamicNameComponent1 rendered', $output); + $this->assertStringContainsString('DynamicNameComponent2 rendered', $output); + } + + public function testThrowsWhenDynamicComponentNameDoesNotExist() + { + $this->expectException(RuntimeError::class); + $this->expectExceptionMessage('Unknown component "DynamicNameComponent3".'); + + $environment = self::getContainer()->get(Environment::class); + $template = $environment->createTemplate('{% set prefix = "DynamicNameComponent" %}{% component (prefix ~ 3) %}{% endcomponent %}'); + $template->render(); + } + + public function testCanRenderEmbeddedComponentFromObjectExpression() + { + $environment = self::getContainer()->get(Environment::class); + $template = $environment->createTemplate('{% for component in components %}{% component component %}{% endcomponent %}{% endfor %}'); + $output = $template->render([ + 'components' => [ + new DynamicNameComponent1(), + new DynamicNameComponent2(), + ], + ]); + + $this->assertStringContainsString('DynamicNameComponent1 rendered', $output); + $this->assertStringContainsString('DynamicNameComponent2 rendered', $output); + } + + public function testThrowsWhenObjectExpressionIsNotAComponent() + { + $this->expectException(SyntaxError::class); + $this->expectExceptionMessage('must evaluate to a component name (string/scalar/Stringable) or a component object'); + $this->expectExceptionMessage('stdClass'); + + $environment = self::getContainer()->get(Environment::class); + $template = $environment->createTemplate('{% component obj %}{% endcomponent %}'); + $template->render(['obj' => new \stdClass()]); + } + public function testComponentWithNamespace() { $output = $this->renderComponent('foo:bar:baz'); diff --git a/src/TwigComponent/tests/Integration/Twig/ComponentParserTest.php b/src/TwigComponent/tests/Integration/Twig/ComponentParserTest.php index e5806747328..5d72b7a8d0d 100644 --- a/src/TwigComponent/tests/Integration/Twig/ComponentParserTest.php +++ b/src/TwigComponent/tests/Integration/Twig/ComponentParserTest.php @@ -75,6 +75,16 @@ public function testItThrowsWhenComponentNameCannotBeParsed() $environment->createTemplate($source, 'foo.html.twig'); } + public function testItAcceptsDynamicComponentExpression() + { + $environment = $this->createEnvironment(); + $source = '{% component var.componentName %}{% endcomponent %}'; + + $template = $environment->createTemplate($source); + + $this->assertInstanceOf(TemplateWrapper::class, $template); + } + public static function provideValidComponentNames(): iterable { // Those names are all syntactically valid even if From c056e1af4e397bb81e4af505c604e4473cba6389 Mon Sep 17 00:00:00 2001 From: Andrey Yanduganov Date: Fri, 20 Mar 2026 21:32:14 +0700 Subject: [PATCH 2/3] Fix code style --- src/TwigComponent/src/Twig/ComponentNode.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/TwigComponent/src/Twig/ComponentNode.php b/src/TwigComponent/src/Twig/ComponentNode.php index d993ff166c8..71ca6bbbcc0 100644 --- a/src/TwigComponent/src/Twig/ComponentNode.php +++ b/src/TwigComponent/src/Twig/ComponentNode.php @@ -17,8 +17,8 @@ use Twig\Environment; use Twig\Error\SyntaxError; use Twig\Extension\CoreExtension; -use Twig\Node\Expression\NameExpression; use Twig\Node\Expression\AbstractExpression; +use Twig\Node\Expression\NameExpression; use Twig\Node\Node; use Twig\Node\NodeOutputInterface; use Twig\Template; From b9c6386212aa158ba3353e7026f742516acd9fd7 Mon Sep 17 00:00:00 2001 From: Andrey Yanduganov Date: Sat, 21 Mar 2026 09:27:04 +0700 Subject: [PATCH 3/3] Fix doc style --- src/TwigComponent/doc/index.rst | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/TwigComponent/doc/index.rst b/src/TwigComponent/doc/index.rst index 8ca35a13ff3..6fb3e7af471 100644 --- a/src/TwigComponent/doc/index.rst +++ b/src/TwigComponent/doc/index.rst @@ -858,10 +858,9 @@ There is also a non-HTML syntax that can be used: The ``{% component %}`` tag also accepts dynamic expressions: -.. code-block:: html+twig +.. code-block:: twig {% set prefix = 'DynamicNameComponent' %} - {% for i in 1..2 %} {% component (prefix ~ i) %}{% endcomponent %} {% endfor %} @@ -869,7 +868,7 @@ The ``{% component %}`` tag also accepts dynamic expressions: If you pass a variable that contains a component object, its class will be used to resolve the component name: -.. code-block:: html+twig +.. code-block:: twig {% component componentObject %}{% endcomponent %}