mirror of
https://github.com/symfony/ux-live-component.git
synced 2026-03-24 00:42:12 +01:00
bug #3372 [LiveComponent][TwigComponent] Fix reflection issues for private properties from trait and parent class (kachnitel)
This PR was squashed before being merged into the 2.x branch. Discussion ---------- [LiveComponent][TwigComponent] Fix reflection issues for private properties from trait and parent class | Q | A | -------------- | --- | Bug fix? | yes | New feature? | no | Deprecations? | no | Documentation? | no | Issues | <!-- none yet --> | License | MIT ## Problem When a component class *extends* another class that uses a trait with `#[ExposeInTemplate]` or `#[LiveProp]` attributes on **private** properties, those attributes are invisible to the metadata scanners. PHP's `ReflectionClass::getProperties()` called on a child class does not return private properties from traits used in *ancestor* classes — they are only returned when `getProperties()` is called on the exact class that declares `use TraitName`. This is standard PHP reflection behaviour. This affects any inheritance pattern where a base component uses `ComponentWithFormTrait` (or any custom trait with private `#[ExposeInTemplate]` properties) and app-level components extend it without re-declaring the trait. **Symptom in `ux-twig-component`:** `Variable "form" does not exist` in the child component's Twig template. **Symptom in `ux-live-component`:** `#[LiveProp(fieldName: 'callable()')]` declared in a trait used by a parent class is not registered with the correct `fieldName` on the child component, causing frontend model key mismatches. ## Fix ### `ux-twig-component` — `ComponentProperties::loadClassMetadata()` Replace the single `$refClass->getProperties()` call with a loop that walks up `getParentClass()`, calling `getProperties()` at each level. Results are deduplicated by property name (child-class declaration takes priority), giving a complete view of all `#[ExposeInTemplate]` properties across the full hierarchy. ### `ux-live-component` — `LiveComponentMetadataFactory::createPropMetadatas()` Pass `$property->getDeclaringClass()->getName()` instead of `$class->getName()` to `createLivePropMetadata()`. When a property is declared in a trait used by a parent class, the type extractor must be given the declaring class — not the leaf child class — to resolve the type correctly. `#[LiveAction]` and `#[LiveListener]` on parent class methods are **not** affected: `getMethods()` already walks the full inheritance chain, so those are discovered correctly on child components. ## Tests Added ``@group` trait-inheritance` tests in both packages using a minimal fixture: a trait with a private `#[ExposeInTemplate]` property / `#[LiveProp(fieldName: callable)]`, a parent component that uses the trait, and a child component that extends the parent without re-declaring the trait. Commits ------- 799736ff76c [LiveComponent][TwigComponent] Fix reflection issues for private properties from trait and parent class
This commit is contained in:
@@ -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);
|
||||
|
||||
36
tests/Fixtures/Component/HasLivePropTrait.php
Normal file
36
tests/Fixtures/Component/HasLivePropTrait.php
Normal 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';
|
||||
}
|
||||
}
|
||||
27
tests/Fixtures/Component/LivePropInheritanceChild.php
Normal file
27
tests/Fixtures/Component/LivePropInheritanceChild.php
Normal 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().
|
||||
}
|
||||
40
tests/Fixtures/Component/LivePropInheritanceParent.php
Normal file
40
tests/Fixtures/Component/LivePropInheritanceParent.php
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<div>FormValues: {{ formValues|json_encode }}</div>
|
||||
@@ -0,0 +1 @@
|
||||
<div>FormValues: {{ formValues|json_encode }}</div>
|
||||
146
tests/Integration/LivePropInheritanceTest.php
Normal file
146
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user