Skip to content

Commit b0b4cda

Browse files
smnandreweaverryan
authored andcommitted
[LiveComponent] Lazy load LiveComponent
1 parent 6adae60 commit b0b4cda

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)