Skip to content

Commit 6e25ab9

Browse files
authored
Merge pull request #9 from weaverryan/component-object-read-fingerprints
Component object read fingerprints
2 parents 5ed4f8a + 33e7a8d commit 6e25ab9

28 files changed

+747
-92
lines changed

src/LiveComponent/composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
"symfony/security-csrf": "^5.4|^6.0",
4343
"symfony/twig-bundle": "^5.4|^6.0",
4444
"symfony/validator": "^5.4|^6.0",
45-
"zenstruck/browser": "^0.9.1",
45+
"zenstruck/browser": "^1.2.0",
4646
"zenstruck/foundry": "^1.10"
4747
},
4848
"conflict": {

src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,16 @@
2222
use Symfony\UX\LiveComponent\ComponentValidatorInterface;
2323
use Symfony\UX\LiveComponent\Controller\BatchActionController;
2424
use Symfony\UX\LiveComponent\EventListener\AddLiveAttributesSubscriber;
25+
use Symfony\UX\LiveComponent\EventListener\InterceptChildComponentRenderSubscriber;
2526
use Symfony\UX\LiveComponent\EventListener\LiveComponentSubscriber;
27+
use Symfony\UX\LiveComponent\EventListener\ResetDeterministicIdSubscriber;
2628
use Symfony\UX\LiveComponent\Form\Type\LiveCollectionType;
2729
use Symfony\UX\LiveComponent\LiveComponentHydrator;
2830
use Symfony\UX\LiveComponent\Twig\DeterministicTwigIdCalculator;
2931
use Symfony\UX\LiveComponent\Twig\LiveComponentExtension as LiveComponentTwigExtension;
3032
use Symfony\UX\LiveComponent\Twig\LiveComponentRuntime;
3133
use Symfony\UX\LiveComponent\Util\FingerprintCalculator;
34+
use Symfony\UX\LiveComponent\Util\TwigAttributeHelper;
3235
use Symfony\UX\TwigComponent\ComponentFactory;
3336
use Symfony\UX\TwigComponent\ComponentRenderer;
3437
use Symfony\UX\TwigComponent\ComponentStack;
@@ -89,6 +92,24 @@ function (ChildDefinition $definition, AsLiveComponent $attribute) {
8992
->addTag('container.service_subscriber') // csrf
9093
;
9194

95+
$container->register('ux.live_component.intercept_child_component_render_subscriber', InterceptChildComponentRenderSubscriber::class)
96+
->setArguments([
97+
new Reference('ux.twig_component.component_stack'),
98+
new Reference('ux.live_component.deterministic_id_calculator'),
99+
new Reference('ux.live_component.fingerprint_calculator'),
100+
new Reference('ux.live_component.attribute_helper'),
101+
new Reference('ux.twig_component.component_factory'),
102+
new Reference('ux.live_component.component_hydrator'),
103+
])
104+
->addTag('kernel.event_subscriber');
105+
106+
$container->register('ux.live_component.reset_deterministic_id_subscriber', ResetDeterministicIdSubscriber::class)
107+
->setArguments([
108+
new Reference('ux.live_component.deterministic_id_calculator'),
109+
new Reference('ux.twig_component.component_stack'),
110+
])
111+
->addTag('kernel.event_subscriber');
112+
92113
$container->register('ux.live_component.twig.component_extension', LiveComponentTwigExtension::class)
93114
->addTag('twig.extension')
94115
;
@@ -106,13 +127,17 @@ function (ChildDefinition $definition, AsLiveComponent $attribute) {
106127
->addTag('container.service_subscriber', ['key' => 'validator', 'id' => 'validator'])
107128
;
108129

130+
$container->register('ux.live_component.attribute_helper', TwigAttributeHelper::class)
131+
->setArguments([new Reference('twig')]);
132+
109133
$container->register('ux.live_component.add_attributes_subscriber', AddLiveAttributesSubscriber::class)
110134
->addTag('kernel.event_subscriber')
111135
->addTag('container.service_subscriber', ['key' => LiveComponentHydrator::class, 'id' => 'ux.live_component.component_hydrator'])
112136
->addTag('container.service_subscriber', ['key' => ComponentStack::class, 'id' => 'ux.twig_component.component_stack'])
137+
->addTag('container.service_subscriber', ['key' => TwigAttributeHelper::class, 'id' => 'ux.live_component.attribute_helper'])
113138
->addTag('container.service_subscriber', ['key' => DeterministicTwigIdCalculator::class, 'id' => 'ux.live_component.deterministic_id_calculator'])
114139
->addTag('container.service_subscriber', ['key' => FingerprintCalculator::class, 'id' => 'ux.live_component.fingerprint_calculator'])
115-
->addTag('container.service_subscriber') // csrf, twig & router
140+
->addTag('container.service_subscriber') // csrf & router
116141
;
117142

118143
$container->register('ux.live_component.deterministic_id_calculator', DeterministicTwigIdCalculator::class);

src/LiveComponent/src/EventListener/AddLiveAttributesSubscriber.php

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,12 @@
2020
use Symfony\UX\LiveComponent\LiveComponentHydrator;
2121
use Symfony\UX\LiveComponent\Twig\DeterministicTwigIdCalculator;
2222
use Symfony\UX\LiveComponent\Util\FingerprintCalculator;
23+
use Symfony\UX\LiveComponent\Util\TwigAttributeHelper;
2324
use Symfony\UX\TwigComponent\ComponentAttributes;
2425
use Symfony\UX\TwigComponent\ComponentMetadata;
2526
use Symfony\UX\TwigComponent\ComponentStack;
26-
use Symfony\UX\TwigComponent\EventListener\PreRenderEvent;
27+
use Symfony\UX\TwigComponent\Event\PreRenderEvent;
2728
use Symfony\UX\TwigComponent\MountedComponent;
28-
use Twig\Environment;
2929

3030
/**
3131
* @author Kevin Bond <kevinbond@gmail.com>
@@ -80,7 +80,7 @@ public static function getSubscribedServices(): array
8080
return [
8181
LiveComponentHydrator::class,
8282
UrlGeneratorInterface::class,
83-
Environment::class,
83+
TwigAttributeHelper::class,
8484
ComponentStack::class,
8585
DeterministicTwigIdCalculator::class,
8686
FingerprintCalculator::class,
@@ -94,13 +94,14 @@ private function getLiveAttributes(MountedComponent $mounted, ComponentMetadata
9494
$url = $this->container->get(UrlGeneratorInterface::class)->generate('live_component', ['component' => $name]);
9595
/** @var DehydratedComponent $dehydratedComponent */
9696
$dehydratedComponent = $this->container->get(LiveComponentHydrator::class)->dehydrate($mounted);
97-
$twig = $this->container->get(Environment::class);
97+
/** @var TwigAttributeHelper $helper */
98+
$helper = $this->container->get(TwigAttributeHelper::class);
9899

99100
$attributes = [
100101
'data-controller' => 'live',
101-
'data-live-url-value' => twig_escape_filter($twig, $url, 'html_attr'),
102-
'data-live-data-value' => twig_escape_filter($twig, json_encode($dehydratedComponent->getData(), \JSON_THROW_ON_ERROR), 'html_attr'),
103-
'data-live-props-value' => twig_escape_filter($twig, json_encode($dehydratedComponent->getProps(), \JSON_THROW_ON_ERROR), 'html_attr'),
102+
'data-live-url-value' => $helper->escapeAttribute($url),
103+
'data-live-data-value' => $helper->escapeAttribute(json_encode($dehydratedComponent->getData(), \JSON_THROW_ON_ERROR)),
104+
'data-live-props-value' => $helper->escapeAttribute(json_encode($dehydratedComponent->getProps(), \JSON_THROW_ON_ERROR)),
104105
];
105106

106107
if ($this->container->has(CsrfTokenManagerInterface::class) && $metadata->get('csrf')) {
@@ -111,9 +112,10 @@ private function getLiveAttributes(MountedComponent $mounted, ComponentMetadata
111112

112113
if ($this->container->get(ComponentStack::class)->hasParentComponent()) {
113114
$id = $this->container->get(DeterministicTwigIdCalculator::class)->calculateDeterministicId();
115+
$attributes['data-live-id'] = $helper->escapeAttribute($id);
114116

115-
$attributes['data-live-id'] = $id;
116-
$attributes['data-live-value-fingerprint'] = $this->container->get(FingerprintCalculator::class)->calculateFingerprint($mounted->getInputProps());
117+
$fingerprint = $this->container->get(FingerprintCalculator::class)->calculateFingerprint($mounted->getInputProps());
118+
$attributes['data-live-value-fingerprint'] = $helper->escapeAttribute($fingerprint);
117119
}
118120

119121
return new ComponentAttributes($attributes);
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\UX\LiveComponent\EventListener;
13+
14+
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
15+
use Symfony\UX\LiveComponent\LiveComponentHydrator;
16+
use Symfony\UX\LiveComponent\Twig\DeterministicTwigIdCalculator;
17+
use Symfony\UX\LiveComponent\Util\FingerprintCalculator;
18+
use Symfony\UX\LiveComponent\Util\TwigAttributeHelper;
19+
use Symfony\UX\TwigComponent\ComponentFactory;
20+
use Symfony\UX\TwigComponent\ComponentStack;
21+
use Symfony\UX\TwigComponent\Event\PreCreateForRenderEvent;
22+
23+
/**
24+
* Responsible for rendering children as empty elements during a re-render.
25+
*
26+
* @author Ryan Weaver <ryan@symfonycasts.com>
27+
*
28+
* @experimental
29+
*
30+
* @internal
31+
*/
32+
class InterceptChildComponentRenderSubscriber implements EventSubscriberInterface
33+
{
34+
public const CHILDREN_FINGERPRINTS_METADATA_KEY = 'children_fingerprints';
35+
36+
public function __construct(
37+
private ComponentStack $componentStack,
38+
private DeterministicTwigIdCalculator $deterministicTwigIdCalculator,
39+
private FingerprintCalculator $fingerprintCalculator,
40+
private TwigAttributeHelper $twigAttributeHelper,
41+
private ComponentFactory $componentFactory,
42+
private LiveComponentHydrator $liveComponentHydrator,
43+
) {
44+
}
45+
46+
public function preComponentCreated(PreCreateForRenderEvent $event): void
47+
{
48+
// if there is already a component, that's a parent. Else, this is not a child.
49+
if (!$this->componentStack->getCurrentComponent()) {
50+
return;
51+
}
52+
53+
$parentComponent = $this->componentStack->getCurrentComponent();
54+
if (!$parentComponent->hasExtraMetadata(self::CHILDREN_FINGERPRINTS_METADATA_KEY)) {
55+
return;
56+
}
57+
58+
$childFingerprints = $parentComponent->getExtraMetadata(self::CHILDREN_FINGERPRINTS_METADATA_KEY);
59+
60+
// get the deterministic id for this child, but without incrementing the counter yet
61+
$deterministicId = $this->deterministicTwigIdCalculator->calculateDeterministicId(increment: false);
62+
if (!isset($childFingerprints[$deterministicId])) {
63+
// child fingerprint wasn't set, it is likely a new child, allow it to render fully
64+
return;
65+
}
66+
67+
// increment the internal counter now to keep "counter" consistency if we're
68+
// in a loop of children being rendered on the same line
69+
// we need to do this because this component will *not* ever hit
70+
// AddLiveAttributesSubscriber where the counter is normally incremented
71+
$this->deterministicTwigIdCalculator->calculateDeterministicId(increment: true);
72+
73+
$newPropsFingerprint = $this->fingerprintCalculator->calculateFingerprint($event->getProps());
74+
75+
if ($childFingerprints[$deterministicId] === $newPropsFingerprint) {
76+
// the props passed to create this child have *not* changed
77+
// return an empty element so the frontend knows to keep the current child
78+
79+
$rendered = sprintf(
80+
'<div data-live-id="%s"></div>',
81+
$this->twigAttributeHelper->escapeAttribute($deterministicId)
82+
);
83+
$event->setRenderedString($rendered);
84+
85+
return;
86+
}
87+
88+
/*
89+
* The props passed to create this child HAVE changed.
90+
* Send back a fake element with:
91+
* * data-live-id
92+
* * data-live-fingerprint-value (new fingerprint)
93+
* * data-live-props-value (new dehydrated props)
94+
*/
95+
$mounted = $this->componentFactory->create($event->getName(), $event->getProps());
96+
$dehydratedComponent = $this->liveComponentHydrator->dehydrate($mounted);
97+
98+
$rendered = sprintf(
99+
'<div data-live-id="%s" data-live-fingerprint-value="%s" data-live-props-value="%s"></div>',
100+
$this->twigAttributeHelper->escapeAttribute($deterministicId),
101+
$this->twigAttributeHelper->escapeAttribute($newPropsFingerprint),
102+
$this->twigAttributeHelper->escapeAttribute(json_encode($dehydratedComponent->getProps(), \JSON_THROW_ON_ERROR))
103+
);
104+
$event->setRenderedString($rendered);
105+
}
106+
107+
public static function getSubscribedEvents(): array
108+
{
109+
return [
110+
PreCreateForRenderEvent::class => 'preComponentCreated',
111+
];
112+
}
113+
}

src/LiveComponent/src/EventListener/LiveComponentSubscriber.php

Lines changed: 55 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
use Psr\Container\ContainerInterface;
1515
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
16+
use Symfony\Component\HttpFoundation\Exception\JsonException;
1617
use Symfony\Component\HttpFoundation\Request;
1718
use Symfony\Component\HttpFoundation\Response;
1819
use Symfony\Component\HttpKernel\Event\ControllerEvent;
@@ -118,10 +119,10 @@ public function onKernelRequest(RequestEvent $event): void
118119
$request->attributes->set('_controller', 'ux.live_component.batch_action_controller');
119120
$request->attributes->set('serviceId', $metadata->getServiceId());
120121
$request->attributes->set('actions', $data['actions']);
121-
$request->attributes->set('_mounted_component', $this->container->get(LiveComponentHydrator::class)->hydrate(
122+
$request->attributes->set('_mounted_component', $this->hydrateComponent(
122123
$this->container->get(ComponentFactory::class)->get($componentName),
123-
$data['data'],
124124
$componentName,
125+
$request
125126
));
126127
$request->attributes->set('_is_live_batch_action', true);
127128

@@ -162,14 +163,16 @@ public function onKernelController(ControllerEvent $event): void
162163
/*
163164
* Either we:
164165
* A) We do NOT have a _mounted_component, so hydrate $component
166+
* (normal situation, rendering a single component)
165167
* B) We DO have a _mounted_component, so no need to hydrate,
166168
* but we DO need to make sure it's set as the controller.
169+
* (sub-request during batch controller)
167170
*/
168171
if (!$request->attributes->has('_mounted_component')) {
169-
$request->attributes->set('_mounted_component', $this->container->get(LiveComponentHydrator::class)->hydrate(
172+
$request->attributes->set('_mounted_component', $this->hydrateComponent(
170173
$component,
171-
$this->parseDataFor($request)['data'],
172-
$request->attributes->get('_component_name')
174+
$request->attributes->get('_component_name'),
175+
$request
173176
));
174177
} else {
175178
// override the component with our already-mounted version
@@ -196,26 +199,31 @@ public function onKernelController(ControllerEvent $event): void
196199
* data: array,
197200
* args: array,
198201
* actions: array
202+
* childrenFingerprints: array
199203
* }
200204
*/
201-
private function parseDataFor(Request $request): array
205+
private static function parseDataFor(Request $request): array
202206
{
203207
if (!$request->attributes->has('_live_request_data')) {
204208
if ($request->query->has('data')) {
205-
return [
206-
'data' => json_decode($request->query->get('data'), true, 512, \JSON_THROW_ON_ERROR),
209+
$liveRequestData = [
210+
'data' => self::parseJsonFromQuery($request, 'data'),
207211
'args' => [],
208212
'actions' => [],
213+
'childrenFingerprints' => self::parseJsonFromQuery($request, 'childrenFingerprints'),
214+
];
215+
} else {
216+
$requestData = $request->toArray();
217+
218+
$liveRequestData = [
219+
'data' => $requestData['data'] ?? [],
220+
'args' => $requestData['args'] ?? [],
221+
'actions' => $requestData['actions'] ?? [],
222+
'childrenFingerprints' => $requestData['childrenFingerprints'] ?? [],
209223
];
210224
}
211225

212-
$requestData = $request->toArray();
213-
214-
$request->attributes->set('_live_request_data', [
215-
'data' => $requestData['data'] ?? [],
216-
'args' => $requestData['args'] ?? [],
217-
'actions' => $requestData['actions'] ?? [],
218-
]);
226+
$request->attributes->set('_live_request_data', $liveRequestData);
219227
}
220228

221229
return $request->attributes->get('_live_request_data');
@@ -306,4 +314,36 @@ private function isLiveComponentRequest(Request $request): bool
306314
{
307315
return 'live_component' === $request->attributes->get('_route');
308316
}
317+
318+
private function hydrateComponent(object $component, string $componentName, Request $request): MountedComponent
319+
{
320+
$hydrator = $this->container->get(LiveComponentHydrator::class);
321+
\assert($hydrator instanceof LiveComponentHydrator);
322+
323+
$mountedComponent = $hydrator->hydrate(
324+
$component,
325+
$this->parseDataFor($request)['data'],
326+
$componentName
327+
);
328+
329+
$mountedComponent->addExtraMetadata(
330+
InterceptChildComponentRenderSubscriber::CHILDREN_FINGERPRINTS_METADATA_KEY,
331+
$this->parseDataFor($request)['childrenFingerprints']
332+
);
333+
334+
return $mountedComponent;
335+
}
336+
337+
private static function parseJsonFromQuery(Request $request, string $key): array
338+
{
339+
if (!$request->query->has($key)) {
340+
return [];
341+
}
342+
343+
try {
344+
return json_decode($request->query->get($key), true, 512, \JSON_THROW_ON_ERROR);
345+
} catch (\JsonException $exception) {
346+
throw new JsonException(sprintf('Invalid JSON on query string %s.', $key), 0, $exception);
347+
}
348+
}
309349
}

0 commit comments

Comments
 (0)