Repository: symfony/ux
Pull request #2694 (2025-04-14 17:06:08)

[Turbo] Pass `turbo_stream_listen` topics to mercure if not defined
| Q             | A
| ------------- | ---
| Bug fix?      | yes
| New feature?  | no
| Docs?         | no 
| Issues        | See below
| License       | MIT

Currently, the documentation says:

`"If you're using a private hub, you can add { withCredentials: true } as the third argument to turbo_stream_listen() to authenticate with the hub."`

However, this doesn’t actually work because the cookie is not created.
See: [MercureExtension.php#L75](https://github.com/symfony/mercure/blob/304cf84609ef645d63adc65fc6250292909a461b/src/Twig/MercureExtension.php#L75)

To make it work, we need to explicitly specify the topics again. The working call looks like this:
`turbo_stream_listen("topics", "default", { subscribe: 'topics', withCredentials: true }`

So I updated the implementation, adding the `subscribe` key if it's not already defined.

My mistake, sorry :sweat_smile: 


Patch:
diff --git a/src/Icons/CHANGELOG.md b/src/Icons/CHANGELOG.md
index 52b4fb26..48a8ba77 100644
--- a/src/Icons/CHANGELOG.md
+++ b/src/Icons/CHANGELOG.md
@@ -1,9 +1,5 @@
 # CHANGELOG
 
-## 2.25.0
-
--   Improve DX when `symfony/http-client` is not installed.
-
 ## 2.24.0
 
 -    Add `xmlns` attribute to icons downloaded with Iconify, to correctly render icons browser as an external file, in SVG editors, and in files explorers or text editors previews.
diff --git a/src/Icons/config/iconify.php b/src/Icons/config/iconify.php
new file mode 100644
index 00000000..52aa705a
--- /dev/null
+++ b/src/Icons/config/iconify.php
@@ -0,0 +1,56 @@
+<?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\Component\DependencyInjection\Loader\Configurator;
+
+use Symfony\UX\Icons\Command\ImportIconCommand;
+use Symfony\UX\Icons\Command\LockIconsCommand;
+use Symfony\UX\Icons\Command\SearchIconCommand;
+use Symfony\UX\Icons\Iconify;
+use Symfony\UX\Icons\Registry\IconifyOnDemandRegistry;
+
+return static function (ContainerConfigurator $container): void {
+    $container->services()
+        ->set('.ux_icons.iconify_on_demand_registry', IconifyOnDemandRegistry::class)
+            ->args([
+                service('.ux_icons.iconify'),
+            ])
+            ->tag('ux_icons.registry')
+
+        ->set('.ux_icons.iconify', Iconify::class)
+            ->args([
+                service('.ux_icons.cache'),
+                abstract_arg('endpoint'),
+                service('http_client')->nullOnInvalid(),
+            ])
+
+        ->set('.ux_icons.command.import', ImportIconCommand::class)
+            ->args([
+                service('.ux_icons.iconify'),
+                service('.ux_icons.local_svg_icon_registry'),
+            ])
+            ->tag('console.command')
+
+        ->set('.ux_icons.command.lock', LockIconsCommand::class)
+            ->args([
+                service('.ux_icons.iconify'),
+                service('.ux_icons.local_svg_icon_registry'),
+                service('.ux_icons.icon_finder'),
+            ])
+            ->tag('console.command')
+
+        ->set('.ux_icons.command.search', SearchIconCommand::class)
+            ->args([
+                service('.ux_icons.iconify'),
+            ])
+            ->tag('console.command')
+    ;
+};
diff --git a/src/Icons/config/services.php b/src/Icons/config/services.php
index 4b29a381..537c0cbb 100644
--- a/src/Icons/config/services.php
+++ b/src/Icons/config/services.php
@@ -11,17 +11,12 @@
 
 namespace Symfony\Component\DependencyInjection\Loader\Configurator;
 
-use Symfony\UX\Icons\Command\ImportIconCommand;
-use Symfony\UX\Icons\Command\LockIconsCommand;
-use Symfony\UX\Icons\Command\SearchIconCommand;
 use Symfony\UX\Icons\Command\WarmCacheCommand;
 use Symfony\UX\Icons\IconCacheWarmer;
-use Symfony\UX\Icons\Iconify;
 use Symfony\UX\Icons\IconRenderer;
 use Symfony\UX\Icons\IconRendererInterface;
 use Symfony\UX\Icons\Registry\CacheIconRegistry;
 use Symfony\UX\Icons\Registry\ChainIconRegistry;
-use Symfony\UX\Icons\Registry\IconifyOnDemandRegistry;
 use Symfony\UX\Icons\Registry\LocalSvgIconRegistry;
 use Symfony\UX\Icons\Twig\IconFinder;
 use Symfony\UX\Icons\Twig\UXIconExtension;
@@ -91,39 +86,5 @@ return static function (ContainerConfigurator $container): void {
                 service('.ux_icons.cache_warmer'),
             ])
             ->tag('console.command')
