Skip to content

Commit 19f28e3

Browse files
committed
Enable embedded live components
1 parent 94a1d30 commit 19f28e3

File tree

12 files changed

+161
-50
lines changed

12 files changed

+161
-50
lines changed

src/LiveComponent/src/EventListener/AddLiveAttributesSubscriber.php

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,6 @@ public function onPreRender(PreRenderEvent $event): void
4747
return;
4848
}
4949

50-
if ($event->isEmbedded()) {
51-
throw new \LogicException('Embedded components cannot be live.');
52-
}
53-
5450
$metadata = $event->getMetadata();
5551
$attributes = $this->getLiveAttributes($event->getMountedComponent(), $metadata);
5652
$variables = $event->getVariables();
@@ -60,8 +56,21 @@ public function onPreRender(PreRenderEvent $event): void
6056
// onto the variables. So, we manually merge our new attributes in and
6157
// override that variable.
6258
if (isset($variables[$attributesKey]) && $variables[$attributesKey] instanceof ComponentAttributes) {
59+
$originalAttributes = $variables[$attributesKey]->all();
60+
6361
// merge with existing attributes if available
64-
$attributes = $attributes->defaults($variables[$attributesKey]->all());
62+
$attributes = $attributes->defaults($originalAttributes);
63+
64+
if (isset($originalAttributes['data-host-template'], $originalAttributes['data-embedded-template-index'])) {
65+
// This component is an embedded component, that's being re-rendered.
66+
// We'll change the template that will be used to render it to
67+
// the embedded template so that the blocks from that template
68+
// will be used, if any, instead of the originals.
69+
$event->setTemplate(
70+
$originalAttributes['data-host-template'],
71+
$originalAttributes['data-embedded-template-index'],
72+
);
73+
}
6574
}
6675

6776
// "key" is a special attribute: don't actually render it

