Repository: symfony/ux
Pull request #2283 (2024-10-18 23:54:23)

[React] Add `permanent` option to `react_component` function
| Q             | A
| ------------- | ---
| Bug fix?      | yes
| New feature?  | yes
| Issues        | Fix #... 
| License       | MIT

(Issue reported on a work project)

Using Turbo `data-turbo-permanent` creates race conditions that lead to the controller code failing to keep or reload the component. 

In the current implementation, the Stimulus controller _unmounts_ the component then tried to _create_ it again.. which cannot be done: 

> Once you call root.unmount you cannot call root.render again on the same root. Attempting to call root.render on an unmounted root will throw a “Cannot update an unmounted root” error. However, you can create a new root for the same DOM node after the previous root for that node has been unmounted.

https://react.dev/reference/react-dom/client/createRoot#root-unmount

---

~The proposed implementation uses a short delay before calling unmount. It is "morphing-library-agnostic", and will work with any Turbo-like framework, with no impact on the existing projects using UX React (except a bug fixed 😅 ).~

New implementation:

A new option `permanent` prevent the "unmount" of the component when set to true

```twig
{# This component will not be unmounted on "disconnect" #}

{{ react_component('MyComponent', props, {permanent: true}) }}

```

This allows to avoid the bug when a component is disconnected and immediately reconnected (ex with Turbo)


codisart: Hello @smnandre, I work on a website for a french media company which include a audio player which is implemented using react. We tried to use symfony/ux react component with turbo and a data-turbo-permanent attribute to allow the non stop music playing during navigation. 

We discovered that it was not possible with the current code like you said. But it was easily fix moving the connect function to the initialize function and removing the disconnect entierly. 

Should we use your implementation instead ? Would we see bugs with our solution ? 
It seems to me that using only initialize lifecycle is more coherent for a permanent component.


smnandre: Hi @codisart ! 

I changed the implementation to avoid playing with timeouts/delays. 

Patch:
diff --git a/src/Autocomplete/CHANGELOG.md b/src/Autocomplete/CHANGELOG.md
index 1237b13d..b869da8e 100644
--- a/src/Autocomplete/CHANGELOG.md
+++ b/src/Autocomplete/CHANGELOG.md
@@ -1,10 +1,5 @@
 # CHANGELOG
 
-## 2.21.0
-
--   Translate the `option_create` option from TomSelect with remote data setup #2279
--   Add one missing Dutch translation #2279
-
 ## 2.20.0
 
 -   Translate the `option_create` option from TomSelect #2108
diff --git a/src/Autocomplete/assets/dist/controller.js b/src/Autocomplete/assets/dist/controller.js
index 7d9d148d..c6e9660b 100644
--- a/src/Autocomplete/assets/dist/controller.js
+++ b/src/Autocomplete/assets/dist/controller.js
@@ -318,7 +318,7 @@ _default_1_instances = new WeakSet(), _default_1_getCommonConfig = function _def
                 return `<div class="no-results">${this.noResultsFoundTextValue}</div>`;
             },
             option_create: (data, escapeData) => {
-                return `<div class="create">${this.createOptionTextValue.replace('%placeholder%', `<strong>${escapeData(data.input)}</strong>`)}</div>`;
+                return `<div class="create">${this.createOptionTextValue} <strong>${escapeData(data.input)}</strong>&hellip;</div>`;
             },
         },
         preload: this.preload,
diff --git a/src/Autocomplete/assets/src/controller.ts b/src/Autocomplete/assets/src/controller.ts
index 62fe734e..af7f096a 100644
--- a/src/Autocomplete/assets/src/controller.ts
+++ b/src/Autocomplete/assets/src/controller.ts
@@ -253,7 +253,7 @@ export default class extends Controller {
                     return `<div class="no-results">${this.noResultsFoundTextValue}</div>`;
                 },
                 option_create: (data: TomOption, escapeData: typeof escape_html): string => {
-                    return `<div class="create">${this.createOptionTextValue.replace('%placeholder%', `<strong>${escapeData(data.input)}</strong>`)}</div>`;
+                    return `<div class="create">${this.createOptionTextValue} <strong>${escapeData(data.input)}</strong>&hellip;</div>`;
                 },
             },
             preload: this.preload,
