Skip to content

Commit e26be66

Browse files
committed
feature #1143 Add deferred live components (jakubtobiasz)
This PR was squashed before being merged into the 2.x branch. Discussion ---------- Add deferred live components | Q | A | ------------- | --- | Bug fix? | no | New feature? | yes | Tickets | Fix #994 | License | MIT Initially this feature was called `lazy loading`, but this name could be misleading, as my solution doesn't provide **real** lazy loading. So, I named it `deferred live components`, I guess this name fits better. How to use it? ```twig <twig:MyComponent defer /> ``` In such case we'll get an empty div. Once `live:connect` event called, it'll load the component in the background. We can also define a template to be rendered while loading (e.g. a cool spinner or some text). ```twig <twig:MyComponent defer defer-loading-template="my_cool_spinner.html.twig" /> ``` I'm open for any suggestions 🙌🏼! Commits ------- d93cc29 Add deferred live components
2 parents 614cca9 + d93cc29 commit e26be66

15 files changed

+257
-13
lines changed

src/LiveComponent/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# CHANGELOG
22

3+
## 2.13.0
4+
5+
- Add deferred rendering of Live Components
6+
37
## 2.12.0
48

59
- Add support for (de)hydrating DTO classes in `LiveProp`.

src/LiveComponent/doc/index.rst

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2220,6 +2220,32 @@ To validate only on "change", use the ``on(change)`` modifier:
22202220
class="{{ _errors.has('post.content') ? 'is-invalid' : '' }}"
22212221
>
22222222

2223+
Deferring the Loading
2224+
---------------------
2225+
2226+
Certain components might be heavy to load. You can defer the loading of these components
2227+
until after the rest of the page has loaded. To do this, use the ``defer`` attribute:
2228+
2229+
.. code-block:: twig
2230+
2231+
{{ component('SomeHeavyComponent', { defer: true }) }}
2232+
2233+
Doing so will render an empty "placeholder" tag with the live attributes. Once the ``live:connect`` event is triggered,
2234+
the component will be rendered asynchronously.
2235+
2236+
By default the rendered tag is a ``div``. You can change this by specifying the ``loading-tag`` attribute:
2237+
2238+
.. code-block:: twig
2239+
2240+
{{ component('SomeHeavyComponent', { defer: true, loading-tag: 'span' }) }}
2241+
2242+
If you need to signify that the component is loading, use the ``loading-template`` attribute.
2243+
This lets you provide a Twig template that will render inside the "placeholder" tag:
2244+
2245+
.. code-block:: twig
2246+
2247+
{{ component('SomeHeavyComponent', { defer: true, loading-template: 'spinning-wheel.html.twig' }) }}
2248+
22232249
Polling
22242250
-------
22252251

