Repository: symfony/ux
Pull request #2227 (2024-10-01 17:32:55)

Add `<twig:Turbo:Stream:*>` components
| Q             | A
| ------------- | ---
| Bug fix?      | no
| New feature?  | yes
| Issues        | -
| License       | MIT

This PR provides Twig components for turbo-streams:

```html
<twig:Turbo:Stream:Append target="#some-id">
    <div>Append this</div>
</twig:Turbo:Stream:Append>
```

Sibling PR to #2196
See https://github.com/symfony/ux/pull/2196#issuecomment-2386444070 for some background.

neluba: I don't know if the symfony phpstorm plugin works with third party components, but it might incorrectly offer autocompletions for all properties on all components, because they're inherited from TurboStreamComponent. 
nicolas-grekas: I figured out I could use anonymous twig components, so here we are. Tags are now like:

```html
<twig:Turbo:Stream:Append target="#some-id">
    <div>Append this</div>
</twig:Turbo:Stream:Append>
```
rvmourik: > I figured out I could use anonymous twig components, so here we are. Tags are now like:
> 
> ```
> <twig:Turbo:Stream:Append target="#some-id">
>     <div>Append this</div>
> </twig:Turbo:Stream:Append>
> ```

Is there a specific reason why the attribute is called `target` and in the HTML using `targets`?
nicolas-grekas: > Is there a specific reason why the attribute is called target and in the HTML using targets?

Good question. Turbo accepts both target and targets. The former accepts an HTML id, the latter a CSS selector.
IMHO this is provides bad DX. Using a CSS selector should be the only way: when reading `#id`, it's way clearer that we target an id, and also it's hinting that another CSS selector would work.

I'm for not supporting Turbo's target attribute.
Then, which name should we give to the CSS-selector attribute on our side? `target` looks the most sensible to me.
We could name it `targets`, but the "s" feels unnecessary.
rvmourik: > > Is there a specific reason why the attribute is called target and in the HTML using targets?
> 
> Good question. Turbo accepts both target and targets. The former accepts an HTML id, the latter a CSS selector. IMHO this is provides bad DX. Using a CSS selector should be the only way: when reading `#id`, it's way clearer that we target an id, and also it's hinting that another CSS selector would work.
> 
> I'm for not supporting Turbo's target attribute. Then, which name should we give to the CSS-selector attribute on our side? `target` looks the most sensible to me. We could name it `targets`, but the "s" feels unnecessary.

I think `target` is the way to go, but maybe elaborate on the target/targets difference in the docs so people don't mistake the target attribute of the component with the HTML target attribute.

Nevertheless, great DX improvements with the two PR's 👍
nicolas-grekas: All green :)

Patch:
diff --git a/src/Turbo/CHANGELOG.md b/src/Turbo/CHANGELOG.md
index d3e4d1c9..df89967e 100644
--- a/src/Turbo/CHANGELOG.md
+++ b/src/Turbo/CHANGELOG.md
@@ -4,6 +4,7 @@
 
 -   Add `Helper/TurboStream::append()` et al. methods
 -   Add `TurboStreamResponse`
+-   Add `<twig:Turbo:Stream:*>` components
 
 ## 2.19.0
 
diff --git a/src/Turbo/composer.json b/src/Turbo/composer.json
index 46acc1f5..e3cb4651 100644
--- a/src/Turbo/composer.json
+++ b/src/Turbo/composer.json
@@ -52,6 +52,7 @@
         "symfony/property-access": "^5.4|^6.0|^7.0",
         "symfony/security-core": "^5.4|^6.0|^7.0",
         "symfony/stopwatch": "^5.4|^6.0|^7.0",
+        "symfony/ux-twig-component": "^2.21",
         "symfony/twig-bundle": "^5.4|^6.0|^7.0",
         "symfony/web-profiler-bundle": "^5.4|^6.0|^7.0",
         "symfony/webpack-encore-bundle": "^2.1.1",
