From 0227b4d5f5ae3849031187b8ca5453bf2cae2b3f Mon Sep 17 00:00:00 2001 From: Ondrej Vana Date: Tue, 10 Mar 2026 23:08:07 -0700 Subject: [PATCH] [LiveComponent][TwigComponent] Fix reflection issues for private properties from trait and parent class --- src/Metadata/LiveComponentMetadataFactory.php | 3 +- tests/Fixtures/Component/HasLivePropTrait.php | 36 +++++ .../Component/LivePropInheritanceChild.php | 27 ++++ .../Component/LivePropInheritanceParent.php | 40 +++++ .../LivePropInheritanceChild.html.twig | 1 + .../LivePropInheritanceParent.html.twig | 1 + tests/Integration/LivePropInheritanceTest.php | 146 ++++++++++++++++++ 7 files changed, 253 insertions(+), 1 deletion(-) create mode 100644 tests/Fixtures/Component/HasLivePropTrait.php create mode 100644 tests/Fixtures/Component/LivePropInheritanceChild.php create mode 100644 tests/Fixtures/Component/LivePropInheritanceParent.php create mode 100644 tests/Fixtures/templates/components/LivePropInheritanceChild.html.twig create mode 100644 tests/Fixtures/templates/components/LivePropInheritanceParent.html.twig create mode 100644 tests/Integration/LivePropInheritanceTest.php diff --git a/src/Metadata/LiveComponentMetadataFactory.php b/src/Metadata/LiveComponentMetadataFactory.php index 1d8e879..4f5e331 100644 --- a/src/Metadata/LiveComponentMetadataFactory.php +++ b/src/Metadata/LiveComponentMetadataFactory.php @@ -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); diff --git a/tests/Fixtures/Component/HasLivePropTrait.php b/tests/Fixtures/Component/HasLivePropTrait.php new file mode 100644 index 0000000..d619705 --- /dev/null +++ b/tests/Fixtures/Component/HasLivePropTrait.php @@ -0,0 +1,36 @@ + + * + * 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'; + } +} diff --git a/tests/Fixtures/Component/LivePropInheritanceChild.php b/tests/Fixtures/Component/LivePropInheritanceChild.php new file mode 100644 index 0000000..eeb09f4 --- /dev/null +++ b/tests/Fixtures/Component/LivePropInheritanceChild.php @@ -0,0 +1,27 @@ + + * + * 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(). +} diff --git a/tests/Fixtures/Component/LivePropInheritanceParent.php b/tests/Fixtures/Component/LivePropInheritanceParent.php new file mode 100644 index 0000000..05828a9 --- /dev/null +++ b/tests/Fixtures/Component/LivePropInheritanceParent.php @@ -0,0 +1,40 @@ + + * + * 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 + } +} diff --git a/tests/Fixtures/templates/components/LivePropInheritanceChild.html.twig b/tests/Fixtures/templates/components/LivePropInheritanceChild.html.twig new file mode 100644 index 0000000..498078f --- /dev/null +++ b/tests/Fixtures/templates/components/LivePropInheritanceChild.html.twig @@ -0,0 +1 @@ +
FormValues: {{ formValues|json_encode }}
diff --git a/tests/Fixtures/templates/components/LivePropInheritanceParent.html.twig b/tests/Fixtures/templates/components/LivePropInheritanceParent.html.twig new file mode 100644 index 0000000..498078f --- /dev/null +++ b/tests/Fixtures/templates/components/LivePropInheritanceParent.html.twig @@ -0,0 +1 @@ +
FormValues: {{ formValues|json_encode }}
diff --git a/tests/Integration/LivePropInheritanceTest.php b/tests/Integration/LivePropInheritanceTest.php new file mode 100644 index 0000000..072a8f7 --- /dev/null +++ b/tests/Integration/LivePropInheritanceTest.php @@ -0,0 +1,146 @@ + + * + * 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; + } +}