Repository: symfony/ux
Pull request #1461 (2024-02-05 19:56:02)

feat(live): improve `TestLiveComponent::actingAs()`
| Q             | A
| ------------- | ---
| Bug fix?      | no
| New feature?  | no
| Issues        | n/a
| License       | MIT

_(depends on #1460)_

Currently, if you have a component that requires authentication for all actions (ie `#[IsGranted]` on the component class), in your tests, as soon as you call `$this->createLiveComponent(...)`, you'll get an access denied exception. You never get a chance to call `->actingAs()`.

This PR makes the initial request for the live component _lazy_. Only when the initial request is required (for other methods), is it made. This gives you the chance to call `->actingAs()` before the initial render.


daFish: @kbond Using your changes works in my case. I no longer get the CSRF-exception.
kbond: Rebased and fixed the failing test I introduced in #1460.

Patch:
diff --git a/src/Autocomplete/CHANGELOG.md b/src/Autocomplete/CHANGELOG.md
index f4a3c44a..5b78d7ec 100644
--- a/src/Autocomplete/CHANGELOG.md
+++ b/src/Autocomplete/CHANGELOG.md
@@ -1,11 +1,8 @@
 # CHANGELOG
 
-## 2.15.0
+## Unreleased
 
--   Add doctrine/orm 3 support #1468
--   Allow passing extra options to the autocomplete fields #1322
--   Fix 2 bugs where TomSelect would reset when not necessary #1502
--   Add one missing German translation #1521
+-   Add doctrine/orm 3 support.
 
 ## 2.14.0
 
diff --git a/src/Autocomplete/assets/dist/controller.js b/src/Autocomplete/assets/dist/controller.js
index 8e515104..49587145 100644
--- a/src/Autocomplete/assets/dist/controller.js
+++ b/src/Autocomplete/assets/dist/controller.js
@@ -116,7 +116,6 @@ class default_1 extends Controller {
     }
     resetTomSelect() {
         if (this.tomSelect) {
-            this.dispatchEvent('before-reset', { tomSelect: this.tomSelect });
             this.stopMutationObserver();
             const currentHtml = this.element.innerHTML;
             const currentValue = this.tomSelect.getValue();
@@ -144,7 +143,6 @@ class default_1 extends Controller {
                 subtree: true,
                 attributes: true,
                 characterData: true,
-                attributeOldValue: true,
             });
             this.isObserving = true;
         }
@@ -166,11 +164,7 @@ class default_1 extends Controller {
                         break;
                     }
                     if (mutation.target === this.element && mutation.attributeName === 'multiple') {
-                        const isNowMultiple = this.element.hasAttribute('multiple');
-                        const wasMultiple = mutation.oldValue === 'multiple';
-                        if (isNowMultiple !== wasMultiple) {
-                            requireReset = true;
-                        }
+                        requireReset = true;
                         break;
                     }
                     break;
@@ -197,22 +191,12 @@ class default_1 extends Controller {
         });
     }
     areOptionsEquivalent(newOptions) {
-        const filteredOriginalOptions = this.originalOptions.filter((option) => option.value !== '');
-        const filteredNewOptions = newOptions.filter((option) => option.value !== '');
-        const originalPlaceholderOption = this.originalOptions.find((option) => option.value === '');
-        const newPlaceholderOption = newOptions.find((option) => option.value === '');
-        console.log(originalPlaceholderOption, newPlaceholderOption);
-        if (originalPlaceholderOption &&
-            newPlaceholderOption &&
-            originalPlaceholderOption.text !== newPlaceholderOption.text) {
-            return false;
-        }
-        if (filteredOriginalOptions.length !== filteredNewOptions.length) {
+        if (this.originalOptions.length !== newOptions.length) {
             return false;
         }
         const normalizeOption = (option) => `${option.value}-${option.text}-${option.group}`;
-        const originalOptionsSet = new Set(filteredOriginalOptions.map(normalizeOption));
-        const newOptionsSet = new Set(filteredNewOptions.map(normalizeOption));
+        const originalOptionsSet = new Set(this.originalOptions.map(normalizeOption));
+        const newOptionsSet = new Set(newOptions.map(normalizeOption));
         return (originalOptionsSet.size === newOptionsSet.size &&
             [...originalOptionsSet].every((option) => newOptionsSet.has(option)));
     }
diff --git a/src/Autocomplete/assets/src/controller.ts b/src/Autocomplete/assets/src/controller.ts
index 412095e3..580552a2 100644
--- a/src/Autocomplete/assets/src/controller.ts
+++ b/src/Autocomplete/assets/src/controller.ts
@@ -325,7 +325,6 @@ export default class extends Controller {
 
     private resetTomSelect(): void {
         if (this.tomSelect) {
-            this.dispatchEvent('before-reset', { tomSelect: this.tomSelect });
             this.stopMutationObserver();
 
             // Grab the current HTML then restore it after destroying TomSelect
@@ -359,7 +358,6 @@ export default class extends Controller {
                 subtree: true,
                 attributes: true,
                 characterData: true,
-                attributeOldValue: true,
             });
             this.isObserving = true;
         }
@@ -386,11 +384,7 @@ export default class extends Controller {
                     }
 
                     if (mutation.target === this.element && mutation.attributeName === 'multiple') {
-                        const isNowMultiple = this.element.hasAttribute('multiple');
-                        const wasMultiple = mutation.oldValue === 'multiple';
-                        if (isNowMultiple !== wasMultiple) {
-                            requireReset = true;
-                        }
+                        requireReset = true;
 
                         break;
                     }
@@ -425,29 +419,14 @@ export default class extends Controller {
     }
 
     private areOptionsEquivalent(newOptions: Array<{ value: string; text: string; group: string | null }>): boolean {
-        // remove the empty option, which is added by TomSelect so may be missing from new options
-        const filteredOriginalOptions = this.originalOptions.filter((option) => option.value !== '');
-        const filteredNewOptions = newOptions.filter((option) => option.value !== '');
-
-        const originalPlaceholderOption = this.originalOptions.find((option) => option.value === '');
-        const newPlaceholderOption = newOptions.find((option) => option.value === '');
-        console.log(originalPlaceholderOption, newPlaceholderOption);
-        if (
-            originalPlaceholderOption &&
-            newPlaceholderOption &&
-            originalPlaceholderOption.text !== newPlaceholderOption.text
-        ) {
-            return false;
-        }
-
-        if (filteredOriginalOptions.length !== filteredNewOptions.length) {
+        if (this.originalOptions.length !== newOptions.length) {
             return false;
         }
 
         const normalizeOption = (option: { value: string; text: string; group: string | null }) =>
             `${option.value}-${option.text}-${option.group}`;
-        const originalOptionsSet = new Set(filteredOriginalOptions.map(normalizeOption));
-        const newOptionsSet = new Set(filteredNewOptions.map(normalizeOption));
+        const originalOptionsSet = new Set(this.originalOptions.map(normalizeOption));
+        const newOptionsSet = new Set(newOptions.map(normalizeOption));
 
         return (
             originalOptionsSet.size === newOptionsSet.size &&
diff --git a/src/Autocomplete/translations/AutocompleteBundle.de.php b/src/Autocomplete/translations/AutocompleteBundle.de.php
index d26583b4..059119f8 100644
--- a/src/Autocomplete/translations/AutocompleteBundle.de.php
+++ b/src/Autocomplete/translations/AutocompleteBundle.de.php
@@ -10,7 +10,7 @@
  */
 
 return [
-    'Loading more results...' => 'Lade weitere Ergebnisse...',
+    // 'Loading more results...' => 'Loading more results...',
     'No results found' => 'Keine Übereinstimmungen gefunden',
     'No more results' => 'Keine weiteren Ergebnisse',
 ];
diff --git a/src/Chartjs/CHANGELOG.md b/src/Chartjs/CHANGELOG.md
index 1d0c4865..0e83ec18 100644
--- a/src/Chartjs/CHANGELOG.md
+++ b/src/Chartjs/CHANGELOG.md
@@ -1,9 +1,5 @@
 # CHANGELOG
 
-## 2.15.0
-
--   Remove restriction that prevented Chart.js 3.9 #1518
-
 ## 2.14.0
 
 -   Add support for Chart.js version 4
diff --git a/src/Chartjs/assets/package.json b/src/Chartjs/assets/package.json
index b121bce5..9cd0e5dc 100644
--- a/src/Chartjs/assets/package.json
+++ b/src/Chartjs/assets/package.json
@@ -21,7 +21,7 @@
     },
     "peerDependencies": {
         "@hotwired/stimulus": "^3.0.0",
-        "chart.js": "^3.4.1 || ^4.0"
+        "chart.js": "^3.4.1 <3.9 || ^4.0"
     },
     "devDependencies": {
         "@hotwired/stimulus": "^3.0.0",
diff --git a/src/LiveComponent/CHANGELOG.md b/src/LiveComponent/CHANGELOG.md
index 6a55dae2..8903ea2e 100644
--- a/src/LiveComponent/CHANGELOG.md
+++ b/src/LiveComponent/CHANGELOG.md
@@ -2,11 +2,7 @@
 
 ## 2.15.0
 
--   [BC BREAK] The `data-live-id` attribute was changed to `id` #1484
--   Fixed child handling bug during re-rendering introduced with the
-    new morphing library in 2.14.0 #1484
--   Fix bug where the active input would maintain its value, but lose its cursor position #1501
--   Restrict Twig 3.9 for now #1486
+-   [BC BREAK] The `data-live-id` attribute was changed to `id`.
 
 ## 2.14.1
 
diff --git a/src/LiveComponent/assets/dist/live_controller.js b/src/LiveComponent/assets/dist/live_controller.js
index 7e5e4b78..2968fd82 100644
--- a/src/LiveComponent/assets/dist/live_controller.js
+++ b/src/LiveComponent/assets/dist/live_controller.js
@@ -617,7 +617,7 @@ var Idiomorph = (function () {
          * @returns {boolean}
          */
         function ignoreValueOfActiveElement(possibleActiveElement, ctx) {
-            return ctx.ignoreActiveValue && possibleActiveElement === document.activeElement && possibleActiveElement !== document.body;
+            return ctx.ignoreActiveValue && possibleActiveElement === document.activeElement;
         }
 
         /**
@@ -1378,7 +1378,6 @@ function executeMorphdom(rootFromElement, rootToElement, modifiedFieldElements,
         syncAttributes(newElement, oldElement);
     });
     Idiomorph.morph(rootFromElement, rootToElement, {
-        ignoreActiveValue: true,
         callbacks: {
             beforeNodeMorphed: (fromEl, toEl) => {
                 if (!(fromEl instanceof Element) || !(toEl instanceof Element)) {
diff --git a/src/LiveComponent/assets/package.json b/src/LiveComponent/assets/package.json
index c93a7868..a0d5dba4 100644
--- a/src/LiveComponent/assets/package.json
+++ b/src/LiveComponent/assets/package.json
@@ -27,7 +27,7 @@
         }
     },
     "dependencies": {
-        "idiomorph": "https://github.com/bigskysoftware/idiomorph.git"
+        "idiomorph": "^0.3.0"
     },
     "peerDependencies": {
         "@hotwired/stimulus": "^3.0.0"
diff --git a/src/LiveComponent/assets/src/morphdom.ts b/src/LiveComponent/assets/src/morphdom.ts
index 92a31080..7dba0668 100644
--- a/src/LiveComponent/assets/src/morphdom.ts
+++ b/src/LiveComponent/assets/src/morphdom.ts
@@ -53,12 +53,6 @@ export function executeMorphdom(
     });
 
     Idiomorph.morph(rootFromElement, rootToElement, {
-        // We handle updating the value of fields that have been changed
-        // since the HTML was requested. However, the active element is
-        // a special case: replacing the value isn't enough. We need to
-        // prevent the value from being changed in the first place so the
-        // user's cursor position is maintained.
-        ignoreActiveValue: true,
         callbacks: {
             beforeNodeMorphed: (fromEl: Element, toEl: Element) => {
                 // Idiomorph loop also over Text node
diff --git a/src/LiveComponent/doc/index.rst b/src/LiveComponent/doc/index.rst
index 4674dabe..4a4e3b29 100644
--- a/src/LiveComponent/doc/index.rst
+++ b/src/LiveComponent/doc/index.rst
@@ -3448,7 +3448,7 @@ bound to Symfony's BC policy for the moment.
 
 .. _`TwigComponent`: https://symfony.com/bundles/ux-twig-component/current/index.html
 .. _TwigComponent documentation: https://symfony.com/bundles/ux-twig-component/current/index.html
-.. _`Livewire`: https://livewire.laravel.com
+.. _`Livewire`: https://laravel-livewire.com
 .. _`Phoenix LiveView`: https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html
 .. _`Twig Component`: https://symfony.com/bundles/ux-twig-component/current/index.html
 .. _`Twig Component mount documentation`: https://symfony.com/bundles/ux-twig-component/current/index.html#the-mount-method
diff --git a/src/LiveComponent/phpunit.xml.dist b/src/LiveComponent/phpunit.xml.dist
index 8798e3cc..989e6115 100644
--- a/src/LiveComponent/phpunit.xml.dist
+++ b/src/LiveComponent/phpunit.xml.dist
@@ -1,4 +1,5 @@
 <?xml version="1.0" encoding="UTF-8"?>
+
 <!-- https://phpunit.de/manual/current/en/appendixes.configuration.html -->
 <phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
          xsi:noNamespaceSchemaLocation="vendor/bin/.phpunit/phpunit.xsd"
@@ -7,16 +8,10 @@
          failOnRisky="true"
          failOnWarning="true"
 >
-    <coverage processUncoveredFiles="true">
-        <include>
-            <directory suffix=".php">./src</directory>
-        </include>
-    </coverage>
-
     <php>
-        <ini name="error_reporting" value="-1"/>
-        <server name="KERNEL_CLASS" value="Symfony\UX\LiveComponent\Tests\Fixtures\Kernel"/>
-        <server name="DATABASE_URL" value="sqlite:///%kernel.project_dir%/var/data.db"/>
+        <ini name="error_reporting" value="-1" />
+        <server name="KERNEL_CLASS" value="Symfony\UX\LiveComponent\Tests\Fixtures\Kernel" />
+        <server name="DATABASE_URL" value="sqlite:///%kernel.project_dir%/var/data.db" />
         <env name="SHELL_VERBOSITY" value="-1"/>
         <server name="SYMFONY_DEPRECATIONS_HELPER" value="max[self]=0&amp;max[direct]=0"/>
     </php>
@@ -27,6 +22,12 @@
         </testsuite>
     </testsuites>
 
+    <filter>
+        <whitelist processUncoveredFilesFromWhitelist="true">
+            <directory suffix=".php">./src</directory>
+        </whitelist>
+    </filter>
+
     <listeners>
         <listener class="Symfony\Bridge\PhpUnit\SymfonyTestsListener"/>
     </listeners>
diff --git a/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php
index 8683214a..97c48d75 100644
--- a/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php
+++ b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php
@@ -256,11 +256,7 @@ final class LiveComponentExtension extends Extension implements PrependExtension
             ->setArguments(['%kernel.cache_dir%/'.self::TEMPLATES_MAP_FILENAME]);
 
         $container->register('ux.live_component.twig.cache_warmer', TemplateCacheWarmer::class)
-            ->setArguments([
-                new Reference('twig.template_iterator'),
-                self::TEMPLATES_MAP_FILENAME,
-                '%kernel.secret%',
-            ])
+            ->setArguments([new Reference('twig.template_iterator'), self::TEMPLATES_MAP_FILENAME])
             ->addTag('kernel.cache_warmer');
     }
 
diff --git a/src/LiveComponent/src/Twig/TemplateCacheWarmer.php b/src/LiveComponent/src/Twig/TemplateCacheWarmer.php
index 3c9d18ac..18b33be2 100644
--- a/src/LiveComponent/src/Twig/TemplateCacheWarmer.php
+++ b/src/LiveComponent/src/Twig/TemplateCacheWarmer.php
@@ -22,18 +22,15 @@ use Symfony\Component\HttpKernel\CacheWarmer\CacheWarmerInterface;
  */
 final class TemplateCacheWarmer implements CacheWarmerInterface
 {
-    public function __construct(
-        private readonly \IteratorAggregate $templateIterator,
-        private readonly string $cacheFilename,
-        private readonly string $secret,
-    ) {
+    public function __construct(private \IteratorAggregate $templateIterator, private readonly string $cacheFilename)
+    {
     }
 
     public function warmUp(string $cacheDir, ?string $buildDir = null): array
     {
         $map = [];
         foreach ($this->templateIterator as $item) {
-            $map[hash('xxh128', $item.$this->secret)] = $item;
+            $map[bin2hex(random_bytes(16))] = $item;
         }
 
         (new PhpArrayAdapter($cacheDir.'/'.$this->cacheFilename, new NullAdapter()))->warmUp(['map' => $map]);
diff --git a/src/LiveComponent/src/Twig/TemplateMap.php b/src/LiveComponent/src/Twig/TemplateMap.php
index 03dd2ffd..75b8f289 100644
--- a/src/LiveComponent/src/Twig/TemplateMap.php
+++ b/src/LiveComponent/src/Twig/TemplateMap.php
@@ -28,7 +28,7 @@ final class TemplateMap
         $this->map = (new PhpArrayAdapter($cacheFile, new NullAdapter()))->getItem('map')->get();
     }
 
-    public function resolve(string $obscuredName): string
+    public function resolve(string $obscuredName)
     {
         return $this->map[$obscuredName] ?? throw new \RuntimeException(sprintf('Cannot find a template matching "%s". Cache may be corrupt.', $obscuredName));
     }
diff --git a/src/Turbo/CHANGELOG.md b/src/Turbo/CHANGELOG.md
index 4ece2756..73e1fb0c 100644
--- a/src/Turbo/CHANGELOG.md
+++ b/src/Turbo/CHANGELOG.md
@@ -1,10 +1,5 @@
 # CHANGELOG
 
-## 2.15.0
-
--   Add Turbo 8 support #1476
--   Fix missing `use` statement used during broadcast #1475
-
 ## 2.14.2
 
 -   Fix using old `ClassUtils` class that's not used in newer versions of Doctrine
diff --git a/src/TwigComponent/CHANGELOG.md b/src/TwigComponent/CHANGELOG.md
index f3cc7362..df794181 100644
--- a/src/TwigComponent/CHANGELOG.md
+++ b/src/TwigComponent/CHANGELOG.md
@@ -1,16 +1,11 @@
 # CHANGELOG
 
-## 2.15.0
-
--   Add the ability to render specific attributes from the `attributes` variable #1442
--   Restrict Twig 3.9 for now #1486
--   Build reproducible TemplateMap to fix possible post-deploy breakage #1497
-
 ## 2.14.0
 
 -   Make `ComponentAttributes` traversable/countable
 -   Fixed lexing some `{# twig comments #}` with HTML Twig syntax
 -   Fix various usages of deprecated Twig code
+-   Add attribute rendering system
 
 ## 2.13.0
 
diff --git a/src/TwigComponent/phpunit.xml.dist b/src/TwigComponent/phpunit.xml.dist
index cd1dda80..59ac0154 100644
--- a/src/TwigComponent/phpunit.xml.dist
+++ b/src/TwigComponent/phpunit.xml.dist
@@ -1,4 +1,5 @@
 <?xml version="1.0" encoding="UTF-8"?>
+
 <!-- https://phpunit.de/manual/current/en/appendixes.configuration.html -->
 <phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
          xsi:noNamespaceSchemaLocation="vendor/bin/.phpunit/phpunit.xsd"
@@ -7,15 +8,9 @@
          failOnRisky="true"
          failOnWarning="true"
 >
-    <coverage processUncoveredFiles="true">
-        <include>
-            <directory suffix=".php">./src</directory>
-        </include>
-    </coverage>
-
     <php>
-        <ini name="error_reporting" value="-1"/>
-        <server name="KERNEL_CLASS" value="Symfony\UX\TwigComponent\Tests\Fixtures\Kernel"/>
+        <ini name="error_reporting" value="-1" />
+        <server name="KERNEL_CLASS" value="Symfony\UX\TwigComponent\Tests\Fixtures\Kernel" />
         <server name="SYMFONY_DEPRECATIONS_HELPER" value="max[self]=0&amp;max[direct]=0"/>
     </php>
 
@@ -25,6 +20,12 @@
         </testsuite>
     </testsuites>
 
+    <filter>
+        <whitelist processUncoveredFilesFromWhitelist="true">
+            <directory suffix=".php">./src</directory>
+        </whitelist>
+    </filter>
+
     <listeners>
         <listener class="Symfony\Bridge\PhpUnit\SymfonyTestsListener"/>
     </listeners>
diff --git a/ux.symfony.com/assets/styles/vendor/_tom-select.scss b/ux.symfony.com/assets/styles/vendor/_tom-select.scss
index 04c110db..2d6d59bc 100644
--- a/ux.symfony.com/assets/styles/vendor/_tom-select.scss
+++ b/ux.symfony.com/assets/styles/vendor/_tom-select.scss
@@ -1,5 +1,5 @@
 .ts-dropdown, .ts-control, .ts-control input {
-  color: var(--bs-body-color) !important;
+  color: var(--bs-body-color);
 }
 
 .ts-dropdown, .ts-dropdown.form-control, .ts-dropdown.form-select {
diff --git a/ux.symfony.com/src/Model/RecipeFileTree.php b/ux.symfony.com/src/Model/RecipeFileTree.php
index 7944b449..4a5df23e 100644
--- a/ux.symfony.com/src/Model/RecipeFileTree.php
+++ b/ux.symfony.com/src/Model/RecipeFileTree.php
@@ -23,7 +23,7 @@ class RecipeFileTree
             ->addFile('assets/app.js', 'Your main JavaScript file. It\'s job is to import and load all other files.')
             ->addFile('assets/controllers.json', 'Configures 3rd-party Stimulus controllers. This file is automatically updated when you install a UX package.')
             ->addDirectory('assets/controllers', 'The home of your custom Stimulus controllers!')
-            ->addFile('assets/controllers/hello_controller.js', 'An example controller. Add it to any element with <code class="text-nowrap">data-controller="hello"</code>')
+            ->addFile('assets/controllers/hello_controller.js', 'An example controller. Add it to any element with <code class="text-nowrap">{{ stimulus_controller(\'hello\') }}</code>')
             ->addDirectory('assets/styles')
             ->addFile('assets/styles/app.css', 'Your main CSS file')
             ->addFile('package.json', 'Holds your node dependencies, most importantly Stimulus & Webpack Encore.')
diff --git a/ux.symfony.com/src/Util/SourceCleaner.php b/ux.symfony.com/src/Util/SourceCleaner.php
index b44f95b5..86d98899 100644
--- a/ux.symfony.com/src/Util/SourceCleaner.php
+++ b/ux.symfony.com/src/Util/SourceCleaner.php
@@ -151,13 +151,7 @@ EOF;
     public static function removeExcessHtml(string $content): string
     {
         // remove all HTML attributes and values + whitespace around them
-        $content = preg_replace_callback('/\s+[a-z0-9-]+="[^"]*"/', function ($matches) {
-            if (str_starts_with(trim($matches[0]), 'data-')) {
-                return $matches[0];
-            }
-
-            return '';
-        }, $content);
+        $content = preg_replace('/\s+[a-z0-9-]+="[^"]*"/', '', $content);
 
         // Find all the <div> elements without attributes
         preg_match_all('/<div>\s*(.*?)\s*<\/div>/s', $content, $matches);
diff --git a/ux.symfony.com/templates/components/BootstrapModal.html.twig b/ux.symfony.com/templates/components/BootstrapModal.html.twig
index 1158b002..6e4d44aa 100644
--- a/ux.symfony.com/templates/components/BootstrapModal.html.twig
+++ b/ux.symfony.com/templates/components/BootstrapModal.html.twig
@@ -3,9 +3,7 @@
     tabindex: '-1',
     'aria-hidden': 'true',
     id: id ? id : false,
-}) }}
-    data-controller="bootstrap-modal"
->
+}).defaults(stimulus_controller('bootstrap-modal')) }}>
     <div class="modal-dialog">
         <div class="modal-content">
             {% block modal_full_content %}
diff --git a/ux.symfony.com/templates/components/CodeBlock.html.twig b/ux.symfony.com/templates/components/CodeBlock.html.twig
index c95941a4..61e7c85a 100644
--- a/ux.symfony.com/templates/components/CodeBlock.html.twig
+++ b/ux.symfony.com/templates/components/CodeBlock.html.twig
@@ -11,19 +11,19 @@
         </div>
     {% endif %}
 
-    <div class="Terminal_body" data-controller="code-expander">
+    <div class="Terminal_body" {{ stimulus_controller('code-expander') }}>
         {% if not showFilename %}
             <div class="Terminal_actions">
                 <twig:CodeBlockButtons source="{{ this.rawSource }}" link="{{ this.githubLink }}"/>
             </div>
         {% endif %}
         <div class="Terminal_content" style="height: {{ height }};"
-             data-code-expander-target="codeContent"
-             data-controller="code-highlighter"
+            {{ stimulus_target('code-expander', 'codeContent') }}
+            {{ stimulus_controller('code-highlighter') }}
         >
             {% for piece in this.prepareSource %}
                 {% if piece.highlight ?? true %}
-                    <pre><code class="language-{{ this.language }}" data-code-highlighter-target="codeBlock">
+                    <pre><code class="language-{{ this.language }}" {{ stimulus_target('code-highlighter', 'codeBlock') }}>
                         {{- piece.content -}}
                     </code></pre>
                 {% else %}
@@ -36,13 +36,13 @@
             <button
                 type="button"
                 class="btn btn-expand-code pt-2 text-center"
-                data-code-expander-target="expandCodeButton"
-                data-action="code-expander#expandCode"
+                {{ stimulus_target('code-expander', 'expandCodeButton') }}
+                {{ stimulus_action('code-expander', 'expandCode') }}
                 style="display: none;"
             >
-                <twig:Icon name="arrow-down"/>
+                <twig:Icon name="arrow-down" />
                 Expand code
-                <twig:Icon name="arrow-down"/>
+                <twig:Icon name="arrow-down" />
             </button>
         {% endblock %}
     </div>
diff --git a/ux.symfony.com/templates/components/CodeBlockButtons.html.twig b/ux.symfony.com/templates/components/CodeBlockButtons.html.twig
index 0a83aea4..ba185371 100644
--- a/ux.symfony.com/templates/components/CodeBlockButtons.html.twig
+++ b/ux.symfony.com/templates/components/CodeBlockButtons.html.twig
@@ -1,8 +1,8 @@
 {% props source, link %}
 
-<div
-    data-controller="ux-clipboard tooltips"
-    data-ux-clipboard-source-value="{{ source }}"
+<div {{ stimulus_controller('ux-clipboard', {
+    source: source,
+})|stimulus_controller('tooltips') }}
     data-ux-clipboard-success-content="Copied!"
     class="code-buttons"
 >
@@ -19,8 +19,8 @@
     <button
         type="button"
         class="btn btn-copy btn-sm"
-        data-action="ux-clipboard#copy"
+        {{ stimulus_action('ux-clipboard', 'copy') }}
     ><twig:Icon name="copy"/>
-        <span data-ux-clipboard-target="button"> Copy</span>
+        <span {{ stimulus_target('ux-clipboard', 'button') }}> Copy</span>
     </button>
 </div>
diff --git a/ux.symfony.com/templates/components/DinoChart.html.twig b/ux.symfony.com/templates/components/DinoChart.html.twig
index 395f5ef1..5f1da685 100644
--- a/ux.symfony.com/templates/components/DinoChart.html.twig
+++ b/ux.symfony.com/templates/components/DinoChart.html.twig
@@ -34,7 +34,7 @@
                 data-model="currentTypes"
                 multiple
                 id="dinoTypes"
-                data-controller="symfony--ux-autocomplete--autocomplete"
+                {{ stimulus_controller('symfony/ux-autocomplete/autocomplete') }}
             >
                 {% for type in allTypes %}
                     <option value="{{ type }}">{{ type }}</option>
diff --git a/ux.symfony.com/templates/components/HomepageTerminalSwapper.html.twig b/ux.symfony.com/templates/components/HomepageTerminalSwapper.html.twig
index 244e65dc..35ed984f 100644
--- a/ux.symfony.com/templates/components/HomepageTerminalSwapper.html.twig
+++ b/ux.symfony.com/templates/components/HomepageTerminalSwapper.html.twig
@@ -5,13 +5,14 @@
         {% block content -%}
             <span
                 style="font-size: 1.2em;"
-                data-controller="symfony--ux-typed"
-                data-symfony--ux-typed-loop-value="true"
-                data-symfony--ux-typed-show-cursor-value="true"
-                data-symfony--ux-typed-type-speed-value="20"
-                data-symfony--ux-typed-cursor-char-value="🖱"
-                data-symfony--ux-typed-content-type-value="html"
-                data-symfony--ux-typed-strings-value="{{ typedStrings|json_encode|e('html_attr') }}"
+                {{ stimulus_controller('symfony/ux-typed', {
+                    strings: typedStrings,
+                    loop: true,
+                    showCursor: true,
+                    typeSpeed: 20,
+                    cursorChar: '🖱',
+                    contentType: 'html'
+                }) }}
             ></span>
         {%- endblock %}
     {% endcomponent %}
diff --git a/ux.symfony.com/templates/components/TerminalCommand.html.twig b/ux.symfony.com/templates/components/TerminalCommand.html.twig
index 25c305ac..e29eb679 100644
--- a/ux.symfony.com/templates/components/TerminalCommand.html.twig
+++ b/ux.symfony.com/templates/components/TerminalCommand.html.twig
@@ -1,13 +1,13 @@
-<div {{ attributes.defaults({class: 'TerminalCommand'}) }} data-controller="ux-clipboard">
+<div {{ attributes.defaults({class: 'TerminalCommand'}).defaults(stimulus_controller('clipboard')) }}>
     <pre
         class="TerminalCommand_input"
         aria-label="{{ label|default }}"
-    ><code data-clipboard-target="source">{{ command }}</code></pre>
+    ><code {{ stimulus_target('clipboard', 'source') }}>{{ command }}</code></pre>
     <button
         type="button"
         class="TerminalCommand_action btn btn-copy"
         aria-label="Copy command"
-        data-action="clipboard#copy"
+        {{ stimulus_action('clipboard', 'copy') }}
     >
         <twig:Icon name="copy"/>
     </button>
diff --git a/ux.symfony.com/templates/liveDemoBase.html.twig b/ux.symfony.com/templates/liveDemoBase.html.twig
index 6eb9bcfa..2b68c718 100644
--- a/ux.symfony.com/templates/liveDemoBase.html.twig
+++ b/ux.symfony.com/templates/liveDemoBase.html.twig
@@ -68,6 +68,7 @@
 
 {% block aside %}
     <aside style="background-color: var(--bs-secondary-bg-subtle);" class="mt-5">
+
         <twig:PrevNextDemo demo="{{ demo }}" />
-    </aside>
+    </aside>>
 {% endblock %}
diff --git a/ux.symfony.com/templates/main/homepage.html.twig b/ux.symfony.com/templates/main/homepage.html.twig
index e6f32ee3..03f6a7d3 100644
--- a/ux.symfony.com/templates/main/homepage.html.twig
+++ b/ux.symfony.com/templates/main/homepage.html.twig
@@ -37,7 +37,7 @@
         </div>
 
         <div class="col-12 col-md-6 mt-3 mt-md-0">
-            <div data-controller="tooltips" class="ux-container file-tree flex-grow-1 ms-2 py-3">
+            <div {{ stimulus_controller('tooltips') }} class="ux-container file-tree flex-grow-1 ms-2 py-3">
                 <div style="height: 185px; overflow: auto;">
                     <ul class="list-unstyled file-tree-main">
                         {{ include('main/_fileTreeLevel.html.twig', {
@@ -77,19 +77,19 @@
     </div>
 
     {% block markdown_example %}
-        <div class="p-4 markdown-container background-glass shadow-blur shadow-blur--rainbow  mt-5 row" data-controller="markdown">
+        <div class="p-4 markdown-container background-glass shadow-blur shadow-blur--rainbow  mt-5 row" {{ stimulus_controller('markdown') }}>
             <div class="col-12 col-md-5">
                 <textarea rows="3" class="form-control" aria-label="Type markdown into this box"
-                          data-markdown-target="input"
+                    {{ stimulus_target('markdown', 'input') }}
                 >Writing JavaScript is a **dream** with Stimulus 🤩</textarea>
             </div>
             <div class="col-12 col-md-2 d-flex justify-content-center align-items-center py-3">
-                <button class="btn btn-dark" data-action="markdown#render">
-                    Convert <twig:Icon name="arrow-right"/>
-                </button>
+                <button class="btn btn-dark"
+                    {{ stimulus_action('markdown', 'render') }}
+                >Convert <twig:Icon name="arrow-right"/></button>
             </div>
             <div class="col-12 col-md-5">
-                <div style="min-height: 86px;" class="markdown-form-render-container p-2" data-markdown-target="preview">
+                <div style="min-height: 86px;" class="markdown-form-render-container p-2" {{ stimulus_target('markdown', 'preview') }} >
                     <small class="fw-light">(click "Convert")</small>
                 </div>
             </div>
diff --git a/ux.symfony.com/templates/ux_packages/lazy-image.html.twig b/ux.symfony.com/templates/ux_packages/lazy-image.html.twig
index 021d5536..03c73ce2 100644
--- a/ux.symfony.com/templates/ux_packages/lazy-image.html.twig
+++ b/ux.symfony.com/templates/ux_packages/lazy-image.html.twig
@@ -41,8 +41,9 @@
 {% block code_block_right %}
     <img
         src="{{ data_uri_thumbnail(legosFilePath, 40, 30) }}"
-        data-controller="symfony--ux-lazy-image--lazy-image"
-        data-symfony--ux-lazy-image--lazy-image-src="{{ asset('images/legos.jpg') }}"
+        {{ stimulus_controller('symfony/ux-lazy-image/lazy-image', {
+            src: asset('images/legos.jpg')
+        }) }}
         alt="A blurhash image that is replaced by the real image on load"
         width="578"
         height="275"
diff --git a/ux.symfony.com/templates/ux_packages/swup.html.twig b/ux.symfony.com/templates/ux_packages/swup.html.twig
index eba9c937..6aa4c44a 100644
--- a/ux.symfony.com/templates/ux_packages/swup.html.twig
+++ b/ux.symfony.com/templates/ux_packages/swup.html.twig
@@ -30,7 +30,7 @@
 
 {% block demo_content %}
     {% block swup_specific_demo_content %}
-        <div data-controller="symfony--ux-swup--swup" id="swup" data-turbo="false">
+        <div {{ stimulus_controller('symfony/ux-swup/swup') }} id="swup" data-turbo="false">
             <div class="row">
                 {% for package in pager %}
                     {{ include('main/_package_in_list.html.twig') }}
diff --git a/ux.symfony.com/templates/ux_packages/translator.html.twig b/ux.symfony.com/templates/ux_packages/translator.html.twig
index cb9ecef9..94fdbcdc 100644
--- a/ux.symfony.com/templates/ux_packages/translator.html.twig
+++ b/ux.symfony.com/templates/ux_packages/translator.html.twig
@@ -26,12 +26,12 @@
 {% block demo_title %}UX Translator{% endblock %}
 
 {% block demo_content %}
-    <div data-controller="translator-demo">
-        <div data-translator-demo-target="localeSwitcher" style="position: absolute; top: 16px; right: 16px ">
+    <div {{ stimulus_controller('translator-demo') }}>
+        <div {{ stimulus_target('translator-demo', 'localeSwitcher') }} style="position: absolute; top: 16px; right: 16px ">
             <div class="row">
                 <label for="translator-demo-locale" class="col-3 col-form-label">Locale</label>
                 <div class="col">
-                    <select id="translator-demo-locale" class="form-select" data-action="translator-demo#setLocale">
+                    <select id="translator-demo-locale" {{ stimulus_action('translator-demo', 'setLocale') }} class="form-select">
                         <option value="en">🇬🇧 English</option>
                         <option value="fr">🇫🇷 French</option>
                     </select>
@@ -54,22 +54,22 @@
         <div class="row mb-4">
             <div class="col-5">
                 <div class="Terminal_body">
-                    <pre data-translator-demo-target="helloCode"></pre>
+                    <pre {{ stimulus_target('translator-demo', 'helloCode') }}></pre>
                 </div>
             </div>
             <div class="col">
-                <span data-translator-demo-target="helloOutput"></span>
+                <span {{ stimulus_target('translator-demo', 'helloOutput') }}></span>
             </div>
         </div>
 
         <div class="row mb-4">
             <div class="col-5">
                 <div class="Terminal_body">
-                    <pre data-translator-demo-target="sayHelloCode"></pre>
+                    <pre {{ stimulus_target('translator-demo', 'sayHelloCode') }}></pre>
                 </div>
             </div>
             <div class="col">
-                <span data-translator-demo-target="sayHelloOutput"></span>
+                <span {{ stimulus_target('translator-demo', 'sayHelloOutput') }}></span>
             </div>
             <div class="col-4">
                 <div class="row mb-3">
@@ -80,8 +80,8 @@
                             value="Fabien"
                             class="form-control"
                             id="sayHelloNameInput"
-                            data-translator-demo-target="sayHelloNameInput"
-                            data-action="translator-demo#render"
+                            {{ stimulus_target('translator-demo', 'sayHelloNameInput') }}
+                            {{ stimulus_action('translator-demo', 'render') }}
                         >
                     </div>
                 </div>
@@ -91,11 +91,11 @@
         <div class="row mb-4">
             <div class="col-5">
                 <div class="Terminal_body">
-                    <pre data-translator-demo-target="invitationTitleCode"></pre>
+                    <pre {{ stimulus_target('translator-demo', 'invitationTitleCode') }}></pre>
                 </div>
             </div>
             <div class="col">
-                <span data-translator-demo-target="invitationTitleOutput"></span>
+                <span {{ stimulus_target('translator-demo', 'invitationTitleOutput') }}></span>
             </div>
             <div class="col-4">
                 <div class="row mb-3">
@@ -104,8 +104,8 @@
                         <select
                             id="invitationTitleOrganizationGenderInput"
                             class="form-select"
-                            data-translator-demo-target="invitationTitleOrganizationGenderInput"
-                            data-action="translator-demo#render"
+                            {{ stimulus_target('translator-demo', 'invitationTitleOrganizationGenderInput') }}
+                            {{ stimulus_action('translator-demo', 'render') }}
                         >
                             <option value="female">Female</option>
                             <option value="male">Male</option>
@@ -123,8 +123,8 @@
                             type="text"
                             value="Clara"
                             class="form-control"
-                            data-translator-demo-target="invitationTitleOrganizationNameInput"
-                            data-action="translator-demo#render"
+                            {{ stimulus_target('translator-demo', 'invitationTitleOrganizationNameInput') }}
+                            {{ stimulus_action('translator-demo', 'render') }}
                         >
                     </div>
                 </div>
@@ -134,11 +134,11 @@
         <div class="row mb-4">
             <div class="col-5">
                 <div class="Terminal_body">
-                    <pre data-translator-demo-target="numOfApplesCode"></pre>
+                    <pre {{ stimulus_target('translator-demo', 'numOfApplesCode') }}></pre>
                 </div>
             </div>
             <div class="col">
-                <span data-translator-demo-target="numOfApplesOutput"></span>
+                <span {{ stimulus_target('translator-demo', 'numOfApplesOutput') }}></span>
             </div>
             <div class="col-4">
                 <div class="row mb-3">
@@ -151,8 +151,8 @@
                             value="2"
                             class="form-control"
                             id="numOfApplesCountInput"
-                            data-translator-demo-target="numOfApplesCountInput"
-                            data-action="translator-demo#render"
+                            {{ stimulus_target('translator-demo', 'numOfApplesCountInput') }}
+                            {{ stimulus_action('translator-demo', 'render') }}
                         >
                     </div>
                 </div>
@@ -162,11 +162,11 @@
         <div class="row mb-4">
             <div class="col-5">
                 <div class="Terminal_body">
-                    <pre data-translator-demo-target="finishPlaceCode"></pre>
+                    <pre {{ stimulus_target('translator-demo', 'finishPlaceCode') }}></pre>
                 </div>
             </div>
             <div class="col">
-                <span data-translator-demo-target="finishPlaceOutput"></span>
+                <span {{ stimulus_target('translator-demo', 'finishPlaceOutput') }}></span>
             </div>
             <div class="col-4">
                 <div class="row mb-3">
@@ -178,8 +178,8 @@
                             value="1"
                             class="form-control"
                             id="finishPlacePlaceInput"
-                            data-translator-demo-target="finishPlacePlaceInput"
-                            data-action="translator-demo#render"
+                            {{ stimulus_target('translator-demo', 'finishPlacePlaceInput') }}
+                            {{ stimulus_action('translator-demo', 'render') }}
                         >
                     </div>
                 </div>
@@ -189,11 +189,11 @@
         <div class="row mb-4">
             <div class="col-5">
                 <div class="Terminal_body">
-                    <pre data-translator-demo-target="publishedAtCode"></pre>
+                    <pre {{ stimulus_target('translator-demo', 'publishedAtCode') }}></pre>
                 </div>
             </div>
             <div class="col">
-                <span data-translator-demo-target="publishedAtOutput"></span>
+                <span {{ stimulus_target('translator-demo', 'publishedAtOutput') }}></span>
             </div>
             <div class="col-4">
                 <div class="row mb-3">
@@ -204,8 +204,8 @@
                             value="2023-04-27 08:12"
                             class="form-control"
                             id="publishedAtDateInput"
-                            data-translator-demo-target="publishedAtDateInput"
-                            data-action="translator-demo#render"
+                            {{ stimulus_target('translator-demo', 'publishedAtDateInput') }}
+                            {{ stimulus_action('translator-demo', 'render') }}
                         >
                     </div>
                 </div>
@@ -215,11 +215,11 @@
         <div class="row mb-4">
             <div class="col-5">
                 <div class="Terminal_body">
-                    <pre data-translator-demo-target="progressAtCode"></pre>
+                    <pre {{ stimulus_target('translator-demo', 'progressAtCode') }}></pre>
                 </div>
             </div>
             <div class="col">
-                <span data-translator-demo-target="progressAtOutput"></span>
+                <span {{ stimulus_target('translator-demo', 'progressAtOutput') }}></span>
             </div>
             <div class="col-4">
                 <div class="row mb-3">
@@ -229,8 +229,8 @@
                             id="progressAtProgressInput"
                             type="range"
                             class="form-range"
-                            data-translator-demo-target="progressAtProgressInput"
-                            data-action="translator-demo#render"
+                            {{ stimulus_target('translator-demo', 'progressAtProgressInput') }}
+                            {{ stimulus_action('translator-demo', 'render') }}
                         />
                     </div>
                 </div>
@@ -240,11 +240,11 @@
         <div class="row">
             <div class="col-5">
                 <div class="Terminal_body">
-                <pre data-translator-demo-target="valueOfObjectCode"></pre>
+                <pre {{ stimulus_target('translator-demo', 'valueOfObjectCode') }}></pre>
                 </div>
             </div>
             <div class="col">
-                <span data-translator-demo-target="valueOfObjectOutput"></span>
+                <span {{ stimulus_target('translator-demo', 'valueOfObjectOutput') }}></span>
             </div>
             <div class="col-4">
                 <div class="row mb-3">
@@ -256,8 +256,8 @@
                             value="30"
                             class="form-control"
                             id="valueOfObjectValueInput"
-                            data-translator-demo-target="valueOfObjectValueInput"
-                            data-action="translator-demo#render"
+                            {{ stimulus_target('translator-demo', 'valueOfObjectValueInput') }}
+                            {{ stimulus_action('translator-demo', 'render') }}
                         >
                 </div>
             </div>
diff --git a/ux.symfony.com/templates/ux_packages/typed.html.twig b/ux.symfony.com/templates/ux_packages/typed.html.twig
index 495989b5..0fba5b88 100644
--- a/ux.symfony.com/templates/ux_packages/typed.html.twig
+++ b/ux.symfony.com/templates/ux_packages/typed.html.twig
@@ -25,21 +25,21 @@
 
 {% block code_block_right %}
     <div class="p-5" style="font-size: 1.3em;">
-        {% set strings = [
-            'I ❤️ Symfony UX!',
-            'Symfony UX Typed loves to type',
-            'Symfony UX Typed and backspace',
-            'Control the speed',
-            'Control the cursor',
-            'Control your destiny!!!',
-            'Control your destiny... sort of',
-        ] %}
         <span
-            data-controller="symfony--ux-typed"
-            data-symfony--ux-typed-loop-value="true"
-            data-symfony--ux-typed-show-cursor-value="true"
-            data-symfony--ux-typed-cursor-char-value="✨"
-            data-symfony--ux-typed-strings-value="{{ strings|json_encode|e('html_attr') }}"
+            {{ stimulus_controller('symfony/ux-typed', {
+                strings: [
+                    'I ❤️ Symfony UX!',
+                    'Symfony UX Typed loves to type',
+                    'Symfony UX Typed and backspace',
+                    'Control the speed',
+                    'Control the cursor',
+                    'Control your destiny!!!',
+                    'Control your destiny... sort of',
+                ],
+                loop: true,
+                showCursor: true,
+                cursorChar: '✨',
+            }) }}
         ></span>
     </div>
 {% endblock %}


Test patch:
diff --git a/src/Autocomplete/assets/test/controller.test.ts b/src/Autocomplete/assets/test/controller.test.ts
index f7c5060f..440e2004 100644
--- a/src/Autocomplete/assets/test/controller.test.ts
+++ b/src/Autocomplete/assets/test/controller.test.ts
@@ -815,46 +815,4 @@ describe('AutocompleteController', () => {
         });
         expect(getSelectedValues()).toEqual(['2', '3']);
     });
-
-    it('does not trigger a reset when the style of "multiple" attribute changes', async () => {
-        const { container } = await startAutocompleteTest(`
-            <select multiple data-testid='main-element' data-controller='autocomplete'>
-                <option value=''>Select dogs</option>
-                <option value='1'>dog1</option>
-                <option value='2'>dog2</option>
-                <option value='3'>dog3</option>
-            </select>
-        `);
-
-        let wasReset = false;
-        container.addEventListener('autocomplete:before-reset', () => {
-            wasReset = true;
-        });
-
-        const selectElement = getByTestId(container, 'main-element') as HTMLSelectElement;
-        selectElement.setAttribute('multiple', 'multiple');
-        // wait for the mutation observe
-        await shortDelay(10);
-        expect(wasReset).toBe(false);
-    });
-
-    it('does not trigger a reset based on the extra, empty select', async () => {
-        const { container, tomSelect } = await startAutocompleteTest(`
-            <select data-testid='main-element' data-controller='autocomplete'>
-                <option value='1'>dog1</option>
-                <option value='2'>dog2</option>
-                <option value='3'>dog3</option>
-            </select>
-        `);
-
-        let wasReset = false;
-        container.addEventListener('autocomplete:before-reset', () => {
-            wasReset = true;
-        });
-
-        tomSelect.addItem('2');
-        // wait for the mutation observe
-        await shortDelay(10);
-        expect(wasReset).toBe(false);
-    });
 });
diff --git a/src/LiveComponent/assets/test/controller/model.test.ts b/src/LiveComponent/assets/test/controller/model.test.ts
index 49b30365..81ef9189 100644
--- a/src/LiveComponent/assets/test/controller/model.test.ts
+++ b/src/LiveComponent/assets/test/controller/model.test.ts
@@ -810,9 +810,9 @@ describe('LiveController data-model Tests', () => {
             <div ${initComponent(data)}>
                 <form data-model>
                     <textarea name="comment" data-testid="comment">${data.comment}</textarea>
-                </form>
+               </form>
 
-                <input data-testid="other-input">
+               <button data-action="live#$render">Reload</button>
            </div>
        `);
 
@@ -823,18 +823,17 @@ describe('LiveController data-model Tests', () => {
             // delay slightly so we can type in the textarea
             .delayResponse(10);
 
-        const renderPromise = test.component.render();
+        getByText(test.element, 'Reload').click();
         // mimic changing the field, but without (yet) triggering the change event
         const commentField = getByTestId(test.element, 'comment');
-        const inputField = getByTestId(test.element, 'other-input');
+        if (!(commentField instanceof HTMLTextAreaElement)) {
+            throw new Error('wrong type');
+        }
         userEvent.type(commentField, ' ftw!');
-        // To make the test more robust, make the textarea NOT the active element.
-        // The active element's value is ignored during morphing. Here, we want
-        // to test that even if the morph system *does* want to update the value
-        // of the textarea, the value will be kept.
-        userEvent.type(inputField, 'making this element active');
 
-        await renderPromise;
+        // wait for loading start and end
+        await waitFor(() => expect(test.element).toHaveAttribute('busy'));
+        await waitFor(() => expect(test.element).not.toHaveAttribute('busy'));
 
         expect(commentField).toHaveValue('Live components ftw!');
 
@@ -846,7 +845,10 @@ describe('LiveController data-model Tests', () => {
                 data.comment = 'server changed comment';
             });
 
-        await test.component.render();
+        getByText(test.element, 'Reload').click();
+        // wait for loading start and end
+        await waitFor(() => expect(test.element).toHaveAttribute('busy'));
+        await waitFor(() => expect(test.element).not.toHaveAttribute('busy'));
 
         expect(commentField).toHaveValue('server changed comment');
     });
diff --git a/src/LiveComponent/assets/test/controller/render.test.ts b/src/LiveComponent/assets/test/controller/render.test.ts
index 70493eb8..df096692 100644
--- a/src/LiveComponent/assets/test/controller/render.test.ts
+++ b/src/LiveComponent/assets/test/controller/render.test.ts
@@ -149,28 +149,6 @@ describe('LiveController rendering Tests', () => {
         expect((test.element.querySelector('textarea') as HTMLTextAreaElement).value).toEqual('typing after the request starts');
     });
 
-    it('conserves cursor position of active model element', async () => {
-        const test = await createTest({ name: '' }, (data) => `
-            <div ${initComponent(data)}>
-                <input data-model="name" class="anything">
-            </div>
-        `);
-
-        test.expectsAjaxCall()
-            .expectUpdatedData({ name: 'Hello' })
-
-        const input = test.queryByDataModel('name') as HTMLInputElement;
-        userEvent.type(input, 'Hello');
-        userEvent.keyboard('{ArrowLeft}{ArrowLeft}');
-
-        await test.component.render();
-
-        // the cursor position should be preserved
-        expect(input.selectionStart).toBe(3);
-        userEvent.type(input, '!');
-        expect(input.value).toBe('Hel!lo');
-    });
-
     it('does not render over elements with data-live-ignore', async () => {
         const test = await createTest({ firstName: 'Ryan' }, (data: any) => `
             <div ${initComponent(data)}>
diff --git a/src/LiveComponent/src/Test/TestLiveComponent.php b/src/LiveComponent/src/Test/TestLiveComponent.php
index 0a21f33d..4e9234e4 100644
--- a/src/LiveComponent/src/Test/TestLiveComponent.php
+++ b/src/LiveComponent/src/Test/TestLiveComponent.php
@@ -28,12 +28,14 @@ use Symfony\UX\TwigComponent\Test\RenderedComponent;
  */
 final class TestLiveComponent
 {
+    private bool $performedInitialRequest = false;
+
     /**
      * @internal
      */
     public function __construct(
         private ComponentMetadata $metadata,
-        array $data,
+        private array $data,
         private ComponentFactory $factory,
         private KernelBrowser $client,
         private LiveComponentHydrator $hydrator,
@@ -41,35 +43,7 @@ final class TestLiveComponent
         private UrlGeneratorInterface $router,
     ) {
         $this->client->catchExceptions(false);
-
-        $data['attributes']['id'] ??= 'in-a-real-scenario-it-would-already-have-one---provide-one-yourself-if-needed';
-
-        $mounted = $this->factory->create($this->metadata->getName(), $data);
-        $props = $this->hydrator->dehydrate(
-            $mounted->getComponent(),
-            $mounted->getAttributes(),
-            $this->metadataFactory->getMetadata($mounted->getName())
-        );
-
-        if ('POST' === strtoupper($this->metadata->get('method'))) {
-            $this->client->request(
-                'POST',
-                $this->router->generate($this->metadata->get('route'), [
-                    '_live_component' => $this->metadata->getName(),
-                ]),
-                [
-                    'data' => json_encode(['props' => $props->getProps()], flags: \JSON_THROW_ON_ERROR),
-                ],
-            );
-        } else {
-            $this->client->request('GET', $this->router->generate(
-                $this->metadata->get('route'),
-                [
-                    '_live_component' => $this->metadata->getName(),
-                    'props' => json_encode($props->getProps(), flags: \JSON_THROW_ON_ERROR),
-                ]
-            ));
-        }
+        $this->data['attributes']['data-live-id'] ??= 'in-a-real-scenario-it-would-already-have-one---provide-one-yourself-if-needed';
     }
 
     public function render(): RenderedComponent
@@ -95,6 +69,7 @@ final class TestLiveComponent
      */
     public function actingAs(object $user, string $firewallContext = 'main'): self
     {
+        // we call loginUser() on the raw client in case the entire component requires authentication
         $this->client->loginUser($user, $firewallContext);
 
         return $this;
@@ -145,14 +120,14 @@ final class TestLiveComponent
 
     public function response(): Response
     {
-        return $this->client->getResponse();
+        return $this->client()->getResponse();
     }
 
     private function request(array $content = [], ?string $action = null): self
     {
         $csrfToken = $this->csrfToken();
 
-        $this->client->request(
+        $this->client()->request(
             'POST',
             $this->router->generate(
                 $this->metadata->get('route'),
@@ -170,7 +145,7 @@ final class TestLiveComponent
 
     private function props(): array
     {
-        $crawler = $this->client->getCrawler();
+        $crawler = $this->client()->getCrawler();
 
         if (!\count($node = $crawler->filter('[data-live-props-value]'))) {
             throw new \LogicException('A live component action has redirected and you can no longer access the component.');
@@ -181,7 +156,7 @@ final class TestLiveComponent
 
     private function csrfToken(): ?string
     {
-        $crawler = $this->client->getCrawler();
+        $crawler = $this->client()->getCrawler();
 
         if (!\count($node = $crawler->filter('[data-live-csrf-value]'))) {
             return null;
@@ -189,4 +164,42 @@ final class TestLiveComponent
 
         return $node->attr('data-live-csrf-value');
     }
+
+    private function client(): KernelBrowser
+    {
+        if ($this->performedInitialRequest) {
+            return $this->client;
+        }
+
+        $mounted = $this->factory->create($this->metadata->getName(), $this->data);
+        $props = $this->hydrator->dehydrate(
+            $mounted->getComponent(),
+            $mounted->getAttributes(),
+            $this->metadataFactory->getMetadata($mounted->getName())
+        );
+
+        if ('POST' === strtoupper($this->metadata->get('method'))) {
+            $this->client->request(
+                'POST',
+                $this->router->generate($this->metadata->get('route'), [
+                    '_live_component' => $this->metadata->getName(),
+                ]),
+                [
+                    'data' => json_encode(['props' => $props->getProps()], flags: \JSON_THROW_ON_ERROR),
+                ],
+            );
+        } else {
+            $this->client->request('GET', $this->router->generate(
+                $this->metadata->get('route'),
+                [
+                    '_live_component' => $this->metadata->getName(),
+                    'props' => json_encode($props->getProps(), flags: \JSON_THROW_ON_ERROR),
+                ]
+            ));
+        }
+
+        $this->performedInitialRequest = true;
+
+        return $this->client;
+    }
 }
diff --git a/src/LiveComponent/tests/Fixtures/Kernel.php b/src/LiveComponent/tests/Fixtures/Kernel.php
index 8cd25a20..4ec25959 100644
--- a/src/LiveComponent/tests/Fixtures/Kernel.php
+++ b/src/LiveComponent/tests/Fixtures/Kernel.php
@@ -16,6 +16,7 @@ use Doctrine\Bundle\DoctrineBundle\DoctrineBundle;
 use Psr\Log\NullLogger;
 use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
 use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
+use Symfony\Bundle\SecurityBundle\Security;
 use Symfony\Bundle\SecurityBundle\SecurityBundle;
 use Symfony\Bundle\TwigBundle\TwigBundle;
 use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
@@ -118,13 +119,19 @@ final class Kernel extends BaseKernel
             'default_path' => '%kernel.project_dir%/tests/Fixtures/templates',
         ]);
 
-        $c->extension('security', [
+        $security = [
             'password_hashers' => [InMemoryUser::class => 'plaintext'],
             'providers' => ['users' => ['memory' => ['users' => ['kevin' => ['password' => 'pass', 'roles' => ['ROLE_USER']]]]]],
             'firewalls' => ['main' => [
                 'lazy' => true,
             ]],
-        ]);
+        ];
+
+        if (!class_exists(Security::class)) {
+            $security['enable_authenticator_manager'] = true;
+        }
+
+        $c->extension('security', $security);
 
         $c->extension('twig_component', [
             'defaults' => [
diff --git a/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php b/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php
index 1e218337..dce6db3c 100644
--- a/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php
+++ b/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php
@@ -15,6 +15,7 @@ use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
 use Symfony\Component\DomCrawler\Crawler;
 use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
 use Symfony\Component\Security\Core\User\InMemoryUser;
+use Symfony\Component\Security\Http\Attribute\IsGranted;
 use Symfony\UX\LiveComponent\Tests\Fixtures\Entity\Entity1;
 use Symfony\UX\LiveComponent\Tests\LiveComponentTestHelper;
 use Zenstruck\Browser\Test\HasBrowser;
@@ -504,6 +505,10 @@ final class LiveComponentSubscriberTest extends KernelTestCase
 
     public function testCanHaveControllerAttributes(): void
     {
+        if (!class_exists(IsGranted::class)) {
+            $this->markTestSkipped('The security attributes are not available.');
+        }
+
         $dehydrated = $this->dehydrateComponent($this->mountComponent('with_security'));
 
         $this->browser()
diff --git a/src/LiveComponent/tests/Functional/Test/InteractsWithLiveComponentsTest.php b/src/LiveComponent/tests/Functional/Test/InteractsWithLiveComponentsTest.php
index bb10c4f7..c7914f69 100644
--- a/src/LiveComponent/tests/Functional/Test/InteractsWithLiveComponentsTest.php
+++ b/src/LiveComponent/tests/Functional/Test/InteractsWithLiveComponentsTest.php
@@ -12,6 +12,9 @@
 namespace Symfony\UX\LiveComponent\Tests\Functional\Test;
 
 use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
+use Symfony\Component\Security\Core\Exception\AccessDeniedException;
+use Symfony\Component\Security\Core\User\InMemoryUser;
+use Symfony\Component\Security\Http\Attribute\IsGranted;
 use Symfony\UX\LiveComponent\Test\InteractsWithLiveComponents;
 use Symfony\UX\LiveComponent\Tests\Fixtures\Component\Component2;
 
@@ -140,4 +143,30 @@ final class InteractsWithLiveComponentsTest extends KernelTestCase
 
         $testComponent->call('increase');
     }
+
+    public function testRenderingIsLazy(): void
+    {
+        if (!class_exists(IsGranted::class)) {
+            $this->markTestSkipped('The security attributes are not available.');
+        }
+
+        $testComponent = $this->createLiveComponent('with_security');
+
+        $this->expectException(AccessDeniedException::class);
+
+        $testComponent->render();
+    }
+
+    public function testActingAs(): void
+    {
+        $testComponent = $this->createLiveComponent('with_security')
+            ->actingAs(new InMemoryUser('kevin', 'pass', ['ROLE_USER']))
+        ;
+
+        $this->assertStringNotContainsString('Username: kevin', $testComponent->render());
+
+        $testComponent->call('setUsername');
+
+        $this->assertStringContainsString('Username: kevin', $testComponent->render());
+    }
 }
diff --git a/src/LiveComponent/tests/Unit/Twig/TemplateCacheWarmerTest.php b/src/LiveComponent/tests/Unit/Twig/TemplateCacheWarmerTest.php
deleted file mode 100644
index 5e52311b..00000000
--- a/src/LiveComponent/tests/Unit/Twig/TemplateCacheWarmerTest.php
+++ /dev/null
@@ -1,78 +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\LiveComponent\Tests\Unit\Twig;
-
-use PHPUnit\Framework\TestCase;
-use Symfony\Component\Cache\Adapter\NullAdapter;
-use Symfony\Component\Cache\Adapter\PhpArrayAdapter;
-use Symfony\UX\LiveComponent\Twig\TemplateCacheWarmer;
-
-/**
- * @author Simon André <smn.andre@gmail.com>
- */
-final class TemplateCacheWarmerTest extends TestCase
-{
-    private string $cacheDir;
-    private string $cacheFile;
-    private TemplateCacheWarmer $templateCacheWarmer;
-
-    protected function setUp(): void
-    {
-        $this->cacheDir ??= sys_get_temp_dir();
-        $this->cacheFile ??= $this->cacheDir.'/cache_file';
-        if (file_exists($this->cacheFile)) {
-            unlink($this->cacheFile);
-        }
-        $this->templateCacheWarmer ??= new TemplateCacheWarmer(
-            new \ArrayObject(['template1', 'template2']),
-            'cache_file',
-            'secret'
-        );
-    }
-
-    public function testWarmUpCreatesCacheFile(): void
-    {
-        $this->assertFileDoesNotExist($this->cacheFile);
-
-        $this->templateCacheWarmer->warmUp($this->cacheDir);
-
-        $this->assertFileExists($this->cacheFile);
-    }
-
-    public function testWarmUpCreatesCorrectCacheContent(): void
-    {
-        $this->templateCacheWarmer->warmUp($this->cacheDir);
-        $adapter = new PhpArrayAdapter($this->cacheFile, new NullAdapter());
-        $item = $adapter->getItem('map');
-
-        $this->assertSame(
-            [
-                hash('xxh128', 'template1secret') => 'template1',
-                hash('xxh128', 'template2secret') => 'template2',
-            ],
-            $item->get()
-        );
-    }
-
-    public function testWarmUpCreatesReproductibleTemplateMap(): void
-    {
-        $this->templateCacheWarmer->warmUp($this->cacheDir);
-        $adapter = new PhpArrayAdapter($this->cacheFile, new NullAdapter());
-        $map1 = $adapter->getItem('map')->get();
-
-        $this->templateCacheWarmer->warmUp($this->cacheDir);
-        $adapter = new PhpArrayAdapter($this->cacheFile, new NullAdapter());
-        $map2 = $adapter->getItem('map')->get();
-
-        $this->assertSame($map1, $map2);
-    }
-}
diff --git a/ux.symfony.com/tests/Unit/Util/SourceCleanerTest.php b/ux.symfony.com/tests/Unit/Util/SourceCleanerTest.php
index 3db175c4..f76bd07e 100644
--- a/ux.symfony.com/tests/Unit/Util/SourceCleanerTest.php
+++ b/ux.symfony.com/tests/Unit/Util/SourceCleanerTest.php
@@ -109,19 +109,19 @@ class SourceCleanerTest extends TestCase
     public function testItRemovesExcessHtml(): void
     {
         $input = <<<EOF
-            <div class="p-4 markdown-container shadow-blur shadow-blur--rainbow mt-5 row" data-controller="markdown">
+            <div class="p-4 markdown-container shadow-blur shadow-blur--rainbow mt-5 row" {{ stimulus_controller('markdown') }}>
                 <div class="col-12 col-md-5">
                     <textarea rows="3" class="form-control" aria-label="Type markdown into this box"
-                        data-markdown-target="input"
+                        {{ stimulus_target('markdown', 'input') }}
                     >Writing JavaScript is a **dream** with Stimulus 🤩</textarea>
                 </div>
                 <div class="col-12 col-md-2 text-center">
-                    <button class="btn btn-sm btn-dark mt-3" data-action="markdown#render">
+                    <button class="btn btn-sm btn-dark mt-3" {{ stimulus_action('markdown', 'render') }}>
                         Convert <i class="fa fa-arrow-right"></i>
                     </button>
                 </div>
                 <div class="col-12 col-md-5 mt-3 mt-md-0">
-                    <div style="min-height: 86px;" class="markdown-form-render-container p-2" data-markdown-target="preview">
+                    <div style="min-height: 86px;" class="markdown-form-render-container p-2" {{ stimulus_target('markdown', 'preview') }} >
                         <small class="fw-light">(click "Convert")</small>
                     </div>
                 </div>
@@ -129,14 +129,14 @@ class SourceCleanerTest extends TestCase
             EOF;
 
         $expected = <<<EOF
-            <div data-controller="markdown">
+            <div {{ stimulus_controller('markdown') }}>
                 <textarea
-                    data-markdown-target="input"
+                    {{ stimulus_target('markdown', 'input') }}
                 >Writing JavaScript is a **dream** with Stimulus 🤩</textarea>
-                <button data-action="markdown#render">
+                <button {{ stimulus_action('markdown', 'render') }}>
                     Convert <i></i>
                 </button>
-                <div data-markdown-target="preview">
+                <div {{ stimulus_target('markdown', 'preview') }} >
                     <small>(click "Convert")</small>
                 </div>
             </div>