Skip to content

Commit 5c1dc69

Browse files
committed
minor #813 [LiveComponent] Adding more detail about loops & keys behavior (weaverryan)
This PR was squashed before being merged into the 2.x branch. Discussion ---------- [LiveComponent] Adding more detail about loops & keys behavior | Q | A | ------------- | --- | Bug fix? | no | New feature? | no | Tickets | None | License | MIT One of the trickier things in any frontend system like this is dealing with loops of elements or components. This expands on the existing docs to clarify that `data-live-id` is sometimes needed for loops of elements and also to explain an expected situation that might cause a WTF moment for people. Cheers! Commits ------- 4fb2247 [LiveComponent] Adding more detail about loops & keys behavior
2 parents 0e24acb + 4fb2247 commit 5c1dc69

File tree

3 files changed

+195
-12
lines changed

3 files changed

+195
-12
lines changed

src/LiveComponent/doc/index.rst

Lines changed: 183 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2350,6 +2350,8 @@ a model in a child updates, it won't also update that model in its parent
23502350
The parent-child system is *smart*. And with a few tricks, you can make
23512351
it behave exactly like you need.
23522352

2353+
.. _child-component-independent-rerender:
2354+
23532355
Each component re-renders independent of one another
23542356
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
23552357

@@ -2631,6 +2633,27 @@ Notice that ``MarkdownTextarea`` allows a dynamic ``name``
26312633
attribute to be passed in. This makes that component re-usable in any
26322634
form.
26332635

