Repository: symfony/ux
Pull request #2210 (2024-09-26 11:01:32)

[TwigComponent] Fix ux:icon & ux:map renders
Change implementation to avoid many dispatched events for nothing

Add a conflict in symfony/ux-map and symfony/ux-icons  to synchronise the 3 packages to 2.21

Performance gain around 20%

Kocal: > Performance gain around 20%

Can you link the Blackfire profiles delta plz? 

Patch:
diff --git a/src/Icons/composer.json b/src/Icons/composer.json
index 14dfadb5..469effc3 100644
--- a/src/Icons/composer.json
+++ b/src/Icons/composer.json
@@ -52,7 +52,8 @@
         "sort-packages": true
     },
     "conflict": {
-        "symfony/flex": "<1.13"
+        "symfony/flex": "<1.13",
+        "symfony/ux-twig-component": "<2.21"
     },
     "extra": {
         "thanks": {
diff --git a/src/Icons/config/twig_component.php b/src/Icons/config/twig_component.php
index f466f253..5e27cc81 100644
--- a/src/Icons/config/twig_component.php
+++ b/src/Icons/config/twig_component.php
@@ -16,15 +16,6 @@ use Symfony\UX\Icons\Twig\UXIconComponentListener;
 
 return static function (ContainerConfigurator $container): void {
     $container->services()
-        ->set('.ux_icons.twig_component_listener', UXIconComponentListener::class)
-            ->args([
-                service('.ux_icons.icon_renderer'),
-            ])
-            ->tag('kernel.event_listener', [
-                'event' => 'Symfony\UX\TwigComponent\Event\PreCreateForRenderEvent',
-                'method' => 'onPreCreateForRender',
-            ])
-
         ->set('.ux_icons.twig_component.icon', UXIconComponent::class)
             ->tag('twig.component', ['key' => 'UX:Icon'])
     ;
diff --git a/src/Icons/src/Twig/UXIconComponentListener.php b/src/Icons/src/Twig/UXIconComponentListener.php
deleted file mode 100644
index 8ccf4df1..00000000
--- a/src/Icons/src/Twig/UXIconComponentListener.php
+++ /dev/null
@@ -1,43 +0,0 @@
-<?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\Icons\Twig;
-
-use Symfony\UX\Icons\IconRendererInterface;
-use Symfony\UX\TwigComponent\Event\PreCreateForRenderEvent;
-
-/**
- * @author Simon André <smn.andre@gmail.com>
- *
- * @internal
- */
-final class UXIconComponentListener
-{
-    public function __construct(
-        private IconRendererInterface $iconRenderer,
-    ) {
-    }
-
-    public function onPreCreateForRender(PreCreateForRenderEvent $event): void
-    {
-        if ('ux:icon' !== strtolower($event->getName())) {
-            return;
-        }
-
-        $attributes = $event->getInputProps();
-        $name = (string) $attributes['name'];
-        unset($attributes['name']);
-
-        $svg = $this->iconRenderer->renderIcon($name, $attributes);
-        $event->setRenderedString($svg);
-        $event->stopPropagation();
-    }
-}
diff --git a/src/Icons/src/Twig/UXIconRuntime.php b/src/Icons/src/Twig/UXIconRuntime.php
index 7276ac08..edaffd8b 100644
--- a/src/Icons/src/Twig/UXIconRuntime.php
+++ b/src/Icons/src/Twig/UXIconRuntime.php
@@ -47,4 +47,12 @@ final class UXIconRuntime implements RuntimeExtensionInterface
             throw $e;
         }
     }
+
+    public function render(array $args = []): string
+    {
+        $name = $args['name'];
+        unset($args['name']);
+
+        return $this->renderIcon($name, $args);
+    }
 }
diff --git a/src/Map/composer.json b/src/Map/composer.json
index a24b4fd3..52adf9bc 100644
--- a/src/Map/composer.json
+++ b/src/Map/composer.json
@@ -42,6 +42,9 @@
         "symfony/twig-bundle": "^6.4|^7.0",
         "symfony/ux-twig-component": "^2.18"
     },