src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
use Symfony\UX\LiveComponent\Controller\BatchActionController;
2626
use Symfony\UX\LiveComponent\EventListener\AddLiveAttributesSubscriber;
2727
use Symfony\UX\LiveComponent\EventListener\DataModelPropsSubscriber;
28+
use Symfony\UX\LiveComponent\EventListener\DeferLiveComponentSubscriber;
2829
use Symfony\UX\LiveComponent\EventListener\InterceptChildComponentRenderSubscriber;
2930
use Symfony\UX\LiveComponent\EventListener\LiveComponentSubscriber;
3031
use Symfony\UX\LiveComponent\EventListener\ResetDeterministicIdSubscriber;
@@ -215,6 +216,14 @@ function (ChildDefinition $definition, AsLiveComponent $attribute) {
215216
->addTag('container.service_subscriber', ['key' => LiveControllerAttributesCreator::class, 'id' => 'ux.live_component.live_controller_attributes_creator'])
216217
;
217218

219+
$container->register('ux.live_component.defer_live_component_subscriber', DeferLiveComponentSubscriber::class)
220+
->setArguments([
221+
new Reference('ux.twig_component.component_stack'),
222+
new Reference('ux.live_component.live_controller_attributes_creator'),
223+
])
224+
->addTag('kernel.event_subscriber')
225+
;
226+
218227
$container->register('ux.live_component.deterministic_id_calculator', DeterministicTwigIdCalculator::class);
219228
$container->register('ux.live_component.fingerprint_calculator', FingerprintCalculator::class)
220229
->setArguments(['%kernel.secret%']);
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Symfony\UX\LiveComponent\EventListener;
6+
7+
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
8+
use Symfony\UX\TwigComponent\Event\PostMountEvent;
9+
use Symfony\UX\TwigComponent\Event\PreRenderEvent;
10+
11+
final class DeferLiveComponentSubscriber implements EventSubscriberInterface
12+
{
13+
private const DEFAULT_LOADING_TAG = 'div';
14+
15+
private const DEFAULT_LOADING_TEMPLATE = null;
16+
17+
public function onPostMount(PostMountEvent $event): void
18+
{
19+
$data = $event->getData();
20+
if (\array_key_exists('defer', $data)) {
21+
$event->addExtraMetadata('defer', true);
22+
unset($event->getData()['defer']);
23+
}
24+
25+
if (\array_key_exists('loading-template', $data)) {
26+
$event->addExtraMetadata('loading-template', $data['loading-template']);
27+
unset($event->getData()['loading-template']);
28+
}
29+
30+
if (\array_key_exists('loading-tag', $data)) {
31+
$event->addExtraMetadata('loading-tag', $data['loading-tag']);
32+
unset($event->getData()['loading-tag']);
33+
}
34+
35+
$event->setData($data);
36+
}
37+
38+
public function onPreRender(PreRenderEvent $event): void
39+
{
40+
$mountedComponent = $event->getMountedComponent();
41+
42+
if (!$mountedComponent->hasExtraMetadata('defer')) {
43+
return;
44+
}
45+
46+
$event->setTemplate('@LiveComponent/deferred.html.twig');
47+
48+
$variables = $event->getVariables();
49+
$variables['loadingTemplate'] = self::DEFAULT_LOADING_TEMPLATE;
50+
$variables['loadingTag'] = self::DEFAULT_LOADING_TAG;
51+
52+
if ($mountedComponent->hasExtraMetadata('loading-template')) {
53+
$variables['loadingTemplate'] = $mountedComponent->getExtraMetadata('loading-template');
54+
}
55+
56+
if ($mountedComponent->hasExtraMetadata('loading-tag')) {
57+
$variables['loadingTag'] = $mountedComponent->getExtraMetadata('loading-tag');
58+
}
59+
60+
$event->setVariables($variables);
61+
}
62+
63+
public static function getSubscribedEvents(): array
64+
{
65+
return [
66+
PostMountEvent::class => ['onPostMount'],
67+
PreRenderEvent::class => ['onPreRender'],
68+
];
69+
}
70+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<{{ loadingTag }} {{ attributes }} data-action="live:connect->live#$render">
2+
{% block loadingContent %}
3+
{% if loadingTemplate != null %}
4+
{{ include(loadingTemplate) }}
5+
{% endif %}
6+
{% endblock %}
7+
</{{ loadingTag }}>
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Symfony\UX\LiveComponent\Tests\Fixtures\Component;
6+
7+
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
8+
use Symfony\UX\LiveComponent\DefaultActionTrait;
9+
10+
#[AsLiveComponent('deferred_component')]
11+
final class DeferredComponent
12+
{
13+
use DefaultActionTrait;
14+
15+
public function getLongAwaitedData(): string
16+
{
17+
return 'Long awaited data';
18+
}
19+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<div {{ attributes }}>{{ computed.longAwaitedData }}</div>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
I'm loading a reaaaally slow live component
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<twig:deferred_component defer />
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<twig:deferred_component defer loading-tag='li' />
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<twig:deferred_component defer loading-template='dummy/loading.html.twig' />
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Symfony\UX\LiveComponent\Tests\Functional\EventListener;
6+
7+
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
8+
use Symfony\UX\LiveComponent\Tests\LiveComponentTestHelper;
9+
use Zenstruck\Browser\Test\HasBrowser;
10+
11+
final class DeferLiveComponentSubscriberTest extends KernelTestCase
12+
{
13+
use HasBrowser;
14+
use LiveComponentTestHelper;
15+
16+
public function testItSetsDeferredTemplateIfLiveIdNotPassed(): void
17+
{
18+
$div = $this->browser()
19+
->visit('/render-template/render_deferred_component')
20+
->assertSuccessful()
21+
->crawler()
22+
->filter('div')
23+
;
24+
25+
$this->assertSame('', trim($div->html()));
26+
$this->assertSame('live:connect->live#$render', $div->attr('data-action'));
27+
28+
$component = $this->mountComponent('deferred_component', [
29+
'data-live-id' => $div->attr('data-live-id'),
30+
]);
31+
32+
$dehydrated = $this->dehydrateComponent($component);
33+
34+
$div = $this->browser()
35+
->visit('/_components/deferred_component?props='.urlencode(json_encode($dehydrated->getProps())))
36+
->assertSuccessful()
37+
->crawler()
38+
->filter('div')
39+
;
40+
41+
$this->assertSame('Long awaited data', $div->html());
42+
}
43+
44+
public function testItIncludesGivenTemplateWhileLoadingDeferredComponent(): void
45+
{
46+
$div = $this->browser()
47+
->visit('/render-template/render_deferred_component_with_template')
48+
->assertSuccessful()
49+
->crawler()
50+
->filter('div')
51+
;
52+
53+
$this->assertSame('I\'m loading a reaaaally slow live component', trim($div->html()));
54+
55+
$component = $this->mountComponent('deferred_component', [
56+
'data-live-id' => $div->attr('data-live-id'),
57+
]);
58+
59+
$dehydrated = $this->dehydrateComponent($component);
60+
61+
$div = $this->browser()
62+
->visit('/_components/deferred_component?props='.urlencode(json_encode($dehydrated->getProps())))
63+
->assertSuccessful()
64+
->crawler()
65+
->filter('div')
66+
;
67+
68+
$this->assertStringContainsString('Long awaited data', $div->html());
69+
}
70+
71+
public function testItAllowsToSetCustomLoadingHtmlTag(): void
72+
{
73+
$crawler = $this->browser()
74+
->visit('/render-template/render_deferred_component_with_li_tag')
75+
->assertSuccessful()
76+
->crawler()
77+
;
78+
79+
$this->assertSame(0, $crawler->filter('div')->count());
80+
$this->assertSame(1, $crawler->filter('li')->count());
81+
}
82+
}

src/TwigComponent/src/ComponentFactory.php

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,9 @@ public function mountFromObject(object $component, array $data, ComponentMetadat
8888
}
8989
}
9090

91-
$data = $this->postMount($component, $data);
91+
$postMount = $this->postMount($component, $data);
92+
$data = $postMount['data'];
93+
$extraMetadata = $postMount['extraMetadata'];
9294

9395
// create attributes from "attributes" key if exists
9496
$attributesVar = $componentMetadata->getAttributesVar();
@@ -109,7 +111,8 @@ public function mountFromObject(object $component, array $data, ComponentMetadat
109111
$componentMetadata->getName(),
110112
$component,
111113
new ComponentAttributes(array_merge($attributes, $data)),
112-
$originalData
114+
$originalData,
115+
$extraMetadata,
113116
);
114117
}
115118

@@ -188,11 +191,15 @@ private function preMount(object $component, array $data): array
188191
return $data;
189192
}
190193

