Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion src/core/base.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand Down
34 changes: 34 additions & 0 deletions src/core/basepattern.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
73 changes: 68 additions & 5 deletions src/core/registry.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,25 +54,67 @@ 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
// registration just registers a pattern. Once init is called,
// 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.
Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand Down
179 changes: 179 additions & 0 deletions src/core/registry.test.js
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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);
});
});
});
Loading