+    "conflict": {
+        "symfony/ux-twig-component": "<2.21"
+    },
     "extra": {
         "thanks": {
             "name": "symfony/ux",
diff --git a/src/Map/config/twig_component.php b/src/Map/config/twig_component.php
index f09999e9..096eec4a 100644
--- a/src/Map/config/twig_component.php
+++ b/src/Map/config/twig_component.php
@@ -17,15 +17,6 @@ use Symfony\UX\TwigComponent\Event\PreCreateForRenderEvent;
 
 return static function (ContainerConfigurator $container): void {
     $container->services()
-        ->set('.ux_map.twig_component_listener', UXMapComponentListener::class)
-            ->args([
-                service('ux_map.twig_runtime'),
-            ])
-            ->tag('kernel.event_listener', [
-                'event' => PreCreateForRenderEvent::class,
-                'method' => 'onPreCreateForRender',
-            ])
-
         ->set('.ux_map.twig_component.map', UXMapComponent::class)
             ->tag('twig.component', ['key' => 'UX:Map'])
     ;
diff --git a/src/Map/src/Twig/MapRuntime.php b/src/Map/src/Twig/MapRuntime.php
index cfb47560..8b9a1ca2 100644
--- a/src/Map/src/Twig/MapRuntime.php
+++ b/src/Map/src/Twig/MapRuntime.php
@@ -67,4 +67,12 @@ final class MapRuntime implements RuntimeExtensionInterface
 
         return $this->renderer->renderMap($map, $attributes);
     }
+
+    public function render(array $args = []): string
+    {
+        $map = array_intersect_key($args, ['map' => 0, 'markers' => 0, 'polygons' => 0, 'center' => 1, 'zoom' => 2]);
+        $attributes = array_diff_key($args, $map);
+
+        return $this->renderMap(...$map, attributes: $attributes);
+    }
 }
diff --git a/src/Map/src/Twig/UXMapComponentListener.php b/src/Map/src/Twig/UXMapComponentListener.php
deleted file mode 100644
index 51034c53..00000000
--- a/src/Map/src/Twig/UXMapComponentListener.php
+++ /dev/null
@@ -1,42 +0,0 @@
-<?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\Map\Twig;
-
-use Symfony\UX\TwigComponent\Event\PreCreateForRenderEvent;
-
-/**
- * @author Simon André <smn.andre@gmail.com>
- *
- * @internal
- */
-final class UXMapComponentListener
-{
-    public function __construct(
-        private MapRuntime $mapRuntime,
-    ) {
-    }
-
-    public function onPreCreateForRender(PreCreateForRenderEvent $event): void
-    {
-        if ('ux:map' !== strtolower($event->getName())) {
-            return;
-        }
-
-        $attributes = $event->getInputProps();
-        $map = array_intersect_key($attributes, ['markers' => 0, 'polygons' => 0, 'center' => 1, 'zoom' => 2]);
-        $attributes = array_diff_key($attributes, $map);
-
-        $html = $this->mapRuntime->renderMap(...$map, attributes: $attributes);
-        $event->setRenderedString($html);
-        $event->stopPropagation();
-    }
-}
diff --git a/src/Translator/src/Intl/ErrorKind.php b/src/Translator/src/Intl/ErrorKind.php
index 94066bba..cabe240e 100644
--- a/src/Translator/src/Intl/ErrorKind.php
+++ b/src/Translator/src/Intl/ErrorKind.php
@@ -14,7 +14,7 @@ namespace Symfony\UX\Translator\Intl;
 /**
  * Adapted from https://github.com/formatjs/formatjs/blob/590f1f81b26934c6dc7a55fff938df5436c6f158/packages/icu-messageformat-parser/error.ts#L9-L77.
  *
- * @internal
+ * @experimental
  */
 final class ErrorKind
 {
diff --git a/src/Translator/src/Intl/IntlMessageParser.php b/src/Translator/src/Intl/IntlMessageParser.php
index 560b883e..839de42f 100644
--- a/src/Translator/src/Intl/IntlMessageParser.php
+++ b/src/Translator/src/Intl/IntlMessageParser.php
@@ -11,21 +11,16 @@
 
 namespace Symfony\UX\Translator\Intl;
 
-use Symfony\Component\String\AbstractString;
-
 use function Symfony\Component\String\s;
 
 /**
  * Adapted from https://github.com/formatjs/formatjs/blob/590f1f81b26934c6dc7a55fff938df5436c6f158/packages/icu-messageformat-parser/parser.ts.
  *
- * @internal
+ * @experimental
  */
-final class IntlMessageParser
+class IntlMessageParser
 {
-    private readonly AbstractString $message;
-    // Minor optimization, this avoid a lot of calls to `$this->message->length()`
-    private readonly int $messageLength;
-
+    private string $message;
     private Position $position;
     private bool $ignoreTag;
     private bool $requiresOtherClause;
@@ -33,8 +28,7 @@ final class IntlMessageParser
     public function __construct(
         string $message,
     ) {
-        $this->message = s($message);
-        $this->messageLength = $this->message->length();
+        $this->message = $message;
         $this->position = new Position(0, 1, 1);
         $this->ignoreTag = true;
         $this->requiresOtherClause = true;
@@ -118,14 +112,14 @@ final class IntlMessageParser
      */
     private function parseTagName(): string
     {
-        $startOffset = $this->position->offset;
+        $startOffset = $this->offset();
 
         $this->bump(); // the first tag name character
         while (!$this->isEOF() && Utils::isPotentialElementNameChar($this->char())) {
             $this->bump();
         }
 
-        return $this->message->slice($startOffset, $this->position->offset - $startOffset)->toString();
+        return s($this->message)->slice($startOffset, $this->offset() - $startOffset)->toString();
     }
 
     /**
@@ -371,7 +365,7 @@ final class IntlMessageParser
     {
         $startingPosition = clone $this->position;
 
-        $startOffset = $this->position->offset;
+        $startOffset = $this->offset();
         $value = Utils::matchIdentifierAtIndex($this->message, $startOffset);
         $endOffset = $startOffset + s($value)->length();
 
@@ -451,9 +445,9 @@ final class IntlMessageParser
                 );
 
                 // Extract style or skeleton
-                if ($styleAndLocation && ($style = s($styleAndLocation['style'] ?? ''))->startsWith('::')) {
+                if ($styleAndLocation && s($styleAndLocation['style'] ?? '')->startsWith('::')) {
                     // Skeleton starts with `::`.
-                    $skeleton = $style->slice(2)->trimStart()->toString();
+                    $skeleton = s($styleAndLocation['style'])->slice(2)->trimStart()->toString();
 
                     if ('number' === $argType) {
                         $result = $this->parseNumberSkeletonFromString(
@@ -669,7 +663,7 @@ final class IntlMessageParser
                         --$nestedBraces;
                     } else {
                         return [
-                            'val' => $this->message->slice($startPosition->offset, $this->position->offset - $startPosition->offset)->toString(),
+                            'val' => s($this->message)->slice($startPosition->offset, $this->offset() - $startPosition->offset)->toString(),
                             'err' => null,
                         ];
                     }
@@ -682,7 +676,7 @@ final class IntlMessageParser
         }
 
         return [
-            'val' => $this->message->slice($startPosition->offset, $this->position->offset - $startPosition->offset)->toString(),
+            'val' => s($this->message)->slice($startPosition->offset, $this->offset() - $startPosition->offset)->toString(),
             'err' => null,
         ];
     }
@@ -741,7 +735,7 @@ final class IntlMessageParser
                         return $result;
                     }
                     $selectorLocation = new Location($startPosition, clone $this->position);
-                    $selector = $this->message->slice($startPosition->offset, $this->position->offset - $startPosition->offset)->toString();
+                    $selector = s($this->message)->slice($startPosition->offset, $this->offset() - $startPosition->offset)->toString();
                 } else {
                     break;
                 }
@@ -870,9 +864,14 @@ final class IntlMessageParser
         ];
     }
 
+    private function offset(): int
+    {
+        return $this->position->offset;
+    }
+
     private function isEOF(): bool
     {
-        return $this->position->offset === $this->messageLength;
+        return $this->offset() === s($this->message)->length();
     }
 
     /**
@@ -883,14 +882,16 @@ final class IntlMessageParser
      */
     private function char(): int
     {
+        $message = s($this->message);
+
         $offset = $this->position->offset;
-        if ($offset >= $this->messageLength) {
+        if ($offset >= $message->length()) {
             throw new \OutOfBoundsException();
         }
 
-        $code = $this->message->codePointsAt($offset)[0] ?? null;
+        $code = $message->slice($offset, 1)->codePointsAt(0)[0] ?? null;
         if (null === $code) {
-            throw new \Exception("Offset {$offset} is at invalid UTF-16 code unit boundary.");
+            throw new \Exception("Offset {$offset} is at invalid UTF-16 code unit boundary");
         }
 
         return $code;
@@ -908,7 +909,7 @@ final class IntlMessageParser
             'err' => [
                 'kind' => $kind,
                 'location' => $location,
-                'message' => $this->message->toString(),
+                'message' => $this->message,
             ],
         ];
     }
@@ -940,7 +941,7 @@ final class IntlMessageParser
      */
     private function bumpIf(string $prefix): bool
     {
-        if ($this->message->slice($this->position->offset)->startsWith($prefix)) {
+        if (s($this->message)->slice($this->offset())->startsWith($prefix)) {
             for ($i = 0, $len = \strlen($prefix); $i < $len; ++$i) {
                 $this->bump();
             }
@@ -957,13 +958,14 @@ final class IntlMessageParser
      */
     private function bumpUntil(string $pattern): bool
     {
-        $index = $this->message->indexOf($pattern, $this->position->offset);
+        $currentOffset = $this->offset();
+        $index = s($this->message)->indexOf($pattern, $currentOffset);
         if ($index >= 0) {
             $this->bumpTo($index);
 
             return true;
         } else {
-            $this->bumpTo($this->messageLength);
+            $this->bumpTo(s($this->message)->length());
 
             return false;
         }
@@ -977,18 +979,18 @@ final class IntlMessageParser
      */
     private function bumpTo(int $targetOffset)
     {
-        if ($this->position->offset > $targetOffset) {
-            throw new \Exception(\sprintf('targetOffset "%s" must be greater than or equal to the current offset %d', $targetOffset, $this->position->offset));
+        if ($this->offset() > $targetOffset) {
+            throw new \Exception(\sprintf('targetOffset %s must be greater than or equal to the current offset %d', $targetOffset, $this->offset()));
         }
 
-        $targetOffset = min($targetOffset, $this->messageLength);
+        $targetOffset = min($targetOffset, s($this->message)->length());
         while (true) {
-            $offset = $this->position->offset;
+            $offset = $this->offset();
             if ($offset === $targetOffset) {
                 break;
             }
             if ($offset > $targetOffset) {
-                throw new \Exception("targetOffset {$targetOffset} is at invalid UTF-16 code unit boundary.");
+                throw new \Exception("targetOffset {$targetOffset} is at invalid UTF-16 code unit boundary");
             }
 
             $this->bump();
@@ -1017,7 +1019,8 @@ final class IntlMessageParser
         }
 
         $code = $this->char();
-        $nextCodes = $this->message->codePointsAt($this->position->offset + ($code >= 0x10000 ? 2 : 1));
+        $offset = $this->offset();
+        $nextCodes = s($this->message)->codePointsAt($offset + ($code >= 0x10000 ? 2 : 1));
 
         return $nextCodes[0] ?? null;
     }
diff --git a/src/Translator/src/Intl/Location.php b/src/Translator/src/Intl/Location.php
index 270a1bfe..e342a2ff 100644
--- a/src/Translator/src/Intl/Location.php
+++ b/src/Translator/src/Intl/Location.php
@@ -14,7 +14,7 @@ namespace Symfony\UX\Translator\Intl;
 /**
  * Adapted from https://github.com/formatjs/formatjs/blob/590f1f81b26934c6dc7a55fff938df5436c6f158/packages/icu-messageformat-parser/types.ts#L58-L61.
  *
- * @internal
+ * @experimental
  */
 final class Location
 {
diff --git a/src/Translator/src/Intl/Position.php b/src/Translator/src/Intl/Position.php
index 0772508c..bc633622 100644
--- a/src/Translator/src/Intl/Position.php
+++ b/src/Translator/src/Intl/Position.php
@@ -14,7 +14,7 @@ namespace Symfony\UX\Translator\Intl;
 /**
  * Adapted from https://github.com/formatjs/formatjs/blob/590f1f81b26934c6dc7a55fff938df5436c6f158/packages/icu-messageformat-parser/types.ts#L53-L57.
  *
- * @internal
+ * @experimental
  */
 final class Position
 {
diff --git a/src/Translator/src/Intl/SkeletonType.php b/src/Translator/src/Intl/SkeletonType.php
index 089c87fe..b10daeb0 100644
--- a/src/Translator/src/Intl/SkeletonType.php
+++ b/src/Translator/src/Intl/SkeletonType.php
@@ -14,7 +14,7 @@ namespace Symfony\UX\Translator\Intl;
 /**
  * Adapted from https://github.com/formatjs/formatjs/blob/590f1f81b26934c6dc7a55fff938df5436c6f158/packages/icu-messageformat-parser/types.ts#L48-L51.
  *
- * @internal
+ * @experimental
  */
 final class SkeletonType
 {
diff --git a/src/Translator/src/Intl/Type.php b/src/Translator/src/Intl/Type.php
index d02bab4a..12927c34 100644
--- a/src/Translator/src/Intl/Type.php
+++ b/src/Translator/src/Intl/Type.php
@@ -14,7 +14,7 @@ namespace Symfony\UX\Translator\Intl;
 /**
  * Adapted from https://github.com/formatjs/formatjs/blob/590f1f81b26934c6dc7a55fff938df5436c6f158/packages/icu-messageformat-parser/types.ts#L8-L46.
  *
- * @internal
+ * @experimental
  */
 final class Type
 {
diff --git a/src/Translator/src/Intl/Utils.php b/src/Translator/src/Intl/Utils.php
index b5c44a5f..af363073 100644
--- a/src/Translator/src/Intl/Utils.php
+++ b/src/Translator/src/Intl/Utils.php
@@ -11,10 +11,10 @@
 
 namespace Symfony\UX\Translator\Intl;
 
-use Symfony\Component\String\AbstractString;
+use function Symfony\Component\String\s;
 
 /**
- * @internal
+ * @experimental
  */
 final class Utils
 {
@@ -355,12 +355,12 @@ final class Utils
         return $elements;
     }
 
-    public static function matchIdentifierAtIndex(AbstractString $s, int $index): string
+    public static function matchIdentifierAtIndex(string $s, int $index): string
     {
         $match = [];
 
         while (true) {
-            $c = $s->codePointsAt($index)[0] ?? null;
+            $c = s($s)->codePointsAt($index)[0] ?? null;
             if (null === $c || self::isWhiteSpace($c) || self::isPatternSyntax($c)) {
                 break;
             }
diff --git a/src/Translator/src/MessageParameters/Extractor/ExtractorInterface.php b/src/Translator/src/MessageParameters/Extractor/ExtractorInterface.php
index 18c709c3..93d2b1d5 100644
--- a/src/Translator/src/MessageParameters/Extractor/ExtractorInterface.php
+++ b/src/Translator/src/MessageParameters/Extractor/ExtractorInterface.php
@@ -14,7 +14,7 @@ namespace Symfony\UX\Translator\MessageParameters\Extractor;
 /**
  * @author Hugo Alliaume <hugo@alliau.me>
  *
- * @internal
+ * @experimental
  */
 interface ExtractorInterface
 {
diff --git a/src/Translator/src/MessageParameters/Extractor/IntlMessageParametersExtractor.php b/src/Translator/src/MessageParameters/Extractor/IntlMessageParametersExtractor.php
index 091e2789..6722f882 100644
--- a/src/Translator/src/MessageParameters/Extractor/IntlMessageParametersExtractor.php
+++ b/src/Translator/src/MessageParameters/Extractor/IntlMessageParametersExtractor.php
@@ -17,7 +17,7 @@ use Symfony\UX\Translator\Intl\Type;
 /**
  * @author Hugo Alliaume <hugo@alliau.me>
  *
- * @internal
+ * @experimental
  */
 final class IntlMessageParametersExtractor implements ExtractorInterface
 {
diff --git a/src/Translator/src/MessageParameters/Extractor/MessageParametersExtractor.php b/src/Translator/src/MessageParameters/Extractor/MessageParametersExtractor.php
index 38988ea7..289c32db 100644
--- a/src/Translator/src/MessageParameters/Extractor/MessageParametersExtractor.php
+++ b/src/Translator/src/MessageParameters/Extractor/MessageParametersExtractor.php
@@ -14,7 +14,7 @@ namespace Symfony\UX\Translator\MessageParameters\Extractor;
 /**
  * @author Hugo Alliaume <hugo@alliau.me>
  *
- * @internal
+ * @experimental
  */
 final class MessageParametersExtractor implements ExtractorInterface
 {
diff --git a/src/Translator/src/MessageParameters/Printer/TypeScriptMessageParametersPrinter.php b/src/Translator/src/MessageParameters/Printer/TypeScriptMessageParametersPrinter.php
index 2981142e..4eaf9c89 100644
--- a/src/Translator/src/MessageParameters/Printer/TypeScriptMessageParametersPrinter.php
+++ b/src/Translator/src/MessageParameters/Printer/TypeScriptMessageParametersPrinter.php
@@ -14,7 +14,7 @@ namespace Symfony\UX\Translator\MessageParameters\Printer;
 /**
  * @author Hugo Alliaume <hugo@alliau.me>
  *
- * @internal
+ * @experimental
  */
 final class TypeScriptMessageParametersPrinter
 {
diff --git a/src/Translator/src/TranslationsDumper.php b/src/Translator/src/TranslationsDumper.php
index a986c7c0..cba61117 100644
--- a/src/Translator/src/TranslationsDumper.php
+++ b/src/Translator/src/TranslationsDumper.php
@@ -155,18 +155,21 @@ TS
                 }
 
                 $parametersTypes[$domain] = $this->typeScriptMessageParametersPrinter->print($parameters);
+
                 $locales[] = $locale;
             }
         }
 
-        $typeScriptParametersType = [];
-        foreach ($parametersTypes as $domain => $parametersType) {
-            $typeScriptParametersType[] = \sprintf("'%s': { parameters: %s }", $domain, $parametersType);
-        }
-
         return \sprintf(
             'Message<{ %s }, %s>',
-            implode(', ', $typeScriptParametersType),
+            implode(', ', array_reduce(
+                array_keys($parametersTypes),
+                fn (array $carry, string $domain) => [
+                    ...$carry,
+                    \sprintf("'%s': { parameters: %s }", $domain, $parametersTypes[$domain]),
+                ],
+                [],
+            )),
             implode('|', array_map(fn (string $locale) => "'$locale'", array_unique($locales))),
         );
     }
@@ -186,14 +189,13 @@ TS
     {
         static $alreadyGenerated = [];
 
-        $translationId = s($translationId)->ascii()->snake()->upper()->replaceMatches('/^(\d)/', '_$1')->toString();
         $prefix = 0;
         do {
-            $constantName = $translationId.($prefix > 0 ? '_'.$prefix : '');
+            $constantName = s($translationId)->ascii()->snake()->upper()->replaceMatches('/^(\d)/', '_$1')->toString().($prefix > 0 ? '_'.$prefix : '');
             ++$prefix;
-        } while ($alreadyGenerated[$constantName] ?? false);
+        } while (\in_array($constantName, $alreadyGenerated, true));
 
-        $alreadyGenerated[$constantName] = true;
+        $alreadyGenerated[] = $constantName;
 
         return $constantName;
     }
diff --git a/src/TwigComponent/doc/index.rst b/src/TwigComponent/doc/index.rst
index ac0bf998..cb6e22ad 100644
--- a/src/TwigComponent/doc/index.rst
+++ b/src/TwigComponent/doc/index.rst
@@ -1618,15 +1618,15 @@ when using nested components:
     </twig:Card>
 
     {# ✅ this also works because it only uses the Twig syntax #}
-    {% component Card %}
+    <twig:Card>
         {# ... #}
 
-        {% block footer %}
+        <twig:block name="footer">
             {% component 'Button:Primary' with {isBlock: true} %}
                 {% block content %}Edit{% endblock %}
             {% endcomponent %}
-        {% endblock %}
-    {% endcomponent %}
+        </twig:block>
+    </twig:Card>
 
 If you're using `Live Components`_, then there *are* some guidelines related to
 how the re-rendering of parent and child components works. Read `Live Nested Components`_.
diff --git a/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php b/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php
index c0c18b0e..aa1419ee 100644
--- a/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php
+++ b/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php
@@ -36,6 +36,8 @@ use Symfony\UX\TwigComponent\Twig\ComponentExtension;
 use Symfony\UX\TwigComponent\Twig\ComponentLexer;
 use Symfony\UX\TwigComponent\Twig\ComponentRuntime;
 use Symfony\UX\TwigComponent\Twig\TwigEnvironmentConfigurator;
+use function Symfony\Component\DependencyInjection\Loader\Configurator\service;
+use function Symfony\Component\DependencyInjection\Loader\Configurator\service_locator;
 
 /**
  * @author Kevin Bond <kevinbond@gmail.com>
@@ -108,6 +110,10 @@ final class TwigComponentExtension extends Extension implements ConfigurationInt
         $container->register('.ux.twig_component.twig.component_runtime', ComponentRuntime::class)
             ->setArguments([
                 new Reference('ux.twig_component.component_renderer'),
+                service_locator([
+                    'ux:icon' => service('.ux_icons.twig_icon_runtime')->nullOnInvalid(),
+                    'ux:map' => service('ux_map.twig_runtime')->nullOnInvalid(),
+                ]),
             ])
             ->addTag('twig.runtime')
         ;
diff --git a/src/TwigComponent/src/Twig/ComponentRuntime.php b/src/TwigComponent/src/Twig/ComponentRuntime.php
index b6157599..f770ef32 100644
--- a/src/TwigComponent/src/Twig/ComponentRuntime.php
+++ b/src/TwigComponent/src/Twig/ComponentRuntime.php
@@ -11,6 +11,7 @@
 
 namespace Symfony\UX\TwigComponent\Twig;
 
+use Symfony\Component\DependencyInjection\ServiceLocator;
 use Symfony\UX\TwigComponent\ComponentRenderer;
 use Symfony\UX\TwigComponent\Event\PreRenderEvent;
 
@@ -24,15 +25,13 @@ final class ComponentRuntime
 {
     public function __construct(
         private readonly ComponentRenderer $renderer,
+        private readonly ServiceLocator $renderers,
     ) {
     }
 
-    /**
-     * @param array<string, mixed> $props
-     */
-    public function render(string $name, array $props = []): string
+    public function finishEmbedComponent(): void
     {
-        return $this->renderer->createAndRender($name, $props);
+        $this->renderer->finishEmbeddedComponentRender();
     }
 
     /**
@@ -43,6 +42,15 @@ final class ComponentRuntime
         return $this->renderer->preCreateForRender($name, $props);
     }
 
+    public function render(string $name, array $props = []): string
+    {
+        if ($this->renderers->has($normalized = strtolower($name))) {
+            return $this->renderers->get($normalized)->render($props);
+        }
+
+        return $this->renderer->createAndRender($name, $props);
+    }
+
     /**
      * @param array<string, mixed> $props
      * @param array<string, mixed> $context
@@ -51,9 +59,4 @@ final class ComponentRuntime
     {
         return $this->renderer->startEmbeddedComponentRender($name, $props, $context, $hostTemplateName, $index);
     }
-
-    public function finishEmbedComponent(): void
-    {
-        $this->renderer->finishEmbeddedComponentRender();
-    }
 }


Test patch:
diff --git a/src/Translator/tests/TranslationsDumperTest.php b/src/Translator/tests/TranslationsDumperTest.php
index 10b13d90..c68a9fef 100644
--- a/src/Translator/tests/TranslationsDumperTest.php
+++ b/src/Translator/tests/TranslationsDumperTest.php
@@ -57,7 +57,6 @@ export const NOTIFICATION_COMMENT_CREATED = {"id":"notification.comment_created"
 export const NOTIFICATION_COMMENT_CREATED_DESCRIPTION = {"id":"notification.comment_created.description","translations":{"messages+intl-icu":{"en":"Your post \"{title}\" has received a new comment. You can read the comment by following <a href=\"{link}\">this link<\/a>","fr":"Votre article \"{title}\" a re\u00e7u un nouveau commentaire. Vous pouvez lire le commentaire en suivant <a href=\"{link}\">ce lien<\/a>"}}};
 export const POST_NUM_COMMENTS = {"id":"post.num_comments","translations":{"messages+intl-icu":{"en":"{count, plural, one {# comment} other {# comments}}","fr":"{count, plural, one {# commentaire} other {# commentaires}}"},"foobar":{"en":"There is 1 comment|There are %count% comments","fr":"Il y a 1 comment|Il y a %count% comments"}}};
 export const POST_NUM_COMMENTS_1 = {"id":"post.num_comments.","translations":{"messages+intl-icu":{"en":"{count, plural, one {# comment} other {# comments}} (should not conflict with the previous one.)","fr":"{count, plural, one {# commentaire} other {# commentaires}} (ne doit pas rentrer en conflit avec la traduction pr\u00e9c\u00e9dente)"}}};
-export const POST_NUM_COMMENTS_2 = {"id":"post.num_comments..","translations":{"messages+intl-icu":{"en":"{count, plural, one {# comment} other {# comments}} (should not conflict with the previous one.)","fr":"{count, plural, one {# commentaire} other {# commentaires}} (ne doit pas rentrer en conflit avec la traduction pr\u00e9c\u00e9dente)"}}};
 export const SYMFONY_GREAT = {"id":"symfony.great","translations":{"messages":{"en":"Symfony is awesome!","fr":"Symfony est g\u00e9nial !"}}};
 export const SYMFONY_WHAT = {"id":"symfony.what","translations":{"messages":{"en":"Symfony is %what%!","fr":"Symfony est %what%!"}}};
 export const SYMFONY_WHAT_1 = {"id":"symfony.what!","translations":{"messages":{"en":"Symfony is %what%! (should not conflict with the previous one.)","fr":"Symfony est %what%! (ne doit pas rentrer en conflit avec la traduction pr\u00e9c\u00e9dente)"}}};
@@ -84,7 +83,6 @@ export declare const NOTIFICATION_COMMENT_CREATED: Message<{ 'messages+intl-icu'
 export declare const NOTIFICATION_COMMENT_CREATED_DESCRIPTION: Message<{ 'messages+intl-icu': { parameters: { 'title': string, 'link': string } } }, 'en'|'fr'>;
 export declare const POST_NUM_COMMENTS: Message<{ 'messages+intl-icu': { parameters: { 'count': number } }, 'foobar': { parameters: { '%count%': number } } }, 'en'|'fr'>;
 export declare const POST_NUM_COMMENTS_1: Message<{ 'messages+intl-icu': { parameters: { 'count': number } } }, 'en'|'fr'>;
-export declare const POST_NUM_COMMENTS_2: Message<{ 'messages+intl-icu': { parameters: { 'count': number } } }, 'en'|'fr'>;
 export declare const SYMFONY_GREAT: Message<{ 'messages': { parameters: NoParametersType } }, 'en'|'fr'>;
 export declare const SYMFONY_WHAT: Message<{ 'messages': { parameters: { '%what%': string } } }, 'en'|'fr'>;
 export declare const SYMFONY_WHAT_1: Message<{ 'messages': { parameters: { '%what%': string } } }, 'en'|'fr'>;
@@ -151,7 +149,6 @@ TYPESCRIPT);
                     'notification.comment_created.description' => 'Your post "{title}" has received a new comment. You can read the comment by following <a href="{link}">this link</a>',
                     'post.num_comments' => '{count, plural, one {# comment} other {# comments}}',
                     'post.num_comments.' => '{count, plural, one {# comment} other {# comments}} (should not conflict with the previous one.)',
-                    'post.num_comments..' => '{count, plural, one {# comment} other {# comments}} (should not conflict with the previous one.)',
                 ],
                 'messages' => [
                     'symfony.great' => 'Symfony is awesome!',
@@ -181,7 +178,6 @@ TYPESCRIPT);
                     'notification.comment_created.description' => 'Votre article "{title}" a reçu un nouveau commentaire. Vous pouvez lire le commentaire en suivant <a href="{link}">ce lien</a>',
                     'post.num_comments' => '{count, plural, one {# commentaire} other {# commentaires}}',
                     'post.num_comments.' => '{count, plural, one {# commentaire} other {# commentaires}} (ne doit pas rentrer en conflit avec la traduction précédente)',
-                    'post.num_comments..' => '{count, plural, one {# commentaire} other {# commentaires}} (ne doit pas rentrer en conflit avec la traduction précédente)',
                 ],
                 'messages' => [
                     'symfony.great' => 'Symfony est génial !',