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); + }); + }); +});