2636+
.. _rendering-loop-of-elements:
2637+
2638+
Rendering Quirks with List of Elements
2639+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
2640+
2641+
If you're rendering a list of elements in your component, to help LiveComponents
2642+
understand which element is which between re-renders (i.e. if something re-orders
2643+
or removes some of those elements), you can add a ``data-live-id`` attribute to
2644+
each element
2645+
2646+
.. code-block:: html+twig
2647+
2648+
{# templates/components/Invoice.html.twig #}
2649+
{% for lineItem in lineItems %}
2650+
<div data-live-id="{{ lineItem.id }}">
2651+
{{ lineItem.name }}
2652+
</div>
2653+
{% endfor %}
2654+
2655+
.. _key-prop:
2656+
26342657
Rendering Quirks with List of Embedded Components
26352658
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
26362659

@@ -2648,10 +2671,10 @@ to that component:
26482671

26492672
.. code-block:: twig
26502673
2651-
{# templates/components/Invoice.html.twig #}
2652-
{% for lineItem in lineItems %}
2653-
{{ component('invoice_line_item', {
2654-
productId: lineItem.productId,
2674+
{# templates/components/InvoiceCreator.html.twig #}
2675+
{% for lineItem in invoice.lineItems %}
2676+
{{ component('InvoiceLineItemForm', {
2677+
lineItem: lineItem,
26552678
key: lineItem.id,
26562679
}) }}
26572680
{% endfor %}
@@ -2661,6 +2684,128 @@ which will be used to identify each child component. You can
26612684
also pass in a ``data-live-id`` attribute directly, but ``key`` is
26622685
a bit more convenient.
26632686

2687+
.. _rendering-loop-new-element:
2688+
2689+
Tricks with a Loop + a "New" Item
2690+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
2691+
2692+
Let's get fancier. After looping over the current line items, you
2693+
decide to render one more component to create a *new* line item.
2694+
In that case, you can pass in a ``key`` set to something like ``new_line_item``:
2695+
2696+
.. code-block:: twig
2697+
2698+
{# templates/components/InvoiceCreator.html.twig #}
2699+
// ... loop and render the existing line item components
2700+
2701+
{{ component('InvoiceLineItemForm', {
2702+
key: 'new_line_item',
2703+
}) }}
2704+
2705+
Imagine you also have a ``LiveAction`` inside of ``InvoiceLineItemForm``
2706+
that saves the new line item to the database. To be extra fancy,
2707+
it emits a ``lineItem:created`` event to the parent::
2708+
2709+
// src/Twig/InvoiceLineItemForm.php
2710+
// ...
2711+
2712+
#[AsLiveComponent]
2713+
final class InvoiceLineItemForm
2714+
{
2715+
// ...
2716+
2717+
#[LiveProp]
2718+
#[Valid]
2719+
public ?InvoiceLineItem $lineItem = null;
2720+
2721+
#[PostMount]
2722+
public function postMount(): void
2723+
{
2724+
if(!$this->lineItem) {
2725+
$this->lineItem = new InvoiceLineItem();
2726+
}
2727+
}
2728+
2729+
#[LiveAction]
2730+
public function save(EntityManagerInterface $entityManager)
2731+
{
2732+
if (!$this->lineItem->getId()) {
2733+
$this->emit('lineItem:created', $this->lineItem);
2734+
}
2735+
2736+
$entityManager->persist($this->lineItem);
2737+
$entityManager->flush();
2738+
}
2739+
}
2740+
2741+
Finally, the parent ``InvoiceCreator`` component listens to this
2742+
so that it can re-render the line items (which will now contain the
2743+
newly-saved item)::
2744+
2745+
// src/Twig/InvoiceCreator.php
2746+
// ...
2747+
2748+
#[AsLiveComponent]
2749+
final class InvoiceCreator
2750+
{
2751+
// ...
2752+
2753+
#[LiveListener('lineItem:created')]
2754+
public function addLineItem()
2755+
{
2756+
// no need to do anything here: the component will re-render
2757+
}
2758+
}
2759+
2760+
This will work beautifully: when a new line item is saved, the ``InvoiceCreator``
2761+
component will re-render and the newly saved line item will be displayed along
2762+
with the extra ``new_line_item`` component at the bottom.
2763+
2764+
But something surprising might happen: the ``new_line_item`` component won't
2765+
update! It will *keep* the data and props that were there a moment ago (i.e. the
2766+
form fields will still have data in them) instead of rendering a fresh, empty component.
2767+
2768+
Why? When live components re-renders, it thinks the existing ``key: new_line_item``
2769+
component on the page is the *same* new component that it's about to render. And
2770+
because the props passed into that component haven't changed, it doesn't see any
2771+
reason to re-render it.
2772+
2773+
To fix this, you have two options:
2774+
2775+
1. Make the ``key`` dynamic so it will be different after adding a new item::
2776+
2777+
.. code-block:: twig
2778+
2779+
{{ component('InvoiceLineItemForm', {
2780+
key: 'new_line_item_'~lineItems|length,
2781+
}) }}
2782+
2783+
2. Reset the state of the ``InvoiceLineItemForm`` component after it's saved::
2784+
2785+
// src/Twig/InvoiceLineItemForm.php
2786+
// ...
2787+
class InvoiceLineItemForm
2788+
{
2789+
// ...
2790+
2791+
#[LiveAction]
2792+
public function save(EntityManagerInterface $entityManager)
2793+
{
2794+
$isNew = null === $this->lineItem->getId();
2795+
2796+
$entityManager->persist($this->lineItem);
2797+
$entityManager->flush();
2798+
2799+
if ($isNew) {
2800+
// reset the state of this component
2801+
$this->emit('lineItem:created', $this->lineItem);
2802+
$this->lineItem = new InvoiceLineItem();
2803+
// if you're using ValidatableComponentTrait
2804+
$this->clearValidation();
2805+
}
2806+
}
2807+
}
2808+
26642809
Advanced Functionality
26652810
----------------------
26662811

@@ -2694,6 +2839,40 @@ The system doesn't handle every edge case, so here are some things to keep in mi
26942839
that change is **lost**: the element will be re-added in its original location
26952840
during the next re-render.
26962841

2842+
The Mystical data-live-id Attribute
2843+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
2844+
2845+
The ``data-live-id`` attribute is mentioned several times throughout the documentation
2846+
to solve various problems. It's usually not needed, but can be the key to solving
2847+
certain complex problems. But what is it?
2848+
2849+
.. note::
2850+
2851+
The :ref:`key prop <key-prop>` is used to create a ``data-live-id`` attribute
2852+
on child components. So everything in this section applies equally to the
2853+
``key`` prop.
2854+
2855+
The ``data-live-id`` attribute is a unique identifier for an element or a component.
2856+
It's used when a component re-renders and helps Live Components "connect" elements
2857+
or components in the existing HTML with the new HTML. The logic works like this:
2858+
2859+
Suppose an element or component in the new HTML has a ``data-live-id="some-id`` attribute.
2860+
Then:
2861+
2862+
A) If there **is** an element or component with ``data-live-id="some-id"`` in the
2863+
existing HTML, then the old and new elements/components are considered to be the
2864+
"same". For elements, the new element will be used to update the old element even
2865+
if the two elements appear in different places - e.g. like if :ref:`elements are moved <rendering-loop-of-elements>`
2866+
or re-ordered. For components, because child components render independently
2867+
from their parent, the existing component will be "left alone" and not re-rendered
2868+
(unless some ``updateFromParent`` props have changed - see :ref:`child-component-independent-rerender`).
2869+
2870+
B) If there is **not** an element or component with ``data-live-id="some-id"`` in
2871+
the existing HTML, then the new element or component is considered to be "new".
2872+
In both cases, the new element or component will be added to the page. If there
2873+
is a component/element with a ``data-live-id`` attribute that is *not* in the
2874+
new HTML, that component/element will be removed from the page.
2875+
26972876
Skipping Updating Certain Elements
26982877
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
26992878

src/TwigComponent/src/Twig/TwigPreLexer.php

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,13 @@ public function preLexComponents(string $input): string
5858
$this->currentComponents[] = ['name' => $componentName, 'hasDefaultBlock' => false];
5959
}
6060

61-
$output .= "{% component '{$componentName}'".($attributes ? " with { {$attributes} }" : '').' %}';
6261
if ($isSelfClosing) {
63-
$output .= '{% endcomponent %}';
62+
// use the simpler component() format, so that the system doesn't think
63+
// this is an "embedded" component with blocks
64+
// see https://github.com/symfony/ux/issues/810
65+
$output .= "{{ component('{$componentName}'".($attributes ? ", { {$attributes} }" : '').') }}';
66+
} else {
67+
$output .= "{% component '{$componentName}'".($attributes ? " with { {$attributes} }" : '').' %}';
6468
}
6569

6670
continue;

src/TwigComponent/tests/Unit/TwigPreLexerTest.php

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,17 +29,17 @@ public function getLexTests(): iterable
2929
{
3030
yield 'simple_component' => [
3131
'<twig:foo />',
32-
'{% component \'foo\' %}{% endcomponent %}',
32+
'{{ component(\'foo\') }}',
3333
];
3434

3535
yield 'component_with_attributes' => [
3636
'<twig:foo bar="baz" with_quotes="It\'s with quotes" />',
37-
"{% component 'foo' with { bar: 'baz', with_quotes: 'It\'s with quotes' } %}{% endcomponent %}",
37+
"{{ component('foo', { bar: 'baz', with_quotes: 'It\'s with quotes' }) }}",
3838
];
3939

4040
yield 'component_with_dynamic_attributes' => [
4141
'<twig:foo dynamic="{{ dynamicVar }}" :otherDynamic="anotherVar" />',
42-
'{% component \'foo\' with { dynamic: dynamicVar, otherDynamic: anotherVar } %}{% endcomponent %}',
42+
'{{ component(\'foo\', { dynamic: dynamicVar, otherDynamic: anotherVar }) }}',
4343
];
4444

4545
yield 'component_with_closing_tag' => [
@@ -54,12 +54,12 @@ public function getLexTests(): iterable
5454

5555
yield 'component_with_embedded_component_inside_block' => [
5656
'<twig:foo><twig:block name="foo_block"><twig:bar /></twig:block></twig:foo>',
57-
'{% component \'foo\' %}{% block foo_block %}{% component \'bar\' %}{% endcomponent %}{% endblock %}{% endcomponent %}',
57+
'{% component \'foo\' %}{% block foo_block %}{{ component(\'bar\') }}{% endblock %}{% endcomponent %}',
5858
];
5959

6060
yield 'attribute_with_no_value' => [
6161
'<twig:foo bar />',
62-
'{% component \'foo\' with { bar: true } %}{% endcomponent %}',
62+
'{{ component(\'foo\', { bar: true }) }}',
6363
];
6464

6565
yield 'component_with_default_block_content' => [
@@ -69,7 +69,7 @@ public function getLexTests(): iterable
6969

7070
yield 'component_with_default_block_that_holds_a_component_and_multi_blocks' => [
7171
'<twig:foo>Foo <twig:bar /><twig:block name="other_block">Other block</twig:block></twig:foo>',
72-
'{% component \'foo\' %}{% block content %}Foo {% component \'bar\' %}{% endcomponent %}{% endblock %}{% block other_block %}Other block{% endblock %}{% endcomponent %}',
72+
'{% component \'foo\' %}{% block content %}Foo {{ component(\'bar\') }}{% endblock %}{% block other_block %}Other block{% endblock %}{% endcomponent %}',
7373
];
7474
yield 'component_with_character_:_on_his_name' => [
7575
'<twig:foo:bar></twig:foo:bar>',

0 commit comments

Comments
 (0)