Skip to content

Commit f13a498

Browse files
committed
Merge branch '2.x' into fix-expanded-choices
# Conflicts: # src/LiveComponent/assets/src/live_controller.ts
2 parents a636a71 + 609f23b commit f13a498

23 files changed

+260
-259
lines changed

src/LiveComponent/assets/dist/live_controller.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1112,6 +1112,9 @@ class default_1 extends Controller {
11121112
return element.dataset.value || element.value;
11131113
}
11141114
_updateModelFromElement(element, value, shouldRender) {
1115+
if (!(element instanceof HTMLElement)) {
1116+
throw new Error('Could not update model for non HTMLElement');
1117+
}
11151118
const model = element.dataset.model || element.getAttribute('name');
11161119
if (!model) {
11171120
const clonedElement = cloneHTMLElement(element);
@@ -1315,7 +1318,7 @@ class default_1 extends Controller {
13151318
_getLoadingDirectives() {
13161319
const loadingDirectives = [];
13171320
this.element.querySelectorAll('[data-loading]').forEach((element => {
1318-
if (!(element instanceof HTMLElement)) {
1321+
if (!(element instanceof HTMLElement) && !(element instanceof SVGElement)) {
13191322
throw new Error('Invalid Element Type');
13201323
}
13211324
const directives = parseDirectives(element.dataset.loading || 'show');
@@ -1369,6 +1372,9 @@ class default_1 extends Controller {
13691372
const newElement = htmlToElement(newHtml);
13701373
morphdom(this.element, newElement, {
13711374
onBeforeElUpdated: (fromEl, toEl) => {
1375+
if (!(fromEl instanceof HTMLElement) || !(toEl instanceof HTMLElement)) {
1376+
return false;
1377+
}
13721378
if (fromEl.isEqualNode(toEl)) {
13731379
const normalizedFromEl = cloneHTMLElement(fromEl);
13741380
normalizeAttributesForComparison(normalizedFromEl);

src/LiveComponent/assets/src/live_controller.ts

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,14 @@ import { Controller } from '@hotwired/stimulus';
22
import morphdom from 'morphdom';
33
import { parseDirectives, Directive } from './directives_parser';
44
import { combineSpacedArray } from './string_utils';
5-
import {setDeepData, doesDeepPropertyExist, normalizeModelName, parseDeepData} from './set_deep_data';
5+
import { setDeepData, doesDeepPropertyExist, normalizeModelName } from './set_deep_data';
66
import { haveRenderedValuesChanged } from './have_rendered_values_changed';
77
import { normalizeAttributesForComparison } from './normalize_attributes_for_comparison';
88
import { cloneHTMLElement } from './clone_html_element';
99
import {getArrayValue} from "./get_array_value";
1010

1111
interface ElementLoadingDirectives {
12-
element: HTMLElement,
12+
element: HTMLElement|SVGElement,
1313
directives: Directive[]
1414
}
1515

@@ -187,11 +187,14 @@ export default class extends Controller {
187187
this._makeRequest(null);
188188
}
189189

190-
_getValueFromElement(element: HTMLElement) {
190+
_getValueFromElement(element: HTMLElement|SVGElement) {
191191
return element.dataset.value || (element as any).value;
192192
}
193193

194-
_updateModelFromElement(element: HTMLElement, value: string|null, shouldRender: boolean) {
194+
_updateModelFromElement(element: Element, value: string|null, shouldRender: boolean) {
195+
if (!(element instanceof HTMLElement)) {
196+
throw new Error('Could not update model for non HTMLElement');
197+
}
195198
const model = element.dataset.model || element.getAttribute('name');
196199

197200
if (!model) {
@@ -434,7 +437,7 @@ export default class extends Controller {
434437
/**
435438
* @private
436439
*/
437-
_handleLoadingDirective(element: HTMLElement, isLoading: boolean, directive: Directive) {
440+
_handleLoadingDirective(element: HTMLElement|SVGElement, isLoading: boolean, directive: Directive) {
438441
const finalAction = parseLoadingAction(directive.action, isLoading);
439442

440443
let loadingDirective: (() => void);
@@ -505,7 +508,7 @@ export default class extends Controller {
505508
const loadingDirectives: ElementLoadingDirectives[] = [];
506509

507510
this.element.querySelectorAll('[data-loading]').forEach((element => {
508-
if (!(element instanceof HTMLElement)) {
511+
if (!(element instanceof HTMLElement) && !(element instanceof SVGElement)) {
509512
throw new Error('Invalid Element Type');
510513
}
511514

@@ -521,19 +524,19 @@ export default class extends Controller {
521524
return loadingDirectives;
522525
}
523526

524-
_showElement(element: HTMLElement) {
527+
_showElement(element: HTMLElement|SVGElement) {
525528
element.style.display = 'inline-block';
526529
}
527530

528-
_hideElement(element: HTMLElement) {
531+
_hideElement(element: HTMLElement|SVGElement) {
529532
element.style.display = 'none';
530533
}
531534

532-
_addClass(element: HTMLElement, classes: string[]) {
535+
_addClass(element: HTMLElement|SVGElement, classes: string[]) {
533536
element.classList.add(...combineSpacedArray(classes));
534537
}
535538

536-
_removeClass(element: HTMLElement, classes: string[]) {
539+
_removeClass(element: HTMLElement|SVGElement, classes: string[]) {
537540
element.classList.remove(...combineSpacedArray(classes));
538541

539542
// remove empty class="" to avoid morphdom "diff" problem
@@ -579,6 +582,10 @@ export default class extends Controller {
579582
const newElement = htmlToElement(newHtml);
580583
morphdom(this.element, newElement, {
581584
onBeforeElUpdated: (fromEl, toEl) => {
585+
if (!(fromEl instanceof HTMLElement) || !(toEl instanceof HTMLElement)) {
586+
return false;
587+
}
588+
582589
// https://github.com/patrick-steele-idem/morphdom#can-i-make-morphdom-blaze-through-the-dom-tree-even-faster-yes
583590
if (fromEl.isEqualNode(toEl)) {
584591
// the nodes are equal, but the "value" on some might differ
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { cloneHTMLElement } from '../src/clone_html_element';
2+
3+
const createElement = function(html: string): HTMLElement {
4+
const template = document.createElement('template');
5+
html = html.trim();
6+
template.innerHTML = html;
7+
8+
const child = template.content.firstChild;
9+
if (!child || !(child instanceof HTMLElement)) {
10+
throw new Error('Child not found');
11+
}
12+
13+
return child;
14+
}
15+
16+
describe('cloneHTMLElement', () => {
17+
it('allows to clone HTMLElement', () => {
18+
const element = createElement('<div class="foo"></div>');
19+
const clone = cloneHTMLElement(element);
20+
21+
expect(clone.outerHTML).toEqual('<div class="foo"></div>');
22+
});
23+
});

src/LiveComponent/src/EventListener/AddLiveAttributesSubscriber.php

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99
use Symfony\Contracts\Service\ServiceSubscriberInterface;
1010
use Symfony\UX\LiveComponent\LiveComponentHydrator;
1111
use Symfony\UX\TwigComponent\ComponentAttributes;
12-
use Symfony\UX\TwigComponent\ComponentMetadata;
1312
use Symfony\UX\TwigComponent\EventListener\PreRenderEvent;
13+
use Symfony\UX\TwigComponent\MountedComponent;
1414
use Twig\Environment;
1515

1616
/**
@@ -29,7 +29,7 @@ public function onPreRender(PreRenderEvent $event): void
2929
return;
3030
}
3131

32-
$attributes = $this->getLiveAttributes($event->getComponent(), $event->getMetadata());
32+
$attributes = $this->getLiveAttributes($event->getMountedComponent());
3333
$variables = $event->getVariables();
3434

3535
if (isset($variables['attributes']) && $variables['attributes'] instanceof ComponentAttributes) {
@@ -57,12 +57,11 @@ public static function getSubscribedServices(): array
5757
];
5858
}
5959

60-
private function getLiveAttributes(object $component, ComponentMetadata $metadata): ComponentAttributes
60+
private function getLiveAttributes(MountedComponent $mounted): ComponentAttributes
6161
{
62-
$url = $this->container->get(UrlGeneratorInterface::class)
63-
->generate('live_component', ['component' => $metadata->getName()])
64-
;
65-
$data = $this->container->get(LiveComponentHydrator::class)->dehydrate($component);
62+
$name = $mounted->getName();
63+
$url = $this->container->get(UrlGeneratorInterface::class)->generate('live_component', ['component' => $name]);
64+
$data = $this->container->get(LiveComponentHydrator::class)->dehydrate($mounted);
6665
$twig = $this->container->get(Environment::class);
6766

6867
$attributes = [
@@ -73,7 +72,7 @@ private function getLiveAttributes(object $component, ComponentMetadata $metadat
7372

7473
if ($this->container->has(CsrfTokenManagerInterface::class)) {
7574
$attributes['data-live-csrf-value'] = $this->container->get(CsrfTokenManagerInterface::class)
76-
->getToken($metadata->getName())->getValue()
75+
->getToken($name)->getValue()
7776
;
7877
}
7978

src/LiveComponent/src/EventListener/LiveComponentSubscriber.php

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
use Symfony\UX\TwigComponent\ComponentFactory;
3434
use Symfony\UX\TwigComponent\ComponentMetadata;
3535
use Symfony\UX\TwigComponent\ComponentRenderer;
36+
use Symfony\UX\TwigComponent\MountedComponent;
3637

3738
/**
3839
* @author Kevin Bond <[email protected]>
@@ -73,6 +74,8 @@ public function onKernelRequest(RequestEvent $event): void
7374
$action = $request->get('action', 'get');
7475
$componentName = (string) $request->get('component');
7576

77+
$request->attributes->set('_component_name', $componentName);
78+
7679
try {
7780
/** @var ComponentMetadata $metadata */
7881
$metadata = $this->container->get(ComponentFactory::class)->metadataFor($componentName);
@@ -84,8 +87,6 @@ public function onKernelRequest(RequestEvent $event): void
8487
throw new NotFoundHttpException(sprintf('"%s" (%s) is not a Live Component.', $metadata->getClass(), $componentName));
8588
}
8689

87-
$request->attributes->set('_component_metadata', $metadata);
88-
8990
if ('get' === $action) {
9091
$defaultAction = trim($metadata->get('default_action', '__invoke'), '()');
9192

@@ -144,9 +145,13 @@ public function onKernelController(ControllerEvent $event): void
144145
throw new NotFoundHttpException(sprintf('The action "%s" either doesn\'t exist or is not allowed in "%s". Make sure it exist and has the LiveAction attribute above it.', $action, \get_class($component)));
145146
}
146147

147-
$this->container->get(LiveComponentHydrator::class)->hydrate($component, $data);
148+
$mounted = $this->container->get(LiveComponentHydrator::class)->hydrate(
149+
$component,
150+
$data,
151+
$request->attributes->get('_component_name')
152+
);
148153

149-
$request->attributes->set('_component', $component);
154+
$request->attributes->set('_mounted_component', $mounted);
150155

151156
if (!\is_string($queryString = $request->query->get('args'))) {
152157
return;
@@ -170,7 +175,7 @@ public function onKernelView(ViewEvent $event): void
170175
return;
171176
}
172177

173-
$response = $this->createResponse($request->attributes->get('_component'), $request);
178+
$response = $this->createResponse($request->attributes->get('_mounted_component'), $request);
174179

175180
$event->setResponse($response);
176181
}
@@ -187,14 +192,14 @@ public function onKernelException(ExceptionEvent $event): void
187192
return;
188193
}
189194

190-
$component = $request->attributes->get('_component');
195+
$mounted = $request->attributes->get('_mounted_component');
191196

192197
// in case the exception was too early somehow
193-
if (!$component) {
198+
if (!$mounted) {
194199
return;
195200
}
196201

197-
$response = $this->createResponse($component, $request);
202+
$response = $this->createResponse($mounted, $request);
198203
$event->setResponse($response);
199204
}
200205

@@ -232,15 +237,16 @@ public static function getSubscribedEvents(): array
232237
];
233238
}
234239

235-
private function createResponse(object $component, Request $request): Response
240+
private function createResponse(MountedComponent $mounted, Request $request): Response
236241
{
242+
$component = $mounted->getComponent();
243+
237244
foreach (AsLiveComponent::beforeReRenderMethods($component) as $method) {
238245
$component->{$method->name}();
239246
}
240247

241248
$html = $this->container->get(ComponentRenderer::class)->render(
242-
$component,
243-
$request->attributes->get('_component_metadata')
249+
$mounted,
244250
);
245251

246252
return new Response($html);

src/LiveComponent/src/LiveComponentHydrator.php

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
1818
use Symfony\UX\LiveComponent\Attribute\LivePropContext;
1919
use Symfony\UX\LiveComponent\Exception\UnsupportedHydrationException;
20+
use Symfony\UX\TwigComponent\ComponentAttributes;
21+
use Symfony\UX\TwigComponent\MountedComponent;
2022

2123
/**
2224
* @author Kevin Bond <[email protected]>
@@ -29,6 +31,7 @@ final class LiveComponentHydrator
2931
{
3032
private const CHECKSUM_KEY = '_checksum';
3133
private const EXPOSED_PROP_KEY = '_id';
34+
private const ATTRIBUTES_KEY = '_attributes';
3235

3336
/** @var PropertyHydratorInterface[] */
3437
private iterable $propertyHydrators;
@@ -45,8 +48,10 @@ public function __construct(iterable $propertyHydrators, PropertyAccessorInterfa
4548
$this->secret = $secret;
4649
}
4750

48-
public function dehydrate(object $component): array
51+
public function dehydrate(MountedComponent $mounted): array
4952
{
53+
$component = $mounted->getComponent();
54+
5055
foreach (AsLiveComponent::preDehydrateMethods($component) as $method) {
5156
$component->{$method->name}();
5257
}
@@ -100,15 +105,24 @@ public function dehydrate(object $component): array
100105
}
101106
}
102107

108+
if ($attributes = $mounted->getAttributes()->all()) {
109+
$data[self::ATTRIBUTES_KEY] = $attributes;
110+
$readonlyProperties[] = self::ATTRIBUTES_KEY;
111+
}
112+
103113
$data[self::CHECKSUM_KEY] = $this->computeChecksum($data, $readonlyProperties);
104114

105115
return $data;
106116
}
107117

108-
public function hydrate(object $component, array $data): void
118+
public function hydrate(object $component, array $data, string $componentName): MountedComponent
109119
{
110120
$readonlyProperties = [];
111121

122+
if (isset($data[self::ATTRIBUTES_KEY])) {
123+
$readonlyProperties[] = self::ATTRIBUTES_KEY;
124+
}
125+
112126
/** @var LivePropContext[] $propertyContexts */
113127
$propertyContexts = iterator_to_array(AsLiveComponent::liveProps($component));
114128

@@ -129,7 +143,9 @@ public function hydrate(object $component, array $data): void
129143

130144
$this->verifyChecksum($data, $readonlyProperties);
131145

132-
unset($data[self::CHECKSUM_KEY]);
146+
$attributes = new ComponentAttributes($data[self::ATTRIBUTES_KEY] ?? []);
147+
148+
unset($data[self::CHECKSUM_KEY], $data[self::ATTRIBUTES_KEY]);
133149

134150
foreach ($propertyContexts as $context) {
135151
$property = $context->reflectionProperty();
@@ -187,6 +203,8 @@ public function hydrate(object $component, array $data): void
187203
foreach (AsLiveComponent::postHydrateMethods($component) as $method) {
188204
$component->{$method->name}();
189205
}
206+
207+
return new MountedComponent($componentName, $component, $attributes);
190208
}
191209

192210
private function computeChecksum(array $data, array $readonlyProperties): string

src/LiveComponent/src/Resources/doc/index.rst

Lines changed: 4 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -249,30 +249,15 @@ Component Attributes
249249

250250
.. versionadded:: 2.1
251251

252-
The ``HasAttributes`` trait was added in TwigComponents 2.1.
252+
Component attributes were added in TwigComponents 2.1.
253253

254254
`Component attributes`_ allows you to render your components with extra
255255
props that are are converted to html attributes and made available in
256256
your component's template as an ``attributes`` variable. When used on
257-
live components, these props are persisted between renders. You can enable
258-
this feature by having your live component use the ``HasAttributesTrait``:
257+
live components, these props are persisted between renders.
259258

260-
.. code-block:: diff
261-
262-
// ...
263-
use Symfony\UX\LiveComponent\Attribute\LiveProp;
264-
+ use Symfony\UX\TwigComponent\HasAttributesTrait;
265-
266-
#[AsLiveComponent('random_number')]
267-
class RandomNumberComponent
268-
{
269-
+ use HasAttributesTrait;
270-
271-
#[LiveProp]
272-
public int $min = 0;
273-
274-
Now, when rendering your component, you can pass html attributes
275-
as props and these will be added to ``attributes``:
259+
When rendering your component, you can pass html attributes as props and
260+
these will be added to ``attributes``:
276261

277262
.. code-block:: twig
278263

src/LiveComponent/src/Twig/LiveComponentRuntime.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@ public function __construct(
3131

3232
public function getComponentUrl(string $name, array $props = []): string
3333
{
34-
$component = $this->factory->create($name, $props);
35-
$params = ['component' => $name] + $this->hydrator->dehydrate($component);
34+
$mounted = $this->factory->create($name, $props);
35+
$params = ['component' => $name] + $this->hydrator->dehydrate($mounted);
3636

3737
return $this->urlGenerator->generate('live_component', $params);
3838
}

0 commit comments

Comments
 (0)