Skip to content

Commit

Permalink
Add deferred live components
Browse files Browse the repository at this point in the history
  • Loading branch information
jakubtobiasz authored and weaverryan committed Oct 12, 2023
1 parent 6e6c903 commit d93cc29
Show file tree
Hide file tree
Showing 15 changed files with 257 additions and 13 deletions.
4 changes: 4 additions & 0 deletions src/LiveComponent/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# CHANGELOG

## 2.13.0

- Add deferred rendering of Live Components

## 2.12.0

- Add `onUpdated` hook for `LiveProp`
Expand Down
26 changes: 26 additions & 0 deletions src/LiveComponent/doc/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2175,6 +2175,32 @@ To validate only on "change", use the ``on(change)`` modifier:
class="{{ _errors.has('post.content') ? 'is-invalid' : '' }}"
>

Deferring the Loading
---------------------

Certain components might be heavy to load. You can defer the loading of these components
until after the rest of the page has loaded. To do this, use the ``defer`` attribute:

.. code-block:: twig
{{ component('SomeHeavyComponent', { defer: true }) }}
Doing so will render an empty "placeholder" tag with the live attributes. Once the ``live:connect`` event is triggered,
the component will be rendered asynchronously.

By default the rendered tag is a ``div``. You can change this by specifying the ``loading-tag`` attribute:

.. code-block:: twig
{{ component('SomeHeavyComponent', { defer: true, loading-tag: 'span' }) }}
If you need to signify that the component is loading, use the ``loading-template`` attribute.
This lets you provide a Twig template that will render inside the "placeholder" tag:

.. code-block:: twig
{{ component('SomeHeavyComponent', { defer: true, loading-template: 'spinning-wheel.html.twig' }) }}
Polling
-------

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
use Symfony\UX\LiveComponent\Controller\BatchActionController;
use Symfony\UX\LiveComponent\EventListener\AddLiveAttributesSubscriber;
use Symfony\UX\LiveComponent\EventListener\DataModelPropsSubscriber;
use Symfony\UX\LiveComponent\EventListener\DeferLiveComponentSubscriber;
use Symfony\UX\LiveComponent\EventListener\InterceptChildComponentRenderSubscriber;
use Symfony\UX\LiveComponent\EventListener\LiveComponentSubscriber;
use Symfony\UX\LiveComponent\EventListener\ResetDeterministicIdSubscriber;
Expand Down Expand Up @@ -214,6 +215,14 @@ function (ChildDefinition $definition, AsLiveComponent $attribute) {
->addTag('container.service_subscriber', ['key' => LiveControllerAttributesCreator::class, 'id' => 'ux.live_component.live_controller_attributes_creator'])
;

$container->register('ux.live_component.defer_live_component_subscriber', DeferLiveComponentSubscriber::class)
->setArguments([
new Reference('ux.twig_component.component_stack'),
new Reference('ux.live_component.live_controller_attributes_creator'),
])
->addTag('kernel.event_subscriber')
;

$container->register('ux.live_component.deterministic_id_calculator', DeterministicTwigIdCalculator::class);
$container->register('ux.live_component.fingerprint_calculator', FingerprintCalculator::class)
->setArguments(['%kernel.secret%']);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php

declare(strict_types=1);

namespace Symfony\UX\LiveComponent\EventListener;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\UX\TwigComponent\Event\PostMountEvent;
use Symfony\UX\TwigComponent\Event\PreRenderEvent;

final class DeferLiveComponentSubscriber implements EventSubscriberInterface
{
private const DEFAULT_LOADING_TAG = 'div';

private const DEFAULT_LOADING_TEMPLATE = null;

public function onPostMount(PostMountEvent $event): void
{
$data = $event->getData();
if (\array_key_exists('defer', $data)) {
$event->addExtraMetadata('defer', true);
unset($event->getData()['defer']);
}

if (\array_key_exists('loading-template', $data)) {
$event->addExtraMetadata('loading-template', $data['loading-template']);
unset($event->getData()['loading-template']);
}

if (\array_key_exists('loading-tag', $data)) {
$event->addExtraMetadata('loading-tag', $data['loading-tag']);
unset($event->getData()['loading-tag']);
}

$event->setData($data);
}

public function onPreRender(PreRenderEvent $event): void
{
$mountedComponent = $event->getMountedComponent();

if (!$mountedComponent->hasExtraMetadata('defer')) {
return;
}

$event->setTemplate('@LiveComponent/deferred.html.twig');

$variables = $event->getVariables();
$variables['loadingTemplate'] = self::DEFAULT_LOADING_TEMPLATE;
$variables['loadingTag'] = self::DEFAULT_LOADING_TAG;

if ($mountedComponent->hasExtraMetadata('loading-template')) {
$variables['loadingTemplate'] = $mountedComponent->getExtraMetadata('loading-template');
}

if ($mountedComponent->hasExtraMetadata('loading-tag')) {
$variables['loadingTag'] = $mountedComponent->getExtraMetadata('loading-tag');
}

$event->setVariables($variables);
}

public static function getSubscribedEvents(): array
{
return [
PostMountEvent::class => ['onPostMount'],
PreRenderEvent::class => ['onPreRender'],
];
}
}
7 changes: 7 additions & 0 deletions src/LiveComponent/templates/deferred.html.twig
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<{{ loadingTag }} {{ attributes }} data-action="live:connect->live#$render">
{% block loadingContent %}
{% if loadingTemplate != null %}
{{ include(loadingTemplate) }}
{% endif %}
{% endblock %}
</{{ loadingTag }}>
19 changes: 19 additions & 0 deletions src/LiveComponent/tests/Fixtures/Component/DeferredComponent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

namespace Symfony\UX\LiveComponent\Tests\Fixtures\Component;

use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\DefaultActionTrait;

#[AsLiveComponent('deferred_component')]
final class DeferredComponent
{
use DefaultActionTrait;

public function getLongAwaitedData(): string
{
return 'Long awaited data';
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<div {{ attributes }}>{{ computed.longAwaitedData }}</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
I'm loading a reaaaally slow live component
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<twig:deferred_component defer />
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<twig:deferred_component defer loading-tag='li' />
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<twig:deferred_component defer loading-template='dummy/loading.html.twig' />
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<?php

declare(strict_types=1);

namespace Symfony\UX\LiveComponent\Tests\Functional\EventListener;

use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\UX\LiveComponent\Tests\LiveComponentTestHelper;
use Zenstruck\Browser\Test\HasBrowser;

final class DeferLiveComponentSubscriberTest extends KernelTestCase
{
use HasBrowser;
use LiveComponentTestHelper;

public function testItSetsDeferredTemplateIfLiveIdNotPassed(): void
{
$div = $this->browser()
->visit('/render-template/render_deferred_component')
->assertSuccessful()
->crawler()
->filter('div')
;

$this->assertSame('', trim($div->html()));
$this->assertSame('live:connect->live#$render', $div->attr('data-action'));

$component = $this->mountComponent('deferred_component', [
'data-live-id' => $div->attr('data-live-id'),
]);

$dehydrated = $this->dehydrateComponent($component);

$div = $this->browser()
->visit('/_components/deferred_component?props='.urlencode(json_encode($dehydrated->getProps())))
->assertSuccessful()
->crawler()
->filter('div')
;

$this->assertSame('Long awaited data', $div->html());
}

public function testItIncludesGivenTemplateWhileLoadingDeferredComponent(): void
{
$div = $this->browser()
->visit('/render-template/render_deferred_component_with_template')
->assertSuccessful()
->crawler()
->filter('div')
;

$this->assertSame('I\'m loading a reaaaally slow live component', trim($div->html()));

$component = $this->mountComponent('deferred_component', [
'data-live-id' => $div->attr('data-live-id'),
]);

$dehydrated = $this->dehydrateComponent($component);

$div = $this->browser()
->visit('/_components/deferred_component?props='.urlencode(json_encode($dehydrated->getProps())))
->assertSuccessful()
->crawler()
->filter('div')
;

$this->assertStringContainsString('Long awaited data', $div->html());
}

public function testItAllowsToSetCustomLoadingHtmlTag(): void
{
$crawler = $this->browser()
->visit('/render-template/render_deferred_component_with_li_tag')
->assertSuccessful()
->crawler()
;

$this->assertSame(0, $crawler->filter('div')->count());
$this->assertSame(1, $crawler->filter('li')->count());
}
}
16 changes: 13 additions & 3 deletions src/TwigComponent/src/ComponentFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,9 @@ public function mountFromObject(object $component, array $data, ComponentMetadat
}
}

$data = $this->postMount($component, $data);
$postMount = $this->postMount($component, $data);
$data = $postMount['data'];
$extraMetadata = $postMount['extraMetadata'];

// create attributes from "attributes" key if exists
$attributesVar = $componentMetadata->getAttributesVar();
Expand All @@ -109,7 +111,8 @@ public function mountFromObject(object $component, array $data, ComponentMetadat
$componentMetadata->getName(),
$component,
new ComponentAttributes(array_merge($attributes, $data)),
$originalData
$originalData,
$extraMetadata,
);
}

Expand Down Expand Up @@ -188,11 +191,15 @@ private function preMount(object $component, array $data): array
return $data;
}

