Repository: symfony/ux
Pull request #2364 (2024-11-11 01:03:27)

[Twig] Cache template class resolution
Cache template class during rendering.

Non negligeable impact on pages with many components.

<img width="806" alt="Capture d’écran 2024-11-11 à 01 59 30" src="https://github.com/user-attachments/assets/8604c359-11f2-4482-aacc-8670e5ce5006">


smnandre: Arf.... again LiveComponent hidden contract .. :( 

Will fix later
smnandre: Cannot reproduce locally :( 
smnandre: Weird. I think is it related to property accessor but it’s weird this différence between local and CI. 
Kocal: Locally, is your `src/LiveComponent/vendor/symfony/ux-twig-component` points to `src/TwigComponent`? 
smnandre: Ok i confirm... the Live test helper does not simulate real conditions. But i think we can deal with it :) 


This was the change responsible for failures 🤷 


```diff
+       if (!$componentMetadata->isAnonymous()) {
            // set data that wasn't set in mount on the component directly
            foreach ($data as $property => $value) {
                if ($this->propertyAccessor->isWritable($component, $property)) {
                    $this->propertyAccessor->setValue($component, $property, $value);
                    unset($data[$property]);
                }
            }
+        }
```








smnandre: Fabbot wrong, we can move on with this one :) 

Patch:
diff --git a/src/Map/doc/index.rst b/src/Map/doc/index.rst
index 2876d8e6..6c22bc8a 100644
--- a/src/Map/doc/index.rst
+++ b/src/Map/doc/index.rst
@@ -30,11 +30,11 @@ Configuration
 Configuration is done in your ``config/packages/ux_map.yaml`` file:
 
 .. code-block:: yaml
-
+    
     # config/packages/ux_map.yaml
     ux_map:
         renderer: '%env(resolve:default::UX_MAP_DSN)%'
-
+        
         # Google Maps specific configuration
         google_maps:
             # Configure the default Map Id (https://developers.google.com/maps/documentation/get-map-id),
@@ -47,8 +47,8 @@ Map renderers
 ~~~~~~~~~~~~~
 
 The Symfony UX Map bundle supports multiple renderers. A map renderer is a
-service that provides the code and graphic assets required to render and
-interact with a map in the browser.
+service that provides the code and graphic assets required to render and 
+interact with a map in the browser. 
 
 Available renderers
 ~~~~~~~~~~~~~~~~~~~
@@ -75,7 +75,7 @@ Create a map
 
 A map is created by calling ``new Map()``. You can configure the center, zoom, and add markers.
 Start by creating a new map instance::
-
+    
     use Symfony\UX\Map\Map;
 
     // Create a new map instance
@@ -88,12 +88,12 @@ You can set the center and zoom of the map using the ``center()`` and ``zoom()``
 
     use Symfony\UX\Map\Map;
     use Symfony\UX\Map\Point;
-
+    
     $myMap
         // Explicitly set the center and zoom
         ->center(new Point(46.903354, 1.888334))
         ->zoom(6)
-
+        
         // Or automatically fit the bounds to the markers
         ->fitBoundsToMarkers()
     ;
@@ -105,13 +105,13 @@ You can add markers to a map using the ``addMarker()`` method::
 
     $myMap
         ->addMarker(new Marker(
-            position: new Point(48.8566, 2.3522),
+            position: new Point(48.8566, 2.3522), 
             title: 'Paris'
         ))
 
         // With an info window associated to the marker:
         ->addMarker(new Marker(
-            position: new Point(45.7640, 4.8357),
+            position: new Point(45.7640, 4.8357), 
             title: 'Lyon',
             infoWindow: new InfoWindow(
                 headerContent: '<b>Lyon</b>',
@@ -122,7 +122,7 @@ You can add markers to a map using the ``addMarker()`` method::
         // You can also pass arbitrary data via the `extra` option in both the marker
         // and the infoWindow; you can later use this data in your custom Stimulus controllers
         ->addMarker(new Marker(
-            position: new Point(45.7740, 4.8351), 
+            // ...
             extra: [
                 'icon_mask_url' => 'https://maps.gstatic.com/mapfiles/place_api/icons/v2/tree_pinlet.svg',
             ],
@@ -133,7 +133,7 @@ You can add markers to a map using the ``addMarker()`` method::
                     'includes_link' => true,
                 ],
             ),
-        ))
+        )
     ;
 
 Add Polygons
@@ -174,7 +174,7 @@ You can add custom HTML attributes too:
 Twig Function ``ux_map()``
 ~~~~~~~~~~~~~~~~~~~~~~~~~~
 
