Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -82,6 +83,7 @@ public static function getSubscribedServices(): array
Environment::class,
ComponentStack::class,
DeterministicTwigIdCalculator::class,
FingerprintCalculator::class,
'?'.CsrfTokenManagerInterface::class,
];
}
Expand Down Expand Up @@ -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());
}
}
15 changes: 15 additions & 0 deletions src/LiveComponent/src/Util/FingerprintCalculator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

namespace Symfony\UX\LiveComponent\Util;

class FingerprintCalculator
{
public function __construct(private string $secret)
{
}

public function calculateFingerprint(array $data): string
{
return base64_encode(hash_hmac('sha256', serialize($data), $this->secret, true));
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is all internal so we can change later but maybe we should create a ChecksumCalculator object that's used for calculating the checksum and the fingerprint?

In a future PR, will you need to verify the fingerprint?

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm going to save for later, but you're right.

Related, it's odd to have to pass the $secret into DehydratedComponent. Probably there should be a DehydratedComponentFactory and some of the DehydratedComponent logic (i.e. public static function createFromCombinedData and the checksum creation) should be moved there.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, a new ChecksumCalculator would contain the secret - once we have this service, it will probably require some refactoring of DehydratedComponent.

}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{{ component('todo_list', {
items: [
{ text: 'item 1'},
{ text: 'item 2'}
{ text: 'milk'},
{ text: 'cheese'},
{ text: 'milk'},
]
}) }}
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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'));
}
}
8 changes: 7 additions & 1 deletion src/TwigComponent/src/ComponentFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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
);
}

/**
Expand Down
17 changes: 16 additions & 1 deletion src/TwigComponent/src/MountedComponent.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
) {
}

Expand All @@ -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;
}
}
6 changes: 6 additions & 0 deletions src/TwigComponent/tests/Integration/ComponentFactoryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down