diff --git a/src/Turbo/templates/components/Stream/After.html.twig b/src/Turbo/templates/components/Stream/After.html.twig
new file mode 100644
index 00000000..3105a376
--- /dev/null
+++ b/src/Turbo/templates/components/Stream/After.html.twig
@@ -0,0 +1,5 @@
+{% props target -%}
+
+<turbo-stream action="after" targets="{{ target }}" {{ attributes }}>
+    <template>{% block content %}{% endblock %}</template>
+</turbo-stream>
diff --git a/src/Turbo/templates/components/Stream/Append.html.twig b/src/Turbo/templates/components/Stream/Append.html.twig
new file mode 100644
index 00000000..aea22f28
--- /dev/null
+++ b/src/Turbo/templates/components/Stream/Append.html.twig
@@ -0,0 +1,5 @@
+{% props target -%}
+
+<turbo-stream action="append" targets="{{ target }}" {{ attributes }}>
+    <template>{% block content %}{% endblock %}</template>
+</turbo-stream>
diff --git a/src/Turbo/templates/components/Stream/Before.html.twig b/src/Turbo/templates/components/Stream/Before.html.twig
new file mode 100644
index 00000000..3ffdeded
--- /dev/null
+++ b/src/Turbo/templates/components/Stream/Before.html.twig
@@ -0,0 +1,5 @@
+{% props target -%}
+
+<turbo-stream action="before" targets="{{ target }}" {{ attributes }}>
+    <template>{% block content %}{% endblock %}</template>
+</turbo-stream>
diff --git a/src/Turbo/templates/components/Stream/Prepend.html.twig b/src/Turbo/templates/components/Stream/Prepend.html.twig
new file mode 100644
index 00000000..b75d3534
--- /dev/null
+++ b/src/Turbo/templates/components/Stream/Prepend.html.twig
@@ -0,0 +1,5 @@
+{% props target -%}
+
+<turbo-stream action="prepend" targets="{{ target }}" {{ attributes }}>
+    <template>{% block content %}{% endblock %}</template>
+</turbo-stream>
diff --git a/src/Turbo/templates/components/Stream/Refresh.html.twig b/src/Turbo/templates/components/Stream/Refresh.html.twig
new file mode 100644
index 00000000..182129e6
--- /dev/null
+++ b/src/Turbo/templates/components/Stream/Refresh.html.twig
@@ -0,0 +1,3 @@
+{% props requestId = null -%}
+
+<turbo-stream action="refresh"{% if requestId is not null %} request-id="{{ requestId }}"{% endif %} {{ attributes }}></turbo-stream>
diff --git a/src/Turbo/templates/components/Stream/Remove.html.twig b/src/Turbo/templates/components/Stream/Remove.html.twig
new file mode 100644
index 00000000..9e3c0234
--- /dev/null
+++ b/src/Turbo/templates/components/Stream/Remove.html.twig
@@ -0,0 +1,3 @@
+{% props target -%}
+
+<turbo-stream action="remove" targets="{{ target }}" {{ attributes }}></turbo-stream>
diff --git a/src/Turbo/templates/components/Stream/Replace.html.twig b/src/Turbo/templates/components/Stream/Replace.html.twig
new file mode 100644
index 00000000..d11598f6
--- /dev/null
+++ b/src/Turbo/templates/components/Stream/Replace.html.twig
@@ -0,0 +1,5 @@
+{% props target, morph = false -%}
+
+<turbo-stream action="replace" targets="{{ target }}"{% if morph %} method="morph"{% endif %} {{ attributes }}>
+    <template>{% block content %}{% endblock %}</template>
+</turbo-stream>
diff --git a/src/Turbo/templates/components/Stream/Update.html.twig b/src/Turbo/templates/components/Stream/Update.html.twig
new file mode 100644
index 00000000..f15fbfc0
--- /dev/null
+++ b/src/Turbo/templates/components/Stream/Update.html.twig
@@ -0,0 +1,5 @@
+{% props target, morph = false -%}
+
+<turbo-stream action="update" targets="{{ target }}"{% if morph %} method="morph"{% endif %} {{ attributes }}>
+    <template>{% block content %}{% endblock %}</template>
+</turbo-stream>


Test patch:
diff --git a/.github/workflows/test-turbo.yml b/.github/workflows/test-turbo.yml
index fb2b145e..47008d26 100644
--- a/.github/workflows/test-turbo.yml
+++ b/.github/workflows/test-turbo.yml
@@ -8,6 +8,9 @@ on:
         paths:
             - 'src/Turbo/**'
 
+env:
+    SYMFONY_REQUIRE: '>=5.4'
+
 jobs:
     phpstan:
         runs-on: ubuntu-latest
@@ -38,11 +41,11 @@ jobs:
         strategy:
            fail-fast: false
            matrix:
-               php-version: ['8.1', '8.3']
+               php-version: ['8.1', '8.4']
                include:
                   - php-version: '8.1'
                     dependency-version: 'lowest'
-                  - php-version: '8.3'
+                  - php-version: '8.4'
                     dependency-version: 'highest'
 
         services:
