Skip to content

Commit 2dcfc13

Browse files
committed
feature #1515 [LiveComponent] Lazy load LiveComponent (smnandre)
This PR was squashed before being merged into the 2.x branch. Discussion ---------- [LiveComponent] Lazy load LiveComponent # UPDATED COMMENT This PR add loading support for LiveComponent * `loading="defer"` to load the component on page load * `loading="lazy"` to load the component when the component is visible ```twig {# HTML Syntax #} <twig:HeavyComponent loading="lazy" /> {# Twig Syntax #} {{ component('HeavyComponent, { loading: "lazy" }) }} ``` The `defer`attribute is deprecated for this solution. See more details in the document. --- # ORIGINAL PR: I have some live component stuff in progress, so let's post this one now to start the discussion. This PR implements a _volontarely-minimal_ `loading="lazy"` mode on LiveComponents, based on top of the `defer` one. ## ⚠️ Live Component This feature is dedicated to Live Component (as there is some particularities due to keeping "state"). (if you need some lazy loading with classic twig components, there are already methods out there to handle such things) ## Defer ? Lazy ? Both defer and lazy attributes allow to ... defer the full rendering of the component. So when the page first renders, it contains only a skeletton / empty component. Then, depending of the attribute used, the component will fetch its content via a fetch request: * `defer`: when the page loads (it react to the "connect" Stimulus) * `lazy`: when the component enters the viewport (so as soon it's at least 1px **visible** in it) I precise "**visible**" because if you use some tabbed content, horizontal sliders, etc... the "intersect" is not triggered until the component is actually.... visible. ## Usage ```twig <twig:Acme foo="bar" lazy /> ``` Note: `lazy` implies `defer`, so this is equivalent to ```twig <twig:Acme foo="bar" defer lazy /> ``` ## So.. defer or lazy ? As i see it: * `defer` should be used for any content that will 100% be visible in the main content, but too heavy to be computed with the original request (think Facebook/Insta first results of the timeline) * `Lazy` should be used for anything really not in the viewport, and that you're not sure will be rendered to the user (comments, tabbed content, ...) In both situation, this is not a good idea to have too many lazy live components in the page, it's probably a sign that you should use Turbo instead! (i'll add some doc about defer/lazy and layout shift too, but the idea is: better "keep the space you'll need") ## Rendering ### IntersectionObserver The loading is triggered with a dedicated intersection observer. It happens only once and the the element in unregistered. No settings allowed (as images, iframes, etc.. in the browser) TODO: optimize threesholds. ### Loading=lazy I chose to use the `loading="lazy"` attribute tag on the rendered HTML, as the goal is to have the same behaviour on the browser side. ```html <div loading="lazy" data-.... class="foo" > ``` It is standard for image, iframes, maybe portals soon, and i think it's self-explanatory. Example: https://web.dev/articles/browser-level-image-lazy-loading For browsers and for seo , this seems to be a good way to hint what this div is (i'll see aria stuff later as it touches many things on the codebase) ## DX ### Boolean attribute values This is how we document the defer feature ```twig {# expression can be a bool, a var, .... #} {{ component('acme', {foo: "bar, defer: expression}) }} ``` For better DX, i add a small bool cast for both lazy and defer attributes values. It allows to have some dynamism on the calling side with HTML syntax too. ```twig {# Defer: true #} <twig:Acme foo="bar" defer /> <twig:Acme foo="bar" defer="defer" /> <twig:Acme foo="bar" defer="true" /> <twig:Acme foo="bar" defer="{{ 8 < 42 }}" /> <twig:Acme foo="bar" :defer="true" /> {# Also defer: true ! #} <twig:Acme foo="bar" defer="false" /> {# Defer: false #} <twig:Acme foo="bar" defer="" /> <twig:Acme foo="bar" defer="{{ 8 > 42 }}" /> <twig:Acme foo="bar" :defer="false" /> ``` (Tests / doc incoming, demo(s) in progress) -- IN PROGRESS -- Commits ------- b0b4cda [LiveComponent] Lazy load LiveComponent
2 parents 6adae60 + b0b4cda commit 2dcfc13

22 files changed

+712
-516
lines changed

UPGRADE.md

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

3+
## FROM 2.16 to 2.17
4+
5+
- **Live Components**: Change `defer` attribute to `loading="defer"` #1515.
6+
37
## FROM 2.15 to 2.16
48

59
- **Live Components**: Change `data-action-name` attribute to `data-live-action-param`

