Skip to content

Commit 9d2d49d

Browse files
feature #780 [Live] Adding a "key" attribute that can be used when rendering a collection of children (weaverryan)
This PR was merged into the 2.x branch. Discussion ---------- [Live] Adding a "key" attribute that can be used when rendering a collection of children | Q | A | ------------- | --- | Bug fix? | no | New feature? | yes | Tickets | None | License | MIT Found this while building some components. When you render an array of child components, we need a `data-live-id` on each one, which is how we identify which component is which in case some are reordered or removed. But, adding a `data-live-id` is ugly, so this allows a simpler `key` attribute, like any other frontend framework. Cheers! Commits ------- 9b1711b0 Adding a "key" attribute that can be used when rendering a collection of children
2 parents b4958b1 + fef70f1 commit 9d2d49d

File tree

9 files changed

+136
-21
lines changed

9 files changed

+136
-21
lines changed

src/LiveComponent/doc/index.rst

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -578,7 +578,7 @@ option::
578578

579579
Now you can bind this to a field on the frontend that uses that same format:
580580

581-
.. code-block:: twig
581+
.. code-block:: html+twig
582582

583583
<input type="date" data-model="publishOn">
584584

@@ -2547,16 +2547,35 @@ form.
25472547
Rendering Quirks with List of Embedded Components
25482548
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
25492549

2550-
Imagine your component renders a list of embedded components and
2551-
that list is updated as the user types into a search box. Most of the
2552-
time, this works *fine*. But in some cases, as the list of items
2553-
changes, a child component will re-render even though it was there
2554-
before *and* after the list changed. This can cause that child component
2555-
to lose some state (i.e. it re-renders with its original live props data).
2550+
Rendering Quirks with List of Embedded Components
2551+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
2552+
2553+
Imagine your component renders a list of child components and
2554+
the list changes as the user types into a search box... or by clicking
2555+
"delete" on an item. In this case, the wrong children may be removed
2556+
or existing child components may not disappear when they should.
2557+
2558+
.. versionadded:: 2.8
2559+
2560+
The ``key`` prop was added in Symfony UX Live Component 2.8.
2561+
2562+
To fix this, add a ``key`` prop to each child component that's unique
2563+
to that component:
2564+
2565+
.. code-block:: twig
2566+
2567+
{# templates/components/invoice.html.twig #}
2568+
{% for lineItem in lineItems %}
2569+
{{ component('invoice_line_item', {
2570+
productId: lineItem.productId,
2571+
key: lineItem.id,
2572+
}) }}
2573+
{% endfor %}
25562574
2557-
To fix this, add a unique ``data-live-id`` attribute to the root component of each
2558-
child element. This will helps LiveComponent identify each item in the
2559-
list and correctly determine if a re-render is necessary, or not.
2575+
The ``key`` will be used to generate a ``data-live-id`` attribute,
2576+
which will be used to identify each child component. You can
2577+
also pass in a ``data-live-id`` attribute directly, but ``key`` is
2578+
a bit more convenient.
25602579

25612580
Advanced Functionality
25622581
----------------------

src/LiveComponent/src/EventListener/AddLiveAttributesSubscriber.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@ public function onPreRender(PreRenderEvent $event): void
6464
$attributes = $attributes->defaults($variables[$attributesKey]->all());
6565
}
6666

67+
// "key" is a special attribute: don't actually render it
68+
// this is used inside LiveControllerAttributesCreator
69+
$attributes = $attributes->without(LiveControllerAttributesCreator::KEY_PROP_NAME);
70+
6771
$variables[$attributesKey] = $attributes;
6872

6973
$event->setVariables($variables);

src/LiveComponent/src/EventListener/InterceptChildComponentRenderSubscriber.php

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,13 @@ public function preComponentCreated(PreCreateForRenderEvent $event): void
5353
$childFingerprints = $parentComponent->getExtraMetadata(self::CHILDREN_FINGERPRINTS_METADATA_KEY);
5454

