[LiveComponent][TwigComponent] Fix reflection issues for private properties from trait and parent class

This commit is contained in:
Ondrej Vana
2026-03-10 23:08:07 -07:00
committed by Hugo Alliaume
parent 39c64f4872
commit 799736ff76
15 changed files with 450 additions and 14 deletions

View File

@@ -74,7 +74,8 @@ class LiveComponentMetadataFactory implements ResetInterface
continue;
}
$metadatas[$propertyName] = $this->createLivePropMetadata($class->getName(), $propertyName, $property, $attribute->newInstance());
$declaringClassName = $property->getDeclaringClass()->getName();
$metadatas[$propertyName] = $this->createLivePropMetadata($declaringClassName, $propertyName, $property, $attribute->newInstance());
}
return array_values($metadatas);

View File

@@ -0,0 +1,36 @@
<?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\LiveComponent\Tests\Fixtures\Component;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
/**
* Reproduces the pattern in ComponentWithFormTrait where a public #[LiveProp]
* with a callable fieldName is declared in a trait.
*
* Child components that extend a parent using this trait must inherit the
* LiveProp with the correct fieldName callable.
*/
trait HasLivePropTrait
{
/**
* Public #[LiveProp] with a callable fieldName — this is the pattern used
* in ComponentWithFormTrait::$formValues.
*/
#[LiveProp(writable: true, fieldName: 'getFormName()')]
public array $formValues = [];
public function getFormName(): string
{
return 'live_prop_inheritance_form';
}
}

View File

@@ -0,0 +1,27 @@
<?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\LiveComponent\Tests\Fixtures\Component;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
/**
* Child component that extends LivePropInheritanceParent without redeclaring:
* - HasLivePropTrait
* - save() method with #[LiveAction] and #[LiveListener]
*
* All of those must still be discoverable on this class.
*/
#[AsLiveComponent]
class LivePropInheritanceChild extends LivePropInheritanceParent
{
// Intentionally empty: does NOT redeclare the trait or override save().
}

View File

@@ -0,0 +1,40 @@
<?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\LiveComponent\Tests\Fixtures\Component;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\Attribute\LiveListener;
use Symfony\UX\LiveComponent\DefaultActionTrait;
/**
* Parent component that:
* - uses HasLivePropTrait (contains a public #[LiveProp(fieldName: callable)])
* - declares a #[LiveAction] + #[LiveListener] on a method
*
* Child classes that extend this without redeclaring the trait or the method
* must still have the LiveProp registered with the correct fieldName, and the
* action/listener discoverable.
*/
#[AsLiveComponent]
class LivePropInheritanceParent
{
use DefaultActionTrait;
use HasLivePropTrait;
#[LiveAction]
#[LiveListener('save')]
public function save(): void
{
// no-op for testing
}
}

View File

@@ -0,0 +1 @@
<div>FormValues: {{ formValues|json_encode }}</div>

View File

@@ -0,0 +1 @@
<div>FormValues: {{ formValues|json_encode }}</div>

View File

