Skip to content

Commit fc4883c

Browse files
committed
Split backend to data and props
Also refactored to a new DehydratedComponent
1 parent d1076f8 commit fc4883c

18 files changed

+583
-153
lines changed

src/LiveComponent/CHANGELOG.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,13 @@
3838
- [BC BREAK] The `live:update-model` and `live:render` events are not longer
3939
dispatched. You can now use the hook system directly on the `Component` object.
4040
41-
- Added the ability to add `data-loading` behavior, which is only activated
41+
- [BC BREAK] The `LiveComponentHydrator::dehydrate()` method now returns a
42+
`DehydratedComponent` object.
43+
44+
- Added a new JavaScript `Component` object, which is attached to the `__component`
45+
property of all root component elements.
46+
47+
- the ability to add `data-loading` behavior, which is only activated
4248
when a specific **action** is triggered - e.g. `<span data-loading="action(save)|show">Loading</span>`.
4349
4450
- Added the ability to add `data-loading` behavior, which is only activated
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
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;
13+
14+
/**
15+
* @experimental
16+
*/
17+
class DehydratedComponent
18+
{
19+
private const CHECKSUM_KEY = '_checksum';
20+
private const ATTRIBUTES_KEY = '_attributes';
21+
private const EXPOSED_PROP_KEY = '_id';
22+
23+
private ?string $checksum = null;
24+
25+
/**
26+
* props & data, for objects, should be set to the identifier (e.g. entity id).
27+
* Any "exposed" data should be passed to exposedData.
28+
*
29+
* For example, if the payload is { user: { _id: 5, firstName: Ryan } }
30+
* where "_id" is a prop (read-only) and firstName is "exposed", then:
31+
* $props = ['user' => 5]
32+
* $data = []
33+
* $exposedData = ['user' => ['firstName' => 'Ryan']]
34+
*/
35+
public function __construct(
36+
private array $props,
37+
private array $data,
38+
private array $exposedData,
39+
private array $attributes,
40+
private string $secret
41+
) {
42+
}
43+
44+
public static function createFromCombinedData(array $data, array $propNames, string $secret): self
45+
{
46+
unset($data[self::CHECKSUM_KEY]);
47+
$attributes = $data[self::ATTRIBUTES_KEY] ?? [];
48+
unset($data[self::ATTRIBUTES_KEY]);
49+
50+
// normalize values that are a mix of key identifier + exposed data
51+
// e.g. [_id: 5, name: 'foo']
52+
// data = 5
53+
// exposeData = [name: foo]
54+
$exposedData = [];
55+
foreach ($data as $key => $value) {
56+
if (!\is_array($value) || !isset($value[self::EXPOSED_PROP_KEY])) {
57+
continue;
58+
}
59+
60+
$data[$key] = $value[self::EXPOSED_PROP_KEY];
61+
unset($value[self::EXPOSED_PROP_KEY]);
62+
$exposedData[$key] = $value;
63+
}
64+
65+
// separate data between props and data
66+
$props = [];
67+
foreach ($data as $key => $value) {
68+
if (\in_array($key, $propNames, true)) {
69+
$props[$key] = $value;
70+
unset($data[$key]);
71+
}
72+
}
73+
74+
return new self($props, $data, $exposedData, $attributes, $secret);
75+
}
76+
77+
public function getProps(bool $includeNonComponentKeys = true): array
78+
{
79+
$props = $this->structureKeyForExposedData($this->props);
80+
81+
if ($includeNonComponentKeys) {
82+
$props += [
83+
self::CHECKSUM_KEY => $this->getChecksum(),
84+
];
85+
86+
if ($this->attributes) {
87+
$props[self::ATTRIBUTES_KEY] = $this->attributes;
88+
}
89+
}
90+
91+
return $props;
92+
}
93+
94+
public function getData(): array
95+
{
96+
$data = $this->structureKeyForExposedData($this->data);
97+
98+
return array_merge_recursive($data, $this->exposedData);
99+
}
100+
101+
public function getAttributes(): array
102+
{
103+
return $this->attributes;
104+
}
105+
106+
/**
107+
* Returns all the props and data combined.
108+
*/
109+
public function all(): array
110+
{
111+
return array_merge_recursive($this->getProps(), $this->getData());
112+
}
113+
114+
/**
115+
* Returns just the props & data intended to hydrate the component.
116+
*
117+
* This does *not* include special keys like the checksum or attributes.
118+
*/
119+
public function allForComponent(): array
120+
{
121+
return array_merge_recursive(
122+
$this->getProps(includeNonComponentKeys: false),
123+
$this->getData()
124+
);
125+
}
126+
127+
public function isChecksumValid(array $data): bool
128+
{
129+
if (!\array_key_exists(self::CHECKSUM_KEY, $data)) {
130+
return false;
131+
}
132+
133+
return hash_equals($this->getChecksum(), $data[self::CHECKSUM_KEY]);
134+
}
135+
136+
public function has(string $key): bool
137+
{
138+
$data = $this->allForComponent();
139+
140+
return \array_key_exists($key, $data);
141+
}
142+
143+
public function get(string $key): mixed
144+
{
145+
if (\array_key_exists($key, $this->props)) {
146+
return $this->props[$key];
147+
}
148+
149+
if (\array_key_exists($key, $this->data)) {
150+
return $this->data[$key];
151+
}
152+
153+
throw new \InvalidArgumentException(sprintf('No data for key "%s"', $key));
154+
}
155+
156+
public function hasExposed(string $key): bool
157+
{
158+
return \array_key_exists($key, $this->exposedData);
159+
}
160+
161+
public function getExposed(string $key): array
162+
{
163+
if (!$this->hasExposed($key)) {
164+
throw new \InvalidArgumentException(sprintf('No exposed data for key "%s"', $key));
165+
}
166+
167+
return $this->exposedData[$key];
168+
}
169+
170+
private function getChecksum(): string
171+
{
172+
$props = $this->props;
173+
if (null === $this->checksum) {
174+
// sort so it is always consistent (frontend could have re-ordered data)
175+
ksort($props);
176+
177+
$this->checksum = base64_encode(hash_hmac('sha256', http_build_query($props), $this->secret, true));
178+
}
179+
180+
return $this->checksum;
181+
}
182+
183+
/**
184+
* Restructures "value" to the [_id: value] format if it has exposed data.
185+
*/
186+
private function structureKeyForExposedData(array $values): array
187+
{
188+
foreach ($values as $key => $value) {
189+
if (\array_key_exists($key, $this->exposedData)) {
190+
$values[$key] = [self::EXPOSED_PROP_KEY => $value];
191+
}
192+
}
193+
194+
return $values;
195+
}
196+
}

