Repository: symfony/ux
Pull request #2168 (2024-09-15 03:22:57)

[TwigComponent] Use a RuntimeExtension
* Hide implementation & start decoupling the template from the event system
* Optimize (a bit) the ComponentNode


Patch:
diff --git a/src/TwigComponent/doc/index.rst b/src/TwigComponent/doc/index.rst
index cb6e22ad..e324db1c 100644
--- a/src/TwigComponent/doc/index.rst
+++ b/src/TwigComponent/doc/index.rst
@@ -348,7 +348,7 @@ close tag, it's passed to your component template as the block called
 
 .. code-block:: html+twig
 
-    <div {{ attributes.defaults({class: 'alert alert-' ~ type}) }}>
+    <div {{ attributes.defaults({class: 'alert alert-' ~ type}) }}">
         {% block content %}{% endblock %}
     </div>
 
@@ -1771,4 +1771,4 @@ https://symfony.com/doc/current/contributing/code/bc.html
 .. _`html_cva`: https://twig.symfony.com/doc/3.x/functions/html_cva.html
 .. _`shadcn/ui`: https://ui.shadcn.com
 .. _`tales-from-a-dev/twig-tailwind-extra`: https://github.com/tales-from-a-dev/twig-tailwind-extra
-.. _`ignore not defined options`: https://symfony.com/doc/current/components/options_resolver.html#ignore-not-defined-options
+.. _`ignore not defined options`: https://symfony.com/doc/current/components/options_resolver.html#ignore-not-defined-options
\ No newline at end of file
diff --git a/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php b/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php
index e69bfb62..c0c18b0e 100644
--- a/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php
+++ b/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php
@@ -34,6 +34,7 @@ use Symfony\UX\TwigComponent\ComponentTemplateFinder;
 use Symfony\UX\TwigComponent\DependencyInjection\Compiler\TwigComponentPass;
 use Symfony\UX\TwigComponent\Twig\ComponentExtension;
 use Symfony\UX\TwigComponent\Twig\ComponentLexer;
+use Symfony\UX\TwigComponent\Twig\ComponentRuntime;
 use Symfony\UX\TwigComponent\Twig\TwigEnvironmentConfigurator;
 
 /**
@@ -102,7 +103,13 @@ final class TwigComponentExtension extends Extension implements ConfigurationInt
 
         $container->register('ux.twig_component.twig.component_extension', ComponentExtension::class)
             ->addTag('twig.extension')
-            ->addTag('container.service_subscriber', ['key' => ComponentRenderer::class, 'id' => 'ux.twig_component.component_renderer'])
+        ;
+
+        $container->register('.ux.twig_component.twig.component_runtime', ComponentRuntime::class)
+            ->setArguments([
+                new Reference('ux.twig_component.component_renderer'),
+            ])
+            ->addTag('twig.runtime')
         ;
 
         $container->register('ux.twig_component.twig.lexer', ComponentLexer::class);
diff --git a/src/TwigComponent/src/Twig/ComponentExtension.php b/src/TwigComponent/src/Twig/ComponentExtension.php
index eaa737b6..f577ae08 100644
--- a/src/TwigComponent/src/Twig/ComponentExtension.php
+++ b/src/TwigComponent/src/Twig/ComponentExtension.php
@@ -11,13 +11,8 @@
 
 namespace Symfony\UX\TwigComponent\Twig;
 
-use Psr\Container\ContainerInterface;
-use Symfony\Contracts\Service\ServiceSubscriberInterface;
-use Symfony\UX\TwigComponent\ComponentRenderer;
 use Symfony\UX\TwigComponent\CVA;
-use Symfony\UX\TwigComponent\Event\PreRenderEvent;
 use Twig\DeprecatedCallableInfo;
-use Twig\Error\RuntimeError;
 use Twig\Extension\AbstractExtension;
 use Twig\TwigFunction;
 
@@ -26,23 +21,12 @@ use Twig\TwigFunction;
  *
  * @internal
  */
