From 9bbcc0f4b8bc808bb6f34ce7e24eac527b7594ee Mon Sep 17 00:00:00 2001 From: Julien Wajsberg Date: Tue, 15 Sep 2026 16:27:17 +0200 Subject: [PATCH] feat(fluent-dom): Implement a new Localization class MiniDOMLocalization for projects that have a lot of roots The new class doesn't use a MutationObserver which has performance problems when observing a lot of nodes. Therefore it's up to the users to call `translateFragment` or `translateElements` at the right moment. It also doesn't check if a newly connected root overlaps with the previously connected roots because of the quadratic behavior of this check. It's up to the users of the library to take care about that. The possible drawback is to possibly translate the same elements twice when the locale change, which is a small drawback compared to the quadratic behavior. The previously existing DOMLocalization is now based on MiniDOMLocalization, but otherwise doesn't change its API or behavior, so this change shouldn't be breaking for existing users. --- fluent-dom/README.md | 33 ++++ fluent-dom/src/dom_localization.js | 136 ++------------ fluent-dom/src/index.js | 1 + fluent-dom/src/mini_dom_localization.js | 174 ++++++++++++++++++ fluent-dom/test/dom_localization_test.js | 16 -- fluent-dom/test/mini_dom_localization_test.js | 94 ++++++++++ 6 files changed, 316 insertions(+), 138 deletions(-) create mode 100644 fluent-dom/src/mini_dom_localization.js create mode 100644 fluent-dom/test/mini_dom_localization_test.js diff --git a/fluent-dom/README.md b/fluent-dom/README.md index 1e77142b..622de98b 100644 --- a/fluent-dom/README.md +++ b/fluent-dom/README.md @@ -41,6 +41,39 @@ const h1 = document.querySelector("h1"); l10n.setAttributes(h1, "welcome", { user: "Anna" }); ``` +If your application already has a rendering lifecycle, for example when it's +built with Web Components or a component framework, the `MutationObserver` +used by `DOMLocalization` can become a performance problem as the number of +connected roots grows. The `MiniDOMLocalization` class provides the same DOM +translation API without the observer. Elements are only translated when you +ask for it, typically from the component's render hook, and connected roots are +retranslated when the language changes. + +```javascript +import { MiniDOMLocalization } from "@fluent/dom"; + +const l10n = new MiniDOMLocalization( + ["/browser/main.ftl", "/toolkit/menu.ftl"], + generateBundles +); + +class MyElement extends HTMLElement { + connectedCallback() { + l10n.connectRoot(this.shadowRoot); + } + + disconnectedCallback() { + l10n.disconnectRoot(this.shadowRoot); + } + + render() { + this.shadowRoot.innerHTML = `

`; + // Nothing observes the DOM, so translate explicitly after rendering. + l10n.translateFragment(this.shadowRoot); + } +} +``` + For imperative uses straight from the JS code, there's also a `Localization` class that provides just the API needed to format messages in the running code. diff --git a/fluent-dom/src/dom_localization.js b/fluent-dom/src/dom_localization.js index dc7e410e..ec48662a 100644 --- a/fluent-dom/src/dom_localization.js +++ b/fluent-dom/src/dom_localization.js @@ -1,10 +1,7 @@ -import translateElement from "./overlay.js"; -import Localization from "./localization.js"; - -const L10NID_ATTR_NAME = "data-l10n-id"; -const L10NARGS_ATTR_NAME = "data-l10n-args"; - -const L10N_ELEMENT_QUERY = `[${L10NID_ATTR_NAME}]`; +import MiniDOMLocalization, { + L10NID_ATTR_NAME, + L10NARGS_ATTR_NAME, +} from "./mini_dom_localization.js"; /** * The `DOMLocalization` class is responsible for fetching resources and @@ -13,8 +10,10 @@ const L10N_ELEMENT_QUERY = `[${L10NID_ATTR_NAME}]`; * It implements the fallback strategy in case of errors encountered during the * formatting of translations and methods for observing DOM * trees with a `MutationObserver`. + * + * See `MiniDOMLocalization` for a variant without the `MutationObserver`. */ -export default class DOMLocalization extends Localization { +export default class DOMLocalization extends MiniDOMLocalization { /** * @param {Array} resourceIds - List of resource IDs * @param {Function} generateBundles - Function that returns a @@ -24,8 +23,6 @@ export default class DOMLocalization extends Localization { constructor(resourceIds, generateBundles) { super(resourceIds, generateBundles); - // A Set of DOM trees observed by the `MutationObserver`. - this.roots = new Set(); // requestAnimationFrame handler. this.pendingrAF = null; // list of elements pending for translation. @@ -42,13 +39,6 @@ export default class DOMLocalization extends Localization { }; } - onChange(eager = false) { - super.onChange(eager); - if (this.roots) { - this.translateRoots(); - } - } - /** * Set the `data-l10n-id` and `data-l10n-args` attributes on DOM elements. * FluentDOM makes use of mutation observers to detect changes @@ -94,26 +84,6 @@ export default class DOMLocalization extends Localization { return element; } - /** - * Get the `data-l10n-*` attributes from DOM elements. - * - * ```javascript - * localization.getAttributes( - * document.querySelector('#welcome') - * ); - * // -> { id: 'hello', args: { who: 'world' } } - * ``` - * - * @param {Element} element - HTML element - * @returns {{id: string, args: Object}} - */ - getAttributes(element) { - return { - id: element.getAttribute(L10NID_ATTR_NAME), - args: JSON.parse(element.getAttribute(L10NARGS_ATTR_NAME) || null), - }; - } - /** * Add `newRoot` to the list of roots managed by this `DOMLocalization`. * @@ -145,7 +115,7 @@ export default class DOMLocalization extends Localization { ); } - this.roots.add(newRoot); + super.connectRoot(newRoot); this.mutationObserver.observe(newRoot, this.observerConfig); } @@ -162,11 +132,11 @@ export default class DOMLocalization extends Localization { * @returns {boolean} */ disconnectRoot(root) { - this.roots.delete(root); + const wasLast = super.disconnectRoot(root); // Pause the mutation observer to stop observing `root`. this.pauseObserving(); - if (this.roots.size === 0) { + if (wasLast) { this.mutationObserver = null; if (this.windowElement && this.pendingrAF) { this.windowElement.cancelAnimationFrame(this.pendingrAF); @@ -182,16 +152,6 @@ export default class DOMLocalization extends Localization { return false; } - /** - * Translate all roots associated with this `DOMLocalization`. - * - * @returns {Promise} - */ - translateRoots() { - const roots = Array.from(this.roots); - return Promise.all(roots.map(root => this.translateFragment(root))); - } - /** * Pauses the `MutationObserver`. */ @@ -259,97 +219,29 @@ export default class DOMLocalization extends Localization { } } - /** - * Translate a DOM element or fragment asynchronously using this - * `DOMLocalization` object. - * - * Manually trigger the translation (or re-translation) of a DOM fragment. - * Use the `data-l10n-id` and `data-l10n-args` attributes to mark up the DOM - * with information about which translations to use. - * - * Returns a `Promise` that gets resolved once the translation is complete. - * - * @param {Element | DocumentFragment} frag - Element or DocumentFragment to be translated - * @returns {Promise} - */ - translateFragment(frag) { - return this.translateElements(this.getTranslatables(frag)); - } - - /** - * Translate a list of DOM elements asynchronously using this - * `DOMLocalization` object. - * - * Manually trigger the translation (or re-translation) of a list of elements. - * Use the `data-l10n-id` and `data-l10n-args` attributes to mark up the DOM - * with information about which translations to use. - * - * Returns a `Promise` that gets resolved once the translation is complete. - * - * @param {Array} elements - List of elements to be translated - * @returns {Promise} - */ - async translateElements(elements) { - if (!elements.length) { - return undefined; - } - - const keys = elements.map(this.getKeysForElement); - const translations = await this.formatMessages(keys); - return this.applyTranslations(elements, translations); - } - /** * Applies translations onto elements. * * @param {Array} elements * @param {Array} translations - * @private + * @protected */ applyTranslations(elements, translations) { this.pauseObserving(); - - for (let i = 0; i < elements.length; i++) { - if (translations[i] !== undefined) { - translateElement(elements[i], translations[i]); - } - } - + super.applyTranslations(elements, translations); this.resumeObserving(); } - /** - * Collects all translatable child elements of the element. - * - * @param {Element | DocumentFragment} element - * @returns {Array} - * @private - */ - getTranslatables(element) { - const nodes = Array.from(element.querySelectorAll(L10N_ELEMENT_QUERY)); - - if ( - typeof element.hasAttribute === "function" && - element.hasAttribute(L10NID_ATTR_NAME) - ) { - nodes.push(element); - } - - return nodes; - } - /** * Get the `data-l10n-*` attributes from DOM elements as a two-element * array. * + * @deprecated Use `getAttributes` instead. * @param {Element} element * @returns {Object} * @private */ getKeysForElement(element) { - return { - id: element.getAttribute(L10NID_ATTR_NAME), - args: JSON.parse(element.getAttribute(L10NARGS_ATTR_NAME) || null), - }; + return this.getAttributes(element); } } diff --git a/fluent-dom/src/index.js b/fluent-dom/src/index.js index d5575ad4..21d171a4 100644 --- a/fluent-dom/src/index.js +++ b/fluent-dom/src/index.js @@ -1,2 +1,3 @@ export { default as DOMLocalization } from "./dom_localization.js"; +export { default as MiniDOMLocalization } from "./mini_dom_localization.js"; export { default as Localization } from "./localization.js"; diff --git a/fluent-dom/src/mini_dom_localization.js b/fluent-dom/src/mini_dom_localization.js new file mode 100644 index 00000000..75a876af --- /dev/null +++ b/fluent-dom/src/mini_dom_localization.js @@ -0,0 +1,174 @@ +import translateElement from "./overlay.js"; +import Localization from "./localization.js"; + +export const L10NID_ATTR_NAME = "data-l10n-id"; +export const L10NARGS_ATTR_NAME = "data-l10n-args"; + +const L10N_ELEMENT_QUERY = `[${L10NID_ATTR_NAME}]`; + +/** + * The `MiniDOMLocalization` class translates DOM elements marked with + * `data-l10n-id` and `data-l10n-args` attributes. + * + * Unlike `DOMLocalization`, it doesn't observe the DOM: it's up to the caller + * to call `translateFragment` or `translateElements` when elements are added + * or their `data-l10n-*` attributes change. Roots connected with `connectRoot` + * are only retranslated when the language changes, through `onChange`. + * + * This makes it a good fit for applications built with a component framework + * that already has a rendering lifecycle, where a `MutationObserver` per + * component would be too costly. + */ +export default class MiniDOMLocalization extends Localization { + /** + * @param {Array} resourceIds - List of resource IDs + * @param {Function} generateBundles - Function that returns a + * generator over FluentBundles + * @returns {MiniDOMLocalization} + */ + constructor(resourceIds, generateBundles) { + super(resourceIds, generateBundles); + + // A Set of DOM trees retranslated on language change. + this.roots = new Set(); + } + + onChange(eager = false) { + super.onChange(eager); + // The base constructor calls onChange before `roots` is initialized. + if (this.roots) { + this.translateRoots(); + } + } + + /** + * Get the `data-l10n-*` attributes from DOM elements. + * + * ```javascript + * localization.getAttributes( + * document.querySelector('#welcome') + * ); + * // -> { id: 'hello', args: { who: 'world' } } + * ``` + * + * @param {Element} element - HTML element + * @returns {{id: string, args: Object}} + */ + getAttributes(element) { + return { + id: element.getAttribute(L10NID_ATTR_NAME), + args: JSON.parse(element.getAttribute(L10NARGS_ATTR_NAME) || null), + }; + } + + /** + * Add `newRoot` to the list of roots managed by this `MiniDOMLocalization`. + * + * Connected roots are retranslated when the language changes. + * + * @param {Element | DocumentFragment} newRoot - Root to connect. + */ + connectRoot(newRoot) { + this.roots.add(newRoot); + } + + /** + * Remove `root` from the list of roots managed by this + * `MiniDOMLocalization`. + * + * Returns `true` if the root was the last one managed by this + * `MiniDOMLocalization`. + * + * @param {Element | DocumentFragment} root - Root to disconnect. + * @returns {boolean} + */ + disconnectRoot(root) { + this.roots.delete(root); + return this.roots.size === 0; + } + + /** + * Translate all roots associated with this `MiniDOMLocalization`. + * + * @returns {Promise} + */ + translateRoots() { + const roots = Array.from(this.roots); + return Promise.all(roots.map(root => this.translateFragment(root))); + } + + /** + * Translate a DOM element or fragment asynchronously using this + * `MiniDOMLocalization` object. + * + * Manually trigger the translation (or re-translation) of a DOM fragment. + * Use the `data-l10n-id` and `data-l10n-args` attributes to mark up the DOM + * with information about which translations to use. + * + * Returns a `Promise` that gets resolved once the translation is complete. + * + * @param {Element | DocumentFragment} frag - Element or DocumentFragment to be translated + * @returns {Promise} + */ + translateFragment(frag) { + return this.translateElements(this.getTranslatables(frag)); + } + + /** + * Translate a list of DOM elements asynchronously using this + * `MiniDOMLocalization` object. + * + * Manually trigger the translation (or re-translation) of a list of elements. + * Use the `data-l10n-id` and `data-l10n-args` attributes to mark up the DOM + * with information about which translations to use. + * + * Returns a `Promise` that gets resolved once the translation is complete. + * + * @param {Array} elements - List of elements to be translated + * @returns {Promise} + */ + async translateElements(elements) { + if (!elements.length) { + return undefined; + } + + const keys = elements.map(element => this.getAttributes(element)); + const translations = await this.formatMessages(keys); + return this.applyTranslations(elements, translations); + } + + /** + * Applies translations onto elements. + * + * @param {Array} elements + * @param {Array} translations + * @protected + */ + applyTranslations(elements, translations) { + for (let i = 0; i < elements.length; i++) { + if (translations[i] !== undefined) { + translateElement(elements[i], translations[i]); + } + } + } + + /** + * Collects all translatable child elements of the element. + * + * @param {Element | DocumentFragment} element + * @returns {Array} + * @protected + */ + getTranslatables(element) { + const nodes = Array.from(element.querySelectorAll(L10N_ELEMENT_QUERY)); + + if ( + typeof element.hasAttribute === "function" && + element.hasAttribute(L10NID_ATTR_NAME) + ) { + nodes.push(element); + } + + return nodes; + } +} diff --git a/fluent-dom/test/dom_localization_test.js b/fluent-dom/test/dom_localization_test.js index 54effa56..797a5717 100644 --- a/fluent-dom/test/dom_localization_test.js +++ b/fluent-dom/test/dom_localization_test.js @@ -1,7 +1,6 @@ import assert from "assert"; import { FluentBundle, FluentResource } from "@fluent/bundle"; import DOMLocalization from "../src/dom_localization.js"; -import { vi } from "vitest"; async function* mockGenerateMessages() { const bundle = new FluentBundle(["en-US"]); @@ -23,19 +22,4 @@ suite("translateFragment", function () { assert.strictEqual(elem.textContent, "Key 1"); }); - - test("does not inject content into a node with missing translation", async function () { - const domLoc = new DOMLocalization(["test.ftl"], mockGenerateMessages); - - vi.spyOn(console, "warn").mockImplementation(() => {}); - const frag = document.createDocumentFragment(); - const elem = document.createElement("p"); - domLoc.setAttributes(elem, "missing_key"); - elem.textContent = "Original Value"; - frag.appendChild(elem); - - await domLoc.translateFragment(frag); - - assert.strictEqual(elem.textContent, "Original Value"); - }); }); diff --git a/fluent-dom/test/mini_dom_localization_test.js b/fluent-dom/test/mini_dom_localization_test.js new file mode 100644 index 00000000..c43764fc --- /dev/null +++ b/fluent-dom/test/mini_dom_localization_test.js @@ -0,0 +1,94 @@ +import assert from "assert"; +import { FluentBundle, FluentResource } from "@fluent/bundle"; +import MiniDOMLocalization from "../src/mini_dom_localization.js"; +import { beforeEach, vi } from "vitest"; + +let translation = "Key 1"; + +async function* mockGenerateMessages() { + const bundle = new FluentBundle(["en-US"]); + const resource = new FluentResource(`key1 = ${translation}`); + bundle.addResource(resource); + yield bundle; +} + +function createTranslatable(id) { + const elem = document.createElement("p"); + elem.setAttribute("data-l10n-id", id); + return elem; +} + +suite("MiniDOMLocalization", function () { + beforeEach(function () { + translation = "Key 1"; + }); + + suite("translateFragment", function () { + test("translates a node", async function () { + const l10n = new MiniDOMLocalization(["test.ftl"], mockGenerateMessages); + + const frag = document.createDocumentFragment(); + const elem = createTranslatable("key1"); + frag.appendChild(elem); + + await l10n.translateFragment(frag); + + assert.strictEqual(elem.textContent, "Key 1"); + }); + + test("translates the fragment itself when it is an element", async function () { + const l10n = new MiniDOMLocalization(["test.ftl"], mockGenerateMessages); + + const elem = createTranslatable("key1"); + + await l10n.translateFragment(elem); + + assert.strictEqual(elem.textContent, "Key 1"); + }); + + test("does not inject content into a node with missing translation", async function () { + const l10n = new MiniDOMLocalization(["test.ftl"], mockGenerateMessages); + + vi.spyOn(console, "warn").mockImplementation(() => {}); + const frag = document.createDocumentFragment(); + const elem = createTranslatable("missing_key"); + elem.textContent = "Original Value"; + frag.appendChild(elem); + + await l10n.translateFragment(frag); + + assert.strictEqual(elem.textContent, "Original Value"); + }); + }); + + suite("connectRoot", function () { + test("retranslates connected roots on language change", async function () { + const l10n = new MiniDOMLocalization(["test.ftl"], mockGenerateMessages); + const root = document.createElement("div"); + const elem = createTranslatable("key1"); + root.appendChild(elem); + + l10n.connectRoot(root); + await l10n.translateRoots(); + assert.strictEqual(elem.textContent, "Key 1"); + + translation = "Clé 1"; + l10n.onChange(); + // Wait for the pending translation to be applied. + await l10n.formatMessages([]); + assert.strictEqual(elem.textContent, "Clé 1"); + }); + + test("disconnectRoot reports whether the last root was removed", function () { + const l10n = new MiniDOMLocalization(["test.ftl"], mockGenerateMessages); + const root1 = document.createElement("div"); + const root2 = document.createElement("div"); + + l10n.connectRoot(root1); + l10n.connectRoot(root2); + + assert.strictEqual(l10n.disconnectRoot(root1), false); + assert.strictEqual(l10n.disconnectRoot(root2), true); + }); + }); +});