Skip to content

Commit b255914

Browse files
committed
feature #2283 [React] Add permanent option to react_component function (smnandre)
This PR was squashed before being merged into the 2.x branch. Discussion ---------- [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) Commits ------- 4d60724 [React] Add `permanent` option to `react_component` function
2 parents 7803fba + 4d60724 commit b255914

File tree

7 files changed

+108
-10
lines changed

7 files changed

+108
-10
lines changed

src/React/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# CHANGELOG
22

3+
## 2.21.0
4+
5+
- Add `permanent` option to the `react_component` Twig function, to prevent the
6+
_unmounting_ when the component is deconnected and immediately re-connected.
7+
38
## 2.13.2
49

510
- Revert "Change JavaScript package to `type: module`"

src/React/assets/dist/render_controller.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,14 @@ import { Controller } from '@hotwired/stimulus';
33
export default class extends Controller {
44
readonly componentValue?: string;
55
readonly propsValue?: object;
6+
readonly permanentValue: boolean;
67
static values: {
78
component: StringConstructor;
89
props: ObjectConstructor;
10+
permanent: {
11+
type: BooleanConstructor;
12+
default: boolean;
13+
};
914
};
1015
connect(): void;
1116
disconnect(): void;

src/React/assets/dist/render_controller.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ class default_1 extends Controller {
5454
});
5555
}
5656
disconnect() {
57+
if (this.permanentValue) {
58+
return;
59+
}
5760
this.element.root.unmount();
5861
this.dispatchEvent('unmount', {
5962
component: this.componentValue,
@@ -74,6 +77,7 @@ class default_1 extends Controller {
7477
default_1.values = {
7578
component: String,
7679
props: Object,
80+
permanent: { type: Boolean, default: false },
7781
};
7882

7983
export { default_1 as default };

src/React/assets/src/render_controller.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,17 +14,17 @@ import { Controller } from '@hotwired/stimulus';
1414
export default class extends Controller {
1515
declare readonly componentValue?: string;
1616
declare readonly propsValue?: object;
17+
declare readonly permanentValue: boolean;
1718

1819
static values = {
1920
component: String,
2021
props: Object,
22+
permanent: { type: Boolean, default: false },
2123
};
2224

2325
connect() {
2426
const props = this.propsValue ? this.propsValue : null;
25-
2627
this.dispatchEvent('connect', { component: this.componentValue, props: props });
27-
2828
if (!this.componentValue) {
2929
throw new Error('No component specified.');
3030
}
@@ -40,6 +40,12 @@ export default class extends Controller {
4040
}
4141

4242
disconnect() {
43+
if (this.permanentValue) {
44+
// Prevent unmounting the component if the controller is permanent
45+
// (no render is allowed after unmounting)
46+
return;
47+
}
48+
4349
(this.element as any).root.unmount();
4450
this.dispatchEvent('unmount', {
4551
component: this.componentValue,

src/React/doc/index.rst

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@ Symfony UX React
22
================
33

44
Symfony UX React is a Symfony bundle integrating `React`_ in
5-
Symfony applications. It is part of `the Symfony UX initiative`_.
5+
Symfony applications. It is part of the `Symfony UX initiative`_.
66

77
React is a JavaScript library for building user interfaces.
88
Symfony UX React provides tools to render React components from Twig,
99
handling rendering and data transfers.
1010

11+
You can see a live example of this integration on the `Symfony UX React demo`_.
12+
1113
Symfony UX React supports React 18+.
1214

1315
Installation
@@ -41,6 +43,9 @@ React components.
4143
Usage
4244
-----
4345

46+
Register components
47+
~~~~~~~~~~~~~~~~~~~
48+
4449
The Flex recipe will have already added the ``registerReactControllerComponents()``
4550
code to your ``assets/app.js`` file:
4651

@@ -55,7 +60,11 @@ This will load all React components located in the ``assets/react/controllers``
5560
directory. These are known as **React controller components**: top-level
5661
components that are meant to be rendered from Twig.
5762

58-
You can render any React controller component in Twig using the ``react_component()``.
63+
Render in Twig
64+
~~~~~~~~~~~~~~
65+
66+
You can render any React controller component in your Twig templates, using the
67+
``react_component()`` function.
5968

6069
For example:
6170

@@ -82,6 +91,31 @@ For example:
8291
<div {{ react_component('Admin/OtherComponent') }}></div>
8392
{% endblock %}
8493

94+
Permanent components
95+
~~~~~~~~~~~~~~~~~~~~
96+
97+
.. versionadded:: 2.21
98+
99+
The ability to mark a component ``permanent`` was added in UX React 2.21.
100+
101+
The controller responsible to render the React components can be configured
102+
to keep the React component mounted when the root element is removed from
103+
the DOM, using the ``permanent`` option.
104+
105+
This is particularly useful when the root element of a component is moved around
106+
in the DOM or is removed and immediately re-added to the DOM (e.g. when using
107+
`Turbo`_ and its `data-turbo-permanent` attribute).
108+
109+
.. code-block:: html+twig
110+
111+
{# templates/home.html.twig #}
112+
{% extends 'base.html.twig' %}
113+
114+
{# The React component will stay mounted if the div is moved in the DOM #}
115+
<div {{ react_component('Hello', {fullName: 'Fabien'}, {permanent: true}) }}>
116+
Loading...
117+
</div>
118+
85119
.. _using-with-asset-mapper:
86120

87121
Using with AssetMapper
@@ -119,4 +153,6 @@ the Symfony framework:
119153
https://symfony.com/doc/current/contributing/code/bc.html
120154

121155
.. _`React`: https://reactjs.org/
122-
.. _`the Symfony UX initiative`: https://ux.symfony.com/
156+
.. _`Symfony UX initiative`: https://ux.symfony.com/
157+
.. _`Symfony UX React demo`: https://ux.symfony.com/react
158+
:: _`Turbo`: https://turbo.hotwire.dev/

src/React/src/Twig/ReactComponentExtension.php

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,15 +45,24 @@ public function getFunctions(): array
4545
];
4646
}
4747

48-
public function renderReactComponent(string $componentName, array $props = []): string
48+
/**
49+
* @param array<string, mixed> $props
50+
* @param array{permanent?: bool} $options
51+
*/
52+
public function renderReactComponent(string $componentName, array $props = [], array $options = []): string
4953
{
50-
$params = ['component' => $componentName];
54+
$values = ['component' => $componentName];
5155
if ($props) {
52-
$params['props'] = $props;
56+
$values['props'] = $props;
57+
}
58+
if ($options) {
59+
if (\is_bool($permanent = $options['permanent'] ?? null)) {
60+
$values['permanent'] = $permanent;
61+
}
5362
}
5463

5564
$stimulusAttributes = $this->stimulusHelper->createStimulusAttributes();
56-
$stimulusAttributes->addController('@symfony/ux-react/react', $params);
65+
$stimulusAttributes->addController('@symfony/ux-react/react', $values);
5766

5867
return (string) $stimulusAttributes;
5968
}

src/React/tests/Twig/ReactComponentExtensionTest.php

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
* file that was distributed with this source code.
1010
*/
1111

12-
namespace Symfony\UX\React\Tests;
12+
namespace Symfony\UX\React\Tests\Twig;
1313

1414
use PHPUnit\Framework\TestCase;
1515
use Symfony\UX\React\Tests\Kernel\TwigAppKernel;
@@ -41,6 +41,39 @@ public function testRenderComponent()
4141
);
4242
}
4343

44+
/**
45+
* @dataProvider provideOptions
46+
*/
47+
public function testRenderComponentWithOptions(array $options, string|false $expected)
48+
{
49+
$kernel = new TwigAppKernel('test', true);
50+
$kernel->boot();
51+
52+
/** @var ReactComponentExtension $extension */
53+
$extension = $kernel->getContainer()->get('test.twig.extension.react');
54+
55+
$rendered = $extension->renderReactComponent(
56+
'SubDir/MyComponent',
57+
['fullName' => 'Titouan Galopin'],
58+
$options,
59+
);
60+
61+
$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);
62+
if (false === $expected) {
63+
$this->assertStringNotContainsString('data-symfony--ux-react--react-permanent-value', $rendered);
64+
} else {
65+
$this->assertStringContainsString($expected, $rendered);
66+
}
67+
}
68+
69+
public static function provideOptions(): iterable
70+
{
71+
yield 'permanent' => [['permanent' => true], 'data-symfony--ux-react--react-permanent-value="true"'];
72+
yield 'not permanent' => [['permanent' => false], 'data-symfony--ux-react--react-permanent-value="false"'];
73+
yield 'permanent not bool' => [['permanent' => 12345], false];
74+
yield 'no permanent' => [[], false];
75+
}
76+
4477
public function testRenderComponentWithoutProps()
4578
{
4679
$kernel = new TwigAppKernel('test', true);

0 commit comments

Comments
 (0)