Skip to content

Commit d9dd3fc

Browse files
sneakyvvweaverryan
authored andcommitted
[TwigComponent] [LiveComponent] Add support for embedded live components
1 parent f05e5f1 commit d9dd3fc

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)