diff --git a/README.md b/README.md index eed475f38..0a5f92068 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,9 @@ global settings or access otherwise hidden objects. | window.\_\_patternslib_patterns_blacklist | A list of patterns that should not be loaded. | [] | | window.\_\_patternslib_registry | Global access to the Patternslib registry object. | - | | window.\_\_patternslib_registry_initialized | True, if the registry has been initialized. | false | +| window.\_\_patternslib_registry_initializing | True, while the registry waits for Module Federation remotes before the initial scan. | undefined | +| window.\_\_patternslib_mf_initialized | Promise provided by the Module Federation helper of `@patternslib/dev`, resolved once all remote bundles are initialized. The registry waits for it before the initial scan. | undefined | +| window.\_\_patternslib_mf_init_timeout | Maximum time in milliseconds the registry waits for Module Federation remotes before scanning anyway. | 5000 | | window.\_\_patternslib_disable_modernizr (Deprecated) | Disable modernizr, but still write the js/no-js classes to the body. | undefined | ### Bundle build analyzation diff --git a/src/core/base.js b/src/core/base.js index 3861435c7..639606968 100644 --- a/src/core/base.js +++ b/src/core/base.js @@ -141,7 +141,11 @@ Base.extend = function (patternProps) { `The pattern ${patternProps.name} does not have a trigger attribute, it will not be registered.` ); } else if (patternProps.autoregister !== false) { - Registry.register(child, patternProps.name); + // ``replace: true`` replaces an already registered pattern with the + // same name, e.g. to override a core pattern from an add-on bundle. + Registry.register(child, patternProps.name, { + replace: patternProps.replace === true, + }); } return child; }; diff --git a/src/core/basepattern.md b/src/core/basepattern.md index 065724c25..4fedc6e7b 100644 --- a/src/core/basepattern.md +++ b/src/core/basepattern.md @@ -55,3 +55,37 @@ registry.register(Pattern); // Make it available export default Pattern; ``` + +## Replacing a registered pattern + +The first registration of a pattern name wins — registering another pattern +under an already used name is refused. To override a pattern, e.g. a core +pattern from an add-on bundle, pass ``replace: true``: + +```javascript +import registry from "@patternslib/patternslib/src/core/registry"; +import { Pattern as OriginalPattern } from "some-bundle/src/pat/example/example"; + +class Pattern extends OriginalPattern { + // Keep the original name and trigger, so that existing markup and + // options (``data-pat-example``) keep working. + static name = "example"; + static trigger = ".pat-example"; + + async init() { + // Customize, then let the original do the rest. + await super.init(); + } +} + +registry.register(Pattern, Pattern.name, { replace: true }); +``` + +For old-style ``Base.extend`` patterns pass ``replace: true`` along with the +pattern properties. + +The registry waits for Module Federation remote bundles before its initial +DOM scan, so a replacement registered by a remote bundle is in place for the +initial scan no matter whether the remote or the core bundle registered +first. Elements which were already initialized with the previous pattern +keep it; only elements initialized afterwards get the replacement. diff --git a/src/core/registry.js b/src/core/registry.js index c7afcf481..d304ecdb3 100644 --- a/src/core/registry.js +++ b/src/core/registry.js @@ -54,6 +54,11 @@ if (typeof window.__patternslib_registry_initialized === "undefined") { window.__patternslib_registry_initialized = false; } +// Maximum time in milliseconds to wait for Module Federation remote bundles +// to initialize before the initial DOM scan is done anyway. +// Can be overridden via ``window.__patternslib_mf_init_timeout``. +const MF_INIT_TIMEOUT = 5000; + const registry = { patterns: PATTERN_REGISTRY, // reference to global patterns registry // as long as the registry is not initialized, pattern @@ -61,18 +66,55 @@ const registry = { // the DOM is scanned. After that registering a new pattern // results in rescanning the DOM only for this pattern. init() { - dom.document_ready(() => { - if (window.__patternslib_registry_initialized) { + dom.document_ready(async () => { + if ( + window.__patternslib_registry_initialized || + window.__patternslib_registry_initializing + ) { // Do not reinitialize a already initialized registry. return; } + window.__patternslib_registry_initializing = true; + + await registry.wait_for_module_federation(); + window.__patternslib_registry_initialized = true; + window.__patternslib_registry_initializing = false; log.debug("Loaded: " + Object.keys(registry.patterns).sort().join(", ")); registry.scan(document.body); log.debug("Finished initial scan."); }); }, + async wait_for_module_federation(timeout) { + // Defer the initial DOM scan until all Module Federation remote + // bundles are loaded and initialized. Remote bundles register their + // patterns and components asynchronously; scanning before they are + // done would initialize patterns without the remote's additions and + // overrides. + // + // The Module Federation helper of @patternslib/dev provides the + // promise ``window.__patternslib_mf_initialized``. Without a Module + // Federation host on the page there is nothing to wait for. + const mf_initialized = window.__patternslib_mf_initialized; + if (!mf_initialized) { + return; + } + timeout = timeout ?? window.__patternslib_mf_init_timeout ?? MF_INIT_TIMEOUT; + let timed_out = false; + await Promise.race([ + mf_initialized, + utils.timeout(timeout).then(() => { + timed_out = true; + }), + ]); + if (timed_out) { + log.warn( + `Module Federation bundles did not initialize within ${timeout}ms. Scanning the DOM anyway.` + ); + } + }, + clear() { // Removes all patterns from the registry. Currently only being // used in tests. @@ -217,7 +259,17 @@ const registry = { document.body.classList.add("patterns-loaded"); }, - register(pattern, name) { + register(pattern, name, { replace = false } = {}) { + // Register a pattern under ``name`` (defaults to ``pattern.name``). + // + // By default the first registration wins: registering another + // pattern under an already used name is refused. With + // ``replace: true`` an existing registration is replaced instead — + // the way for add-on bundles to override a core pattern. Together + // with the registry waiting for Module Federation remotes before + // the initial scan (see ``init()``), the replacement is in place for + // the initial scan no matter whether the add-on or the core bundle + // registered first. name = name || pattern.name; if (!name) { log.error("Pattern lacks a name.", pattern); @@ -235,8 +287,19 @@ const registry = { } if (registry.patterns[name]) { - log.debug(`Already have a pattern called ${name}.`); - return false; + if (!replace) { + log.debug(`Already have a pattern called ${name}.`); + return false; + } + if (window.__patternslib_registry_initialized) { + // Elements which were already initialized with the previous + // pattern keep it. Only new elements get the replacement. + log.warn( + `Replacing pattern ${name} after the registry was initialized. Already initialized elements keep the previous pattern.` + ); + } else { + log.debug(`Replacing pattern ${name}.`, pattern); + } } // register pattern to be used for scanning new content registry.patterns[name] = pattern; diff --git a/src/core/registry.test.js b/src/core/registry.test.js index 4f7914b05..766b0f087 100644 --- a/src/core/registry.test.js +++ b/src/core/registry.test.js @@ -1,6 +1,7 @@ import Base from "./base"; import BasePattern from "./basepattern"; import registry from "./registry"; +import utils from "./utils"; describe("pat-registry: The registry for patterns", function () { const patterns = registry.patterns; @@ -332,4 +333,182 @@ describe("pat-registry: The registry for patterns", function () { }); }); + + describe("register with replace", function () { + const reset = () => { + window.__patternslib_registry_initialized = false; + delete window.__patternslib_patterns_blacklist; + }; + + beforeEach(reset); + afterEach(function () { + reset(); + jest.restoreAllMocks(); + }); + + const make_pattern = (text) => + class extends BasePattern { + static name = "example"; + static trigger = ".pat-example"; + init() { + this.el.innerHTML = text; + } + }; + + it("Refuses to register a pattern under an already used name by default", function () { + const first = make_pattern("first"); + const second = make_pattern("second"); + + expect(registry.register(first)).toBe(true); + expect(registry.register(second)).toBe(false); + expect(registry.patterns.example).toBe(first); + }); + + it("Replaces an existing pattern with replace: true", function () { + const first = make_pattern("first"); + const second = make_pattern("second"); + + registry.register(first); + expect(registry.register(second, "example", { replace: true })).toBe(true); + expect(registry.patterns.example).toBe(second); + }); + + it("Uses the replacement when scanning", async function () { + registry.register(make_pattern("first")); + registry.register(make_pattern("second"), "example", { replace: true }); + + const tree = document.createElement("div"); + tree.setAttribute("class", "pat-example"); + registry.scan(tree); + await utils.timeout(1); + + expect(tree.textContent).toBe("second"); + }); + + it("Base.extend replaces an existing pattern with replace: true", function () { + const first = Base.extend({ + name: "example", + trigger: ".pat-example", + init: function () {}, + }); + const second = Base.extend({ + name: "example", + trigger: ".pat-example", + replace: true, + init: function () {}, + }); + + expect(registry.patterns.example).not.toBe(first); + expect(registry.patterns.example).toBe(second); + }); + + it("Base.extend without replace keeps the first registration", function () { + const first = Base.extend({ + name: "example", + trigger: ".pat-example", + init: function () {}, + }); + Base.extend({ + name: "example", + trigger: ".pat-example", + init: function () {}, + }); + + expect(registry.patterns.example).toBe(first); + }); + + it("Re-scans for a replaced pattern when the registry is already initialized", function () { + registry.register(make_pattern("first")); + window.__patternslib_registry_initialized = true; + const scan_spy = jest.spyOn(registry, "scan").mockImplementation(() => {}); + + registry.register(make_pattern("second"), "example", { replace: true }); + + expect(scan_spy).toHaveBeenCalledWith(document.body, ["example"]); + }); + + it("Does not replace a blacklisted pattern", function () { + registry.register(make_pattern("first")); + window.__patternslib_patterns_blacklist = ["example"]; + + expect(registry.register(make_pattern("second"), "example", { replace: true })).toBe(false); + }); + }); + + describe("init with Module Federation", function () { + let scan_spy; + + const reset = () => { + window.__patternslib_registry_initialized = false; + delete window.__patternslib_registry_initializing; + delete window.__patternslib_mf_initialized; + delete window.__patternslib_mf_init_timeout; + }; + + beforeEach(function () { + reset(); + scan_spy = jest.spyOn(registry, "scan").mockImplementation(() => {}); + }); + + afterEach(function () { + reset(); + jest.restoreAllMocks(); + }); + + it("Scans immediately when no Module Federation host is present", async function () { + registry.init(); + await utils.timeout(10); + + expect(scan_spy).toHaveBeenCalledWith(document.body); + expect(window.__patternslib_registry_initialized).toBe(true); + }); + + it("Defers the initial scan until the Module Federation bundles are initialized", async function () { + let resolve_initialized; + window.__patternslib_mf_initialized = new Promise((resolve) => { + resolve_initialized = resolve; + }); + + registry.init(); + await utils.timeout(10); + + expect(scan_spy).not.toHaveBeenCalled(); + expect(window.__patternslib_registry_initialized).toBe(false); + + resolve_initialized([]); + await utils.timeout(1); + + expect(scan_spy).toHaveBeenCalledWith(document.body); + expect(window.__patternslib_registry_initialized).toBe(true); + }); + + it("Scans anyway after the timeout when the bundles do not initialize", async function () { + window.__patternslib_mf_initialized = new Promise(() => {}); + window.__patternslib_mf_init_timeout = 20; + + registry.init(); + await utils.timeout(10); + expect(scan_spy).not.toHaveBeenCalled(); + + await utils.timeout(30); + expect(scan_spy).toHaveBeenCalledWith(document.body); + expect(window.__patternslib_registry_initialized).toBe(true); + }); + + it("Does not scan twice when init is called again while waiting", async function () { + let resolve_initialized; + window.__patternslib_mf_initialized = new Promise((resolve) => { + resolve_initialized = resolve; + }); + + registry.init(); + registry.init(); + await utils.timeout(10); + + resolve_initialized([]); + await utils.timeout(1); + + expect(scan_spy).toHaveBeenCalledTimes(1); + }); + }); });