@@ -0,0 +1,146 @@
<?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\LiveComponent\Tests\Integration;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Metadata\LegacyLivePropMetadata;
use Symfony\UX\LiveComponent\Metadata\LiveComponentMetadataFactory;
use Symfony\UX\LiveComponent\Metadata\LivePropMetadata;
use Symfony\UX\LiveComponent\Tests\Fixtures\Component\LivePropInheritanceChild;
use Symfony\UX\LiveComponent\Tests\Fixtures\Component\LivePropInheritanceParent;
/**
* Regression tests for the PHP reflection gap that caused #[LiveProp] attributes
* declared in traits used by a *parent* class to be registered without their
* fieldName callable on a *child* component, and #[LiveAction]/#[LiveListener]
* methods from parent classes to be undiscoverable on child components.
*
* @group trait-inheritance
*/
final class LivePropInheritanceTest extends KernelTestCase
{
/**
* Sanity check: the parent component itself must have the LiveProp with
* the correct fieldName callable.
*/
public function testParentComponentHasLivePropWithFieldName()
{
self::bootKernel();
$prop = $this->findPropMetadata(LivePropInheritanceParent::class, 'formValues');
$this->assertNotNull($prop, 'formValues LiveProp must be registered on the parent component.');
$component = new LivePropInheritanceParent();
$this->assertSame(
'live_prop_inheritance_form',
$prop->calculateFieldName($component, 'formValues'),
'fieldName callable must resolve correctly on the parent component.'
);
}
/**
* Regression test for Bug #2: the child component must have the same
* LiveProp with the correct fieldName callable, even though it does not
* redeclare the trait.
*
* Before the fix the LiveProp was either not registered at all for the child,
* or registered without the fieldName, causing the frontend key mismatch.
*/
public function testChildComponentInheritsLivePropWithFieldName()
{
self::bootKernel();
$prop = $this->findPropMetadata(LivePropInheritanceChild::class, 'formValues');
$this->assertNotNull(
$prop,
'formValues LiveProp must be registered on the child component.'
);
$component = new LivePropInheritanceChild();
$this->assertSame(
'live_prop_inheritance_form',
$prop->calculateFieldName($component, 'formValues'),
'The fieldName callable must be preserved on the child component when '.
'#[LiveProp] is declared in a trait used by the parent class.'
);
}
/**
* Sanity check: the save() action must be allowed on the parent component.
*/
public function testParentComponentHasSaveAction()
{
self::bootKernel();
$this->assertTrue(
AsLiveComponent::isActionAllowed(LivePropInheritanceParent::class, 'save'),
'save() must be a recognised LiveAction on the parent component.'
);
}
/**
* Regression test for Bug #3: the save() action declared with #[LiveAction]
* on the parent class must also be allowed on the child component.
*/
public function testChildComponentInheritsLiveAction()
{
self::bootKernel();
$this->assertTrue(
AsLiveComponent::isActionAllowed(LivePropInheritanceChild::class, 'save'),
'save() must be a recognised LiveAction on the child component when it is '.
'declared with #[LiveAction] on the parent class.'
);
}
/**
* Regression test for Bug #3: the save() listener declared with
* #[LiveListener("save")] on the parent class must appear in the child's
* live listeners.
*/
public function testChildComponentInheritsLiveListener()
{
self::bootKernel();
$child = new LivePropInheritanceChild();
$listeners = AsLiveComponent::liveListeners($child);
$this->assertContains(
['action' => 'save', 'event' => 'save'],
$listeners,
'The save listener must be registered on the child component when '.
'#[LiveListener] is declared on the parent class method.'
);
}
/**
* @param class-string $componentClass
*/
private function findPropMetadata(string $componentClass, string $propName): LivePropMetadata|LegacyLivePropMetadata|null
{
/** @var LiveComponentMetadataFactory $factory */
$factory = self::getContainer()->get('ux.live_component.metadata_factory');
$props = $factory->createPropMetadatas(new \ReflectionClass($componentClass));
foreach ($props as $prop) {
if ($prop->getName() === $propName) {
return $prop;
}
}
return null;
}
}

View File

@@ -112,19 +112,38 @@ final class ComponentProperties
$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;
// Walk the full class hierarchy so that private properties declared in
// traits used by *parent* classes are also discovered.
//
// PHP's ReflectionClass::getProperties() called on a child class does
// not return private properties from traits used in ancestor classes —
// those are only visible when getProperties() is called on the exact
// class that declares "use TraitName". Iterating up via getParentClass()
// and deduplicating by property name gives us the complete picture.
$seenPropertyNames = [];
$currentClass = $refClass;
while (false !== $currentClass) {
foreach ($currentClass->getProperties() as $property) {
if (isset($seenPropertyNames[$property->name])) {
// Already processed from a more-derived class; skip.
continue;
}
$seenPropertyNames[$property->name] = true;
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;
}
}
$currentClass = $currentClass->getParentClass();
}
$methods = [];

View File

@@ -0,0 +1,34 @@
<?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\Component;
use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate;
/**
* Minimal reproduction of the private-trait-property reflection gap.
*
* Private properties declared in a trait are only discoverable via reflection
* on the class that directly uses the trait. When a *child* class inherits
* from a parent that uses this trait, PHP's ReflectionClass::getProperties()
* called on the child does not return these private members.
*/
trait HasExposedVariablesTrait
{
/** Exposed via a custom template-variable name and an explicit getter. */
#[ExposeInTemplate(name: 'exposed_from_trait', getter: 'getExposedValue()')]
private string $traitExposedProp = 'trait-value';
public function getExposedValue(): string
{
return $this->traitExposedProp;
}
}

