Skip to content

Commit

Permalink
feature #913 [TwigComponent] [LiveComponent] Add support for embedded…
Browse files Browse the repository at this point in the history
… 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
  • Loading branch information
weaverryan committed Aug 18, 2023
2 parents 8f68fc2 + d9dd3fc commit 71aebf1
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 71aebf1

Please sign in to comment.