[IMP] website, web: add loading effect on buttons during lazy-loading

Odoo implements its own lazy-loading mechanism for the biggest JS
bundles that are loaded on the frontend. This mechanism allows the page
to appear to the user very fast, at the downside of having some
interactive elements (such as buttons) have no effect during the lazy
loading. In general, this is not a problem since:
- Standard links and buttons work, only those with custom effects (a
  modal, a custom JS behavior, etc) have no effect.
- The full loading should not take long anyway.
- After the pages have been visited, everything should be in cache.

However, in some cases (countries with poor internet connections), the
experience can be confusing. Without lazy loading, they would have a
page that appears as a blank white page for a few seconds. With our
custom lazy loading, they get the website very fast... but some buttons
appear buggy (no effect) for a few seconds.

The long term plan is to review our lazy loading:
- Should it be less delayed than it currently is? (at the time, this was
  the minimum delay that made Google give us good page scoring but it
  may not be as impacting as before)
- Should some of the lazy-loaded JS should actually not be?
- Should the assets be split differently?
- Could we be able to remove some code that weighs too much?
- ...?

Meanwhile, this commit improves the behavior this way: during lazy
loading, any click on a button is now ignored but a loading effect is
displayed. Once the JS is fully loaded, the click is then re-played on
the previously clicked button, hopefully triggering its effect. In any
case, this cannot be worse than what we have before... except for:

- The reasonable risk we take merging this in stable (we considered
  merging in a more recent version but it is needed for some specific
  projects and many websites would benefit from this improvement).

- Any custom code that added behavior on buttons to be available during
  lazy loading... will just wait for lazy loading with a loading effect
  too now. This should be a good compromise as, again, that lazy loading
  is cached and should not take too long anyway.

Note that this replaces the previous o_wait_lazy_js class behavior (it
has now no effect).

Overall:
- This should not impact (neither improve nor worsen) most websites that
  are currently experienced from good internet connections.
- This should be a big improvement for most websites that are currently
  experienced from bad internet connections.

Related to task-3770362

closes odoo/odoo#159680

