Repository: symfony/ux (php)

Issue:
[Turbo] Request format is not registered for workers
`BundleInterface::boot()` is only called when the worker is booted.

This leads to the stream format not being available for consecutive requests to the same worker.

I noticed this when trying to find out why a valid Turbo request never returned a stream response.

Pull request:
[Turbo] Add stream format with request listener
| Q             | A
| ------------- | ---
| Bug fix?      | yes
| New feature?  | no
| Issues        | Fix #2549
| License       | MIT

BundleInterface::boot() is only called when booting a worker and not on every request. So on FrankenPHP for example Turbo requests would never return a stream when checking with `if ($request->getPreferredFormat() === TurboBundle::STREAM_FORMAT`.

This replaces `TurboBundle::boot()` with a request listener to register the format on every request.

Patch:
diff --git a/src/Autocomplete/CHANGELOG.md b/src/Autocomplete/CHANGELOG.md
index 8720236c..1f8cea05 100644
--- a/src/Autocomplete/CHANGELOG.md
+++ b/src/Autocomplete/CHANGELOG.md
@@ -4,7 +4,6 @@
 
 -   Deprecate `ExtraLazyChoiceLoader` in favor of `Symfony\Component\Form\ChoiceList\Loader\LazyChoiceLoader`
 -   Reset TomSelect when updating url attribute #1505
--   Add `getAttributes()` method to define additional attributes for autocomplete results #2541
 
 ## 2.22.0
 
diff --git a/src/Autocomplete/src/AutocompleteResultsExecutor.php b/src/Autocomplete/src/AutocompleteResultsExecutor.php
index 03d10e03..0061ca8a 100644
--- a/src/Autocomplete/src/AutocompleteResultsExecutor.php
+++ b/src/Autocomplete/src/AutocompleteResultsExecutor.php
@@ -73,7 +73,10 @@ final class AutocompleteResultsExecutor
 
         if (!method_exists($autocompleter, 'getGroupBy') || null === $groupBy = $autocompleter->getGroupBy()) {
             foreach ($paginator as $entity) {
-                $results[] = $this->formatResult($autocompleter, $entity);
+                $results[] = [
+                    'value' => $autocompleter->getValue($entity),
+                    'text' => $autocompleter->getLabel($entity),
+                ];
             }
 
             return new AutocompleteResults($results, $hasNextPage);
@@ -101,7 +104,10 @@ final class AutocompleteResultsExecutor
         $optgroupLabels = [];
 
         foreach ($paginator as $entity) {
-            $result = $this->formatResult($autocompleter, $entity);
+            $result = [
+                'value' => $autocompleter->getValue($entity),
+                'text' => $autocompleter->getLabel($entity),
+            ];
 
             $groupLabels = $groupBy($entity, $result['value'], $result['text']);
 
@@ -118,21 +124,4 @@ final class AutocompleteResultsExecutor
 
         return new AutocompleteResults($results, $hasNextPage, $optgroups);
     }
-
-    /**
-     * @return array<string, mixed>
-     */
-    private function formatResult(EntityAutocompleterInterface $autocompleter, object $entity): array
-    {
-        $attributes = [];
-        if (method_exists($autocompleter, 'getAttributes')) {
-            $attributes = $autocompleter->getAttributes($entity);
-        }
-
-        return [
-            ...$attributes,
-            'value' => $autocompleter->getValue($entity),
-            'text' => $autocompleter->getLabel($entity),
-        ];
-    }
 }
