From f353956c345d2b0c79fca2b3c71a4517103e3331 Mon Sep 17 00:00:00 2001 From: Peter Mathis Date: Thu, 10 Sep 2026 13:44:44 +0200 Subject: [PATCH 1/2] feat(core registry): wait for Module Federation remotes before the initial scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Module Federation remote bundles (add-ons) register their patterns and components asynchronously after document ready, while the registry scanned the DOM right at document ready. Whether an add-on's pattern override or component registration was in place for the initial scan was a race between the remote's chunk loading and the host's own initialization — usually won by the remote, but not guaranteed. ``registry.init()`` now awaits ``window.__patternslib_mf_initialized``, the promise provided by the Module Federation helper of @patternslib/dev, before the initial scan. Without a Module Federation host on the page nothing changes. A timeout (default 5000ms, ``window.__patternslib_mf_init_timeout``) makes sure a broken or slow remote cannot block the page; the scan then runs anyway with a warning. Repeated ``init()`` calls while waiting are ignored. --- src/core/registry.js | 46 ++++++++++++++++++++++- src/core/registry.test.js | 78 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 2 deletions(-) diff --git a/src/core/registry.js b/src/core/registry.js index c7afcf481..cb10f92c9 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. diff --git a/src/core/registry.test.js b/src/core/registry.test.js index 4f7914b05..b907b2d56 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,81 @@ describe("pat-registry: The registry for patterns", function () { }); }); + + 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); + }); + }); }); From 11bf65955a2d6e3c3a5baa288dfa9583ce221a15 Mon Sep 17 00:00:00 2001 From: Peter Mathis Date: Thu, 10 Sep 2026 14:32:47 +0200 Subject: [PATCH 2/2] feat(core registry): replace option to override an already registered pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first registration of a pattern name wins, so an add-on bundle could not override a core pattern under its own name — it had to blacklist the original and register a replacement under a different name with the original trigger, and then bridge the original's options by hand. ``registry.register(pattern, name, { replace: true })`` now replaces an existing registration; ``Base.extend`` accepts ``replace: true`` as pattern property. Together with the registry waiting for Module Federation remotes before the initial scan, the replacement is in place for the initial scan no matter whether the remote or the core bundle registered first. Replacing after the registry was initialized logs a warning: already initialized elements keep the previous pattern, only new elements get the replacement. The blacklist still wins over a replacement. Also documents the new Module Federation globals in the README. --- README.md | 3 ++ src/core/base.js | 6 ++- src/core/basepattern.md | 34 +++++++++++++ src/core/registry.js | 27 ++++++++-- src/core/registry.test.js | 101 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 167 insertions(+), 4 deletions(-) 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 cb10f92c9..d304ecdb3 100644 --- a/src/core/registry.js +++ b/src/core/registry.js @@ -259,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); @@ -277,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 b907b2d56..766b0f087 100644 --- a/src/core/registry.test.js +++ b/src/core/registry.test.js @@ -334,6 +334,107 @@ 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;