-
-        ->set('.ux_icons.iconify', Iconify::class)
-            ->args([
-                service('.ux_icons.cache'),
-                abstract_arg('endpoint'),
-                service('http_client')->nullOnInvalid(),
-            ])
-
-        ->set('.ux_icons.iconify_on_demand_registry', IconifyOnDemandRegistry::class)
-            ->args([
-                service('.ux_icons.iconify'),
-            ])
-            ->tag('ux_icons.registry', ['priority' => -10])
-
-        ->set('.ux_icons.command.import', ImportIconCommand::class)
-            ->args([
-                service('.ux_icons.iconify'),
-                service('.ux_icons.local_svg_icon_registry'),
-            ])
-            ->tag('console.command')
-
-        ->set('.ux_icons.command.lock', LockIconsCommand::class)
-            ->args([
-                service('.ux_icons.iconify'),
-                service('.ux_icons.local_svg_icon_registry'),
-                service('.ux_icons.icon_finder'),
-            ])
-            ->tag('console.command')
-
-        ->set('.ux_icons.command.search', SearchIconCommand::class)
-            ->args([
-                service('.ux_icons.iconify'),
-            ])
-            ->tag('console.command')
     ;
 };
diff --git a/src/Icons/doc/index.rst b/src/Icons/doc/index.rst
index 607e0160..5700e12a 100644
--- a/src/Icons/doc/index.rst
+++ b/src/Icons/doc/index.rst
@@ -286,18 +286,6 @@ the report to overwrite existing icons by using the ``--force`` option:
 
     $ php bin/console ux:icons:lock --force
 
-.. caution::
-
-    The process to find icons to lock in your Twig templates is imperfect. It
-    looks for any string that matches the pattern ``something:something`` so
-    it's probable there will be false positives. This command should not be used
-    to audit the icons in your templates in an automated way. Add ``-v`` see
-    *potential* invalid icons:
-
-    .. code-block:: terminal
-
-        $ php bin/console ux:icons:lock -v
-
 Rendering Icons
 ---------------
 
@@ -484,18 +472,6 @@ In production, you can pre-warm the cache by running the following command:
 This command looks in all your Twig templates for ``ux_icon()`` calls and
 ``<twig:ux:icon>`` tags and caches the icons it finds.
 
-.. caution::
-
-    The process to find icons to cache in your Twig templates is imperfect. It
-    looks for any string that matches the pattern ``something:something`` so
-    it's probable there will be false positives. This command should not be used
-    to audit the icons in your templates in an automated way. Add ``-v`` see
-    *potential* invalid icons:
-
-    .. code-block:: terminal
-
-        $ php bin/console ux:icons:warm-cache -v
-
 .. caution::
 
     Icons that have a name built dynamically will not be cached. It's advised to