X-original-commit: 6aeebe47dd552144fabd42ddb190021f1499e38a
Signed-off-by: Romain Derie (rde) <rde@odoo.com>
Signed-off-by: Quentin Smetz (qsm) <qsm@odoo.com>
This commit is contained in:
qsm-odoo
2024-04-02 15:40:27 +00:00
parent 0d7b7c792b
commit 8d830ac484
6 changed files with 139 additions and 40 deletions
@@ -1,6 +1,7 @@
/** @odoo-module **/
export const DEBOUNCE = 400;
export const BUTTON_HANDLER_SELECTOR = 'a, button, input[type="submit"], input[type="button"], .btn';
/**
* Protects a function which is to be used as a handler by preventing its
@@ -20,6 +21,9 @@ export const DEBOUNCE = 400;
* @param {function|boolean} stopPropagation
*/
export function makeAsyncHandler(fct, preventDefault, stopPropagation) {
// TODO in master, add those as arguments.
const stopImmediatePropagation = this && this.__makeAsyncHandler_stopImmediatePropagation;
let pending = false;
function _isLocked() {
return pending;
@@ -37,6 +41,9 @@ export function makeAsyncHandler(fct, preventDefault, stopPropagation) {
if (stopPropagation === true || stopPropagation && stopPropagation()) {
ev.stopPropagation();
}
if (stopImmediatePropagation === true || stopImmediatePropagation && stopImmediatePropagation()) {
ev.stopImmediatePropagation();
}
if (_isLocked()) {
// If a previous call to this handler is still pending, ignore
@@ -66,15 +73,24 @@ export function makeAsyncHandler(fct, preventDefault, stopPropagation) {
* re-enabled. Otherwise, the return is used as jQuery uses it.
*/
export function makeButtonHandler(fct) {
// TODO in master, add those as arguments. Even though buttons are probably
// blocked by the o_website_btn_loading and related classes, it is not
// necessarily true for all event types.
const preventDefault = this && this.__makeButtonHandler_preventDefault;
const stopPropagation = this && this.__makeButtonHandler_stopPropagation;
const stopImmediatePropagation = this && this.__makeButtonHandler_stopImmediatePropagation;
// Fallback: if the final handler is not bound to a button, at least
// make it an async handler (also handles the case where some events
// might ignore the disabled state of the button).
fct = makeAsyncHandler(fct);
fct = makeAsyncHandler.call({
'__makeAsyncHandler_stopImmediatePropagation': stopImmediatePropagation,
}, fct, preventDefault, stopPropagation);
return function (ev) {
const result = fct.apply(this, arguments);
const buttonEl = ev.target && ev.target.closest && ev.target.closest('.btn');
const buttonEl = ev.target && ev.target.closest && ev.target.closest(BUTTON_HANDLER_SELECTOR);
if (!buttonEl) {
return result;
}
@@ -109,8 +125,10 @@ export function makeButtonHandler(fct) {
* initial state
*/
export function addButtonLoadingEffect(btnEl) {
// Note that pe-none is used alongside "disabled" so that the behavior is
// the same on links not using the "btn" class -> pointer-events disabled.
if (btnEl.classList) {
btnEl.classList.add('o_website_btn_loading', 'disabled');
btnEl.classList.add('o_website_btn_loading', 'disabled', 'pe-none');
}
btnEl.disabled = true;
const loaderEl = document.createElement('span');
@@ -118,7 +136,7 @@ export function addButtonLoadingEffect(btnEl) {
btnEl.prepend(loaderEl);
return () => {
if (btnEl.classList) {
btnEl.classList.remove('o_website_btn_loading', 'disabled');
btnEl.classList.remove('o_website_btn_loading', 'disabled', 'pe-none');
}
btnEl.disabled = false;
loaderEl.remove();
@@ -1,10 +1,10 @@
/** @odoo-module **/
var blockEvents = ['submit', 'click'];
var blockFunction = function (ev) {
ev.preventDefault();
ev.stopImmediatePropagation();
};
import {
BUTTON_HANDLER_SELECTOR,
makeAsyncHandler,
makeButtonHandler,
} from '@web/legacy/js/core/minimal_dom';
// Track when all JS files have been lazy loaded. Will allow to unblock the
// related DOM sections when the whole JS have been loaded and executed.
@@ -13,12 +13,62 @@ const _allScriptsLoaded = new Promise(resolve => {
allScriptsLoadedResolve = resolve;
}).then(stopWaitingLazy);
var waitingLazy = false;
const retriggeringWaitingProms = [];
/**
* Function to use as an event handler to replay the incoming event after the
* whole lazy JS has been loaded. Note that blocking the incoming event is left
* up to the caller (i.e. a potential wrapper, @see waitLazy).
*
* @param {Event} ev
* @returns {Promise}
*/
async function waitForLazyAndRetrigger(ev) {
// Wait for the lazy JS to be loaded before re-triggering the event.
const targetEl = ev.target;
await _allScriptsLoaded;
// Loaded scripts were able to add a delay to wait for before re-triggering
// events: we wait for it here.
await Promise.all(retriggeringWaitingProms);
// At the end of the current execution queue, retrigger the event. Note that
// the event is reconstructed: this is necessary in some cases, e.g. submit
// buttons. Probably because the event was originally defaultPrevented.
setTimeout(() => {
// Extra safety check: the element might have been removed from the DOM
if (targetEl.isConnected) {
targetEl.dispatchEvent(new ev.constructor(ev.type, ev));
}
}, 0);
}
const loadingEffectHandlers = [];
/**
* Adds the given event listener and saves it for later removal.
*
* @param {HTMLElement} el
* @param {string} type
* @param {Function} handler
*/
function registerLoadingEffectHandler(el, type, handler) {
el.addEventListener(type, handler, {capture: true});
loadingEffectHandlers.push({el, type, handler});
}
let waitingLazy = false;
/**
* Blocks the DOM sections which explicitly require the lazy loaded JS to be
* working (those sections should be marked with the 'o_wait_lazy_js' class).
* Automatically adds a loading effect on clicked buttons (that were not marked
* with a specific class). Once the whole JS has been loaded, the events will be
* triggered again.
*
* For forms, we automatically prevent submit events (since can be triggered
* without click on a button) but we do not retrigger them (could be duplicate
* with re-trigger of a click on a submit button otherwise). However, submitting
* a form in any way should most of the time simulate a click on the submit
* button if any anyway.
*
* @todo This function used to consider the o_wait_lazy_js class. In master, the
* uses of this classes should be removed in XML templates.
* @see stopWaitingLazy
*/
function waitLazy() {
@@ -27,19 +77,55 @@ function waitLazy() {
}
waitingLazy = true;
var lazyEls = document.querySelectorAll('.o_wait_lazy_js');
for (var i = 0; i < lazyEls.length; i++) {
var element = lazyEls[i];
blockEvents.forEach(function (evType) {
element.addEventListener(evType, blockFunction);
document.body.classList.add('o_lazy_js_waiting');
// TODO should probably find the wrapwrap another way but in future versions
// the element will be gone anyway.
const mainEl = document.getElementById('wrapwrap') || document.body;
const loadingEffectButtonEls = [...mainEl.querySelectorAll(BUTTON_HANDLER_SELECTOR)]
// We target all buttons but...
.filter(el => {
// ... we allow to disable the effect by adding a specific class if
// needed. Note that if some non-lazy loaded code is adding an event
// handler on some buttons, it means that if they do not have that
// class, they will show a loading effect and not do anything until
// lazy JS is loaded anyway. This is not ideal, especially since
// this was added as a stable fix/imp, but this is a compromise: on
// next page visits, the cache should limit to effect of the lazy
// loading anyway.
return !el.classList.contains('o_no_wait_lazy_js')
// ... we also allow do not consider links with a href which is
// not "#". They could be linked to handlers that prevent their
// default behavior but we consider that following the link
// should still be relevant in that case.
&& !(el.nodeName === 'A' && el.href && el.getAttribute('href') !== '#');
});
// Note: this is a limitation/a "risk" to only block and retrigger those
// specific event types.
const loadingEffectEventTypes = ['mouseover', 'mouseenter', 'mousedown', 'mouseup', 'click', 'mouseout', 'mouseleave'];
for (const buttonEl of loadingEffectButtonEls) {
for (const eventType of loadingEffectEventTypes) {
const loadingEffectHandler = eventType === 'click'
? makeButtonHandler.call({
'__makeButtonHandler_preventDefault': true,
'__makeButtonHandler_stopImmediatePropagation': true,
}, waitForLazyAndRetrigger)
: makeAsyncHandler.call({
'__makeAsyncHandler_stopImmediatePropagation': true,
}, waitForLazyAndRetrigger, true);
registerLoadingEffectHandler(buttonEl, eventType, loadingEffectHandler);
}
}
document.body.classList.add('o_lazy_js_waiting');
for (const formEl of document.querySelectorAll('form:not(.o_no_wait_lazy_js)')) {
registerLoadingEffectHandler(formEl, 'submit', ev => {
ev.preventDefault();
ev.stopImmediatePropagation();
});
}
}
/**
* Unblocks the DOM sections blocked by @see waitLazy and removes the related
* 'o_wait_lazy_js' class from the whole DOM.
* Undo what @see waitLazy did.
*/
function stopWaitingLazy() {
if (!waitingLazy) {
@@ -47,16 +133,11 @@ function stopWaitingLazy() {
}
waitingLazy = false;
var lazyEls = document.querySelectorAll('.o_wait_lazy_js');
for (var i = 0; i < lazyEls.length; i++) {
var element = lazyEls[i];
blockEvents.forEach(function (evType) {
element.removeEventListener(evType, blockFunction);
});
element.classList.remove('o_wait_lazy_js');
}
document.body.classList.remove('o_lazy_js_waiting');
for (const { el, type, handler } of loadingEffectHandlers) {
el.removeEventListener(type, handler, {capture: true});
}
}
// Start waiting for lazy loading as soon as the DOM is available
@@ -102,4 +183,5 @@ function _loadScripts(scripts, index) {
export default {
loadScripts: _loadScripts,
allScriptsLoaded: _allScriptsLoaded,
registerPageReadinessDelay: retriggeringWaitingProms.push.bind(retriggeringWaitingProms),
};
@@ -1,4 +1,7 @@
/** @odoo-module alias=root.widget */
import { PublicRoot, createPublicRoot } from "./public_root";
import lazyloader from "@web/legacy/js/public/lazyloader";
export default createPublicRoot(PublicRoot);
const prom = createPublicRoot(PublicRoot);
lazyloader.registerPageReadinessDelay(prom);
export default prom;
@@ -1,8 +1 @@
// Disable buttons and links marked with the o_wait_lazy_js class or in a
// section which is marked with the o_wait_lazy_js class.
a[href], button, input[type="submit"], input[type="button"], .btn {
&.o_wait_lazy_js, .o_wait_lazy_js & {
pointer-events: none;
}
}
// TODO remove this file in master
@@ -1,10 +1,11 @@
/** @odoo-module alias=root.widget */
import { createPublicRoot } from "@web/legacy/js/public/public_root";
import lazyloader from "@web/legacy/js/public/lazyloader";
import { WebsiteRoot } from "./website_root";
import { loadBundle } from "@web/core/assets";
export default createPublicRoot(WebsiteRoot).then(async (rootInstance) => {
const prom = createPublicRoot(WebsiteRoot).then(async rootInstance => {
// This data attribute is set by the WebsitePreview client action for a
// restricted editor user.
if (window.frameElement && window.frameElement.dataset.loadWysiwyg === 'true') {
@@ -13,3 +14,5 @@ export default createPublicRoot(WebsiteRoot).then(async (rootInstance) => {
}
return rootInstance;
});
lazyloader.registerPageReadinessDelay(prom);
export default prom;
+1 -1
View File
@@ -1632,8 +1632,8 @@ $ribbon-padding: 100px;
}
.o_website_btn_loading {
cursor: wait;
opacity: $btn-disabled-opacity;
.fa:not(.fa-spin) {
display: none;
}