Skip to content
Merged
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
23 changes: 23 additions & 0 deletions src/TwigComponent/config/cache.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* 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')
;
};
55 changes: 55 additions & 0 deletions src/TwigComponent/src/CacheWarmer/TwigComponentCacheWarmer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* 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é <[email protected]>
*
* @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;
}
}
148 changes: 148 additions & 0 deletions src/TwigComponent/src/ComponentProperties.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* 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é <[email protected]>
*
* @internal
*/
final class ComponentProperties
{
private const CACHE_KEY = 'ux.twig_component.component_properties';

/**
* @var array<class-string, array{
* properties: array<class-string, array{string, array{string, string, bool}, bool}>,
* methods: array<class-string, array{string, array{string, bool}}>,
* }|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<string, mixed>
*/
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<string, mixed>
*/
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<string, array{
* name?: string,
* getter?: string,
* destruct?: bool
* }>,
* methods: array<string, array{
* name?: string,
* destruct?: bool
* }>,
* }
*/
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,
];
}
}
63 changes: 5 additions & 58 deletions src/TwigComponent/src/ComponentRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
) {
}
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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}();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Loading