Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down Expand Up @@ -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([
Expand Down Expand Up @@ -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'])
;

Expand All @@ -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)
Expand Down
59 changes: 52 additions & 7 deletions src/TwigComponent/src/ComponentFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -42,8 +43,17 @@ final class ComponentFactory implements ResetInterface
private readonly ?IntrospectableEventDispatcherInterface $introspectableDispatcher;

/**
* @param array<string, array> $config
* @param array<class-string, string> $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<string, array> $config
* @param array<class-string, string> $classMap
* @param array<class-string, array{0: string|null, 1: array<string, true>, 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,
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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<string, mixed> $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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// the listeners known when the container was built all ignore this component, not the ones added since
// Listeners registered at compile time are out of scope, but those added at runtime may not be.

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) {
Expand Down Expand Up @@ -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();
Expand Down
16 changes: 4 additions & 12 deletions src/TwigComponent/src/ComponentRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -28,28 +27,21 @@ 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,
private ComponentFactory $factory,
private ComponentProperties $componentProperties,
private ComponentStack $componentStack,
) {
$this->introspectableDispatcher = $dispatcher instanceof IntrospectableEventDispatcherInterface ? $dispatcher : null;
}

/**
* Allow the render process to be short-circuited.
*/
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;
}

Expand Down Expand Up @@ -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));
}
}
Expand All @@ -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));
}
}
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
<?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\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 <hugo@alliau.me>
*
* @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<string, array{metadata?: string, props?: list<string>}|null> $scopes
*
* @return array{metadata?: string, props?: list<string>}|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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
[],

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
[],
new AbstractArgument(\sprintf('Added in %s.', ComponentListenerScopesPass::class)),

])
->addTag('kernel.reset', ['method' => 'reset'])
;
Expand Down
4 changes: 4 additions & 0 deletions src/TwigComponent/src/TwigComponentBundle.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?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\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<array{class-string, string}>
*/
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(),
}];
}
}
Loading
Loading