View File

@@ -0,0 +1,30 @@
<?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\Component;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
/**
* Child component that inherits from WithExposedTraitParent without
* redeclaring HasExposedVariablesTrait itself.
*
* Bug: PHP's ReflectionClass::getProperties() called on this class does not
* return private properties from traits used in parent classes.
* The #[ExposeInTemplate] property from HasExposedVariablesTrait must
* still be exposed in this component's template.
*/
#[AsTwigComponent]
class WithExposedTraitChild extends WithExposedTraitParent
{
// Intentionally does not redeclare: use HasExposedVariablesTrait;
// The inherited trait property must still be discoverable.
}

View File

@@ -0,0 +1,25 @@
<?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\Component;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
/**
* Parent component that directly uses HasExposedVariablesTrait.
* The private #[ExposeInTemplate] property from the trait should be
* available in this component's template.
*/
#[AsTwigComponent]
class WithExposedTraitParent
{
use HasExposedVariablesTrait;
}

View File

@@ -0,0 +1 @@
ExposedFromTrait: {{ exposed_from_trait }}

View File

@@ -0,0 +1 @@
ExposedFromTrait: {{ exposed_from_trait }}

View File

@@ -288,7 +288,7 @@ final class ComponentFactoryTest extends KernelTestCase
* @testWith ["tabl", "Unknown component \"tabl\". Did you mean this: \"table\"?"]
* ["Basic", "Unknown component \"Basic\". Did you mean this: \"BasicComponent\"?"]
* ["basic", "Unknown component \"basic\". Did you mean this: \"BasicComponent\"?"]
* ["with", "Unknown component \"with\". Did you mean one of these: \"with_attributes\", \"with_exposed_variables\", \"WithSlots\"?"]
* ["with", "Unknown component \"with\". Did you mean one of these: \"with_attributes\", \"WithExposedTraitChild\", \"WithExposedTraitParent\", \"with_exposed_variables\", \"WithSlots\"?"]
* ["anonAnon", "Unknown component \"anonAnon\". And no matching anonymous component template was found."]
*/
public function testCannotGetInvalidComponent(string $name, string $expectedExceptionMessage)

View File

@@ -0,0 +1,74 @@
<?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\Integration;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Twig\Environment;
/**
* Regression tests for the PHP reflection gap that caused #[ExposeInTemplate]
* properties declared in traits used by a *parent* class to be invisible when
* rendering a *child* component that extends that parent without redeclaring
* the trait.
*
* Root cause: ReflectionClass::getProperties() on the child class does not
* return private properties from traits used in ancestor classes.
*
* @group trait-inheritance
*/
final class ExposeInTemplateTraitInheritanceTest extends KernelTestCase
{
/**
* Sanity check: the parent component itself must expose the trait property.
* This works in all versions because the parent directly uses the trait.
*/
public function testParentComponentExposesTraitProperty()
{
self::bootKernel();
$output = $this->renderComponent('WithExposedTraitParent');
$this->assertStringContainsString('ExposedFromTrait: trait-value', $output);
}
/**
* Regression test for Bug #1: the child component must also expose the
* private trait property inherited through the parent class.
*
* Before the fix, this throws:
* Variable "exposed_from_trait" does not exist in ...WithExposedTraitChild.html.twig
*/
public function testChildComponentExposesInheritedTraitProperty()
{
self::bootKernel();
$output = $this->renderComponent('WithExposedTraitChild');
$this->assertStringContainsString(
'ExposedFromTrait: trait-value',
$output,
'A child component must expose private #[ExposeInTemplate] properties '.
'from traits used by its parent class.'
);
}
private function renderComponent(string $name, array $data = []): string
{
/** @var Environment $twig */
$twig = self::getContainer()->get(Environment::class);
return $twig->render('render_component.html.twig', [
'name' => $name,
'data' => $data,
]);
}
}