Repository: symfony/ux (php)

Issue:
[Turbo][Mercure] Allow turbo_stream_listen() to subscribe multiple topics in single connection
Hi! First thanks for turbo and mercure integration, i am playing with it for last several days and it is awesome!

I am struggling with finding a way, how to subscribe to multiple topics using `turbo_stream_listen()` in twig and after some digging around, it seems that it is not currently supported.

I can do it this way, but i would love to avoid writing javascript in twig (as documented in https://symfony.com/doc/current/mercure.html#subscribing):
```twig
<script>
const eventSource = new EventSource("{{ mercure([
    'a',
    'b'
])|escape('js') }}");
</script>
```

But by doing this i am loosing a lot of comfort.

What i tried:
```twig
<div {{ turbo_stream_listen(['a', 'b']) }}></div>  {# Ends with error #}
<div {{ turbo_stream_listen('a,b']) }}></div>  {# Subscribes to single "a,b" topic #}
<div {{ turbo_stream_listen('a&topic=b') }}></div>  {# Subscribes to single "a&topic=b" topic #}
```

That `a&topic=b` variant was my favourite that it would work, but unfortunately it escapes the `&` so final URL is like this, which is why it is treated as single topic:
```
URL: http://localhost:8080/.well-known/mercure?topic=a%26topic%3Db
```

What i am actually trying to achieve is this hub url with single `turbo_stream_listem()`:
```
http://localhost:8080/.well-known/mercure?topic=a&topic=b
```

My current solution is to:
```twig
<div {{ turbo_stream_listen('a') }}></div>
<div {{ turbo_stream_listen('b') }}></div>
```
Which results into two connections to hub and i believe it would be more efficient to have one connection with two subscriptions.

I could probably solve this by writing my own stimulus controller (inspired by turbo_stream_controller.js).

My idea is to update `Symfony\UX\Turbo\Bridge\Mercure\TurboStreamListenRenderer`
```diff
public function renderTurboStreamListen(Environment $env, $topic): string
    {
+        if (\is_array($topic)) {
+            // .. some logic here :-)
+        }

         if (\is_object($topic)) {
```

Pull request description:
[Turbo] Add support for providing multiple mercure topics to `turbo_stream_listen`
| Q             | A
| ------------- | ---
| Bug fix?      | no
| New feature?  | yes
| Issues        | Fix #213
| License       | MIT



Patch:
diff --git a/src/Turbo/CHANGELOG.md b/src/Turbo/CHANGELOG.md
index 012b15f2..5a0f7d0f 100644
--- a/src/Turbo/CHANGELOG.md
+++ b/src/Turbo/CHANGELOG.md
@@ -5,6 +5,7 @@
 -   Add `<twig:Turbo:Stream>` component
 -   Add `<twig:Turbo:Frame>` component
 -   Add support for custom actions in `TurboStream` and `TurboStreamResponse`
+-   Add support for providing multiple mercure topics to `turbo_stream_listen`
 
 ## 2.21.0
 
diff --git a/src/Turbo/assets/dist/turbo_stream_controller.d.ts b/src/Turbo/assets/dist/turbo_stream_controller.d.ts
index a86c6796..2806afea 100644
--- a/src/Turbo/assets/dist/turbo_stream_controller.d.ts
+++ b/src/Turbo/assets/dist/turbo_stream_controller.d.ts
@@ -2,14 +2,17 @@ import { Controller } from '@hotwired/stimulus';
 export default class extends Controller {
     static values: {
         topic: StringConstructor;
+        topics: ArrayConstructor;
         hub: StringConstructor;
     };
     es: EventSource | undefined;
     url: string | undefined;
     readonly topicValue: string;
+    readonly topicsValue: string[];
     readonly hubValue: string;
     readonly hasHubValue: boolean;
     readonly hasTopicValue: boolean;
+    readonly hasTopicsValue: boolean;
     initialize(): void;
     connect(): void;
     disconnect(): void;
diff --git a/src/Turbo/assets/dist/turbo_stream_controller.js b/src/Turbo/assets/dist/turbo_stream_controller.js
index 287dbbf7..3d55567c 100644
--- a/src/Turbo/assets/dist/turbo_stream_controller.js
+++ b/src/Turbo/assets/dist/turbo_stream_controller.js
@@ -6,12 +6,19 @@ class default_1 extends Controller {
         const errorMessages = [];
         if (!this.hasHubValue)
             errorMessages.push('A "hub" value pointing to the Mercure hub must be provided.');
-        if (!this.hasTopicValue)
-            errorMessages.push('A "topic" value must be provided.');
+        if (!this.hasTopicValue && !this.hasTopicsValue)
+            errorMessages.push('Either "topic" or "topics" value must be provided.');
         if (errorMessages.length)
             throw new Error(errorMessages.join(' '));
         const u = new URL(this.hubValue);
-        u.searchParams.append('topic', this.topicValue);
+        if (this.hasTopicValue) {
+            u.searchParams.append('topic', this.topicValue);
+        }
+        else {
+            this.topicsValue.forEach((topic) => {
+                u.searchParams.append('topic', topic);
+            });
+        }
         this.url = u.toString();
     }
     connect() {
@@ -29,6 +36,7 @@ class default_1 extends Controller {
 }
 default_1.values = {
     topic: String,
+    topics: Array,
     hub: String,
 };
 
diff --git a/src/Turbo/assets/src/turbo_stream_controller.ts b/src/Turbo/assets/src/turbo_stream_controller.ts
index c408dcfb..4c8fd4d9 100644
--- a/src/Turbo/assets/src/turbo_stream_controller.ts
+++ b/src/Turbo/assets/src/turbo_stream_controller.ts
@@ -16,24 +16,34 @@ import { connectStreamSource, disconnectStreamSource } from '@hotwired/turbo';
 export default class extends Controller {
     static values = {
         topic: String,
+        topics: Array,
         hub: String,
     };
     es: EventSource | undefined;
     url: string | undefined;
 
     declare readonly topicValue: string;
+    declare readonly topicsValue: string[];
     declare readonly hubValue: string;
     declare readonly hasHubValue: boolean;
     declare readonly hasTopicValue: boolean;
+    declare readonly hasTopicsValue: boolean;
 
     initialize() {
         const errorMessages: string[] = [];
         if (!this.hasHubValue) errorMessages.push('A "hub" value pointing to the Mercure hub must be provided.');
-        if (!this.hasTopicValue) errorMessages.push('A "topic" value must be provided.');
+        if (!this.hasTopicValue && !this.hasTopicsValue)
+            errorMessages.push('Either "topic" or "topics" value must be provided.');
         if (errorMessages.length) throw new Error(errorMessages.join(' '));
 
         const u = new URL(this.hubValue);
-        u.searchParams.append('topic', this.topicValue);
+        if (this.hasTopicValue) {
+            u.searchParams.append('topic', this.topicValue);
+        } else {
+            this.topicsValue.forEach((topic) => {
+                u.searchParams.append('topic', topic);
+            });
+        }
 
         this.url = u.toString();
     }
diff --git a/src/Turbo/src/Bridge/Mercure/TopicSet.php b/src/Turbo/src/Bridge/Mercure/TopicSet.php
new file mode 100644
index 00000000..42a6ffe9
--- /dev/null
+++ b/src/Turbo/src/Bridge/Mercure/TopicSet.php
@@ -0,0 +1,34 @@
+<?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\Turbo\Bridge\Mercure;
+
+/**
+ * @internal
+ */
+final class TopicSet
+{
+    /**
+     * @param array<string|object> $topics
+     */
+    public function __construct(
+        private array $topics,
+    ) {
+    }
+
+    /**
+     * @return array<string|object>
+     */
+    public function getTopics(): array
+    {
+        return $this->topics;
+    }
+}
diff --git a/src/Turbo/src/Bridge/Mercure/TurboStreamListenRenderer.php b/src/Turbo/src/Bridge/Mercure/TurboStreamListenRenderer.php
index 89f24c29..68eadd82 100644
--- a/src/Turbo/src/Bridge/Mercure/TurboStreamListenRenderer.php
+++ b/src/Turbo/src/Bridge/Mercure/TurboStreamListenRenderer.php
@@ -43,6 +43,28 @@ final class TurboStreamListenRenderer implements TurboStreamListenRendererInterf
     }
 
     public function renderTurboStreamListen(Environment $env, $topic): string
+    {
+        $topics = $topic instanceof TopicSet
+            ? array_map($this->resolveTopic(...), $topic->getTopics())
+            : [$this->resolveTopic($topic)];
+
+        $controllerAttributes = ['hub' => $this->hub->getPublicUrl()];
+        if (1 < \count($topics)) {
+            $controllerAttributes['topics'] = $topics;
+        } else {
+            $controllerAttributes['topic'] = current($topics);
+        }
+
+        $stimulusAttributes = $this->stimulusHelper->createStimulusAttributes();
+        $stimulusAttributes->addController(
+            'symfony/ux-turbo/mercure-turbo-stream',
+            $controllerAttributes,
+        );
+
+        return (string) $stimulusAttributes;
+    }
+
+    private function resolveTopic(object|string $topic): string
     {
         if (\is_object($topic)) {
             $class = $topic::class;
@@ -51,18 +73,14 @@ final class TurboStreamListenRenderer implements TurboStreamListenRendererInterf
                 throw new \LogicException(\sprintf('Cannot listen to entity of class "%s" as the PropertyAccess component is not installed. Try running "composer require symfony/property-access".', $class));
             }
 
-            $topic = \sprintf(Broadcaster::TOPIC_PATTERN, rawurlencode($class), rawurlencode(implode('-', $id)));
-        } elseif (!preg_match('/[^a-zA-Z0-9_\x7f-\xff\\\\]/', $topic) && class_exists($topic)) {
-            // Generate a URI template to subscribe to updates for all objects of this class
-            $topic = \sprintf(Broadcaster::TOPIC_PATTERN, rawurlencode($topic), '{id}');
+            return \sprintf(Broadcaster::TOPIC_PATTERN, rawurlencode($class), rawurlencode(implode('-', $id)));
         }
 
-        $stimulusAttributes = $this->stimulusHelper->createStimulusAttributes();
-        $stimulusAttributes->addController(
-            'symfony/ux-turbo/mercure-turbo-stream',
-            ['topic' => $topic, 'hub' => $this->hub->getPublicUrl()]
-        );
+        if (!preg_match('/[^a-zA-Z0-9_\x7f-\xff\\\\]/', $topic) && class_exists($topic)) {
+            // Generate a URI template to subscribe to updates for all objects of this class
+            return \sprintf(Broadcaster::TOPIC_PATTERN, rawurlencode($topic), '{id}');
+        }
 
-        return (string) $stimulusAttributes;
+        return $topic;
     }
 }
diff --git a/src/Turbo/src/Twig/TwigExtension.php b/src/Turbo/src/Twig/TwigExtension.php
index 87dc3fe5..b44d9931 100644
--- a/src/Turbo/src/Twig/TwigExtension.php
+++ b/src/Turbo/src/Twig/TwigExtension.php
@@ -12,6 +12,7 @@
 namespace Symfony\UX\Turbo\Twig;
 
 use Psr\Container\ContainerInterface;
+use Symfony\UX\Turbo\Bridge\Mercure\TopicSet;
 use Twig\Environment;
 use Twig\Extension\AbstractExtension;
 use Twig\TwigFunction;
@@ -35,7 +36,7 @@ final class TwigExtension extends AbstractExtension
     }
 
     /**
-     * @param object|string $topic
+     * @param object|string|array<object|string> $topic
      */
     public function turboStreamListen(Environment $env, $topic, ?string $transport = null): string
     {
@@ -45,6 +46,10 @@ final class TwigExtension extends AbstractExtension
             throw new \InvalidArgumentException(\sprintf('The Turbo stream transport "%s" does not exist.', $transport));
         }
 