-The ``ux_map()`` Twig function allows you to create and render a map in your Twig
+The ``ux_map()`` Twig function allows you to create and render a map in your Twig 
 templates. The function accepts the same arguments as the ``Map`` class:
 
 .. code-block:: html+twig
@@ -216,8 +216,10 @@ Alternatively, you can use the ``<twig:ux:map />`` component.
                 "infoWindow": {"content": "Welcome to <b>New York</b>"}
             }
         ]'
-        class="foo"
-        style="height: 800px; width: 100%; border: 4px solid red; margin-block: 10vh;"
+        attributes='{
+            "class": "foo",
+            "style": "height: 800px; width: 100%; border: 4px solid red; margin-block: 10vh;"
+        }'
     />
 
 The ``<twig:ux:map />`` component requires the `Twig Component`_ package.
@@ -234,9 +236,9 @@ Symfony UX Map allows you to extend its default behavior using a custom Stimulus
 .. code-block:: javascript
 
     // assets/controllers/mymap_controller.js
-
+    
     import { Controller } from '@hotwired/stimulus';
-
+    
     export default class extends Controller {
         connect() {
             this.element.addEventListener('ux:map:pre-connect', this._onPreConnect);
@@ -246,7 +248,7 @@ Symfony UX Map allows you to extend its default behavior using a custom Stimulus
             this.element.addEventListener('ux:map:info-window:before-create', this._onInfoWindowBeforeCreate);
             this.element.addEventListener('ux:map:info-window:after-create', this._onInfoWindowAfterCreate);
         }
-
+    
         disconnect() {
             // You should always remove listeners when the controller is disconnected to avoid side effects
             this.element.removeEventListener('ux:map:pre-connect', this._onPreConnect);
@@ -256,13 +258,13 @@ Symfony UX Map allows you to extend its default behavior using a custom Stimulus
             this.element.removeEventListener('ux:map:info-window:before-create', this._onInfoWindowBeforeCreate);
             this.element.removeEventListener('ux:map:info-window:after-create', this._onInfoWindowAfterCreate);
         }
-
+    
         _onPreConnect(event) {
             // The map is not created yet
             // You can use this event to configure the map before it is created
             console.log(event.detail.options);
         }
-
+        
         _onConnect(event) {
             // The map, markers and infoWindows are created
             // The instances depend on the renderer you are using
@@ -270,19 +272,19 @@ Symfony UX Map allows you to extend its default behavior using a custom Stimulus
             console.log(event.detail.markers);
             console.log(event.detail.infoWindows);
         }
-
+        
         _onMarkerBeforeCreate(event) {
             // The marker is not created yet
             // You can use this event to configure the marker before it is created
             console.log(event.detail.definition);
         }
-
+        
         _onMarkerAfterCreate(event) {
             // The marker is created
             // The instance depends on the renderer you are using
             console.log(event.detail.marker);
         }
-
+        
         _onInfoWindowBeforeCreate(event) {
             // The infoWindow is not created yet
             // You can use this event to configure the infoWindow before it is created
@@ -290,7 +292,7 @@ Symfony UX Map allows you to extend its default behavior using a custom Stimulus
             // The associated marker instance is also available
             console.log(event.detail.marker);
         }
-
+        
         _onInfoWindowAfterCreate(event) {
             // The infoWindow is created
             // The instance depends on the renderer you are using
@@ -304,7 +306,7 @@ Symfony UX Map allows you to extend its default behavior using a custom Stimulus
 Then, you can use this controller in your template:
 
 .. code-block:: twig
-
+    
     {{ ux_map(my_map, { 'data-controller': 'mymap', style: 'height: 300px' }) }}
 
 .. tip::
diff --git a/src/Map/src/Bridge/Google/README.md b/src/Map/src/Bridge/Google/README.md
index 707907cf..59a55a06 100644
--- a/src/Map/src/Bridge/Google/README.md
+++ b/src/Map/src/Bridge/Google/README.md
@@ -10,7 +10,7 @@ UX_MAP_DSN=google://GOOGLE_MAPS_API_KEY@default
 # With options
 UX_MAP_DSN=google://GOOGLE_MAPS_API_KEY@default?version=weekly
 UX_MAP_DSN=google://GOOGLE_MAPS_API_KEY@default?language=fr&region=FR
-UX_MAP_DSN=google://GOOGLE_MAPS_API_KEY@default?libraries[]=geometry&libraries[]=places
+UX_MAP_DSN=google://GOOGLE_MAPS_API_KEY@default??libraries[]=geometry&libraries[]=places
 ```
 
 Available options:
diff --git a/src/TwigComponent/src/ComponentFactory.php b/src/TwigComponent/src/ComponentFactory.php
index 8699fb10..9e2d618b 100644
--- a/src/TwigComponent/src/ComponentFactory.php
+++ b/src/TwigComponent/src/ComponentFactory.php
@@ -25,12 +25,11 @@ use Symfony\UX\TwigComponent\Event\PreMountEvent;
  */
 final class ComponentFactory implements ResetInterface
 {
-    private static array $mountMethods = [];
+    private array $mountMethods = [];
 
     /**
      * @param array<string, array>        $config
      * @param array<class-string, string> $classMap
-     * @param array<class-string, array<string, string[]> $classMounts
      */
     public function __construct(
         private ComponentTemplateFinderInterface $componentTemplateFinder,
@@ -38,7 +37,7 @@ final class ComponentFactory implements ResetInterface
         private PropertyAccessorInterface $propertyAccessor,
         private EventDispatcherInterface $eventDispatcher,
         private array $config,
-        private array $classMap,
+        private readonly array $classMap,
     ) {
     }
 
@@ -88,29 +87,29 @@ final class ComponentFactory implements ResetInterface
     public function mountFromObject(object $component, array $data, ComponentMetadata $componentMetadata): MountedComponent
     {
         $originalData = $data;
-        $data = $this->preMount($component, $data, $componentMetadata);
+        $event = $this->preMount($component, $data, $componentMetadata);
+        $data = $event->getData();
 
         $this->mount($component, $data, $componentMetadata);
 
-        // set data that wasn't set in mount on the component directly
-        foreach ($data as $property => $value) {
-            if ($this->propertyAccessor->isWritable($component, $property)) {
-                $this->propertyAccessor->setValue($component, $property, $value);
-
-                unset($data[$property]);
+        if (!$componentMetadata->isAnonymous()) {
+            // set data that wasn't set in mount on the component directly
+            foreach ($data as $property => $value) {
+                if ($this->propertyAccessor->isWritable($component, $property)) {
+                    $this->propertyAccessor->setValue($component, $property, $value);
+                    unset($data[$property]);
+                }
             }
         }
 
         $postMount = $this->postMount($component, $data, $componentMetadata);
-        $data = $postMount['data'];
-        $extraMetadata = $postMount['extraMetadata'];
+        $data = $postMount->getData();
 
         // create attributes from "attributes" key if exists
         $attributesVar = $componentMetadata->getAttributesVar();
         $attributes = $data[$attributesVar] ?? [];
         unset($data[$attributesVar]);
 
-        // ensure remaining data is scalar
         foreach ($data as $key => $value) {
             if ($value instanceof \Stringable) {
                 $data[$key] = (string) $value;
@@ -120,9 +119,9 @@ final class ComponentFactory implements ResetInterface
         return new MountedComponent(
             $componentMetadata->getName(),
             $component,
-            new ComponentAttributes(array_merge($attributes, $data)),
+            new ComponentAttributes([...$attributes, ...$data]),
             $originalData,
-            $extraMetadata,
+            $postMount->getExtraMetadata(),
         );
     }
 
@@ -154,7 +153,7 @@ final class ComponentFactory implements ResetInterface
             return;
         }
 
-        $mount = self::$mountMethods[$component::class] ??= (new \ReflectionClass($component))->getMethod('mount');
+        $mount = $this->mountMethods[$component::class] ??= (new \ReflectionClass($component))->getMethod('mount');
 
         $parameters = [];
         foreach ($mount->getParameters() as $refParameter) {
@@ -172,40 +171,34 @@ final class ComponentFactory implements ResetInterface
         $mount->invoke($component, ...$parameters);
     }
 
-    private function preMount(object $component, array $data, ComponentMetadata $componentMetadata): array
+    private function preMount(object $component, array $data, ComponentMetadata $componentMetadata): PreMountEvent
     {
         $event = new PreMountEvent($component, $data, $componentMetadata);
         $this->eventDispatcher->dispatch($event);
-        $data = $event->getData();
 
+        $data = $event->getData();
         foreach ($componentMetadata->getPreMounts() as $preMount) {
             if (null !== $newData = $component->$preMount($data)) {
-                $data = $newData;
+                $event->setData($data = $newData);
             }
         }
 
-        return $data;
+        return $event;
     }
 
-    /**
-     * @return array{data: array<string, mixed>, extraMetadata: array<string, mixed>}
-     */
-    private function postMount(object $component, array $data, ComponentMetadata $componentMetadata): array
+    private function postMount(object $component, array $data, ComponentMetadata $componentMetadata): PostMountEvent
     {
         $event = new PostMountEvent($component, $data, $componentMetadata);
         $this->eventDispatcher->dispatch($event);
-        $data = $event->getData();
 
+        $data = $event->getData();
         foreach ($componentMetadata->getPostMounts() as $postMount) {
             if (null !== $newData = $component->$postMount($data)) {
-                $data = $newData;
+                $event->setData($data = $newData);
             }
         }
 
-        return [
-            'data' => $data,
-            'extraMetadata' => $event->getExtraMetadata(),
-        ];
+        return $event;
     }
 
     /**
@@ -244,6 +237,6 @@ final class ComponentFactory implements ResetInterface
 
     public function reset(): void
     {
-        self::$mountMethods = [];
+        $this->mountMethods = [];
     }
 }
diff --git a/src/TwigComponent/src/ComponentRenderer.php b/src/TwigComponent/src/ComponentRenderer.php
index fb6a01ff..2595d646 100644
--- a/src/TwigComponent/src/ComponentRenderer.php
+++ b/src/TwigComponent/src/ComponentRenderer.php
@@ -12,6 +12,7 @@
 namespace Symfony\UX\TwigComponent;
 
 use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
+use Symfony\Contracts\Service\ResetInterface;
 use Symfony\UX\TwigComponent\Event\PostRenderEvent;
 use Symfony\UX\TwigComponent\Event\PreCreateForRenderEvent;
 use Symfony\UX\TwigComponent\Event\PreRenderEvent;
@@ -22,8 +23,10 @@ use Twig\Environment;
  *
  * @internal
  */
-final class ComponentRenderer implements ComponentRendererInterface
+final class ComponentRenderer implements ComponentRendererInterface, ResetInterface
 {
+    private array $templateClasses = [];
+
     public function __construct(
         private Environment $twig,
         private EventDispatcherInterface $dispatcher,
@@ -62,15 +65,15 @@ final class ComponentRenderer implements ComponentRendererInterface
         $variables = $event->getVariables();
         // see ComponentNode. When rendering an individual embedded component,
         // *not* through its parent, we need to set the parent template.
-        if ($event->getTemplateIndex()) {
+        if ($templateIndex = $event->getTemplateIndex()) {
             $variables['__parent__'] = $event->getParentTemplateForEmbedded();
         }
 
         try {
             return $this->twig->loadTemplate(
-                $this->twig->getTemplateClass($event->getTemplate()),
-                $event->getTemplate(),
-                $event->getTemplateIndex(),
+                $this->templateClasses[$template = $event->getTemplate()] ??= $this->twig->getTemplateClass($template),
+                $template,
+                $templateIndex,
             )->render($variables);
         } finally {
             $mounted = $this->componentStack->pop();
@@ -137,4 +140,9 @@ final class ComponentRenderer implements ComponentRendererInterface
 
         return $event;
     }
+
+    public function reset(): void
+    {
+        $this->templateClasses = [];
+    }
 }
diff --git a/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php b/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php
index 19432f63..ebe2bb17 100644
--- a/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php
+++ b/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php
@@ -113,6 +113,7 @@ final class TwigComponentExtension extends Extension implements ConfigurationInt
                 new Reference('ux.twig_component.component_properties'),
                 new Reference('ux.twig_component.component_stack'),
             ])
+            ->addTag('kernel.reset', ['method' => 'reset'])
         ;
 
         $container->register('ux.twig_component.twig.component_extension', ComponentExtension::class)


Test patch:
diff --git a/src/LiveComponent/tests/Integration/LiveComponentHydratorTest.php b/src/LiveComponent/tests/Integration/LiveComponentHydratorTest.php
index 66ea8cd2..004a6dff 100644
--- a/src/LiveComponent/tests/Integration/LiveComponentHydratorTest.php
+++ b/src/LiveComponent/tests/Integration/LiveComponentHydratorTest.php
@@ -1875,6 +1875,7 @@ class HydrationTest
                 new ComponentMetadata([
                     'key' => '__testing',
                     'mount' => $reflectionClass->hasMethod('mount') ? ['mount'] : [],
+                    'service_id' => '__testing_id',
                 ]),
                 $metadataFactory->createPropMetadatas($reflectionClass),
             ),