From d9b090daa3caf6e3583328a45ae29f7d8c9b549a Mon Sep 17 00:00:00 2001 From: Hugo Alliaume Date: Thu, 15 Oct 2026 22:05:02 +0200 Subject: [PATCH] [LiveComponent][TwigComponent] Skip live-only and `data-model` listeners for other components | Q | A | -------------- | --- | Bug fix? | no | New feature? | no | Deprecations? | no | Documentation? | no | Issues | | License | MIT Of the 7 LiveComponent listeners on the TwigComponent events, 6 only act on some components: the live ones, or the ones rendered with a `data-model` prop. These listeners still run for every component. Their presence also turns off two TwigComponent fast paths: mounting an anonymous component directly and building its render variables without a copy. The example dashboard renders 1,860 components, of which 28 are live. With the internal `ux.twig_component.scoped_listener` tag, a listener can now declare its scope: the components it acts on. A scope is a metadata key or a list of props. When all the listeners of an event declare a scope, TwigComponent only dispatches this event for the components in one of these scopes. Any listener without a scope brings back the dispatch for every component, in the same order as before. This applies to a listener registered in the container. It also applies to a listener added at runtime to a dispatcher that is not compiled. Symfony 8.2 compiles the event dispatcher of the container. It deprecates adding listeners to this dispatcher. Reading the listeners of a compiled dispatcher thaws it into a regular one for the rest of the request. So TwigComponent does not look for listeners added at runtime to a compiled dispatcher. Applications that declare their listeners in the container see no change. LiveComponent tags its 4 live-only services and `DataModelPropsSubscriber` (props `data-model` and `dataModel`). As a result, TwigComponent no longer dispatches `PreCreateForRenderEvent`, `PreMountEvent`, `PostMountEvent` and `PreRenderEvent` for other components. `ResetDeterministicIdSubscriber` (`PostRenderEvent`) stays unscoped because it runs after every top-level component. Every combination of versions keeps working. An older TwigComponent ignores the tag. An older LiveComponent does not set it. | Measure | Before | After | | --- | --- | --- | | Standalone benchmark below: 100 renders of 333 nested anonymous components, LiveComponent installed | 393-398 ms | 335-339 ms (-15%) | | Admin dashboard, CPU per request | 60.7-62.5 ms | 54.7-55.5 ms (-10%) | | Same dashboard with 100 rows | 151.9-155.1 ms | 134.3-137.3 ms (-12%) | | Re-render of its live posts table after ticking rows | 46.0-47.0 ms | 27.9-28.7 ms (-39%) | | Re-render of its live posts table after typing in its search box | 50.9-52.5 ms | 32.8-34.0 ms (-35%) | | Blackfire, 5 dashboard requests: listener calls | 46,525 | 9,765 | | Blackfire, 5 dashboard requests: `AddLiveAttributesSubscriber::onPreRender()` calls | 9,300 | 140 | | Blackfire, 5 dashboard requests: wall time | 3.30 s | 2.73 s | The ranges are the medians of 3 interleaved runs each side for the standalone benchmark, and of 4 interleaved runs of 20 requests each side for the dashboard. The output is identical before and after. The benchmark needs the TwigComponent of this branch in `vendor/symfony/ux-twig-component`. ```php 'section', 'Table' => 'table', 'Row' => 'tr', 'Cell' => 'td'] as $name => $tag) { file_put_contents("$dir/templates/components/$name.html.twig", "<$tag {{ attributes }}>{% block content %}{% endblock %}"); } file_put_contents($dir.'/templates/components/Badge.html.twig', <<<'TWIG' {% props label %} {{ label }} TWIG); // A card holding a table of 25 rows of 6 cells, each cell holding a badge: 333 nested components, none of them live file_put_contents($dir.'/templates/page.html.twig', <<<'TWIG' {% for row in 1..25 %} {% for col in 1..6 %}{% endfor %} {% endfor %} TWIG); $kernel = new class('prod', false, $dir) extends Kernel { use MicroKernelTrait; public function __construct(string $environment, bool $debug, private string $dir) { parent::__construct($environment, $debug); } public function registerBundles(): iterable { yield new FrameworkBundle(); yield new TwigBundle(); yield new TwigComponentBundle(); yield new StimulusBundle(); yield new LiveComponentBundle(); } public function getProjectDir(): string { return $this->dir; } protected function configureContainer(ContainerConfigurator $container): void { $container->extension('framework', ['secret' => 'S3CRET', 'test' => true, 'http_method_override' => false]); $container->extension('twig_component', ['anonymous_template_directory' => 'components', 'defaults' => []]); } }; $kernel->boot(); $twig = $kernel->getContainer()->get('test.service_container')->get('twig'); // A controller renders its template some 30 frames deep (kernel, listeners, controller...) $deep = static function (int $depth, callable $fn) use (&$deep) { return $depth > 0 ? $deep($depth - 1, $fn) : $fn(); }; $html = $deep(30, static fn () => $twig->render('page.html.twig')); $times = []; for ($run = 0; $run < 7; ++$run) { $start = hrtime(true); for ($i = 0; $i < 100; ++$i) { $deep(30, static fn () => $twig->render('page.html.twig')); } $times[] = (hrtime(true) - $start) / 1e6; } sort($times); printf("100 renders of 333 nested components, LiveComponent installed: median %.0f ms (min %.0f, max %.0f) | sha1 %s\n", $times[3], $times[0], $times[6], sha1($html)); ``` Blackfire profiles (dashboard, 5 requests): - before: https://app.blackfire.io/envs/5f4f9a62-eaa0-45ee-b7b3-a1b879f550e9/profiles/cc239f2d-01fa-4e52-9b76-cabfdcbae8d5/graph - after: https://app.blackfire.io/envs/5f4f9a62-eaa0-45ee-b7b3-a1b879f550e9/profiles/f7309c2d-8ca1-458a-b2b2-86ffce8c80a6/graph - comparison: https://app.blackfire.io/envs/5f4f9a62-eaa0-45ee-b7b3-a1b879f550e9/profiles/compare/cc239f2d-01fa-4e52-9b76-cabfdcbae8d5...f7309c2d-8ca1-458a-b2b2-86ffce8c80a6/graph The dashboard is available at https://github.com/Kocal/sf-ux-perfs-twig-components. --- .../LiveComponentExtension.php | 9 +- src/TwigComponent/src/ComponentFactory.php | 59 ++++++++-- src/TwigComponent/src/ComponentRenderer.php | 16 +-- .../Compiler/ComponentListenerScopesPass.php | 110 ++++++++++++++++++ .../TwigComponentExtension.php | 1 + src/TwigComponent/src/TwigComponentBundle.php | 4 + .../EventListener/ComponentEventRecorder.php | 35 ++++++ src/TwigComponent/tests/Fixtures/Kernel.php | 28 +++++ .../tests/Integration/ComponentEventTest.php | 39 +++++++ .../tests/Unit/ComponentFactoryTest.php | 57 +++++++++ .../ComponentListenerScopesPassTest.php | 72 ++++++++++++ 11 files changed, 409 insertions(+), 21 deletions(-) create mode 100644 src/TwigComponent/src/DependencyInjection/Compiler/ComponentListenerScopesPass.php create mode 100644 src/TwigComponent/tests/Fixtures/EventListener/ComponentEventRecorder.php create mode 100644 src/TwigComponent/tests/Unit/DependencyInjection/Compiler/ComponentListenerScopesPassTest.php diff --git a/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php index 8bf20c7d802..57d709be3a7 100644 --- a/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php +++ b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php @@ -127,6 +127,7 @@ static function (ChildDefinition $definition, AsLiveComponent $attribute) { $container->register('ux.live_component.event_listener.data_model_props_subscriber', DataModelPropsSubscriber::class) ->addTag('kernel.event_subscriber') + ->addTag('ux.twig_component.scoped_listener', ['props' => ['data-model', 'dataModel']]) ->setArguments([ new Reference('ux.twig_component.live_component_stack'), new Reference('property_accessor'), @@ -163,7 +164,8 @@ static function (ChildDefinition $definition, AsLiveComponent $attribute) { ]) ->addTag('container.service_subscriber', ['key' => DeterministicTwigIdCalculator::class, 'id' => 'ux.live_component.deterministic_id_calculator']) ->addTag('container.service_subscriber', ['key' => ChildComponentPartialRenderer::class, 'id' => 'ux.live_component.child_component_partial_renderer']) - ->addTag('kernel.event_subscriber'); + ->addTag('kernel.event_subscriber') + ->addTag('ux.twig_component.scoped_listener', ['metadata' => 'live']); $container->register('ux.live_component.child_component_partial_renderer', ChildComponentPartialRenderer::class) ->setArguments([ @@ -235,6 +237,7 @@ static function (ChildDefinition $definition, AsLiveComponent $attribute) { new Reference('twig'), ]) ->addTag('kernel.event_subscriber') + ->addTag('ux.twig_component.scoped_listener', ['metadata' => 'live']) ->addTag('container.service_subscriber', ['key' => LiveControllerAttributesCreator::class, 'id' => 'ux.live_component.live_controller_attributes_creator']) ; @@ -250,10 +253,12 @@ static function (ChildDefinition $definition, AsLiveComponent $attribute) { new Reference('ux.live_component.query_string_props_extractor'), new Reference('property_accessor'), ]) - ->addTag('kernel.event_subscriber'); + ->addTag('kernel.event_subscriber') + ->addTag('ux.twig_component.scoped_listener', ['metadata' => 'live']); $container->register('ux.live_component.defer_live_component_subscriber', DeferLiveComponentSubscriber::class) ->addTag('kernel.event_subscriber') + ->addTag('ux.twig_component.scoped_listener', ['metadata' => 'live']) ; $container->register('ux.live_component.deterministic_id_calculator', DeterministicTwigIdCalculator::class) diff --git a/src/TwigComponent/src/ComponentFactory.php b/src/TwigComponent/src/ComponentFactory.php index 4cfbcf25ae1..0eee741c7ea 100644 --- a/src/TwigComponent/src/ComponentFactory.php +++ b/src/TwigComponent/src/ComponentFactory.php @@ -13,6 +13,7 @@ use Psr\Container\ContainerInterface; use Psr\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\EventDispatcher\CompiledEventDispatcher; use Symfony\Component\EventDispatcher\EventDispatcherInterface as IntrospectableEventDispatcherInterface; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Contracts\Service\ResetInterface; @@ -42,8 +43,17 @@ final class ComponentFactory implements ResetInterface private readonly ?IntrospectableEventDispatcherInterface $introspectableDispatcher; /** - * @param array $config - * @param array $classMap + * Whether to count the listeners of an event, to find the ones added at runtime. + * + * Adding listeners to a compiled dispatcher is deprecated since Symfony 8.2, and reading them would thaw it. + */ + private readonly bool $countsRuntimeListeners; + + /** + * @param array $config + * @param array $classMap + * @param array, 2: int}> $listenerScopes For the events whose listeners all only act on some components: + * the metadata key and the props of those components, and the number of listeners */ public function __construct( private ComponentTemplateFinderInterface $componentTemplateFinder, @@ -53,8 +63,10 @@ public function __construct( private array $config, private readonly array $classMap, private readonly Environment $twig, + private readonly array $listenerScopes = [], ) { $this->introspectableDispatcher = $eventDispatcher instanceof IntrospectableEventDispatcherInterface ? $eventDispatcher : null; + $this->countsRuntimeListeners = !$eventDispatcher instanceof CompiledEventDispatcher; } public function metadataFor(string $name): ComponentMetadata @@ -107,10 +119,11 @@ public function create(string $name, array $data = []): MountedComponent public function mountFromObject(object $component, array $data, ComponentMetadata $componentMetadata): MountedComponent { $originalData = $data; - $dispatchPreMount = null === $this->introspectableDispatcher || $this->introspectableDispatcher->hasListeners(PreMountEvent::class); + $dispatchPreMount = $this->hasListenersFor(PreMountEvent::class, $componentMetadata, $data); + $dispatchPostMount = $this->hasListenersFor(PostMountEvent::class, $componentMetadata, $data); // Without mount listeners, nothing can change the data of an anonymous component: it has no hooks and no properties - if (!$dispatchPreMount && $component instanceof AnonymousComponent && !$this->introspectableDispatcher->hasListeners(PostMountEvent::class)) { + if (!$dispatchPreMount && !$dispatchPostMount && $component instanceof AnonymousComponent) { $component->mount($data); $extraMetadata = []; } else { @@ -128,7 +141,7 @@ public function mountFromObject(object $component, array $data, ComponentMetadat } } - $extraMetadata = $this->postMount($component, $data, $componentMetadata); + $extraMetadata = $this->postMount($component, $data, $componentMetadata, $dispatchPostMount); } // create attributes from "attributes" key if exists @@ -167,6 +180,38 @@ public function get(string $name): object return $this->components->get($metadata->getName()); } + /** + * Whether the given event has a listener that may act on the given component. + * + * @internal + * + * @param class-string $eventClass + * @param array $props The props the component is rendered with + */ + public function hasListenersFor(string $eventClass, string|ComponentMetadata $component, array $props): bool + { + if (null === $this->introspectableDispatcher) { + return true; + } + + if ($scope = $this->listenerScopes[$eventClass] ?? null) { + [$scopedMetadataKey, $scopedProps, $listenerCount] = $scope; + $inScope = $scopedProps && array_intersect_key($props, $scopedProps); + + if (!$inScope && null !== $scopedMetadataKey) { + $metadata = $component instanceof ComponentMetadata ? $component : $this->metadataFor($component); + $inScope = $metadata->get($scopedMetadataKey, false); + } + + if (!$inScope) { + // the listeners known when the container was built all ignore this component, not the ones added since + return $this->countsRuntimeListeners && \count($this->introspectableDispatcher->getListeners($eventClass)) > $listenerCount; + } + } + + return $this->introspectableDispatcher->hasListeners($eventClass); + } + private function mount(object $component, array &$data, ComponentMetadata $componentMetadata): void { if ($component instanceof AnonymousComponent) { @@ -215,10 +260,10 @@ private function preMount(object $component, array &$data, ComponentMetadata $co /** * @return array The extra metadata collected by the listeners */ - private function postMount(object $component, array &$data, ComponentMetadata $componentMetadata): array + private function postMount(object $component, array &$data, ComponentMetadata $componentMetadata, bool $dispatch): array { $extraMetadata = []; - if (null === $this->introspectableDispatcher || $this->introspectableDispatcher->hasListeners(PostMountEvent::class)) { + if ($dispatch) { $event = new PostMountEvent($component, $data, $componentMetadata); $this->eventDispatcher->dispatch($event); $data = $event->getData(); diff --git a/src/TwigComponent/src/ComponentRenderer.php b/src/TwigComponent/src/ComponentRenderer.php index 33c0be609d0..99601ca382c 100644 --- a/src/TwigComponent/src/ComponentRenderer.php +++ b/src/TwigComponent/src/ComponentRenderer.php @@ -11,7 +11,6 @@ namespace Symfony\UX\TwigComponent; -use Symfony\Component\EventDispatcher\EventDispatcherInterface as IntrospectableEventDispatcherInterface; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; use Symfony\Contracts\Service\ResetInterface; use Symfony\UX\TwigComponent\Event\PostRenderEvent; @@ -28,12 +27,6 @@ final class ComponentRenderer implements ComponentRendererInterface, ResetInterf { private array $templateClasses = []; - /** - * Set when the dispatcher can be asked whether an event has listeners, - * allowing to skip creating and dispatching events nobody listens to. - */ - private readonly ?IntrospectableEventDispatcherInterface $introspectableDispatcher; - public function __construct( private Environment $twig, private EventDispatcherInterface $dispatcher, @@ -41,7 +34,6 @@ public function __construct( private ComponentProperties $componentProperties, private ComponentStack $componentStack, ) { - $this->introspectableDispatcher = $dispatcher instanceof IntrospectableEventDispatcherInterface ? $dispatcher : null; } /** @@ -49,7 +41,7 @@ public function __construct( */ public function preCreateForRender(string $name, array $props = []): ?string { - if ($this->introspectableDispatcher && !$this->introspectableDispatcher->hasListeners(PreCreateForRenderEvent::class)) { + if (!$this->factory->hasListenersFor(PreCreateForRenderEvent::class, $name, $props)) { return null; } @@ -90,7 +82,7 @@ public function render(MountedComponent $mounted): string } finally { $mounted = $this->componentStack->pop(); - if (null === $this->introspectableDispatcher || $this->introspectableDispatcher->hasListeners(PostRenderEvent::class)) { + if ($this->factory->hasListenersFor(PostRenderEvent::class, $mounted->getName(), $mounted->getInputProps())) { $this->dispatcher->dispatch(new PostRenderEvent($mounted)); } } @@ -113,7 +105,7 @@ public function finishEmbeddedComponentRender(): void { $mounted = $this->componentStack->pop(); - if (null === $this->introspectableDispatcher || $this->introspectableDispatcher->hasListeners(PostRenderEvent::class)) { + if ($this->factory->hasListenersFor(PostRenderEvent::class, $mounted->getName(), $mounted->getInputProps())) { $this->dispatcher->dispatch(new PostRenderEvent($mounted)); } } @@ -136,7 +128,7 @@ private function preRender(MountedComponent $mounted, array $context = []): PreR ...$classProps, $metadata->getAttributesVar() => $mounted->getAttributes(), ]; - if (null === $this->introspectableDispatcher || $this->introspectableDispatcher->hasListeners(PreRenderEvent::class)) { + if ($this->factory->hasListenersFor(PreRenderEvent::class, $metadata, $inputProps)) { $event = new PreRenderEvent($mounted, $metadata, $variables); $this->dispatcher->dispatch($event); $variables = $event->getVariables(); diff --git a/src/TwigComponent/src/DependencyInjection/Compiler/ComponentListenerScopesPass.php b/src/TwigComponent/src/DependencyInjection/Compiler/ComponentListenerScopesPass.php new file mode 100644 index 00000000000..ddf7d9135df --- /dev/null +++ b/src/TwigComponent/src/DependencyInjection/Compiler/ComponentListenerScopesPass.php @@ -0,0 +1,110 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\TwigComponent\DependencyInjection\Compiler; + +use Symfony\Component\DependencyInjection\Argument\ServiceClosureArgument; +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Reference; +use Symfony\UX\TwigComponent\Event\PostMountEvent; +use Symfony\UX\TwigComponent\Event\PostRenderEvent; +use Symfony\UX\TwigComponent\Event\PreCreateForRenderEvent; +use Symfony\UX\TwigComponent\Event\PreMountEvent; +use Symfony\UX\TwigComponent\Event\PreRenderEvent; + +/** + * Finds the component events whose listeners all only care about some components. + * + * A listener tagged "ux.twig_component.scoped_listener" declares the components it acts on: with a "metadata" + * attribute, the components whose metadata holds a truthy value under that key; with a "props" attribute, the + * components rendered with at least one of these props. When every listener of an event declares a scope, and + * those with a "metadata" attribute all name the same key, the event is only dispatched for the components in + * the scope of at least one of them. + * + * @author Hugo Alliaume + * + * @internal + */ +final class ComponentListenerScopesPass implements CompilerPassInterface +{ + public const SCOPED_LISTENER_TAG = 'ux.twig_component.scoped_listener'; + + private const EVENTS = [ + PreCreateForRenderEvent::class, + PreMountEvent::class, + PostMountEvent::class, + PreRenderEvent::class, + PostRenderEvent::class, + ]; + + public function process(ContainerBuilder $container): void + { + if (!$container->hasDefinition('ux.twig_component.component_factory') || !$container->has('event_dispatcher')) { + return; + } + + $scopes = []; + foreach ($container->findTaggedServiceIds(self::SCOPED_LISTENER_TAG) as $id => $tags) { + $scope = array_filter([ + 'metadata' => $tags[0]['metadata'] ?? null, + 'props' => (array) ($tags[0]['props'] ?? []), + ]); + $scopes[$id] = $scope ?: null; + } + + if (!$scopes) { + return; + } + + $listenerScopes = []; + foreach ($container->findDefinition('event_dispatcher')->getMethodCalls() as [$method, $arguments]) { + if ('addListener' !== $method || !\in_array($arguments[0] ?? null, self::EVENTS, true)) { + continue; + } + + $listenerScopes[$arguments[0]][] = $this->scopeOf($arguments[1] ?? null, $scopes); + } + + $eventScopes = []; + foreach ($listenerScopes as $eventClass => $eventListenerScopes) { + if (\in_array(null, $eventListenerScopes, true)) { + continue; + } + + $metadataKeys = array_unique(array_column($eventListenerScopes, 'metadata')); + if (\count($metadataKeys) > 1) { + continue; + } + + $props = array_fill_keys(array_merge(...array_column($eventListenerScopes, 'props')), true); + $eventScopes[$eventClass] = [$metadataKeys[0] ?? null, $props, \count($eventListenerScopes)]; + } + + $container->getDefinition('ux.twig_component.component_factory')->setArgument(7, $eventScopes); + } + + /** + * @param array}|null> $scopes + * + * @return array{metadata?: string, props?: list}|null + */ + private function scopeOf(mixed $listener, array $scopes): ?array + { + if (!\is_array($listener) || !($listener[0] ?? null) instanceof ServiceClosureArgument) { + return null; + } + + $reference = $listener[0]->getValues()[0] ?? null; + + return $reference instanceof Reference ? $scopes[(string) $reference] ?? null : null; + } +} diff --git a/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php b/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php index 8bf1f4fa419..97e0a639c4c 100644 --- a/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php +++ b/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php @@ -87,6 +87,7 @@ static function (ChildDefinition $definition, AsTwigComponent $attribute) { new AbstractArgument(\sprintf('Added in %s.', TwigComponentPass::class)), new AbstractArgument(\sprintf('Added in %s.', TwigComponentPass::class)), new Reference('twig'), + [], ]) ->addTag('kernel.reset', ['method' => 'reset']) ; diff --git a/src/TwigComponent/src/TwigComponentBundle.php b/src/TwigComponent/src/TwigComponentBundle.php index d11c31443d3..f0e6c16db5a 100644 --- a/src/TwigComponent/src/TwigComponentBundle.php +++ b/src/TwigComponent/src/TwigComponentBundle.php @@ -11,8 +11,10 @@ namespace Symfony\UX\TwigComponent; +use Symfony\Component\DependencyInjection\Compiler\PassConfig; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HttpKernel\Bundle\Bundle; +use Symfony\UX\TwigComponent\DependencyInjection\Compiler\ComponentListenerScopesPass; use Symfony\UX\TwigComponent\DependencyInjection\Compiler\TwigComponentPass; /** @@ -23,6 +25,8 @@ final class TwigComponentBundle extends Bundle public function build(ContainerBuilder $container): void { $container->addCompilerPass(new TwigComponentPass()); + // after RegisterListenersPass, which adds the listeners to the event dispatcher + $container->addCompilerPass(new ComponentListenerScopesPass(), PassConfig::TYPE_BEFORE_REMOVING, -8); } public function getPath(): string diff --git a/src/TwigComponent/tests/Fixtures/EventListener/ComponentEventRecorder.php b/src/TwigComponent/tests/Fixtures/EventListener/ComponentEventRecorder.php new file mode 100644 index 00000000000..397f03650f8 --- /dev/null +++ b/src/TwigComponent/tests/Fixtures/EventListener/ComponentEventRecorder.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\TwigComponent\Tests\Fixtures\EventListener; + +use Symfony\UX\TwigComponent\Event\PostMountEvent; +use Symfony\UX\TwigComponent\Event\PostRenderEvent; +use Symfony\UX\TwigComponent\Event\PreCreateForRenderEvent; +use Symfony\UX\TwigComponent\Event\PreMountEvent; +use Symfony\UX\TwigComponent\Event\PreRenderEvent; + +final class ComponentEventRecorder +{ + /** + * @var list + */ + public array $events = []; + + public function __invoke(object $event): void + { + $this->events[] = [$event::class, match (true) { + $event instanceof PreCreateForRenderEvent => $event->getName(), + $event instanceof PreMountEvent, $event instanceof PostMountEvent, $event instanceof PreRenderEvent => $event->getMetadata()?->getName(), + $event instanceof PostRenderEvent => $event->getMountedComponent()->getName(), + }]; + } +} diff --git a/src/TwigComponent/tests/Fixtures/Kernel.php b/src/TwigComponent/tests/Fixtures/Kernel.php index 481e103c719..68440bb09b3 100644 --- a/src/TwigComponent/tests/Fixtures/Kernel.php +++ b/src/TwigComponent/tests/Fixtures/Kernel.php @@ -17,8 +17,14 @@ use Symfony\Bundle\TwigBundle\TwigBundle; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; use Symfony\Component\HttpKernel\Kernel as BaseKernel; +use Symfony\UX\TwigComponent\Event\PostMountEvent; +use Symfony\UX\TwigComponent\Event\PostRenderEvent; +use Symfony\UX\TwigComponent\Event\PreCreateForRenderEvent; +use Symfony\UX\TwigComponent\Event\PreMountEvent; +use Symfony\UX\TwigComponent\Event\PreRenderEvent; use Symfony\UX\TwigComponent\Tests\Fixtures\Bundle\AcmeBundle\AcmeBundle; use Symfony\UX\TwigComponent\Tests\Fixtures\Component\ComponentB; +use Symfony\UX\TwigComponent\Tests\Fixtures\EventListener\ComponentEventRecorder; use Symfony\UX\TwigComponent\TwigComponentBundle; use Twig\Extra\TwigExtraBundle\TwigExtraBundle; @@ -92,6 +98,28 @@ protected function configureContainer(ContainerConfigurator $c): void ]) ; + if ('scoped_listener' === $this->environment) { + $services->set('scoped_component', ComponentB::class) + ->tag('twig.component', [ + 'key' => 'scoped_component', + 'template' => 'components/custom2.html.twig', + 'scoped' => true, + ]) + ; + $scopes = [ + 'scoped_listener' => ['metadata' => 'scoped'], + 'props_scoped_listener' => ['props' => ['data-scoped', 'dataScoped']], + ]; + foreach ($scopes as $id => $scope) { + $recorder = $services->set($id, ComponentEventRecorder::class) + ->tag('ux.twig_component.scoped_listener', $scope) + ; + foreach ([PreCreateForRenderEvent::class, PreMountEvent::class, PostMountEvent::class, PreRenderEvent::class, PostRenderEvent::class] as $eventClass) { + $recorder->tag('kernel.event_listener', ['event' => $eventClass]); + } + } + } + if ('missing_key' === $this->environment) { $services->set('missing_key', ComponentB::class) ->tag('twig.component') diff --git a/src/TwigComponent/tests/Integration/ComponentEventTest.php b/src/TwigComponent/tests/Integration/ComponentEventTest.php index 6be538246dd..62ab18ef90d 100644 --- a/src/TwigComponent/tests/Integration/ComponentEventTest.php +++ b/src/TwigComponent/tests/Integration/ComponentEventTest.php @@ -68,6 +68,45 @@ public function testEventsAreDispatchedForAnonymousComponentsWhenListenersExist( ], $dispatched); } + public function testScopedListenersOnlyReceiveTheEventsOfTheComponentsInTheirScope(): void + { + self::bootKernel(['environment' => 'scoped_listener']); + + self::getContainer()->get(Environment::class) + ->createTemplate('{{ component("scoped_component") }}{{ component("BasicComponent") }}') + ->render(); + + // PreRender and PostRender have listeners outside the scope in this kernel, so every component still gets them + self::assertSame([ + [PreCreateForRenderEvent::class, 'scoped_component'], + [PreMountEvent::class, 'scoped_component'], + [PostMountEvent::class, 'scoped_component'], + [PreRenderEvent::class, 'scoped_component'], + [PostRenderEvent::class, 'scoped_component'], + [PreRenderEvent::class, 'BasicComponent'], + [PostRenderEvent::class, 'BasicComponent'], + ], self::getContainer()->get('scoped_listener')->events); + } + + public function testPropsScopedListenersOnlyReceiveTheEventsOfTheComponentsRenderedWithOneOfTheirProps(): void + { + self::bootKernel(['environment' => 'scoped_listener']); + + self::getContainer()->get(Environment::class) + ->createTemplate('{{ component("BasicComponent") }}{{ component("BasicComponent", {dataScoped: true}) }}') + ->render(); + + self::assertSame([ + [PreRenderEvent::class, 'BasicComponent'], + [PostRenderEvent::class, 'BasicComponent'], + [PreCreateForRenderEvent::class, 'BasicComponent'], + [PreMountEvent::class, 'BasicComponent'], + [PostMountEvent::class, 'BasicComponent'], + [PreRenderEvent::class, 'BasicComponent'], + [PostRenderEvent::class, 'BasicComponent'], + ], self::getContainer()->get('props_scoped_listener')->events); + } + public static function provideFooBarSyntaxes(): iterable { yield 'TWIG component tag' => ['{% component "FooBar:Baz" %}{% endcomponent %}']; diff --git a/src/TwigComponent/tests/Unit/ComponentFactoryTest.php b/src/TwigComponent/tests/Unit/ComponentFactoryTest.php index 4ae4a5b66e1..2456a1ceecc 100644 --- a/src/TwigComponent/tests/Unit/ComponentFactoryTest.php +++ b/src/TwigComponent/tests/Unit/ComponentFactoryTest.php @@ -13,10 +13,14 @@ use PHPUnit\Framework\TestCase; use Psr\Container\ContainerInterface; +use Symfony\Component\EventDispatcher\CompiledEventDispatcher; +use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; use Symfony\UX\TwigComponent\ComponentFactory; +use Symfony\UX\TwigComponent\ComponentMetadata; use Symfony\UX\TwigComponent\ComponentTemplateFinderInterface; +use Symfony\UX\TwigComponent\Event\PreMountEvent; use Twig\Environment; /** @@ -93,4 +97,57 @@ public function testMetadataForReuseAnonymousConfig(): void $this->assertSame('foo', $metadata->getName()); $this->assertSame('foo.html.twig', $metadata->getTemplate()); } + + public function testAListenerAddedAtRuntimeBringsBackTheEventsOfTheComponentsOutOfTheScope(): void + { + $dispatcher = new EventDispatcher(); + $dispatcher->addListener(PreMountEvent::class, static function (): void {}); + $factory = new ComponentFactory( + $this->createStub(ComponentTemplateFinderInterface::class), + $this->createStub(ContainerInterface::class), + $this->createStub(PropertyAccessorInterface::class), + $dispatcher, + [], + [], + $this->createStub(Environment::class), + [PreMountEvent::class => ['live', [], 1]], + ); + $metadata = new ComponentMetadata(['key' => 'foo', 'template' => 'foo.html.twig']); + + $beforeAddingAListener = $factory->hasListenersFor(PreMountEvent::class, $metadata, []); + $dispatcher->addListener(PreMountEvent::class, static function (): void {}); + $afterAddingAListener = $factory->hasListenersFor(PreMountEvent::class, $metadata, []); + + $this->assertFalse($beforeAddingAListener); + $this->assertTrue($afterAddingAListener); + } + + public function testCheckingTheListenersOfAComponentOutOfTheirScopeDoesNotThawACompiledDispatcher(): void + { + if (!class_exists(CompiledEventDispatcher::class)) { + $this->markTestSkipped('CompiledEventDispatcher requires symfony/event-dispatcher 8.2 or higher.'); + } + + $dispatcher = new CompiledEventDispatcher( + [PreMountEvent::class => [0 => [['live_listener', '__invoke']]]], + $this->createStub(ContainerInterface::class), + ); + $factory = new ComponentFactory( + $this->createStub(ComponentTemplateFinderInterface::class), + $this->createStub(ContainerInterface::class), + $this->createStub(PropertyAccessorInterface::class), + $dispatcher, + [], + [], + $this->createStub(Environment::class), + [PreMountEvent::class => ['live', [], 1]], + ); + $metadata = new ComponentMetadata(['key' => 'foo', 'template' => 'foo.html.twig']); + + $hasListeners = $factory->hasListenersFor(PreMountEvent::class, $metadata, []); + + $this->assertFalse($hasListeners); + // a thawed dispatcher dispatches like a compiled one, only its state tells them apart + $this->assertNull((fn () => $this->dispatcher)->call($dispatcher)); + } } diff --git a/src/TwigComponent/tests/Unit/DependencyInjection/Compiler/ComponentListenerScopesPassTest.php b/src/TwigComponent/tests/Unit/DependencyInjection/Compiler/ComponentListenerScopesPassTest.php new file mode 100644 index 00000000000..cbc611bc880 --- /dev/null +++ b/src/TwigComponent/tests/Unit/DependencyInjection/Compiler/ComponentListenerScopesPassTest.php @@ -0,0 +1,72 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\TwigComponent\Tests\Unit\DependencyInjection\Compiler; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\EventDispatcher\DependencyInjection\RegisterListenersPass; +use Symfony\Component\EventDispatcher\EventDispatcher; +use Symfony\UX\TwigComponent\ComponentFactory; +use Symfony\UX\TwigComponent\DependencyInjection\Compiler\ComponentListenerScopesPass; +use Symfony\UX\TwigComponent\Event\PostMountEvent; +use Symfony\UX\TwigComponent\Event\PreMountEvent; +use Symfony\UX\TwigComponent\Tests\Fixtures\EventListener\ComponentEventRecorder; + +final class ComponentListenerScopesPassTest extends TestCase +{ + public function testTheScopesOfTheListenersOfAnEventAreMerged(): void + { + $container = $this->createContainer([ + 'live_listener' => [PostMountEvent::class, ['metadata' => 'live']], + 'data_model_listener' => [PostMountEvent::class, ['props' => ['data-model', 'dataModel']]], + ]); + + new ComponentListenerScopesPass()->process($container); + + $this->assertSame([ + PostMountEvent::class => ['live', ['data-model' => true, 'dataModel' => true], 2], + ], $container->getDefinition('ux.twig_component.component_factory')->getArgument(7)); + } + + public function testAnEventWhoseListenersNameDifferentMetadataKeysIsNotScoped(): void + { + $container = $this->createContainer([ + 'live_listener' => [PreMountEvent::class, ['metadata' => 'live']], + 'other_listener' => [PreMountEvent::class, ['metadata' => 'other']], + ]); + + new ComponentListenerScopesPass()->process($container); + + $this->assertSame([], $container->getDefinition('ux.twig_component.component_factory')->getArgument(7)); + } + + /** + * @param array}> $listeners + */ + private function createContainer(array $listeners): ContainerBuilder + { + $container = new ContainerBuilder(); + $container->register('event_dispatcher', EventDispatcher::class); + $container->register('ux.twig_component.component_factory', ComponentFactory::class) + ->setArguments([null, null, null, null, [], [], null, []]); + + foreach ($listeners as $id => [$eventClass, $scope]) { + $container->register($id, ComponentEventRecorder::class) + ->addTag('kernel.event_listener', ['event' => $eventClass]) + ->addTag(ComponentListenerScopesPass::SCOPED_LISTENER_TAG, $scope); + } + + new RegisterListenersPass()->process($container); + + return $container; + } +}