5555
// get the deterministic id for this child, but without incrementing the counter yet
56-
$deterministicId = $event->getInputProps()['data-live-id'] ?? $this->getDeterministicIdCalculator()->calculateDeterministicId(increment: false);
56+
if (isset($event->getInputProps()['data-live-id'])) {
57+
$deterministicId = $event->getInputProps()['data-live-id'];
58+
} else {
59+
$key = $event->getInputProps()[LiveControllerAttributesCreator::KEY_PROP_NAME] ?? null;
60+
$deterministicId = $this->getDeterministicIdCalculator()->calculateDeterministicId(increment: false, key: $key);
61+
}
62+
5763
if (!isset($childFingerprints[$deterministicId])) {
5864
// child fingerprint wasn't set, it is likely a new child, allow it to render fully
5965
return;

src/LiveComponent/src/Twig/DeterministicTwigIdCalculator.php

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,11 @@ class DeterministicTwigIdCalculator
3737
* called this method 3 times on the same line during one request, you will
3838
* get the same value back if you call it 3 times on a future request for
3939
* that same file & line.
40+
*
41+
* @param bool $increment Whether to increment the counter for this file+line
42+
* @param string|null $key An optional key to use instead of the incremented counter
4043
*/
41-
public function calculateDeterministicId(bool $increment = true): string
44+
public function calculateDeterministicId(bool $increment = true, string $key = null): string
4245
{
4346
$lineData = $this->guessTemplateInfo();
4447

@@ -48,9 +51,9 @@ public function calculateDeterministicId(bool $increment = true): string
4851
}
4952

5053
$id = sprintf(
51-
'live-%s-%d',
54+
'live-%s-%s',
5255
crc32($fileAndLine),
53-
$this->lineAndFileCounts[$fileAndLine]
56+
null !== $key ? $key : $this->lineAndFileCounts[$fileAndLine]
5457
);
5558

5659
if ($increment) {

src/LiveComponent/src/Util/LiveControllerAttributesCreator.php

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,13 @@
3131
*/
3232
class LiveControllerAttributesCreator
3333
{
34+
/**
35+
* Prop name that can be passed into a component to keep it unique in a loop.
36+
*
37+
* This is used to generate the unique data-live-id for the child component.
38+
*/
39+
public const KEY_PROP_NAME = 'key';
40+
3441
public function __construct(
3542
private LiveComponentMetadataFactory $metadataFactory,
3643
private LiveComponentHydrator $hydrator,
@@ -70,7 +77,8 @@ public function attributesForRendering(MountedComponent $mounted, ComponentMetad
7077

7178
if ($isChildComponent) {
7279
if (!isset($mountedAttributes->all()['data-live-id'])) {
73-
$id = $deterministicId ?: $this->idCalculator->calculateDeterministicId();
80+
$id = $deterministicId ?: $this->idCalculator
81+
->calculateDeterministicId(key: $mounted->getInputProps()[self::KEY_PROP_NAME] ?? null);
7482
$attributesCollection->setLiveId($id);
7583
// we need to add this to the mounted attributes so that it is
7684
// will be included in the "attributes" part of the props data.

src/LiveComponent/tests/Fixtures/Component/TodoItemComponent.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
#[AsLiveComponent('todo_item')]
2121
final class TodoItemComponent
2222
{
23+
use DefaultActionTrait;
24+
2325
#[LiveProp(writable: true)]
2426
public string $text = '';
2527

@@ -29,6 +31,4 @@ final class TodoItemComponent
2931
// here just to force a checksum to be needed, helps make tests more robust
3032
#[LiveProp(writable: false)]
3133
public string $readonlyValue = 'readonly';
32-
33-
use DefaultActionTrait;
3434
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
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\Tests\Fixtures\Component;
13+
14+
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
15+
use Symfony\UX\LiveComponent\Attribute\LiveProp;
16+
use Symfony\UX\LiveComponent\DefaultActionTrait;
17+
18+
#[AsLiveComponent('todo_list_with_keys')]
19+
final class TodoListWithKeysComponent
20+
{
21+
use DefaultActionTrait;
22+
23+
#[LiveProp(writable: true)]
24+
public string $name = '';
25+
26+
#[LiveProp]
27+
public array $items = [];
28+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<div{{ attributes }}>
2+
Todo List: {{ name }}
3+
4+
<ul>
5+
{% for key, item in items %}
6+
{{ component('todo_item', {
7+
text: item.text,
8+
textLength: item.text|length,
9+
key: 'the-key'~key,
10+
}) }}
11+
{% endfor %}
12+
</ul>
13+
</div>

src/LiveComponent/tests/Functional/EventListener/InterceptChildComponentRenderSubscriberTest.php

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,16 +103,50 @@ public function testItRendersNewPropWhenFingerprintDoesNotMatch(): void
103103
});
104104
}
105105

106+
public function testItUsesKeysToRenderChildrenLiveIds(): void
107+
{
108+
$fingerprintValues = array_values(self::$actualTodoItemFingerprints);
109+
$fingerprints = [];
110+
foreach ($fingerprintValues as $key => $fingerprintValue) {
111+
// creating fingerprints keys to match todo_list_with_keys.html.twig
112+
$fingerprints['live-4172682817-the-key'.$key] = $fingerprintValue;
113+
}
114+
$fingerprints['live-4172682817-the-key1'] = 'wrong fingerprint';
115+
116+
$urlSimple = $this->doBuildUrlForComponent('todo_list_with_keys', []);
117+
$urlWithChangedFingerprints = $this->doBuildUrlForComponent('todo_list_with_keys', $fingerprints);
118+
119+
$this->browser()
120+
->visit($urlSimple)
121+
->assertSuccessful()
122+
->assertHtml()
123+
->assertElementCount('ul li', 3)
124+
// check for the live-id we expect based on the key
125+
->assertContains('data-live-id="live-4172682817-the-key0"')
126+
->assertNotContains('key="the-key0"')
127+
->visit($urlWithChangedFingerprints)
128+
->assertContains('<li data-live-id="live-4172682817-the-key0"></li>')
129+
// this one is changed, so it renders a full element
130+
->assertContains('<li data-live-name-value="todo_item" data-live-id="live-4172682817-the-key1"')
131+
;
132+
}
133+
106134
private function buildUrlForTodoListComponent(array $childrenFingerprints, bool $includeLiveId = false): string
107135
{
108-
$component = $this->mountComponent('todo_list', [
136+
return $this->doBuildUrlForComponent('todo_list', $childrenFingerprints, [
137+
'includeDataLiveId' => $includeLiveId,
138+
]);
139+
}
140+
141+
private function doBuildUrlForComponent(string $componentName, array $childrenFingerprints, array $extraProps = []): string
142+
{
143+
$component = $this->mountComponent($componentName, array_merge([
109144
'items' => [
110145
['text' => 'wake up'],
111146
['text' => 'high five a friend'],
112147
['text' => 'take a nap'],
113148
],
114-
'includeDataLiveId' => $includeLiveId,
115-
]);
149+
], $extraProps));
116150

117151
$dehydratedProps = $this->dehydrateComponent($component);
118152

@@ -131,6 +165,6 @@ private function buildUrlForTodoListComponent(array $childrenFingerprints, bool
131165
$queryData['children'] = json_encode($children);
132166
}
133167

134-
return '/_components/todo_list?'.http_build_query($queryData);
168+
return sprintf('/_components/%s?%s', $componentName, http_build_query($queryData));
135169
}
136170
}

0 commit comments

Comments
 (0)