Skip to content

Commit 71aebf1

Browse files
committed
feature #913 [TwigComponent] [LiveComponent] Add support for embedded live components (sneakyvv)
This PR was squashed before being merged into the 2.x branch. Discussion ---------- [TwigComponent] [LiveComponent] Add support for embedded live components | Q | A | ------------- | --- | Bug fix? | no | New feature? | yes | Tickets | | License | MIT ## Context Using embedded components was introduced in #317, but support for Live embedded components was not added. The issue is that on a re-render of a Live component you lose the blocks defined within an embedded component. This PR solves that issue. ## Example To explain the solution, take this example: ```twig {# someTemplate.html.twig #} {% component Foo %} {% block content %} Override content {% endblock %} {% endcomponent %} ``` ```twig {# Foo.html.twig #} <div {{ attributes }}> {% block content %} Default content {% endblock %} </div> ``` Of course, Foo is a Live component. This obviously also works with the new Twig syntax. ## Background 1. Each `{% component %}` tag is compiled by `ComponentNode`. It adds an embedded Template to the Template class compiled for `someTemplate.html.twig`. This is a second class inside the same php file, with a suffix in the form of `___%d`. That number at the end is normally random, and is called the embedded template index. 2. `ComponentNode` would generate Template code which fetches the `embeddedContext` from the `ComponentRenderer` and passed that to the `loadTemplate('Foo.html.twig', $index)->display()` 3. When a component is re-rendered (via an action callback) it uses the template of the component `(Foo.html.twig`), which does not have the original block content, because that's part of the host Template (`someTemplate.html.twig`). ## Solution We only need to use the embedded Template instead of the component Template to re-render a component. To make this happen, we need to: 1. Use a deterministic index for an embedded template during compilation. 2. Store info on the rendered component's HTML (via the attributes) about the host template and the embedded template's index. 3. Load the embedded Template during re-render using the info passed along with the other attributes/props. ## Remaining 1. I use `loadTemplate` now in the `ComponentRender`, which is marked as internal in the Twig package. Can we ignore that within this package (as both are "Symfony")? 2. The `PreRenderEvent::EMBEDDED` constant and the `isEmbedded` function were introduced to block live embedded components. Should this PR remove those as well? ### Tasks - [ ] Remove `isEmbedded`? - [ ] Remove `PreRenderEvent::EMBEDDED`? - [ ] Add CHANGELOG Commits ------- d9dd3fc [TwigComponent] [LiveComponent] Add support for embedded live components
2 parents 8f68fc2 + d9dd3fc commit 71aebf1

23 files changed

+421
-83
lines changed

