Skip to content

Commit

Permalink
[TwigComponent] [LiveComponent] Add support for embedded live components
Browse files Browse the repository at this point in the history
  • Loading branch information
sneakyvv authored and weaverryan committed Aug 18, 2023
1 parent f05e5f1 commit d9dd3fc
Show file tree
Hide file tree
Showing 23 changed files with 421 additions and 83 deletions.
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 <[email protected]>
*
* 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 <[email protected]>
*
* @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 <[email protected]>
*
* 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 <[email protected]>
*
* @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

0 comments on commit d9dd3fc

Please sign in to comment.