-final class ComponentExtension extends AbstractExtension implements ServiceSubscriberInterface
+final class ComponentExtension extends AbstractExtension
 {
-    public function __construct(private ContainerInterface $container)
-    {
-    }
-
-    public static function getSubscribedServices(): array
-    {
-        return [
-            ComponentRenderer::class,
-        ];
-    }
-
     public function getFunctions(): array
     {
         return [
-            new TwigFunction('component', [$this, 'render'], ['is_safe' => ['all']]),
+            new TwigFunction('component', [ComponentRuntime::class, 'render'], ['is_safe' => ['all']]),
             new TwigFunction('cva', [$this, 'cva'], [
                 ...(class_exists(DeprecatedCallableInfo::class)
                     ? ['deprecation_info' => new DeprecatedCallableInfo('symfony/ux-twig-component', '2.20', 'html_cva', 'twig/html-extra')]
@@ -59,38 +43,6 @@ final class ComponentExtension extends AbstractExtension implements ServiceSubsc
         ];
     }
 
-    public function render(string $name, array $props = []): string
-    {
-        try {
-            return $this->container->get(ComponentRenderer::class)->createAndRender($name, $props);
-        } catch (\Throwable $e) {
-            $this->throwRuntimeError($name, $e);
-        }
-    }
-
-    public function extensionPreCreateForRender(string $name, array $props): ?string
-    {
-        try {
-            return $this->container->get(ComponentRenderer::class)->preCreateForRender($name, $props);
-        } catch (\Throwable $e) {
-            $this->throwRuntimeError($name, $e);
-        }
-    }
-
-    public function startEmbeddedComponentRender(string $name, array $props, array $context, string $hostTemplateName, int $index): PreRenderEvent
-    {
-        try {
-            return $this->container->get(ComponentRenderer::class)->startEmbeddedComponentRender($name, $props, $context, $hostTemplateName, $index);
-        } catch (\Throwable $e) {
-            $this->throwRuntimeError($name, $e);
-        }
-    }
-
-    public function finishEmbeddedComponentRender(): void
-    {
-        $this->container->get(ComponentRenderer::class)->finishEmbeddedComponentRender();
-    }
-
     /**
      * Create a CVA instance.
      *
@@ -119,14 +71,4 @@ final class ComponentExtension extends AbstractExtension implements ServiceSubsc
             $cva['defaultVariants'] ?? [],
         );
     }
-
-    private function throwRuntimeError(string $name, \Throwable $e): void
-    {
-        // if it's already a Twig RuntimeError, just rethrow it
-        if ($e instanceof RuntimeError) {
-            throw $e;
-        }
-
-        throw new RuntimeError(\sprintf('Error rendering "%s" component: "%s"', $name, $e->getMessage()), previous: $e);
-    }
 }
diff --git a/src/TwigComponent/src/Twig/ComponentNode.php b/src/TwigComponent/src/Twig/ComponentNode.php
index 2c0561c6..439175c5 100644
--- a/src/TwigComponent/src/Twig/ComponentNode.php
+++ b/src/TwigComponent/src/Twig/ComponentNode.php
@@ -48,6 +48,8 @@ final class ComponentNode extends Node implements NodeOutputInterface
     {
         $compiler->addDebugInfo($this);
 
+        $useYield = method_exists(Environment::class, 'useYield') && $compiler->getEnvironment()->useYield();
+
         // since twig/twig 3.9.0: Using the internal "twig_to_array" function is deprecated.
         if (method_exists(CoreExtension::class, 'toArray')) {
             $twig_to_array = 'Twig\Extension\CoreExtension::toArray';
@@ -55,6 +57,13 @@ final class ComponentNode extends Node implements NodeOutputInterface
             $twig_to_array = 'twig_to_array';
         }
 
+        $componentRuntime = $compiler->getVarName();
+
+        $compiler
+               ->write(\sprintf('$%s = $this->env->getRuntime(', $componentRuntime))
+               ->string(ComponentRuntime::class)
+               ->raw(");\n");
+
         /*
          * Block 1) PreCreateForRender handling
          *
@@ -62,9 +71,7 @@ final class ComponentNode extends Node implements NodeOutputInterface
          * a string, we return that string and skip the rest of the rendering process.
          */
         $compiler
-            ->write('$preRendered = $this->extensions[')
-            ->string(ComponentExtension::class)
-            ->raw(']->extensionPreCreateForRender(')
+            ->write(\sprintf('$preRendered = $%s->preRender(', $componentRuntime))
             ->string($this->getAttribute('component'))
             ->raw(', ')
             ->raw($twig_to_array)
@@ -96,9 +103,7 @@ final class ComponentNode extends Node implements NodeOutputInterface
          * the final template, template index & variables.
          */
         $compiler
-            ->write('$preRenderEvent = $this->extensions[')
-            ->string(ComponentExtension::class)
-            ->raw(']->startEmbeddedComponentRender(')
+            ->write(\sprintf('$preRenderEvent = $%s->startEmbedComponent(', $componentRuntime))
             ->string($this->getAttribute('component'))
             ->raw(', ')
             ->raw($twig_to_array)
@@ -111,6 +116,7 @@ final class ComponentNode extends Node implements NodeOutputInterface
             ->raw(', ')
             ->raw($this->getAttribute('embedded_index'))
             ->raw(");\n");
+
         $compiler
             ->write('$embeddedContext = $preRenderEvent->getVariables();')
             ->raw("\n")
@@ -132,18 +138,11 @@ final class ComponentNode extends Node implements NodeOutputInterface
          * We add the outerBlock to the context if it doesn't exist yet.
          * Then add them to the block stack and get the converted embedded blocks.
          */
-        $compiler->write('if (!isset($embeddedContext["outerBlocks"])) {')
-            ->raw("\n")
-            ->indent()
-            ->write(\sprintf('$embeddedContext["outerBlocks"] = new \%s();', BlockStack::class))
-            ->raw("\n")
-            ->outdent()
-            ->write('}')
+        $compiler
+            ->write(\sprintf('$embeddedContext["outerBlocks"] ??= new \%s();', BlockStack::class))
             ->raw("\n");
 
-        $compiler->write('$embeddedBlocks = $embeddedContext[')
-            ->string('outerBlocks')
-            ->raw(']->convert($blocks, ')
+        $compiler->write('$embeddedBlocks = $embeddedContext["outerBlocks"]->convert($blocks, ')
             ->raw($this->getAttribute('embedded_index'))
             ->raw(");\n");
 
@@ -152,9 +151,8 @@ final class ComponentNode extends Node implements NodeOutputInterface
          *
          * This will actually render the child component template.
          */
-        if (method_exists(Environment::class, 'useYield') && $compiler->getEnvironment()->useYield()) {
-            $compiler
-                ->write('yield from ');
+        if ($useYield) {
+            $compiler->write('yield from ');
         }
         $compiler
             ->write('$this->loadTemplate(')
@@ -167,7 +165,7 @@ final class ComponentNode extends Node implements NodeOutputInterface
             ->string($this->getAttribute('embedded_index'))
             ->raw(')');
 
-        if (method_exists(Environment::class, 'useYield') && $compiler->getEnvironment()->useYield()) {
+        if ($useYield) {
             $compiler->raw('->unwrap()->yield(');
         } else {
             $compiler->raw('->display(');
@@ -176,10 +174,8 @@ final class ComponentNode extends Node implements NodeOutputInterface
             ->raw('$embeddedContext, $embeddedBlocks')
             ->raw(");\n");
 
-        $compiler->write('$this->extensions[')
-            ->string(ComponentExtension::class)
-            ->raw(']->finishEmbeddedComponentRender()')
-            ->raw(";\n")
+        $compiler->write(\sprintf('$%s->finishEmbedComponent();', $componentRuntime))
+            ->raw("\n")
         ;
 
         $compiler
diff --git a/src/TwigComponent/src/Twig/ComponentRuntime.php b/src/TwigComponent/src/Twig/ComponentRuntime.php
new file mode 100644
index 00000000..b6157599
--- /dev/null
+++ b/src/TwigComponent/src/Twig/ComponentRuntime.php
@@ -0,0 +1,59 @@
+<?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\Twig;
+
+use Symfony\UX\TwigComponent\ComponentRenderer;
+use Symfony\UX\TwigComponent\Event\PreRenderEvent;
+
+/**
+ * @author Kevin Bond <kevinbond@gmail.com>
+ * @author Simon André <smn.andre@gmail.com>
+ *
+ * @internal
+ */
+final class ComponentRuntime
+{
+    public function __construct(
+        private readonly ComponentRenderer $renderer,
+    ) {
+    }
+
+    /**
+     * @param array<string, mixed> $props
+     */
+    public function render(string $name, array $props = []): string
+    {
+        return $this->renderer->createAndRender($name, $props);
+    }
+
+    /**
+     * @param array<string, mixed> $props
+     */
+    public function preRender(string $name, array $props): ?string
+    {
+        return $this->renderer->preCreateForRender($name, $props);
+    }
+
+    /**
+     * @param array<string, mixed> $props
+     * @param array<string, mixed> $context
+     */
+    public function startEmbedComponent(string $name, array $props, array $context, string $hostTemplateName, int $index): PreRenderEvent
+    {
+        return $this->renderer->startEmbeddedComponentRender($name, $props, $context, $hostTemplateName, $index);
+    }
+
+    public function finishEmbedComponent(): void
+    {
+        $this->renderer->finishEmbeddedComponentRender();
+    }
+}
diff --git a/src/TwigComponent/src/Twig/PropsTokenParser.php b/src/TwigComponent/src/Twig/PropsTokenParser.php
index 9cc94e07..fc3b6640 100644
--- a/src/TwigComponent/src/Twig/PropsTokenParser.php
+++ b/src/TwigComponent/src/Twig/PropsTokenParser.php
@@ -44,7 +44,7 @@ class PropsTokenParser extends AbstractTokenParser
             }
         }
 
-        return new PropsNode($names, $values, $token->getLine(), $token->getValue());
+        return new PropsNode($names, $values, $token->getLine());
     }
 
     public function getTag(): string
diff --git a/src/TwigComponent/src/Twig/TwigEnvironmentConfigurator.php b/src/TwigComponent/src/Twig/TwigEnvironmentConfigurator.php
index 3fe38967..4b952a10 100644
--- a/src/TwigComponent/src/Twig/TwigEnvironmentConfigurator.php
+++ b/src/TwigComponent/src/Twig/TwigEnvironmentConfigurator.php
@@ -30,6 +30,7 @@ class TwigEnvironmentConfigurator
     public function configure(Environment $environment): void
     {
         $this->decorated->configure($environment);
+
         $environment->setLexer(new ComponentLexer($environment));
 
         if (class_exists(EscaperRuntime::class)) {


Test patch:
diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
index 49a8f3aa..15f70a23 100644
--- a/.github/workflows/test.yaml
+++ b/.github/workflows/test.yaml
@@ -81,14 +81,12 @@ jobs:
         strategy:
             fail-fast: false
             matrix:
-                php-version: ['8.1', '8.3', '8.4']
+                php-version: ['8.1', '8.3']
                 include:
                   - php-version: '8.1'
                     dependency-version: 'lowest'
                   - php-version: '8.3'
                     dependency-version: 'highest'
-                  - php-version: '8.4'
-                    dependency-version: 'highest'
                 component: ${{ fromJson(needs.tests-php-components.outputs.components )}}
                 exclude:
                   - component: Map # does not support PHP 8.1
@@ -143,5 +141,5 @@ jobs:
                   restore-keys: |
                       ${{ runner.os }}-yarn-
             - run: yarn --immutable
-            - run: yarn playwright install
+            - run: yarn playwright install  
             - run: yarn test
diff --git a/src/LiveComponent/tests/Fixtures/Serializer/Entity2Normalizer.php b/src/LiveComponent/tests/Fixtures/Serializer/Entity2Normalizer.php
index f7278ed9..b63331a3 100644
--- a/src/LiveComponent/tests/Fixtures/Serializer/Entity2Normalizer.php
+++ b/src/LiveComponent/tests/Fixtures/Serializer/Entity2Normalizer.php
@@ -25,24 +25,24 @@ final class Entity2Normalizer implements NormalizerInterface, DenormalizerInterf
     {
     }
 
-    public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed
+    public function denormalize(mixed $data, string $type, string $format = null, array $context = []): mixed
     {
-        [, $id] = explode(':', $data);
+        [, $id] = \explode(':', $data);
 
         return $this->doctrine->getRepository(Entity2::class)->find($id);
     }
 
-    public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool
+    public function supportsDenormalization(mixed $data, string $type, string $format = null, array $context = []): bool
     {
         return Entity2::class === $type;
     }
 
-    public function normalize(mixed $object, ?string $format = null, array $context = []): float|array|\ArrayObject|bool|int|string|null
+    public function normalize(mixed $object, string $format = null, array $context = []): float|array|\ArrayObject|bool|int|string|null
     {
         return 'entity2:'.$object->id;
     }
 
-    public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool
+    public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool
     {
         return $data instanceof Entity2;
     }
diff --git a/src/LiveComponent/tests/Fixtures/Serializer/MoneyNormalizer.php b/src/LiveComponent/tests/Fixtures/Serializer/MoneyNormalizer.php
index ad52f89e..90889a47 100644
--- a/src/LiveComponent/tests/Fixtures/Serializer/MoneyNormalizer.php
+++ b/src/LiveComponent/tests/Fixtures/Serializer/MoneyNormalizer.php
@@ -17,27 +17,27 @@ use Symfony\UX\LiveComponent\Tests\Fixtures\Dto\Money;
 
 final class MoneyNormalizer implements NormalizerInterface, DenormalizerInterface
 {
-    public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): Money
+    public function denormalize(mixed $data, string $type, string $format = null, array $context = []): Money
     {
-        return new Money(...explode('|', $data));
+        return new Money(...\explode('|', $data));
     }
 
-    public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool
+    public function supportsDenormalization(mixed $data, string $type, string $format = null, array $context = []): bool
     {
         return Money::class === $type;
     }
 
-    public function normalize(mixed $object, ?string $format = null, array $context = []): float|array|\ArrayObject|bool|int|string|null
+    public function normalize(mixed $object, string $format = null, array $context = []): float|array|\ArrayObject|bool|int|string|null
     {
-        return implode('|', [$object->amount, $object->currency]);
+        return \implode('|', [$object->amount, $object->currency]);
     }
 
-    public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool
+    public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool
     {
         return $data instanceof Money;
     }
 
-    public function getSupportedTypes(?string $format = null): array
+    public function getSupportedTypes(string $format = null): array
     {
         return [Money::class => true];
     }