diff --git a/src/Autocomplete/src/EntityAutocompleterInterface.php b/src/Autocomplete/src/EntityAutocompleterInterface.php
index 4ad8a855..df0874fd 100644
--- a/src/Autocomplete/src/EntityAutocompleterInterface.php
+++ b/src/Autocomplete/src/EntityAutocompleterInterface.php
@@ -18,50 +18,30 @@ use Symfony\Bundle\SecurityBundle\Security;
 /**
  * Interface for classes that will have an "autocomplete" endpoint exposed.
  *
- * @template T of object
- *
- * TODO Remove next lines for Symfony UX 3
- *
- * @method array getAttributes(object $entity) Returns extra attributes to add to the autocomplete result.
- * @method mixed getGroupBy()                  Return group_by option.
+ * @method mixed getGroupBy() Return group_by option.
  */
 interface EntityAutocompleterInterface
 {
     /**
      * The fully-qualified entity class this will be autocompleting.
-     *
-     * @return class-string<T>
      */
     public function getEntityClass(): string;
 
     /**
      * Create a query builder that filters for the given "query".
-     *
-     * @param EntityRepository<T> $repository
      */
     public function createFilteredQueryBuilder(EntityRepository $repository, string $query): QueryBuilder;
 
     /**
      * Returns the "choice_label" used to display this entity.
-     *
-     * @param T $entity
      */
     public function getLabel(object $entity): string;
 
     /**
      * Returns the "value" attribute for this entity, usually the id.
-     *
-     * @param T $entity
      */
     public function getValue(object $entity): mixed;
 
-    /**
-     * Returns extra attributes to add to the autocomplete result.
-     *
-     * TODO Uncomment for Symfony UX 3
-     */
-    /* public function getAttributes(object $entity): array; */
-
     /**
      * Return true if access should be granted to the autocomplete results for the current user.
      *
@@ -71,8 +51,6 @@ interface EntityAutocompleterInterface
 
     /*
      * Return group_by option.
-     *
-     * TODO Uncomment for Symfony UX 3
      */
     /* public function getGroupBy(): mixed; */
 }
diff --git a/src/Turbo/config/services.php b/src/Turbo/config/services.php
index 0cd1e6a1..0344e95a 100644
--- a/src/Turbo/config/services.php
+++ b/src/Turbo/config/services.php
@@ -11,11 +11,13 @@
 
 namespace Symfony\Component\DependencyInjection\Loader\Configurator;
 
+use Symfony\Component\HttpKernel\KernelEvents;
 use Symfony\UX\Turbo\Broadcaster\BroadcasterInterface;
 use Symfony\UX\Turbo\Broadcaster\IdAccessor;
 use Symfony\UX\Turbo\Broadcaster\ImuxBroadcaster;
 use Symfony\UX\Turbo\Broadcaster\TwigBroadcaster;
 use Symfony\UX\Turbo\Doctrine\BroadcastListener;
+use Symfony\UX\Turbo\Request\RequestListener;
 use Symfony\UX\Turbo\Twig\TwigExtension;
 
 /*
@@ -55,5 +57,8 @@ return static function (ContainerConfigurator $container): void {
             ])
             ->tag('doctrine.event_listener', ['event' => 'onFlush'])
             ->tag('doctrine.event_listener', ['event' => 'postFlush'])
+
+        ->set('turbo.kernel.request_listener', RequestListener::class)
+            ->tag('kernel.event_listener', ['event' => KernelEvents::REQUEST, 'priority' => 256])
     ;
 };
diff --git a/src/Turbo/src/Request/RequestListener.php b/src/Turbo/src/Request/RequestListener.php
new file mode 100644
index 00000000..a59172af
--- /dev/null
+++ b/src/Turbo/src/Request/RequestListener.php
@@ -0,0 +1,28 @@
+<?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\Request;
+
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\UX\Turbo\TurboBundle;
+
+/**
+ * Registers the Turbo request format for all requests.
+ *
+ * @author Alexander Hofbauer <a.hofbauer@fify.at>
+ */
+final class RequestListener
+{
+    public function __invoke(): void
+    {
+        (new Request())->setFormat(TurboBundle::STREAM_FORMAT, TurboBundle::STREAM_MEDIA_TYPE);
+    }
+}
diff --git a/src/Turbo/src/TurboBundle.php b/src/Turbo/src/TurboBundle.php
index 4c97d525..e1524e4b 100644
--- a/src/Turbo/src/TurboBundle.php
+++ b/src/Turbo/src/TurboBundle.php
@@ -14,7 +14,6 @@ namespace Symfony\UX\Turbo;
 use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
 use Symfony\Component\DependencyInjection\Compiler\PassConfig;
 use Symfony\Component\DependencyInjection\ContainerBuilder;
