mirror of
https://github.com/symfony/ux.git
synced 2026-03-24 00:02:21 +01:00
[LiveComponent][TwigComponent] Fix reflection issues for private properties from trait and parent class
This commit is contained in:
committed by
Hugo Alliaume
parent
39c64f4872
commit
799736ff76
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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().
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<div>FormValues: {{ formValues|json_encode }}</div>
|
||||
@@ -0,0 +1 @@
|
||||
<div>FormValues: {{ formValues|json_encode }}</div>
|
||||
146
src/LiveComponent/tests/Integration/LivePropInheritanceTest.php
Normal file
146
src/LiveComponent/tests/Integration/LivePropInheritanceTest.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ExposedFromTrait: {{ exposed_from_trait }}
|
||||
@@ -0,0 +1 @@
|
||||
ExposedFromTrait: {{ exposed_from_trait }}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user