Skip to content

[TwigComponent] [LiveComponent] Add support for embedded live components #913

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Aug 18, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@
use Symfony\UX\LiveComponent\Twig\DeterministicTwigIdCalculator;
use Symfony\UX\LiveComponent\Twig\LiveComponentExtension as LiveComponentTwigExtension;
use Symfony\UX\LiveComponent\Twig\LiveComponentRuntime;
use Symfony\UX\LiveComponent\Twig\TemplateCacheWarmer;
use Symfony\UX\LiveComponent\Twig\TemplateMap;
use Symfony\UX\LiveComponent\Util\ChildComponentPartialRenderer;
use Symfony\UX\LiveComponent\Util\FingerprintCalculator;
use Symfony\UX\LiveComponent\Util\LiveControllerAttributesCreator;
Expand All @@ -55,6 +57,8 @@
*/
final class LiveComponentExtension extends Extension implements PrependExtensionInterface
{
public const TEMPLATES_MAP_FILENAME = 'live_components_twig_templates.map';

public function prepend(ContainerBuilder $container)
{
// Register the form theme if TwigBundle is available
Expand Down Expand Up @@ -190,12 +194,14 @@ function (ChildDefinition $definition, AsLiveComponent $attribute) {
new Reference('router'),
new Reference('ux.live_component.live_responder'),
new Reference('security.csrf.token_manager', ContainerInterface::NULL_ON_INVALID_REFERENCE),
new Reference('ux.live_component.twig.template_mapper'),
])
;

$container->register('ux.live_component.add_attributes_subscriber', AddLiveAttributesSubscriber::class)
->setArguments([
new Reference('ux.twig_component.component_stack'),
new Reference('ux.live_component.twig.template_mapper'),
])
->addTag('kernel.event_subscriber')
->addTag('container.service_subscriber', ['key' => LiveControllerAttributesCreator::class, 'id' => 'ux.live_component.live_controller_attributes_creator'])
Expand All @@ -212,6 +218,13 @@ function (ChildDefinition $definition, AsLiveComponent $attribute) {
->addTag('form.type')
->setPublic(false)
;

$container->register('ux.live_component.twig.template_mapper', TemplateMap::class)
->setArguments(['%kernel.cache_dir%/'.self::TEMPLATES_MAP_FILENAME]);

$container->register('ux.live_component.twig.cache_warmer', TemplateCacheWarmer::class)
->setArguments([new Reference('twig.template_iterator'), self::TEMPLATES_MAP_FILENAME])
->addTag('kernel.cache_warmer');
}

private function isAssetMapperAvailable(ContainerBuilder $container): bool
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use Psr\Container\ContainerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Contracts\Service\ServiceSubscriberInterface;
use Symfony\UX\LiveComponent\Twig\TemplateMap;
use Symfony\UX\LiveComponent\Util\LiveControllerAttributesCreator;
use Symfony\UX\TwigComponent\ComponentAttributes;
use Symfony\UX\TwigComponent\ComponentMetadata;
Expand All @@ -36,7 +37,8 @@ final class AddLiveAttributesSubscriber implements EventSubscriberInterface, Ser
{
public function __construct(
private ComponentStack $componentStack,
private ContainerInterface $container
private TemplateMap $templateMap,
private ContainerInterface $container,
) {
}

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

if ($event->isEmbedded()) {
throw new \LogicException('Embedded components cannot be live.');
}

$metadata = $event->getMetadata();
$attributes = $this->getLiveAttributes($event->getMountedComponent(), $metadata);
$variables = $event->getVariables();
Expand All @@ -60,8 +58,21 @@ public function onPreRender(PreRenderEvent $event): void
// onto the variables. So, we manually merge our new attributes in and
// override that variable.
if (isset($variables[$attributesKey]) && $variables[$attributesKey] instanceof ComponentAttributes) {
$originalAttributes = $variables[$attributesKey]->all();

// merge with existing attributes if available
$attributes = $attributes->defaults($variables[$attributesKey]->all());
$attributes = $attributes->defaults($originalAttributes);

if (isset($originalAttributes['data-host-template'], $originalAttributes['data-embedded-template-index'])) {
// This component is an embedded component, that's being re-rendered.
// We'll change the template that will be used to render it to
// the embedded template so that the blocks from that template
// will be used, if any, instead of the originals.
$event->setTemplate(
$this->templateMap->resolve($originalAttributes['data-host-template']),
$originalAttributes['data-embedded-template-index'],
);
}
}

// "key" is a special attribute: don't actually render it
Expand Down
43 changes: 43 additions & 0 deletions src/LiveComponent/src/Twig/TemplateCacheWarmer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\UX\LiveComponent\Twig;

use Symfony\Component\Cache\Adapter\NullAdapter;
use Symfony\Component\Cache\Adapter\PhpArrayAdapter;
use Symfony\Component\HttpKernel\CacheWarmer\CacheWarmerInterface;

/**
* @author Bart Vanderstukken <bart.vanderstukken@gmail.com>
*
* @internal
*/
final class TemplateCacheWarmer implements CacheWarmerInterface
{
public function __construct(private \IteratorAggregate $templateIterator, private readonly string $cacheFilename)
{
}

public function warmUp(string $cacheDir): void
{
$map = [];
foreach ($this->templateIterator as $item) {
$map[bin2hex(random_bytes(16))] = $item;
}

(new PhpArrayAdapter($cacheDir.'/'.$this->cacheFilename, new NullAdapter()))->warmUp(['map' => $map]);
}

public function isOptional(): bool
{
return false;
}
}
45 changes: 45 additions & 0 deletions src/LiveComponent/src/Twig/TemplateMap.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\UX\LiveComponent\Twig;

use Symfony\Component\Cache\Adapter\NullAdapter;
use Symfony\Component\Cache\Adapter\PhpArrayAdapter;

/**
* @author Bart Vanderstukken <bart.vanderstukken@gmail.com>
*
* @internal
*/
final class TemplateMap
{
private readonly array $map;

public function __construct(string $cacheFile)
{
$this->map = (new PhpArrayAdapter($cacheFile, new NullAdapter()))->getItem('map')->get();
}

public function resolve(string $obscuredName)
{
return $this->map[$obscuredName] ?? throw new \RuntimeException(sprintf('Cannot find a template matching "%s". Cache may be corrupt.', $obscuredName));
}

public function obscuredName(string $templateName): string
{
$obscuredName = array_search($templateName, $this->map, true);
if (false === $obscuredName) {
throw new \RuntimeException(sprintf('Cannot find a match for template "%s". Cache may be corrupt.', $templateName));
}

return $obscuredName;
}
}
27 changes: 18 additions & 9 deletions src/LiveComponent/src/Util/LiveControllerAttributesCreator.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
use Symfony\UX\LiveComponent\LiveResponder;
use Symfony\UX\LiveComponent\Metadata\LiveComponentMetadataFactory;
use Symfony\UX\LiveComponent\Twig\DeterministicTwigIdCalculator;
use Symfony\UX\LiveComponent\Twig\TemplateMap;
use Symfony\UX\TwigComponent\ComponentAttributes;
use Symfony\UX\TwigComponent\ComponentMetadata;
use Symfony\UX\TwigComponent\MountedComponent;
Expand Down Expand Up @@ -47,6 +48,7 @@ public function __construct(
private UrlGeneratorInterface $urlGenerator,
private LiveResponder $liveResponder,
private ?CsrfTokenManagerInterface $csrfTokenManager,
private TemplateMap $templateMap,
) {
}

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

$mountedAttributes = $mounted->getAttributes();

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

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

if ($isChildComponent) {
$fingerprint = $this->fingerprintCalculator->calculateFingerprint(
$mounted->getInputProps(),
$this->metadataFactory->getMetadata($mounted->getName())
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
<div{{ attributes }}>
<div{{ attributes.defaults({class: 'component2'}) }} >
{% block content %}
Count: {{ this.count }}
PreReRenderCalled: {{ this.preReRenderCalled ? 'Yes' : 'No' }}
{% endblock %}
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@
{% if includeDataLiveId %}
{% set componentProps = componentProps|merge({'data-live-id': ('todo-item-' ~ loop.index) }) %}
{% endif %}
{{ component('todo_item', componentProps) }}
{% if loop.index is odd %}
{{ component('todo_item', componentProps) }}
{% else %}
{% component 'todo_item' with componentProps %}{% endcomponent %}
{% endif %}
{% endfor %}
</ul>
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@

<ul>
{% for key, item in items %}
{{ component('todo_item', { text: item.text, textLength: item.text|length, key: 'the-key'~key }) }}
{% if loop.index is odd %}
{{ component('todo_item', { text: item.text, textLength: item.text|length, key: 'the-key'~key }) }}
{% else %}
{% component 'todo_item' with { text: item.text, textLength: item.text|length, key: 'the-key'~key } %}{% endcomponent %}
{% endif %}
{% endfor %}
</ul>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{% component component2 %}
{% block content %}
{{ parent() }}
Embedded content with access to context, like count={{ this.count }}
{% endblock %}
{% endcomponent %}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<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>
<div id="component3">Not overriding{% component component2 %}{% endcomponent %}</div>
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
namespace Symfony\UX\LiveComponent\Tests\Functional\EventListener;

use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\UX\LiveComponent\Tests\LiveComponentTestHelper;
use Zenstruck\Browser\Test\HasBrowser;

/**
Expand All @@ -20,11 +21,14 @@
final class AddLiveAttributesSubscriberTest extends KernelTestCase
{
use HasBrowser;
use LiveComponentTestHelper;

/**
* The deterministic id of the "todo_item" components in todo_list.html.twig.
* If that template changes, this will need to be updated.
*/
public const TODO_ITEM_DETERMINISTIC_PREFIX = 'live-289310975-';
public const TODO_ITEM_DETERMINISTIC_PREFIX = 'live-1715058793-';
public const TODO_ITEM_DETERMINISTIC_PREFIX_EMBEDDED = 'live-2285361477-';

public function testInitLiveComponent(): void
{
Expand All @@ -40,10 +44,12 @@ public function testInitLiveComponent(): void
$this->assertSame('live', $div->attr('data-controller'));
$this->assertSame('/_components/component_with_writable_props', $div->attr('data-live-url-value'));
$this->assertNotNull($div->attr('data-live-csrf-value'));
$this->assertCount(3, $props);
$this->assertCount(4, $props);
$this->assertSame(5, $props['max']);
$this->assertSame(1, $props['count']);
$this->assertArrayHasKey('@checksum', $props);
$this->assertArrayHasKey('@attributes', $props);
$this->assertArrayHasKey('data-live-id', $props['@attributes']);
}

public function testCanUseCustomAttributesVariableName(): void
Expand Down Expand Up @@ -79,6 +85,10 @@ public function testCanDisableCsrf(): void

public function testItAddsIdAndFingerprintToChildComponent(): void
{
$templateName = 'components/todo_list.html.twig';
$obscuredName = 'd9bcb8935cbb4282ac5d227fc82ae782';
$this->addTemplateMap($obscuredName, $templateName);

$ul = $this->browser()
->visit('/render-template/render_todo_list')
->assertSuccessful()
Expand All @@ -89,7 +99,7 @@ public function testItAddsIdAndFingerprintToChildComponent(): void
$lis = $ul->children('li');
// deterministic id: should not change, and counter should increase
$this->assertSame(self::TODO_ITEM_DETERMINISTIC_PREFIX.'0', $lis->first()->attr('data-live-id'));
$this->assertSame(self::TODO_ITEM_DETERMINISTIC_PREFIX.'2', $lis->last()->attr('data-live-id'));
$this->assertSame(self::TODO_ITEM_DETERMINISTIC_PREFIX.'1', $lis->last()->attr('data-live-id'));

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

public function testItDoesNotOverrideDataLiveIdIfSpecified(): void
{
$templateName = 'components/todo_list.html.twig';
$obscuredName = 'a643d58357b14c9bb077f0c00a742059';
$this->addTemplateMap($obscuredName, $templateName);

$ul = $this->browser()
->visit('/render-template/render_todo_list_with_live_id')
->assertSuccessful()
Expand Down
Loading