-use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\HttpKernel\Bundle\Bundle;
 
 /**
@@ -25,11 +24,6 @@ final class TurboBundle extends Bundle
     public const STREAM_FORMAT = 'turbo_stream';
     public const STREAM_MEDIA_TYPE = 'text/vnd.turbo-stream.html';
 
-    public function boot(): void
-    {
-        (new Request())->setFormat(self::STREAM_FORMAT, self::STREAM_MEDIA_TYPE);
-    }
-
     public function build(ContainerBuilder $container): void
     {
         parent::build($container);


Test patch:
diff --git a/src/Autocomplete/tests/Fixtures/Autocompleter/CustomAttributesProductAutocompleter.php b/src/Autocomplete/tests/Fixtures/Autocompleter/CustomAttributesProductAutocompleter.php
deleted file mode 100644
index 7f86431a..00000000
--- a/src/Autocomplete/tests/Fixtures/Autocompleter/CustomAttributesProductAutocompleter.php
+++ /dev/null
@@ -1,17 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace Symfony\UX\Autocomplete\Tests\Fixtures\Autocompleter;
-
-class CustomAttributesProductAutocompleter extends CustomProductAutocompleter
-{
-    public function getAttributes(object $entity): array
-    {
-        return [
-            'disabled' => true,
-            'value' => 'This value should be replaced with the result of getValue()',
-            'text' => 'This value should be replaced with the result of getText()',
-        ];
-    }
-}
diff --git a/src/Autocomplete/tests/Fixtures/Autocompleter/CustomProductAutocompleter.php b/src/Autocomplete/tests/Fixtures/Autocompleter/CustomProductAutocompleter.php
index 09b5b96c..912a864c 100644
--- a/src/Autocomplete/tests/Fixtures/Autocompleter/CustomProductAutocompleter.php
+++ b/src/Autocomplete/tests/Fixtures/Autocompleter/CustomProductAutocompleter.php
@@ -19,9 +19,6 @@ use Symfony\UX\Autocomplete\Doctrine\EntitySearchUtil;
 use Symfony\UX\Autocomplete\EntityAutocompleterInterface;
 use Symfony\UX\Autocomplete\Tests\Fixtures\Entity\Product;
 
-/**
- * @implements EntityAutocompleterInterface<Product>
- */
 class CustomProductAutocompleter implements EntityAutocompleterInterface
 {
     public function __construct(
@@ -61,11 +58,6 @@ class CustomProductAutocompleter implements EntityAutocompleterInterface
         return $entity->getId();
     }
 
-    public function getAttributes(object $entity): array
-    {
-        return [];
-    }
-
     public function isGranted(Security $security): bool
     {
         if ($this->requestStack->getCurrentRequest()?->query->get('enforce_test_security')) {
diff --git a/src/Autocomplete/tests/Fixtures/Kernel.php b/src/Autocomplete/tests/Fixtures/Kernel.php
index de2877b0..f1991a22 100644
--- a/src/Autocomplete/tests/Fixtures/Kernel.php
+++ b/src/Autocomplete/tests/Fixtures/Kernel.php
@@ -34,7 +34,6 @@ use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator;
 use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
 use Symfony\UX\Autocomplete\AutocompleteBundle;
 use Symfony\UX\Autocomplete\DependencyInjection\AutocompleteFormTypePass;
-use Symfony\UX\Autocomplete\Tests\Fixtures\Autocompleter\CustomAttributesProductAutocompleter;
 use Symfony\UX\Autocomplete\Tests\Fixtures\Autocompleter\CustomGroupByProductAutocompleter;
 use Symfony\UX\Autocomplete\Tests\Fixtures\Autocompleter\CustomProductAutocompleter;
 use Symfony\UX\Autocomplete\Tests\Fixtures\Form\ProductType;
@@ -177,13 +176,6 @@ final class Kernel extends BaseKernel
                 'alias' => 'custom_group_by_product',
             ]);
 
-        $services->set(CustomAttributesProductAutocompleter::class)
-            ->public()
-            ->arg(1, new Reference('ux.autocomplete.entity_search_util'))
-            ->tag(AutocompleteFormTypePass::ENTITY_AUTOCOMPLETER_TAG, [
-                'alias' => 'custom_attributes_product',
-            ]);
-
         $services->alias('public.results_executor', 'ux.autocomplete.results_executor')
             ->public();
 
diff --git a/src/Autocomplete/tests/Integration/AutocompleteResultsExecutorTest.php b/src/Autocomplete/tests/Integration/AutocompleteResultsExecutorTest.php
deleted file mode 100644
index b804afd1..00000000
--- a/src/Autocomplete/tests/Integration/AutocompleteResultsExecutorTest.php
+++ /dev/null
@@ -1,44 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * 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\Autocomplete\Tests\Integration;
-
-use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
-use Symfony\UX\Autocomplete\AutocompleteResultsExecutor;
-use Symfony\UX\Autocomplete\Tests\Fixtures\Autocompleter\CustomAttributesProductAutocompleter;
-use Symfony\UX\Autocomplete\Tests\Fixtures\Factory\ProductFactory;
-use Symfony\UX\Autocomplete\Tests\Fixtures\Kernel;
-use Zenstruck\Foundry\Test\Factories;
-use Zenstruck\Foundry\Test\ResetDatabase;
-
-class AutocompleteResultsExecutorTest extends KernelTestCase
-{
-    use Factories;
-    use ResetDatabase;
-
-    public function testItReturnsExtraAttributes(): void
-    {
-        $kernel = new Kernel('test', true);
-        $kernel->disableForms();
-        $kernel->boot();
-
-        $product = ProductFactory::createOne(['name' => 'Foo']);
-
-        /** @var AutocompleteResultsExecutor $executor */
-        $executor = $kernel->getContainer()->get('public.results_executor');
-        $autocompleter = $kernel->getContainer()->get(CustomAttributesProductAutocompleter::class);
-        $data = $executor->fetchResults($autocompleter, '', 1);
-        $this->assertCount(1, $data->results);
-        $this->assertSame(['disabled' => true, 'value' => $product->getId(), 'text' => 'Foo'], $data->results[0]);
-    }
-}
diff --git a/src/LiveComponent/tests/Functional/Test/InteractsWithLiveComponentsTest.php b/src/LiveComponent/tests/Functional/Test/InteractsWithLiveComponentsTest.php
index 097e4d77..6bae36f9 100644
--- a/src/LiveComponent/tests/Functional/Test/InteractsWithLiveComponentsTest.php
+++ b/src/LiveComponent/tests/Functional/Test/InteractsWithLiveComponentsTest.php
@@ -18,7 +18,6 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
 use Symfony\UX\LiveComponent\Test\InteractsWithLiveComponents;
 use Symfony\UX\LiveComponent\Tests\Fixtures\Component\Component2;
 use Symfony\UX\LiveComponent\Tests\Fixtures\Factory\CategoryFixtureEntityFactory;
-use Zenstruck\Foundry\Test\Factories;
 use Zenstruck\Foundry\Test\ResetDatabase;
 
 /**
@@ -26,7 +25,6 @@ use Zenstruck\Foundry\Test\ResetDatabase;
  */
 final class InteractsWithLiveComponentsTest extends KernelTestCase
 {
-    use Factories;
     use InteractsWithLiveComponents;
     use ResetDatabase;
 
diff --git a/src/LiveComponent/tests/Unit/Form/ComponentWithFormTest.php b/src/LiveComponent/tests/Unit/Form/ComponentWithFormTest.php
index 01941f10..a7062605 100644
--- a/src/LiveComponent/tests/Unit/Form/ComponentWithFormTest.php
+++ b/src/LiveComponent/tests/Unit/Form/ComponentWithFormTest.php
@@ -14,7 +14,6 @@ namespace Symfony\UX\LiveComponent\Tests\Unit\Form;
 use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
 use Symfony\UX\LiveComponent\Tests\Fixtures\Component\FormComponentWithManyDifferentFieldsType;
 use Symfony\UX\LiveComponent\Tests\Fixtures\Factory\CategoryFixtureEntityFactory;
-use Zenstruck\Foundry\Test\Factories;
 use Zenstruck\Foundry\Test\ResetDatabase;
 
 /**
@@ -22,7 +21,6 @@ use Zenstruck\Foundry\Test\ResetDatabase;
  */
 class ComponentWithFormTest extends KernelTestCase
 {
-    use Factories;
     use ResetDatabase;
 
     public function testFormValues(): void
diff --git a/src/Turbo/tests/Request/RequestListenerTest.php b/src/Turbo/tests/Request/RequestListenerTest.php
new file mode 100644
index 00000000..27b1c73f
--- /dev/null
+++ b/src/Turbo/tests/Request/RequestListenerTest.php
@@ -0,0 +1,54 @@
+<?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\Request;
+
+use Symfony\Bundle\FrameworkBundle\KernelBrowser;
+use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
+use Symfony\UX\Turbo\TurboBundle;
+
+/**
+ * Tests the Turbo request listener.
+ *
+ * @author Alexander Hofbauer <a.hofbauer@fify.at>
+ */
+class RequestListenerTest extends WebTestCase
+{
+    public function testAddsTurboRequestFormat(): void
+    {
+        $client = static::createClient(server: [
+            'HTTP_ACCEPT' => 'text/vnd.turbo-stream.html, text/html, application/xhtml+xml',
+        ]);
+
+        // simulate worker mode
+        $client->disableReboot();
+
+        // request twice to test if listener is always called
+        $this->assertPreferredFormat();
+        $this->assertPreferredFormat();
+    }
+
+    private function assertPreferredFormat(): void
+    {
+        /** @var KernelBrowser $client */
+        $client = static::getClient();
+
+        $client->request('POST', '/turboRequest');
+
+        $response = $client->getResponse()->getContent();
+
+        $expectedJson = json_encode([
+            'preferred_format' => TurboBundle::STREAM_FORMAT,
+        ]);
+
+        $this->assertJsonStringEqualsJsonString($expectedJson ?: '', $response ?: '');
+    }
+}
diff --git a/src/Turbo/tests/app/Kernel.php b/src/Turbo/tests/app/Kernel.php
index 675a0f8b..3f68994e 100644
--- a/src/Turbo/tests/app/Kernel.php
+++ b/src/Turbo/tests/app/Kernel.php
@@ -29,6 +29,7 @@ use Symfony\Component\Form\Extension\Core\Type\FormType;
 use Symfony\Component\Form\Extension\Core\Type\SubmitType;
 use Symfony\Component\Form\Extension\Core\Type\TextType;
 use Symfony\Component\Form\FormFactoryInterface;
+use Symfony\Component\HttpFoundation\JsonResponse;
 use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\HttpFoundation\Response;
 use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@@ -144,6 +145,7 @@ class Kernel extends BaseKernel
         $routes->add('artists', '/artists')->controller('kernel::artists');
         $routes->add('artist', '/artists/{id}')->controller('kernel::artist');
         $routes->add('artist_from_song', '/artistFromSong')->controller('kernel::artistFromSong');
+        $routes->add('turbo_request', '/turboRequest')->controller('kernel::turboRequest');
     }
 
     public function getProjectDir(): string
@@ -312,4 +314,11 @@ class Kernel extends BaseKernel
             'song' => $song,
         ]));
     }
+
+    public function turboRequest(Request $request): Response
+    {
+        return new JsonResponse([
+            'preferred_format' => $request->getPreferredFormat(),
+        ]);
+    }
 }