diff --git a/src/Icons/src/Command/WarmCacheCommand.php b/src/Icons/src/Command/WarmCacheCommand.php
index 8263183c..1451a7cf 100644
--- a/src/Icons/src/Command/WarmCacheCommand.php
+++ b/src/Icons/src/Command/WarmCacheCommand.php
@@ -45,11 +45,6 @@ final class WarmCacheCommand extends Command
                     $io->writeln(\sprintf(' Warmed icon <comment>%s</comment>.', $name));
                 }
             },
-            onFailure: function (string $name, \Exception $e) use ($io) {
-                if ($io->isVerbose()) {
-                    $io->writeln(\sprintf(' Failed to warm (potential) icon <error>%s</error>.', $name));
-                }
-            }
         );
 
         $io->success('Icon cache warmed.');
diff --git a/src/Icons/src/DependencyInjection/UXIconsExtension.php b/src/Icons/src/DependencyInjection/UXIconsExtension.php
index b9324091..85672aeb 100644
--- a/src/Icons/src/DependencyInjection/UXIconsExtension.php
+++ b/src/Icons/src/DependencyInjection/UXIconsExtension.php
@@ -18,6 +18,7 @@ use Symfony\Component\Config\FileLocator;
 use Symfony\Component\DependencyInjection\ContainerBuilder;
 use Symfony\Component\DependencyInjection\Loader\PhpFileLoader;
 use Symfony\Component\HttpKernel\DependencyInjection\ConfigurableExtension;
+use Symfony\Contracts\HttpClient\HttpClientInterface;
 use Symfony\UX\Icons\Iconify;
 
 /**
@@ -86,7 +87,7 @@ final class UXIconsExtension extends ConfigurableExtension implements Configurat
                 ->end()
                 ->arrayNode('iconify')
                     ->info('Configuration for the remote icon service.')
-                    ->canBeDisabled()
+                    ->{interface_exists(HttpClientInterface::class) ? 'canBeDisabled' : 'canBeEnabled'}()
                     ->children()
                         ->booleanNode('on_demand')
                             ->info('Whether to download icons "on demand".')
@@ -163,24 +164,26 @@ final class UXIconsExtension extends ConfigurableExtension implements Configurat
             ->setArgument(1, $mergedConfig['ignore_not_found'])
         ;
 
-        $container->getDefinition('.ux_icons.iconify')
-            ->setArgument(1, $mergedConfig['iconify']['endpoint']);
+        if ($mergedConfig['iconify']['enabled']) {
+            $loader->load('iconify.php');
 
-        $container->getDefinition('.ux_icons.iconify_on_demand_registry')
-            ->setArgument(1, $iconSetAliases);
+            $container->getDefinition('.ux_icons.iconify')
+                ->setArgument(1, $mergedConfig['iconify']['endpoint']);
 
-        $container->getDefinition('.ux_icons.command.lock')
-            ->setArgument(3, $mergedConfig['aliases'])
-            ->setArgument(4, $iconSetAliases);
+            $container->getDefinition('.ux_icons.iconify_on_demand_registry')
+                ->setArgument(1, $iconSetAliases);
 
-        if (!$mergedConfig['iconify']['on_demand'] || !$mergedConfig['iconify']['enabled']) {
-            $container->removeDefinition('.ux_icons.iconify_on_demand_registry');
+            $container->getDefinition('.ux_icons.command.lock')
+                ->setArgument(3, $mergedConfig['aliases'])
+                ->setArgument(4, $iconSetAliases);
+
+            if (!$mergedConfig['iconify']['on_demand']) {
+                $container->removeDefinition('.ux_icons.iconify_on_demand_registry');
+            }
         }
 
         if (!$container->getParameter('kernel.debug')) {
             $container->removeDefinition('.ux_icons.command.import');
-            $container->removeDefinition('.ux_icons.command.search');
-            $container->removeDefinition('.ux_icons.command.lock');
         }
     }
 }
diff --git a/src/Icons/src/Exception/HttpClientNotInstalledException.php b/src/Icons/src/Exception/HttpClientNotInstalledException.php
deleted file mode 100644
index eb624ade..00000000
--- a/src/Icons/src/Exception/HttpClientNotInstalledException.php
+++ /dev/null
@@ -1,21 +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\Icons\Exception;
-
-/**
- * @author Kevin Bond <kevinbond@gmail.com>
- *
- * @internal
- */
-final class HttpClientNotInstalledException extends \LogicException
-{
-}
diff --git a/src/Icons/src/IconCacheWarmer.php b/src/Icons/src/IconCacheWarmer.php
index 2a63ad84..04215ae7 100644
--- a/src/Icons/src/IconCacheWarmer.php
+++ b/src/Icons/src/IconCacheWarmer.php
@@ -27,8 +27,8 @@ final class IconCacheWarmer
     }
 
     /**
-     * @param callable(string,Icon):void|null       $onSuccess
-     * @param callable(string,\Exception):void|null $onFailure
+     * @param callable(string,Icon):void|null $onSuccess
+     * @param callable(string):void|null      $onFailure
      */
     public function warm(?callable $onSuccess = null, ?callable $onFailure = null): void
     {
@@ -40,8 +40,8 @@ final class IconCacheWarmer
                 $icon = $this->registry->get($name, refresh: true);
 
                 $onSuccess($name, $icon);
-            } catch (IconNotFoundException $e) {
-                $onFailure($name, $e);
+            } catch (IconNotFoundException) {
+                $onFailure($name);
             }
         }
     }
