diff --git a/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php index a47b20b72c8..4b4af2169ec 100644 --- a/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php +++ b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php @@ -28,6 +28,7 @@ use Symfony\UX\LiveComponent\Twig\DeterministicTwigIdCalculator; use Symfony\UX\LiveComponent\Twig\LiveComponentExtension as LiveComponentTwigExtension; use Symfony\UX\LiveComponent\Twig\LiveComponentRuntime; +use Symfony\UX\LiveComponent\Util\FingerprintCalculator; use Symfony\UX\TwigComponent\ComponentFactory; use Symfony\UX\TwigComponent\ComponentRenderer; use Symfony\UX\TwigComponent\ComponentStack; @@ -110,10 +111,13 @@ function (ChildDefinition $definition, AsLiveComponent $attribute) { ->addTag('container.service_subscriber', ['key' => LiveComponentHydrator::class, 'id' => 'ux.live_component.component_hydrator']) ->addTag('container.service_subscriber', ['key' => ComponentStack::class, 'id' => 'ux.twig_component.component_stack']) ->addTag('container.service_subscriber', ['key' => DeterministicTwigIdCalculator::class, 'id' => 'ux.live_component.deterministic_id_calculator']) + ->addTag('container.service_subscriber', ['key' => FingerprintCalculator::class, 'id' => 'ux.live_component.fingerprint_calculator']) ->addTag('container.service_subscriber') // csrf, twig & router ; $container->register('ux.live_component.deterministic_id_calculator', DeterministicTwigIdCalculator::class); + $container->register('ux.live_component.fingerprint_calculator', FingerprintCalculator::class) + ->setArguments(['%kernel.secret%']); $container->setAlias(ComponentValidatorInterface::class, ComponentValidator::class); diff --git a/src/LiveComponent/src/EventListener/AddLiveAttributesSubscriber.php b/src/LiveComponent/src/EventListener/AddLiveAttributesSubscriber.php index 48e9d00d677..b6d0059c3b3 100644 --- a/src/LiveComponent/src/EventListener/AddLiveAttributesSubscriber.php +++ b/src/LiveComponent/src/EventListener/AddLiveAttributesSubscriber.php @@ -19,6 +19,7 @@ use Symfony\UX\LiveComponent\DehydratedComponent; use Symfony\UX\LiveComponent\LiveComponentHydrator; use Symfony\UX\LiveComponent\Twig\DeterministicTwigIdCalculator; +use Symfony\UX\LiveComponent\Util\FingerprintCalculator; use Symfony\UX\TwigComponent\ComponentAttributes; use Symfony\UX\TwigComponent\ComponentMetadata; use Symfony\UX\TwigComponent\ComponentStack; @@ -82,6 +83,7 @@ public static function getSubscribedServices(): array Environment::class, ComponentStack::class, DeterministicTwigIdCalculator::class, + FingerprintCalculator::class, '?'.CsrfTokenManagerInterface::class, ]; } @@ -111,8 +113,18 @@ private function getLiveAttributes(MountedComponent $mounted, ComponentMetadata $id = $this->container->get(DeterministicTwigIdCalculator::class)->calculateDeterministicId(); $attributes['data-live-id'] = $id; + $attributes['data-live-value-fingerprint'] = $this->calculateFingerprint($mounted); } return new ComponentAttributes($attributes); } + + private function calculateFingerprint(MountedComponent $mounted): string + { + if (null === $mounted->getInputProps()) { + throw new \LogicException('Child component is missing its input props.'); + } + + return $this->container->get(FingerprintCalculator::class)->calculateFingerprint($mounted->getInputProps()); + } } diff --git a/src/LiveComponent/src/Util/FingerprintCalculator.php b/src/LiveComponent/src/Util/FingerprintCalculator.php new file mode 100644 index 00000000000..208e9b3d320 --- /dev/null +++ b/src/LiveComponent/src/Util/FingerprintCalculator.php @@ -0,0 +1,15 @@ +secret, true)); + } +} diff --git a/src/LiveComponent/tests/Fixtures/templates/render_todo_list.html.twig b/src/LiveComponent/tests/Fixtures/templates/render_todo_list.html.twig index 90256cdfdc5..218cf1d8866 100644 --- a/src/LiveComponent/tests/Fixtures/templates/render_todo_list.html.twig +++ b/src/LiveComponent/tests/Fixtures/templates/render_todo_list.html.twig @@ -1,6 +1,7 @@ {{ component('todo_list', { items: [ - { text: 'item 1'}, - { text: 'item 2'} + { text: 'milk'}, + { text: 'cheese'}, + { text: 'milk'}, ] }) }} diff --git a/src/LiveComponent/tests/Functional/EventListener/AddLiveAttributesSubscriberTest.php b/src/LiveComponent/tests/Functional/EventListener/AddLiveAttributesSubscriberTest.php index 4c70d7339b1..98fdeda61c7 100644 --- a/src/LiveComponent/tests/Functional/EventListener/AddLiveAttributesSubscriberTest.php +++ b/src/LiveComponent/tests/Functional/EventListener/AddLiveAttributesSubscriberTest.php @@ -78,7 +78,7 @@ public function testCanDisableCsrf(): void $this->assertNull($div->attr('data-live-csrf-value')); } - public function testItAddsIdToChildComponent(): void + public function testItAddsIdAndFingerprintToChildComponent(): void { $response = $this->browser() ->visit('/render-template/render_todo_list') @@ -91,6 +91,13 @@ public function testItAddsIdToChildComponent(): void $lis = $ul->children('li'); // deterministic id: should not change, and counter should increase $this->assertSame('live-2816377500-0', $lis->first()->attr('data-live-id')); - $this->assertSame('live-2816377500-1', $lis->last()->attr('data-live-id')); + $this->assertSame('live-2816377500-2', $lis->last()->attr('data-live-id')); + + // fingerprints + // first and last both have the same input - thus fingerprint + $this->assertSame('sH/Rwn0x37n3KyMWQLa6OBPgglriBZqlwPLnm/EQTlE=', $lis->first()->attr('data-live-value-fingerprint')); + $this->assertSame('sH/Rwn0x37n3KyMWQLa6OBPgglriBZqlwPLnm/EQTlE=', $lis->last()->attr('data-live-value-fingerprint')); + // middle has a different fingerprint + $this->assertSame('cuOKkrHC9lOmBa6dyVZ3S0REdw4CKCwJgLDdrVoTb2g=', $lis->eq(1)->attr('data-live-value-fingerprint')); } } diff --git a/src/TwigComponent/src/ComponentFactory.php b/src/TwigComponent/src/ComponentFactory.php index ca1103930e2..a78d938fd89 100644 --- a/src/TwigComponent/src/ComponentFactory.php +++ b/src/TwigComponent/src/ComponentFactory.php @@ -48,6 +48,7 @@ public function metadataFor(string $name): ComponentMetadata */ public function create(string $name, array $data = []): MountedComponent { + $originalData = $data; $component = $this->getComponent($name); $data = $this->preMount($component, $data); @@ -80,7 +81,12 @@ public function create(string $name, array $data = []): MountedComponent } } - return new MountedComponent($name, $component, new ComponentAttributes(array_merge($attributes, $data))); + return new MountedComponent( + $name, + $component, + new ComponentAttributes(array_merge($attributes, $data)), + $originalData + ); } /** diff --git a/src/TwigComponent/src/MountedComponent.php b/src/TwigComponent/src/MountedComponent.php index 88acb34a3f6..7186ad501ed 100644 --- a/src/TwigComponent/src/MountedComponent.php +++ b/src/TwigComponent/src/MountedComponent.php @@ -20,10 +20,16 @@ */ final class MountedComponent { + /** + * @param array|null $inputProps if the component was just originally created, + * (not hydrated from a request), this is the + * array of initial props used to create the component + */ public function __construct( private string $name, private object $component, - private ComponentAttributes $attributes + private ComponentAttributes $attributes, + private ?array $inputProps = [] ) { } @@ -41,4 +47,13 @@ public function getAttributes(): ComponentAttributes { return $this->attributes; } + + public function getInputProps(): array + { + if (null === $this->inputProps) { + throw new \LogicException('The component was not created from input props.'); + } + + return $this->inputProps; + } } diff --git a/src/TwigComponent/tests/Integration/ComponentFactoryTest.php b/src/TwigComponent/tests/Integration/ComponentFactoryTest.php index d5e69e29f66..924e391af3d 100644 --- a/src/TwigComponent/tests/Integration/ComponentFactoryTest.php +++ b/src/TwigComponent/tests/Integration/ComponentFactoryTest.php @@ -154,6 +154,12 @@ public function testCannotGetInvalidComponent(): void $this->factory()->get('invalid'); } + public function testInputPropsStoredOnMountedComponent(): void + { + $mountedComponent = $this->factory()->create('component', ['propA' => 'A', 'propB' => 'B']); + $this->assertSame(['propA' => 'A', 'propB' => 'B'], $mountedComponent->getInputProps()); + } + private function factory(): ComponentFactory { return self::getContainer()->get('ux.twig_component.component_factory');