/**
* @return array{data: array<string, mixed>, extraMetadata: array<string, mixed>}
*/
private function postMount(object $component, array $data): array
{
$event = new PostMountEvent($component, $data);
$this->eventDispatcher->dispatch($event);
$data = $event->getData();
$extraMetadata = $event->getExtraMetadata();

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

return $data;
return [
'data' => $data,
'extraMetadata' => $extraMetadata,
];
}

private function isAnonymousComponent(string $name): bool
Expand Down
22 changes: 20 additions & 2 deletions src/TwigComponent/src/Event/PostMountEvent.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,11 @@
*/
final class PostMountEvent extends Event
{
public function __construct(private object $component, private array $data)
{
public function __construct(
private object $component,
private array $data,
private array $extraMetadata = [],
) {
}

public function getComponent(): object
Expand All @@ -36,4 +39,19 @@ public function setData(array $data): void
{
$this->data = $data;
}

public function getExtraMetadata(): array
{
return $this->extraMetadata;
}

public function addExtraMetadata(string $key, mixed $value): void
{
$this->extraMetadata[$key] = $value;
}

public function removeExtraMetadata(string $key): void
{
unset($this->extraMetadata[$key]);
}
}
10 changes: 2 additions & 8 deletions src/TwigComponent/src/MountedComponent.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,6 @@
*/
final class MountedComponent
{
/**
* Any extra metadata that might be useful to set.
*
* @var array<string, mixed>
*/
private array $extraMetadata = [];

/**
* @param array|null $inputProps if the component was just originally created,
* (not hydrated from a request), this is the
Expand All @@ -34,7 +27,8 @@ public function __construct(
private string $name,
private object $component,
private ComponentAttributes $attributes,
private ?array $inputProps = []
private ?array $inputProps = [],
private array $extraMetadata = [],
) {
}

Expand Down

0 comments on commit d93cc29

Please sign in to comment.