diff --git a/addons/web/static/src/core/assets.js b/addons/web/static/src/core/assets.js index f872aa0327b..f84799eb2d8 100644 --- a/addons/web/static/src/core/assets.js +++ b/addons/web/static/src/core/assets.js @@ -2,42 +2,10 @@ import { memoize } from "./utils/functions"; import { browser } from "./browser/browser"; - -const { onWillStart, useEnv } = owl; +import { registry } from "./registry"; class AssetsLoadingError extends Error {} -//------------------------------------------------------------------------------ -// Types -//------------------------------------------------------------------------------ - -/** - * An object describing a bundle to load - * @typedef {Object} Bundle - * @property {boolean} [templates] whether to load the qweb templates - * @property {boolean} [js] whether to load the js from the bundle. - * @property {boolean} [css] whether to load the css from the bundle. - */ - -/** - * An object describing a bundle to load. The keys are bundle names. - * @typedef {Object} Bundles - */ - -/** - * An object describing a loaded bundle - * @typedef {Object} LoadedBundle - * @property {XMLDocument} [templates] an XML document containing the owl - * templates defined in that bundle - * @property {true[]} [js] a promise or the resolution of that promise that signifies that the js has been loaded - * @property {true[]} [css] a promise or the resolution of that promise that signifies that the css has been loaded - */ - -/** - * An object describing a loaded bundle. The keys are bundle names. - * @typedef {Object} LoadedBundles - */ - /** * An object describing a bundle to load * @typedef {Object} BundleInfo @@ -45,9 +13,6 @@ class AssetsLoadingError extends Error {} * @property {string} [src] the url of the file for this bundle, for this type of file * @example `[{"type": "script", "src": "/web/assets/266-d34b0b4/documents_spreadsheet.o_spreadsheet.min.js"}]` */ -//------------------------------------------------------------------------------ -// Helpers -//------------------------------------------------------------------------------ /** * Loads the given url inside a script tag. @@ -80,7 +45,7 @@ export const loadJS = memoize(function loadJS(url) { * @param {string} url the url of the stylesheet * @returns {Promise} resolved when the stylesheet has been loaded */ -const loadCSS = memoize(function loadCSS(url) { +export const loadCSS = memoize(function loadCSS(url) { if (document.querySelector(`link[href="${url}"]`)) { // Already in the DOM and wasn't loaded through this function // Unfortunately there is no way to check whether a link has loaded @@ -102,21 +67,26 @@ const loadCSS = memoize(function loadCSS(url) { }); /** * Loads the qweb templates from a given bundle name. + * TODO: merge this into loadBundleDefinition? * - * @param {string} name the name of the bundle as declared in the manifest. - * @returns {Promise} A Promise of an XML document containing the - * owl templates. + * @param {string} bundle the name of the bundle as declared in the manifest. + * @returns {Promise} A Promise of an XML document containing + * the owl templates or an empty string if the bundle has none. */ -export const loadBundleTemplates = memoize(async function loadBundleTemplates(name) { - // TODO: quid of the "unique" in the URL? We can"t have one cache_hash - // for each and every bundle I"m guessing. - const bundleURL = `/web/webclient/qweb/${Date.now()}?bundle=${name}`; +export const fetchAndProcessTemplates = memoize(async function fetchAndProcessTemplates(bundle) { + // TODO: quid of the "unique" in the URL? We can't have one cache_hash + // for each and every bundle I'm guessing. + const bundleURL = `/web/webclient/qweb/${Date.now()}?bundle=${bundle}`; const templates = await (await browser.fetch(bundleURL)).text(); + if (!templates) { + return ""; + } return processTemplates(templates); }); /** - * Loads the content definition of a bundle + * Loads the content definition of a bundle. + * * @param {string} name the bundleName of the bundle as declared in the manifest. * @returns {Promise} A promise of the content definition of the bundle */ @@ -131,49 +101,30 @@ const bundlesCache = {}; * Loads a bundle. * * @param {string} name the name of the bundle to load - * @param {Bundle} options parts of the bundle to load (see Bundle typedef) - * @returns {Promise} + * @param {owl.App} [app] the app in which the bundle's templates should be + * loaded. Defaults to the app that's written on the function itself, this + * is considered the main app, and should be written on the function by the + * code that bootstraps the app. In most cases, this will be the webclient, + * and is set in start.js + * @returns {Promise} a promise that is resolved after the bundle has been + * loaded. */ -export async function loadBundle(name, options) { - const { templates, css, js } = options; +export async function loadBundle(name, app = loadBundle.app) { if (!bundlesCache[name]) { - bundlesCache[name] = { name }; - } - if (templates && !bundlesCache[name].templates) { - bundlesCache[name].templates = loadBundleTemplates(name); + bundlesCache[name] = Promise.all([ + fetchAndProcessTemplates(name).then((templates) => app.addTemplates(templates, app)), + loadBundleDefinition(name).then((bundleInfo) => + Promise.all([ + ...bundleInfo.filter((i) => i.type === "script").map((i) => loadJS(i.src)), + ...bundleInfo.filter((i) => i.type === "link").map((i) => loadCSS(i.src)), + ]) + ), + ]).then(() => {}); } - if ((css && !bundlesCache[name].css) || (js && !bundlesCache[name].js)) { - // we only load the bundle info (that is an RPC) if JS or CSS is requested on the bundle, - // and if it has not yet been requested. - /** @type BundleInfo[] */ - const bundleInfo = await loadBundleDefinition(name); - - if (options.js) { - bundlesCache[name].js = Promise.all( - bundleInfo.filter((i) => i.type === "script").map((i) => loadJS(i.src)) - ); - } - - if (options.css) { - bundlesCache[name].css = Promise.all( - bundleInfo.filter((i) => i.type === "link").map((i) => loadCSS(i.src)) - ); - } - } - - // Wait only for the requested keys - const entries = await Promise.all( - Object.keys(options).map(async (key) => [key, await bundlesCache[name][key]]) - ); - - return Object.fromEntries(entries); + return bundlesCache[name]; } -//------------------------------------------------------------------------------ -// Exports -//------------------------------------------------------------------------------ - /** * Process the qweb templates to obtain only the owl templates. This function * does NOT register the templates into Owl. @@ -187,7 +138,7 @@ export function processTemplates(templates) { // flagged with attribute `owl="1"`. The following lines removes the "owl" // attribute from the templates, so that it doesn't appear in the DOM. We // also remove the non-owl templates, as those shouldn't be loaded in the - // owl environment's QWeb, and will be loaded separately. + // owl application, and will be loaded separately. for (const template of [...doc.querySelector("templates").children]) { if (template.hasAttribute("owl")) { template.removeAttribute("owl"); @@ -207,86 +158,32 @@ export function processTemplates(templates) { * @deprecated * @param {string} xmlid The xmlid of the template that defines the public asset * @param {ORM} orm An ORM object capable of calling methods on models - * @returns {Promise} Resolved when the contents of the asset is loaded + * @returns {Promise} Resolved when the contents of the asset is loaded */ export const loadPublicAsset = memoize(async function loadPublicAsset(xmlid, orm) { const xml = await orm.call("ir.ui.view", "render_public_asset", [xmlid]); const doc = new DOMParser().parseFromString(`${xml}`, "text/xml"); - return loadAssets({ - cssLibs: [...doc.querySelectorAll("link[href]")].map((node) => node.getAttribute("href")), - jsLibs: [...doc.querySelectorAll("script[src]")].map((node) => node.getAttribute("src")), - }); + return Promise.all([ + ...[...doc.querySelectorAll("link[href]")].map((el) => loadCSS(el.getAttribute("href"))), + ...[...doc.querySelectorAll("script[src]")].map((el) => loadJS(el.getAttribute("src"))), + ]); }); +const { Component, xml, onWillStart } = owl; /** - * Loads the given assets. Currently, when passing bundles, only the templates - * key is supported, as loading a bundle's JS/CSS asynchronously requires some - * groundwork. - * - * @param {Object} assets - * @param {Bundles} [assets.bundles] an object describing the bundles to load. - * The keys are bundle names, and the values describe whether to load the - * templates, js and/or css from that bundle. - * @param {string[]} [assets.jsLibs] urls of the javascript libraries to load - * @param {string[]} [assets.cssLibs] urls of the css libraries to load - * @returns {Promise<{[bundles: LoadedBundle[]]}>} An object describing the loaded - * assets. The js and css libs are loaded globally and will be loaded when - * this promise is resolved, the owl xml templates will be loaded in the - * `templates` key of each LoadedBundle object. + * Utility component that loads an asset bundle before instanciating a component */ -export async function loadAssets(assets) { - const proms = []; - const loadedAssets = {}; - if ("bundles" in assets) { - const bundles = Object.entries(assets.bundles); - const bundlesProm = Promise.all( - bundles.map(async ([name, options]) => [name, await loadBundle(name, options)]) - ).then((loadedBundles) => { - loadedAssets.bundles = Object.fromEntries(loadedBundles); +export class LazyComponent extends Component { + setup() { + onWillStart(async () => { + await loadBundle(this.props.bundle); + this.Component = registry.category("lazy_components").get(this.props.Component); }); - proms.push(bundlesProm); } - if ("jsLibs" in assets) { - proms.push(Promise.all(assets.jsLibs.map(loadJS))); - } - if ("cssLibs" in assets) { - proms.push(Promise.all(assets.cssLibs.map(loadCSS))); - } - await Promise.all(proms); - return loadedAssets; -} - -/** - * Loads the given assets, and adds the loaded owl templates into the current - * environment's qweb instance. - * - * @param {Object} assets - * @param {Bundles} [assets.bundles] the bundles to load. See above typedef for - * details. - * @param {string[]} [assets.jsLibs] urls of the javascript libraries to load - * @param {string[]} [assets.cssLibs] urls of the css libraries to load - */ -export function useAssets(assets) { - const env = useEnv(); - onWillStart(async () => { - const loadedAssets = await loadAssets(assets); - // TODO: { js, css } = loadedAssets when we support lazy loading js/css from bundles - const { bundles } = loadedAssets; - if (bundles) { - const templateDocs = Object.values(bundles).map(({ templates }) => templates); - // Some templates might already be defined by another bundle, but need - // to be included in the current bundle for primary inherits to work. - // We don't want to add those again. We also have to add them to qweb - // one bundle at a time in case multiple bundles were specified that - // include the same template (e.g. for inheritance) - for (const xmlDoc of templateDocs) { - for (const template of [...xmlDoc.querySelector("templates").children]) { - if (template.getAttribute("t-name") in env.qweb.templates) { - template.remove(); - } - } - env.qweb.addTemplates(xmlDoc); - } - } - }); } +LazyComponent.template = xml``; +LazyComponent.props = { + Component: String, + bundle: String, + props: { type: Object, optional: true }, +}; diff --git a/addons/web/static/src/core/errors/error_utils.js b/addons/web/static/src/core/errors/error_utils.js index 67ecdc60290..56364b5610e 100644 --- a/addons/web/static/src/core/errors/error_utils.js +++ b/addons/web/static/src/core/errors/error_utils.js @@ -1,6 +1,6 @@ /** @odoo-module **/ -import { loadAssets } from "../assets"; +import { loadJS } from "../assets"; import { isBrowserChrome } from "../browser/feature_detection"; /** @@ -49,9 +49,7 @@ export function formatTraceback(error) { export async function annotateTraceback(error) { const traceback = formatTraceback(error); try { - await loadAssets({ - jsLibs: ["/web/static/lib/stacktracejs/stacktrace.js"], - }); + await loadJS("/web/static/lib/stacktracejs/stacktrace.js"); } catch (_e) { return traceback; } diff --git a/addons/web/static/src/legacy/js/public/public_root.js b/addons/web/static/src/legacy/js/public/public_root.js index acf39b2c431..5401487f1f8 100644 --- a/addons/web/static/src/legacy/js/public/public_root.js +++ b/addons/web/static/src/legacy/js/public/public_root.js @@ -21,7 +21,7 @@ import { } from "../../utils"; import { standaloneAdapter } from "web.OwlCompatibility"; -import { loadBundleTemplates } from "@web/core/assets"; +import { fetchAndProcessTemplates } from "@web/core/assets"; import { makeEnv, startServices } from "@web/env"; import { MainComponentsContainer } from "@web/core/main_components_container"; import { browser } from '@web/core/browser/browser'; @@ -405,7 +405,7 @@ export async function createPublicRoot(RootWidget) { const wowlEnv = makeEnv(); - const templates = await loadBundleTemplates("web.assets_frontend"); + const templates = await fetchAndProcessTemplates("web.assets_frontend"); window.__OWL_TEMPLATES__ = templates; await startServices(wowlEnv); mapLegacyEnvToWowlEnv(legacyEnv, wowlEnv); diff --git a/addons/web/static/src/start.js b/addons/web/static/src/start.js index bf0bb0ead39..f0b3361cd30 100644 --- a/addons/web/static/src/start.js +++ b/addons/web/static/src/start.js @@ -3,7 +3,7 @@ import { makeEnv, startServices } from "./env"; import { legacySetupProm } from "./legacy/legacy_setup"; import { mapLegacyEnvToWowlEnv } from "./legacy/utils"; -import { processTemplates } from "./core/assets"; +import { processTemplates, loadBundle } from "./core/assets"; import { localization } from "@web/core/l10n/localization"; import { session } from "@web/session"; import { renderToString } from "./core/utils/render"; @@ -47,6 +47,7 @@ export async function startWebClient(Webclient) { translateFn: env._t, }); renderToString.app = app; + loadBundle.app = app; const root = await app.mount(document.body); const classList = document.body.classList; if (localization.direction === "rtl") { diff --git a/addons/web/static/src/views/graph/graph_renderer.js b/addons/web/static/src/views/graph/graph_renderer.js index e6c14294d10..1e998c8f350 100644 --- a/addons/web/static/src/views/graph/graph_renderer.js +++ b/addons/web/static/src/views/graph/graph_renderer.js @@ -5,10 +5,10 @@ import { BORDER_WHITE, DEFAULT_BG, getColor, hexToRGBA } from "./colors"; import { formatFloat } from "@web/fields/formatters"; import { SEP } from "./graph_model"; import { sortBy } from "@web/core/utils/arrays"; -import { useAssets } from "@web/core/assets"; +import { loadJS } from "@web/core/assets"; import { renderToString } from "@web/core/utils/render"; -const { Component, onWillUnmount, useEffect, useRef } = owl; +const { Component, onWillUnmount, useEffect, useRef, onWillStart } = owl; const NO_DATA = _lt("No data"); @@ -50,7 +50,7 @@ export class GraphRenderer extends Component { this.tooltip = null; this.legendTooltip = null; - useAssets({ jsLibs: ["/web/static/lib/Chart/Chart.js"] }); + onWillStart(() => loadJS(["/web/static/lib/Chart/Chart.js"])); useEffect(() => this.renderChart()); onWillUnmount(this.onWillUnmount); diff --git a/addons/web/static/src/views/onboarding_banner.js b/addons/web/static/src/views/onboarding_banner.js index ef809822c30..b886c386333 100644 --- a/addons/web/static/src/views/onboarding_banner.js +++ b/addons/web/static/src/views/onboarding_banner.js @@ -1,6 +1,6 @@ /**@odoo-module */ -import { loadAssets } from "@web/core/assets"; +import { loadCSS, loadJS } from "@web/core/assets"; import { useService } from "@web/core/utils/hooks"; import { useActionLinks } from "@web/views/helpers/view_hook"; @@ -33,21 +33,16 @@ export class OnboardingBanner extends Component { } const banner = new DOMParser().parseFromString(response.html, "text/html"); - const assets = { - jsLibs: [], - cssLibs: [], - }; - banner - .querySelectorAll(`link[rel="stylesheet"] , script[type="text/javascript"]`) - .forEach((elem) => { - if (elem.tagName === "SCRIPT") { - assets.jsLibs.push(elem.src); - } else if (elem.tagName === "LINK") { - assets.cssLibs.push(elem.href); - } - elem.remove(); - }); - await loadAssets(assets); + await Promise.all([ + ...[...banner.querySelectorAll(`script[type="text/javascript"]`)].map((el) => { + el.remove(); + return loadJS(el.getAttribute("src")); + }), + ...[...banner.querySelectorAll(`link[rel="stylesheet"]`)].map((el) => { + el.remove(); + return loadCSS(el.getAttribute("href")); + }), + ]); return markup(new XMLSerializer().serializeToString(banner)); } } diff --git a/addons/web/static/tests/core/utils/assets_tests.js b/addons/web/static/tests/core/utils/assets_tests.js index 61cd91f5478..b0ae0f682f6 100644 --- a/addons/web/static/tests/core/utils/assets_tests.js +++ b/addons/web/static/tests/core/utils/assets_tests.js @@ -1,15 +1,15 @@ /** @odoo-module **/ -import { loadAssets } from "@web/core/assets"; +import { loadJS, loadCSS, fetchAndProcessTemplates } from "@web/core/assets"; import { patchWithCleanup } from "@web/../tests/helpers/utils"; import { browser } from "@web/core/browser/browser"; QUnit.module("utils", () => { QUnit.module("Assets"); - QUnit.test("loadAssets: load invalid JS lib", function (assert) { + QUnit.test("loadJS: load invalid JS lib", function (assert) { assert.rejects( - loadAssets({ jsLibs: ["/some/invalid/file.js"] }), + loadJS("/some/invalid/file.js"), new RegExp("The loading of /some/invalid/file.js failed"), "Trying to load an invalid file rejects the promise" ); @@ -19,9 +19,9 @@ QUnit.module("utils", () => { ); }); - QUnit.test("loadAssets: load invalid CSS lib", function (assert) { + QUnit.test("loadCSS: load invalid CSS lib", function (assert) { assert.rejects( - loadAssets({ cssLibs: ["/some/invalid/file.css"] }), + loadCSS("/some/invalid/file.css"), new RegExp("The loading of /some/invalid/file.css failed"), "Trying to load an invalid file rejects the promise" ); @@ -31,7 +31,7 @@ QUnit.module("utils", () => { ); }); - QUnit.test("loadAssets: load invalid bundle", function (assert) { + QUnit.test("fetchAndProcessTemplates: load invalid bundle", function (assert) { let lastFetchedURL; patchWithCleanup(browser, { fetch: function (url) { @@ -40,11 +40,7 @@ QUnit.module("utils", () => { }, }); assert.rejects( - loadAssets({ - bundles: { - "web.some_invalid_bundle": { templates: true }, - }, - }), + fetchAndProcessTemplates("web.some_invalid_bundle"), "Trying to load an invalid bundle rejects the promise" ); assert.ok( diff --git a/odoo/addons/test_assetsbundle/__manifest__.py b/odoo/addons/test_assetsbundle/__manifest__.py index a324a952f72..850fff6e039 100644 --- a/odoo/addons/test_assetsbundle/__manifest__.py +++ b/odoo/addons/test_assetsbundle/__manifest__.py @@ -57,6 +57,12 @@ ('include', 'test_assetsbundle.manifest4'), ], 'test_assetsbundle.manifest_multi_module1': [], + 'web.qunit_suite_tests': [ + 'test_assetsbundle/static/tests/lazyloading_test.js' + ], + 'test_assetsbundle.lazy_test_component': [ + 'test_assetsbundle/static/tests/lazy_test_component/**/*', + ], }, 'license': 'LGPL-3', } diff --git a/odoo/addons/test_assetsbundle/static/tests/lazy_test_component/lazy_test_component.js b/odoo/addons/test_assetsbundle/static/tests/lazy_test_component/lazy_test_component.js new file mode 100644 index 00000000000..e32e83edb2f --- /dev/null +++ b/odoo/addons/test_assetsbundle/static/tests/lazy_test_component/lazy_test_component.js @@ -0,0 +1,14 @@ +/** @odoo-module */ + +import { registry } from "@web/core/registry"; + +const { Component } = owl; + +export class LazyTestComponent extends Component { + setup() { + this.props.onCreated(); + } +} +LazyTestComponent.template = "test_assetsbundle.LazyTestComponent"; + +registry.category("lazy_components").add("LazyTestComponent", LazyTestComponent); diff --git a/odoo/addons/test_assetsbundle/static/tests/lazy_test_component/lazy_test_component.scss b/odoo/addons/test_assetsbundle/static/tests/lazy_test_component/lazy_test_component.scss new file mode 100644 index 00000000000..4522b000cf6 --- /dev/null +++ b/odoo/addons/test_assetsbundle/static/tests/lazy_test_component/lazy_test_component.scss @@ -0,0 +1,3 @@ +.o_lazy_test_component { + background-color: rgb(165, 94, 117); +} diff --git a/odoo/addons/test_assetsbundle/static/tests/lazy_test_component/lazy_test_component.xml b/odoo/addons/test_assetsbundle/static/tests/lazy_test_component/lazy_test_component.xml new file mode 100644 index 00000000000..c36692927a3 --- /dev/null +++ b/odoo/addons/test_assetsbundle/static/tests/lazy_test_component/lazy_test_component.xml @@ -0,0 +1,8 @@ + + + + +
Lazy Component!
+
+ +
diff --git a/odoo/addons/test_assetsbundle/static/tests/lazyloading_test.js b/odoo/addons/test_assetsbundle/static/tests/lazyloading_test.js new file mode 100644 index 00000000000..401fa7e28a7 --- /dev/null +++ b/odoo/addons/test_assetsbundle/static/tests/lazyloading_test.js @@ -0,0 +1,47 @@ +/** @odoo-module */ + +import { loadBundle, LazyComponent } from "@web/core/assets"; +import { registerCleanup } from "@web/../tests/helpers/cleanup"; +import { makeTestEnv } from "@web/../tests/helpers/mock_env"; +import { getFixture, patchWithCleanup } from "@web/../tests/helpers/utils"; + +const { Component, App, xml } = owl; + +QUnit.module("utils", () => { + QUnit.module("Assets"); + + QUnit.test("LazyComponent loads the required bundle", async function (assert) { + class Test extends Component { + get childProps() { + return { + onCreated: () => assert.step("Lazy test component created"), + }; + } + } + Test.template = xml` + + `; + Test.components = { LazyComponent }; + + const target = getFixture(); + const app = new App(Test, { + test: true, + env: makeTestEnv(), + }); + registerCleanup(() => app.destroy()); + patchWithCleanup(loadBundle, { app }); + + assert.verifySteps([]); + await app.mount(target); + assert.verifySteps(["Lazy test component created"]); + assert.strictEqual( + target.innerHTML, + `
Lazy Component!
` + ); + assert.strictEqual( + window.getComputedStyle(target.querySelector(".o_lazy_test_component")).backgroundColor, + "rgb(165, 94, 117)", + "scss file was loaded" + ); + }); +});