diff --git a/src/Autocomplete/translations/AutocompleteBundle.de.php b/src/Autocomplete/translations/AutocompleteBundle.de.php
index 107fbae1..fb5729ba 100644
--- a/src/Autocomplete/translations/AutocompleteBundle.de.php
+++ b/src/Autocomplete/translations/AutocompleteBundle.de.php
@@ -13,5 +13,5 @@ return [
     'Loading more results...' => 'Lade weitere Ergebnisse...',
     'No results found' => 'Keine Übereinstimmungen gefunden',
     'No more results' => 'Keine weiteren Ergebnisse',
-    'Add %placeholder%...' => '%placeholder% hinzufügen...',
+    // 'Add %placeholder%...' => 'Add %placeholder%...',
 ];
diff --git a/src/Autocomplete/translations/AutocompleteBundle.nl.php b/src/Autocomplete/translations/AutocompleteBundle.nl.php
index 106be824..d9a2a90c 100644
--- a/src/Autocomplete/translations/AutocompleteBundle.nl.php
+++ b/src/Autocomplete/translations/AutocompleteBundle.nl.php
@@ -13,5 +13,5 @@ return [
     'Loading more results...' => 'Meer resultaten aan het laden...',
     'No results found' => 'Geen resultaten gevonden…',
     'No more results' => 'Niet meer resultaten gevonden…',
-    'Add %placeholder%...' => 'Voeg %placeholder% toe...',
+    // 'Add %placeholder%...' => 'Add %placeholder%...',
 ];
diff --git a/src/Icons/src/Command/LockIconsCommand.php b/src/Icons/src/Command/LockIconsCommand.php
index e452a7d7..c58a258f 100644
--- a/src/Icons/src/Command/LockIconsCommand.php
+++ b/src/Icons/src/Command/LockIconsCommand.php
@@ -72,21 +72,10 @@ final class LockIconsCommand extends Command
 
             [$prefix, $name] = $parts;
 
-            if (!$this->iconify->hasIconSet($prefix)) {
-                // not an icon set? example: "og:twitter"
-                if ($io->isVeryVerbose()) {
-                    $io->writeln(\sprintf(' <fg=bright-yellow;options=bold>✗</> IconSet Not Found: <fg=bright-white;bg=black>%s:%s</>.', $prefix, $name));
-                }
-                continue;
-            }
-
             try {
                 $svg = $this->iconify->fetchSvg($prefix, $name);
             } catch (IconNotFoundException) {
                 // icon not found on iconify
-                if ($io->isVerbose()) {
-                    $io->writeln(\sprintf(' <fg=bright-red;options=bold>✗</> Icon Not Found: <fg=bright-white;bg=black>%s:%s</>.', $prefix, $name));
-                }
                 continue;
             }
 
@@ -95,7 +84,7 @@ final class LockIconsCommand extends Command
             $license = $this->iconify->metadataFor($prefix)['license'];
             ++$count;
 