src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@
3737
use Symfony\UX\LiveComponent\Twig\DeterministicTwigIdCalculator;
3838
use Symfony\UX\LiveComponent\Twig\LiveComponentExtension as LiveComponentTwigExtension;
3939
use Symfony\UX\LiveComponent\Twig\LiveComponentRuntime;
40+
use Symfony\UX\LiveComponent\Twig\TemplateCacheWarmer;
41+
use Symfony\UX\LiveComponent\Twig\TemplateMap;
4042
use Symfony\UX\LiveComponent\Util\ChildComponentPartialRenderer;
4143
use Symfony\UX\LiveComponent\Util\FingerprintCalculator;
4244
use Symfony\UX\LiveComponent\Util\LiveControllerAttributesCreator;
@@ -55,6 +57,8 @@
5557
*/
5658
final class LiveComponentExtension extends Extension implements PrependExtensionInterface
5759
{
60+
public const TEMPLATES_MAP_FILENAME = 'live_components_twig_templates.map';
61+
5862
public function prepend(ContainerBuilder $container)
5963
{
6064
// Register the form theme if TwigBundle is available
@@ -190,12 +194,14 @@ function (ChildDefinition $definition, AsLiveComponent $attribute) {
190194
new Reference('router'),
191195
new Reference('ux.live_component.live_responder'),
192196
new Reference('security.csrf.token_manager', ContainerInterface::NULL_ON_INVALID_REFERENCE),
197+
new Reference('ux.live_component.twig.template_mapper'),
193198
])
194199
;
195200

196201
$container->register('ux.live_component.add_attributes_subscriber', AddLiveAttributesSubscriber::class)
197202
->setArguments([
198203
new Reference('ux.twig_component.component_stack'),
204+
new Reference('ux.live_component.twig.template_mapper'),
199205
])
200206
->addTag('kernel.event_subscriber')
201207
->addTag('container.service_subscriber', ['key' => LiveControllerAttributesCreator::class, 'id' => 'ux.live_component.live_controller_attributes_creator'])
@@ -212,6 +218,13 @@ function (ChildDefinition $definition, AsLiveComponent $attribute) {
212218
->addTag('form.type')
213219
->setPublic(false)
214220
;
221+
222+
$container->register('ux.live_component.twig.template_mapper', TemplateMap::class)
223+
->setArguments(['%kernel.cache_dir%/'.self::TEMPLATES_MAP_FILENAME]);
224+
225+
$container->register('ux.live_component.twig.cache_warmer', TemplateCacheWarmer::class)
226+
->setArguments([new Reference('twig.template_iterator'), self::TEMPLATES_MAP_FILENAME])
227+
->addTag('kernel.cache_warmer');
215228
}
216229

217230
private function isAssetMapperAvailable(ContainerBuilder $container): bool

src/LiveComponent/src/EventListener/AddLiveAttributesSubscriber.php

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use Psr\Container\ContainerInterface;
1515
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
1616
use Symfony\Contracts\Service\ServiceSubscriberInterface;
17+
use Symfony\UX\LiveComponent\Twig\TemplateMap;
1718
use Symfony\UX\LiveComponent\Util\LiveControllerAttributesCreator;
1819
use Symfony\UX\TwigComponent\ComponentAttributes;
1920
use Symfony\UX\TwigComponent\ComponentMetadata;
@@ -36,7 +37,8 @@ final class AddLiveAttributesSubscriber implements EventSubscriberInterface, Ser
3637
{
3738
public function __construct(
3839
private ComponentStack $componentStack,
39-
private ContainerInterface $container
40+
private TemplateMap $templateMap,
41+
private ContainerInterface $container,
4042
) {
4143
}
4244

@@ -47,10 +49,6 @@ public function onPreRender(PreRenderEvent $event): void
4749
return;
4850
}
4951

50-
if ($event->isEmbedded()) {
51-
throw new \LogicException('Embedded components cannot be live.');
52-
}
53-
5452
$metadata = $event->getMetadata();
5553
$attributes = $this->getLiveAttributes($event->getMountedComponent(), $metadata);
5654
$variables = $event->getVariables();
@@ -60,8 +58,21 @@ public function onPreRender(PreRenderEvent $event): void
6058
// onto the variables. So, we manually merge our new attributes in and
6159
// override that variable.
6260
if (isset($variables[$attributesKey]) && $variables[$attributesKey] instanceof ComponentAttributes) {
61+
$originalAttributes = $variables[$attributesKey]->all();
62+
6363
// merge with existing attributes if available
64-
$attributes = $attributes->defaults($variables[$attributesKey]->all());
64+
$attributes = $attributes->defaults($originalAttributes);
65+
66+
if (isset($originalAttributes['data-host-template'], $originalAttributes['data-embedded-template-index'])) {
67+
// This component is an embedded component, that's being re-rendered.
68+
// We'll change the template that will be used to render it to
69+
// the embedded template so that the blocks from that template
70+
// will be used, if any, instead of the originals.
71+
$event->setTemplate(
72+
$this->templateMap->resolve($originalAttributes['data-host-template']),
73+
$originalAttributes['data-embedded-template-index'],
74+
);
75+
}
6576
}
6677

6778
// "key" is a special attribute: don't actually render it
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
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\Twig;
13+
14+
use Symfony\Component\Cache\Adapter\NullAdapter;
15+
use Symfony\Component\Cache\Adapter\PhpArrayAdapter;
16+
use Symfony\Component\HttpKernel\CacheWarmer\CacheWarmerInterface;
17+
18+
/**
19+
* @author Bart Vanderstukken <bart.vanderstukken@gmail.com>
20+
*
21+
* @internal
22+
*/
23+
final class TemplateCacheWarmer implements CacheWarmerInterface
24+
{
25+
public function __construct(private \IteratorAggregate $templateIterator, private readonly string $cacheFilename)
26+
{
27+
}
28+
29+
public function warmUp(string $cacheDir): void
30+
{
31+
$map = [];
32+
foreach ($this->templateIterator as $item) {
33+
$map[bin2hex(random_bytes(16))] = $item;
34+
}
35+
36+
(new PhpArrayAdapter($cacheDir.'/'.$this->cacheFilename, new NullAdapter()))->warmUp(['map' => $map]);
37+
}
38+
39+
public function isOptional(): bool
40+
{
41+
return false;
42+
}
43+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
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\Twig;
13+
14+
use Symfony\Component\Cache\Adapter\NullAdapter;
15+
use Symfony\Component\Cache\Adapter\PhpArrayAdapter;
16+
17+
/**
18+
* @author Bart Vanderstukken <bart.vanderstukken@gmail.com>
19+
*
20+
* @internal
21+
*/
22+
final class TemplateMap
23+
{
24+
private readonly array $map;
25+
26+
public function __construct(string $cacheFile)
27+
{
28+
$this->map = (new PhpArrayAdapter($cacheFile, new NullAdapter()))->getItem('map')->get();
29+
}
30+
31+
public function resolve(string $obscuredName)
32+
{
33+
return $this->map[$obscuredName] ?? throw new \RuntimeException(sprintf('Cannot find a template matching "%s". Cache may be corrupt.', $obscuredName));
34+
}
35+
36+
public function obscuredName(string $templateName): string
37+
{
38+
$obscuredName = array_search($templateName, $this->map, true);
39+
if (false === $obscuredName) {
40+
throw new \RuntimeException(sprintf('Cannot find a match for template "%s". Cache may be corrupt.', $templateName));
41+
}
42+
43+
return $obscuredName;
44+
}
45+
}

src/LiveComponent/src/Util/LiveControllerAttributesCreator.php

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
use Symfony\UX\LiveComponent\LiveResponder;
1919
use Symfony\UX\LiveComponent\Metadata\LiveComponentMetadataFactory;
2020
use Symfony\UX\LiveComponent\Twig\DeterministicTwigIdCalculator;
21+
use Symfony\UX\LiveComponent\Twig\TemplateMap;
2122
use Symfony\UX\TwigComponent\ComponentAttributes;
2223
use Symfony\UX\TwigComponent\ComponentMetadata;
2324
use Symfony\UX\TwigComponent\MountedComponent;
@@ -47,6 +48,7 @@ public function __construct(
4748
private UrlGeneratorInterface $urlGenerator,
4849
private LiveResponder $liveResponder,
4950
private ?CsrfTokenManagerInterface $csrfTokenManager,
51+
private TemplateMap $templateMap,
5052
) {
5153
}
5254

@@ -80,16 +82,23 @@ public function attributesForRendering(MountedComponent $mounted, ComponentMetad
8082

8183
$mountedAttributes = $mounted->getAttributes();
8284

83-
if ($isChildComponent) {
84-
if (!isset($mountedAttributes->all()['data-live-id'])) {
85-
$id = $deterministicId ?: $this->idCalculator
86-
->calculateDeterministicId(key: $mounted->getInputProps()[self::KEY_PROP_NAME] ?? null);
87-
$attributesCollection->setLiveId($id);
88-
// we need to add this to the mounted attributes so that it is
89-
// will be included in the "attributes" part of the props data.
90-
$mountedAttributes = $mountedAttributes->defaults(['data-live-id' => $id]);
91-
}
85+
if ($mounted->hasExtraMetadata('hostTemplate') && $mounted->hasExtraMetadata('embeddedTemplateIndex')) {
86+
$mountedAttributes = $mountedAttributes->defaults([
87+
'data-host-template' => $this->templateMap->obscuredName($mounted->getExtraMetadata('hostTemplate')),
88+
'data-embedded-template-index' => $mounted->getExtraMetadata('embeddedTemplateIndex'),
89+
]);
90+
}
9291

92+
if (!isset($mountedAttributes->all()['data-live-id'])) {
93+
$id = $deterministicId ?: $this->idCalculator
94+
->calculateDeterministicId(key: $mounted->getInputProps()[self::KEY_PROP_NAME] ?? null);
95+
$attributesCollection->setLiveId($id);
96+
// we need to add this to the mounted attributes so that it is
97+
// will be included in the "attributes" part of the props data.
98+
$mountedAttributes = $mountedAttributes->defaults(['data-live-id' => $id]);
99+
}
100+
101+
if ($isChildComponent) {
93102
$fingerprint = $this->fingerprintCalculator->calculateFingerprint(
94103
$mounted->getInputProps(),
95104
$this->metadataFactory->getMetadata($mounted->getName())
Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
<div{{ attributes }}>
1+
<div{{ attributes.defaults({class: 'component2'}) }} >
2+
{% block content %}
23
Count: {{ this.count }}
34
PreReRenderCalled: {{ this.preReRenderCalled ? 'Yes' : 'No' }}
5+
{% endblock %}
46
</div>

src/LiveComponent/tests/Fixtures/templates/components/todo_list.html.twig

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@
77
{% if includeDataLiveId %}
88
{% set componentProps = componentProps|merge({'data-live-id': ('todo-item-' ~ loop.index) }) %}
99
{% endif %}
10-
{{ component('todo_item', componentProps) }}
10+
{% if loop.index is odd %}
11+
{{ component('todo_item', componentProps) }}
12+
{% else %}
13+
{% component 'todo_item' with componentProps %}{% endcomponent %}
14+
{% endif %}
1115
{% endfor %}
1216
</ul>
1317
</div>

src/LiveComponent/tests/Fixtures/templates/components/todo_list_with_keys.html.twig

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@
33

44
<ul>
55
{% for key, item in items %}
6-
{{ component('todo_item', { text: item.text, textLength: item.text|length, key: 'the-key'~key }) }}
6+
{% if loop.index is odd %}
7+
{{ component('todo_item', { text: item.text, textLength: item.text|length, key: 'the-key'~key }) }}
8+
{% else %}
9+
{% component 'todo_item' with { text: item.text, textLength: item.text|length, key: 'the-key'~key } %}{% endcomponent %}
10+
{% endif %}
711
{% endfor %}
812
</ul>
913
</div>
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{% component component2 %}
2+
{% block content %}
3+
{{ parent() }}
4+
Embedded content with access to context, like count={{ this.count }}
5+
{% endblock %}
6+
{% endcomponent %}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
<div id="component1">{% component component2 %}{% block content %}Overridden content from component 1{% endblock %}{% endcomponent %}</div><div id="component2">{% component component2 %}{% block content %}Overridden content from component 2 on same line - count: {{ this.count }}{% endblock %}{% endcomponent %}</div>
2+
<div id="component3">Not overriding{% component component2 %}{% endcomponent %}</div>

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

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
namespace Symfony\UX\LiveComponent\Tests\Functional\EventListener;
1313

1414
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
15+
use Symfony\UX\LiveComponent\Tests\LiveComponentTestHelper;
1516
use Zenstruck\Browser\Test\HasBrowser;
1617

1718
/**
@@ -20,11 +21,14 @@
2021
final class AddLiveAttributesSubscriberTest extends KernelTestCase
2122
{
2223
use HasBrowser;
24+
use LiveComponentTestHelper;
25+
2326
/**
2427
* The deterministic id of the "todo_item" components in todo_list.html.twig.
2528
* If that template changes, this will need to be updated.
2629
*/
27-
public const TODO_ITEM_DETERMINISTIC_PREFIX = 'live-289310975-';
30+
public const TODO_ITEM_DETERMINISTIC_PREFIX = 'live-1715058793-';
31+
public const TODO_ITEM_DETERMINISTIC_PREFIX_EMBEDDED = 'live-2285361477-';
2832

2933
public function testInitLiveComponent(): void
3034
{
@@ -40,10 +44,12 @@ public function testInitLiveComponent(): void
4044
$this->assertSame('live', $div->attr('data-controller'));
4145
$this->assertSame('/_components/component_with_writable_props', $div->attr('data-live-url-value'));
4246
$this->assertNotNull($div->attr('data-live-csrf-value'));
43-
$this->assertCount(3, $props);
47+
$this->assertCount(4, $props);
4448
$this->assertSame(5, $props['max']);
4549
$this->assertSame(1, $props['count']);
4650
$this->assertArrayHasKey('@checksum', $props);
51+
$this->assertArrayHasKey('@attributes', $props);
52+
$this->assertArrayHasKey('data-live-id', $props['@attributes']);
4753
}
4854

4955
public function testCanUseCustomAttributesVariableName(): void
@@ -79,6 +85,10 @@ public function testCanDisableCsrf(): void
7985

8086
public function testItAddsIdAndFingerprintToChildComponent(): void
8187
{
88+
$templateName = 'components/todo_list.html.twig';
89+
$obscuredName = 'd9bcb8935cbb4282ac5d227fc82ae782';
90+
$this->addTemplateMap($obscuredName, $templateName);
91+
8292
$ul = $this->browser()
8393
->visit('/render-template/render_todo_list')
8494
->assertSuccessful()
@@ -89,7 +99,7 @@ public function testItAddsIdAndFingerprintToChildComponent(): void
8999
$lis = $ul->children('li');
90100
// deterministic id: should not change, and counter should increase
91101
$this->assertSame(self::TODO_ITEM_DETERMINISTIC_PREFIX.'0', $lis->first()->attr('data-live-id'));
92-
$this->assertSame(self::TODO_ITEM_DETERMINISTIC_PREFIX.'2', $lis->last()->attr('data-live-id'));
102+
$this->assertSame(self::TODO_ITEM_DETERMINISTIC_PREFIX.'1', $lis->last()->attr('data-live-id'));
93103

94104
// the data-live-id attribute also needs to be part of the "props" so that it persists on renders
95105
$props = json_decode($lis->first()->attr('data-live-props-value'), true);
@@ -107,6 +117,10 @@ public function testItAddsIdAndFingerprintToChildComponent(): void
107117

108118
public function testItDoesNotOverrideDataLiveIdIfSpecified(): void
109119
{
120+
$templateName = 'components/todo_list.html.twig';
121+
$obscuredName = 'a643d58357b14c9bb077f0c00a742059';
122+
$this->addTemplateMap($obscuredName, $templateName);
123+
110124
$ul = $this->browser()
111125
->visit('/render-template/render_todo_list_with_live_id')
112126
->assertSuccessful()

0 commit comments

Comments
 (0)