From d93cc29741fa53ab3a9600fd1e035304b6f7f2c9 Mon Sep 17 00:00:00 2001 From: Jacob Tobiasz Date: Mon, 25 Sep 2023 23:17:23 +0200 Subject: [PATCH] Add deferred live components --- src/LiveComponent/CHANGELOG.md | 4 + src/LiveComponent/doc/index.rst | 26 ++++++ .../LiveComponentExtension.php | 9 ++ .../DeferLiveComponentSubscriber.php | 70 ++++++++++++++++ .../templates/deferred.html.twig | 7 ++ .../Fixtures/Component/DeferredComponent.php | 19 +++++ .../components/deferred_component.html.twig | 1 + .../templates/dummy/loading.html.twig | 1 + .../render_deferred_component.html.twig | 1 + ...r_deferred_component_with_li_tag.html.twig | 1 + ...deferred_component_with_template.html.twig | 1 + .../DeferLiveComponentSubscriberTest.php | 82 +++++++++++++++++++ src/TwigComponent/src/ComponentFactory.php | 16 +++- .../src/Event/PostMountEvent.php | 22 ++++- src/TwigComponent/src/MountedComponent.php | 10 +-- 15 files changed, 257 insertions(+), 13 deletions(-) create mode 100644 src/LiveComponent/src/EventListener/DeferLiveComponentSubscriber.php create mode 100644 src/LiveComponent/templates/deferred.html.twig create mode 100644 src/LiveComponent/tests/Fixtures/Component/DeferredComponent.php create mode 100644 src/LiveComponent/tests/Fixtures/templates/components/deferred_component.html.twig create mode 100644 src/LiveComponent/tests/Fixtures/templates/dummy/loading.html.twig create mode 100644 src/LiveComponent/tests/Fixtures/templates/render_deferred_component.html.twig create mode 100644 src/LiveComponent/tests/Fixtures/templates/render_deferred_component_with_li_tag.html.twig create mode 100644 src/LiveComponent/tests/Fixtures/templates/render_deferred_component_with_template.html.twig create mode 100644 src/LiveComponent/tests/Functional/EventListener/DeferLiveComponentSubscriberTest.php diff --git a/src/LiveComponent/CHANGELOG.md b/src/LiveComponent/CHANGELOG.md index c37f0067de6..25f2ba14e7f 100644 --- a/src/LiveComponent/CHANGELOG.md +++ b/src/LiveComponent/CHANGELOG.md @@ -1,5 +1,9 @@ # CHANGELOG +## 2.13.0 + +- Add deferred rendering of Live Components + ## 2.12.0 - Add `onUpdated` hook for `LiveProp` diff --git a/src/LiveComponent/doc/index.rst b/src/LiveComponent/doc/index.rst index 019ccc8e814..3bbaae0e966 100644 --- a/src/LiveComponent/doc/index.rst +++ b/src/LiveComponent/doc/index.rst @@ -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 ------- diff --git a/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php index 830a7a75d72..2b081a8eafc 100644 --- a/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php +++ b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php @@ -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; @@ -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%']); diff --git a/src/LiveComponent/src/EventListener/DeferLiveComponentSubscriber.php b/src/LiveComponent/src/EventListener/DeferLiveComponentSubscriber.php new file mode 100644 index 00000000000..8136080c4a2 --- /dev/null +++ b/src/LiveComponent/src/EventListener/DeferLiveComponentSubscriber.php @@ -0,0 +1,70 @@ +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'], + ]; + } +} diff --git a/src/LiveComponent/templates/deferred.html.twig b/src/LiveComponent/templates/deferred.html.twig new file mode 100644 index 00000000000..0705be8e4ee --- /dev/null +++ b/src/LiveComponent/templates/deferred.html.twig @@ -0,0 +1,7 @@ +<{{ loadingTag }} {{ attributes }} data-action="live:connect->live#$render"> + {% block loadingContent %} + {% if loadingTemplate != null %} + {{ include(loadingTemplate) }} + {% endif %} + {% endblock %} + diff --git a/src/LiveComponent/tests/Fixtures/Component/DeferredComponent.php b/src/LiveComponent/tests/Fixtures/Component/DeferredComponent.php new file mode 100644 index 00000000000..fae8a65aa8c --- /dev/null +++ b/src/LiveComponent/tests/Fixtures/Component/DeferredComponent.php @@ -0,0 +1,19 @@ +{{ computed.longAwaitedData }} diff --git a/src/LiveComponent/tests/Fixtures/templates/dummy/loading.html.twig b/src/LiveComponent/tests/Fixtures/templates/dummy/loading.html.twig new file mode 100644 index 00000000000..989f40885f8 --- /dev/null +++ b/src/LiveComponent/tests/Fixtures/templates/dummy/loading.html.twig @@ -0,0 +1 @@ +I'm loading a reaaaally slow live component diff --git a/src/LiveComponent/tests/Fixtures/templates/render_deferred_component.html.twig b/src/LiveComponent/tests/Fixtures/templates/render_deferred_component.html.twig new file mode 100644 index 00000000000..7d26d1d0df5 --- /dev/null +++ b/src/LiveComponent/tests/Fixtures/templates/render_deferred_component.html.twig @@ -0,0 +1 @@ + diff --git a/src/LiveComponent/tests/Fixtures/templates/render_deferred_component_with_li_tag.html.twig b/src/LiveComponent/tests/Fixtures/templates/render_deferred_component_with_li_tag.html.twig new file mode 100644 index 00000000000..c557f96b116 --- /dev/null +++ b/src/LiveComponent/tests/Fixtures/templates/render_deferred_component_with_li_tag.html.twig @@ -0,0 +1 @@ + diff --git a/src/LiveComponent/tests/Fixtures/templates/render_deferred_component_with_template.html.twig b/src/LiveComponent/tests/Fixtures/templates/render_deferred_component_with_template.html.twig new file mode 100644 index 00000000000..adb14376af3 --- /dev/null +++ b/src/LiveComponent/tests/Fixtures/templates/render_deferred_component_with_template.html.twig @@ -0,0 +1 @@ + diff --git a/src/LiveComponent/tests/Functional/EventListener/DeferLiveComponentSubscriberTest.php b/src/LiveComponent/tests/Functional/EventListener/DeferLiveComponentSubscriberTest.php new file mode 100644 index 00000000000..55fcc169cda --- /dev/null +++ b/src/LiveComponent/tests/Functional/EventListener/DeferLiveComponentSubscriberTest.php @@ -0,0 +1,82 @@ +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()); + } +} diff --git a/src/TwigComponent/src/ComponentFactory.php b/src/TwigComponent/src/ComponentFactory.php index b75fc32f19f..4e0dcb0d26f 100644 --- a/src/TwigComponent/src/ComponentFactory.php +++ b/src/TwigComponent/src/ComponentFactory.php @@ -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(); @@ -109,7 +111,8 @@ public function mountFromObject(object $component, array $data, ComponentMetadat $componentMetadata->getName(), $component, new ComponentAttributes(array_merge($attributes, $data)), - $originalData + $originalData, + $extraMetadata, ); } @@ -188,11 +191,15 @@ private function preMount(object $component, array $data): array return $data; } + /** + * @return array{data: array, extraMetadata: array} + */ 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); @@ -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 diff --git a/src/TwigComponent/src/Event/PostMountEvent.php b/src/TwigComponent/src/Event/PostMountEvent.php index e1e63161d4f..170e1cd555f 100644 --- a/src/TwigComponent/src/Event/PostMountEvent.php +++ b/src/TwigComponent/src/Event/PostMountEvent.php @@ -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 @@ -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]); + } } diff --git a/src/TwigComponent/src/MountedComponent.php b/src/TwigComponent/src/MountedComponent.php index b56476f0738..4406f9c41bf 100644 --- a/src/TwigComponent/src/MountedComponent.php +++ b/src/TwigComponent/src/MountedComponent.php @@ -18,13 +18,6 @@ */ final class MountedComponent { - /** - * Any extra metadata that might be useful to set. - * - * @var array - */ - private array $extraMetadata = []; - /** * @param array|null $inputProps if the component was just originally created, * (not hydrated from a request), this is the @@ -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 = [], ) { }