194+
/**
195+
* @return array{data: array<string, mixed>, extraMetadata: array<string, mixed>}
196+
*/
191197
private function postMount(object $component, array $data): array
192198
{
193199
$event = new PostMountEvent($component, $data);
194200
$this->eventDispatcher->dispatch($event);
195201
$data = $event->getData();
202+
$extraMetadata = $event->getExtraMetadata();
196203

197204
foreach (AsTwigComponent::postMountMethods($component) as $method) {
198205
$newData = $component->{$method->name}($data);
@@ -202,7 +209,10 @@ private function postMount(object $component, array $data): array
202209
}
203210
}
204211

205-
return $data;
212+
return [
213+
'data' => $data,
214+
'extraMetadata' => $extraMetadata,
215+
];
206216
}
207217

208218
private function isAnonymousComponent(string $name): bool

src/TwigComponent/src/Event/PostMountEvent.php

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,11 @@
1818
*/
1919
final class PostMountEvent extends Event
2020
{
21-
public function __construct(private object $component, private array $data)
22-
{
21+
public function __construct(
22+
private object $component,
23+
private array $data,
24+
private array $extraMetadata = [],
25+
) {
2326
}
2427

2528
public function getComponent(): object
@@ -36,4 +39,19 @@ public function setData(array $data): void
3639
{
3740
$this->data = $data;
3841
}
42+
43+
public function getExtraMetadata(): array
44+
{
45+
return $this->extraMetadata;
46+
}
47+
48+
public function addExtraMetadata(string $key, mixed $value): void
49+
{
50+
$this->extraMetadata[$key] = $value;
51+
}
52+
53+
public function removeExtraMetadata(string $key): void
54+
{
55+
unset($this->extraMetadata[$key]);
56+
}
3957
}

src/TwigComponent/src/MountedComponent.php

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,6 @@
1818
*/
1919
final class MountedComponent
2020
{
21-
/**
22-
* Any extra metadata that might be useful to set.
23-
*
24-
* @var array<string, mixed>
25-
*/
26-
private array $extraMetadata = [];
27-
2821
/**
2922
* @param array|null $inputProps if the component was just originally created,
3023
* (not hydrated from a request), this is the
@@ -34,7 +27,8 @@ public function __construct(
3427
private string $name,
3528
private object $component,
3629
private ComponentAttributes $attributes,
37-
private ?array $inputProps = []
30+
private ?array $inputProps = [],
31+
private array $extraMetadata = [],
3832
) {
3933
}
4034

0 commit comments

Comments
 (0)