diff --git a/src/LiveComponent/doc/index.rst b/src/LiveComponent/doc/index.rst index ab6f5373b69..c02452bd18d 100644 --- a/src/LiveComponent/doc/index.rst +++ b/src/LiveComponent/doc/index.rst @@ -2350,6 +2350,8 @@ a model in a child updates, it won't also update that model in its parent The parent-child system is *smart*. And with a few tricks, you can make it behave exactly like you need. +.. _child-component-independent-rerender: + Each component re-renders independent of one another ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -2631,6 +2633,27 @@ Notice that ``MarkdownTextarea`` allows a dynamic ``name`` attribute to be passed in. This makes that component re-usable in any form. +.. _rendering-loop-of-elements: + +Rendering Quirks with List of Elements +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If you're rendering a list of elements in your component, to help LiveComponents +understand which element is which between re-renders (i.e. if something re-orders +or removes some of those elements), you can add a ``data-live-id`` attribute to +each element + +.. code-block:: html+twig + + {# templates/components/Invoice.html.twig #} + {% for lineItem in lineItems %} +
+ {{ lineItem.name }} +
+ {% endfor %} + +.. _key-prop: + Rendering Quirks with List of Embedded Components ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -2648,10 +2671,10 @@ to that component: .. code-block:: twig - {# templates/components/Invoice.html.twig #} - {% for lineItem in lineItems %} - {{ component('invoice_line_item', { - productId: lineItem.productId, + {# templates/components/InvoiceCreator.html.twig #} + {% for lineItem in invoice.lineItems %} + {{ component('InvoiceLineItemForm', { + lineItem: lineItem, key: lineItem.id, }) }} {% endfor %} @@ -2661,6 +2684,128 @@ which will be used to identify each child component. You can also pass in a ``data-live-id`` attribute directly, but ``key`` is a bit more convenient. +.. _rendering-loop-new-element: + +Tricks with a Loop + a "New" Item +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Let's get fancier. After looping over the current line items, you +decide to render one more component to create a *new* line item. +In that case, you can pass in a ``key`` set to something like ``new_line_item``: + +.. code-block:: twig + + {# templates/components/InvoiceCreator.html.twig #} + // ... loop and render the existing line item components + + {{ component('InvoiceLineItemForm', { + key: 'new_line_item', + }) }} + +Imagine you also have a ``LiveAction`` inside of ``InvoiceLineItemForm`` +that saves the new line item to the database. To be extra fancy, +it emits a ``lineItem:created`` event to the parent:: + + // src/Twig/InvoiceLineItemForm.php + // ... + + #[AsLiveComponent] + final class InvoiceLineItemForm + { + // ... + + #[LiveProp] + #[Valid] + public ?InvoiceLineItem $lineItem = null; + + #[PostMount] + public function postMount(): void + { + if(!$this->lineItem) { + $this->lineItem = new InvoiceLineItem(); + } + } + + #[LiveAction] + public function save(EntityManagerInterface $entityManager) + { + if (!$this->lineItem->getId()) { + $this->emit('lineItem:created', $this->lineItem); + } + + $entityManager->persist($this->lineItem); + $entityManager->flush(); + } + } + +Finally, the parent ``InvoiceCreator`` component listens to this +so that it can re-render the line items (which will now contain the +newly-saved item):: + + // src/Twig/InvoiceCreator.php + // ... + + #[AsLiveComponent] + final class InvoiceCreator + { + // ... + + #[LiveListener('lineItem:created')] + public function addLineItem() + { + // no need to do anything here: the component will re-render + } + } + +This will work beautifully: when a new line item is saved, the ``InvoiceCreator`` +component will re-render and the newly saved line item will be displayed along +with the extra ``new_line_item`` component at the bottom. + +But something surprising might happen: the ``new_line_item`` component won't +update! It will *keep* the data and props that were there a moment ago (i.e. the +form fields will still have data in them) instead of rendering a fresh, empty component. + +Why? When live components re-renders, it thinks the existing ``key: new_line_item`` +component on the page is the *same* new component that it's about to render. And +because the props passed into that component haven't changed, it doesn't see any +reason to re-render it. + +To fix this, you have two options: + +1. Make the ``key`` dynamic so it will be different after adding a new item:: + +.. code-block:: twig + + {{ component('InvoiceLineItemForm', { + key: 'new_line_item_'~lineItems|length, + }) }} + +2. Reset the state of the ``InvoiceLineItemForm`` component after it's saved:: + + // src/Twig/InvoiceLineItemForm.php + // ... + class InvoiceLineItemForm + { + // ... + + #[LiveAction] + public function save(EntityManagerInterface $entityManager) + { + $isNew = null === $this->lineItem->getId(); + + $entityManager->persist($this->lineItem); + $entityManager->flush(); + + if ($isNew) { + // reset the state of this component + $this->emit('lineItem:created', $this->lineItem); + $this->lineItem = new InvoiceLineItem(); + // if you're using ValidatableComponentTrait + $this->clearValidation(); + } + } + } + Advanced Functionality ---------------------- @@ -2694,6 +2839,40 @@ The system doesn't handle every edge case, so here are some things to keep in mi that change is **lost**: the element will be re-added in its original location during the next re-render. +The Mystical data-live-id Attribute +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The ``data-live-id`` attribute is mentioned several times throughout the documentation +to solve various problems. It's usually not needed, but can be the key to solving +certain complex problems. But what is it? + +.. note:: + + The :ref:`key prop ` is used to create a ``data-live-id`` attribute + on child components. So everything in this section applies equally to the + ``key`` prop. + +The ``data-live-id`` attribute is a unique identifier for an element or a component. +It's used when a component re-renders and helps Live Components "connect" elements +or components in the existing HTML with the new HTML. The logic works like this: + +Suppose an element or component in the new HTML has a ``data-live-id="some-id`` attribute. +Then: + +A) If there **is** an element or component with ``data-live-id="some-id"`` in the + existing HTML, then the old and new elements/components are considered to be the + "same". For elements, the new element will be used to update the old element even + if the two elements appear in different places - e.g. like if :ref:`elements are moved ` + or re-ordered. For components, because child components render independently + from their parent, the existing component will be "left alone" and not re-rendered + (unless some ``updateFromParent`` props have changed - see :ref:`child-component-independent-rerender`). + +B) If there is **not** an element or component with ``data-live-id="some-id"`` in + the existing HTML, then the new element or component is considered to be "new". + In both cases, the new element or component will be added to the page. If there + is a component/element with a ``data-live-id`` attribute that is *not* in the + new HTML, that component/element will be removed from the page. + Skipping Updating Certain Elements ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/src/TwigComponent/src/Twig/TwigPreLexer.php b/src/TwigComponent/src/Twig/TwigPreLexer.php index ed1a4b3faac..2aae143a9a3 100644 --- a/src/TwigComponent/src/Twig/TwigPreLexer.php +++ b/src/TwigComponent/src/Twig/TwigPreLexer.php @@ -58,9 +58,13 @@ public function preLexComponents(string $input): string $this->currentComponents[] = ['name' => $componentName, 'hasDefaultBlock' => false]; } - $output .= "{% component '{$componentName}'".($attributes ? " with { {$attributes} }" : '').' %}'; if ($isSelfClosing) { - $output .= '{% endcomponent %}'; + // use the simpler component() format, so that the system doesn't think + // this is an "embedded" component with blocks + // see https://github.com/symfony/ux/issues/810 + $output .= "{{ component('{$componentName}'".($attributes ? ", { {$attributes} }" : '').') }}'; + } else { + $output .= "{% component '{$componentName}'".($attributes ? " with { {$attributes} }" : '').' %}'; } continue; diff --git a/src/TwigComponent/tests/Unit/TwigPreLexerTest.php b/src/TwigComponent/tests/Unit/TwigPreLexerTest.php index 80347b2b3fd..8220d29f96a 100644 --- a/src/TwigComponent/tests/Unit/TwigPreLexerTest.php +++ b/src/TwigComponent/tests/Unit/TwigPreLexerTest.php @@ -29,17 +29,17 @@ public function getLexTests(): iterable { yield 'simple_component' => [ '', - '{% component \'foo\' %}{% endcomponent %}', + '{{ component(\'foo\') }}', ]; yield 'component_with_attributes' => [ '', - "{% component 'foo' with { bar: 'baz', with_quotes: 'It\'s with quotes' } %}{% endcomponent %}", + "{{ component('foo', { bar: 'baz', with_quotes: 'It\'s with quotes' }) }}", ]; yield 'component_with_dynamic_attributes' => [ '', - '{% component \'foo\' with { dynamic: dynamicVar, otherDynamic: anotherVar } %}{% endcomponent %}', + '{{ component(\'foo\', { dynamic: dynamicVar, otherDynamic: anotherVar }) }}', ]; yield 'component_with_closing_tag' => [ @@ -54,12 +54,12 @@ public function getLexTests(): iterable yield 'component_with_embedded_component_inside_block' => [ '', - '{% component \'foo\' %}{% block foo_block %}{% component \'bar\' %}{% endcomponent %}{% endblock %}{% endcomponent %}', + '{% component \'foo\' %}{% block foo_block %}{{ component(\'bar\') }}{% endblock %}{% endcomponent %}', ]; yield 'attribute_with_no_value' => [ '', - '{% component \'foo\' with { bar: true } %}{% endcomponent %}', + '{{ component(\'foo\', { bar: true }) }}', ]; yield 'component_with_default_block_content' => [ @@ -69,7 +69,7 @@ public function getLexTests(): iterable yield 'component_with_default_block_that_holds_a_component_and_multi_blocks' => [ 'Foo Other block', - '{% component \'foo\' %}{% block content %}Foo {% component \'bar\' %}{% endcomponent %}{% endblock %}{% block other_block %}Other block{% endblock %}{% endcomponent %}', + '{% component \'foo\' %}{% block content %}Foo {{ component(\'bar\') }}{% endblock %}{% block other_block %}Other block{% endblock %}{% endcomponent %}', ]; yield 'component_with_character_:_on_his_name' => [ '',