@@ -69,6 +72,9 @@ jobs:
               with:
                   php-version: ${{ matrix.php-version }}
 
+            - name: Install symfony/flex
+              run: composer global config allow-plugins.symfony/flex true && composer global require symfony/flex
+
             - name: Install Turbo packages
               uses: ramsey/composer-install@v3
               with:
@@ -101,6 +107,8 @@ jobs:
 
             - name: Run tests
               working-directory: src/Turbo
-              run: vendor/bin/simple-phpunit
+              run: |
+                [ 'lowest' = '${{ matrix.dependency-version }}' ] && export SYMFONY_DEPRECATIONS_HELPER=weak
+                vendor/bin/simple-phpunit
               env:
                   SYMFONY_DEPRECATIONS_HELPER: 'max[self]=1'
diff --git a/src/Turbo/tests/app/Kernel.php b/src/Turbo/tests/app/Kernel.php
index f61de905..5e259413 100644
--- a/src/Turbo/tests/app/Kernel.php
+++ b/src/Turbo/tests/app/Kernel.php
@@ -38,6 +38,7 @@ use Symfony\Component\Mercure\Update;
 use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator;
 use Symfony\UX\StimulusBundle\StimulusBundle;
 use Symfony\UX\Turbo\TurboBundle;
+use Symfony\UX\TwigComponent\TwigComponentBundle;
 use Symfony\WebpackEncoreBundle\WebpackEncoreBundle;
 use Twig\Environment;
 
@@ -54,6 +55,7 @@ class Kernel extends BaseKernel
         yield new DoctrineBundle();
         yield new TwigBundle();
         yield new MercureBundle();
+        yield new TwigComponentBundle();
         yield new TurboBundle();
         yield new WebpackEncoreBundle();
         yield new StimulusBundle();
@@ -120,6 +122,11 @@ class Kernel extends BaseKernel
                 ],
             ],
         ]);
+
+        $container->extension('twig_component', [
+            'anonymous_template_directory' => 'components/',
+            'defaults' => ['App\Twig\Components\\' => 'components/'],
+        ]);
     }
 
     protected function configureRoutes(RoutingConfigurator $routes): void
diff --git a/src/Turbo/tests/app/templates/broadcast/Artist.stream.html.twig b/src/Turbo/tests/app/templates/broadcast/Artist.stream.html.twig
index 7d7d7bd5..f975b3ca 100644
--- a/src/Turbo/tests/app/templates/broadcast/Artist.stream.html.twig
+++ b/src/Turbo/tests/app/templates/broadcast/Artist.stream.html.twig
@@ -1,19 +1,15 @@
 {% block create %}
