From de302ef36c4f67f1c2e1b378aa31a7b2e41ad5a5 Mon Sep 17 00:00:00 2001 From: Hugo Alliaume Date: Sun, 13 Jul 2025 15:04:47 +0200 Subject: [PATCH] [LiveComponent] Fix BC break when using `PropertyTypeExtractorInterface::getType()` on a `#[LiveProp]` property `x` when getter `getX` exists --- .../LiveComponentExtension.php | 1 + .../Metadata/LiveComponentMetadataFactory.php | 45 +++++++++------ .../FormWithUserInterfaceComponent.php | 36 ++++++++++++ .../tests/Fixtures/Entity/User.php | 57 +++++++++++++++++++ .../tests/Fixtures/Form/UserFormType.php | 35 ++++++++++++ .../form_with_user_interface.html.twig | 3 + .../Functional/Form/ComponentWithFormTest.php | 38 +++++++++++++ .../Integration/LiveComponentHydratorTest.php | 22 +++++++ 8 files changed, 221 insertions(+), 16 deletions(-) create mode 100644 src/LiveComponent/tests/Fixtures/Component/FormWithUserInterfaceComponent.php create mode 100644 src/LiveComponent/tests/Fixtures/Entity/User.php create mode 100644 src/LiveComponent/tests/Fixtures/Form/UserFormType.php create mode 100644 src/LiveComponent/tests/Fixtures/templates/components/form_with_user_interface.html.twig diff --git a/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php index 5fedbf522a0..c0a832f964d 100644 --- a/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php +++ b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php @@ -191,6 +191,7 @@ function (ChildDefinition $definition, AsLiveComponent $attribute) { ->setArguments([ new Reference('ux.twig_component.component_factory'), new Reference('property_info'), + new Reference('type_info.resolver', ContainerInterface::NULL_ON_INVALID_REFERENCE), ]) ->addTag('kernel.reset', ['method' => 'reset']) ; diff --git a/src/LiveComponent/src/Metadata/LiveComponentMetadataFactory.php b/src/LiveComponent/src/Metadata/LiveComponentMetadataFactory.php index fddff53a9ed..63109093377 100644 --- a/src/LiveComponent/src/Metadata/LiveComponentMetadataFactory.php +++ b/src/LiveComponent/src/Metadata/LiveComponentMetadataFactory.php @@ -13,9 +13,9 @@ use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface; use Symfony\Component\PropertyInfo\Type as LegacyType; -use Symfony\Component\TypeInfo\Type\IntersectionType; -use Symfony\Component\TypeInfo\Type\NullableType; -use Symfony\Component\TypeInfo\Type\UnionType; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\CollectionType; +use Symfony\Component\TypeInfo\TypeResolver\TypeResolver; use Symfony\Contracts\Service\ResetInterface; use Symfony\UX\LiveComponent\Attribute\LiveProp; use Symfony\UX\TwigComponent\ComponentFactory; @@ -33,7 +33,11 @@ class LiveComponentMetadataFactory implements ResetInterface public function __construct( private ComponentFactory $componentFactory, private PropertyTypeExtractorInterface $propertyTypeExtractor, + private ?TypeResolver $typeResolver = null, ) { + if (method_exists($this->propertyTypeExtractor, 'getType') && !$this->typeResolver) { + throw new \LogicException('Symfony TypeInfo is required to use LiveProps. Try running "composer require symfony/type-info".'); + } } public function getMetadata(string $name): LiveComponentMetadata @@ -77,13 +81,13 @@ public function createPropMetadatas(\ReflectionClass $class): array public function createLivePropMetadata(string $className, string $propertyName, \ReflectionProperty $property, LiveProp $liveProp): LivePropMetadata|LegacyLivePropMetadata { + $reflectionType = $property->getType(); + if ($reflectionType instanceof \ReflectionUnionType || $reflectionType instanceof \ReflectionIntersectionType) { + throw new \LogicException(\sprintf('Union or intersection types are not supported for LiveProps. You may want to change the type of property %s in %s.', $property->getName(), $property->getDeclaringClass()->getName())); + } + // BC layer when "symfony/type-info" is not available if (!method_exists($this->propertyTypeExtractor, 'getType')) { - $type = $property->getType(); - if ($type instanceof \ReflectionUnionType || $type instanceof \ReflectionIntersectionType) { - throw new \LogicException(\sprintf('Union or intersection types are not supported for LiveProps. You may want to change the type of property %s in %s.', $property->getName(), $property->getDeclaringClass()->getName())); - } - $infoTypes = $this->propertyTypeExtractor->getTypes($className, $propertyName) ?? []; $collectionValueType = null; @@ -96,14 +100,16 @@ public function createLivePropMetadata(string $className, string $propertyName, } } - if (null === $type && null === $collectionValueType && isset($infoTypes[0])) { + if (null === $reflectionType && null === $collectionValueType && isset($infoTypes[0])) { + // If it's an "advanced" type (like a Collection), let's use the PropertyTypeExtractor to get the Type $infoType = LegacyType::BUILTIN_TYPE_OBJECT === $infoTypes[0]->getBuiltinType() ? $infoTypes[0]->getClassName() : $infoTypes[0]->getBuiltinType(); $isTypeBuiltIn = null === $infoTypes[0]->getClassName(); $isTypeNullable = $infoTypes[0]->isNullable(); } else { - $infoType = $type?->getName(); - $isTypeBuiltIn = $type?->isBuiltin() ?? false; - $isTypeNullable = $type?->allowsNull() ?? true; + // Otherwise, we can use the ReflectionType to get the Type + $infoType = $reflectionType?->getName(); + $isTypeBuiltIn = $reflectionType?->isBuiltin() ?? false; + $isTypeNullable = $reflectionType?->allowsNull() ?? true; } return new LegacyLivePropMetadata( @@ -115,10 +121,17 @@ public function createLivePropMetadata(string $className, string $propertyName, $collectionValueType ); } else { - $type = $this->propertyTypeExtractor->getType($className, $property->getName()); - - if ($type instanceof UnionType && !$type instanceof NullableType || $type instanceof IntersectionType) { - throw new \LogicException(\sprintf('Union or intersection types are not supported for LiveProps. You may want to change the type of property "%s" in "%s".', $propertyName, $className)); + $infoType = $this->propertyTypeExtractor->getType($className, $property->getName()); + + if ($infoType instanceof CollectionType) { + // If it's an "advanced" type (like CollectionType), let's use the PropertyTypeExtractor to get the Type + $type = $infoType; + } elseif (null !== $reflectionType) { + // Otherwise, we can use the TypeResolver to convert the ReflectionType to a Type + $type = $this->typeResolver->resolve($reflectionType); + } else { + // If no type is available, we default to mixed + $type = Type::mixed(); } return new LivePropMetadata($property->getName(), $liveProp, $type); diff --git a/src/LiveComponent/tests/Fixtures/Component/FormWithUserInterfaceComponent.php b/src/LiveComponent/tests/Fixtures/Component/FormWithUserInterfaceComponent.php new file mode 100644 index 00000000000..0e7d53b23dc --- /dev/null +++ b/src/LiveComponent/tests/Fixtures/Component/FormWithUserInterfaceComponent.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\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\Form\FormInterface; +use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; +use Symfony\UX\LiveComponent\Attribute\LiveProp; +use Symfony\UX\LiveComponent\ComponentWithFormTrait; +use Symfony\UX\LiveComponent\DefaultActionTrait; +use Symfony\UX\LiveComponent\Tests\Fixtures\Entity\User; +use Symfony\UX\LiveComponent\Tests\Fixtures\Form\UserFormType; + +#[AsLiveComponent('form_with_user_interface', template: 'components/form_with_user_interface.html.twig')] +class FormWithUserInterfaceComponent extends AbstractController +{ + use ComponentWithFormTrait; + use DefaultActionTrait; + + #[LiveProp] + public User $user; + + protected function instantiateForm(): FormInterface + { + return $this->createForm(UserFormType::class, $this->user); + } +} diff --git a/src/LiveComponent/tests/Fixtures/Entity/User.php b/src/LiveComponent/tests/Fixtures/Entity/User.php new file mode 100644 index 00000000000..499559a35da --- /dev/null +++ b/src/LiveComponent/tests/Fixtures/Entity/User.php @@ -0,0 +1,57 @@ + + * + * 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\Entity; + +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Security\Core\User\UserInterface; + +#[ORM\Entity] +class User implements UserInterface +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: 'integer')] + public $id; + + #[ORM\Column(type: 'string', length: 180, unique: true)] + public $username; + + public function getRoles(): array + { + return ['ROLE_USER']; + } + + public function eraseCredentials(): void + { + // no-op + } + + public function getUsername(): string + { + return $this->getUserIdentifier(); + } + + public function getUserIdentifier(): string + { + return $this->username; + } + + public function getPassword(): ?string + { + return null; + } + + public function getSalt(): ?string + { + return null; + } +} diff --git a/src/LiveComponent/tests/Fixtures/Form/UserFormType.php b/src/LiveComponent/tests/Fixtures/Form/UserFormType.php new file mode 100644 index 00000000000..99bd456d38c --- /dev/null +++ b/src/LiveComponent/tests/Fixtures/Form/UserFormType.php @@ -0,0 +1,35 @@ + + * + * 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\Form; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\TextType; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\UX\LiveComponent\Tests\Fixtures\Entity\User; + +class UserFormType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options) + { + $builder + ->add('username', TextType::class) + ; + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'data_class' => User::class, + ]); + } +} diff --git a/src/LiveComponent/tests/Fixtures/templates/components/form_with_user_interface.html.twig b/src/LiveComponent/tests/Fixtures/templates/components/form_with_user_interface.html.twig new file mode 100644 index 00000000000..32dce1b7b1e --- /dev/null +++ b/src/LiveComponent/tests/Fixtures/templates/components/form_with_user_interface.html.twig @@ -0,0 +1,3 @@ + + {{ form(form) }} + diff --git a/src/LiveComponent/tests/Functional/Form/ComponentWithFormTest.php b/src/LiveComponent/tests/Functional/Form/ComponentWithFormTest.php index e0d72870b8e..9b527ab3523 100644 --- a/src/LiveComponent/tests/Functional/Form/ComponentWithFormTest.php +++ b/src/LiveComponent/tests/Functional/Form/ComponentWithFormTest.php @@ -15,6 +15,7 @@ use Symfony\Component\DomCrawler\Crawler; use Symfony\Component\Form\FormFactoryInterface; use Symfony\UX\LiveComponent\Tests\Fixtures\Component\FormWithCollectionTypeComponent; +use Symfony\UX\LiveComponent\Tests\Fixtures\Entity\User; use Symfony\UX\LiveComponent\Tests\Fixtures\Factory\CategoryFixtureEntityFactory; use Symfony\UX\LiveComponent\Tests\Fixtures\Form\BlogPostFormType; use Symfony\UX\LiveComponent\Tests\LiveComponentTestHelper; @@ -22,6 +23,9 @@ use Zenstruck\Foundry\Test\Factories; use Zenstruck\Foundry\Test\ResetDatabase; +use function Zenstruck\Foundry\Persistence\persist; +use function Zenstruck\Foundry\Persistence\refresh; + /** * @author Jakub Caban */ @@ -450,4 +454,38 @@ public function testDataModelAttributeAutomaticallyAdded(): void ->assertElementAttributeContains('form', 'data-model', 'on(change)|*') ; } + + public function testFormWithLivePropContainingAnEntityImplementingAnInterface(): void + { + $user = persist(User::class, ['username' => 'Fabien']); + self::assertInstanceOf(User::class, $user); + self::assertEquals(1, $user->id); + self::assertEquals('Fabien', $user->username); + + $mounted = $this->mountComponent('form_with_user_interface', [ + 'user' => $user, + ]); + + $dehydrated = $this->dehydrateComponent($mounted)->getProps(); + + $this->browser() + ->post('/_components/form_with_user_interface', [ + 'body' => [ + 'data' => json_encode([ + 'props' => $dehydrated, + 'updated' => [ + 'user_form.username' => 'Nicolas', + 'validatedFields' => ['user_form.username'], + ], + ]), + ], + ]) + ->assertStatus(200) + ->assertElementAttributeContains('form', 'data-model', 'on(change)|*') + ; + + refresh($user); + self::assertEquals(1, $user->id); + self::assertEquals('Nicolas', $user->username); + } } diff --git a/src/LiveComponent/tests/Integration/LiveComponentHydratorTest.php b/src/LiveComponent/tests/Integration/LiveComponentHydratorTest.php index 9df33c46c22..3dacba5d71c 100644 --- a/src/LiveComponent/tests/Integration/LiveComponentHydratorTest.php +++ b/src/LiveComponent/tests/Integration/LiveComponentHydratorTest.php @@ -37,6 +37,7 @@ use Symfony\UX\LiveComponent\Tests\Fixtures\Entity\Entity1; use Symfony\UX\LiveComponent\Tests\Fixtures\Entity\Entity2; use Symfony\UX\LiveComponent\Tests\Fixtures\Entity\ProductFixtureEntity; +use Symfony\UX\LiveComponent\Tests\Fixtures\Entity\User; use Symfony\UX\LiveComponent\Tests\Fixtures\Enum\EmptyStringEnum; use Symfony\UX\LiveComponent\Tests\Fixtures\Enum\IntEnum; use Symfony\UX\LiveComponent\Tests\Fixtures\Enum\StringEnum; @@ -336,6 +337,27 @@ public function onEntireEntityUpdated($oldValue) ; }]; + yield 'Persisted entity: (de)hydration works correctly to/from id, when the entity implements an interface' => [function () { + $user = persist(User::class, [ + 'username' => 'Fabien', + ]); + \assert($user instanceof User); + + return HydrationTest::create(new class { + #[LiveProp] + public User $user; + }) + ->mountWith(['user' => $user]) + ->assertDehydratesTo(['user' => $user->id]) + ->assertObjectAfterHydration(function (object $object) use ($user) { + self::assertSame( + $user->id, + $object->user->id + ); + }) + ; + }]; + yield 'Persisted entity: writable CAN be changed via id' => [function () { $entityOriginal = persist(Entity1::class); $entityNext = persist(Entity1::class);