Skip to content

[LiveComponent] Lazy load LiveComponent #1515

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Mar 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions UPGRADE.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# UPGRADE

## FROM 2.16 to 2.17

- **Live Components**: Change `defer` attribute to `loading="defer"` #1515.

## FROM 2.15 to 2.16

- **Live Components**: Change `data-action-name` attribute to `data-live-action-param`
Expand Down
4 changes: 4 additions & 0 deletions src/LiveComponent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@

- Add `modifier` option in `LiveProp` so options can be modified at runtime.
- Fix collections hydration with serializer in LiveComponents
- Add `loading` attribute to defer the rendering on the component after the
page is rendered, either when the page loads (`loading="defer"`) or when
the component becomes visible in the viewport (`loading="lazy"`).
- Deprecate the `defer` attribute.

## 2.16.0

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { PluginInterface } from './PluginInterface';
import Component from '../index';
export default class implements PluginInterface {
private intersectionObserver;
attachToComponent(component: Component): void;
private getObserver;
}
33 changes: 33 additions & 0 deletions src/LiveComponent/assets/dist/live_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -2914,6 +2914,38 @@ class ChildComponentPlugin {
}
}

class LazyPlugin {
constructor() {
this.intersectionObserver = null;
}
attachToComponent(component) {
var _a;
if ('lazy' !== ((_a = component.element.attributes.getNamedItem('loading')) === null || _a === void 0 ? void 0 : _a.value)) {
return;
}
component.on('connect', () => {
this.getObserver().observe(component.element);
});
component.on('disconnect', () => {
var _a;
(_a = this.intersectionObserver) === null || _a === void 0 ? void 0 : _a.unobserve(component.element);
});
}
getObserver() {
if (!this.intersectionObserver) {
this.intersectionObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.dispatchEvent(new CustomEvent('live:appear'));
observer.unobserve(entry.target);
}
});
});
}
return this.intersectionObserver;
}
}

class LiveControllerDefault extends Controller {
constructor() {
super(...arguments);
Expand Down Expand Up @@ -3063,6 +3095,7 @@ class LiveControllerDefault extends Controller {
}
const plugins = [
new LoadingPlugin(),
new LazyPlugin(),
new ValidatedFieldsPlugin(),
new PageUnloadingPlugin(),
new PollingPlugin(),
Expand Down
33 changes: 33 additions & 0 deletions src/LiveComponent/assets/src/Component/plugins/LazyPlugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import {PluginInterface} from './PluginInterface';
import Component from '../index';

export default class implements PluginInterface {
private intersectionObserver: IntersectionObserver | null = null;

attachToComponent(component: Component): void {
if ('lazy' !== component.element.attributes.getNamedItem('loading')?.value) {
return;
}
component.on('connect', () => {
this.getObserver().observe(component.element);
});
component.on('disconnect', () => {
this.intersectionObserver?.unobserve(component.element);
});
}

private getObserver(): IntersectionObserver {
if (!this.intersectionObserver) {
this.intersectionObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.dispatchEvent(new CustomEvent('live:appear'));
observer.unobserve(entry.target);
}
});
});
}

return this.intersectionObserver;
}
}
2 changes: 2 additions & 0 deletions src/LiveComponent/assets/src/live_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import getModelBinding from './Directive/get_model_binding';
import QueryStringPlugin from './Component/plugins/QueryStringPlugin';
import ChildComponentPlugin from './Component/plugins/ChildComponentPlugin';
import getElementAsTagText from './Util/getElementAsTagText';
import LazyPlugin from './Component/plugins/LazyPlugin';

export { Component };
export { getComponent } from './ComponentRegistry';
Expand Down Expand Up @@ -295,6 +296,7 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple

const plugins: PluginInterface[] = [
new LoadingPlugin(),
new LazyPlugin(),
new ValidatedFieldsPlugin(),
new PageUnloadingPlugin(),
new PollingPlugin(),
Expand Down
1 change: 0 additions & 1 deletion src/LiveComponent/assets/test/dom_utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import {
import ValueStore from '../src/Component/ValueStore';
import Component from '../src/Component';
import Backend from '../src/Backend/Backend';
import {StimulusElementDriver} from '../src/Component/ElementDriver';
import { noopElementDriver } from './tools';

const createStore = function(props: any = {}): ValueStore {
Expand Down
2 changes: 1 addition & 1 deletion src/LiveComponent/assets/test/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -487,7 +487,7 @@ export class noopElementDriver implements ElementDriver {
throw new Error('Method not implemented.');
}

getModelName(element: HTMLElement): string | null {
getModelName(): string | null {
throw new Error('Method not implemented.');
}
}
85 changes: 68 additions & 17 deletions src/LiveComponent/doc/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2258,37 +2258,88 @@ To validate only on "change", use the ``on(change)`` modifier:
Deferring / Lazy Loading Components
-----------------------------------

When a page loads, all components are rendered immediately. If a component is
heavy to render, you can defer its rendering until after the page has loaded.
This is done by making an Ajax call to load the component's real content either
as soon as the page loads (``defer``) or when the component becomes visible
(``lazy``).

.. note::

Behind the scenes, your component *is* created & mounted during the initial
page load, but its template isn't rendered. So keep your heavy work to
methods in your component (e.g. ``getProducts()``) that are only called
from the component's template.

Loading "defer" (Ajax on Load)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

.. versionadded:: 2.13.0

The ability to defer loading a component was added in Live Components 2.13.

If a component is heavy to render, you can defer rendering it until after
the page has loaded. To do this, add the ``defer`` option:
the page has loaded. To do this, add a ``loading="defer"`` attribute:

.. code-block:: html+twig

{# With the HTML syntax #}
<twig:SomeHeavyComponent defer />

{# With the component function #}
{{ component('SomeHeavyComponent', { defer: true }) }}
<twig:SomeHeavyComponent loading="defer" />

.. code-block:: twig

{# With the HTML syntax #}
{{ component('SomeHeavyComponent', { loading: 'defer' }) }}

This renders an empty ``<div>`` tag, but triggers an Ajax call to render the
real component once the page has loaded.

.. note::
Loading "lazy" (Ajax when Visible)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Behind the scenes, your component *is* created & mounted during the initial
page load, but it isn't rendered. So keep your heavy work to methods in
your component (e.g. ``getProducts()``) that are only called when rendering.
.. versionadded:: 2.17.0

The ability to load a component "lazily" was added in Live Components 2.17.

The ``lazy`` option is similar to ``defer``, but it defers the loading of
the component until it's in the viewport. This is useful for components that
are far down the page and are not needed until the user scrolls to them.

To use this, set a ``loading="lazy"`` attribute to your component:

.. code-block:: html+twig

{# With the HTML syntax #}
<twig:Acme foo="bar" loading="lazy" />

.. code-block:: twig

{# With the Twig syntax #}
{{ component('SomeHeavyComponent', { loading: 'lazy' }) }}

This renders an empty ``<div>`` tag. The real component is only rendered when
it appears in the viewport.

Defer or Lazy?
~~~~~~~~~~~~~~

The ``defer`` and ``lazy`` options may seem similar, but they serve different
purposes:
* ``defer`` is useful for components that are heavy to render but are required
when the page loads.
* ``lazy`` is useful for components that are not needed until the user scrolls
to them (and may even never be rendered).

Loading content
~~~~~~~~~~~~~~~

You can define some content to be rendered while the component is loading, either
inside the component template (the ``placeholder`` macro) or from the calling template
(the ``loading-template`` attribute and the ``loadingContent`` block).

.. versionadded:: 2.17.0
.. versionadded:: 2.16.0

Defining a placeholder macro into the component template was added in Live Components 2.17.0.
Defining a placeholder macro into the component template was added in Live Components 2.16.0.

In the component template, define a ``placeholder`` macro, outside of the
component's main content. This macro will be called when the component is deferred:
Expand Down Expand Up @@ -2317,7 +2368,7 @@ number of rows:
.. code-block:: html+twig

{# In the calling template #}
<twig:RecommendedProducts size="3" defer />
<twig:RecommendedProducts size="3" loading="defer" />

.. code-block:: html+twig

Expand All @@ -2336,22 +2387,22 @@ the ``loading-template`` option to point to a template:
.. code-block:: html+twig

{# With the HTML syntax #}
<twig:SomeHeavyComponent defer loading-template="spinning-wheel.html.twig" />
<twig:SomeHeavyComponent loading="defer" loading-template="spinning-wheel.html.twig" />

{# With the component function #}
{{ component('SomeHeavyComponent', { defer: true, loading-template: 'spinning-wheel.html.twig' }) }}
{{ component('SomeHeavyComponent', { loading: 'defer', loading-template: 'spinning-wheel.html.twig' }) }}

Or override the ``loadingContent`` block:

.. code-block:: html+twig

{# With the HTML syntax #}
<twig:SomeHeavyComponent defer>
<twig:SomeHeavyComponent loading="defer">
<twig:block name="loadingContent">Custom Loading Content...</twig:block>
</twig:SomeHeavyComponent>

{# With the component tag #}
{% component SomeHeavyComponent with { defer: true } %}
{% component SomeHeavyComponent with { loading: 'defer' } %}
{% block loadingContent %}Loading...{% endblock %}
{% endcomponent %}

Expand All @@ -2362,7 +2413,7 @@ To change the initial tag from a ``div`` to something else, use the ``loading-ta

.. code-block:: twig

{{ component('SomeHeavyComponent', { defer: true, loading-tag: 'span' }) }}
{{ component('SomeHeavyComponent', { loading: 'defer', loading-tag: 'span' }) }}

Polling
-------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,29 @@ final class DeferLiveComponentSubscriber implements EventSubscriberInterface
public function onPostMount(PostMountEvent $event): void
{
$data = $event->getData();

if (\array_key_exists('defer', $data)) {
$event->addExtraMetadata('defer', true);
trigger_deprecation('symfony/ux-live-component', '2.17', 'The "defer" attribute is deprecated and will be removed in 3.0. Use the "loading" attribute instead set to the value "defer".');
if ($data['defer']) {
$event->addExtraMetadata('loading', 'defer');
}
unset($data['defer']);
}

if (\array_key_exists('loading', $data)) {
// Ignored values: false / null / ''
if ($loading = $data['loading']) {
if (!\is_scalar($loading)) {
throw new \InvalidArgumentException(sprintf('The "loading" attribute value must be scalar, "%s" passed.', get_debug_type($loading)));
}
if (!\in_array($loading, ['defer', 'lazy'], true)) {
throw new \InvalidArgumentException(sprintf('Invalid "loading" attribute value "%s". Accepted values: "defer" and "lazy".', $loading));
}
$event->addExtraMetadata('loading', $loading);
}
unset($data['loading']);
}

if (\array_key_exists('loading-template', $data)) {
$event->addExtraMetadata('loading-template', $data['loading-template']);
unset($data['loading-template']);
Expand All @@ -53,7 +71,10 @@ public function onPreRender(PreRenderEvent $event): void
{
$mountedComponent = $event->getMountedComponent();

if (!$mountedComponent->hasExtraMetadata('defer')) {
if (!$mountedComponent->hasExtraMetadata('loading')) {
return;
}
if (!\in_array($mountedComponent->getExtraMetadata('loading'), ['defer', 'lazy'], true)) {
return;
}

Expand All @@ -63,6 +84,7 @@ public function onPreRender(PreRenderEvent $event): void
$variables = $event->getVariables();
$variables['loadingTemplate'] = self::DEFAULT_LOADING_TEMPLATE;
$variables['loadingTag'] = self::DEFAULT_LOADING_TAG;
$variables['loading'] = $mountedComponent->getExtraMetadata('loading');

if ($mountedComponent->hasExtraMetadata('loading-template')) {
$variables['loadingTemplate'] = $mountedComponent->getExtraMetadata('loading-template');
Expand Down
8 changes: 7 additions & 1 deletion src/LiveComponent/templates/deferred.html.twig
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
<{{ loadingTag }} {{ attributes }} data-action="live:connect->live#$render">
<{{ loadingTag }} {{ attributes }}
{% if 'lazy' == loading %}
data-action="live:appear->live#$render" loading="lazy"
{% else %}
data-action="live:connect->live#$render"
{% endif %}
>
{% block loadingContent %}
{% if loadingTemplate != null %}
{{ include(loadingTemplate) }}
Expand Down
31 changes: 31 additions & 0 deletions src/LiveComponent/tests/Fixtures/Component/TallyComponent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

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

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

#[AsLiveComponent('tally_component')]
final class TallyComponent
{
use DefaultActionTrait;

#[LiveProp]
public int $count = 0;

#[LiveAction]
public function click(): void
{
$this->count++;
}

#[LiveAction]
public function reset(): void
{
$this->count = 0;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<div {{ attributes }}>

<data id="count" value="{{ count }}">{{ count }}</data>

<button id="click" type="button" data-action="live#action" data-action-name="click">Click</button>

<button id="reset" type="button" data-action="live#action" data-action-name="reset">Reset</button>

</div>
Original file line number Diff line number Diff line change
@@ -1 +1 @@
<twig:deferred_component defer />
<twig:deferred_component loading="defer" />
Original file line number Diff line number Diff line change
@@ -1 +1 @@
<twig:deferred_component defer loading-tag='li' />
<twig:deferred_component loading="defer" loading-tag="li" />
Loading