+        if (\is_array($topic)) {
+            $topic = new TopicSet($topic);
+        }
+
         return $this->turboStreamListenRenderers->get($transport)->renderTurboStreamListen($env, $topic);
     }
 }


Test patch:
diff --git a/src/Turbo/tests/Bridge/Mercure/TurboStreamListenRendererTest.php b/src/Turbo/tests/Bridge/Mercure/TurboStreamListenRendererTest.php
new file mode 100644
index 00000000..9b19ba4d
--- /dev/null
+++ b/src/Turbo/tests/Bridge/Mercure/TurboStreamListenRendererTest.php
@@ -0,0 +1,75 @@
+<?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\Turbo\Tests\Bridge\Mercure;
+
+use App\Entity\Book;
+use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
+use Symfony\UX\StimulusBundle\Dto\StimulusAttributes;
+
+final class TurboStreamListenRendererTest extends KernelTestCase
+{
+    /**
+     * @dataProvider provideTestCases
+     *
+     * @param array<mixed> $context
+     */
+    public function testRenderTurboStreamListen(string $template, array $context, string $expectedResult): void
+    {
+        $twig = self::getContainer()->get('twig');
+        self::assertInstanceOf(\Twig\Environment::class, $twig);
+
+        $this->assertSame($expectedResult, $twig->createTemplate($template)->render($context));
+    }
+
+    /**
+     * @return iterable<array{0: string, 1: array<mixed>, 2: string}>
+     */
+    public static function provideTestCases(): iterable
+    {
+        $newEscape = (new \ReflectionClass(StimulusAttributes::class))->hasMethod('escape');
+
+        $book = new Book();
+        $book->id = 123;
+
+        yield [
+            "{{ turbo_stream_listen('a_topic') }}",
+            [],
+            $newEscape
+                ? 'data-controller="symfony--ux-turbo--mercure-turbo-stream" data-symfony--ux-turbo--mercure-turbo-stream-hub-value="http://127.0.0.1:3000/.well-known/mercure" data-symfony--ux-turbo--mercure-turbo-stream-topic-value="a_topic"'
+                : 'data-controller="symfony--ux-turbo--mercure-turbo-stream" data-symfony--ux-turbo--mercure-turbo-stream-hub-value="http&#x3A;&#x2F;&#x2F;127.0.0.1&#x3A;3000&#x2F;.well-known&#x2F;mercure" data-symfony--ux-turbo--mercure-turbo-stream-topic-value="a_topic"',
+        ];
+
+        yield [
+            "{{ turbo_stream_listen('App\\Entity\\Book') }}",
+            [],
+            $newEscape
+                ? 'data-controller="symfony--ux-turbo--mercure-turbo-stream" data-symfony--ux-turbo--mercure-turbo-stream-hub-value="http://127.0.0.1:3000/.well-known/mercure" data-symfony--ux-turbo--mercure-turbo-stream-topic-value="AppEntityBook"'
+                : 'data-controller="symfony--ux-turbo--mercure-turbo-stream" data-symfony--ux-turbo--mercure-turbo-stream-hub-value="http&#x3A;&#x2F;&#x2F;127.0.0.1&#x3A;3000&#x2F;.well-known&#x2F;mercure" data-symfony--ux-turbo--mercure-turbo-stream-topic-value="AppEntityBook"',
+        ];
+
+        yield [
+            '{{ turbo_stream_listen(book) }}',
+            ['book' => $book],
+            $newEscape
+                ? 'data-controller="symfony--ux-turbo--mercure-turbo-stream" data-symfony--ux-turbo--mercure-turbo-stream-hub-value="http://127.0.0.1:3000/.well-known/mercure" data-symfony--ux-turbo--mercure-turbo-stream-topic-value="https://symfony.com/ux-turbo/App%5CEntity%5CBook/123"'
+                : 'data-controller="symfony--ux-turbo--mercure-turbo-stream" data-symfony--ux-turbo--mercure-turbo-stream-hub-value="http&#x3A;&#x2F;&#x2F;127.0.0.1&#x3A;3000&#x2F;.well-known&#x2F;mercure" data-symfony--ux-turbo--mercure-turbo-stream-topic-value="https&#x3A;&#x2F;&#x2F;symfony.com&#x2F;ux-turbo&#x2F;App&#x25;5CEntity&#x25;5CBook&#x2F;123"',
+        ];
+
+        yield [
+            "{{ turbo_stream_listen(['a_topic', 'App\\Entity\\Book', book]) }}",
+            ['book' => $book],
+            $newEscape
+                ? 'data-controller="symfony--ux-turbo--mercure-turbo-stream" data-symfony--ux-turbo--mercure-turbo-stream-hub-value="http://127.0.0.1:3000/.well-known/mercure" data-symfony--ux-turbo--mercure-turbo-stream-topics-value="[&quot;a_topic&quot;,&quot;AppEntityBook&quot;,&quot;https:\/\/symfony.com\/ux-turbo\/App%5CEntity%5CBook\/123&quot;]"'
+                : 'data-controller="symfony--ux-turbo--mercure-turbo-stream" data-symfony--ux-turbo--mercure-turbo-stream-hub-value="http&#x3A;&#x2F;&#x2F;127.0.0.1&#x3A;3000&#x2F;.well-known&#x2F;mercure" data-symfony--ux-turbo--mercure-turbo-stream-topics-value="&#x5B;&quot;a_topic&quot;,&quot;AppEntityBook&quot;,&quot;https&#x3A;&#x5C;&#x2F;&#x5C;&#x2F;symfony.com&#x5C;&#x2F;ux-turbo&#x5C;&#x2F;App&#x25;5CEntity&#x25;5CBook&#x5C;&#x2F;123&quot;&#x5D;"',
+        ];
+    }
+}