-            $io->writeln(\sprintf(
+            $io->text(\sprintf(
                 " <fg=bright-green;options=bold>✓</> Imported <fg=bright-white;bg=black>%s:</><fg=bright-magenta;bg=black;options>%s</> (License: <href=%s>%s</>). Render with: <comment>{{ ux_icon('%s') }}</comment>",
                 $prefix,
                 $name,
diff --git a/src/Icons/src/Iconify.php b/src/Icons/src/Iconify.php
index c948e24d..d9d3fa15 100644
--- a/src/Icons/src/Iconify.php
+++ b/src/Icons/src/Iconify.php
@@ -55,10 +55,6 @@ final class Iconify
 
         $response = $this->http->request('GET', \sprintf('/%s.json?icons=%s', $prefix, $name));
 
-        if (200 !== $response->getStatusCode()) {
-            throw new IconNotFoundException(\sprintf('The icon "%s:%s" does not exist on iconify.design.', $prefix, $name));
-        }
-
         try {
             $data = $response->toArray();
         } catch (JsonException) {
@@ -91,22 +87,16 @@ final class Iconify
             throw new IconNotFoundException(\sprintf('The icon "%s:%s" does not exist on iconify.design.', $prefix, $name));
         }
 
-        $response = $this->http->request('GET', \sprintf('/%s/%s.svg', $prefix, $name));
+        $content = $this->http
+            ->request('GET', \sprintf('/%s/%s.svg', $prefix, $name))
+            ->getContent()
+        ;
 
-        if (200 !== $response->getStatusCode()) {
+        if (!str_starts_with($content, '<svg')) {
             throw new IconNotFoundException(\sprintf('The icon "%s:%s" does not exist on iconify.design.', $prefix, $name));
         }
 
-        if (!str_starts_with($svg = $response->getContent(), '<svg')) {
-            throw new IconNotFoundException(\sprintf('The icon "%s:%s" does not exist on iconify.design.', $prefix, $name));
-        }
-
-        return $svg;
-    }
-
-    public function hasIconSet(string $prefix): bool
-    {
-        return isset($this->sets()[$prefix]);
+        return $content;
     }
 
     public function getIconSets(): array
diff --git a/src/React/CHANGELOG.md b/src/React/CHANGELOG.md
index 77b48ddc..c9c5c066 100644
--- a/src/React/CHANGELOG.md
+++ b/src/React/CHANGELOG.md
@@ -1,5 +1,10 @@
 # CHANGELOG
 
+## 2.21.0
+
+-   Add `permanent` option to the `react_component` Twig function, to prevent the 
+    _unmounting_ when the component is deconnected and immediately re-connected. 
+
 ## 2.13.2
 
 -   Revert "Change JavaScript package to `type: module`"
diff --git a/src/React/assets/dist/render_controller.d.ts b/src/React/assets/dist/render_controller.d.ts
index fc7b1dd4..8f6dbd18 100644
--- a/src/React/assets/dist/render_controller.d.ts
+++ b/src/React/assets/dist/render_controller.d.ts
@@ -3,9 +3,14 @@ import { Controller } from '@hotwired/stimulus';
 export default class extends Controller {
     readonly componentValue?: string;
     readonly propsValue?: object;
+    readonly permanentValue: boolean;
     static values: {
         component: StringConstructor;
         props: ObjectConstructor;
+        permanent: {
+            type: BooleanConstructor;
+            default: boolean;
+        };
     };
     connect(): void;
     disconnect(): void;
diff --git a/src/React/assets/dist/render_controller.js b/src/React/assets/dist/render_controller.js
index 5b8f9fb8..c66a3204 100644
--- a/src/React/assets/dist/render_controller.js
+++ b/src/React/assets/dist/render_controller.js
@@ -54,6 +54,9 @@ class default_1 extends Controller {
         });
     }
     disconnect() {
+        if (this.permanentValue) {
+            return;
+        }
         this.element.root.unmount();
         this.dispatchEvent('unmount', {
             component: this.componentValue,
@@ -74,6 +77,7 @@ class default_1 extends Controller {
 default_1.values = {
     component: String,
     props: Object,
+    permanent: { type: Boolean, default: false },
 };
 
 export { default_1 as default };
diff --git a/src/React/assets/src/render_controller.ts b/src/React/assets/src/render_controller.ts
index 595b5a6a..b35d0499 100644
--- a/src/React/assets/src/render_controller.ts
+++ b/src/React/assets/src/render_controller.ts
@@ -14,17 +14,17 @@ import { Controller } from '@hotwired/stimulus';
 export default class extends Controller {
     declare readonly componentValue?: string;
     declare readonly propsValue?: object;
+    declare readonly permanentValue: boolean;
 
     static values = {
         component: String,
         props: Object,
+        permanent: { type: Boolean, default: false },
     };
 
     connect() {
         const props = this.propsValue ? this.propsValue : null;
-
         this.dispatchEvent('connect', { component: this.componentValue, props: props });
-
         if (!this.componentValue) {
             throw new Error('No component specified.');
         }
@@ -40,6 +40,12 @@ export default class extends Controller {
     }
 
     disconnect() {
+        if (this.permanentValue) {
+            // Prevent unmounting the component if the controller is permanent
+            // (no render is allowed after unmounting)
+            return;
+        }
+
         (this.element as any).root.unmount();
         this.dispatchEvent('unmount', {
             component: this.componentValue,
diff --git a/src/React/doc/index.rst b/src/React/doc/index.rst
index 06a2b285..96e16e0a 100644
--- a/src/React/doc/index.rst
+++ b/src/React/doc/index.rst
@@ -2,12 +2,14 @@ Symfony UX React
 ================
 
 Symfony UX React is a Symfony bundle integrating `React`_ in
-Symfony applications. It is part of `the Symfony UX initiative`_.
+Symfony applications. It is part of the `Symfony UX initiative`_.
 
 React is a JavaScript library for building user interfaces.
 Symfony UX React provides tools to render React components from Twig,
 handling rendering and data transfers.
 
+You can see a live example of this integration on the `Symfony UX React demo`_.
+
 Symfony UX React supports React 18+.
 
 Installation
@@ -41,6 +43,9 @@ React components.
 Usage
 -----
 
+Register components
+~~~~~~~~~~~~~~~~~~~
+
 The Flex recipe will have already added the ``registerReactControllerComponents()``
 code to your ``assets/app.js`` file:
 
@@ -55,7 +60,11 @@ This will load all React components located in the ``assets/react/controllers``
 directory. These are known as **React controller components**: top-level
 components that are meant to be rendered from Twig.
 
-You can render any React controller component in Twig using the ``react_component()``.
+Render in Twig
+~~~~~~~~~~~~~~
+
+You can render any React controller component in your Twig templates, using the
+``react_component()`` function.
 
 For example:
 
@@ -82,6 +91,31 @@ For example:
         <div {{ react_component('Admin/OtherComponent') }}></div>
     {% endblock %}
 
+Permanent components
+~~~~~~~~~~~~~~~~~~~~
+
+.. versionadded:: 2.21
+
+    The ability to mark a component ``permanent`` was added in UX React 2.21.
+
+The controller responsible to render the React components can be configured
+to keep the React component mounted when the root element is removed from 
+the DOM, using the ``permanent`` option.
+
+This is particularly useful when the root element of a component is moved around
+in the DOM  or is removed and immediately re-added to the DOM (e.g. when using 
+`Turbo`_ and its `data-turbo-permanent` attribute).
+
+.. code-block:: html+twig
+
+    {# templates/home.html.twig #}
+    {% extends 'base.html.twig' %}
+    
+    {# The React component will stay mounted if the div is moved in the DOM #}
+    <div {{ react_component('Hello', {fullName: 'Fabien'}, {permanent: true}) }}>
+         Loading...  
+    </div>
+
 .. _using-with-asset-mapper:
 
 Using with AssetMapper
@@ -119,4 +153,6 @@ the Symfony framework:
 https://symfony.com/doc/current/contributing/code/bc.html
 
 .. _`React`: https://reactjs.org/
-.. _`the Symfony UX initiative`: https://ux.symfony.com/
+.. _`Symfony UX initiative`: https://ux.symfony.com/
+.. _`Symfony UX React demo`: https://ux.symfony.com/react
+:: _`Turbo`: https://turbo.hotwire.dev/
diff --git a/src/React/src/Twig/ReactComponentExtension.php b/src/React/src/Twig/ReactComponentExtension.php
index aabbbd9e..9780df31 100644
--- a/src/React/src/Twig/ReactComponentExtension.php
+++ b/src/React/src/Twig/ReactComponentExtension.php
@@ -45,15 +45,24 @@ class ReactComponentExtension extends AbstractExtension
         ];
     }
 
-    public function renderReactComponent(string $componentName, array $props = []): string
+    /**
+     * @param array<string, mixed>    $props
+     * @param array{permanent?: bool} $options
+     */
+    public function renderReactComponent(string $componentName, array $props = [], array $options = []): string
     {
-        $params = ['component' => $componentName];
+        $values = ['component' => $componentName];
         if ($props) {
-            $params['props'] = $props;
+            $values['props'] = $props;
+        }
+        if ($options) {
+            if (\is_bool($permanent = $options['permanent'] ?? null)) {
+                $values['permanent'] = $permanent;
+            }
         }
 
         $stimulusAttributes = $this->stimulusHelper->createStimulusAttributes();
-        $stimulusAttributes->addController('@symfony/ux-react/react', $params);
+        $stimulusAttributes->addController('@symfony/ux-react/react', $values);
 
         return (string) $stimulusAttributes;
     }


Test patch:
diff --git a/src/Icons/tests/Unit/IconifyTest.php b/src/Icons/tests/Unit/IconifyTest.php
index a3aa50bf..f61abf3a 100644
--- a/src/Icons/tests/Unit/IconifyTest.php
+++ b/src/Icons/tests/Unit/IconifyTest.php
@@ -144,25 +144,6 @@ class IconifyTest extends TestCase
         $iconify->fetchIcon('bi', 'heart');
     }
 
-    public function testFetchIconThrowsWhenStatusCodeNot200(): void
-    {
-        $iconify = new Iconify(
-            cache: new NullAdapter(),
-            endpoint: 'https://example.com',
-            http: new MockHttpClient([
-                new JsonMockResponse([
-                    'bi' => [],
-                ]),
-                new JsonMockResponse([], ['http_code' => 404]),
-            ]),
-        );
-
-        $this->expectException(IconNotFoundException::class);
-        $this->expectExceptionMessage('The icon "bi:heart" does not exist on iconify.design.');
-
-        $iconify->fetchIcon('bi', 'heart');
-    }
-
     public function testGetMetadata(): void
     {
         $responseFile = __DIR__.'/../Fixtures/Iconify/collections.json';
@@ -189,21 +170,6 @@ class IconifyTest extends TestCase
         $this->stringContains('-.224l.235-.468ZM6.013 2.06c-.649-.1', $svg);
     }
 
-    public function testFetchSvgThrowIconNotFoundExceptionWhenStatusCodeNot200(): void
-    {
-        $client = new MockHttpClient([
-            new MockResponse(file_get_contents(__DIR__.'/../Fixtures/Iconify/collections.json'), [
-                'response_headers' => ['content-type' => 'application/json'],
-            ]),
-            new MockResponse('', ['http_code' => 404]),
-        ]);
-        $iconify = new Iconify(new NullAdapter(), 'https://localhost', $client);
-
-        $this->expectException(IconNotFoundException::class);
-
-        $iconify->fetchSvg('fa6-regular', 'bar');
-    }
-
     private function createHttpClient(mixed $data, int $code = 200): MockHttpClient
     {
         $mockResponse = new JsonMockResponse($data, ['http_code' => $code]);
diff --git a/src/React/tests/Twig/ReactComponentExtensionTest.php b/src/React/tests/Twig/ReactComponentExtensionTest.php
index 9fbf8c8b..a522572f 100644
--- a/src/React/tests/Twig/ReactComponentExtensionTest.php
+++ b/src/React/tests/Twig/ReactComponentExtensionTest.php
@@ -9,7 +9,7 @@
  * file that was distributed with this source code.
  */
 
-namespace Symfony\UX\React\Tests;
+namespace Symfony\UX\React\Tests\Twig;
 
 use PHPUnit\Framework\TestCase;
 use Symfony\UX\React\Tests\Kernel\TwigAppKernel;
@@ -41,6 +41,39 @@ class ReactComponentExtensionTest extends TestCase
         );
     }
 
+    /**
+     * @dataProvider provideOptions
+     */
+    public function testRenderComponentWithOptions(array $options, string|false $expected)
+    {
+        $kernel = new TwigAppKernel('test', true);
+        $kernel->boot();
+
+        /** @var ReactComponentExtension $extension */
+        $extension = $kernel->getContainer()->get('test.twig.extension.react');
+
+        $rendered = $extension->renderReactComponent(
+            'SubDir/MyComponent',
+            ['fullName' => 'Titouan Galopin'],
+            $options,
+        );
+
+        $this->assertStringContainsString('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);
+        if (false === $expected) {
+            $this->assertStringNotContainsString('data-symfony--ux-react--react-permanent-value', $rendered);
+        } else {
+            $this->assertStringContainsString($expected, $rendered);
+        }
+    }
+
+    public static function provideOptions(): iterable
+    {
+        yield 'permanent' => [['permanent' => true], 'data-symfony--ux-react--react-permanent-value="true"'];
+        yield 'not permanent' => [['permanent' => false], 'data-symfony--ux-react--react-permanent-value="false"'];
+        yield 'permanent not bool' => [['permanent' => 12345], false];
+        yield 'no permanent' => [[], false];
+    }
+
     public function testRenderComponentWithoutProps()
     {
         $kernel = new TwigAppKernel('test', true);