src/LiveComponent/src/EventListener/AddLiveAttributesSubscriber.php

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
1717
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
1818
use Symfony\Contracts\Service\ServiceSubscriberInterface;
19+
use Symfony\UX\LiveComponent\DehydratedComponent;
1920
use Symfony\UX\LiveComponent\LiveComponentHydrator;
2021
use Symfony\UX\TwigComponent\ComponentAttributes;
2122
use Symfony\UX\TwigComponent\ComponentMetadata;
@@ -82,13 +83,15 @@ private function getLiveAttributes(MountedComponent $mounted, ComponentMetadata
8283
{
8384
$name = $mounted->getName();
8485
$url = $this->container->get(UrlGeneratorInterface::class)->generate('live_component', ['component' => $name]);
85-
$data = $this->container->get(LiveComponentHydrator::class)->dehydrate($mounted);
86+
/** @var DehydratedComponent $dehydratedComponent */
87+
$dehydratedComponent = $this->container->get(LiveComponentHydrator::class)->dehydrate($mounted);
8688
$twig = $this->container->get(Environment::class);
8789

8890
$attributes = [
8991
'data-controller' => 'live',
9092
'data-live-url-value' => twig_escape_filter($twig, $url, 'html_attr'),
91-
'data-live-data-value' => twig_escape_filter($twig, json_encode($data, \JSON_THROW_ON_ERROR), 'html_attr'),
93+
'data-live-data-value' => twig_escape_filter($twig, json_encode($dehydratedComponent->getData(), \JSON_THROW_ON_ERROR), 'html_attr'),
94+
'data-live-props-value' => twig_escape_filter($twig, json_encode($dehydratedComponent->getProps(), \JSON_THROW_ON_ERROR), 'html_attr'),
9295
];
9396

9497
if ($this->container->has(CsrfTokenManagerInterface::class) && $metadata->get('csrf')) {

0 commit comments

Comments
 (0)