-    <turbo-stream action="append" target="artists">
-        <template>
-            <div id="{{ 'artist_' ~ id }}"><a href="{{ path('artist', {id: id}) }}">{{ entity.name }} (#{{ id }})</a></div>
-        </template>
-    </turbo-stream>
+    <twig:Turbo:Stream:Append target="#artists">
+        <div id="artist_{{ id }}"><a href="{{ path('artist', {id: id}) }}">{{ entity.name }} (#{{ id }})</a></div>
+    </twig:Turbo:Stream:Append>
 {% endblock %}
 
 {% block update %}
-    <turbo-stream action="update" target="artist_{{ id }}">
-        <template>
-            {{ entity.name }} (#{{ id }}, updated)
-        </template>
-    </turbo-stream>
+    <twig:Turbo:Stream:Update target="#artist_{{ id }}">
+        {{ entity.name }} (#{{ id }}, updated)
+    </twig:Turbo:Stream:Update>
 {% endblock %}
 
 {% block remove %}
-    <turbo-stream action="remove" target="artist_{{ id }}"></turbo-stream>
+    <twig:Turbo:Stream:Remove target="#artist_{{ id }}"></twig:Turbo:Stream:Remove>
 {% endblock %}
diff --git a/src/Turbo/tests/app/templates/broadcast/Book.stream.html.twig b/src/Turbo/tests/app/templates/broadcast/Book.stream.html.twig
index 94b1d72a..1ed70a70 100644
--- a/src/Turbo/tests/app/templates/broadcast/Book.stream.html.twig
+++ b/src/Turbo/tests/app/templates/broadcast/Book.stream.html.twig
@@ -1,19 +1,15 @@
 {% block create %}
-    <turbo-stream action="append" target="books">
-        <template>
-            <div id="{{ 'book_' ~ id }}">{{ entity.title }} (#{{ id }})</div>
-        </template>
-    </turbo-stream>
+    <twig:Turbo:Stream:Append target="#books">
+        <div id="book_{{ id }}">{{ entity.title }} (#{{ id }})</div>
+    </twig:Turbo:Stream:Append>
 {% endblock %}
 
 {% block update %}
-    <turbo-stream action="update" target="book_{{ id }}">
-        <template>
-            {{ entity.title }} (#{{ id }}, updated)
-        </template>
-    </turbo-stream>
+    <twig:Turbo:Stream:Update target="#book_{{ id }}">
+        {{ entity.title }} (#{{ id }}, updated)
+    </twig:Turbo:Stream:Update>
 {% endblock %}
 
 {% block remove %}
-    <turbo-stream action="remove" target="book_{{ id }}"></turbo-stream>
+    <twig:Turbo:Stream:Remove target="#book_{{ id }}"></twig:Turbo:Stream:Remove>
 {% endblock %}
diff --git a/src/Turbo/tests/app/templates/broadcast/Song.stream.html.twig b/src/Turbo/tests/app/templates/broadcast/Song.stream.html.twig
index 14b283c4..f2719f3b 100644
--- a/src/Turbo/tests/app/templates/broadcast/Song.stream.html.twig
+++ b/src/Turbo/tests/app/templates/broadcast/Song.stream.html.twig
@@ -1,19 +1,15 @@
 {% block create %}
-    <turbo-stream action="append" target="songs">
-        <template>
-            <div id="{{ 'song_' ~ id }}">{{ entity.title }} (#{{ id }}){% if entity.artist %} by {{ entity.artist.name }} (#{{ entity.artist.id }}){% endif %}</div>
-        </template>
-    </turbo-stream>
+    <twig:Turbo:Stream:Append target="#songs">
+        <div id="song_{{ id }}">{{ entity.title }} (#{{ id }}){% if entity.artist %} by {{ entity.artist.name }} (#{{ entity.artist.id }}){% endif %}</div>
+    </twig:Turbo:Stream:Append>
 {% endblock %}
 
 {% block update %}
-    <turbo-stream action="update" target="song_{{ id }}">
-        <template>
-            {{ entity.title }} (#{{ id }}, updated)
-        </template>
-    </turbo-stream>
+    <twig:Turbo:Stream:Update target="#song_{{ id }}">
+        {{ entity.title }} (#{{ id }}, updated)
+    </twig:Turbo:Stream:Update>
 {% endblock %}
 
 {% block remove %}
-    <turbo-stream action="remove" target="song_{{ id }}"></turbo-stream>
+    <twig:Turbo:Stream:Remove target="#song_{{ id }}"></twig:Turbo:Stream:Remove>
 {% endblock %}
diff --git a/src/Turbo/tests/app/templates/chat/message.stream.html.twig b/src/Turbo/tests/app/templates/chat/message.stream.html.twig
index 00bffdd5..791a5dcc 100644
--- a/src/Turbo/tests/app/templates/chat/message.stream.html.twig
+++ b/src/Turbo/tests/app/templates/chat/message.stream.html.twig
@@ -1,5 +1,3 @@
-<turbo-stream action="append" target="messages">
-    <template>
-        <div>{{ message }}</div>
-    </template>
-</turbo-stream>
+<twig:Turbo:Stream:Append target="#messages">
+    <div>{{ message }}</div>
+</twig:Turbo:Stream:Append>
diff --git a/src/Turbo/tests/app/templates/form.stream.html.twig b/src/Turbo/tests/app/templates/form.stream.html.twig
index 043c31da..0c848d83 100644
--- a/src/Turbo/tests/app/templates/form.stream.html.twig
+++ b/src/Turbo/tests/app/templates/form.stream.html.twig
@@ -1,13 +1,9 @@
-<turbo-stream action="replace" target="form">
-    <template>
-        <div id="form">
-            This div replaces the existing element with the DOM ID "form".
-        </div>
-    </template>
-</turbo-stream>
+<twig:Turbo:Stream:Replace target="#form">
+    <div id="form">
+        This div replaces the existing element with the DOM ID "form".
+    </div>
+</twig:Turbo:Stream:Replace>
 
-<turbo-stream action="append" target="another_block">
-    <template>
-        <div>Appended!</div>
-    </template>
-</turbo-stream>
+<twig:Turbo:Stream:Append target="#another_block">
+    <div>Appended!</div>
+</twig:Turbo:Stream:Append>