diff --git a/src/TwigComponent/config/cache.php b/src/TwigComponent/config/cache.php new file mode 100644 index 00000000000..e8f62ee2027 --- /dev/null +++ b/src/TwigComponent/config/cache.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\TwigComponent\DependencyInjection\Loader\Configurator; + +use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; + +return static function (ContainerConfigurator $container): void { + $container->services() + ->set('cache.ux.twig_component') + ->parent('cache.system') + ->private() + ->tag('cache.pool') + ; +}; diff --git a/src/TwigComponent/src/CacheWarmer/TwigComponentCacheWarmer.php b/src/TwigComponent/src/CacheWarmer/TwigComponentCacheWarmer.php new file mode 100644 index 00000000000..fad5c74cade --- /dev/null +++ b/src/TwigComponent/src/CacheWarmer/TwigComponentCacheWarmer.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\TwigComponent\CacheWarmer; + +use Psr\Container\ContainerInterface; +use Symfony\Component\HttpKernel\CacheWarmer\CacheWarmerInterface; +use Symfony\Contracts\Service\ServiceSubscriberInterface; +use Symfony\UX\TwigComponent\ComponentProperties; + +/** + * Warm the TwigComponent metadata caches. + * + * @author Simon André + * + * @internal + */ +final class TwigComponentCacheWarmer implements CacheWarmerInterface, ServiceSubscriberInterface +{ + /** + * As this cache warmer is optional, dependencies should be lazy-loaded, that's why a container should be injected. + */ + public function __construct( + private readonly ContainerInterface $container, + ) { + } + + public static function getSubscribedServices(): array + { + return [ + 'ux.twig_component.component_properties' => ComponentProperties::class, + ]; + } + + public function warmUp(string $cacheDir, ?string $buildDir = null): array + { + $properties = $this->container->get('ux.twig_component.component_properties'); + $properties->warmup(); + + return []; + } + + public function isOptional(): bool + { + return true; + } +} diff --git a/src/TwigComponent/src/ComponentProperties.php b/src/TwigComponent/src/ComponentProperties.php new file mode 100644 index 00000000000..abb8c733a0e --- /dev/null +++ b/src/TwigComponent/src/ComponentProperties.php @@ -0,0 +1,148 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\TwigComponent; + +use Symfony\Component\Cache\Adapter\AdapterInterface; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate; + +/** + * @author Simon André + * + * @internal + */ +final class ComponentProperties +{ + private const CACHE_KEY = 'ux.twig_component.component_properties'; + + /** + * @var array, + * methods: array, + * }|null> + */ + private array $classMetadata; + + public function __construct( + private readonly PropertyAccessorInterface $propertyAccessor, + ?array $classMetadata = [], + private readonly ?AdapterInterface $cache = null, + ) { + $cacheItem = $this->cache?->getItem(self::CACHE_KEY); + + $this->classMetadata = $cacheItem?->isHit() ? [...$cacheItem->get(), ...$classMetadata] : $classMetadata; + } + + /** + * @return array + */ + public function getProperties(object $component, bool $publicProps = false): array + { + return iterator_to_array($this->extractProperties($component, $publicProps)); + } + + public function warmup(): void + { + if (!$this->cache) { + return; + } + + foreach ($this->classMetadata as $class => $metadata) { + if (null === $metadata) { + $this->classMetadata[$class] = $this->loadClassMetadata($class); + } + } + + $this->cache->save($this->cache->getItem(self::CACHE_KEY)->set($this->classMetadata)); + } + + /** + * @return \Generator + */ + private function extractProperties(object $component, bool $publicProps): \Generator + { + yield from $publicProps ? get_object_vars($component) : []; + + $metadata = $this->classMetadata[$component::class] ??= $this->loadClassMetadata($component::class); + + foreach ($metadata['properties'] as $propertyName => $property) { + $value = $property['getter'] ? $component->{$property['getter']}() : $this->propertyAccessor->getValue($component, $propertyName); + if ($property['destruct'] ?? false) { + yield from $value; + } else { + yield $property['name'] => $value; + } + } + + foreach ($metadata['methods'] as $methodName => $method) { + if ($method['destruct'] ?? false) { + yield from $component->{$methodName}(); + } else { + yield $method['name'] => $component->{$methodName}(); + } + } + } + + /** + * @param class-string $class + * + * @return array{ + * properties: array, + * methods: array, + * } + */ + private function loadClassMetadata(string $class): array + { + $refClass = new \ReflectionClass($class); + + $properties = []; + foreach ($refClass->getProperties() as $property) { + if (!$attributes = $property->getAttributes(ExposeInTemplate::class)) { + continue; + } + $attribute = $attributes[0]->newInstance(); + $properties[$property->name] = [ + 'name' => $attribute->name ?? $property->name, + 'getter' => $attribute->getter ? rtrim($attribute->getter, '()') : null, + ]; + if ($attribute->destruct) { + unset($properties[$property->name]['name']); + $properties[$property->name]['destruct'] = true; + } + } + + $methods = []; + foreach ($refClass->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) { + if (!$attributes = $method->getAttributes(ExposeInTemplate::class)) { + continue; + } + if ($method->getNumberOfRequiredParameters()) { + throw new \LogicException(\sprintf('Cannot use "%s" on methods with required parameters (%s::%s).', ExposeInTemplate::class, $class, $method->name)); + } + $attribute = $attributes[0]->newInstance(); + $name = $attribute->name ?? (str_starts_with($method->name, 'get') ? lcfirst(substr($method->name, 3)) : $method->name); + $methods[$method->name] = $attribute->destruct ? ['destruct' => true] : ['name' => $name]; + } + + return [ + 'properties' => $properties, + 'methods' => $methods, + ]; + } +} diff --git a/src/TwigComponent/src/ComponentRenderer.php b/src/TwigComponent/src/ComponentRenderer.php index 3273ba5118f..fb6a01ffda6 100644 --- a/src/TwigComponent/src/ComponentRenderer.php +++ b/src/TwigComponent/src/ComponentRenderer.php @@ -11,9 +11,7 @@ namespace Symfony\UX\TwigComponent; -use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; -use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate; use Symfony\UX\TwigComponent\Event\PostRenderEvent; use Symfony\UX\TwigComponent\Event\PreCreateForRenderEvent; use Symfony\UX\TwigComponent\Event\PreRenderEvent; @@ -30,7 +28,7 @@ public function __construct( private Environment $twig, private EventDispatcherInterface $dispatcher, private ComponentFactory $factory, - private PropertyAccessorInterface $propertyAccessor, + private ComponentProperties $componentProperties, private ComponentStack $componentStack, ) { } @@ -107,9 +105,11 @@ private function preRender(MountedComponent $mounted, array $context = []): PreR { $component = $mounted->getComponent(); $metadata = $this->factory->metadataFor($mounted->getName()); - $isAnonymous = $mounted->getComponent() instanceof AnonymousComponent; - $classProps = $isAnonymous ? [] : iterator_to_array($this->exposedVariables($component, $metadata->isPublicPropsExposed())); + $classProps = []; + if (!$metadata->isAnonymous()) { + $classProps = $this->componentProperties->getProperties($component, $metadata->isPublicPropsExposed()); + } // expose public properties and properties marked with ExposeInTemplate attribute $props = [...$mounted->getInputProps(), ...$classProps]; @@ -137,57 +137,4 @@ private function preRender(MountedComponent $mounted, array $context = []): PreR return $event; } - - private function exposedVariables(object $component, bool $exposePublicProps): \Iterator - { - if ($exposePublicProps) { - yield from get_object_vars($component); - } - - $class = new \ReflectionClass($component); - - foreach ($class->getProperties() as $property) { - if (!$attribute = $property->getAttributes(ExposeInTemplate::class)[0] ?? null) { - continue; - } - - $attribute = $attribute->newInstance(); - - /** @var ExposeInTemplate $attribute */ - $value = $attribute->getter ? $component->{rtrim($attribute->getter, '()')}() : $this->propertyAccessor->getValue($component, $property->name); - - if ($attribute->destruct) { - foreach ($value as $key => $destructedValue) { - yield $key => $destructedValue; - } - } - - yield $attribute->name ?? $property->name => $value; - } - - foreach ($class->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) { - if (!$attribute = $method->getAttributes(ExposeInTemplate::class)[0] ?? null) { - continue; - } - - $attribute = $attribute->newInstance(); - - /** @var ExposeInTemplate $attribute */ - $name = $attribute->name ?? (str_starts_with($method->name, 'get') ? lcfirst(substr($method->name, 3)) : $method->name); - - if ($method->getNumberOfRequiredParameters()) { - throw new \LogicException(\sprintf('Cannot use "%s" on methods with required parameters (%s::%s).', ExposeInTemplate::class, $component::class, $method->name)); - } - - if ($attribute->destruct) { - foreach ($component->{$method->name}() as $prop => $value) { - yield $prop => $value; - } - - return; - } - - yield $name => $component->{$method->name}(); - } - } } diff --git a/src/TwigComponent/src/DependencyInjection/Compiler/TwigComponentPass.php b/src/TwigComponent/src/DependencyInjection/Compiler/TwigComponentPass.php index 2a4ec1b0ba4..faf86afe925 100644 --- a/src/TwigComponent/src/DependencyInjection/Compiler/TwigComponentPass.php +++ b/src/TwigComponent/src/DependencyInjection/Compiler/TwigComponentPass.php @@ -80,6 +80,9 @@ public function process(ContainerBuilder $container): void $factoryDefinition->setArgument(4, $componentConfig); $factoryDefinition->setArgument(5, $componentClassMap); + $componentPropertiesDefinition = $container->findDefinition('ux.twig_component.component_properties'); + $componentPropertiesDefinition->setArgument(1, array_fill_keys(array_keys($componentClassMap), null)); + $debugCommandDefinition = $container->findDefinition('ux.twig_component.command.debug'); $debugCommandDefinition->setArgument(3, $componentClassMap); } diff --git a/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php b/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php index 72cd6f261ab..0ee7230600a 100644 --- a/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php +++ b/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php @@ -21,14 +21,17 @@ use Symfony\Component\DependencyInjection\Argument\TaggedIteratorArgument; use Symfony\Component\DependencyInjection\ChildDefinition; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\DependencyInjection\Exception\LogicException; use Symfony\Component\DependencyInjection\Extension\Extension; use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; use Symfony\Component\DependencyInjection\Parameter; use Symfony\Component\DependencyInjection\Reference; use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; +use Symfony\UX\TwigComponent\CacheWarmer\TwigComponentCacheWarmer; use Symfony\UX\TwigComponent\Command\TwigComponentDebugCommand; use Symfony\UX\TwigComponent\ComponentFactory; +use Symfony\UX\TwigComponent\ComponentProperties; use Symfony\UX\TwigComponent\ComponentRenderer; use Symfony\UX\TwigComponent\ComponentRendererInterface; use Symfony\UX\TwigComponent\ComponentStack; @@ -84,21 +87,29 @@ static function (ChildDefinition $definition, AsTwigComponent $attribute) { $container->register('ux.twig_component.component_factory', ComponentFactory::class) ->setArguments([ new Reference('ux.twig_component.component_template_finder'), - class_exists(AbstractArgument::class) ? new AbstractArgument(\sprintf('Added in %s.', TwigComponentPass::class)) : null, + new AbstractArgument(\sprintf('Added in %s.', TwigComponentPass::class)), new Reference('property_accessor'), new Reference('event_dispatcher'), - class_exists(AbstractArgument::class) ? new AbstractArgument(\sprintf('Added in %s.', TwigComponentPass::class)) : [], + new AbstractArgument(\sprintf('Added in %s.', TwigComponentPass::class)), ]) ; $container->register('ux.twig_component.component_stack', ComponentStack::class); + $container->register('ux.twig_component.component_properties', ComponentProperties::class) + ->setArguments([ + new Reference('property_accessor'), + new AbstractArgument(\sprintf('Added in %s.', TwigComponentPass::class)), + new Reference('cache.ux.twig_component', ContainerInterface::IGNORE_ON_INVALID_REFERENCE), + ]) + ; + $container->register('ux.twig_component.component_renderer', ComponentRenderer::class) ->setArguments([ new Reference('twig'), new Reference('event_dispatcher'), new Reference('ux.twig_component.component_factory'), - new Reference('property_accessor'), + new Reference('ux.twig_component.component_properties'), new Reference('ux.twig_component.component_stack'), ]) ; @@ -107,7 +118,7 @@ class_exists(AbstractArgument::class) ? new AbstractArgument(\sprintf('Added in ->addTag('twig.extension') ; - $container->register('.ux.twig_component.twig.component_runtime', ComponentRuntime::class) + $container->register('ux.twig_component.twig.component_runtime', ComponentRuntime::class) ->setArguments([ new Reference('ux.twig_component.component_renderer'), new ServiceLocatorArgument(new TaggedIteratorArgument('ux.twig_component.twig_renderer', indexAttribute: 'key', needsIndexes: true)), @@ -126,7 +137,7 @@ class_exists(AbstractArgument::class) ? new AbstractArgument(\sprintf('Added in new Parameter('twig.default_path'), new Reference('ux.twig_component.component_factory'), new Reference('twig'), - class_exists(AbstractArgument::class) ? new AbstractArgument(\sprintf('Added in %s.', TwigComponentPass::class)) : [], + new AbstractArgument(\sprintf('Added in %s.', TwigComponentPass::class)), $config['anonymous_template_directory'], ]) ->addTag('console.command') @@ -138,6 +149,14 @@ class_exists(AbstractArgument::class) ? new AbstractArgument(\sprintf('Added in if ($container->getParameter('kernel.debug') && $config['profiler']) { $loader->load('debug.php'); } + + $loader->load('cache.php'); + + $container->register('ux.twig_component.cache_warmer', TwigComponentCacheWarmer::class) + ->setArguments([new Reference(\Psr\Container\ContainerInterface::class)]) + ->addTag('kernel.cache_warmer') + ->addTag('container.service_subscriber', ['id' => 'ux.twig_component.component_properties']) + ; } public function getConfigTreeBuilder(): TreeBuilder