Skip to content

Commit 017006c

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 symfony/ux#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 ------- d9dd3fc4 [TwigComponent] [LiveComponent] Add support for embedded live components
2 parents 38d8590 + 0446191 commit 017006c

File tree

5 files changed

+110
-20
lines changed

5 files changed

+110
-20
lines changed

src/ComponentRenderer.php

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
namespace Symfony\UX\TwigComponent;
1313

14+
use Composer\InstalledVersions;
1415
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
1516
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
1617
use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate;
@@ -38,14 +39,21 @@ public function __construct(
3839
) {
3940
}
4041

41-
public function createAndRender(string $name, array $props = []): string
42+
/**
43+
* Allow the render process to be short-circuited.
44+
*/
45+
public function preCreateForRender(string $name, array $props = []): ?string
4246
{
4347
$event = new PreCreateForRenderEvent($name, $props);
4448
$this->dispatcher->dispatch($event);
4549

46-
// allow the process to be short-circuited
47-
if (null !== $rendered = $event->getRenderedString()) {
48-
return $rendered;
50+
return $event->getRenderedString();
51+
}
52+
53+
public function createAndRender(string $name, array $props = []): string
54+
{
55+
if ($preRendered = $this->preCreateForRender($name, $props)) {
56+
return $preRendered;
4957
}
5058

5159
return $this->render($this->factory->create($name, $props));
@@ -58,7 +66,15 @@ public function render(MountedComponent $mounted): string
5866
$event = $this->preRender($mounted);
5967

6068
try {
61-
return $this->twig->render($event->getTemplate(), $event->getVariables());
69+
if (InstalledVersions::getVersion('twig/twig') < 3) {
70+
return $this->twig->loadTemplate($event->getTemplate(), $event->getTemplateIndex())->render($event->getVariables());
71+
}
72+
73+
return $this->twig->loadTemplate(
74+
$this->twig->getTemplateClass($event->getTemplate()),
75+
$event->getTemplate(),
76+
$event->getTemplateIndex(),
77+
)->render($event->getVariables());
6278
} finally {
6379
$this->componentStack->pop();
6480

@@ -67,17 +83,27 @@ public function render(MountedComponent $mounted): string
6783
}
6884
}
6985

70-
public function embeddedContext(string $name, array $props, array $context): array
86+
public function embeddedContext(string $name, array $props, array $context, string $hostTemplateName, int $index): array
7187
{
7288
$context[PreRenderEvent::EMBEDDED] = true;
7389

74-
$embeddedContext = $this->preRender($this->factory->create($name, $props), $context)->getVariables();
90+
$mounted = $this->factory->create($name, $props);
91+
$mounted->addExtraMetadata('hostTemplate', $hostTemplateName);
92+
$mounted->addExtraMetadata('embeddedTemplateIndex', $index);
7593

76-
if (!isset($embeddedContext['outerBlocks'])) {
77-
$embeddedContext['outerBlocks'] = new BlockStack();
78-
}
94+
$this->componentStack->push($mounted);
95+
96+
try {
97+
$embeddedContext = $this->preRender($mounted, $context)->getVariables();
98+
99+
if (!isset($embeddedContext['outerBlocks'])) {
100+
$embeddedContext['outerBlocks'] = new BlockStack();
101+
}
79102

80-
return $embeddedContext;
103+
return $embeddedContext;
104+
} finally {
105+
$this->componentStack->pop();
106+
}
81107
}
82108

83109
private function preRender(MountedComponent $mounted, array $context = []): PreRenderEvent

src/Event/PreRenderEvent.php

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ final class PreRenderEvent extends Event
2525

2626
private string $template;
2727

28+
private ?int $templateIndex = null;
29+
2830
/**
2931
* @internal
3032
*/
@@ -52,17 +54,22 @@ public function getTemplate(): string
5254
/**
5355
* Change the twig template used.
5456
*/
55-
public function setTemplate(string $template): self
57+
public function setTemplate(string $template, int $index = null): self
5658
{
57-
if ($this->isEmbedded()) {
58-
throw new \LogicException('Cannot modify template for embedded components.');
59-
}
60-
6159
$this->template = $template;
60+
$this->templateIndex = $index;
6261

6362
return $this;
6463
}
6564

65+
/**
66+
* @return string The twig template index used for the component, in case it's an embedded template
67+
*/
68+
public function getTemplateIndex(): ?int
69+
{
70+
return $this->templateIndex;
71+
}
72+
6673
public function getComponent(): object
6774
{
6875
return $this->mounted->getComponent();

src/Twig/ComponentExtension.php

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,10 +62,19 @@ public function render(string $name, array $props = []): string
6262
}
6363
}
6464