src/LiveComponent/src/Util/LiveControllerAttributesCreator.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,13 @@ public function attributesForRendering(MountedComponent $mounted, ComponentMetad
8080

8181
$mountedAttributes = $mounted->getAttributes();
8282

83+
if ($mounted->hasExtraMetadata('hostTemplate') && $mounted->hasExtraMetadata('embeddedTemplateIndex')) {
84+
$mountedAttributes = $mountedAttributes->defaults([
85+
'data-host-template' => $mounted->getExtraMetadata('hostTemplate'),
86+
'data-embedded-template-index' => $mounted->getExtraMetadata('embeddedTemplateIndex'),
87+
]);
88+
}
89+
8390
if ($isChildComponent) {
8491
if (!isset($mountedAttributes->all()['data-live-id'])) {
8592
$id = $deterministicId ?: $this->idCalculator
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>
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/LiveComponentSubscriberTest.php

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,17 @@ final class LiveComponentSubscriberTest extends KernelTestCase
3232
use LiveComponentTestHelper;
3333
use ResetDatabase;
3434

35+
/**
36+
* The deterministic id of the "component2" component in render_embedded_with_blocks.html.twig.
37+
* If that template changes, this will need to be updated.
38+
*/
39+
public const DETERMINISTIC_ID = 21098427781;
40+
/**
41+
* The deterministic id of the "component2" component in render_multiple_embedded_with_blocks.html.twig.
42+
* If that template changes, this will need to be updated.
43+
*/
44+
public const DETERMINISTIC_ID_MULTI_2 = 30904230242;
45+
3546
public function testCanRenderComponentAsHtml(): void
3647
{
3748
$component = $this->mountComponent('component1', [
@@ -70,7 +81,15 @@ public function testCanRenderComponentAsHtmlWithAlternateRoute(): void
7081

7182
public function testCanExecuteComponentActionNormalRoute(): void
7283
{
73-
$dehydrated = $this->dehydrateComponent($this->mountComponent('component2'));
84+
$dehydrated = $this->dehydrateComponent(
85+
$this->mountComponent(
86+
'component2',
87+
[
88+
'data-host-template' => 'render_embedded_with_blocks.html.twig',
89+
'data-embedded-template-index' => self::DETERMINISTIC_ID,
90+
]
91+
)
92+
);
7493
$token = null;
7594

7695
$this->browser()
@@ -90,6 +109,7 @@ public function testCanExecuteComponentActionNormalRoute(): void
90109
->assertSuccessful()
91110
->assertHeaderContains('Content-Type', 'html')
92111
->assertContains('Count: 2')
112+
->assertSee('Embedded content with access to context, like count=2')
93113
;
94114
}
95115

@@ -204,6 +224,67 @@ public function testPreReRenderHookOnlyExecutedDuringAjax(): void
204224
;
205225
}
206226

227+
public function testItAddsEmbeddedTemplateContextToEmbeddedComponents(): void
228+
{
229+
$dehydrated = $this->dehydrateComponent(
230+
$this->mountComponent(
231+
'component2',
232+
[
233+
'data-host-template' => 'render_embedded_with_blocks.html.twig',
234+
'data-embedded-template-index' => self::DETERMINISTIC_ID,
235+
]
236+
)
237+
);
238+
239+
$this->browser()
240+
->visit('/render-template/render_embedded_with_blocks')
241+
->assertSuccessful()
242+
->assertSee('PreReRenderCalled: No')
243+
->assertSee('Embedded content with access to context, like count=1')
244+
->assertSeeElement('.component2')
245+
->assertElementAttributeContains('.component2', 'data-live-props-value', '"data-host-template":"render_embedded_with_blocks.html.twig"')
246+
->assertElementAttributeContains('.component2', 'data-live-props-value', '"data-embedded-template-index":'.self::DETERMINISTIC_ID)
247+
->get('/_components/component2?props='.urlencode(json_encode($dehydrated->getProps())))
248+
->assertSuccessful()
249+
->assertSee('PreReRenderCalled: Yes')
250+
->assertSee('Embedded content with access to context, like count=1')
251+
;
252+
}
253+
254+
public function testItUseBlocksFromEmbeddedContextUsingMultipleComponents(): void
255+
{
256+
$dehydrated = $this->dehydrateComponent(
257+
$this->mountComponent(
258+
'component2',
259+
[
260+
'data-host-template' => 'render_multiple_embedded_with_blocks.html.twig',
261+
'data-embedded-template-index' => self::DETERMINISTIC_ID_MULTI_2,
262+
]
263+
)
264+
);
265+
266+
$token = null;
267+
268+
$this->browser()
269+
->visit('/render-template/render_multiple_embedded_with_blocks')
270+
->assertSuccessful()
271+
->assertSeeIn('#component1', 'Overridden content from component 1')
272+
->assertSeeIn('#component2', 'Overridden content from component 2 on same line - count: 1')
273+
->assertSeeIn('#component3', 'PreReRenderCalled: No')
274+
->use(function (Crawler $crawler) use (&$token) {
275+
// get a valid token to use for actions
276+
$token = $crawler->filter('div')->eq(1)->attr('data-live-csrf-value');
277+
})
278+
->post('/_components/component2/increase', [
279+
'headers' => ['X-CSRF-TOKEN' => $token],
280+
'body' => json_encode(['props' => $dehydrated->getProps()]),
281+
])
282+
->assertSuccessful()
283+
->assertHeaderContains('Content-Type', 'html')
284+
->assertSee('Overridden content from component 2 on same line - count: 2')
285+
;
286+
}
287+
207288
public function testCanRedirectFromComponentAction(): void
208289
{
209290
$dehydrated = $this->dehydrateComponent($this->mountComponent('component2'));

src/LiveComponent/tests/Integration/EventListener/AddLiveAttributesSubscriberTest.php

Lines changed: 0 additions & 28 deletions
This file was deleted.

src/TwigComponent/src/ComponentRenderer.php

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,11 @@ public function render(MountedComponent $mounted): string
5858
$event = $this->preRender($mounted);
5959

6060
try {
61-
return $this->twig->render($event->getTemplate(), $event->getVariables());
61+
return $this->twig->loadTemplate(
62+
$this->twig->getTemplateClass($event->getTemplate()),
63+
$event->getTemplate(),
64+
$event->getTemplateIndex(),
65+
)->render($event->getVariables());
6266
} finally {
6367
$this->componentStack->pop();
6468

@@ -67,11 +71,15 @@ public function render(MountedComponent $mounted): string
6771
}
6872
}
6973

70-
public function embeddedContext(string $name, array $props, array $context): array
74+
public function embeddedContext(string $name, array $props, array $context, string $hostTemplateName, int $index): array
7175
{
7276
$context[PreRenderEvent::EMBEDDED] = true;
7377

74-
return $this->preRender($this->factory->create($name, $props), $context)->getVariables();
78+
$mounted = $this->factory->create($name, $props);
79+
$mounted->addExtraMetadata('hostTemplate', $hostTemplateName);
80+
$mounted->addExtraMetadata('embeddedTemplateIndex', $index);
81+
82+
return $this->preRender($mounted, $context)->getVariables();
7583
}
7684

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

src/TwigComponent/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/TwigComponent/src/Twig/ComponentExtension.php

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

64-
public function embeddedContext(string $name, array $props, array $context): array
64+
public function embeddedContext(string $name, array $props, array $context, string $hostTemplateName, int $index): array
6565
{
6666
try {
67-
return $this->container->get(ComponentRenderer::class)->embeddedContext($name, $props, $context);
67+
return $this->container->get(ComponentRenderer::class)->embeddedContext($name, $props, $context, $hostTemplateName, $index);
6868
} catch (\Throwable $e) {
6969
$this->throwRuntimeError($name, $e);
7070
}

src/TwigComponent/src/Twig/ComponentNode.php

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

3737
$compiler
38-
->raw('$props = $this->extensions[')
38+
->write('$embeddedContext = $this->extensions[')
3939
->string(ComponentExtension::class)
4040
->raw(']->embeddedContext(')
4141
->string($this->getAttribute('component'))
42-
->raw(', ')
43-
->raw('twig_to_array(')
42+
->raw(', twig_to_array(')
4443
->subcompile($this->getNode('variables'))
4544
->raw('), ')
4645
->raw($this->getAttribute('only') ? '[]' : '$context')
46+
->raw(', ')
47+
->string($this->getAttribute('name'))
48+
->raw(', ')
49+
->raw($this->getAttribute('index'))
4750
->raw(");\n")
4851
;
4952

5053
$this->addGetTemplate($compiler);
51-
52-
$compiler->raw('->display($props);');
54+
$compiler->write('->display($embeddedContext);');
5355
$compiler->raw("\n");
5456
}
5557
}

src/TwigComponent/src/Twig/ComponentTokenParser.php

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

34+
private array $lineAndFileCounts = [];
35+
3436
/**
3537
* @param callable():ComponentFactory $factory
3638
*/
@@ -72,6 +74,9 @@ public function parse(Token $token): Node
7274

7375
$this->parser->embedTemplate($module);
7476

77+
// use deterministic index for the embedded template, so it can be loaded in a controlled manner
78+
$module->setAttribute('index', $this->generateEmbeddedTemplateIndex($stream->getSourceContext()->getName(), $token->getLine()));
79+
7580
$stream->expect(Token::BLOCK_END_TYPE);
7681

7782
return new ComponentNode($componentName, $module->getTemplateName(), $module->getAttribute('index'), $variables, $only, $token->getLine(), $this->getTag());
@@ -124,4 +129,14 @@ private function parseArguments(): array
124129

125130
return [$variables, $only];
126131
}
132+
133+
private function generateEmbeddedTemplateIndex(string $file, int $line): int
134+
{
135+
$fileAndLine = sprintf('%s-%d', $file, $line);
136+
if (!isset($this->lineAndFileCounts[$fileAndLine])) {
137+
$this->lineAndFileCounts[$fileAndLine] = 0;
138+
}
139+
140+
return crc32($fileAndLine).++$this->lineAndFileCounts[$fileAndLine];
141+
}
127142
}

0 commit comments

Comments
 (0)