src/LiveComponent/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44

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

812
## 2.16.0
913

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { PluginInterface } from './PluginInterface';
2+
import Component from '../index';
3+
export default class implements PluginInterface {
4+
private intersectionObserver;
5+
attachToComponent(component: Component): void;
6+
private getObserver;
7+
}

src/LiveComponent/assets/dist/live_controller.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2914,6 +2914,38 @@ class ChildComponentPlugin {
29142914
}
29152915
}
29162916

2917+
class LazyPlugin {
2918+
constructor() {
2919+
this.intersectionObserver = null;
2920+
}
2921+
attachToComponent(component) {
2922+
var _a;
2923+
if ('lazy' !== ((_a = component.element.attributes.getNamedItem('loading')) === null || _a === void 0 ? void 0 : _a.value)) {
2924+
return;
2925+
}
2926+
component.on('connect', () => {
2927+
this.getObserver().observe(component.element);
2928+
});
2929+
component.on('disconnect', () => {
2930+
var _a;
2931+
(_a = this.intersectionObserver) === null || _a === void 0 ? void 0 : _a.unobserve(component.element);
2932+
});
2933+
}
2934+
getObserver() {
2935+
if (!this.intersectionObserver) {
2936+
this.intersectionObserver = new IntersectionObserver((entries, observer) => {
2937+
entries.forEach(entry => {
2938+
if (entry.isIntersecting) {
2939+
entry.target.dispatchEvent(new CustomEvent('live:appear'));
2940+
observer.unobserve(entry.target);
2941+
}
2942+
});
2943+
});
2944+
}
2945+
return this.intersectionObserver;
2946+
}
2947+
}
2948+
29172949
class LiveControllerDefault extends Controller {
29182950
constructor() {
29192951
super(...arguments);
@@ -3063,6 +3095,7 @@ class LiveControllerDefault extends Controller {
30633095
}
30643096
const plugins = [
30653097
new LoadingPlugin(),
3098+
new LazyPlugin(),
30663099
new ValidatedFieldsPlugin(),
30673100
new PageUnloadingPlugin(),
30683101
new PollingPlugin(),
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import {PluginInterface} from './PluginInterface';
2+
import Component from '../index';
3+
4+
export default class implements PluginInterface {
5+
private intersectionObserver: IntersectionObserver | null = null;
6+
7+
attachToComponent(component: Component): void {
8+
if ('lazy' !== component.element.attributes.getNamedItem('loading')?.value) {
9+
return;
10+
}
11+
component.on('connect', () => {
12+
this.getObserver().observe(component.element);
13+
});
14+
component.on('disconnect', () => {
15+
this.intersectionObserver?.unobserve(component.element);
16+
});
17+
}
18+
19+
private getObserver(): IntersectionObserver {
20+
if (!this.intersectionObserver) {
21+
this.intersectionObserver = new IntersectionObserver((entries, observer) => {
22+
entries.forEach(entry => {
23+
if (entry.isIntersecting) {
24+
entry.target.dispatchEvent(new CustomEvent('live:appear'));
25+
observer.unobserve(entry.target);
26+
}
27+
});
28+
});
29+
}
30+
31+
return this.intersectionObserver;
32+
}
33+
}

src/LiveComponent/assets/src/live_controller.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import getModelBinding from './Directive/get_model_binding';
1414
import QueryStringPlugin from './Component/plugins/QueryStringPlugin';
1515
import ChildComponentPlugin from './Component/plugins/ChildComponentPlugin';
1616
import getElementAsTagText from './Util/getElementAsTagText';
17+
import LazyPlugin from './Component/plugins/LazyPlugin';
1718

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

296297
const plugins: PluginInterface[] = [
297298
new LoadingPlugin(),
299+
new LazyPlugin(),
298300
new ValidatedFieldsPlugin(),
299301
new PageUnloadingPlugin(),
300302
new PollingPlugin(),

src/LiveComponent/assets/test/dom_utils.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import {
99
import ValueStore from '../src/Component/ValueStore';
1010
import Component from '../src/Component';
1111
import Backend from '../src/Backend/Backend';
12-
import {StimulusElementDriver} from '../src/Component/ElementDriver';
1312
import { noopElementDriver } from './tools';
1413

1514
const createStore = function(props: any = {}): ValueStore {

src/LiveComponent/assets/test/tools.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -487,7 +487,7 @@ export class noopElementDriver implements ElementDriver {
487487
throw new Error('Method not implemented.');
488488
}
489489

490-
getModelName(element: HTMLElement): string | null {
490+
getModelName(): string | null {
491491
throw new Error('Method not implemented.');
492492
}
493493
}

src/LiveComponent/doc/index.rst

Lines changed: 68 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2258,37 +2258,88 @@ To validate only on "change", use the ``on(change)`` modifier:
22582258
Deferring / Lazy Loading Components
22592259
-----------------------------------
22602260

2261+
When a page loads, all components are rendered immediately. If a component is
2262+
heavy to render, you can defer its rendering until after the page has loaded.
2263+
This is done by making an Ajax call to load the component's real content either
2264+
as soon as the page loads (``defer``) or when the component becomes visible
2265+
(``lazy``).
2266+
2267+
.. note::
2268+
2269+
Behind the scenes, your component *is* created & mounted during the initial
2270+
page load, but its template isn't rendered. So keep your heavy work to
2271+
methods in your component (e.g. ``getProducts()``) that are only called
2272+
from the component's template.
2273+
2274+
Loading "defer" (Ajax on Load)
2275+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
2276+
22612277
.. versionadded:: 2.13.0
22622278

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

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

22682284
.. code-block:: html+twig
22692285

2270-
{# With the HTML syntax #}
2271-
<twig:SomeHeavyComponent defer />
2272-
22732286
{# With the component function #}
2274-
{{ component('SomeHeavyComponent', { defer: true }) }}
2287+
<twig:SomeHeavyComponent loading="defer" />
2288+
2289+
.. code-block:: twig
2290+
2291+
{# With the HTML syntax #}
2292+
{{ component('SomeHeavyComponent', { loading: 'defer' }) }}
22752293
22762294
This renders an empty ``<div>`` tag, but triggers an Ajax call to render the
22772295
real component once the page has loaded.
22782296

2279-
.. note::
2297+
Loading "lazy" (Ajax when Visible)
2298+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
22802299

2281-
Behind the scenes, your component *is* created & mounted during the initial
2282-
page load, but it isn't rendered. So keep your heavy work to methods in
2283-
your component (e.g. ``getProducts()``) that are only called when rendering.
2300+
.. versionadded:: 2.17.0
2301+
2302+
The ability to load a component "lazily" was added in Live Components 2.17.
2303+
2304+
The ``lazy`` option is similar to ``defer``, but it defers the loading of
2305+
the component until it's in the viewport. This is useful for components that
2306+
are far down the page and are not needed until the user scrolls to them.
2307+
2308+
To use this, set a ``loading="lazy"`` attribute to your component:
2309+
2310+
.. code-block:: html+twig
2311+
2312+
{# With the HTML syntax #}
2313+
<twig:Acme foo="bar" loading="lazy" />
2314+
2315+
.. code-block:: twig
2316+
2317+
{# With the Twig syntax #}
2318+
{{ component('SomeHeavyComponent', { loading: 'lazy' }) }}
2319+
2320+
This renders an empty ``<div>`` tag. The real component is only rendered when
2321+
it appears in the viewport.
2322+
2323+
Defer or Lazy?
2324+
~~~~~~~~~~~~~~
2325+
2326+
The ``defer`` and ``lazy`` options may seem similar, but they serve different
2327+
purposes:
2328+
* ``defer`` is useful for components that are heavy to render but are required
2329+
when the page loads.
2330+
* ``lazy`` is useful for components that are not needed until the user scrolls
2331+
to them (and may even never be rendered).
2332+
2333+
Loading content
2334+
~~~~~~~~~~~~~~~
22842335

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

2289-
.. versionadded:: 2.17.0
2340+
.. versionadded:: 2.16.0
22902341

2291-
Defining a placeholder macro into the component template was added in Live Components 2.17.0.
2342+
Defining a placeholder macro into the component template was added in Live Components 2.16.0.
22922343

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

23192370
{# In the calling template #}
2320-
<twig:RecommendedProducts size="3" defer />
2371+
<twig:RecommendedProducts size="3" loading="defer" />
23212372

23222373
.. code-block:: html+twig
23232374

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

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

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

23442395
Or override the ``loadingContent`` block:
23452396

23462397
.. code-block:: html+twig
23472398

23482399
{# With the HTML syntax #}
2349-
<twig:SomeHeavyComponent defer>
2400+
<twig:SomeHeavyComponent loading="defer">
23502401
<twig:block name="loadingContent">Custom Loading Content...</twig:block>
23512402
</twig:SomeHeavyComponent>
23522403

23532404
{# With the component tag #}
2354-
{% component SomeHeavyComponent with { defer: true } %}
2405+
{% component SomeHeavyComponent with { loading: 'defer' } %}
23552406
{% block loadingContent %}Loading...{% endblock %}
23562407
{% endcomponent %}
23572408

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

23632414
.. code-block:: twig
23642415
2365-
{{ component('SomeHeavyComponent', { defer: true, loading-tag: 'span' }) }}
2416+
{{ component('SomeHeavyComponent', { loading: 'defer', loading-tag: 'span' }) }}
23662417
23672418
Polling
23682419
-------

src/LiveComponent/src/EventListener/DeferLiveComponentSubscriber.php

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,29 @@ final class DeferLiveComponentSubscriber implements EventSubscriberInterface
3131
public function onPostMount(PostMountEvent $event): void
3232
{
3333
$data = $event->getData();
34+
3435
if (\array_key_exists('defer', $data)) {
35-
$event->addExtraMetadata('defer', true);
36+
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".');
37+
if ($data['defer']) {
38+
$event->addExtraMetadata('loading', 'defer');
39+
}
3640
unset($data['defer']);
3741
}
3842

43+
if (\array_key_exists('loading', $data)) {
44+
// Ignored values: false / null / ''
45+
if ($loading = $data['loading']) {
46+
if (!\is_scalar($loading)) {
47+
throw new \InvalidArgumentException(sprintf('The "loading" attribute value must be scalar, "%s" passed.', get_debug_type($loading)));
48+
}
49+
if (!\in_array($loading, ['defer', 'lazy'], true)) {
50+
throw new \InvalidArgumentException(sprintf('Invalid "loading" attribute value "%s". Accepted values: "defer" and "lazy".', $loading));
51+
}
52+
$event->addExtraMetadata('loading', $loading);
53+
}
54+
unset($data['loading']);
55+
}
56+
3957
if (\array_key_exists('loading-template', $data)) {
4058
$event->addExtraMetadata('loading-template', $data['loading-template']);
4159
unset($data['loading-template']);
@@ -53,7 +71,10 @@ public function onPreRender(PreRenderEvent $event): void
5371
{
5472
$mountedComponent = $event->getMountedComponent();
5573

56-
if (!$mountedComponent->hasExtraMetadata('defer')) {
74+
if (!$mountedComponent->hasExtraMetadata('loading')) {
75+
return;
76+
}
77+
if (!\in_array($mountedComponent->getExtraMetadata('loading'), ['defer', 'lazy'], true)) {
5778
return;
5879
}
5980

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

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

src/LiveComponent/templates/deferred.html.twig

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
<{{ loadingTag }} {{ attributes }} data-action="live:connect->live#$render">
1+
<{{ loadingTag }} {{ attributes }}
2+
{% if 'lazy' == loading %}
3+
data-action="live:appear->live#$render" loading="lazy"
4+
{% else %}
5+
data-action="live:connect->live#$render"
6+
{% endif %}
7+
>
28
{% block loadingContent %}
39
{% if loadingTemplate != null %}
410
{{ include(loadingTemplate) }}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
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\Attribute\LiveAction;
9+
use Symfony\UX\LiveComponent\Attribute\LiveProp;
10+
use Symfony\UX\LiveComponent\DefaultActionTrait;
11+
12+
#[AsLiveComponent('tally_component')]
13+
final class TallyComponent
14+
{
15+
use DefaultActionTrait;
16+
17+
#[LiveProp]
18+
public int $count = 0;
19+
20+
#[LiveAction]
21+
public function click(): void
22+
{
23+
$this->count++;
24+
}
25+
26+
#[LiveAction]
27+
public function reset(): void
28+
{
29+
$this->count = 0;
30+
}
31+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<div {{ attributes }}>
2+
3+
<data id="count" value="{{ count }}">{{ count }}</data>
4+
5+
<button id="click" type="button" data-action="live#action" data-action-name="click">Click</button>
6+
7+
<button id="reset" type="button" data-action="live#action" data-action-name="reset">Reset</button>
8+
9+
</div>
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
<twig:deferred_component defer />
1+
<twig:deferred_component loading="defer" />
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
<twig:deferred_component defer loading-tag='li' />
1+
<twig:deferred_component loading="defer" loading-tag="li" />

0 commit comments

Comments
 (0)