65-
public function embeddedContext(string $name, array $props, array $context): array
65+
public function preRender(string $name, array $props): ?string
6666
{
6767
try {
68-
return $this->container->get(ComponentRenderer::class)->embeddedContext($name, $props, $context);
68+
return $this->container->get(ComponentRenderer::class)->preCreateForRender($name, $props);
69+
} catch (\Throwable $e) {
70+
$this->throwRuntimeError($name, $e);
71+
}
72+
}
73+
74+
public function embeddedContext(string $name, array $props, array $context, string $hostTemplateName, int $index): array
75+
{
76+
try {
77+
return $this->container->get(ComponentRenderer::class)->embeddedContext($name, $props, $context, $hostTemplateName, $index);
6978
} catch (\Throwable $e) {
7079
$this->throwRuntimeError($name, $e);
7180
}

src/Twig/ComponentNode.php

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,15 +35,42 @@ public function compile(Compiler $compiler): void
3535
$compiler->addDebugInfo($this);
3636

3737
$compiler
38-
->write('$embeddedContext = $this->extensions[')
38+
->write('$preRendered = $this->extensions[')
3939
->string(ComponentExtension::class)
40-
->raw(']->embeddedContext(')
40+
->raw(']->preRender(')
4141
->string($this->getAttribute('component'))
4242
->raw(', ')
4343
->raw('twig_to_array(')
4444
->subcompile($this->getNode('variables'))
45+
->raw(')')
46+
->raw(");\n")
47+
;
48+
49+
$compiler
50+
->write('if (null !== $preRendered) {')
51+
->raw("\n")
52+
->indent()
53+
->write('echo $preRendered;')
54+
->raw("\n")
55+
->outdent()
56+
->write('} else {')
57+
->raw("\n")
58+
->indent()
59+
;
60+
61+
$compiler
62+
->write('$embeddedContext = $this->extensions[')
63+
->string(ComponentExtension::class)
64+
->raw(']->embeddedContext(')
65+
->string($this->getAttribute('component'))
66+
->raw(', twig_to_array(')
67+
->subcompile($this->getNode('variables'))
4568
->raw('), ')
4669
->raw($this->getAttribute('only') ? '[]' : '$context')
70+
->raw(', ')
71+
->string($this->getAttribute('name'))
72+
->raw(', ')
73+
->raw($this->getAttribute('index'))
4774
->raw(");\n")
4875
;
4976

@@ -57,5 +84,11 @@ public function compile(Compiler $compiler): void
5784
$this->addGetTemplate($compiler);
5885
$compiler->raw('->display($embeddedContext, $embeddedBlocks);');
5986
$compiler->raw("\n");
87+
88+
$compiler
89+
->outdent()
90+
->write('}')
91+
->raw("\n")
92+
;
6093
}
6194
}

src/Twig/ComponentTokenParser.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ final class ComponentTokenParser extends AbstractTokenParser
3232
/** @var ComponentFactory|callable():ComponentFactory */
3333
private $factory;
3434

35+
private array $lineAndFileCounts = [];
36+
3537
/**
3638
* @param callable():ComponentFactory $factory
3739
*/
@@ -84,6 +86,9 @@ public function parse(Token $token): Node
8486

8587
$this->parser->embedTemplate($module);
8688

89+
// use deterministic index for the embedded template, so it can be loaded in a controlled manner
90+
$module->setAttribute('index', $this->generateEmbeddedTemplateIndex($stream->getSourceContext()->getName(), $token->getLine()));
91+
8792
$stream->expect(Token::BLOCK_END_TYPE);
8893

8994
return new ComponentNode($componentName, $module->getTemplateName(), $module->getAttribute('index'), $variables, $only, $token->getLine(), $this->getTag());
@@ -136,4 +141,14 @@ private function parseArguments(): array
136141

137142
return [$variables, $only];
138143
}
144+
145+
private function generateEmbeddedTemplateIndex(string $file, int $line): int
146+
{
147+
$fileAndLine = sprintf('%s-%d', $file, $line);
148+
if (!isset($this->lineAndFileCounts[$fileAndLine])) {
149+
$this->lineAndFileCounts[$fileAndLine] = 0;
150+
}
151+
152+
return crc32($fileAndLine).++$this->lineAndFileCounts[$fileAndLine];
153+
}
139154
}

0 commit comments

Comments
 (0)