diff --git a/src/Icons/src/Iconify.php b/src/Icons/src/Iconify.php
index 7f7db1ea..55c5051d 100644
--- a/src/Icons/src/Iconify.php
+++ b/src/Icons/src/Iconify.php
@@ -16,7 +16,6 @@ use Symfony\Component\HttpClient\HttpClient;
 use Symfony\Component\HttpClient\ScopingHttpClient;
 use Symfony\Contracts\Cache\CacheInterface;
 use Symfony\Contracts\HttpClient\HttpClientInterface;
-use Symfony\UX\Icons\Exception\HttpClientNotInstalledException;
 use Symfony\UX\Icons\Exception\IconNotFoundException;
 
 /**
@@ -40,10 +39,15 @@ final class Iconify
 
     public function __construct(
         private CacheInterface $cache,
-        private string $endpoint = self::API_ENDPOINT,
-        private ?HttpClientInterface $httpClient = null,
+        string $endpoint = self::API_ENDPOINT,
+        ?HttpClientInterface $http = null,
         ?int $maxIconsQueryLength = null,
     ) {
+        if (!class_exists(HttpClient::class)) {
+            throw new \LogicException('You must install "symfony/http-client" to use Iconify. Try running "composer require symfony/http-client".');
+        }
+
+        $this->http = ScopingHttpClient::forBaseUri($http ?? HttpClient::create(), $endpoint);
         $this->maxIconsQueryLength = min(self::MAX_ICONS_QUERY_LENGTH, $maxIconsQueryLength ?? self::MAX_ICONS_QUERY_LENGTH);
     }
 
@@ -58,7 +62,7 @@ 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.json?icons=%s', $prefix, $name));
+        $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));
@@ -108,7 +112,7 @@ final class Iconify
             throw new \InvalidArgumentException('The query string is too long.');
         }
 
-        $response = $this->http()->request('GET', \sprintf('/%s.json', $prefix), [
+        $response = $this->http->request('GET', \sprintf('/%s.json', $prefix), [
             'headers' => [
                 'Accept' => 'application/json',
             ],
@@ -154,7 +158,7 @@ final class Iconify
 
     public function searchIcons(string $prefix, string $query)
     {
-        $response = $this->http()->request('GET', '/search', [
+        $response = $this->http->request('GET', '/search', [
             'query' => [
                 'query' => $query,
                 'prefix' => $prefix,
@@ -201,22 +205,9 @@ final class Iconify
     private function sets(): \ArrayObject
     {
         return $this->sets ??= $this->cache->get('iconify-sets', function () {
-            $response = $this->http()->request('GET', '/collections');
+            $response = $this->http->request('GET', '/collections');
 
             return new \ArrayObject($response->toArray());
         });
     }
-
-    private function http(): HttpClientInterface
-    {
-        if (isset($this->http)) {
-            return $this->http;
-        }
-
-        if (!class_exists(HttpClient::class)) {
-            throw new HttpClientNotInstalledException('You must install "symfony/http-client" to use icons from ux.symfony.com/icons. Try running "composer require symfony/http-client".');
-        }
-
-        return $this->http = ScopingHttpClient::forBaseUri($this->httpClient ?? HttpClient::create(), $this->endpoint);
-    }
 }
diff --git a/src/Icons/src/Registry/ChainIconRegistry.php b/src/Icons/src/Registry/ChainIconRegistry.php
index d476d25c..c6e882cf 100644
--- a/src/Icons/src/Registry/ChainIconRegistry.php
+++ b/src/Icons/src/Registry/ChainIconRegistry.php
@@ -34,16 +34,10 @@ final class ChainIconRegistry implements IconRegistryInterface
         foreach ($this->registries as $registry) {
             try {
                 return $registry->get($name);
-            } catch (IconNotFoundException $e) {
+            } catch (IconNotFoundException) {
             }
         }
 
-        $message = \sprintf('Icon "%s" not found.', $name);
-
-        if (isset($e)) {
-            $message .= " {$e->getMessage()}";
-        }
-
-        throw new IconNotFoundException($message, previous: $e ?? null);
+        throw new IconNotFoundException(\sprintf('Icon "%s" not found.', $name));
     }
 }
diff --git a/src/Icons/src/Registry/IconifyOnDemandRegistry.php b/src/Icons/src/Registry/IconifyOnDemandRegistry.php
index 60c1b591..5931854c 100644
--- a/src/Icons/src/Registry/IconifyOnDemandRegistry.php
+++ b/src/Icons/src/Registry/IconifyOnDemandRegistry.php
@@ -11,7 +11,6 @@
 
 namespace Symfony\UX\Icons\Registry;
 
-use Symfony\UX\Icons\Exception\HttpClientNotInstalledException;
 use Symfony\UX\Icons\Exception\IconNotFoundException;
 use Symfony\UX\Icons\Icon;
 use Symfony\UX\Icons\Iconify;
@@ -37,10 +36,6 @@ final class IconifyOnDemandRegistry implements IconRegistryInterface
         }
         [$prefix, $icon] = $parts;
 
-        try {
-            return $this->iconify->fetchIcon($this->prefixAliases[$prefix] ?? $prefix, $icon);
-        } catch (HttpClientNotInstalledException $e) {
-            throw new IconNotFoundException($e->getMessage());
-        }
+        return $this->iconify->fetchIcon($this->prefixAliases[$prefix] ?? $prefix, $icon);
     }
 }
diff --git a/src/Turbo/src/Bridge/Mercure/TurboStreamListenRenderer.php b/src/Turbo/src/Bridge/Mercure/TurboStreamListenRenderer.php
index a5e511ca..1d427410 100644
--- a/src/Turbo/src/Bridge/Mercure/TurboStreamListenRenderer.php
+++ b/src/Turbo/src/Bridge/Mercure/TurboStreamListenRenderer.php
@@ -65,11 +65,13 @@ final class TurboStreamListenRenderer implements TurboStreamListenRendererWithOp
         if (isset($eventSourceOptions)) {
             try {
                 $mercure = $this->twig->getExtension(MercureExtension::class);
-                $mercure->mercure($topics, $eventSourceOptions);
 
-                if (isset($eventSourceOptions['withCredentials'])) {
-                    $controllerAttributes['withCredentials'] = $eventSourceOptions['withCredentials'];
+                if ($eventSourceOptions['withCredentials'] ?? false) {
+                    $eventSourceOptions['subscribe'] ??= $topics;
+                    $controllerAttributes['withCredentials'] = true;
                 }
+
+                $mercure->mercure($topics, $eventSourceOptions);
             } catch (RuntimeError $e) {
             }
         }


Test patch:
diff --git a/src/Icons/tests/Unit/IconifyTest.php b/src/Icons/tests/Unit/IconifyTest.php
index 8516568b..cec6172c 100644
--- a/src/Icons/tests/Unit/IconifyTest.php
+++ b/src/Icons/tests/Unit/IconifyTest.php
@@ -29,7 +29,7 @@ class IconifyTest extends TestCase
         $iconify = new Iconify(
             cache: new NullAdapter(),
             endpoint: 'https://example.com',
-            httpClient: new MockHttpClient([
+            http: new MockHttpClient([
                 new JsonMockResponse([
                     'bi' => [],
                 ]),
@@ -55,7 +55,7 @@ class IconifyTest extends TestCase
         $iconify = new Iconify(
             cache: new NullAdapter(),
             endpoint: 'https://example.com',
-            httpClient: new MockHttpClient([
+            http: new MockHttpClient([
                 new JsonMockResponse([
                     'bi' => [],
                 ]),
@@ -96,7 +96,7 @@ class IconifyTest extends TestCase
         $iconify = new Iconify(
             cache: new NullAdapter(),
             endpoint: 'https://example.com',
-            httpClient: new MockHttpClient([
+            http: new MockHttpClient([
                 new JsonMockResponse([
                     'bi' => [
                         'height' => 17,
@@ -124,7 +124,7 @@ class IconifyTest extends TestCase
         $iconify = new Iconify(
             cache: new NullAdapter(),
             endpoint: 'https://example.com',
-            httpClient: new MockHttpClient([
+            http: new MockHttpClient([
                 new JsonMockResponse([
                     'bi' => [],
                 ]),
@@ -149,7 +149,7 @@ class IconifyTest extends TestCase
         $iconify = new Iconify(
             cache: new NullAdapter(),
             endpoint: 'https://example.com',
-            httpClient: new MockHttpClient([
+            http: new MockHttpClient([
                 new JsonMockResponse([
                     'bi' => [],
                 ]),
@@ -168,7 +168,7 @@ class IconifyTest extends TestCase
         $iconify = new Iconify(
             cache: new NullAdapter(),
             endpoint: 'https://example.com',
-            httpClient: new MockHttpClient([
+            http: new MockHttpClient([
                 new JsonMockResponse([
                     'bi' => [],
                 ]),
@@ -199,7 +199,7 @@ class IconifyTest extends TestCase
         $iconify = new Iconify(
             cache: new NullAdapter(),
             endpoint: 'https://example.com',
-            httpClient: new MockHttpClient([
+            http: new MockHttpClient([
                 new JsonMockResponse([
                     'mdi' => [],
                 ]),
@@ -239,7 +239,7 @@ class IconifyTest extends TestCase
         $iconify = new Iconify(
             cache: new NullAdapter(),
             endpoint: 'https://example.com',
-            httpClient: new MockHttpClient([
+            http: new MockHttpClient([
                 new JsonMockResponse([
                     'bi' => [],
                 ]),
@@ -256,7 +256,7 @@ class IconifyTest extends TestCase
         $iconify = new Iconify(
             cache: new NullAdapter(),
             endpoint: 'https://example.com',
-            httpClient: new MockHttpClient([
+            http: new MockHttpClient([
                 new JsonMockResponse([
                     'bi' => [],
                 ]),