Repository: symfony/ux
Pull request #2180 (2024-09-18 13:30:47)

[StimulusBundle] Improve `StimulusAttributes` rendering performances by switching to `html` escaping strategy
| Q             | A
| ------------- | ---
| Bug fix?      | no
| New feature?  | no <!-- please update src/**/CHANGELOG.md files -->
| Issues        | Fix #... <!-- prefix each issue number with "Fix #", no need to create an issue if none exist, explain below instead -->
| License       | MIT

<!--
Replace this notice by a description of your feature/bugfix.
This will help reviewers and should be a good start for the documentation.

Additionally (see https://symfony.com/releases):
 - Always add tests and ensure they pass.
 - For new features, provide some code snippets to help understand usage.
 - Features and deprecations must be submitted against branch main.
 - Changelog entry should follow https://symfony.com/doc/current/contributing/code/conventions.html#writing-a-changelog-entry
 - Never break backward compatibility (see https://symfony.com/bc).
-->

A better alternative to #2178, by changing the escaping strategy for HTML attributes value from `html_attr` to `html`, as indicated by @stof in https://github.com/twigphp/Twig/issues/4322#issuecomment-2358230379.

On my use case (a `Map` with 1000 `Marker` and `InfoWindow`), [I have a performance gain of ~68%](https://blackfire.io/profiles/compare/56f4d7d2-ee56-487f-8a78-420f4037165f/graph):
<img width="1057" alt="image" src="https://github.com/user-attachments/assets/8b7ff48d-8e6e-4dae-b09e-2c323bc2449d">

This PR should also improve performances of our packages using the `StimulusAttributes` DTO, like Chart.js, LiveComponent, ...


---

> [!IMPORTANT]
> The initial PR changed a bit, the default rendering strategy does not change, but instead we introduce a new configuration to use the new (and optimized) rendering strategy, see  https://github.com/symfony/ux/pull/2180#issuecomment-2370574032.

smnandre: Do we still need to call the Runtime then ?  Would this not be sufficient ?

```
 return htmlspecialchars($string, \ENT_QUOTES | \ENT_SUBSTITUTE, $charset);
 ```
 
 
Kocal: > Do we still need to call the Runtime then ? Would this not be sufficient ?
> 
> ```
>  return htmlspecialchars($string, \ENT_QUOTES | \ENT_SUBSTITUTE, $charset);
> ```

If we assume UTF-8 is used everywhere, yes, but `|escape('html')` does more than that :/  
smnandre: This bundle is the most used / downloaded. So we cannot release a version that will break tests (and maybe code) during a minor...

so we need to find a way out here... what about thinking a new object ? less "stimulus" and more "attributes"  ?
smnandre: Sadly, it's difficult, as this class is directly used by other components / bundle.. and it's neither final or internal :| 
Kocal: It's a BC for tests/assertions yes, but I don't really see how and where it can impact end users.

However, we can't stay ad vitam aeternam with `html_attr` escaping for HTML attribute values, as we all see it is not performant at all. 

Since `StimulusAttributes` is not final nor internal, maybe... we can configure the escaping format through a new method? 
kbond: New bundle config? Deprecate not setting it to the new, desired value?
smnandre: > New bundle config ? 

We cannot here, this class may be instanciated directly in many projects, as it is not a service.

> Deprecate not setting it to the new, desired value?

This is a good idea. Probably we can add an argument either in the render / toString methods, or maybe more in the constructor..

.. and deprecate no passing it (stating in 3.x it will use the new mode) 

? 


smnandre: Regarding performances, i'm very _very_ happy you found this bottleneck and have a clean method to improve things.

But as always, we cannot "force it" down, and it's been written everywhere Symfony  UX would follow Symfony BC promise.

To be clear, i'm _**not**_ saying we cannot use these optimisations in the ux components
* ux map is still experimental, and this is a very legit case of accepted change 
* we did not made any promise/engagement on the attributes generated by live component, and it could 100% be considered internal .. this also work for chartsjs, etc (but should we release a typed bundle version just for this ? i'm not entirely sure here..)

I'm concerned about two things here:
* the StimulusAttributes class in itself, that can be used elsewhere / tested / etc ... this would be a major BC break, as simple unit test cases would break
* the StimulusBundle twig functions (`stimulus_xxx`), that could be configured by the stimulus bundle but i'm not sure this is worth it 

kbond: > This is a good idea. Probably we can add an argument either in the render / toString methods, or maybe more in the constructor..

> .. and deprecate no passing it (stating in 3.x it will use the new mode)

Constructor then, yes.
Kocal: I'm reverting to `html_attr` escaping strategy by default, then I will add whatever needed to re-use `html` for escaping html attribute values (without BC breaks)
javiereguiluz: Folks, I'm a bit lost about the mentioned BC break. Can you please share an example of a BC-break caused by changing this? Thanks!
stof: there is no BC break for users of the library. But there is a different escaping applied, which can make their *tests* fail if they assert the output of the rendering of StimulusAttribute.

Note however that by this argument, any bugfix is also forbidden.
Kocal: I've updated the PR and introduced a new config `stimulus.use_optimized_attributes_rendering` (default to `null`, which allows me to trigger a deprecation if not explicitly specified):
<img width="969" alt="image" src="https://github.com/user-attachments/assets/f7297fe3-a259-4103-aae6-f2613d2dd883">
_Note: I'll gladly take any comments for my deprecation messages... 😅_

This configuration is injected to the `StimulusHelper` constructor (the class is final), then injected to the `StimulusAttributes` constructor (the class **is not** final nor internal, so I had to play `func_get_arg(1)` to get `$useOptimizedRendering` value).

I've rollbacked tests added in my first commit as it was considered as breaking changes. Instead, I've configured `stimulus.use_optimized_attributes_rendering`/`$useOptimizedRendering` to `false` in order to get rid of deprecation notices (which make tests fails and CI red). I also adapted actual tests to test the _new optimized_ rendering, this way we can clearly see the difference between the actual and new rendering behaviour.

With all those changes, I took a new Blackfire profile and did [a new comparaison](https://blackfire.io/profiles/compare/f71cdd8c-c741-47ec-a598-2318a4821b3e/graph):
<img width="1031" alt="image" src="https://github.com/user-attachments/assets/43a90046-387a-42fa-a5bd-f22fbcb5acf4">

javiereguiluz: @Kocal impressive performance improvement 🤯 thanks a lot 🙇 

I still think this is not a true BC break and we shouldn't complicate things with the new config option.
Kocal: I agree with all of you, I don't consider it a breaking change either. 
The behavior **won't change** in the browser, users won't be (badly) impacted by this modification. 

Let's revert the 2 last commit then, just in case.
Kocal: The PR is now back at its original state, there is no `stimulus.use_optimized_attributes_rendering` config anymore. 



Patch:
diff --git a/src/StimulusBundle/src/Dto/StimulusAttributes.php b/src/StimulusBundle/src/Dto/StimulusAttributes.php
index 1b477172..5b91589a 100644
--- a/src/StimulusBundle/src/Dto/StimulusAttributes.php
+++ b/src/StimulusBundle/src/Dto/StimulusAttributes.php
@@ -107,54 +107,36 @@ class StimulusAttributes implements \Stringable, \IteratorAggregate
 
     public function __toString(): string
     {
-        $controllers = array_map(function (string $controllerName): string {
-            return $this->escapeAsHtmlAttr($controllerName);
-        }, $this->controllers);
-
-        // done separately so we can escape, but avoid escaping ->
-        $actions = array_map(function (array $actionData): string {
-            $controllerName = $this->escapeAsHtmlAttr($actionData['controllerName']);
-            $actionName = $this->escapeAsHtmlAttr($actionData['actionName']);
-            $eventName = $actionData['eventName'];
-
-            $action = $controllerName.'#'.$actionName;
-            if (null !== $eventName) {
-                $action = $this->escapeAsHtmlAttr($eventName).'->'.$action;
-            }
-
-            return $action;
-        }, $this->actions);
+        $attributes = [];
 
-        $targets = [];
-        foreach ($this->targets as $key => $targetNamesString) {
-            $targetNames = explode(' ', $targetNamesString);
-            $targets[$key] = implode(' ', array_map(function (string $targetName): string {
-                return $this->escapeAsHtmlAttr($targetName);
-            }, $targetNames));
+        if ($this->controllers) {
+            $attributes[] = 'data-controller="'.$this->escape(implode(' ', $this->controllers)).'"';
         }
 
-        $attributes = [];
+        if ($this->actions) {
+            $actions = [];
+            foreach ($this->actions as ['controllerName' => $controllerName, 'actionName' => $actionName, 'eventName' => $eventName]) {
+                $action = $this->escape($controllerName.'#'.$actionName);
+                if (null !== $eventName) {
+                    // done separately so we can escape, but avoid escaping ->
+                    $action = $this->escape($eventName).'->'.$action;
+                }
+
+                $actions[] = $action;
+            }
 
-        if ($controllers) {
-            $attributes[] = \sprintf('data-controller="%s"', implode(' ', $controllers));
+            $attributes[] = 'data-action="'.implode(' ', $actions).'"';
         }
 
-        if ($actions) {
-            $attributes[] = \sprintf('data-action="%s"', implode(' ', $actions));
+        foreach ($this->targets as $k => $v) {
+            $attributes[] = $this->escape($k, 'html_attr').'="'.$this->escape($v).'"';
         }
 
-        if ($targets) {
-            $attributes[] = implode(' ', array_map(function (string $key, string $value): string {
-                return \sprintf('%s="%s"', $key, $value);
-            }, array_keys($targets), $targets));
+        foreach ($this->attributes as $k => $v) {
+            $attributes[] = $this->escape($k, 'html_attr').'="'.$this->escape($v).'"';
         }
 
-        return rtrim(implode(' ', [
-            ...$attributes,
-            ...array_map(function (string $attribute, string $value): string {
-                return $attribute.'="'.$this->escapeAsHtmlAttr($value).'"';
-            }, array_keys($this->attributes), $this->attributes),
-        ]));
+        return implode(' ', $attributes);
     }
 
     public function toArray(): array
@@ -193,7 +175,7 @@ class StimulusAttributes implements \Stringable, \IteratorAggregate
     {
         $escaped = [];
         foreach ($this->toArray() as $key => $value) {
-            $escaped[$key] = $this->escapeAsHtmlAttr($value);
+            $escaped[$key] = $this->escape($value);
         }
 
         return $escaped;
@@ -212,18 +194,18 @@ class StimulusAttributes implements \Stringable, \IteratorAggregate
         return (string) $value;
     }
 
-    private function escapeAsHtmlAttr(mixed $value): string
+    private function escape(mixed $value, string $strategy = 'html'): string
     {
         if (class_exists(EscaperRuntime::class)) {
-            return $this->env->getRuntime(EscaperRuntime::class)->escape($value, 'html_attr');
+            return $this->env->getRuntime(EscaperRuntime::class)->escape($value, $strategy);
         }
 
         if (method_exists(EscaperExtension::class, 'escape')) {
-            return EscaperExtension::escape($this->env, $value, 'html_attr');
+            return EscaperExtension::escape($this->env, $value, $strategy);
         }
 
         // since twig/twig 3.9.0: Using the internal "twig_escape_filter" function is deprecated.
-        return (string) twig_escape_filter($this->env, $value, 'html_attr');
+        return (string) twig_escape_filter($this->env, $value, $strategy);
     }
 
     /**


Test patch:
diff --git a/src/Chartjs/tests/Twig/ChartExtensionTest.php b/src/Chartjs/tests/Twig/ChartExtensionTest.php
index 03472690..0f3ae34e 100644
--- a/src/Chartjs/tests/Twig/ChartExtensionTest.php
+++ b/src/Chartjs/tests/Twig/ChartExtensionTest.php
@@ -56,7 +56,7 @@ class ChartExtensionTest extends TestCase
         );
 
         $this->assertSame(
-            '<canvas data-controller="mycontroller symfony--ux-chartjs--chart" data-symfony--ux-chartjs--chart-view-value="&#x7B;&quot;type&quot;&#x3A;&quot;line&quot;,&quot;data&quot;&#x3A;&#x7B;&quot;labels&quot;&#x3A;&#x5B;&quot;January&quot;,&quot;February&quot;,&quot;March&quot;,&quot;April&quot;,&quot;May&quot;,&quot;June&quot;,&quot;July&quot;&#x5D;,&quot;datasets&quot;&#x3A;&#x5B;&#x7B;&quot;label&quot;&#x3A;&quot;My&#x20;First&#x20;dataset&quot;,&quot;backgroundColor&quot;&#x3A;&quot;rgb&#x28;255,&#x20;99,&#x20;132&#x29;&quot;,&quot;borderColor&quot;&#x3A;&quot;rgb&#x28;255,&#x20;99,&#x20;132&#x29;&quot;,&quot;data&quot;&#x3A;&#x5B;0,10,5,2,20,30,45&#x5D;&#x7D;&#x5D;&#x7D;,&quot;options&quot;&#x3A;&#x7B;&quot;showLines&quot;&#x3A;false&#x7D;&#x7D;" class="myclass"></canvas>',
+            '<canvas data-controller="mycontroller symfony--ux-chartjs--chart" data-symfony--ux-chartjs--chart-view-value="{&quot;type&quot;:&quot;line&quot;,&quot;data&quot;:{&quot;labels&quot;:[&quot;January&quot;,&quot;February&quot;,&quot;March&quot;,&quot;April&quot;,&quot;May&quot;,&quot;June&quot;,&quot;July&quot;],&quot;datasets&quot;:[{&quot;label&quot;:&quot;My First dataset&quot;,&quot;backgroundColor&quot;:&quot;rgb(255, 99, 132)&quot;,&quot;borderColor&quot;:&quot;rgb(255, 99, 132)&quot;,&quot;data&quot;:[0,10,5,2,20,30,45]}]},&quot;options&quot;:{&quot;showLines&quot;:false}}" class="myclass"></canvas>',
             $rendered
         );
     }
diff --git a/src/LiveComponent/tests/Unit/Twig/LiveComponentRuntimeTest.php b/src/LiveComponent/tests/Unit/Twig/LiveComponentRuntimeTest.php
index 30c3ba8c..01f067c0 100644
--- a/src/LiveComponent/tests/Unit/Twig/LiveComponentRuntimeTest.php
+++ b/src/LiveComponent/tests/Unit/Twig/LiveComponentRuntimeTest.php
@@ -28,17 +28,15 @@ final class LiveComponentRuntimeTest extends KernelTestCase
         $this->assertSame('data-action="live#action" data-live-prop1-param="val1" data-live-some-prop-param="val2" data-live-action-param="action-name"', $props);
 
         $props = $runtime->liveAction('action-name', ['prop1' => 'val1', 'prop2' => 'val2'], ['debounce' => 300]);
-        $this->assertSame('data-action="live#action" data-live-prop1-param="val1" data-live-prop2-param="val2" data-live-action-param="debounce(300)|action-name"', html_entity_decode($props));
-        $this->assertSame('data-action="live#action" data-live-prop1-param="val1" data-live-prop2-param="val2" data-live-action-param="debounce&#x28;300&#x29;&#x7C;action-name"', $props);
+        $this->assertSame('data-action="live#action" data-live-prop1-param="val1" data-live-prop2-param="val2" data-live-action-param="debounce(300)|action-name"', $props);
 
         $props = $runtime->liveAction('action-name:prevent', ['pro1' => 'val1', 'prop2' => 'val2'], ['debounce' => 300]);
-        $this->assertSame('data-action="live#action:prevent" data-live-pro1-param="val1" data-live-prop2-param="val2" data-live-action-param="debounce(300)|action-name"', html_entity_decode($props));
-        $this->assertSame('data-action="live#action&#x3A;prevent" data-live-pro1-param="val1" data-live-prop2-param="val2" data-live-action-param="debounce&#x28;300&#x29;&#x7C;action-name"', $props);
+        $this->assertSame('data-action="live#action:prevent" data-live-pro1-param="val1" data-live-prop2-param="val2" data-live-action-param="debounce(300)|action-name"', $props);
 
         $props = $runtime->liveAction('action-name:prevent', [], ['debounce' => 300]);
-        $this->assertSame('data-action="live#action:prevent" data-live-action-param="debounce(300)|action-name"', html_entity_decode($props));
+        $this->assertSame('data-action="live#action:prevent" data-live-action-param="debounce(300)|action-name"', $props);
 
         $props = $runtime->liveAction('action-name', [], [], 'keydown.esc');
-        $this->assertSame('data-action="keydown.esc->live#action" data-live-action-param="action-name"', html_entity_decode($props));
+        $this->assertSame('data-action="keydown.esc->live#action" data-live-action-param="action-name"', $props);
     }
 }
diff --git a/src/Notify/tests/Twig/NotifyRuntimeTest.php b/src/Notify/tests/Twig/NotifyRuntimeTest.php
index 7f21970f..8c3b3786 100644
--- a/src/Notify/tests/Twig/NotifyRuntimeTest.php
+++ b/src/Notify/tests/Twig/NotifyRuntimeTest.php
@@ -39,13 +39,13 @@ class NotifyRuntimeTest extends TestCase
 
     public static function streamNotificationsDataProvider(): iterable
     {
-        $publicUrl = 'http&#x3A;&#x2F;&#x2F;localhost&#x3A;9090&#x2F;.well-known&#x2F;mercure';
+        $publicUrl = 'http://localhost:9090/.well-known/mercure';
 
         yield [
             [['/topic/1', '/topic/2']],
             '<div '.
                 'data-controller="symfony--ux-notify--notify" '.
-                'data-symfony--ux-notify--notify-topics-value="&#x5B;&quot;&#x5C;&#x2F;topic&#x5C;&#x2F;1&quot;,&quot;&#x5C;&#x2F;topic&#x5C;&#x2F;2&quot;&#x5D;" '.
+                'data-symfony--ux-notify--notify-topics-value="[&quot;\/topic\/1&quot;,&quot;\/topic\/2&quot;]" '.
                 'data-symfony--ux-notify--notify-hub-value="'.$publicUrl.'"'.
             '></div>',
         ];
@@ -54,7 +54,7 @@ class NotifyRuntimeTest extends TestCase
             ['/topic/1'],
             '<div '.
                 'data-controller="symfony--ux-notify--notify" '.
-                'data-symfony--ux-notify--notify-topics-value="&#x5B;&quot;&#x5C;&#x2F;topic&#x5C;&#x2F;1&quot;&#x5D;" '.
+                'data-symfony--ux-notify--notify-topics-value="[&quot;\/topic\/1&quot;]" '.
                 'data-symfony--ux-notify--notify-hub-value="'.$publicUrl.'"'.
             '></div>',
         ];
@@ -63,7 +63,7 @@ class NotifyRuntimeTest extends TestCase
             [],
             '<div '.
                 'data-controller="symfony--ux-notify--notify" '.
-                'data-symfony--ux-notify--notify-topics-value="&#x5B;&quot;https&#x3A;&#x5C;&#x2F;&#x5C;&#x2F;symfony.com&#x5C;&#x2F;notifier&quot;&#x5D;" '.
+                'data-symfony--ux-notify--notify-topics-value="[&quot;https:\/\/symfony.com\/notifier&quot;]" '.
                 'data-symfony--ux-notify--notify-hub-value="'.$publicUrl.'"'.
             '></div>',
         ];
diff --git a/src/React/tests/Twig/ReactComponentExtensionTest.php b/src/React/tests/Twig/ReactComponentExtensionTest.php
index aae2d6b6..9fbf8c8b 100644
--- a/src/React/tests/Twig/ReactComponentExtensionTest.php
+++ b/src/React/tests/Twig/ReactComponentExtensionTest.php
@@ -36,7 +36,7 @@ class ReactComponentExtensionTest extends TestCase
         );
 
         $this->assertSame(
-            'data-controller="symfony--ux-react--react" data-symfony--ux-react--react-component-value="SubDir&#x2F;MyComponent" data-symfony--ux-react--react-props-value="&#x7B;&quot;fullName&quot;&#x3A;&quot;Titouan&#x20;Galopin&quot;&#x7D;"',
+            'data-controller="symfony--ux-react--react" data-symfony--ux-react--react-component-value="SubDir/MyComponent" data-symfony--ux-react--react-props-value="{&quot;fullName&quot;:&quot;Titouan Galopin&quot;}"',
             $rendered
         );
     }
@@ -52,7 +52,7 @@ class ReactComponentExtensionTest extends TestCase
         $rendered = $extension->renderReactComponent('SubDir/MyComponent');
 
         $this->assertSame(
-            'data-controller="symfony--ux-react--react" data-symfony--ux-react--react-component-value="SubDir&#x2F;MyComponent"',
+            'data-controller="symfony--ux-react--react" data-symfony--ux-react--react-component-value="SubDir/MyComponent"',
             $rendered
         );
     }
diff --git a/src/StimulusBundle/tests/Dto/StimulusAttributesTest.php b/src/StimulusBundle/tests/Dto/StimulusAttributesTest.php
index 63393d99..17527a34 100644
--- a/src/StimulusBundle/tests/Dto/StimulusAttributesTest.php
+++ b/src/StimulusBundle/tests/Dto/StimulusAttributesTest.php
@@ -145,7 +145,7 @@ final class StimulusAttributesTest extends TestCase
     public function testAddAttribute()
     {
         $this->stimulusAttributes->addAttribute('foo', 'bar baz');
-        $this->assertSame('foo="bar&#x20;baz"', (string) $this->stimulusAttributes);
+        $this->assertSame('foo="bar baz"', (string) $this->stimulusAttributes);
         $this->assertSame(['foo' => 'bar baz'], $this->stimulusAttributes->toArray());
     }
 }
diff --git a/src/StimulusBundle/tests/Twig/StimulusTwigExtensionTest.php b/src/StimulusBundle/tests/Twig/StimulusTwigExtensionTest.php
index 41793ed5..0e8cdbf8 100644
--- a/src/StimulusBundle/tests/Twig/StimulusTwigExtensionTest.php
+++ b/src/StimulusBundle/tests/Twig/StimulusTwigExtensionTest.php
@@ -135,8 +135,8 @@ final class StimulusTwigExtensionTest extends TestCase
         $extension = new StimulusTwigExtension(new StimulusHelper($this->twig));
         $dto = $extension->renderStimulusController('my-controller', ['myValue' => 'scalar-value']);
         $this->assertSame(
-            'data-controller="my-controller another-controller" data-my-controller-my-value-value="scalar-value" data-another-controller-another-value-value="scalar-value&#x20;2"',
-            (string) $extension->appendStimulusController($dto, 'another-controller', ['another-value' => 'scalar-value 2']),
+            'data-controller="my-controller another-controller" data-my-controller-my-value-value="scalar-value" data-another-controller-another-value-value="scalar-value 2" data-another-controller-json-value-value="{&quot;key&quot;:&quot;Value with quotes &#039; and \&quot;.&quot;}"',
+            (string) $extension->appendStimulusController($dto, 'another-controller', ['another-value' => 'scalar-value 2', 'jsonValue' => json_encode(['key' => 'Value with quotes \' and ".'])]),
         );
     }
 
diff --git a/src/Svelte/tests/Twig/SvelteComponentExtensionTest.php b/src/Svelte/tests/Twig/SvelteComponentExtensionTest.php
index 8231c028..7a9a6d03 100644
--- a/src/Svelte/tests/Twig/SvelteComponentExtensionTest.php
+++ b/src/Svelte/tests/Twig/SvelteComponentExtensionTest.php
@@ -37,7 +37,7 @@ class SvelteComponentExtensionTest extends TestCase
         );
 
         $this->assertSame(
-            'data-controller="symfony--ux-svelte--svelte" data-symfony--ux-svelte--svelte-component-value="SubDir&#x2F;MyComponent" data-symfony--ux-svelte--svelte-props-value="&#x7B;&quot;fullName&quot;&#x3A;&quot;Titouan&#x20;Galopin&quot;&#x7D;"',
+            'data-controller="symfony--ux-svelte--svelte" data-symfony--ux-svelte--svelte-component-value="SubDir/MyComponent" data-symfony--ux-svelte--svelte-props-value="{&quot;fullName&quot;:&quot;Titouan Galopin&quot;}"',
             $rendered
         );
     }
@@ -53,7 +53,7 @@ class SvelteComponentExtensionTest extends TestCase
         $rendered = $extension->renderSvelteComponent('SubDir/MyComponent');
 
         $this->assertSame(
-            'data-controller="symfony--ux-svelte--svelte" data-symfony--ux-svelte--svelte-component-value="SubDir&#x2F;MyComponent"',
+            'data-controller="symfony--ux-svelte--svelte" data-symfony--ux-svelte--svelte-component-value="SubDir/MyComponent"',
             $rendered
         );
     }
@@ -73,7 +73,7 @@ class SvelteComponentExtensionTest extends TestCase
         );
 
         $this->assertSame(
-            'data-controller="symfony--ux-svelte--svelte" data-symfony--ux-svelte--svelte-component-value="SubDir&#x2F;MyComponent" data-symfony--ux-svelte--svelte-props-value="&#x7B;&quot;fullName&quot;&#x3A;&quot;Titouan&#x20;Galopin&quot;&#x7D;" data-symfony--ux-svelte--svelte-intro-value="true"',
+            'data-controller="symfony--ux-svelte--svelte" data-symfony--ux-svelte--svelte-component-value="SubDir/MyComponent" data-symfony--ux-svelte--svelte-props-value="{&quot;fullName&quot;:&quot;Titouan Galopin&quot;}" data-symfony--ux-svelte--svelte-intro-value="true"',
             $rendered
         );
     }
diff --git a/src/TwigComponent/tests/Unit/ComponentAttributesTest.php b/src/TwigComponent/tests/Unit/ComponentAttributesTest.php
index 5e22ac73..8ed7aa4f 100644
--- a/src/TwigComponent/tests/Unit/ComponentAttributesTest.php
+++ b/src/TwigComponent/tests/Unit/ComponentAttributesTest.php
@@ -147,7 +147,7 @@ final class ComponentAttributesTest extends TestCase
             'data-controller' => 'foo live',
             'data-live-data-value' => '{}',
             'data-foo-name-value' => 'ryan',
-            'data-foo-some-array-value' => '&#x5B;&quot;a&quot;,&quot;b&quot;&#x5D;',
+            'data-foo-some-array-value' => '[&quot;a&quot;,&quot;b&quot;]',
         ], $attributes->all());
     }
 
diff --git a/src/Vue/tests/Twig/VueComponentExtensionTest.php b/src/Vue/tests/Twig/VueComponentExtensionTest.php
index 3c618cca..6a539395 100644
--- a/src/Vue/tests/Twig/VueComponentExtensionTest.php
+++ b/src/Vue/tests/Twig/VueComponentExtensionTest.php
@@ -37,7 +37,7 @@ class VueComponentExtensionTest extends TestCase
         );
 
         $this->assertSame(
-            'data-controller="symfony--ux-vue--vue" data-symfony--ux-vue--vue-component-value="SubDir&#x2F;MyComponent" data-symfony--ux-vue--vue-props-value="&#x7B;&quot;fullName&quot;&#x3A;&quot;Titouan&#x20;Galopin&quot;&#x7D;"',
+            'data-controller="symfony--ux-vue--vue" data-symfony--ux-vue--vue-component-value="SubDir/MyComponent" data-symfony--ux-vue--vue-props-value="{&quot;fullName&quot;:&quot;Titouan Galopin&quot;}"',
             $rendered
         );
     }
@@ -53,7 +53,7 @@ class VueComponentExtensionTest extends TestCase
         $rendered = $extension->renderVueComponent('SubDir/MyComponent');
 
         $this->assertSame(
-            'data-controller="symfony--ux-vue--vue" data-symfony--ux-vue--vue-component-value="SubDir&#x2F;MyComponent"',
+            'data-controller="symfony--ux-vue--vue" data-symfony--ux-vue--vue-component-value="SubDir/MyComponent"',
             $rendered
         );
     }