trisquel-icecat/icecat/toolkit/components/translations/content/translations-document.sys.mjs
2025-10-06 02:35:48 -06:00

6694 lines
237 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
/**
* @typedef {object} Lazy
* @property {typeof setTimeout} setTimeout
* @property {typeof clearTimeout} clearTimeout
* @property {typeof console} console
* @property {typeof import("chrome://global/content/translations/TranslationsUtils.mjs").TranslationsUtils} TranslationsUtils
*/
/** @type {Lazy} */
const lazy = /** @type {any} */ ({});
ChromeUtils.defineESModuleGetters(lazy, {
setTimeout: "resource://gre/modules/Timer.sys.mjs",
clearTimeout: "resource://gre/modules/Timer.sys.mjs",
TranslationsUtils:
"chrome://global/content/translations/TranslationsUtils.mjs",
});
ChromeUtils.defineLazyGetter(lazy, "console", () => {
return console.createInstance({
maxLogLevelPref: "browser.translations.logLevel",
prefix: "Translations",
});
});
/**
* Map the NodeFilter enums that are used by the TreeWalker into enums that make
* sense for determining the status of the nodes for the TranslationsDocument process.
* This aligns the meanings of the filtering for the translations process.
*/
const NodeStatus = {
// This node is ready to translate as is.
READY_TO_TRANSLATE: NodeFilter.FILTER_ACCEPT,
// This node is a shadow host and needs to be subdivided further.
SHADOW_HOST: NodeFilter.FILTER_ACCEPT,
// This node contains too many block elements and needs to be subdivided further.
SUBDIVIDE_FURTHER: NodeFilter.FILTER_SKIP,
// This node should not be considered for translation.
NOT_TRANSLATABLE: NodeFilter.FILTER_REJECT,
};
/**
* @typedef {import("../translations").NodeVisibility} NodeVisibility
* @typedef {import("../translations").LanguagePair} LanguagePair
* @typedef {import("../translations").PortToPage} PortToPage
* @typedef {import("../translations").EngineStatus} EngineStatus
* @typedef {import("../translations").TranslationsMode} TranslationsMode
* @typedef {import("../translations").ScrollDirection} ScrollDirection
* @typedef {import("../translations").NodeViewportContext} NodeViewportContext
* @typedef {import("../translations").NodeSpatialContext} NodeSpatialContext
* @typedef {import("../translations").UpdateEligibility} UpdateEligibility
* @typedef {import("../translations").SortableContentElement} SortableContentElement
* @typedef {import("../translations").PrioritizedContentElements} PrioritizedContentElements
* @typedef {import("../translations").SortableAttributeElement} SortableAttributeElement
* @typedef {import("../translations").PrioritizedAttributeElements} PrioritizedAttributeElements
* @typedef {import("../translations").TranslationPriorityKinds} TranslationPriorityKinds
* @typedef {import("../translations").TranslationRequest} TranslationRequest
* @typedef {import("../translations").TranslationFunction} TranslationFunction
*/
/**
* Create a translation cache with a limit. It implements a "least recently used" strategy
* to remove old translations. After `#cacheExpirationMS` the cache will be emptied.
* This cache is owned statically by the TranslationsChild. This means that it will be
* re-used on page reloads if the origin of the site does not change.
*/
export class LRUCache {
/**
* A Map from input HTML strings to their translated HTML strings.
*
* This cache is used to check if we already have a translated response for the given
* input HTML, to help avoid spending CPU cycles translating HTML for which we already
* know the translated output.
*
* @type {Map<string, string>}
*/
#htmlCacheMap = new Map();
/**
* A Map from input text strings to their translated text strings.
*
* This cache is used to check if we already have a translated response for the given
* input text, to help avoid spending CPU cycles translating text for which we already
* know the translated output.
*
* @type {Map<string, string>}
*/
#textCacheMap = new Map();
/**
* A Set containing strings of translated HTML output.
*
* This cache is used to check if the HTML has already been translated,
* to help avoid sending already-translated HTML to be translated a second time.
*
* Ideally, a translation model that receives source text that is already in the
* target translation language should just pass it through, but this is not always
* the case in practice. Depending on the model, sending already-translated text to
* be translated again may change the translation or even produce garbage as a response.
*
* Best to avoid this situation altogether if we can.
*
* @type {Set<string>}
*/
#htmlCacheSet = new Set();
/**
* A Set containing strings of translated plain text output.
*
* This cache is used to check if the text has already been translated,
* to help avoid sending already-translated text to be translated a second time.
*
* Ideally, a translation model that receives source text that is already in the
* target translation language should just pass it through, but this is not always
* the case in practice. Depending on the model, sending already-translated text to
* be translated again may change the translation or even produce garbage as a response.
*
* Best to avoid this situation altogether if we can.
*
* @type {Set<string>}
*/
#textCacheSet = new Set();
/**
* The language pair for this cache. All cached translations will be for the given pair.
*
* @type {LanguagePair}
*/
#languagePair;
/**
* The limit of entries that can be held in each underlying cache before old entries
* will start being replaced by new entries.
*
* @type {number}
*/
#cacheLimit = 5_000;
/**
* This cache will self-destruct after 10 minutes.
*
* @type {number}
*/
#cacheExpirationMS = 10 * 60_000;
/**
* The source and target langue pair for the content in this cache.
*
* @param {LanguagePair} languagePair
*/
constructor(languagePair) {
this.#languagePair = languagePair;
}
/**
* Retrieves the corresponding Map from source text to translated text.
*
* This is used to determine if a cached translation already exists for
* the given source text, preventing us from having to spend CPU time by
* recomputing the translation.
*
* @param {boolean} isHTML
*
* @returns {Map<string, string>}
*/
#getCacheMap(isHTML) {
return isHTML ? this.#htmlCacheMap : this.#textCacheMap;
}
/**
* Retrieves the corresponding Set of translated text responses
*
* This is used to determine if the text being sent to translate
* has already been translated. In such a situation we want to
* avoid sending it to the translator a second time.
*
* @param {boolean} isHTML
* @returns {Set<string>}
*/
#getCacheSet(isHTML) {
return isHTML ? this.#htmlCacheSet : this.#textCacheSet;
}
/**
* Get a translation if it exists from the cache, and move it to the end of the cache
* to keep it alive longer.
*
* @param {string} sourceString
* @param {boolean} isHTML
*
* @returns {string | undefined}
*/
get(sourceString, isHTML) {
const cacheMap = this.#getCacheMap(isHTML);
const targetString = cacheMap.get(sourceString);
if (targetString === undefined) {
return undefined;
}
// Maps are ordered, move this item to the end of the list so it will stay
// alive longer.
cacheMap.delete(sourceString);
cacheMap.set(sourceString, targetString);
this.keepAlive();
return targetString;
}
/**
* Adds a new translation to the cache, a mapping from the source text to the target text.
*
* @param {string} sourceString
* @param {string} targetString
* @param {boolean} isHTML
*/
set(sourceString, targetString, isHTML) {
const cacheMap = this.#getCacheMap(isHTML);
if (cacheMap.has(sourceString)) {
// The Map already has this value, so we must delete it to
// re-insert it at the most-recently-used position of the Map.
cacheMap.delete(sourceString);
} else if (cacheMap.size === this.#cacheLimit) {
// The Map is at capacity, so we must evict the least-recently-used value.
const oldestKey = cacheMap.keys().next().value;
// @ts-ignore: We can ensure that oldestKey is not undefined.
cacheMap.delete(oldestKey);
}
cacheMap.set(sourceString, targetString);
const cacheSet = this.#getCacheSet(isHTML);
if (cacheSet.has(targetString)) {
// The Set already has this value, so we must delete it to
// re-insert it at the most-recently-used position of the Set.
cacheSet.delete(targetString);
} else if (cacheSet.size === this.#cacheLimit) {
// The Set is at capacity, so we must evict the least-recently-used value.
const oldestKey = cacheSet.keys().next().value;
// @ts-ignore: We can ensure that oldestKey is not undefined.
cacheSet.delete(oldestKey);
}
cacheSet.add(targetString);
this.keepAlive();
}
/**
* Returns true if the source text is text that has already been translated
* into the target language, otherwise false. If so, we want to avoid sending
* this text to be translated a second time. Depending on the model, retranslating
* text that is already in the target language may produce garbage output.
*
* @param {string} sourceText
* @param {boolean} isHTML
*
* @returns {boolean}
*/
isAlreadyTranslated(sourceText, isHTML) {
return this.#getCacheSet(isHTML).has(sourceText);
}
/**
* Returns true if the given pair matches the language pair for this cache, otherwise false.
*
* @param {LanguagePair} languagePair
*
* @returns {boolean}
*/
matches(languagePair) {
return (
lazy.TranslationsUtils.langTagsMatch(
this.#languagePair.sourceLanguage,
languagePair.sourceLanguage
) &&
lazy.TranslationsUtils.langTagsMatch(
this.#languagePair.targetLanguage,
languagePair.targetLanguage
)
);
}
/**
* The id for the cache's keep-alive timeout, at which point it will destroy itself.
*
* @type {number}
*/
#keepAliveTimeoutId = 0;
/**
* Used to ensure that only one callback is added to the event loop to set keep-alive timeout.
*
* @type {boolean}
*/
#hasPendingKeepAliveCallback = false;
/**
* Resets the timer for the cache's keep-alive timeout, extending the time the cache will live.
*/
keepAlive() {
if (this.#hasPendingKeepAliveCallback) {
// There is already a pending callback to extend the timeout.
return;
}
if (this.#keepAliveTimeoutId) {
lazy.clearTimeout(this.#keepAliveTimeoutId);
this.#keepAliveTimeoutId = 0;
}
this.#hasPendingKeepAliveCallback = true;
lazy.setTimeout(() => {
this.#hasPendingKeepAliveCallback = false;
this.#keepAliveTimeoutId = lazy.setTimeout(() => {
this.#htmlCacheMap = new Map();
this.#textCacheMap = new Map();
this.#htmlCacheSet = new Set();
this.#textCacheSet = new Set();
}, this.#cacheExpirationMS);
}, 0);
}
}
/**
* How often the DOM is updated with translations, in milliseconds.
*
* Each time the DOM is updated, we must pause the mutation observer.
*
* - Stopping the observer takes about 5 micro seconds based on profiling.
*
* - Starting the observer takes about 30 micro seconds based on profiling.
*
* We want to choose a DOM update interval that is fast enough to feel instantaneously
* reactive when completed translation requests come in, while also allowing multiple
* nodes to be updated within a single pause of the observer.
*
* @type {number}
*/
const DOM_UPDATE_INTERVAL_MS = 25;
/**
* Tags excluded from content translation.
*/
const CONTENT_EXCLUDED_TAGS = new Set([
// The following are elements that semantically should not be translated.
"CODE",
"KBD",
"SAMP",
"VAR",
"ACRONYM",
// The following are deprecated tags.
"DIR",
"APPLET",
// The following are embedded elements, and are not supported (yet).
"MATH",
"EMBED",
"OBJECT",
"IFRAME",
// This is an SVG tag that can contain arbitrary XML, ignore it.
"METADATA",
// These are elements that are treated as opaque by IceCat which causes their
// innerHTML property to be just the raw text node behind it. Any text that is sent as
// HTML must be valid, and there is no guarantee that the innerHTML is valid.
"NOSCRIPT",
"NOEMBED",
"NOFRAMES",
// The title is handled separately, and a HEAD tag should not be considered.
"HEAD",
// These are not user-visible tags.
"STYLE",
"SCRIPT",
"TEMPLATE",
// Textarea elements contain user content, which should not be translated.
"TEXTAREA",
]);
/**
* Tags excluded from attribute translation.
*/
const ATTRIBUTE_EXCLUDED_TAGS = (() => {
const attributeTags = new Set(CONTENT_EXCLUDED_TAGS);
// The <head> element may contain <meta> elements that may have translatable attributes.
// So we will allow <head> for attribute translations, but not for content translations.
attributeTags.delete("HEAD");
// <textarea> elements are excluded from content translation, because we do not want to
// translate text that the user types, but the "placeholder"attribute should be translated.
attributeTags.delete("TEXTAREA");
return attributeTags;
})();
/**
* A map of criteria to determine if an attribute is translatable for a given element.
* Each key in the map represents an attribute name, while the value can be either `null` or an array of further criteria.
*
* - If the criteria value is `null`, the attribute is considered translatable for any element.
*
* - If the criteria array is specified, then at least one criterion must match a given element in order for the attribute to be translatable.
* Each object in the array defines a tagName and optional conditions to match against an element in question.
*
* - If none of the tagNames match the element, then the attribute is not translatable for that element.
*
* - If a tagName matches and no further conditions are specified, then the attribute is always translatable for elements of that type.
*
* - If a tagName matches and further conditions are specified, then at least one of the conditions must match for the attribute to be translatable for that element.
*
* Example:
*
* - "title" is translatable for all elements.
*
* - "label" is translatable only for "TRACK" elements.
*
* - "value" is translatable only for "INPUT" elements whose "type" attribute is "button", "reset".
*
* @type {Map<string, Array<{ tagName: string, conditions?: Record<string, Array<string>> }> | null>}
*/
const TRANSLATABLE_ATTRIBUTES = new Map([
["abbr", [{ tagName: "TH" }]],
[
"alt",
[
{ tagName: "AREA" },
{ tagName: "IMAGE" },
{ tagName: "IMG" },
{ tagName: "INPUT" },
],
],
["aria-braillelabel", null],
["aria-brailleroledescription", null],
["aria-colindextext", null],
["aria-description", null],
["aria-label", null],
["aria-placeholder", null],
["aria-roledescription", null],
["aria-rowindextext", null],
["aria-valuetext", null],
[
"content",
[{ tagName: "META", conditions: { name: ["description", "keywords"] } }],
],
["download", [{ tagName: "A" }, { tagName: "AREA" }]],
[
"label",
[{ tagName: "TRACK" }, { tagName: "OPTGROUP" }, { tagName: "OPTION" }],
],
["placeholder", [{ tagName: "INPUT" }, { tagName: "TEXTAREA" }]],
["title", null],
[
// We only want to translate value attributes for button-like <input> elements.
// See https://bugzilla.mozilla.org/show_bug.cgi?id=1919230#c10
// type: submit is not translated because it may affect form submission, depending on how the server is configured.
// See https://github.com/whatwg/html/issues/3396#issue-291182587
"value",
[{ tagName: "INPUT", conditions: { type: ["button", "reset"] } }],
],
]);
/**
* A single CSS selector string that matches elements with the criteria defined in TRANSLATABLE_ATTRIBUTES.
*
* @see TRANSLATABLE_ATTRIBUTES
*
* @type {string}
*/
const TRANSLATABLE_ATTRIBUTES_SELECTOR = (() => {
const selectors = [];
for (const [attribute, criteria] of TRANSLATABLE_ATTRIBUTES) {
if (!criteria) {
// There are no further criteria: we translate this attribute for all elements.
// Example: [title]
selectors.push(`[${attribute}]`);
continue;
}
for (const { tagName, conditions } of criteria) {
if (!conditions) {
// There are no further conditions: we translate this attribute for all elements with this tagName.
// Example: TRACK[label]
selectors.push(`${tagName}[${attribute}]`);
continue;
}
// Further conditions are specified, so we must add a selector for each condition.
for (const [key, values] of Object.entries(conditions)) {
for (const value of values) {
// Example: INPUT[value][type="button"]
selectors.push(`${tagName}[${attribute}][${key}="${value}"]`);
}
}
}
}
return selectors.join(",");
})();
/**
* Options used by the mutation observer
*/
const MUTATION_OBSERVER_OPTIONS = {
characterData: true,
childList: true,
subtree: true,
attributes: true,
attributeOldValue: true,
attributeFilter: [...TRANSLATABLE_ATTRIBUTES.keys()],
};
/**
* This class manages the process of translating the DOM from one language to another.
*
* The logic within this class is generally separated into two types of translations:
* Content Translations and Attribute Translations.
*
* - For Content Translations, the DOM is traversed, filtered, and subdivided into smaller
* groups of Nodes that have translatable text content.
*
* - For Attribute Translations, a series of query selectors are used to filter all of
* the Nodes that have translatable attributes within the DOM.
*
* Once nodes have been identified for both Content Translations and Attribute Translations,
* they are then registered for intersection observation and mutation observation.
*
* The mutation observer notifies us when a Node's content has changed, when a Node's translatable
* attributes have changed, as well as when new nodes are added into the DOM tree, and need to be
* further filtered, subdivided, and registered for intersection observation.
*
* In total, four intersection observers are used to prioritize which nodes should be translated: two to
* handle content-translation observations, and the other two to handle attribute-translation observations.
*
* Once intersections have been observed, the relevant nodes are sent into a queue where they will
* wait to be assigned a priority, based on both the type of translation, as well as the Node's location
* relative to the viewport of the screen.
*
* Prioritized nodes are then sent to the translation scheduler @see {TranslationScheduler}, which
* will attempt to optimally send requests to the TranslationsEngine worker to be translated, based
* both on the engine's throughput as well as on how many new translation requests are coming in.
*
* Once a request has come back from the TranslationsEngine worker, its response is validated, then
* the relevant node's content or attribute is scheduled to be updated in the DOM with the corresponding
* result of the translation.
*
* Note that a pending translation request may be cancelled at any stage in this process, up until the point
* where the request has come back from the TranslationsEngine worker, and the Node's content or attribute
* has been replaced in the DOM. Cancellations may happen for one of several reasons:
*
* 1) The page has been hidden (such as switching tabs), and we are pausing all execution until it is shown again.
* 2) The user has scrolled to a new location on the page entirely, and prior requests are no longer relevant.
* 3) A Node's location with respect to the viewport has changed and it needs a new translation priority.
* 4) A Node's content has mutated within the DOM, and the pending translation request is no longer relevant.
*
* The following diagram shows the flow of translations throughout the entire lifecycle of the TranslationsDocument.
*
* ┌────────────────────────┐ ┌──────────┐
* │ Register DOM roots for │ │ Mutation │
* │ mutation observation │ │ Observer │
* └────────────────────────┘ └──────────┘
* │ │
* │ │ New nodes
* │ │ observed
* v │
* ┌─────────────────┐ │
* │ Subdivide nodes │ <───────────┘
* │ within the DOM │
* └─────────────────┘
* │
* │
* │
* v
* ┌──────────────────┐
* │ Register nodes │
* ┌────────────────────────────> │ for intersection │
* │ │ observation │
* │ └──────────────────┘
* │ │
* │ │
* │ │
* │ v
* │ ┌───────────────────┐
* │ │ Wait for observed │
* │ ┌─────────────────────────> │ intersection │
* │ │ └───────────────────┘
* │ │ │
* │ │ │ Node intersection with
* │ │ │ viewport is observed
* │ │ │
* │ │ v
* │ │ ┌────────┐ Node mutated ┌──────────────────┐
* │ ├─ │ Cancel │ <──────────── │ Enqueue node for │ Node's intersection context with
* │ │ └────────┘ │ prioritization │ respect to the viewport has changed ┌────────┐
* │ │ │ │ ────────────────────────────────────> │ Cancel │
* │ │ └──────────────────┘ └────────┘
* │ │ │ ^ ^ │ Node's new intersection context is
* │ │ Send prioritized node │ │ │ │ still relevant to be translated
* │ │ to translation scheduler │ │ └──────────────────────────────────────────────┘
* │ │ │ │
* │ │ v └───────────────────────────────────────────────────┐
* │ │ ┌───────────────────┐ │ Node's new intersection context is
* │ │ │ Scheduler creates │ Node's intersection context with │ still relevant to be translated
* │ │ ┌────────┐ Node mutated │ a request promise │ respect to the viewport has changed ┌────────┐
* │ ├─ │ Cancel │ <──────────── │ for the node │ ─────────────────────────────────────> │ Cancel │
* │ │ └────────┘ └───────────────────┘ └────────┘
* │ │ │
* │ │ │ Send translation request
* │ │ │ to TranslationsEngine
* │ │ │
* │ │ v
* │ │ ┌───────────────────┐
* │ │ │ Wait for response │
* │ │ ┌────────┐ Node mutated │ from translations │
* │ ├─ │ Cancel │ <──────────── │ engine │
* │ │ └────────┘ └───────────────────┘
* │ │ │
* │ │ │ Receive response with
* │ │ │ translated text for node
* │ │ │
* │ │ v
* │ │ ┌───────────────┐
* │ │ │ Schedule node │
* │ │ ┌────────┐ Node mutated │ to be updated │
* │ └─ │ Cancel │ <──────────── │ │
* │ └────────┘ └───────────────┘
* │ │
* │ │ Update node content
* │ │ or attribute with
* │ │ translated text
* │ v
* │ ┌───────────────────┐
* │ │ Unregister node │
* │ Node mutated │ from intersection │
* └───────────────────────────── │ observation │
* └───────────────────┘
*/
export class TranslationsDocument {
/**
* The BCP 47 language tag that matches the page's source language.
*
* If elements are found that do not match this language, then they are skipped,
* because our translation models only operate between the exact language pair.
*
* @type {string}
*/
#documentLanguage;
/**
* Marks when we have a pending callback for updating all nodes whose content translation
* requests have completed. This ensures that we won't redundantly request to update nodes.
*
* @type {boolean}
*/
#hasPendingUpdateContentCallback = false;
/**
* Marks when we have a pending callback for updating all elements whose attribute
* translation requests have completed. This ensures that we won't redundantly request
* to update nodes.
*
* @type {boolean}
*/
#hasPendingUpdateAttributesCallback = false;
/**
* A map of elements with translatable text content that may be prevented and removed
* by the intersection observers before they are prioritized and sent to the scheduler.
*
* @type {Map<Element, Set<Node>>}
*/
#queuedIntersectionPrunableContentElements = new Map();
/**
* A map of elements with translatable text content that are unaffected by intersection
* observation. An example of this would be the <title> element, which will never intersect
* with the viewport.
*
* @type {Map<Element, Set<Node>>}
*/
#queuedIntersectionExemptContentElements = new Map();
/**
* A map of elements with translatable attributes that may be prevented and removed
* by the intersection observers before they are prioritized and sent to the scheduler.
*
* @type {Map<Element, Set<string>>}
*/
#queuedIntersectionPrunableAttributeElements = new Map();
/**
* A map of elements with translatable attributes that are unaffected by intersection
* observation. An example of this would be the <head> element, which may have translatable
* attributes, but will never intersect with the viewport.
*
* @type {Map<Element, Set<string>>}
*/
#queuedIntersectionExemptAttributeElements = new Map();
/**
* The list of nodes that need updating with the translated content. These are batched into an update.
* The translationId is a monotonically increasing number that represents a unique id for a translation.
* It guards against races where a node is mutated before the translation is returned. The translation is
* asynchronously cancelled during a mutation, but it can still return a translation before it is
* cancelled.
*
* @type {Set<{ element: Element, targetNode: Node, translatedContent: string, translationId: number }>}
*/
#elementsThatNeedContentUpdates = new Set();
/**
* The list of nodes that need updating with the translated attributes. These are batched into an update.
* The translationId is a monotonically increasing number that represents a unique id for a translation.
* It guards against races where a node is mutated before the translation is returned. The translation is
* asynchronously cancelled during a mutation, but it can still return a translation before it is
* cancelled.
*
* @type {Set<{ element: Element, translation: string, attribute: string, translationId: number }>}
*/
#elementsThatNeedAttributeUpdates = new Set();
/**
* This is the set of nodes (both elements and text nodes) whose translation requests
* have fully completed, and the node's content has been updated with the translated
* value.
*
* Nodes will be removed from this set when they are observed for mutations.
*
* @type {WeakSet<Node>}
*/
#processedContentNodes = new WeakSet();
/**
* All root elements we're trying to translate. This should be the `document.body`
* the `head` (for attributes only), and the `title` element.
*
* @type {Set<Node>}
*/
#rootNodes = new Set();
/**
* A collection of nodes whose text content has mutated, which will be batched
* together and sent to be re-translated once every requestAnimationFrame.
*
* @type {Set<Node>}
*/
#nodesWithMutatedContent = new Set();
/**
* A collection of elements whose attributes have mutated, which will be batched
* together and sent to be re-translated once every requestAnimationFrame.
*
* @type {Map<Element, Set<string>>}
*/
#elementsWithMutatedAttributes = new Map();
/**
* Marks when we have a pending callback for updating the mutated nodes.
* This ensures that we won't redundantly request for nodes to be updated.
*
* @type {boolean}
*/
#hasPendingMutatedNodesCallback = false;
/**
* Marks when we have a pending callback for sending prioritizing translation
* requests and submitting them to the TranslationScheduler. This ensures that
* we won't redundantly request prioritization.
*
* @type {boolean}
*/
#hasPendingPrioritizationCallback = false;
/**
* This boolean indicates whether the first visible DOM translation change is about to occur.
*
* @type {boolean}
*/
#hasFirstVisibleChange = false;
/**
* A unique ID that guards against races between translations and mutations.
*
* @type {Map<Element, Map<Node, number>>}
*/
#pendingContentTranslations = new Map();
/**
* A unique ID that guards against races between translations and mutations. The
* Map<string, number> is a mapping of the node's attribute to the translation id.
*
* @type {Map<Element, Map<string, number>>}
*/
#pendingAttributeTranslations = new Map();
/**
* Cache a map of all child nodes to their pending parents. This lookup was slow
* from profiling sites like YouTube with lots of mutations. Caching the relationship
* speeds it up.
*
* @type {WeakMap<Node, Node>}
*/
#nodeToPendingParent = new WeakMap();
/**
* The y-axis location of the viewport the previous time a scroll event was fired.
*
* @type {number}
*/
#previousScrollY = 0;
/**
* A hint at the most recent direction in which the user scrolled since requesting translations.
* This helps with the prioritization of translation requests for outside-of-viewport nodes.
*
* @type {ScrollDirection?}
*/
#mostRecentScrollDirection = null;
/**
* The most recent timestamp from a "scroll" event.
*
* @type {number}
*/
#mostRecentScrollTimestamp = 0;
/**
* Start with 1 so that it will never be falsey.
*
* @type {number}
*/
#lastTranslationId = 1;
/**
* A cache of recent translations, used to avoid wasting CPU time translating text
* for which we already have a translated response.
*
* @type {LRUCache}
*/
#translationsCache;
/**
* The DOMParser is used when updating elements with translated text.
*
* @type {DOMParser}
*/
#domParser;
/**
* The mutation observer that watches for both new and mutated nodes.
*
* @type {MutationObserver}
*/
#mutationObserver;
/**
* The inner-window ID is used for better profiler marker reporting.
*
* @type {number}
*/
#innerWindowId;
/**
* The original document of the page that we will be updating with translated text.
*
* @type {Document}
*/
#sourceDocument;
/**
* A callback that will report that the first visible change has been made to the page.
* This is a key performance metric when considering the time to initialize translations.
*
* @type {() => void}
*/
#actorReportFirstVisibleChange;
/**
* The scheduler that is responsible for sending translation requests to the TranslationsEngine.
*
* @type {TranslationScheduler}
*/
#scheduler;
/**
* The script direction of the target language.
*
* @type {("ltr"|"rtl")}
*/
#targetScriptDirection;
/**
* The mode of translation, either "content-eager" or "lazy".
*
* When the find bar is closed, the mode will be "lazy", translating only content near the viewport.
* This is better for power consumption, conserves battery on mobile, etc., and is the default behavior.
*
* When the find bar is open, the mode will change to "content-eager", eventually translating the entire page,
* regardless of proximity to the viewport. This way the find-in-page functionality will work as intended.
*
* @type {TranslationsMode}
*/
#translationsMode;
/**
* A map containing all elements that are being observed for content translations,
* and the set of translatable nodes for that element.
*
* Only Element type nodes are observable for intersection, so in order to observe
* a Text Node for intersection, it must be linked to its parent element.
*
* Note that the set of translatable nodes may contain the element itself.
*
* @type {Map<Element, Set<Node>>}
*/
#intersectionObservedContentElements = new Map();
/**
* A map containing all elements that are being observed for attribute translations,
* and the set of translatable attribute names for each element.
*
* @type {Map<Element, Set<string>>}
*/
#intersectionObservedAttributeElements = new Map();
// The following four intersection observers are responsible for detecting when nodes are within close enough range of the viewport
// to have their content and/or attributes scheduled to be translated. Two observers are dedicated to observing nodes with translatable
// text content, and two observers are dedicated to observing nodes with translatable attributes.
//
// Each pair has one In-Viewport Observer and one Beyond-Viewport Observer. The priority at which a node's translations are scheduled is
// determined by its location within these observer pairs. Translations for nodes that are observed by the In-Viewport observers are scheduled
// at the highest priority. Translations for nodes that are observed by the Beyond-Viewport observers are scheduled at lower priorities.
//
// As the location of the viewport changes with respect to the page, translations for nodes may be reprioritized or cancelled altogether.
// The following diagram shows a few examples of how translation priorities for nodes may change as the viewport moves:
//
//
// Page Page
// ┌─────────────────────────────────────┐ ┌─────────────────────────────────────┐
// │ ~~~ ~ ~ ~ ~ ~ │ │ ~~~ ~ ~ ~ ~ ~ │
// │ │ │ │
// │ ~~~~~~~~~~~~~~~~~~ │ │ ~~~~~~~~~~~~~~~~~~ │
// Beyond-Viewport ══╪═> ┌─────────────────────────────┐ │ v │ ╔═══════════════════════════════════╪════╦═══ Translations for these nodes
// Observer │ │ ~~ ~~~~~~~~~~~~~~~~~~ │ │ v │ ╠═> ~~ ~~~~~~~~~~~~~~~~~~ <═══════╪════╣ will be cancelled if their
// │ │ ~~ ~~~~~~~~~~~~~~~~~~~ │ │ v │ ╚═> ~~ ~~~~~~~~~~~~~~~~~~~ <═══════╪════╣ requests did not yet complete.
// │ │ │ │ v │ │ ║
// In-Viewport ══╪═══╪══> ┌───────────────────┐ │ │ v │ │ ║
// Observer │ │ │~~~~~~~~~~~~~~~~~ │ │ │ v │ ~~~~~~~~~~~~~~~~~ <═══════╪════╝
// │ │ │ │ │ │ v Beyond-Viewport ══╪═> ┌─────────────────────────────┐ │
// │ │ │~~~~~~~~~~~~~~~~~~ │ │ │ v Observer │ │ ~~~~~~~~~~~~~~~~~~~ <═══╪═══╪════╦═══ Translations for these nodes
// │ │ │ │ │ │ │ │ │ │ ║ will be moved to a lower priority
// │ │ └───────────────────┘ │ │ Scroll │ │ ~~~~~~~~~~~~~~~~~~ <════╪═══╪════╝ if their requests did not yet complete.
// │ │ │ │ down In-Viewport ══╪═══╪══> ┌───────────────────┐ │ │
// │ │ ~~ │ │ Observer │ │ ~~ │ │ │ │
// │ │ ~~ ~~~~~~~~~~~~~~~ │ │ v │ │ ~~ │~~~~~~~~~~~~~~~ <══╪════╪═══╪════════ Translations for this node will
// │ └─────────────────────────────┘ │ v │ │ │ │ │ │ be moved to a higher priority if
// │ │ v │ │ │ │ │ │ its requests did not yet complete.
// │ │ v │ │ └───────────────────┘ │ │
// │ │ v │ │ │ │
// │ ~~~~~~~~~~~~~~~~~~ │ v │ │ ~~~~~~~~~~~~~~~~~~ <════╪═══╪════╦═══ Translations for these nodes will
// │ ~~~~~~~~~~~~~~~~~ │ v │ │ ~~~~~~~~~~~~~~~~~ <═════╪═══╪════╝ be newly requested at a lower priority.
// │ │ │ └─────────────────────────────┘ │
// │ ~~ ~~~~~~~~~~~~~~~~~~~ │ │ ~~ ~~~~~~~~~~~~~~~~~~~ │
// │ ~~~~~~~~~~~~~~~~ │ │ ~~~~~~~~~~~~~~~~ │
// │ ~~~ ~~~~ │ │ ~~~ ~~~~ │
// └─────────────────────────────────────┘ └─────────────────────────────────────┘
/**
* An intersection observer bound to the exact dimensions of the viewport
* that watches for nodes whose text content is translatable.
*
* Nodes observed by this observer lead to the highest-priority translation requests
* since they are the nodes that are immediately within the viewport.
*
* @type {IntersectionObserver}
*/
#intersectionObserverForContentTranslationsWithinViewport;
/**
* A promise that is resolved once the in-viewport content intersection observer's
* first observation has completed.
*
* @type {PromiseWithResolvers<void>}
*/
#contentWithinViewportInitialObservation = Promise.withResolvers();
/**
* An intersection observer whose borders extend beyond the viewport
* that watches for nodes whose text content is translatable.
*
* Nodes observed by this observer lead to lower-priority translation requests
* since they lie just beyond the viewport of what the user can see.
*
* @type {IntersectionObserver}
*/
#intersectionObserverForContentTranslationsBeyondViewport;
/**
* A promise that is resolved once the beyond-viewport content intersection observer's
* first observation has completed.
*
* @type {PromiseWithResolvers<void>}
*/
#contentBeyondViewportInitialObservation = Promise.withResolvers();
/**
* An intersection observer bound to the exact dimensions of the viewport
* that watches for nodes with attributes that are translatable.
*
* Nodes observed by this observer lead to the highest-priority translation requests
* since they are the nodes that are immediately within the viewport.
*
* @type {IntersectionObserver}
*/
#intersectionObserverForAttributeTranslationsWithinViewport;
/**
* A promise that is resolved once the in-viewport attribute intersection observer's
* first observation has completed.
*
* @type {PromiseWithResolvers<void>}
*/
#attributesWithinViewportInitialObservation = Promise.withResolvers();
/**
* An intersection observer whose borders extend beyond the viewport
* that watches for nodes with attributes that are translatable.
*
* Nodes observed by this observer lead to lower-priority translation requests
* since they lie just beyond the viewport of what the user can see.
*
* @type {IntersectionObserver}
*/
#intersectionObserverForAttributeTranslationsBeyondViewport;
/**
* A promise that is resolved once the beyond-viewport attribute intersection observer's
* first observation has completed.
*
* @type {PromiseWithResolvers<void>}
*/
#attributesBeyondViewportInitialObservation = Promise.withResolvers();
/**
* Construct a new TranslationsDocument. It is tied to a specific Document and cannot
* be re-used. The translation functions are injected since this class shouldn't
* manage the life cycle of the translations engines.
*
* @param {Document} document
* @param {string} documentLanguage - The BCP 47 tag of the source language.
* @param {string} targetLanguage - The BCP 47 tag of the destination language.
* @param {number} innerWindowId - This is used for better profiler marker reporting.
* @param {MessagePort} port - The port to the translations engine.
* @param {() => void} requestNewPort - Used when an engine times out and a new
* translation request comes in.
* @param {() => void} reportVisibleChange - Used to report to the actor that the first visible change
* for a translation is about to occur.
* @param {LRUCache} translationsCache - A cache in which to store translated text.
* @param {boolean} isFindBarOpen - Whether the find bar was open in the current tab upon construction.
*/
constructor(
document,
documentLanguage,
targetLanguage,
innerWindowId,
port,
requestNewPort,
reportVisibleChange,
translationsCache,
isFindBarOpen
) {
/** @type {WindowProxy} */
const ownerGlobal = ensureExists(document.ownerGlobal);
ownerGlobal.addEventListener("scroll", this.#handleScrollEvent);
this.#domParser = new ownerGlobal.DOMParser();
this.#innerWindowId = innerWindowId;
this.#sourceDocument = document;
this.#documentLanguage = documentLanguage;
this.#translationsCache = translationsCache;
this.#actorReportFirstVisibleChange = reportVisibleChange;
this.#targetScriptDirection =
Services.intl.getScriptDirection(targetLanguage);
this.#translationsMode = isFindBarOpen ? "content-eager" : "lazy";
this.#scheduler = new TranslationScheduler(
port,
this.#innerWindowId,
translationsCache,
requestNewPort
);
/**
* This selector runs to find child nodes that should be excluded. It should be
* basically the same implementation of `isExcludedNode`, but as a selector.
*
* @type {string}
*/
this.contentExcludedNodeSelector = [
// Use: [lang|=value] to match language codes.
//
// Per: https://developer.mozilla.org/en-US/docs/Web/CSS/Attribute_selectors
//
// The elements with an attribute name of attr whose value can be exactly
// value or can begin with value immediately followed by a hyphen, - (U+002D).
// It is often used for language subcode matches.
`[lang]:not([lang|="${this.#documentLanguage}"])`,
`[translate=no]`,
`.notranslate`,
`[contenteditable="true"]`,
`[contenteditable=""]`,
[...CONTENT_EXCLUDED_TAGS].join(","),
].join(",");
/**
* This selector runs to find elements that should be excluded from attribute translation.
*
* @type {string}
*/
this.attributeExcludedNodeSelector = [
// Exclude any element with translate="no", as it explicitly opts out of translation.
`[translate="no"]`,
// Exclude any element that is a descendant of a container marked with "notranslate" class.
`.notranslate`,
[...ATTRIBUTE_EXCLUDED_TAGS].join(","),
].join(",");
/**
* Define the type of IntersectionObserver for lazily prioritizing translations.
*
* @type {typeof IntersectionObserver}
*/
const DocumentIntersectionObserver = ownerGlobal.IntersectionObserver;
this.#intersectionObserverForContentTranslationsWithinViewport =
new DocumentIntersectionObserver(
entries => {
// The count of requests that we prevent from being sent to the TranslationsEngine.
let preventedCount = 0;
// The count of requests that we had to cancel from the TranslationScheduler.
// This is a subset of preventedCount.
let cancelledCount = 0;
// The count of nodes that entered this observer's proximity.
let enteredCount = 0;
// The count of nodes that exited this observer's proximity.
let exitedCount = 0;
const startTime = Cu.now();
for (const { target, isIntersecting } of entries) {
isIntersecting ? enteredCount++ : exitedCount++;
// The logic here does not care about `isIntersecting`, because it doesn't matter
// whether the target entered the boundary or exited the boundary. If the target
// entered, then it may need to be reprioritized to a higher priority. If it exited
// then the target may need to be reprioritized to a lower priority. In either case, we
// need to try to cancel any unscheduled requests, and resubmit them with a new priority.
const { preventedNodeSet, cancelledFromSchedulerCount } =
this.#preventUnscheduledContentTranslations(target);
if (preventedNodeSet) {
preventedCount += preventedNodeSet.size;
cancelledCount += cancelledFromSchedulerCount;
this.#queuedIntersectionPrunableContentElements.set(
target,
preventedNodeSet
);
}
}
ChromeUtils.addProfilerMarker(
"TranslationsDocument IntersectionObserver (Content)",
{ startTime, innerWindowId },
`Within Viewport: ${enteredCount} elements entered, ${exitedCount} exited, ` +
`prevented ${preventedCount} requests: ` +
`${preventedCount - cancelledCount} requests were never sent to the scheduler, ` +
`${cancelledCount} requests were cancelled from the scheduler.`
);
this.#contentWithinViewportInitialObservation.resolve();
this.#maybePrioritizeRequestsAndSubmitToScheduler();
},
{
root: null,
rootMargin: "0% 0% 0% 0%",
}
);
this.#intersectionObserverForContentTranslationsBeyondViewport =
new DocumentIntersectionObserver(
entries => {
// The count of requests that we prevent from being sent to the TranslationsEngine.
let preventedCount = 0;
// The count of requests that we had to cancel from the TranslationScheduler.
// This is a subset of preventedCount.
let cancelledCount = 0;
// The count of nodes that entered this observer's proximity.
let enteredCount = 0;
// The count of nodes that exited this observer's proximity.
let exitedCount = 0;
const startTime = Cu.now();
for (const { target, isIntersecting } of entries) {
if (isIntersecting) {
// The target has entered the boundary, so we will enqueue it for translation.
// Even if the target is also within the boundary of the in-viewport observer
// this call is idempotent and the target will be enqueued only one time.
enteredCount++;
this.#enqueueForIntersectionPrunableContentPrioritization(target);
} else {
// The target has exited the boundary of the beyond-viewport observer,
// which means that is certainly not within range of the in-viewport observer.
// We should simply cancel the translation at this point until a time when the
// user moves the viewport near to this target again.
exitedCount++;
if (this.#translationsMode === "lazy") {
// We only want to prevent content translations after they exit beyond-viewport
// proximity in "lazy" translations mode. In "content-eager" translation mode,
// we must ensure that all content is still translated regardless of spatial context.
const { preventedNodeSet, cancelledFromSchedulerCount } =
this.#preventUnscheduledContentTranslations(target);
if (preventedNodeSet) {
preventedCount += preventedNodeSet.size;
cancelledCount += cancelledFromSchedulerCount;
}
}
}
}
ChromeUtils.addProfilerMarker(
"TranslationsDocument IntersectionObserver (Content)",
{ startTime, innerWindowId },
`Extended Viewport: ${enteredCount} elements entered, ${exitedCount} exited, ` +
`prevented ${preventedCount} requests: ` +
`${preventedCount - cancelledCount} requests were never sent to the scheduler, ` +
`${cancelledCount} requests were cancelled from the scheduler.`
);
this.#contentBeyondViewportInitialObservation.resolve();
this.#maybePrioritizeRequestsAndSubmitToScheduler();
},
{
root: null,
rootMargin: "150% 50% 150% 50%",
}
);
this.#intersectionObserverForAttributeTranslationsWithinViewport =
new DocumentIntersectionObserver(
entries => {
// The count of requests that we prevent from being sent to the TranslationsEngine.
let preventedCount = 0;
// The count of requests that we had to cancel from the TranslationScheduler.
// This is a subset of preventedCount.
let cancelledCount = 0;
// The count of nodes that entered this observer's proximity.
let enteredCount = 0;
// The count of nodes that exited this observer's proximity.
let exitedCount = 0;
const startTime = Cu.now();
for (const { target, isIntersecting } of entries) {
isIntersecting ? enteredCount++ : exitedCount++;
// The logic here does not care about `isIntersecting`, because it doesn't matter
// whether the target entered the boundary or exited the boundary. If the target
// entered, then it may need to be reprioritized to a higher priority. If it exited
// then the target may need to be reprioritized to a lower priority. In either case, we
// need to try to cancel any unscheduled requests, and resubmit them with a new priority.
const { preventedAttributeSet, cancelledFromSchedulerCount } =
this.#preventUnscheduledAttributeTranslations(target);
if (preventedAttributeSet) {
preventedCount += preventedAttributeSet.size;
cancelledCount += cancelledFromSchedulerCount;
this.#queuedIntersectionPrunableAttributeElements.set(
target,
preventedAttributeSet
);
}
}
ChromeUtils.addProfilerMarker(
"TranslationsDocument IntersectionObserver (Attributes)",
{ startTime, innerWindowId },
`Within Viewport: ${enteredCount} elements entered, ${exitedCount} exited, ` +
`prevented ${preventedCount} requests: ` +
`${preventedCount - cancelledCount} requests were never sent to the scheduler, ` +
`${cancelledCount} requests were cancelled from the scheduler.`
);
this.#attributesWithinViewportInitialObservation.resolve();
this.#maybePrioritizeRequestsAndSubmitToScheduler();
},
{
root: null,
rootMargin: "0% 0% 0% 0%",
}
);
this.#intersectionObserverForAttributeTranslationsBeyondViewport =
new DocumentIntersectionObserver(
entries => {
// The count of requests that we prevent from being sent to the TranslationsEngine.
let preventedCount = 0;
// The count of requests that we had to cancel from the TranslationScheduler.
// This is a subset of preventedCount.
let cancelledCount = 0;
// The count of nodes that entered this observer's proximity.
let enteredCount = 0;
// The count of nodes that exited this observer's proximity.
let exitedCount = 0;
const startTime = Cu.now();
for (const { target, isIntersecting } of entries) {
if (isIntersecting) {
// The target has entered the boundary, so we will enqueue it for translation.
// Even if the target is also within the boundary of the in-viewport observer
// this call is idempotent and the target will be enqueued only one time.
enteredCount++;
this.#enqueueForIntersectionPrunableAttributePrioritization(
target
);
} else {
// The target has exited the boundary of the beyond-viewport observer,
// which means that is certainly not within range of the in-viewport observer.
// We should simply cancel the translation at this point until a time when the
// user moves the viewport near to this target again.
exitedCount++;
const { preventedAttributeSet, cancelledFromSchedulerCount } =
this.#preventUnscheduledAttributeTranslations(target);
if (preventedAttributeSet) {
preventedCount += preventedAttributeSet.size;
cancelledCount += cancelledFromSchedulerCount;
}
}
}
ChromeUtils.addProfilerMarker(
"TranslationsDocument IntersectionObserver (Attributes)",
{ startTime, innerWindowId },
`Extended Viewport: ${enteredCount} elements entered, ${exitedCount} exited, ` +
`prevented ${preventedCount} requests: ` +
`${preventedCount - cancelledCount} were never sent to the scheduler, ` +
`${cancelledCount} requests were cancelled from the scheduler.`
);
this.#attributesBeyondViewportInitialObservation.resolve();
this.#maybePrioritizeRequestsAndSubmitToScheduler();
},
{
root: null,
rootMargin: "100% 50% 100% 50%",
}
);
/**
* Define the type of the MutationObserver for editor type hinting.
*
* @type {typeof MutationObserver}
*/
const DocumentMutationObserver = ownerGlobal.MutationObserver;
this.#mutationObserver = new DocumentMutationObserver(
async mutationsList => {
await this.#waitForFirstIntersectionObservations();
const startTime = Cu.now();
// The count of attribute mutations in this observation.
let attributeCount = 0;
// The count of child-list mutations in this observation.
let childListCount = 0;
// The count of character-data mutations in this observation.
let characterDataCount = 0;
// The count of requests that we prevent from being sent to the TranslationsEngine.
let preventedCount = 0;
// The count of translation requests that had to be cancelled from the TranslationScheduler.
// This is a subset of preventedCount.
let cancelledFromSchedulerCount = 0;
// The count of translation requests that had to be cancelled from the TranslationsEngine.
// This is a subset of cancelledFromSchedulerCount.
let cancelledFromEngineCount = 0;
for (const mutation of mutationsList) {
if (!mutation.target) {
continue;
}
const pendingParentElement = this.#getPendingParentElementFromTarget(
mutation.target
);
if (pendingParentElement && mutation.type === "childList") {
const preventionResult =
this.#preventContentTranslation(pendingParentElement);
if (preventionResult.preventedCount) {
preventedCount += preventionResult.preventedCount;
cancelledFromSchedulerCount +=
preventionResult.cancelledFromSchedulerCount;
cancelledFromEngineCount +=
preventionResult.cancelledFromEngineCount;
// The node was still pending to be translated, and we cancelled it.
// Make sure it gets marked as mutated so it will be resubmitted.
this.#markNodeContentMutated(pendingParentElement);
// New nodes could have been added, make sure we can follow their shadow roots.
ensureExists(
this.#sourceDocument.ownerGlobal
).requestAnimationFrame(() => {
this.#addShadowRootsToObserver(pendingParentElement);
});
}
}
switch (mutation.type) {
case "childList": {
childListCount++;
for (const addedNode of mutation.addedNodes) {
if (!addedNode) {
continue;
}
this.#subdivideNodeForAttributeTranslations(addedNode);
this.#addShadowRootsToObserver(addedNode);
this.#markNodeContentMutated(addedNode);
}
for (const removedNode of mutation.removedNodes) {
if (!removedNode) {
continue;
}
const contentPreventionResult =
this.#preventContentTranslation(removedNode);
preventedCount += contentPreventionResult.preventedCount;
cancelledFromSchedulerCount +=
contentPreventionResult.cancelledFromSchedulerCount;
cancelledFromEngineCount +=
contentPreventionResult.cancelledFromEngineCount;
const selfOrParentElement =
asElement(removedNode) ?? asElement(removedNode.parentNode);
if (selfOrParentElement) {
deleteFromNestedMap(
this.#pendingContentTranslations,
selfOrParentElement,
removedNode
);
this.#removeFromContentIntersectionObservation(
selfOrParentElement,
removedNode
);
}
const element = asElement(removedNode);
if (element) {
const attributePreventionResult =
this.#preventAttributeTranslations(element);
preventedCount += attributePreventionResult.preventedCount;
cancelledFromSchedulerCount +=
attributePreventionResult.cancelledFromSchedulerCount;
cancelledFromEngineCount +=
attributePreventionResult.cancelledFromEngineCount;
this.#pendingAttributeTranslations.delete(element);
this.#removeFromAttributeIntersectionObservation(element);
}
}
break;
}
case "characterData": {
characterDataCount++;
const node = mutation.target;
if (node) {
// The mutated node will implement the CharacterData interface. The only
// node of this type that contains user-visible text is the `Text` node.
// Ignore others such as the comment node.
// https://developer.mozilla.org/en-US/docs/Web/API/CharacterData
if (node.nodeType === Node.TEXT_NODE) {
const preventionResult =
this.#preventContentTranslation(node);
preventedCount += preventionResult.preventedCount;
cancelledFromSchedulerCount +=
preventionResult.cancelledFromSchedulerCount;
cancelledFromEngineCount +=
preventionResult.cancelledFromEngineCount;
this.#markNodeContentMutated(node);
}
}
break;
}
case "attributes": {
attributeCount++;
const element = asElement(mutation.target);
if (element && mutation.attributeName) {
const { oldValue, attributeName } = mutation;
this.#maybeMarkElementAttributeMutated(
element,
attributeName,
oldValue
);
}
break;
}
default: {
break;
}
}
}
ChromeUtils.addProfilerMarker(
"TranslationsDocument MutationObserver",
{ startTime, innerWindowId },
`Observed ${childListCount + characterDataCount + attributeCount} mutations: ` +
`childList(${childListCount}), characterData(${characterDataCount}), attribute(${attributeCount}), ` +
`prevented ${preventedCount} requests: ` +
`${preventedCount - cancelledFromSchedulerCount - cancelledFromEngineCount} requests were never sent to the scheduler, ` +
`${cancelledFromSchedulerCount - cancelledFromEngineCount} requests were cancelled from the scheduler before being sent to the engine, ` +
`${cancelledFromEngineCount} requests were cancelled from the engine.`
);
this.#maybePrioritizeRequestsAndSubmitToScheduler();
}
);
this.#sourceDocument.addEventListener(
"visibilitychange",
this.#handleVisibilityChange
);
const addRootElements = () => {
const startTime = Cu.now();
this.#addRootElement(document.body);
this.#addRootElement(document.head);
this.#addRootElement(document.querySelector("title"));
ChromeUtils.addProfilerMarker(
"TranslationsDocument Initialize",
{ startTime, innerWindowId: this.#innerWindowId },
"Added initial root elements for translation"
);
if (this.#intersectionObservedContentElements.size === 0) {
// After the initial parse of the page, there are no intersection-observable
// content elements, so we must vacuously consider the first observation complete.
this.#contentWithinViewportInitialObservation.resolve();
this.#contentBeyondViewportInitialObservation.resolve();
}
if (this.#intersectionObservedAttributeElements.size === 0) {
// After the initial parse of the page, there are no intersection-observable
// attribute elements, so we must vacuously consider the first observation complete.
this.#attributesWithinViewportInitialObservation.resolve();
this.#attributesBeyondViewportInitialObservation.resolve();
}
if (
// The page may have content nodes that cannot be observed for intersection.
this.#queuedIntersectionExemptContentElements.size > 0 ||
// The page may have attribute elements that cannot be observed for intersection.
this.#queuedIntersectionExemptAttributeElements.size > 0
) {
// These are either elements such as <title> that will never intersect with the
// observers, or the find bar was open when Full-Page Translations was invoked,
// causing us to start in "content-eager" translations mode.
this.#maybePrioritizeRequestsAndSubmitToScheduler();
}
};
if (document.body) {
addRootElements();
} else {
// The TranslationsDocument was invoked before the DOM was ready, wait for
// it to be loaded.
document.addEventListener("DOMContentLoaded", addRootElements, {
once: true,
});
}
/** @type {HTMLElement} */ (document.documentElement).lang = targetLanguage;
lazy.console.log(
"Beginning to translate.",
// The defaultView may not be there on tests.
document.defaultView?.location.href
);
}
/**
* Enters content-eager translations mode, where all elements with translatable
* text content will be sent to the scheduler, but attribute translations will
* continue to be handled lazily based on viewport intersection proximity.
*/
async enterContentEagerTranslationsMode() {
lazy.console.info("Entering Content-Eager translations mode.");
this.#translationsMode = "content-eager";
await this.#waitForFirstIntersectionObservations();
if (this.#translationsMode !== "content-eager") {
// The translations mode changed while we were waiting for the
// first intersection observations: do not continue.
return;
}
for (const element of this.#intersectionObservedContentElements.keys()) {
this.#enqueueForIntersectionPrunableContentPrioritization(element);
}
// Most attributes are not searchable within the find bar, so we will not eagerly
// enqueue them to be sent to the scheduler. They will still be translated based
// on their proximity to the viewport.
this.#maybePrioritizeRequestsAndSubmitToScheduler();
}
/**
* Enters lazy translations mode, where all translations will be scheduled lazily
* based on viewport intersection proximity. Any pending requests that are not
* within viewport proximity will be cancelled.
*/
async enterLazyTranslationsMode() {
lazy.console.info("Entering Lazy translations mode.");
this.#translationsMode = "lazy";
await this.#waitForFirstIntersectionObservations();
if (this.#translationsMode !== "lazy") {
// The translations mode changed while we were waiting for the
// first intersection observations: do not continue.
return;
}
for (const element of this.#pendingContentTranslations.keys()) {
if (getNodeSpatialContext(element).viewportContext !== "within") {
this.#preventUnscheduledContentTranslations(element);
}
}
this.#maybePrioritizeRequestsAndSubmitToScheduler();
}
/**
* This is a test-only function that simulates intersection observation
* by running through all of the observed nodes and enqueuing them for
* prioritization if they are not already associated with a pending
* translation request.
*
* This function may only be used in testing contexts where the viewport
* is effectively non-existent, such that the intersection observers will
* not observe nodes as intended.
*
* @throws If this function is called outside of automated testing.
* @throws If the viewport is not zero-width or zero-height.
*/
simulateIntersectionObservationForNonPendingNodes() {
lazy.console.debug("Simulating intersection observations for test.");
if (!Cu.isInAutomation) {
// There is no scenario in which we should call this function outside of an
// automated test that requires it.
throw new Error(
"Attempt to manually simulate intersection observation outside of test."
);
}
const window = ensureExists(this.#sourceDocument.ownerGlobal);
const { visualViewport } = window;
if (visualViewport.width > 0 && visualViewport.height > 0) {
// The only time we should call this function is in test cases where the
// intersection observers will not function because a viewport dimension is zero.
// If a viewport dimension is not actually zero, then this was called in error.
throw new Error(
"Attempt to manually simulate intersection observation with a valid viewport."
);
}
// This should never be called as the first intersection observation.
// See #waitForFirstIntersectionObservation for an explanation why.
//
// The code is written so that the first intersection observation is
// guaranteed to be fulfilled when adding the initial root elements.
//
// If you are modifying this code, and this promise hangs, then the
// code has been modified incorrectly such that the first observation
// guarantee is no longer upheld.
/** @type {PromiseWithResolvers<void>} */
const firstIntersectionObservationsTimeout = Promise.withResolvers();
lazy.setTimeout(
() =>
firstIntersectionObservationsTimeout.reject(
new Error(
"The TranslationDocument's first intersection observations failed to resolve."
)
),
2000
);
Promise.race([
firstIntersectionObservationsTimeout.promise,
this.#waitForFirstIntersectionObservations(),
]).then(() => {
firstIntersectionObservationsTimeout.resolve();
for (const element of this.#intersectionObservedContentElements.keys()) {
if (!this.#pendingContentTranslations.has(element)) {
this.#enqueueForIntersectionPrunableContentPrioritization(element);
}
}
for (const element of this.#intersectionObservedAttributeElements.keys()) {
if (!this.#pendingAttributeTranslations.has(element)) {
this.#enqueueForIntersectionPrunableAttributePrioritization(element);
}
}
this.#maybePrioritizeRequestsAndSubmitToScheduler();
});
}
/**
* The first intersection observation is critical to the flow of the TranslationsDocument.
*
* When we add the root elements within the constructor, the entire DOM is parsed, and each
* translatable element on the page is registered with the intersection observers. As such,
* each observer's first observation will mostly contain nodes that are "exiting" proximity,
* since most of the element on the page will likely lie well beyond the viewport.
*
* To prevent unnecessary cancellations, race conditions, etc. many of the asynchronous
* callbacks within this file such as submitting nodes to the scheduler or handling mutated
* nodes must wait until the first intersection observation has occurred.
*/
async #waitForFirstIntersectionObservations() {
await Promise.all([
this.#contentWithinViewportInitialObservation.promise,
this.#contentBeyondViewportInitialObservation.promise,
this.#attributesWithinViewportInitialObservation.promise,
this.#attributesBeyondViewportInitialObservation.promise,
]);
}
/**
* Marks that the text content of the given node has mutated, both allowing and
* ensuring that the node will be rescheduled for translation, even if it had
* previously been translated.
*
* @param {Node} node
*/
#markNodeContentMutated(node) {
this.#processedContentNodes.delete(node);
this.#nodesWithMutatedContent.add(node);
const selfOrParentElement = asElement(node) ?? asElement(node.parentNode);
if (selfOrParentElement) {
deleteFromNestedMap(
this.#pendingContentTranslations,
selfOrParentElement,
node
);
if (this.#intersectionObservedContentElements.has(selfOrParentElement)) {
// If the mutated content belongs to an element that we are already observing
// for intersection, we must re-register it with the Beyond-Viewport intersection
// observer, which will ensure that any mutated elements within extended-viewport
// proximity will be re-enqueued for prioritization when the next observer cycle runs.
this.#intersectionObserverForContentTranslationsBeyondViewport.unobserve(
selfOrParentElement
);
this.#intersectionObserverForContentTranslationsBeyondViewport.observe(
selfOrParentElement
);
}
}
this.#ensureMutationUpdateCallbackIsRegistered();
}
/**
* Marks that the given element's attribute has been mutated, only if that attribute
* is translatable for that element, both allowing and ensuring that the attribute will
* be rescheduled for translation, even if it had previously been translated.
*
* @param {Element} element
* @param {string} attributeName
* @param {string?} oldValue
*/
#maybeMarkElementAttributeMutated(element, attributeName, oldValue) {
const newValue = element.getAttribute(attributeName);
if (!newValue) {
// The element no longer has a value for this attribute.
return;
}
if (oldValue === newValue) {
// The new attribute value is exactly the same as the old value.
return;
}
if (
this.#translationsCache.isAlreadyTranslated(newValue, /* isHTML */ false)
) {
// We know that the new attribute value is already text in the target language.
return;
}
if (!isAttributeTranslatable(element, attributeName)) {
// The given attribute is not translatable for this element.
return;
}
let mutatedAttributes = this.#elementsWithMutatedAttributes.get(element);
if (!mutatedAttributes) {
mutatedAttributes = new Set();
this.#elementsWithMutatedAttributes.set(element, mutatedAttributes);
}
mutatedAttributes.add(attributeName);
deleteFromNestedMap(
this.#pendingAttributeTranslations,
element,
attributeName
);
if (this.#intersectionObservedAttributeElements.has(element)) {
// If the mutated attribute belongs to an element that we are already observing
// for intersection, we must re-register it with the Beyond-Viewport intersection
// observer, which will ensure that any mutated elements within extended-viewport
// proximity will be re-enqueued for prioritization when the next observer cycle runs.
this.#intersectionObserverForAttributeTranslationsBeyondViewport.unobserve(
element
);
this.#intersectionObserverForAttributeTranslationsBeyondViewport.observe(
element
);
}
this.#ensureMutationUpdateCallbackIsRegistered();
}
/**
* Ensures that all nodes that have been picked up by the mutation observer
* are processed, prioritized and sent to the scheduler to re translated.
*/
#ensureMutationUpdateCallbackIsRegistered() {
if (this.#hasPendingMutatedNodesCallback) {
// A callback has already been registered to update mutated nodes.
return;
}
if (
this.#nodesWithMutatedContent.size === 0 &&
this.#elementsWithMutatedAttributes.size === 0
) {
// There are no mutated nodes to update.
return;
}
this.#hasPendingMutatedNodesCallback = true;
const ownerGlobal = ensureExists(this.#sourceDocument.ownerGlobal);
// Nodes can be mutated in a tight loop. To guard against the performance of re-translating nodes too frequently,
// we will batch the processing of mutated nodes into a double requestAnimationFrame.
ownerGlobal.requestAnimationFrame(() => {
ownerGlobal.requestAnimationFrame(async () => {
// We should not handle any mutations until the intersection observers have completed their first observations.
await this.#waitForFirstIntersectionObservations();
this.#hasPendingMutatedNodesCallback = false;
// The count of content translation requests will be 1:1 with the count of content-translation nodes.
const contentNodeCount = this.#nodesWithMutatedContent.size;
// Attribute translation requests have a 1:many relationship with their element, so we must increment manually.
const attributeElementCount = this.#elementsWithMutatedAttributes.size;
let attributeRequestCount = 0;
const startTime = Cu.now();
// Ensure the nodes are still alive.
const liveNodes = [];
for (const node of this.#nodesWithMutatedContent) {
if (isNodeDetached(node)) {
this.#nodesWithMutatedContent.delete(node);
} else {
liveNodes.push(node);
}
}
// Remove any nodes that are contained in another node.
for (let i = 0; i < liveNodes.length; i++) {
const node = liveNodes[i];
if (!this.#nodesWithMutatedContent.has(node)) {
continue;
}
for (let j = i + 1; j < liveNodes.length; j++) {
const otherNode = liveNodes[j];
if (!this.#nodesWithMutatedContent.has(otherNode)) {
continue;
}
if (node.contains(otherNode)) {
this.#nodesWithMutatedContent.delete(otherNode);
} else if (otherNode.contains(node)) {
this.#nodesWithMutatedContent.delete(node);
break;
}
}
}
for (const node of this.#nodesWithMutatedContent) {
this.#addShadowRootsToObserver(node);
this.#subdivideNodeForContentTranslations(node);
}
this.#nodesWithMutatedContent.clear();
for (const [
element,
attributes,
] of this.#elementsWithMutatedAttributes.entries()) {
attributeRequestCount += attributes.size;
this.#maybeObserveElementForAttributePrioritization(
element,
attributes
);
}
this.#elementsWithMutatedAttributes.clear();
ChromeUtils.addProfilerMarker(
"TranslationsDocument MutationObserver",
{ startTime, innerWindowId: this.#innerWindowId },
`Handled content mutations for ${contentNodeCount} nodes, and ` +
`${attributeRequestCount} attribute mutations among ${attributeElementCount} elements.`
);
this.#maybePrioritizeRequestsAndSubmitToScheduler();
});
});
}
/**
* If a pending node contains or is the target node, return that pending node.
*
* @param {Node} target
*
* @returns {Element | undefined}
*/
#getPendingParentElementFromTarget(target) {
const pendingParent = this.#nodeToPendingParent.get(target);
const pendingParentElement = asElement(pendingParent);
if (
pendingParentElement &&
this.#pendingContentTranslations.has(pendingParentElement)
) {
return pendingParentElement;
}
return undefined;
}
/**
* Attempts to cancel a translation for the given node, even if the relevant
* translation request has already been sent to the TranslationsEngine.
*
* This function is primarily used by the mutation observer, when we are certain
* that content has changed, and the previous translation is no longer valid.
*
* For a more conservative cancellation that will only cancel a translation
* request before it has been sent to the TranslationsEngine, use the
* `#maybePreventUnscheduledContentTranslation` function.
*
* @param {Node} node
*
* @returns {{
* preventedCount: number,
* cancelledFromSchedulerCount: number,
* cancelledFromEngineCount: number,
* }}
*/
#preventContentTranslation(node) {
const textNode = asTextNode(node);
const parentElement = asElement(node.parentNode);
if (textNode && parentElement) {
const pendingNodes = this.#pendingContentTranslations.get(parentElement);
const translationId = pendingNodes?.get(textNode);
if (translationId) {
const { didPrevent, didCancelFromScheduler, didCancelFromEngine } =
this.#scheduler.preventSingleTranslation(translationId);
if (didPrevent) {
return {
preventedCount: Number(didPrevent),
cancelledFromSchedulerCount: Number(didCancelFromScheduler),
cancelledFromEngineCount: Number(didCancelFromEngine),
};
}
}
}
const element = asElement(node);
if (!element) {
return {
preventedCount: 0,
cancelledFromSchedulerCount: 0,
cancelledFromEngineCount: 0,
};
}
let preventedCount = 0;
let cancelledFromSchedulerCount = 0;
let cancelledFromEngineCount = 0;
const preventionResult =
this.#preventUnscheduledContentTranslations(element);
if (preventionResult.preventedNodeSet) {
// We were able to prevent these content translations before
// they were sent to the TranslationsEngine.
preventedCount += preventionResult.preventedNodeSet.size;
cancelledFromSchedulerCount +=
preventionResult.cancelledFromSchedulerCount;
}
const pendingNodes = this.#pendingContentTranslations.get(element);
if (!pendingNodes) {
// No pending content translations were found for this element.
// They either already completed, or never existed.
return {
preventedCount,
cancelledFromSchedulerCount,
cancelledFromEngineCount: 0,
};
}
for (const [pendingNode, translationId] of pendingNodes) {
// eslint-disable-next-line no-shadow
const { didPrevent, didCancelFromScheduler, didCancelFromEngine } =
this.#scheduler.preventSingleTranslation(translationId);
if (didPrevent) {
pendingNodes.delete(pendingNode);
}
preventedCount += Number(didPrevent);
cancelledFromSchedulerCount += Number(didCancelFromScheduler);
cancelledFromEngineCount += Number(didCancelFromEngine);
}
if (pendingNodes.size === 0) {
removeMozTranslationsIds(element);
this.#pendingContentTranslations.delete(element);
}
return {
preventedCount,
cancelledFromSchedulerCount,
cancelledFromEngineCount,
};
}
/**
* Attempts to cancel all attribute translations for the given node, even if the
* relevant translation requests have already been sent to the TranslationsEngine.
*
* This function is primarily used by the mutation observer, when we are certain
* that content has changed, and the previous translation is no longer valid.
*
* For a more conservative cancellation that will only cancel translation requests
* before they have been sent to the TranslationsEngine, use the
* `#maybePreventUnscheduledAttributeTranslations` function.
*
* @param {Element} element
*
* @returns {{
* preventedCount: number,
* cancelledFromSchedulerCount: number,
* cancelledFromEngineCount: number,
* }}
*/
#preventAttributeTranslations(element) {
const preventionResult =
this.#preventUnscheduledAttributeTranslations(element);
let preventedCount = 0;
let cancelledFromSchedulerCount = 0;
let cancelledFromEngineCount = 0;
if (preventionResult.preventedAttributeSet) {
// We were able to prevent these attributes translations before
// they were send to the TranslationsEngine.
preventedCount += preventionResult.preventedAttributeSet.size;
cancelledFromSchedulerCount +=
preventionResult.cancelledFromSchedulerCount;
}
const pendingAttributes = this.#pendingAttributeTranslations.get(element);
if (!pendingAttributes) {
// No pending attribute translations were found for this element.
// They either already completed, or never existed.
return {
preventedCount,
cancelledFromSchedulerCount,
cancelledFromEngineCount: 0,
};
}
for (const [attributeName, translationId] of pendingAttributes) {
// eslint-disable-next-line no-shadow
const { didPrevent, didCancelFromScheduler, didCancelFromEngine } =
this.#scheduler.preventSingleTranslation(translationId);
if (didPrevent) {
pendingAttributes.delete(attributeName);
}
preventedCount += Number(didPrevent);
cancelledFromSchedulerCount += Number(didCancelFromScheduler);
cancelledFromEngineCount += Number(didCancelFromEngine);
}
if (pendingAttributes.size === 0) {
this.#pendingAttributeTranslations.delete(element);
}
return {
preventedCount,
cancelledFromSchedulerCount,
cancelledFromEngineCount,
};
}
/**
* Adds an element to a queue from which it will eventually be prioritized
* and submitted to the scheduler for attribute translation.
*
* The queue is intersection-exempt, meaning that the intersection observers
* will not be able to remove this element from the queue before it is prioritized
* and submitted to the scheduler.
*
* @param {Element} element
*/
#enqueueForIntersectionPrunableAttributePrioritization(element) {
if (this.#queuedIntersectionPrunableAttributeElements.has(element)) {
return;
}
const translatableAttributes =
this.#intersectionObservedAttributeElements.get(element);
if (!translatableAttributes) {
lazy.console.warn(`
Attempted to enqueue an element for attribute translation,
but no translatable attributes were registered with the element.
`);
return;
}
let queuedAttributes =
this.#queuedIntersectionPrunableAttributeElements.get(element);
if (queuedAttributes) {
for (const attributeName of translatableAttributes) {
queuedAttributes.add(attributeName);
}
} else {
queuedAttributes = translatableAttributes;
this.#queuedIntersectionPrunableAttributeElements.set(
element,
translatableAttributes
);
}
}
/**
* Adds an element to a queue from which it will eventually be prioritized
* and submitted to the scheduler for attribute translation.
*
* The queue is intersection-exempt, meaning that the intersection observers
* will not be able to remove this element from the queue before it is prioritized
* and submitted to the scheduler.
*
* @param {Element} element
*/
#maybeEnqueueForIntersectionExemptAttributePrioritization(element) {
if (this.#queuedIntersectionExemptAttributeElements.has(element)) {
return;
}
const translatableAttributes =
this.#intersectionObservedAttributeElements.get(element) ??
this.#getTranslatableAttributes(element);
if (!translatableAttributes) {
return;
}
let queuedAttributes =
this.#queuedIntersectionExemptAttributeElements.get(element);
if (queuedAttributes) {
for (const attributeName of translatableAttributes) {
queuedAttributes.add(attributeName);
}
} else {
queuedAttributes = translatableAttributes;
this.#queuedIntersectionExemptAttributeElements.set(
element,
translatableAttributes
);
}
}
/**
* Retrieves an array of translatable attributes within the given node.
*
* If the node is deemed to be excluded from translation, no attributes
* will be returned even if they are otherwise translatable.
*
* @see TRANSLATABLE_ATTRIBUTES
* @see TranslationsDocument.contentExcludedNodeSelector
*
* @param {Node} node - The node from which to retrieve translatable attributes.
*
* @returns {null | Set<string>} - The translatable attribute names from the given node.
*/
#getTranslatableAttributes(node) {
const element = asHTMLElement(node);
if (!element) {
// We only translate attributes on element node types.
return null;
}
if (element.closest(this.attributeExcludedNodeSelector)) {
// Either this node or an ancestor is explicitly excluded from translations, so we should not translate.
return null;
}
let attributes = null;
for (const attribute of TRANSLATABLE_ATTRIBUTES.keys()) {
if (isAttributeTranslatable(node, attribute)) {
if (!attributes) {
attributes = new Set();
}
attributes.add(attribute);
}
}
return attributes;
}
/**
* Start and stop translation as the page is shown. For instance, this will
* transition into "hidden" when the user tabs away from a document.
*/
#handleVisibilityChange = () => {
if (this.#sourceDocument.visibilityState === "visible") {
this.#scheduler.onShowPage();
} else {
ChromeUtils.addProfilerMarker(
"TranslationsDocument Pause",
{ innerWindowId: this.#innerWindowId },
"Pausing translations and discarding the port"
);
this.#scheduler.onHidePage();
}
};
/**
* Remove any dangling event handlers.
*/
destroy() {
this.#scheduler.destroy();
this.#stopAllObservers();
if (!Cu.isDeadWrapper(this.#sourceDocument)) {
this.#sourceDocument.removeEventListener(
"visibilitychange",
this.#handleVisibilityChange
);
const window = this.#sourceDocument.ownerGlobal;
if (window) {
window.removeEventListener("scroll", this.#handleScrollEvent);
}
}
}
/**
* Helper function for adding a new root to the mutation
* observer.
*
* @param {Node} root
*/
#observeNewRoot(root) {
this.#rootNodes.add(root);
this.#mutationObserver.observe(root, MUTATION_OBSERVER_OPTIONS);
}
/**
* Shadow roots are used in custom elements, and are a method for encapsulating
* markup. Normally only "open" shadow roots can be accessed, but in privileged
* contexts, they can be traversed using the ChromeOnly property openOrClosedShadowRoot.
*
* @param {Node} node
*/
#addShadowRootsToObserver(node) {
const { ownerDocument } = node;
if (!ownerDocument) {
return;
}
const nodeIterator = ownerDocument.createTreeWalker(
node,
NodeFilter.SHOW_ELEMENT,
currentNode =>
getShadowRoot(currentNode)
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_SKIP
);
/** @type {Node | null} */
let currentNode;
while ((currentNode = nodeIterator.nextNode())) {
// Only shadow hosts are accepted nodes
const shadowRoot = ensureExists(getShadowRoot(currentNode));
if (!this.#rootNodes.has(shadowRoot)) {
this.#observeNewRoot(shadowRoot);
}
// A shadow root may contain other shadow roots, recurse into them.
this.#addShadowRootsToObserver(shadowRoot);
}
}
/**
* Add a new element to start translating. This root is tracked for mutations and
* kept up to date with translations. This will be the body element and title tag
* for the document.
*
* @param {Node | null | undefined} node
*/
#addRootElement(node) {
if (!node) {
return;
}
const element = asHTMLElement(node);
if (!element) {
return;
}
if (this.#rootNodes.has(element)) {
// Exclude nodes that are already targeted.
return;
}
this.#rootNodes.add(element);
if (element.nodeName === "TITLE") {
// The <title> node is special, in that it will never intersect with the viewport,
// so we must explicitly enqueue it for translation here.
this.#enqueueForIntersectionExemptContentPrioritization(element);
this.#maybeEnqueueForIntersectionExemptAttributePrioritization(element);
this.#mutationObserver.observe(element, MUTATION_OBSERVER_OPTIONS);
return;
}
if (element.nodeName === "HEAD") {
// The <head> element is not considered for content translations, but it may contain <meta>
// elements that may have translatable attributes. This is a special case where we should
// explicitly check for <meta> elements within the <head> and eagerly enqueue them, since
// they will not intersect with the intersection observers.
for (const metaElement of element.querySelectorAll("meta")) {
this.#maybeEnqueueForIntersectionExemptAttributePrioritization(
metaElement
);
}
this.#mutationObserver.observe(element, MUTATION_OBSERVER_OPTIONS);
return;
}
const contentStartTime = Cu.now();
this.#subdivideNodeForContentTranslations(element);
ChromeUtils.addProfilerMarker(
"TranslationsDocument Add Root",
{ startTime: contentStartTime, innerWindowId: this.#innerWindowId },
`Subdivided new root "${node.nodeName}" for content translations`
);
const attributeStartTime = Cu.now();
this.#subdivideNodeForAttributeTranslations(element);
ChromeUtils.addProfilerMarker(
"TranslationsDocument Add Root",
{ startTime: attributeStartTime, innerWindowId: this.#innerWindowId },
`Subdivided new root "${node.nodeName}" for attribute translations`
);
this.#mutationObserver.observe(element, MUTATION_OBSERVER_OPTIONS);
this.#addShadowRootsToObserver(element);
}
/**
* Add qualified nodes to be observed for intersection or enqueued for
* translation by recursively walking through the DOM tree of nodes,
* including elements in the Shadow DOM.
*
* @param {Node} node
*/
#processSubdivide(node) {
const { ownerDocument } = node;
if (!ownerDocument) {
return;
}
// This iterator will contain each node that has been subdivided enough to be translated.
const nodeIterator = ownerDocument.createTreeWalker(
node,
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT,
this.#determineTranslationStatusForUnprocessedNodes
);
let currentNode;
while ((currentNode = nodeIterator.nextNode())) {
const shadowRoot = getShadowRoot(currentNode);
if (shadowRoot) {
this.#processSubdivide(shadowRoot);
} else {
this.#observeOrEnqueueNodeForContentPrioritization(currentNode);
}
}
}
/**
* Start walking down through a node's subtree and decide which nodes to queue for
* content translation. This first node could be the root nodes of the DOM, such as
* the document body, or the title element, or it could be a mutation target.
*
* The nodes go through a process of subdivision until an appropriate sized chunk
* of inline text can be found.
*
* @param {Node} node
*/
#subdivideNodeForContentTranslations(node) {
if (!this.#rootNodes.has(node)) {
// This is a non-root node, which means it came from a mutation observer.
// This new node could be a host element for shadow tree
const shadowRoot = getShadowRoot(node);
if (shadowRoot && !this.#rootNodes.has(shadowRoot)) {
this.#observeNewRoot(shadowRoot);
} else {
// Ensure that it is a valid node to translate by checking all of its ancestors.
for (let parent of getAncestorsIterator(node)) {
// Parent is ShadowRoot. We can stop here since this is
// the top ancestor of the shadow tree.
if (parent.containingShadowRoot == parent) {
break;
}
if (
this.#determineTranslationStatus(parent) ===
NodeStatus.NOT_TRANSLATABLE
) {
return;
}
}
}
}
switch (this.#determineTranslationStatusForUnprocessedNodes(node)) {
case NodeStatus.NOT_TRANSLATABLE: {
// This node is rejected as it shouldn't be translated.
return;
}
// SHADOW_HOST and READY_TO_TRANSLATE both map to FILTER_ACCEPT
case NodeStatus.SHADOW_HOST:
case NodeStatus.READY_TO_TRANSLATE: {
const shadowRoot = getShadowRoot(node);
if (shadowRoot) {
this.#processSubdivide(shadowRoot);
} else {
// This node is ready for translating, and doesn't need to be subdivided. There
// is no reason to run the TreeWalker, it can be directly submitted for
// translation.
this.#observeOrEnqueueNodeForContentPrioritization(node);
}
break;
}
case NodeStatus.SUBDIVIDE_FURTHER: {
// This node may be translatable, but it needs to be subdivided into smaller
// pieces. Create a TreeWalker to walk the subtree, and find the subtrees/nodes
// that contain enough inline elements to send to be translated.
this.#processSubdivide(node);
break;
}
}
}
/**
* Uses query selectors to locate all of the elements that have translatable attributes,
* then registers those elements with the intersection observers for their attributes
* to be translated when observed.
*
* @param {Node} node
*/
#subdivideNodeForAttributeTranslations(node) {
const element = asElement(node);
if (!element) {
// We only translate attributes on Element type nodes.
return;
}
this.#maybeObserveElementForAttributePrioritization(element);
const childElementsWithTranslatableAttributes = element.querySelectorAll(
TRANSLATABLE_ATTRIBUTES_SELECTOR
);
for (const childElement of childElementsWithTranslatableAttributes) {
this.#maybeObserveElementForAttributePrioritization(childElement);
}
}
/**
* Test whether this is an element we do not want to translate. These are things like
* <code> elements, elements with a different "lang" attribute, and elements that
* have a `translate=no` attribute.
*
* @param {Node} node
*/
#isExcludedNode(node) {
// Property access be expensive, so destructure required properties so they are
// not accessed multiple times.
const { nodeType } = node;
if (nodeType === Node.TEXT_NODE) {
// Text nodes are never excluded.
return false;
}
const element = asElement(node);
if (!element) {
// Only elements and and text nodes should be considered.
return true;
}
const { nodeName } = element;
if (CONTENT_EXCLUDED_TAGS.has(nodeName.toUpperCase())) {
// SVG tags can be lowercased, so ensure everything is uppercased.
// This is an excluded tag.
return true;
}
if (!this.#matchesDocumentLanguage(element)) {
// Exclude nodes that don't match the sourceLanguage.
return true;
}
if (element.getAttribute("translate") === "no") {
// This element has a translate="no" attribute.
// https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/translate
return true;
}
if (element.classList.contains("notranslate")) {
// Google Translate skips translations if the classList contains "notranslate"
// https://cloud.google.com/translate/troubleshooting
return true;
}
if (asHTMLElement(element)?.isContentEditable) {
// This field is editable, and so exclude it similar to the way that form input
// fields are excluded.
return true;
}
return false;
}
/**
* Runs `determineTranslationStatus`, but only on unprocessed nodes.
*
* @param {Node} node
*
* @returns {number} - One of the NodeStatus values.
*/
#determineTranslationStatusForUnprocessedNodes = node => {
if (this.#processedContentNodes.has(node)) {
// Skip nodes that have already been processed.
return NodeStatus.NOT_TRANSLATABLE;
}
return this.#determineTranslationStatus(node);
};
/**
* Determines if a node should be submitted for translation, not translatable, or if
* it should be subdivided further. It doesn't check if the node has already been
* processed.
*
* The return result works as a TreeWalker NodeFilter as well.
*
* @param {Node} node
*
* @returns {number} - One of the `NodeStatus` values. See that object
* for documentation. These values match the filters for the TreeWalker.
* These values also work as a `NodeFilter` value.
*/
#determineTranslationStatus(node) {
if (getShadowRoot(node)) {
return NodeStatus.SHADOW_HOST;
}
if (this.#isExcludedNode(node)) {
// This is an explicitly excluded node.
return NodeStatus.NOT_TRANSLATABLE;
}
if (
nodeOrParentIncludesItself(
node,
this.#intersectionObservedContentElements
)
) {
// This node or its parent is already being observed for translation: reject it.
return NodeStatus.NOT_TRANSLATABLE;
}
if (
containsExcludedNode(node, this.contentExcludedNodeSelector) &&
!hasNonWhitespaceTextNodes(node)
) {
// Skip this node, and dig deeper into its tree to cut off smaller pieces to translate.
return NodeStatus.SUBDIVIDE_FURTHER;
}
if (nodeNeedsSubdividing(node)) {
// Skip this node, and dig deeper into its tree to cut off smaller pieces
// to translate. It is presumed to be a wrapper of block elements.
return NodeStatus.SUBDIVIDE_FURTHER;
}
if (!node.textContent?.trim().length) {
// Do not use subtrees that are empty of text. This textContent call is fairly expensive.
return !node.hasChildNodes()
? NodeStatus.NOT_TRANSLATABLE
: NodeStatus.SUBDIVIDE_FURTHER;
}
// This node can be treated as entire block to submit for translation.
return NodeStatus.READY_TO_TRANSLATE;
}
/**
* Adds an element to a queue from which it will eventually be prioritized
* and submitted to the scheduler for content translation.
*
* The queue is intersection-exempt, meaning that the intersection observers
* will not be able to remove this element from the queue before it is prioritized
* and submitted to the scheduler.
*
* @param {Element} element
*/
#enqueueForIntersectionPrunableContentPrioritization(element) {
if (this.#queuedIntersectionPrunableContentElements.has(element)) {
return;
}
const nodeSet =
this.#intersectionObservedContentElements.get(element) ??
new Set([element]);
let queuedNodes =
this.#queuedIntersectionPrunableContentElements.get(element);
if (queuedNodes) {
for (const node of nodeSet) {
queuedNodes.add(node);
}
} else {
queuedNodes = nodeSet;
this.#queuedIntersectionPrunableContentElements.set(element, queuedNodes);
}
}
/**
* Adds an element to a queue from which it will eventually be prioritized
* and submitted to the scheduler for attribute translation.
*
* The queue is intersection-exempt, meaning that the intersection observers
* will not be able to remove this element from the queue before it is prioritized
* and submitted to the scheduler.
*
* @param {Element} element
*/
#enqueueForIntersectionExemptContentPrioritization(element) {
if (this.#queuedIntersectionExemptContentElements.has(element)) {
return;
}
const nodeSet =
this.#intersectionObservedContentElements.get(element) ??
new Set([element]);
let queuedNodes =
this.#queuedIntersectionExemptContentElements.get(element);
if (queuedNodes) {
for (const node of nodeSet) {
queuedNodes.add(node);
}
} else {
queuedNodes = nodeSet;
this.#queuedIntersectionExemptContentElements.set(element, queuedNodes);
}
}
/**
* Submit each translatable attribute for the given element to the TranslationScheduler
* to have the attribute text translated.
*
* @param {number} priority
* @param {Element} element
* @param {Set<string>} attributeSet
*/
#submitForAttributeTranslation(priority, element, attributeSet) {
for (const attribute of attributeSet) {
const sourceText = element.getAttribute(attribute);
if (!sourceText?.trim().length) {
continue;
}
const translationId = this.#lastTranslationId++;
let pendingAttributes = this.#pendingAttributeTranslations.get(element);
if (!pendingAttributes) {
pendingAttributes = new Map();
this.#pendingAttributeTranslations.set(element, pendingAttributes);
}
pendingAttributes.set(attribute, translationId);
this.#tryTranslate(
element,
sourceText,
false /*isHTML*/,
translationId,
priority
)
.then(translation => {
if (translation) {
this.#registerElementForAttributeTranslationUpdate(
element,
translation,
attribute,
translationId
);
} else if (
pendingAttributes.get(attribute) === translationId &&
this.#pendingAttributeTranslations.get(element) ===
pendingAttributes
) {
// There is nothing to update for this translation request.
pendingAttributes.delete(attribute);
if (pendingAttributes.size === 0) {
this.#pendingAttributeTranslations.delete(element);
this.#removeFromAttributeIntersectionObservation(
element,
attribute
);
}
}
})
.catch(error => {
lazy.console.error(error);
if (
pendingAttributes.get(attribute) === translationId &&
this.#pendingAttributeTranslations.get(element) ===
pendingAttributes
) {
// There is nothing to update for this translation request.
pendingAttributes.delete(attribute);
if (pendingAttributes.size === 0) {
this.#pendingAttributeTranslations.delete(element);
this.#removeFromAttributeIntersectionObservation(
element,
attribute
);
}
}
});
}
}
/**
* Ensures that elements with completed attribute translation requests will be updated.
*
* This may happen immediately if there are very few active translation requests.
*
* If there are many active translation requests, we will register a callback to the
* event loop to update a batch of elements all at once.
*
* This distinction is made because updating any content within the DOM requires
* pausing the mutation observer, and that cost adds up if you do it individually
* for every translation request that completes.
*
* @param {Element} element
* @param {string} translation
* @param {string} attribute
* @param {number} translationId
*/
#registerElementForAttributeTranslationUpdate(
element,
translation,
attribute,
translationId
) {
// Add the nodes to be populated with the next translation update.
this.#elementsThatNeedAttributeUpdates.add({
element,
translation,
attribute,
translationId,
});
if (this.#scheduler.isWithinFinalBatches()) {
// The scheduler is within the final batches of requests that it will send, so we will eagerly update
// instead of registering a callback to update several nodes in a batch. This is particularly important
// for cases such as translating a YouTube video with closed captions. When the rest of the viewport
// is already translated, and a new request for a caption comes in, that will be the only request that
// the scheduler is reacting to, and we want to update the caption text as soon as we possibly can.
this.#updateElementsWithAttributeTranslations();
} else if (!this.#hasPendingUpdateAttributesCallback) {
// Schedule a callback on the event loop to update a batch elements with completed attribute translations.
this.#hasPendingUpdateAttributesCallback = true;
lazy.setTimeout(
this.#updateElementsWithAttributeTranslations,
DOM_UPDATE_INTERVAL_MS
);
} else {
// An update has been previously scheduled, do nothing here.
}
}
/**
* Updates all elements that have completed attribute translation requests.
*
* This function is intentionally written as a lambda so that it can be passed as a callback without the
* need to explicitly bind `this` to the function object.
*/
#updateElementsWithAttributeTranslations = () => {
this.#hasPendingUpdateAttributesCallback = false;
let staleRequestCount = 0;
let detachedElementCount = 0;
let updatedAttributeCount = 0;
const startTime = Cu.now();
// Stop the mutations so that the updates won't trigger observations.
this.#pauseMutationObserverAndThen(() => {
for (const entry of this.#elementsThatNeedAttributeUpdates) {
const { element, translation, attribute, translationId } = entry;
const eligibility = this.#determineElementAttributeUpdateEligibility(
element,
attribute,
translationId
);
if (eligibility === "stale") {
// A new request has been submitted for this node. This one is no longer relevant.
staleRequestCount++;
continue;
} else if (eligibility === "detached") {
// This element is detached from the DOM: there is no point in updating it.
detachedElementCount++;
} else {
updatedAttributeCount++;
element.setAttribute(attribute, translation);
}
deleteFromNestedMap(
this.#queuedIntersectionPrunableAttributeElements,
element,
attribute
);
deleteFromNestedMap(
this.#queuedIntersectionExemptAttributeElements,
element,
attribute
);
deleteFromNestedMap(
this.#pendingAttributeTranslations,
element,
attribute
);
this.#removeFromAttributeIntersectionObservation(element, attribute);
}
this.#elementsThatNeedAttributeUpdates.clear();
});
ChromeUtils.addProfilerMarker(
"TranslationsDocument Update (Attributes)",
{ startTime, innerWindowId: this.#innerWindowId },
"Attribute Update Request: " +
`${staleRequestCount} stale requests, ${detachedElementCount} detached elements, ` +
`${updatedAttributeCount} attributes updated.`
);
};
/**
* Submit a node to the TranslationScheduler to have its text content translated.
*
* @param {number} priority
* @param {Element} observableElement
* @param {Set<Node>} nodeSet
*/
#submitForContentTranslation(priority, observableElement, nodeSet) {
for (const targetNode of nodeSet) {
// Give each element an id that gets passed through the translation so it can be reunited later on.
if (observableElement === targetNode) {
/** @type {Array<Element>} */
const elements = observableElement.querySelectorAll("*");
elements.forEach((el, i) => {
const dataset = getDataset(el);
if (dataset) {
dataset.mozTranslationsId = String(i);
}
});
}
/** @type {string} */
let sourceText;
/** @type {boolean} */
let isHTML;
if (
// This node is a text node, therefore it cannot be an HTML translation.
asTextNode(targetNode) ||
// When an element has no child elements and its textContent is exactly
// equal to its innerHTML, then it is safe to treat as a text translation.
(observableElement.childElementCount === 0 &&
observableElement.textContent === observableElement.innerHTML)
) {
sourceText = targetNode.textContent ?? "";
isHTML = false;
} else {
sourceText = /** @type {string} */ (observableElement.innerHTML);
isHTML = true;
}
if (sourceText.trim().length === 0) {
return;
}
const translationId = this.#lastTranslationId++;
let pendingNodes =
this.#pendingContentTranslations.get(observableElement);
if (!pendingNodes) {
pendingNodes = new Map();
this.#pendingContentTranslations.set(observableElement, pendingNodes);
}
pendingNodes.set(targetNode, translationId);
this.#walkNodeToPendingParent(targetNode);
this.#tryTranslate(
targetNode,
sourceText,
isHTML,
translationId,
priority
)
.then(translation => {
if (translation) {
this.#registerElementForContentTranslationUpdate(
observableElement,
targetNode,
translation,
translationId
);
} else if (
pendingNodes.get(targetNode) === translationId &&
this.#pendingContentTranslations.get(observableElement) ===
pendingNodes
) {
// There is nothing to update for this translation request.
pendingNodes.delete(targetNode);
if (pendingNodes.size === 0) {
this.#pendingContentTranslations.delete(observableElement);
this.#removeFromContentIntersectionObservation(
observableElement,
targetNode
);
}
}
})
.catch(error => {
lazy.console.error(error);
if (
pendingNodes.get(targetNode) === translationId &&
this.#pendingContentTranslations.get(observableElement) ===
pendingNodes
) {
pendingNodes.delete(targetNode);
if (pendingNodes.size === 0) {
this.#pendingContentTranslations.delete(observableElement);
this.#removeFromContentIntersectionObservation(
observableElement,
targetNode
);
}
}
});
}
}
/**
* Walks the nodes to set the relationship between the node to the pending parent node.
* This solves a performance problem with pages with large subtrees and lots of mutation.
* For instance on YouTube it took 838ms to `getPendingParentElementFromTarget` by going
* through all pending translations. Caching this relationship reduced it to 26ms to walk
* it while adding the pending translation.
*
* On a page like the Wikipedia "Cat" entry, there are not many mutations, and this
* adds 4ms of additional wasted work.
*
* @param {Node} pendingParent
*/
#walkNodeToPendingParent(pendingParent) {
this.#nodeToPendingParent.set(pendingParent, pendingParent);
const { ownerDocument } = pendingParent;
if (!ownerDocument) {
return;
}
const nodeIterator = ownerDocument.createTreeWalker(
pendingParent,
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT
);
/** @type {Node | null} */
let node;
while ((node = nodeIterator.nextNode())) {
this.#nodeToPendingParent.set(node, pendingParent);
}
}
/**
* Attempts to translate the given text for the given node.
*
* If we already have a cached result for this translation,
* then we will resolve immediately and never send the request
* to the TranslationsEngine.
*
* The request may also fail or be cancelled before it completes.
*
* @param {Node} node
* @param {string} sourceText
* @param {boolean} isHTML
* @param {number} translationId
* @param {number} priority
*
* @returns {Promise<string | null>}
*/
async #tryTranslate(node, sourceText, isHTML, translationId, priority) {
if (this.#translationsCache.isAlreadyTranslated(sourceText, isHTML)) {
// The cache indicates that the text being sent to translate is already
// translated into the target language. Don't try to re-translate it.
return null;
}
/** @type {string | null | undefined} */
let translation = this.#translationsCache.get(sourceText, isHTML);
if (translation !== undefined) {
// We already have a cached translation for this source text.
return translation;
}
translation = await this.#scheduler
.createTranslationRequestPromise(
node,
sourceText,
isHTML,
translationId,
priority
)
.finally(() => {
// Any time a request resolves or rejects, we need to inform the scheduler
// so that it can determine if it needs to schedule a new batch of requests.
this.#scheduler.maybeScheduleMoreTranslationRequests();
});
if (translation !== null) {
this.#translationsCache.set(sourceText, translation, isHTML);
if (!this.#hasFirstVisibleChange) {
this.#hasFirstVisibleChange = true;
this.#actorReportFirstVisibleChange();
}
}
return translation;
}
/**
* Start the mutation observer, for instance after applying the translations to the DOM.
*/
#startMutationObserver() {
if (Cu.isDeadWrapper(this.#mutationObserver)) {
// This observer is no longer alive.
return;
}
for (const node of this.#rootNodes) {
if (Cu.isDeadWrapper(node)) {
// This node is no longer alive.
continue;
}
this.#mutationObserver.observe(node, MUTATION_OBSERVER_OPTIONS);
}
}
/**
* Stop the mutation observer, for instance to apply the translations to the DOM.
*/
#stopMutationObserver() {
// Was the window already destroyed?
if (!Cu.isDeadWrapper(this.#mutationObserver)) {
this.#mutationObserver.disconnect();
}
}
/**
* Stops the mutation observer and all intersection observers.
*/
#stopAllObservers() {
const observers = [
this.#mutationObserver,
this.#intersectionObserverForContentTranslationsWithinViewport,
this.#intersectionObserverForContentTranslationsBeyondViewport,
this.#intersectionObserverForAttributeTranslationsWithinViewport,
this.#intersectionObserverForAttributeTranslationsBeyondViewport,
];
for (const observer of observers) {
if (!Cu.isDeadWrapper(observer)) {
observer.disconnect();
}
}
}
/**
* Updates all nodes that have completed attribute translation requests.
*
* This function is called asynchronously, so nodes may already be dead. Before
* accessing a node make sure and run `Cu.isDeadWrapper` to check that it is alive.
*/
#updateNodesWithContentTranslations = () => {
this.#hasPendingUpdateContentCallback = false;
let staleRequestCount = 0;
let detachedNodeCount = 0;
let textNodeCount = 0;
let elementCount = 0;
const startTime = Cu.now();
// Stop the mutations so that the updates won't trigger observations.
this.#pauseMutationObserverAndThen(() => {
const entries = this.#elementsThatNeedContentUpdates;
for (const {
element,
targetNode,
translatedContent,
translationId,
} of entries) {
const eligibility = this.#determineNodeContentUpdateEligibility(
element,
targetNode,
translationId
);
if (eligibility === "stale") {
// A new request has been submitted for this node. This one is no longer relevant.
staleRequestCount++;
continue;
} else if (eligibility === "detached") {
// This node is detached from the DOM: there is no point in updating it.
detachedNodeCount++;
} else if (element === targetNode) {
elementCount++;
const translationsDocument = this.#domParser.parseFromString(
`<!DOCTYPE html><div>${translatedContent}</div>`,
"text/html"
);
updateElement(translationsDocument, element);
this.#processedContentNodes.add(targetNode);
} else {
textNodeCount++;
targetNode.textContent = translatedContent;
this.#processedContentNodes.add(targetNode);
}
deleteFromNestedMap(
this.#queuedIntersectionPrunableContentElements,
element,
targetNode
);
deleteFromNestedMap(
this.#queuedIntersectionExemptContentElements,
element,
targetNode
);
deleteFromNestedMap(
this.#pendingContentTranslations,
element,
targetNode
);
this.#removeFromContentIntersectionObservation(element, targetNode);
}
this.#elementsThatNeedContentUpdates.clear();
});
ChromeUtils.addProfilerMarker(
"TranslationsDocument Update (Content)",
{ startTime, innerWindowId: this.#innerWindowId },
"Content Update Request: " +
`${staleRequestCount} stale requests, ${detachedNodeCount} detached nodes, ` +
`${textNodeCount} text nodes, and ${elementCount} elements.`
);
};
/**
* Stops the mutation observer while running the given callback,
* then restarts the mutation observer once the callback has finished.
*
* This is used to update nodes with translated content when their
* translation requests have completed, ensuring that we will always
* stop and restart the observer.
*
* @param {Function} callback - A callback to run while the mutation observer is paused.
*/
#pauseMutationObserverAndThen(callback) {
this.#stopMutationObserver();
try {
callback();
} finally {
this.#startMutationObserver();
}
}
/**
* Ensures that nodes with completed content translation requests will be updated.
*
* This may happen immediately if there are very few active translation requests.
*
* If there are many active translation requests, we will register a callback to the
* event loop to update a batch of nodes all at once.
*
* This distinction is made because updating any content within the DOM requires
* pausing the mutation observer, and that cost adds up if you do it individually
* for every translation request that completes.
*
* @param {Element} element
* @param {Node} targetNode
* @param {string} translatedContent
* @param {number} translationId - A unique id to identify this translation request.
*/
#registerElementForContentTranslationUpdate(
element,
targetNode,
translatedContent,
translationId
) {
// Add the nodes to be populated with the next translation update.
this.#elementsThatNeedContentUpdates.add({
element,
targetNode,
translatedContent,
translationId,
});
if (this.#scheduler.isWithinFinalBatches()) {
// The scheduler is within the final batches of requests that it will send, so we will eagerly update
// instead of registering a callback to update several nodes in a batch. This is particularly important
// for cases such as translating a YouTube video with closed captions. When the rest of the viewport
// is already translated, and a new request for a caption comes in, that will be the only request that
// the scheduler is reacting to, and we want to update the caption text as soon as we possibly can.
this.#updateNodesWithContentTranslations();
} else if (!this.#hasPendingUpdateContentCallback) {
// Schedule a callback on the event loop to update all nodes with completed translations.
this.#hasPendingUpdateContentCallback = true;
lazy.setTimeout(
this.#updateNodesWithContentTranslations,
DOM_UPDATE_INTERVAL_MS
);
} else {
// An update has been previously scheduled, do nothing here.
}
}
/**
* Check to see if a language matches the document's source language.
*
* @param {Node} node
*/
#matchesDocumentLanguage(node) {
const lang = asHTMLElement(node)?.lang;
if (!lang) {
// No `lang` was present, so assume it matches the language.
return true;
}
// First, cheaply check if language tags match, without canonicalizing.
if (lazy.TranslationsUtils.langTagsMatch(this.#documentLanguage, lang)) {
return true;
}
try {
// Make sure the local is in the canonical form, and check again. This function
// throws, so don't trust that the language tags are formatting correctly.
const [language] = Intl.getCanonicalLocales(lang);
return lazy.TranslationsUtils.langTagsMatch(
this.#documentLanguage,
language
);
} catch (_error) {
return false;
}
}
/**
* Called by external code (the actor) once a new MessagePort has been established.
* We pass this along to the scheduler, since this is the port that will be used
* to send translation requests to the TranslationsEngine.
*
* @param {MessagePort} port
*/
acquirePort(port) {
this.#scheduler.acquirePort(port);
}
/**
* Retrieves the current status of the TranslationsEngine that is handling translations
* for this TranslationsDocument instance.
*
* @returns {EngineStatus}
*/
get engineStatus() {
return this.#scheduler.engineStatus;
}
/**
* Returns true if the TranslationsDocument has any pending translation requests
* that are actively being handled by the TranslationScheduler, otherwise false.
*
* @returns {boolean}
*/
hasPendingTranslationRequests() {
return (
this.#pendingContentTranslations.size > 0 ||
this.#pendingAttributeTranslations.size > 0
);
}
/**
* Returns true if the TranslationsDocument has any pending callback on the event loop
* that has not yet completed, otherwise false.
*
* @returns {boolean}
*/
hasPendingCallbackOnEventLoop() {
return (
this.#hasPendingMutatedNodesCallback ||
this.#hasPendingPrioritizationCallback ||
this.#hasPendingUpdateAttributesCallback ||
this.#hasPendingUpdateContentCallback ||
this.#scheduler.hasPendingScheduleRequestsCallback()
);
}
/**
* Returns true if the TranslationsDocument is observing at least one
* element for intersection to translate its content, otherwise false.
*
* @returns {boolean}
*/
isObservingAnyElementForContentIntersection() {
return this.#intersectionObservedContentElements.size > 0;
}
/**
* Returns true if the TranslationsDocument is observing at least one
* element for intersection to translate its attributes, otherwise false.
*
* @returns {boolean}
*/
isObservingAnyElementForAttributeIntersection() {
return this.#intersectionObservedAttributeElements.size > 0;
}
/**
* An event handler for when the user scrolls around the page.
* Uses the scrollY position to determine if the user is scrolling up or down.
* This scroll hint is used to help optimally prioritize translation requests.
*
* This function is intentionally written as a lambda so that it can be passed as a
* callback without the need to explicitly bind `this` to the function object.
*/
#handleScrollEvent = () => {
if (Cu.now() - this.#mostRecentScrollTimestamp < 100) {
// Scrolling can fire a lot of events in rapid succession, and computing the scrollY value can
// trigger reflow, so we will limit how often we take the time to compute the scrollY value.
// Scroll hints are critical to providing a smooth translation experience, but it's not the
// end of the world if we happen to miss one.
return;
}
const scrollY = ensureExists(this.#sourceDocument.ownerGlobal).scrollY;
this.#mostRecentScrollDirection =
scrollY >= this.#previousScrollY ? "down" : "up";
this.#previousScrollY = scrollY;
this.#mostRecentScrollTimestamp = Cu.now();
};
/**
* Returns true if the user has scrolled recently, otherwise false.
*
* @returns {boolean}
*/
#hasUserScrolledRecently() {
return Cu.now() - this.#mostRecentScrollTimestamp < 200;
}
/**
* Attempts to determine an optimal set of translation priorities considering the location
* of nodes with respect to the viewport, the type of translation request (content or attribute),
* as well as the user's recent scroll activity.
*
* For example, if the user is actively scrolling up, we will do our best to prioritize visible
* content translations that are just above the user's viewport, in hopes that their translation
* requests will complete before the user even sees them.
*
* @returns {TranslationPriorityKinds}
*/
#determinePrioritiesForTranslations() {
// The following priorities are always the same, regardless of recent scroll activity.
// Translating in-viewport content will always be of the highest priority.
const inViewportContentPriority = TranslationScheduler.P0;
// The priority of translating content nodes whose viewport context was indeterminate.
const otherContentPriority = TranslationScheduler.P6;
// The priority of translating attributes whose viewport context was indeterminate.
const otherAttributePriority = TranslationScheduler.P7;
// The following priorities are all dependent on the user's recent scroll activity.
// The priority of translating attributes within the viewport.
let inViewportAttributePriority;
// The priority of translating content above the viewport.
let aboveViewportContentPriority;
// The priority of translating attributes above the viewport.
let aboveViewportAttributePriority;
// The priority of translating content below the viewport.
let belowViewportContentPriority;
// The priority of translating attributes below the viewport.
let belowViewportAttributePriority;
switch (this.#mostRecentScrollDirection) {
case "up": {
// The user has recently scrolled up, so we will prioritize content above the viewport.
aboveViewportContentPriority = TranslationScheduler.P1;
// Since the user is scrolling up, it is likely that the content below the viewport
// has already been translated, which means that we can skip over this priority in most
// cases, but in the event that there are leftover, untranslated nodes, we still want to
// get all of the visible content around the viewport translated at the highest priorities.
belowViewportContentPriority = TranslationScheduler.P2;
// Attributes within and above the viewport are the next most important.
inViewportAttributePriority = TranslationScheduler.P3;
aboveViewportAttributePriority = TranslationScheduler.P4;
// Attributes below the viewport are the next most important.
belowViewportAttributePriority = TranslationScheduler.P5;
break;
}
case "down": {
// The user has recently scrolled down, so we will prioritize content below the viewport.
belowViewportContentPriority = TranslationScheduler.P1;
// Since the user is scrolling down, it is likely that the content above the viewport
// has already been translated, which means that we can skip over this priority in most
// cases, but in the event that there are leftover, untranslated nodes, we still want to
// get all of the visible content around the viewport translated at the highest priorities.
aboveViewportContentPriority = TranslationScheduler.P2;
// Attributes within and above the viewport are the next most important.
inViewportAttributePriority = TranslationScheduler.P3;
belowViewportAttributePriority = TranslationScheduler.P4;
// Attributes above the viewport are the next most important.
aboveViewportAttributePriority = TranslationScheduler.P5;
break;
}
default: {
// The user has not scrolled at all since activating Full-Page Translations.
if (AppConstants.platform === "android") {
// Attributes, e.g. "title" are less accessible on Android, so even if the user has not
// scrolled yet, we are going to do our best to prioritize visible content beyond the viewport.
// Mobile viewports are also pretty small, so we should quickly get through to the attributes.
belowViewportContentPriority = TranslationScheduler.P1;
aboveViewportContentPriority = TranslationScheduler.P2;
inViewportAttributePriority = TranslationScheduler.P3;
} else {
// On Desktop, however, if the user has not scrolled yet, we have no indication that they
// are going to scroll, so we should prioritize the entire viewport, including attributes.
inViewportAttributePriority = TranslationScheduler.P1;
belowViewportContentPriority = TranslationScheduler.P2;
aboveViewportContentPriority = TranslationScheduler.P3;
}
belowViewportAttributePriority = TranslationScheduler.P4;
aboveViewportAttributePriority = TranslationScheduler.P5;
}
}
return {
inViewportContentPriority,
inViewportAttributePriority,
aboveViewportContentPriority,
aboveViewportAttributePriority,
belowViewportContentPriority,
belowViewportAttributePriority,
otherContentPriority,
otherAttributePriority,
};
}
/**
* Registers a callback on the event loop to drain the queued content-translation nodes and the
* queued attribute-translation elements, prioritizing them and sending their translation requests
* to the TranslationScheduler.
*
* Does nothing if a callback is already pending.
*
* The callback registered by this function uses a dynamic rate limit, where the time between sending
* a batch of requests to the scheduler is much longer if the user is actively scrolling around the page.
*
* The intersection observers are constantly monitoring the locations of nodes within the page,
* enqueuing them to be scheduled when they get near to the viewport, cancelling their requests
* when they exit the viewport, etc.
*
* When an intersection observer needs to cancel a translation request, it is much cheaper to
* remove the node from the queue before it gets assigned a priority submitted to the scheduler.
* If we submit a translation request for every node that gets close to the viewport immediately
* then we will waste a lot of resources cancelling all of those requests if the viewport moves.
*
* So we want to have some mechanism to throttle how frequently nodes are submitted to the scheduler,
* allowing the intersection observers to rapidly resolve the ideal state by adding and removing nodes
* from the queues before we pause to schedule translations for all of the nodes currently in the queues.
*
* However, if we wait too long between each time we send requests to the scheduler, the user experience
* will no longer feel fluid and reactive.
*
* When the user is scrolling, the observers are going to be adding and cancelling many nodes in rapid
* succession as their spatial contexts relative to the viewport change. We need to allow extra time
* to cheaply resolve the state of the queues before sending requests to the scheduler.
*
* When the user is not scrolling, new nodes may still be entering or exiting proximity with te viewport,
* but in this case it is often due to closed caption text updates on a video, or a chat section for a live
* stream being flooded with new comments. Here we want to prioritize and submit much more quickly so that
* we can react fluidly to dynamic changes on the page.
*/
async #maybePrioritizeRequestsAndSubmitToScheduler() {
// Ensure that we've completed the first intersection observation before we submit any requests
// to the scheduler. Otherwise, the observers may end up cancelling the requests, because every observed
// element that is not within the observer's proximity will be seen the first time as leaving proximity.
await this.#waitForFirstIntersectionObservations();
if (this.#hasPendingPrioritizationCallback) {
// A callback has already been registered to submit to the scheduler.
return;
}
if (
this.#queuedIntersectionExemptContentElements.size === 0 &&
this.#queuedIntersectionPrunableContentElements.size === 0 &&
this.#queuedIntersectionExemptAttributeElements.size === 0 &&
this.#queuedIntersectionPrunableAttributeElements.size === 0
) {
// There are no nodes to submit to the scheduler.
return;
}
this.#hasPendingPrioritizationCallback = true;
lazy.setTimeout(
async () => {
const contentElementCount =
this.#queuedIntersectionPrunableContentElements.size;
const attributeElementCount =
this.#queuedIntersectionPrunableAttributeElements.size;
let contentRequestCount = 0;
let attributeRequestCount = 0;
const startTime = Cu.now();
const {
inViewportContentPriority,
inViewportAttributePriority,
aboveViewportContentPriority,
aboveViewportAttributePriority,
belowViewportContentPriority,
belowViewportAttributePriority,
otherContentPriority,
otherAttributePriority,
} = this.#determinePrioritiesForTranslations();
const {
titleElement,
inViewportContent,
aboveViewportContent,
belowViewportContent,
otherContent,
} = this.#prioritizeQueuedContentElements();
const {
inViewportAttributes,
aboveViewportAttributes,
belowViewportAttributes,
otherAttributes,
} = this.#prioritizeQueuedAttributeElements();
for (const { element, nodeSet } of inViewportContent) {
contentRequestCount += nodeSet.size;
this.#submitForContentTranslation(
inViewportContentPriority,
element,
nodeSet
);
}
if (titleElement) {
// The translator pops nodes off in LIFO order, so if the <title> element is present
// in this group, we want to push it on as the final top-priority node, to ensure
// that it is the very first element to be translated.
contentRequestCount++;
this.#submitForContentTranslation(
inViewportContentPriority,
titleElement,
new Set([titleElement])
);
}
for (const { element, attributeSet } of inViewportAttributes) {
attributeRequestCount += attributeSet.size;
this.#submitForAttributeTranslation(
inViewportAttributePriority,
element,
attributeSet
);
}
for (const { element, nodeSet } of aboveViewportContent) {
contentRequestCount += nodeSet.size;
this.#submitForContentTranslation(
aboveViewportContentPriority,
element,
nodeSet
);
}
for (const { element, attributeSet } of aboveViewportAttributes) {
attributeRequestCount += attributeSet.size;
this.#submitForAttributeTranslation(
aboveViewportAttributePriority,
element,
attributeSet
);
}
for (const { element, nodeSet } of belowViewportContent) {
contentRequestCount += nodeSet.size;
this.#submitForContentTranslation(
belowViewportContentPriority,
element,
nodeSet
);
}
for (const { element, attributeSet } of belowViewportAttributes) {
attributeRequestCount += attributeSet.size;
this.#submitForAttributeTranslation(
belowViewportAttributePriority,
element,
attributeSet
);
}
for (const { element, nodeSet } of otherContent) {
contentRequestCount += nodeSet.size;
this.#submitForContentTranslation(
otherContentPriority,
element,
nodeSet
);
}
for (const { element, attributeSet } of otherAttributes) {
attributeRequestCount += attributeSet.size;
this.#submitForAttributeTranslation(
otherAttributePriority,
element,
attributeSet
);
}
this.#hasPendingPrioritizationCallback = false;
ChromeUtils.addProfilerMarker(
"TranslationsDocument Prioritize",
{ startTime, innerWindowId: this.#innerWindowId },
`Prioritized ${contentRequestCount} content translation requests among ${contentElementCount} elements, ` +
`${attributeRequestCount} attribute translation requests among ${attributeElementCount} elements.`
);
},
this.#hasUserScrolledRecently() ? 250 : 25
);
}
/**
* Iterates through all of the nodes that the observers have queued to be sent
* to the TranslationScheduler for attribute translations, groups them based on their
* spatial context with respect to the viewport, then sorts them such that the nodes
* most likely to be encountered next will be scheduled for translation first.
*
* If the <title> is contained within this batch, it specially returns the title node
* as a distinct field so that we can specially ensure that it is the very first translation.
*
* @returns {PrioritizedContentElements}
*/
#prioritizeQueuedContentElements() {
/**
* Nodes that lie at least partially within the viewport.
*
* @type {Array<SortableContentElement>}
*/
const inViewportContent = [];
/**
* Nodes that lie entirely above the viewport.
*
* @type {Array<SortableContentElement>}
*/
const aboveViewportContent = [];
/**
* Nodes that lie entirely below the viewport.
*
* @type {Array<SortableContentElement>}
*/
const belowViewportContent = [];
/**
* Nodes that lie entirely to either side of the viewport,
* or whose position could not be determined.
*
* @type {Array<SortableContentElement>}
*/
const otherContent = [];
// The <title> will be specially returned in this variable if it is present
// in this batch of nodes.
let titleElement;
const queuedContentElements =
this.#queuedIntersectionPrunableContentElements;
for (const [element, nodeSet] of this
.#queuedIntersectionExemptContentElements) {
const existingSet = queuedContentElements.get(element);
if (existingSet) {
for (const node of nodeSet) {
existingSet.add(node);
}
} else {
queuedContentElements.set(element, nodeSet);
}
}
for (const [element, nodeSet] of queuedContentElements) {
// We will cache the location values so that they don't have to be recomputed
// for every comparison when we sort. Based on my profiles, this all but removes
// samples captured with `Array.prototype.sort`, and cuts the number of samples
// from submitting nodes to the scheduler roughly in half.
const { top, left, right, viewportContext } =
getNodeSpatialContext(element);
switch (viewportContext) {
case "within": {
inViewportContent.push({ element, nodeSet, top, left, right });
break;
}
case "above": {
aboveViewportContent.push({ element, nodeSet, top, left, right });
break;
}
case "below": {
belowViewportContent.push({ element, nodeSet, top, left, right });
break;
}
default: {
if (element.nodeName === "TITLE") {
titleElement = element;
} else {
otherContent.push({ element, nodeSet, top, left, right });
}
}
}
}
// These node groups will be iterated over and sent to the TranslationScheduler in a regular loop,
// but the scheduler processes new requests in a stack-based LIFO ordering, so the following
// sorting semantics will sort nodes in the REVERSE order of how we want them to be scheduled.
// Sort nodes below the viewport such that the top-most nodes will be scheduled first.
this.#orderFromBottomToTop(belowViewportContent);
// Sort nodes above the viewport such that the bottom-most nodes will be scheduled first.
this.#orderFromTopToBottom(aboveViewportContent);
if (
this.#mostRecentScrollDirection === "up" &&
this.#hasUserScrolledRecently()
) {
// If the user is scrolling up, we should sort nodes that come into intersection proximity
// such that the bottom-most nodes will be scheduled first.
this.#orderFromTopToBottom(inViewportContent);
} else {
// If the user is scrolling down, or by default if they have not scrolled recently, we should
// sort such that the top-most nodes will be scheduled first.
this.#orderFromBottomToTop(inViewportContent);
}
this.#queuedIntersectionPrunableContentElements.clear();
this.#queuedIntersectionExemptContentElements.clear();
return {
titleElement,
inViewportContent,
aboveViewportContent,
belowViewportContent,
otherContent,
};
}
/**
* Iterates through all of the elements that the observers have queued to be sent
* to the TranslationScheduler for attribute translations, groups them based on their
* spatial context with respect to the viewport, then sorts them such that the elements
* most likely to be encountered next will be scheduled for translation first.
*
* @returns {PrioritizedAttributeElements}
*/
#prioritizeQueuedAttributeElements() {
/**
* Elements that lie at least partially within the viewport.
*
* @type {Array<SortableAttributeElement>}
*/
const inViewportAttributes = [];
/**
* Elements that lie entirely above the viewport.
*
* @type {Array<SortableAttributeElement>}
*/
const aboveViewportAttributes = [];
/**
* Elements that lie entirely below the viewport.
*
* @type {Array<SortableAttributeElement>}
*/
const belowViewportAttributes = [];
/**
* Elements that lie to either side of the viewport,
* or whose position could not be determined.
*
* @type {Array<SortableAttributeElement>}
*/
const otherAttributes = [];
const queuedAttributeElements =
this.#queuedIntersectionPrunableAttributeElements;
for (const [element, attributeSet] of this
.#queuedIntersectionExemptAttributeElements) {
const existingSet = queuedAttributeElements.get(element);
if (!existingSet) {
queuedAttributeElements.set(element, attributeSet);
continue;
}
for (const attributeName of attributeSet) {
existingSet.add(attributeName);
}
}
for (const [element, attributeSet] of queuedAttributeElements) {
// We will cache the location values so that they don't have to be recomputed
// for every comparison when we sort. Based on my profiles, this all but removes
// samples captured with `Array.prototype.sort`, and cuts the time to submit requests
// to the scheduler roughly in half.
const { top, left, right, viewportContext } =
getNodeSpatialContext(element);
switch (viewportContext) {
case "within": {
inViewportAttributes.push({
element,
attributeSet,
top,
left,
right,
});
break;
}
case "above": {
aboveViewportAttributes.push({
element,
attributeSet,
top,
left,
right,
});
break;
}
case "below": {
belowViewportAttributes.push({
element,
attributeSet,
top,
left,
right,
});
break;
}
default: {
otherAttributes.push({ element, attributeSet, top, left, right });
}
}
}
// These element groups will be iterated over and sent to the TranslationScheduler in a regular loop,
// but the scheduler processes new requests in a stack-based LIFO ordering, so the following
// sorting semantics will sort elements in the REVERSE order of how we want them to be scheduled.
// Sort elements below the viewport such that the top-most elements will be scheduled first.
this.#orderFromBottomToTop(belowViewportAttributes);
// Sort elements above the viewport such that the bottom-most elements will be scheduled first.
this.#orderFromTopToBottom(aboveViewportAttributes);
if (this.#mostRecentScrollDirection === "up") {
// If we are scrolling up, we should sort new elements that come into the viewport
// such that the bottom-most elements will be scheduled first.
this.#orderFromTopToBottom(inViewportAttributes);
} else {
// If we are scrolling down, we should sort new elements that come into the viewport
// such that the top-most elements will be scheduled first.
this.#orderFromBottomToTop(inViewportAttributes);
}
this.#queuedIntersectionPrunableAttributeElements.clear();
this.#queuedIntersectionExemptAttributeElements.clear();
return {
inViewportAttributes,
aboveViewportAttributes,
belowViewportAttributes,
otherAttributes,
};
}
/**
* Sorts such that nodes closer to the top of the page are first,
* and nodes closer to the bottom of the page are last.
*
* @param {Array<SortableContentElement> | Array<SortableAttributeElement>} nodes
*/
#orderFromTopToBottom(nodes) {
nodes.sort((lhs, rhs) => {
const verticalDifference =
(lhs.top ?? -Infinity) - (rhs.top ?? -Infinity);
if (Math.abs(verticalDifference) > 1) {
// The vertical difference is greater than one pixel: this takes full precedence.
return verticalDifference;
}
if (this.#targetScriptDirection === "ltr") {
// Secondarily sort such that the LIFO scheduler will process from left to right.
return (rhs.right ?? Infinity) - (lhs.right ?? Infinity);
}
// Secondarily sort such that the LIFO scheduler will process from right to left.
return (lhs.left ?? -Infinity) - (rhs.left ?? -Infinity);
});
}
/**
* Sorts such that nodes closer to the bottom of the page are first,
* and nodes closer to the bottom of the page are last.
*
* @param {Array<SortableContentElement> | Array<SortableAttributeElement>} nodes
*/
#orderFromBottomToTop(nodes) {
nodes.sort((lhs, rhs) => {
const verticalDifference = (rhs.top ?? Infinity) - (lhs.top ?? Infinity);
if (verticalDifference) {
// The vertical difference is greater than one pixel: this takes full precedence.
return verticalDifference;
}
if (this.#targetScriptDirection === "ltr") {
// Secondarily sort such that the LIFO scheduler will process from left to right.
return (rhs.right ?? Infinity) - (lhs.right ?? Infinity);
}
// Secondarily sort such that the LIFO scheduler will process from right to left.
return (lhs.left ?? -Infinity) - (rhs.left ?? -Infinity);
});
}
/**
* Attempts to register a node with the content-translation intersection observers.
*
* If the node is a text node that was determined to be translatable, then it will
* be immediately enqueued for translation because only element type nodes can be
* observed for intersection.
*
* @param {Node} node
*/
#observeOrEnqueueNodeForContentPrioritization(node) {
let observableElement;
let translatableNode;
const element = asElement(node);
if (element) {
observableElement = element;
translatableNode = element;
} else if ((translatableNode = asTextNode(node))) {
observableElement = asElement(node.parentNode);
}
if (!translatableNode) {
// This node is not translatable, and it should have been filtered earlier.
lazy.console.warn(
`A non-translatable ${node.nodeName} node was not filtered correctly.`
);
return;
}
if (!observableElement) {
// This node is translatable, but its immediate parent is not observable for intersection.
lazy.console.warn(
`Found a translatable ${node.nodeName} node is not a direct child of an element.`
);
return;
}
let nodeSet =
this.#intersectionObservedContentElements.get(observableElement);
if (!nodeSet) {
nodeSet = new Set([translatableNode]);
this.#intersectionObservedContentElements.set(observableElement, nodeSet);
}
nodeSet.add(translatableNode);
if (this.#translationsMode === "content-eager") {
this.#enqueueForIntersectionPrunableContentPrioritization(
observableElement
);
}
// It is very important that we register the element with the In-Viewport
// observer before the Beyond-Viewport observer, to ensure that the In-Viewport
// observer callback is triggered first, otherwise we will be sending unnecessary
// cancellations for any nodes that lie within the bounds of both observers.
this.#intersectionObserverForContentTranslationsWithinViewport.observe(
observableElement
);
this.#intersectionObserverForContentTranslationsBeyondViewport.observe(
observableElement
);
}
/**
* Ensures that an element is removed from content intersection observation.
* If the element was not already being observed, has no effect.
*
* @param {Element} observableElement
* @param {Node} targetNode
*/
#removeFromContentIntersectionObservation(observableElement, targetNode) {
const { didDeleteOuterEntry } = deleteFromNestedMap(
this.#intersectionObservedContentElements,
observableElement,
targetNode
);
if (didDeleteOuterEntry) {
this.#intersectionObserverForContentTranslationsWithinViewport.unobserve(
observableElement
);
this.#intersectionObserverForContentTranslationsBeyondViewport.unobserve(
observableElement
);
}
}
/**
* Ensures that an element is removed from attribute intersection observation.
* If the element was not already being observed, has no effect.
*
* @param {Element} observableElement
* @param {string} [attributeName]
*/
#removeFromAttributeIntersectionObservation(
observableElement,
attributeName
) {
let didDeleteOuterEntry = false;
if (!attributeName) {
didDeleteOuterEntry = true;
this.#intersectionObservedAttributeElements.delete(observableElement);
} else {
const deletionResult = deleteFromNestedMap(
this.#intersectionObservedAttributeElements,
observableElement,
attributeName
);
didDeleteOuterEntry = deletionResult.didDeleteOuterEntry;
}
if (didDeleteOuterEntry) {
this.#intersectionObserverForAttributeTranslationsWithinViewport.unobserve(
observableElement
);
this.#intersectionObserverForAttributeTranslationsBeyondViewport.unobserve(
observableElement
);
}
}
/**
* Attempts to register an element with the attribute-translation intersection observers.
* If the element has no translatable attributes, it will not be registered for observation.
*
* @param {Element} element
* @param {Set<string> | null} [attributes]
*/
#maybeObserveElementForAttributePrioritization(element, attributes) {
attributes = attributes ?? this.#getTranslatableAttributes(element);
if (!attributes) {
return;
}
// It is very important that we register the element with the In-Viewport
// observer before the Beyond-Viewport observer, to ensure that the In-Viewport
// observer callback is triggered first, otherwise we will be sending unnecessary
// cancellations for any nodes that lie within the bounds of both observers.
this.#intersectionObservedAttributeElements.set(element, attributes);
this.#intersectionObserverForAttributeTranslationsWithinViewport.observe(
element
);
this.#intersectionObserverForAttributeTranslationsBeyondViewport.observe(
element
);
}
/**
* Attempts to cancel a content translation request for the given node,
* only if the request has not already been sent to the TranslationsEngine.
*
* This function is intended to be used by the intersection observers to
* re-prioritize a translation. If a translation request has already been
* sent to the TranslationsEngine, in this case, it will soon be complete
* so it would be wasteful to fully cancel it solely to re-prioritize.
*
* In order to fully cancel a translation request, even if it has already been
* sent to the TranslationsEngine, as such is the use case for the mutation
* observer, then the `#maybePreventContentTranslation` function should be used instead.
*
* @param {Element} element
* @returns {{
* preventedNodeSet?: Set<Node>,
* cancelledFromSchedulerCount: number
* }}
*/
#preventUnscheduledContentTranslations(element) {
/** @type {Set<Node> | undefined} */
let preventedNodeSet =
this.#queuedIntersectionPrunableContentElements.get(element);
if (preventedNodeSet) {
this.#queuedIntersectionPrunableContentElements.delete(element);
}
const pendingNodes = this.#pendingContentTranslations.get(element);
let cancelledFromSchedulerCount = 0;
if (!pendingNodes) {
return {
preventedNodeSet,
cancelledFromSchedulerCount,
};
}
/** @param {Node} node */
const addNodeToSet = node => {
if (!preventedNodeSet) {
preventedNodeSet = new Set();
}
preventedNodeSet.add(node);
};
for (const [node, translationId] of pendingNodes) {
if (this.#scheduler.preventUnscheduledTranslation(translationId)) {
addNodeToSet(node);
}
}
if (preventedNodeSet) {
for (const node of preventedNodeSet.keys()) {
pendingNodes.delete(node);
cancelledFromSchedulerCount++;
}
}
if (pendingNodes.size === 0) {
this.#pendingContentTranslations.delete(element);
}
return {
preventedNodeSet,
cancelledFromSchedulerCount,
};
}
/**
* Attempts to cancel all attribute translation requests for the given element,
* only if the requests have not already been sent to the TranslationsEngine.
*
* This function is intended to be used by the intersection observers to
* re-prioritize translations. If the translation requests have already been
* sent to the TranslationsEngine, in this case, they will soon be complete
* so it would be wasteful to fully cancel them solely to re-prioritize.
*
* In order to fully cancel an element's attribute translation requests, even
* if they have already been sent to the TranslationsEngine, as such is the use
* case for the mutation observer, then the `#maybePreventAttributeTranslations`
* function should be used instead.
*
* @param {Element} element
* @returns {{
* preventedAttributeSet?: Set<string>,
* cancelledFromSchedulerCount: number,
* }}
*/
#preventUnscheduledAttributeTranslations(element) {
/** @type {Set<string> | undefined} */
let preventedAttributeSet =
this.#queuedIntersectionPrunableAttributeElements.get(element);
if (preventedAttributeSet) {
this.#queuedIntersectionPrunableAttributeElements.delete(element);
}
const pendingAttributes = this.#pendingAttributeTranslations.get(element);
let cancelledFromSchedulerCount = 0;
if (!pendingAttributes) {
return {
preventedAttributeSet,
cancelledFromSchedulerCount,
};
}
/** @param {string} attribute */
const addAttributeToSet = attribute => {
if (!preventedAttributeSet) {
preventedAttributeSet = new Set();
}
preventedAttributeSet.add(attribute);
};
for (const [attribute, translationId] of pendingAttributes) {
if (this.#scheduler.preventUnscheduledTranslation(translationId)) {
addAttributeToSet(attribute);
}
}
if (preventedAttributeSet) {
for (const attribute of preventedAttributeSet.keys()) {
pendingAttributes.delete(attribute);
cancelledFromSchedulerCount++;
}
}
if (pendingAttributes.size === 0) {
this.#pendingAttributeTranslations.delete(element);
}
return {
preventedAttributeSet,
cancelledFromSchedulerCount,
};
}
/**
* Determines whether the given node is eligible to have its text content updated.
*
* Updates to nodes within the DOM may happen asynchronously, so by the time that we are
* ready to update the content we need to check two conditions:
*
* 1) Has the fulfilled request that we have gone stale due to a newer, more-relevant request
* that was scheduled for this same node?
*
* 2) Has this node already detached from the DOM before we updated its content, in which case
* there is no point in moving forward with the update?
*
* @param {Element} element
* @param {Node} targetNode
* @param {number} translationId
*
* @returns {UpdateEligibility}
*/
#determineNodeContentUpdateEligibility(element, targetNode, translationId) {
const pendingNodes = this.#pendingContentTranslations.get(element);
if (!pendingNodes || pendingNodes.get(targetNode) !== translationId) {
// This translation lost a race, and was deleted or re-submitted under a different id.
return "stale";
}
if (this.#nodesWithMutatedContent.has(targetNode)) {
// The target node has been mutated since the time we requested translation.
// The translated value that we have is no longer relevant.
return "stale";
}
if (isNodeDetached(targetNode)) {
// The node is detached from the DOM, there is no use in updating its content.
return "detached";
}
return "valid";
}
/**
* Determines whether the given element is eligible to have its attributes updated.
*
* Updates to elements within the DOM may happen asynchronously, so by the time that we are
* ready to update the attributes we need to check two conditions:
*
* 1) Has the fulfilled request that we have gone stale due to a newer, more-relevant request
* that was scheduled for this same attribute on this element?
*
* 2) Has this element already detached from the DOM before we updated its attribute, in which
* case there is no point moving forward with the update?
*
* @param {Element} element
* @param {string} attribute
* @param {number} translationId
*
* @returns {UpdateEligibility}
*/
#determineElementAttributeUpdateEligibility(
element,
attribute,
translationId
) {
const pendingAttributes = this.#pendingAttributeTranslations.get(element);
if (
!pendingAttributes ||
pendingAttributes.get(attribute) !== translationId
) {
// A new request has been submitted for this attribute. This one is no longer relevant.
return "stale";
}
if (this.#elementsWithMutatedAttributes.get(element)?.has(attribute)) {
// This attribute has been mutated since the time we requested translation.
// The translated value that we have is no longer relevant.
return "stale";
}
if (isNodeDetached(element)) {
// This element is detached from the DOM: there is no point in updating it.
return "detached";
}
return "valid";
}
}
/**
* The AntiStarvationStack is a stack-like data structure with a predefined batch size.
* Requests are pushed to the stack one at a time, but they may only be popped in a batch.
*
* The stack keeps track of whether the net count of requests has increased or decreased
* between each time it pops a batch of request. If the size of the stack has not decreased
* since the previous time a batch was popped, then it means that more requests are being
* pushed to the stack than are being popped from the stack, and the stack is considered
* to have starving requests.
*
* This terminology is derived from the idea that if the stack is growing faster than it is
* processing, then requests at the bottom of the stack will never be popped, and they will starve,
* i.e. they will never have a chance to be processed.
*
* - https://en.wikipedia.org/wiki/Starvation_(computer_science)
*
* In order to ensure fairness in processing, when the stack has starving requests it will pull
* a predefined portion of the batch from the bottom of the stack, instead of only from the top.
* This ensures that if the stack is growing faster than it can be processed, we are guaranteed
* to eventually process the oldest requests in the stack, given enough time, and no request will
* ever starve entirely.
*
* It is recommended that the starvation batch portion is less than or equal half of the batch size.
* This ensures that priority is still given to newer requests, as is the intent of the stack, while
* still ensuring fairness in scheduling.
*
* The following is a diagram of several calls to popBatch(), demonstrating both normal calls to
* popBatch() as well as calls to popBatch() under starvation conditions:
*
* AntiStarvationStack: size == 9, #batchSize == 5, #starvationBatchPortion == 2
*
* ┌─┬─┬─┬─┬─┬─┬─┬─┬─┐
* └─┴─┴─┴─┴─┴─┴─┴─┴─┘
* popBatch(): └────┬────┘
* 5
*
* ┌─┬─┬─┬─┐
* └─┴─┴─┴─┘
* push() x 7: └──────┬──────┘
* 7
*
* ┌─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┐
* └─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┘
* popBatch(): └─┬─┘ └──┬──┘
* 2 3
*
* ┌─┬─┬─┬─┬─┬─┐
* └─┴─┴─┴─┴─┴─┘
* push() x 4: └───┬───┘
* 4
*
* ┌─┬─┬─┬─┬─┬─┬─┬─┬─┬─┐
* └─┴─┴─┴─┴─┴─┴─┴─┴─┴─┘
* popBatch(): └────┬────┘
* 5
*
* ┌─┬─┬─┬─┬─┐
* └─┴─┴─┴─┴─┘
*/
class AntiStarvationStack {
/**
* The array that represents the internal stack.
*
* @type {Array<TranslationRequest>}
*/
#stack = [];
/**
* Keeps track of the size of the stack the previous time a batch was popped.
* This is used to determine if the stack contains any starving requests,
* i.e. more requests are being pushed to the stack than are being popped.
*
* @type {number}
*/
#sizeBeforePreviousPop = 0;
/**
* The size of the batch that will be popped from the top of stack when no
* starvation is occurring, i.e. more requests are being popped than pushed.
*
* @type {number}
*/
#batchSize = 2;
/**
* Returns the count of requests that are popped from this stack when calling popBatch().
*
* @see {AntiStarvationStack.popBatch}
*
* @returns {number}
*/
get batchSize() {
return this.#batchSize;
}
/**
* The size of the batch that will be popped from the bottom of stack when the
* stack has starving requests, i.e. more requests are being pushed than popped.
*
* When the stack is starving, then (#batchSize - #starvationBatchPortion)
* nodes will still be removed from the top of the stack, but #starvationBatchPortion
* nodes will also be removed from the bottom of the stack to ensure fairness for
* continuing to process old requests in addition to new requests.
*
* @type {number}
*/
#starvationBatchPortion = 1;
/**
* Constructs a new AntiStarvationStack.
*
* The given batchSize must be larger than the starvationBatchPortion.
*
* @param {number} batchSize
* @param {number} starvationBatchPortion
*/
constructor(batchSize, starvationBatchPortion) {
this.#batchSize = batchSize;
this.#starvationBatchPortion = starvationBatchPortion;
if (this.#batchSize < 2) {
throw new Error("Batch size must be at least 2.");
}
if (this.#starvationBatchPortion <= 0) {
throw new Error("Starvation batch portion must be greater than zero.");
}
if (this.#batchSize < this.#starvationBatchPortion) {
throw new Error(
"Batch size must not be smaller than starvation batch portion."
);
}
}
/**
* Returns the current count of requests in the stack.
*
* @returns {number}
*/
get size() {
return this.#stack.length;
}
/**
* Pushes a translation request to the top of the stack.
*
* @param {TranslationRequest} request
*/
push(request) {
this.#stack.push(request);
}
/**
* Pops at most #batchSize requests from the stack.
*
* If the stack is starving (i.e. the net count of requests in the stack has
* increased since the previous call to popBatch(), rather than decreased),
* then a portion of requests will be removed from the bottom of the stack
* to ensure fairness in scheduling.
*
* @returns {{ starvationDetected: boolean, requests: Array<TranslationRequest>}}
*/
popBatch() {
const currentSize = this.size;
const starvationDetected =
// The stack was not empty the last time we popped.
this.#sizeBeforePreviousPop > 0 &&
// The net requests have not decreased since the last time we popped.
currentSize >= this.#sizeBeforePreviousPop &&
// The stack currently has more than one batch worth of requests.
currentSize > this.#batchSize;
this.#sizeBeforePreviousPop = currentSize;
if (currentSize === 0) {
return { starvationDetected, requests: [] };
}
let topBatchSize = this.#batchSize;
let bottomBatchSize = 0;
if (starvationDetected && currentSize > this.#batchSize) {
// The stack is growing faster than it is being processed,
// the stack contains more than one batch worth of requests.
// We will pull some from the bottom and the top to prevent starvation.
topBatchSize -= this.#starvationBatchPortion;
bottomBatchSize = this.#starvationBatchPortion;
}
/** @type {Array<TranslationRequest>} */
const requests = [];
for (let i = 0; i < topBatchSize && this.size > 0; i++) {
// @ts-ignore: this.#stack.pop() cannot return undefined here.
requests.push(this.#stack.pop());
}
// Removing requests from the front of an array like this has O(n) performance characteristics.
// An ideal solution here would utilize a deque with amortized O(1) popBack() and popFront()
// guarantees. Unfortunately, JavaScript lacks a standard deque implementation at this time.
//
// We are operating on small arrays, usually single or double digits in size, low hundreds at most.
// I have not found the performance characteristics here to be any sort of bottleneck; I rarely
// see this function show up in performance profiles, even when translating high-activity live
// stream comment sections, which is a prime scenario for starvation conditions.
//
// Until such a time that a deque is readily available in JavaScript, I do not feel the complexity
// of writing a custom deque implementation is justified for our use case here.
if (bottomBatchSize > 0) {
const bottomPortion = this.#stack.slice(0, bottomBatchSize);
requests.push(...bottomPortion);
// Retain the rest of the stack without the bottom portion.
this.#stack = this.#stack.slice(bottomBatchSize, this.size);
}
return { starvationDetected, requests };
}
/**
* Removes a request from the stack if it matches the given translationId.
*
* @param {number} translationId
* @returns {TranslationRequest | undefined}
*/
remove(translationId) {
const index = this.#stack.findIndex(
request => translationId === request.translationId
);
if (index < 0) {
// No request was found matching this translationId.
// It may have already been sent to the TranslationsEngine.
return undefined;
}
const request = this.#stack[index];
// Removing requests from the middle of an array like this has O(n) performance characteristics.
// An ideal solution here would utilize a table-based strategy with amortized O(1) removal guarantees.
//
// Unfortunately, using a table structure such as Map would make every call to popBatch() have O(n),
// characteristics, even under non-starvation conditions, due to Map not having any double-ended
// iteration capabilities at this time.
//
// We are operating on small arrays, usually single or double digits in size, low hundreds at most.
// I have not found the performance characteristics here to be any sort of bottleneck; I rarely
// see this function show up in performance profiles, even when scrolling rapidly through pages,
// which is a prime scenario for cancelling requests and therefore removing them by their translationIds.
this.#stack.splice(index, 1);
return request;
}
/**
* Clears all entries from the stack.
*/
clear() {
this.#stack = [];
}
}
/**
* The TranslationScheduler orchestrates when translation requests are sent to the TranslationsEngine.
*
* The scheduler implements a stack-based, newest-first priority-scheduling algorithm, which ensures
* that the most recent content that enters proximity to the viewport, whether due to user scrolling,
* or due to dynamic content entering the page, is translated at the highest priority.
*
* Although the scheduler ensures that the highest-priority requests are translated first, it also
* ensures scheduling fairness with guarantees that every request will eventually be scheduled,
* regardless of age or priority, even if more requests are coming in than can be processed.
*
* Fairness is guaranteed by the use of an anti-starvation stack @see {AntiStarvationStack}.
*
* Requests may be cancelled from the scheduler at any time, even after they are sent to the
* TranslationsEngine, though the earlier a request is cancelled, the cheaper it is to do so.
*/
class TranslationScheduler {
/**
* The priorities of the translation requests, where P0 is the highest and P7 is the lowest.
*
* The priorities are determined by the TranslationsDocument, and are dynamically assigned
* based on several factors including whether the request is for a content or an attribute
* translation, the location of the element with respect to the viewport, and the user's
* recent scrolling activity on the page.
*/
static get P0() {
return 0;
}
static get P1() {
return 1;
}
static get P2() {
return 2;
}
static get P3() {
return 3;
}
static get P4() {
return 4;
}
static get P5() {
return 5;
}
static get P6() {
return 6;
}
static get P7() {
return 7;
}
/**
* The count of active requests must be lower than this threshold before we will allow
* sending any more requests to the TranslationsEngine.
*
* We want to strike a balance between being optimally reactive to changes that may
* change request priorities, such as the user scrolling, while also sending a constant
* flow of requests to the TranslationsEngine, minimizing CPU downtime in the worker between
* finishing the current batch of requests and beginning to process the next batch of requests.
*
* This number may need to be increased if the performance of the TranslationsEngine worker
* improves considerably, or if we ever have more than one worker translating in parallel.
*
* @type {number}
*/
static get ACTIVE_REQUEST_THRESHOLD() {
return 1;
}
/**
* The port that sends translation requests to the TranslationsEngine.
*
* @type {MessagePort | null}
*/
#port = null;
/**
* If a new port is needed, this callback will be invoked to request one
* from the actor. After the actor obtains it, it calls `acquirePort`.
*
* @type {() => void}
*/
#actorRequestNewPort;
/**
* A map from the translationId to its corresponding TranslationRequest.
*
* This map contains only the requests that have been sent to the TranslationsEngine.
* Once the engine sends a translation response, we will match the translationId here
* to resolve or reject the request's promise, then remove it from the map.
*
* This map is mutually exclusive to the #unscheduledRequestsPriorityMap.
*
* @type {Map<number, TranslationRequest>}
*/
#activeRequests = new Map();
/**
* A map from the translationId to the corresponding request's priority.
*
* This map contains only the requests that have not yet been sent to the TranslationsEngine.
* We use this map to look up which priority stack a request should be removed from if the
* request needs to be cancelled.
*
* Once the scheduler send the request to the TranslationsEngine, the entry for the translationId
* will be removed from this map, and an entry for the same id will be added to #activeRequests.
*
* @type {Map<number, number>}
*/
#unscheduledRequestPriorities = new Map();
/**
* The stacks that correspond to the eight priorities a translation request can be assigned.
* The lower the number, the higher the priority. Each priority corresponds to an index in this array.
*
* @see {TranslationScheduler.P0}
* @see {TranslationScheduler.P1}
* @see {TranslationScheduler.P2}
* @see {TranslationScheduler.P3}
* @see {TranslationScheduler.P4}
* @see {TranslationScheduler.P5}
* @see {TranslationScheduler.P6}
* @see {TranslationScheduler.P7}
*/
#priorityStacks = [
new AntiStarvationStack(2, 1), // p0 stack
new AntiStarvationStack(2, 1), // p1 stack
new AntiStarvationStack(2, 1), // p2 stack
new AntiStarvationStack(2, 1), // p3 stack
new AntiStarvationStack(2, 1), // p4 stack
new AntiStarvationStack(2, 1), // p5 stack
new AntiStarvationStack(2, 1), // p6 stack
new AntiStarvationStack(2, 1), // p7 stack
];
#maxRequestsPerScheduleEvent = (() => {
let requestCount = 0;
for (const stack of this.#priorityStacks) {
requestCount += stack.batchSize;
}
return requestCount;
})();
/**
* Tracks the status of the translation engine.
*
* @type {EngineStatus}
*/
#engineStatus = "uninitialized";
/**
* Read-only getter to retrieve the engine status.
*
* @returns {EngineStatus}
*/
get engineStatus() {
return this.#engineStatus;
}
/**
* Whether the page is currently shown or not. If hidden, we pause processing
* and do not attempt to send new translation requests to the engine.
*/
#isPageShown = true;
/**
* If a port is being requested, we store a reference to that promise
* (plus its resolve/reject) so that repeated requests are not re-sent.
*
* @type {{ promise: Promise<void>, resolve: Function, reject: Function } | null}
*/
#portRequest = null;
/**
* Marks when we have a pending callback for scheduling more requests
* This ensures that we won't over-schedule requests from multiple calls.
*
* @type {boolean}
*/
#hasPendingScheduleRequestsCallback = false;
/**
* The InnerWindowID value to report to profiler markers.
*
* @type {number}
*/
#innerWindowId;
/**
* A cache of translations that have already been computed.
* This is cache is shared with the TranslationsDocument.
*
* @type {LRUCache}
*/
#translationsCache;
/**
* Constructs a new TranslationScheduler.
*
* @param {MessagePort?} port - A port to send translation requests to the TranslationsEngine.
* @param {number} innerWindowId - The innerWindowId for profiler markers.
* @param {LRUCache} translationsCache - A cache of completed translations, shared with the TranslationsDocument.
* @param {() => void} actorRequestNewPort - The function to call to ask the actor for a new port.
*/
constructor(port, innerWindowId, translationsCache, actorRequestNewPort) {
this.#innerWindowId = innerWindowId;
this.#translationsCache = translationsCache;
this.#actorRequestNewPort = actorRequestNewPort;
if (port) {
this.acquirePort(port);
}
}
/**
* @returns {boolean}
*/
hasPendingScheduleRequestsCallback() {
return this.#hasPendingScheduleRequestsCallback;
}
/**
* Attaches an onmessage handler to manage any communication with the TranslationsEngine.
* If we were waiting for a port (#portRequest), we resolve that once the engine indicates
* "ready" or reject if it indicates failure.
*
* @see {TranslationsDocument.acquirePort}
*
* @param {MessagePort} port
*/
acquirePort(port) {
if (this.#port) {
// If we already have a port open but we somehow got a new one,
// discard the old and use the new. Typically not expected unless the engine
// had an error or the page re-requested a new port forcibly.
if (this.#engineStatus === "ready") {
lazy.console.error(
"Received a new translation port while one already existed."
);
}
this.#discardPort();
}
this.#port = port;
const portRequest = this.#portRequest;
// Wire up message handling
port.onmessage = event => {
/** @type {{data: PortToPage}} */
const { data } = /** @type {any} */ (event);
switch (data.type) {
case "TranslationsPort:TranslationResponse": {
const { translationId, targetText } = data;
const request = this.#activeRequests.get(translationId);
if (request) {
this.#activeRequests.delete(translationId);
request.resolve(targetText);
}
break;
}
case "TranslationsPort:GetEngineStatusResponse": {
if (portRequest) {
const { resolve, reject } = portRequest;
if (data.status === "ready") {
resolve();
} else {
reject(new Error("The engine failed to load."));
}
}
this.#engineStatus = data.status;
if (data.status === "ready") {
this.maybeScheduleMoreTranslationRequests();
} else {
for (const translationId of this.#activeRequests.keys()) {
this.preventSingleTranslation(translationId);
}
for (const translationId of this.#unscheduledRequestPriorities.keys()) {
this.preventUnscheduledTranslation(translationId);
}
}
break;
}
case "TranslationsPort:EngineTerminated": {
this.#discardPort();
this.maybeScheduleMoreTranslationRequests();
break;
}
default: {
lazy.console.error("Unknown translations port message:", data);
break;
}
}
};
// Ask for the engine status
port.postMessage({ type: "TranslationsPort:GetEngineStatusRequest" });
}
/**
* Returns a promise that will resolve when we have acquired a valid port.
*
* @returns {Promise<void>}
*/
#getPortRequestPromise() {
if (this.#portRequest) {
// We already have a pending request to acquire a port.
return this.#portRequest.promise;
}
if (this.#engineStatus === "ready") {
// The engine is already ready for translating.
return Promise.resolve();
}
if (this.#port) {
// We already have a port: we don't need another one.
return Promise.resolve();
}
const portRequest = Promise.withResolvers();
this.#portRequest = portRequest;
// Ask the actor for a new port (which eventually calls `acquirePort`).
this.#actorRequestNewPort();
this.#portRequest.promise
.catch(error => {
lazy.console.error(error);
})
.finally(() => {
// If we haven't replaced #portRequest with another request,
// clear it out now that it succeeded.
if (portRequest === this.#portRequest) {
this.#portRequest = null;
}
});
return this.#portRequest.promise;
}
/**
* Close the port and remove any chance of further messages to the TranslationsEngine.
* Any active requests are moved back to the priority stacks from which they were scheduled.
*/
#discardPort() {
this.#preserveActiveRequests();
if (this.#port) {
this.#port.close();
this.#port = null;
this.#portRequest = null;
}
this.#engineStatus = "uninitialized";
}
/**
* Called when the page becomes visible again, e.g. the user was on another tab
* and switched back to this page as the active tab. Any requests that were left
* in the stacks will resume to be scheduled.
*/
async onShowPage() {
this.#isPageShown = true;
this.maybeScheduleMoreTranslationRequests();
}
/**
* Called when the page is hidden, e.g. the user moved to a different tab.
* Any active requests that had been sent to the TranslationsEngine will
* be Cancelled and moved back to the corresponding priority stacks that
* they came from.
*/
async onHidePage() {
this.#isPageShown = false;
if (this.#portRequest) {
//this.#portRequest.reject();
// If the page is hidden while a port request is pending,
// wait for that request to finish so we can move any in-flight
// requests to the temp queue properly.
try {
await this.#portRequest.promise;
} catch {
// If the port request fails while hidden, not much to do.
}
if (this.#isPageShown) {
// The page was re-shown while we were awaiting the pending port request.
return;
}
}
// Discard the port to avoid engine usage while hidden.
this.#discardPort();
}
/**
* Creates a new TranslationRequest, adds it to the stack that corresponds to its priority,
* and returns a promise for the resolution or rejection of the request.
*
* @see {TranslationRequest}
*
* @param {Node} node - The node that corresponds to this translation request.
* @param {string} sourceText - The source text to translate for this request.
* @param {boolean} isHTML - True if the source text is HTML markup, false if it is plain text.
* @param {number} translationId - The translationId that corresponds to this request.
* @param {number} priority - The priority at which this request should be scheduled.
* @returns {Promise<string | null>}
* The translated text, or null if the text is already translated, the request becomes stale, the translation fails.
*/
createTranslationRequestPromise(
node,
sourceText,
isHTML,
translationId,
priority
) {
const { promise, resolve, reject } = Promise.withResolvers();
this.#unscheduledRequestPriorities.set(translationId, priority);
this.#priorityStacks[priority].push({
node,
sourceText,
isHTML,
translationId,
priority,
resolve,
reject,
});
this.maybeScheduleMoreTranslationRequests();
return promise;
}
/**
* Attempts to cancel a translation request if it has not been sent to the TranslationsEngine.
*
* To fully cancel a request regardless of whether it has been scheduled or not,
* use the `cancelSingleTranslation` method.
*
* @see {TranslationScheduler.preventSingleTranslation}
*
* @param {number} translationId - The translationId of the request to cancel.
* @returns {boolean} - True if the request was Cancelled, otherwise false.
*/
preventUnscheduledTranslation(translationId) {
const priority = this.#unscheduledRequestPriorities.get(translationId);
if (priority === undefined) {
// We were unable to retrieve an unscheduled priority for the given translationId.
// This request has likely already been sent to the TranslationsEngine.
return false;
}
const request = this.#priorityStacks[priority].remove(translationId);
if (request) {
request.resolve(null);
}
this.#unscheduledRequestPriorities.delete(translationId);
ChromeUtils.addProfilerMarker(
`TranslationScheduler Cancel P${priority}`,
{ innerWindowId: this.#innerWindowId },
`Cancelled one unscheduled P${priority} translation.`
);
return true;
}
/**
* Cancel a translation request regardless of whether it has been sent to the TranslationsEngine.
*
* For a more conservative method to only cancel a request that has not yet been scheduled,
* use the `maybePreventUnscheduledTranslation` method.
*
* @see {TranslationScheduler.preventUnscheduledTranslation}
*
* @param {number} translationId - The translationId of the request to cancel.
* @returns {{
* didPrevent: boolean,
* didCancelFromScheduler: boolean,
* didCancelFromEngine: boolean,
* }}
*/
preventSingleTranslation(translationId) {
if (this.preventUnscheduledTranslation(translationId)) {
// We successfully canceled this request before it was scheduled: nothing more to do.
return {
didPrevent: true,
didCancelFromScheduler: true,
didCancelFromEngine: false,
};
}
const request = this.#activeRequests.get(translationId);
if (!request) {
// This translation completed before we got a chance to cancel it.
return {
didPrevent: false,
didCancelFromScheduler: false,
didCancelFromEngine: false,
};
}
// If the request is active, then it has been sent to the TranslationsEngine,
// so we must attempt to send a cancel request to the engine as well.
this.#port?.postMessage({
type: "TranslationsPort:CancelSingleTranslation",
translationId,
});
request.resolve(null);
this.#activeRequests.delete(translationId);
ChromeUtils.addProfilerMarker(
`TranslationScheduler Cancel P${request.priority}`,
{ innerWindowId: this.#innerWindowId },
`Cancelled one active P${request.priority} translation.`
);
// We may have cancelled the only active request, which may not receive a response now.
// If so, we need to ensure that we continue to schedule more requests.
this.maybeScheduleMoreTranslationRequests();
return {
didPrevent: true,
didCancelFromScheduler: true,
didCancelFromEngine: true,
};
}
/**
* Returns any active translation request back to the priority stack from which they came.
* Whenever the scheduler resumes scheduling, these requests may be already fulfilled,
* resulting in a no-op, or they will be picked back up where they were left off.
*/
#preserveActiveRequests() {
lazy.console.log(
`Pausing translations with ${this.#activeRequests.size} active translation requests.`
);
if (!this.#hasActiveTranslationRequests()) {
// There are no active requests to unschedule: nothing more to do.
return;
}
for (const request of this.#activeRequests.values()) {
const { translationId, priority } = request;
this.#priorityStacks[priority].push(request);
this.#unscheduledRequestPriorities.set(translationId, priority);
}
this.#activeRequests.clear();
}
/**
* Returns true if the scheduler has few enough quests that it is within the
* final batches that it will schedule until more requests come in.
*
* @returns {boolean}
*/
isWithinFinalBatches() {
return (
this.#maxRequestsPerScheduleEvent >=
this.#pendingTranslationRequestCount()
);
}
/**
* Returns the count of pending translation requests, both active and unscheduled.
*
* @returns {number}
*/
#pendingTranslationRequestCount() {
return this.#activeRequests.size + this.#unscheduledRequestPriorities.size;
}
/**
* Returns true if the scheduler has any requests have been sent to the TranslationsEngine,
* and have not yet received a response, otherwise false.
*
* @returns {boolean}
*/
#hasActiveTranslationRequests() {
return this.#activeRequests.size > 0;
}
/**
* Returns true if the scheduler has any requests that have not yet been sent to the TranslationsEngine,
* and are waiting in a corresponding priority stack to be scheduled, otherwise false.
*
* @returns {boolean}
*/
#hasUnscheduledTranslationRequests() {
return this.#unscheduledRequestPriorities.size > 0;
}
/**
* Returns true if the conditions are met to schedule more requests by sending them to the TranslationsEngine,
* otherwise false if the scheduler should wait longer before sending more requests over the port.
*
* @returns {boolean}
*/
#shouldScheduleMoreTranslationRequests() {
if (!this.#isPageShown) {
// We should not spend CPU time if the page is hidden.
return false;
}
if (this.#portRequest) {
// We are still waiting for a port: we will try again if a port is acquired.
return false;
}
if (this.#port && this.#engineStatus === "uninitialized") {
// We have acquired a port, but we are still waiting for an engine status message.
// We will try again if the engine becomes ready.
return false;
}
if (this.#hasPendingScheduleRequestsCallback) {
// There is already a pending callback to schedule more requests.
return false;
}
if (
this.#activeRequests.size > TranslationScheduler.ACTIVE_REQUEST_THRESHOLD
) {
// There are too many active requests to schedule any more right now.
return false;
}
if (!this.#hasUnscheduledTranslationRequests()) {
// There are no unscheduled requests to be sent to the TranslationsEngine.
return false;
}
return true;
}
/**
* Schedules another batch of requests by sending them to the TranslationsEngine,
* only if it makes sense to do so.
*/
maybeScheduleMoreTranslationRequests() {
if (!this.#shouldScheduleMoreTranslationRequests()) {
// The conditions are not currently right to schedule more requests.
return;
}
this.#hasPendingScheduleRequestsCallback = true;
lazy.setTimeout(() => {
this.#getPortRequestPromise()
.then(this.#scheduleMoreTranslationRequests)
.catch(error => {
lazy.console.error(error);
this.#hasPendingScheduleRequestsCallback = false;
});
}, 0);
}
/**
* Schedules a batch of requests from the given stack by sending them to the TranslationsEngine.
*
* @param {AntiStarvationStack} stack - The stack from which to schedule the batch of requests.
* @returns {boolean} - Returns true if starvation was detected in this stack, otherwise false.
*/
#scheduleBatchFromStack(stack) {
const { starvationDetected, requests } = stack.popBatch();
for (const request of requests) {
this.#maybeScheduleTranslationRequest(request);
}
return starvationDetected;
}
/**
* Schedules another batch of requests from the priority stacks by sending them to the TranslationsEngine.
* How many requests are scheduled, and from which stacks, will depend on the current state of the stacks.
*
* This function is intentionally written as a lambda so that it can be passed as a
* callback without the need to explicitly bind `this` to the function object.
*/
#scheduleMoreTranslationRequests = () => {
if (!this.#port) {
// We lost our port between when this function was registered on the event loop, and when it was invoked.
// The best we can do is possibly try again, if the conditions are still right.
this.#hasPendingScheduleRequestsCallback = false;
this.maybeScheduleMoreTranslationRequests();
return;
}
let stackSizesAtStart = null;
const activeRequestsAtStart = this.#activeRequests.size;
const unscheduledRequestsAtStart = this.#unscheduledRequestPriorities.size;
if (Services.profiler.IsActive() || lazy.console.shouldLog("Debug")) {
// We need to preserve the sizes prior to scheduling only if we are adding profiler markers,
// or if we are logging to console debug. Otherwise we shouldn't bother with these computations.
stackSizesAtStart = this.#priorityStacks.map(stack => stack.size);
}
// Schedule only as many requests as we are required to in order to achieve starvation fairness,
// starting with the highest-priority stack and moving toward the lower-priority stacks.
for (const stack of this.#priorityStacks) {
const starvationDetected = this.#scheduleBatchFromStack(stack);
if (stack.size === 0) {
// This stack is now empty, so we are clear to schedule more lower-priority requests.
continue;
}
if (starvationDetected) {
// This stack is starving (i.e. more requests are being added than are being scheduled),
// so we must process a batch of lower-priority requests on this cycle in order to keep
// the priority-scheduling algorithm fair, otherwise we could, in theory, only ever process
// the current-level stack if new requests of the same priority continue to come in at a high rate.
continue;
}
// We just scheduled a batch of requests from the highest-relevant-priority stack, and the count of requests
// in that stack is decreasing. We should break here so as not to schedule any lower-priority requests before
// we absolutely need to. The lower-priority requests may be justifiably cancelled before we get to them,
// such as being re-prioritized or removed if the user scrolls around the page. In the event that they are
// not cancelled, then they are guaranteed to be scheduled eventually, either due to starvation fairness,
// or simply when it is their turn after processing all of the higher-priority requests first.
break;
}
this.#maybeAddProfilerMarkersForStacks(stackSizesAtStart);
this.#maybeLogStackDataToConsoleDebug(
stackSizesAtStart,
activeRequestsAtStart,
unscheduledRequestsAtStart
);
this.#hasPendingScheduleRequestsCallback = false;
};
/**
* If actively profiling, adds a marker for how many requests wre scheduled from each stack, if any.
*
* Normally, we would rely on `ChromeUtils.addProfilerMarker()` itself to no-op if not profiling,
* however there are calculations and conditions for whether or not to post a marker, and scheduling
* happens quite frequently, so it is best to not waste time with these calculations if not profiling.
*
* @param {Array<number>?} stackSizesAtStart The size of each stack prior to the slice of scheduling that just occurred.
*/
#maybeAddProfilerMarkersForStacks(stackSizesAtStart) {
if (!stackSizesAtStart || !Services.profiler.IsActive()) {
return;
}
for (let priority = 0; priority < stackSizesAtStart.length; ++priority) {
const scheduledCount =
stackSizesAtStart[priority] - this.#priorityStacks[priority].size;
if (scheduledCount > 0) {
ChromeUtils.addProfilerMarker(
`TranslationScheduler Send P${priority}`,
{ innerWindowId: this.#innerWindowId },
`Posted ${scheduledCount} P${priority} translation requests.`
);
}
}
}
/**
* If "Debug" is available, logs how many requests were scheduled from each stack on this scheduling pass, starting
* with the highest-priority stack and logging through to the lowest-priority stack that scheduled any request.
*
* Normally, we would rely on `lazy.console.debug()` itself to no-op if "Debug" does not lie within the max log level,
* however there are calculations and conditions related to formatting this log nicely in the console, and scheduling
* happens quite frequently, so it is best to not waste time with these calculations if we will not log them at all.
*
* Example:
*
* "Scheduler(_1 | 422) [ __1, 165, 132, __1, 106, __1, __8, __8 ] => P0(__1), P1(__2)"
* ╻ ╻ ╻ ╻ ╻ ╻ ╻ ╻ ╻ ╻ ╻ ╻
* │ │ │ │ │ │ │ │ │ │ │ │
* │ │ │ │ │ │ │ │ │ │ │ 2 P1 requests were scheduled in this batch.
* │ │ │ │ │ │ │ │ │ │ │
* │ │ │ │ │ │ │ │ │ │ 1 P0 request was scheduled in this batch.
* │ │ │ │ │ │ │ │ │ │
* │ │ │ │ │ │ │ │ │ There are 8 P7 requests
* │ │ │ │ │ │ │ │ │
* │ │ │ │ │ │ │ │ There are 8 P6 requests.
* │ │ │ │ │ │ │ │
* │ │ │ │ │ │ │ There is 1 P5 request.
* │ │ │ │ │ │ │
* │ │ │ │ │ │ There are 106 P4 requests.
* │ │ │ │ │ │
* │ │ │ │ │ There is 1 P3 request.
* │ │ │ │ │
* │ │ │ │ There are 132 P2 requests.
* │ │ │ │
* │ │ │ There are 165 P1 requests.
* │ │ │
* │ │ There is 1 P0 request.
* │ │
* │ There are 422 pending requests.
* │
* There is 1 active request.
*
* @param {Array<number>?} stackSizesAtStart The size of each stack prior to the slice of scheduling that just occurred.
* @param {number} activeRequestsAtStart - The number of active requests that the TranslationsEngine was processing at the
* moment we scheduled more requests from the stacks.
* @param {number} unscheduledRequestsAtStart - The number of unscheduled requests that the TranslationsEngine was processing
* at the moment we scheduled more requests from the stacks.
*/
#maybeLogStackDataToConsoleDebug(
stackSizesAtStart,
activeRequestsAtStart,
unscheduledRequestsAtStart
) {
if (!stackSizesAtStart || !lazy.console.shouldLog("Debug")) {
return;
}
// Find the deepest priority stack that scheduled any requests.
let maxStackDepth;
for (let depth = stackSizesAtStart.length - 1; depth >= 0; --depth) {
if (this.#priorityStacks[depth].size < stackSizesAtStart[depth]) {
maxStackDepth = depth;
break;
}
}
if (maxStackDepth === undefined) {
// No requests were scheduled on this pass.
return;
}
const padLength = Math.max(
3,
...stackSizesAtStart.map(n => String(n).length)
);
const segments = [];
for (let priority = 0; priority <= maxStackDepth; ++priority) {
const sizeAtStart = stackSizesAtStart[priority];
const currentSize = this.#priorityStacks[priority].size;
const scheduledCount = sizeAtStart - currentSize;
const formatted =
scheduledCount === 0
? "_".repeat(padLength)
: String(scheduledCount).padStart(padLength, "_");
segments.push(`P${priority}(${formatted})`);
}
const activeRequestsPadLength = String(
this.#maxRequestsPerScheduleEvent
).length;
const activeRequestsString =
activeRequestsAtStart === 0
? "_".repeat(activeRequestsPadLength)
: String(activeRequestsAtStart).padStart(activeRequestsPadLength, "_");
const unscheduledRequestsString = String(
unscheduledRequestsAtStart
).padStart(3, "_");
lazy.console.debug(
`Scheduler(${activeRequestsString} | ${unscheduledRequestsString}) ` +
TranslationScheduler.#formatSizesAtStart(stackSizesAtStart) +
` => ${segments.join(", ")}`
);
}
/**
* Formats the sizes of each priority stack into a string that is nice to look
* at in the JS console.
*
* Example:
*
* "[ __1, 165, 132, __1, 106, __1, __8, __8 ]"
* // P0 P1 P2 P3 P4 P5 P6 P7
*
* @param {Array<number>} stackSizesAtStart
*/
static #formatSizesAtStart(stackSizesAtStart) {
const padLength = Math.max(
3,
...stackSizesAtStart.map(n => String(Math.abs(n)).length)
);
const segments = stackSizesAtStart.map(n =>
n === 0 ? "_".repeat(padLength) : String(n).padStart(padLength, "_")
);
return `[ ${segments.join(", ")} ]`;
}
/**
* Schedules the translation request by sending it to the TranslationsEngine only
* if the node that is relevant to the request is not detached.
*
* @param {TranslationRequest} request
*/
#maybeScheduleTranslationRequest(request) {
const { node } = request;
if (isNodeDetached(node)) {
// If the node is dead, there is no need to schedule it.
const { translationId, resolve } = request;
this.#unscheduledRequestPriorities.delete(translationId);
resolve(null);
return;
}
this.#scheduleTranslationRequest(request);
}
/**
* Schedules a translation request by sending it to the TranslationsEngine,
* marking the request as active.
*
* @param {TranslationRequest} request
*/
#scheduleTranslationRequest(request) {
if (!this.#port) {
// This should never happen, since we should only be scheduling requests under
// circumstances in which we are certain that we have a valid port.
lazy.console.error(
"Attempt to schedule a translation request without a port."
);
// If this should ever happen, the best thing we can do to recover is to put
// the request back onto its corresponding priority stack to be scheduled again.
const { priority } = request;
this.#priorityStacks[priority].push(request);
return;
}
const { translationId, sourceText, isHTML } = request;
this.#activeRequests.set(translationId, request);
this.#unscheduledRequestPriorities.delete(translationId);
if (this.#translationsCache.isAlreadyTranslated(sourceText, isHTML)) {
// Our cache indicates that the text that is being sent to translate is an exact
// match to the translated output text of a previous request. When this happens
// we should simply signal to the engine that this is a no-op, rather than
// attempting to re-translate text that is already in the target language.
//
// This can happen in cases where a website removes already-translated content,
// and then puts it back in the same spot, triggering our mutation observers.
//
// Wikipedia does this, for example, with the "title" attributes on hyperlinks
// nearly every time they are moused over.
this.#port.postMessage({
type: "TranslationsPort:Passthrough",
translationId,
});
return;
}
const cachedTranslation = this.#translationsCache.get(sourceText, isHTML);
if (cachedTranslation) {
// We already have a matching translated output for this source text, but
// it was not hot in the cache when this request was sent to the translator,
// otherwise the TranslationsDocument would have handled it directly.
//
// This may happen when several nodes with identical text get queued for translation
// all at the same time, while the cache was still cold, such as translating a nested
// comment section with multiple collapsed expandable threads that say "2 replies".
//
// We will signal to the engine to simply pass the cached translation along as
// the response instead of wasting CPU time trying to recompute the translation.
this.#port.postMessage({
type: "TranslationsPort:CachedTranslation",
translationId,
cachedTranslation,
});
return;
}
this.#port.postMessage({
type: "TranslationsPort:TranslationRequest",
translationId,
sourceText,
isHTML,
});
}
/**
* Cleans up everything, closing the port and removing all translation request data.
*/
destroy() {
this.#port?.close();
this.#port = null;
this.#portRequest?.reject();
this.#portRequest = null;
this.#engineStatus = "uninitialized";
this.#activeRequests.clear();
this.#unscheduledRequestPriorities.clear();
for (const stack of this.#priorityStacks) {
stack.clear();
}
}
}
/**
* Returns true if an HTML element is hidden based on factors such as collapsed state and
* computed style, otherwise false.
*
* @param {HTMLElement} element
* @returns {boolean}
*/
function isHTMLElementHidden(element) {
// This is a cheap and easy check that will not compute style or force reflow.
if (element.hidden) {
// The element is explicitly hidden.
return true;
}
// Handle open/closed <details> elements. This will also not compute style or force reflow.
// https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/details
if (
// The element is within a closed <details>
element.closest("details:not([open])") &&
// The element is not part of the <summary> of the <details>, which is always visible, even when closed.
!element.closest("summary")
) {
// The element is within a closed <details> and is not part of the <summary>, therefore it is not visible.
return true;
}
// This forces reflow, which has a performance cost, but this is also what JQuery uses for its :hidden and :visible.
// https://github.com/jquery/jquery/blob/bd6b453b7effa78b292812dbe218491624994526/src/css/hiddenVisibleSelectors.js#L1-L10
if (
!(
element.offsetWidth ||
element.offsetHeight ||
element.getClientRects().length
)
) {
return true;
}
const { ownerGlobal } = element;
if (!ownerGlobal) {
// We cannot compute the style without ownerGlobal, so we will assume it is not visible.
return true;
}
// This flushes the style, which is a performance cost.
const style = ownerGlobal.getComputedStyle(element);
if (!style) {
// We were unable to compute the style, so we will assume it is not visible.
return true;
}
// This is an issue with the DOM library generation.
// @ts-expect-error Property 'display' does not exist on type 'CSSStyleDeclaration'.ts(2339)
const { display, visibility, opacity } = style;
return (
display === "none" ||
visibility === "hidden" ||
visibility === "collapse" ||
opacity === "0"
);
}
/**
* This function returns the correct element to determine the
* style of node.
*
* @param {Node} node
*
* @returns {HTMLElement | null}
*/
function getHTMLElementForStyle(node) {
const element = asHTMLElement(node);
if (element) {
return element;
}
if (node.parentElement) {
return asHTMLElement(node.parentElement);
}
// For cases like text node where its parent is ShadowRoot,
// we'd like to use flattenedTreeParentNode
if (node.flattenedTreeParentNode) {
return asHTMLElement(node.flattenedTreeParentNode);
}
// If the text node is not connected or doesn't have a frame.
return null;
}
/**
* Gets the spatial context of the node with respect to the viewport.
*
* If the node lies entirely to the left or entirely to the right of the viewport,
* this takes precedence over whether the node is entirely above or below the viewport.
*
* For example, if a node is both entirely above, and entirely to the right of the
* viewport, then the returned context will be "right".
*
* If any part of a node's bounding box lies within the viewport then the context
* is considered "within".
*
* @param {Node} node
*
* @returns {NodeSpatialContext}
*/
function getNodeSpatialContext(node) {
const window = node.ownerGlobal;
const document = node.ownerDocument;
if (!window || !document || !document.documentElement) {
// We won't be able to calculate the spatial context for this node.
return {};
}
const element = getHTMLElementForStyle(node);
if (!element) {
// We only calculate the spatial context for HTML elements.
return {};
}
if (isHTMLElementHidden(element)) {
// If the element is hidden, then the spatial context is not important.
return {};
}
const { top, right, bottom, left } = element.getBoundingClientRect();
const viewportHeight =
window.innerHeight || document.documentElement.clientHeight;
const viewportWidth =
window.innerWidth || document.documentElement.clientWidth;
/** @type {NodeSpatialContext} */
let spatialContext = { top, left, right, viewportContext: undefined };
if (right < 0) {
// The node is entirely to the left of the viewport.
spatialContext.viewportContext = "left";
return spatialContext;
}
if (left > viewportWidth) {
// The node is entirely to the right of the viewport.
spatialContext.viewportContext = "right";
return spatialContext;
}
if (bottom < 0) {
// The node is entirely above the viewport.
spatialContext.viewportContext = "above";
return spatialContext;
}
if (top > viewportHeight) {
// The node is entirely below the viewport.
spatialContext.viewportContext = "below";
return spatialContext;
}
// The node must be within the viewport.
spatialContext.viewportContext = "within";
return spatialContext;
}
/**
* Actually perform the update of the element with the translated node. This step
* will detach all of the "live" nodes, and match them up in the correct order as provided
* by the translations engine.
*
* @param {Document} translationsDocument
* @param {Element} element
*
* @returns {void}
*/
function updateElement(translationsDocument, element) {
// This text should have the same layout as the target, but it's not completely
// guaranteed since the content page could change at any time, and the translation process is async.
//
// The document has the following structure:
//
// <html>
// <head>
// <body>{translated content}</body>
// </html>
const originalHTML = element.innerHTML;
/**
* The Set of translation IDs for nodes that have been cloned.
*
* @type {Set<string>}
*/
const clonedNodes = new Set();
// Guard against unintended changes to the "value" of <option> elements during
// translation. This issue occurs because if an <option> element lacks an explicitly
// set "value" attribute, then the default "value" will be taken from the text content
// when requested.
//
// For example, <option>dog</option> might be translated to <option>perro</option>.
// Without an explicit "value", the implicit "value" would change from "dog" to "perro",
// and this can cause problems for submissions to queries etc.
//
// To prevent this, we ensure every translated <option> has an explicit "value"
// attribute, either preserving the original "value" or assigning it from the original
// text content. This results in <option>dog</option> being translated to
// <option value="dog">perro</option>
//
// https://developer.mozilla.org/en-US/docs/Web/HTML/Element/option#value
if (element.tagName === "OPTION") {
element.setAttribute(
"value",
/** @type {HTMLOptionElement} */ (element).value
);
}
for (const option of element.querySelectorAll("option")) {
option.setAttribute("value", option.value);
}
/**
* Build up a mapping of any element that has a "value" field that may change based
* on translations. In the recursive "merge" function below, we can remove <option>
* elements from <select> elements, which could cause the value attribute to change
* as the option is removed. This will need to be restored.
*
* @type {Map<Node, string>}
*/
const nodeValues = new Map();
for (const select of element.querySelectorAll("select")) {
nodeValues.set(select, select.value);
}
const firstChild = translationsDocument.body?.firstChild;
if (firstChild) {
merge(element, firstChild);
}
// Restore the <select> values.
if (element.tagName === "SELECT") {
/** @type {HTMLSelectElement} */ (element).value =
nodeValues.get(element) ?? "";
}
for (const select of element.querySelectorAll("select")) {
select.value = nodeValues.get(select);
}
/**
* Merge the live tree with the translated tree by re-using elements from the live tree.
*
* @param {Element} liveTree
* @param {Node} translatedTree
*/
function merge(liveTree, translatedTree) {
/** @type {Map<string, Element>} */
const liveElementsById = new Map();
/** @type {Array<Text>} */
const liveTextNodes = [];
// Remove all the nodes from the liveTree, and categorize them by Text node or
// Element node.
/** @type {Node | null} */
let node;
while ((node = liveTree.firstChild)) {
// This is a ChildNode with the `remove` method.
const childNode = /** @type {ChildNode} */ (
/** @type {unknown} */ (node)
);
childNode.remove();
const childElement = asElement(node);
const childTextNode = asTextNode(node);
const dataset = getDataset(childElement);
if (childElement && dataset) {
liveElementsById.set(dataset.mozTranslationsId, childElement);
} else if (childTextNode) {
liveTextNodes.push(childTextNode);
}
}
// The translated tree dictates the order.
/** @type {Node[]} */
const translatedNodes = [];
for (const childNode of translatedTree.childNodes) {
if (childNode) {
translatedNodes.push(childNode);
}
}
for (
let translatedIndex = 0;
translatedIndex < translatedNodes.length;
translatedIndex++
) {
const translatedNode = ensureExists(translatedNodes[translatedIndex]);
const translatedTextNode = asTextNode(translatedNode);
const translatedElement = asElement(translatedNode);
const dataset = getDataset(translatedElement);
if (translatedTextNode) {
// Copy the translated text to the original Text node and re-append it.
let liveTextNode = liveTextNodes.shift();
if (liveTextNode) {
liveTextNode.data = translatedTextNode.data;
} else {
liveTextNode = translatedTextNode;
}
liveTree.appendChild(liveTextNode);
} else if (dataset) {
const liveElementId = dataset.mozTranslationsId;
// Element nodes try to use the already existing DOM nodes.
// Find the element in the live tree that matches the one in the translated tree.
let liveElement = liveElementsById.get(liveElementId);
if (!liveElement) {
lazy.console.warn("Could not find a corresponding live element", {
path: createNodePath(translatedNode, translationsDocument.body),
liveElementId,
liveElementsById,
translatedNode,
});
continue;
}
// Has this element already been added to the list? Then duplicate it and re-add
// it as a clone. The Translations Engine can sometimes duplicate HTML.
if (liveElement.parentNode) {
liveElement = ensureExists(
asElement(liveElement.cloneNode(true /* deep clone */))
);
clonedNodes.add(liveElementId);
lazy.console.warn(
"Cloning a node because it was already inserted earlier",
{
path: createNodePath(translatedNode, translationsDocument.body),
translatedNode,
liveElement,
}
);
}
if (isNodeTextEmpty(translatedNode) && !isNodeTextEmpty(liveElement)) {
// The translated node has no text, but the original node does have text, so we should investigate.
//
// Note that it is perfectly fine if both the translated node and original node do not have text.
// This occurs when attributes are translated on the node, but no text content was translated.
//
// However, since we have a case where the original node has text and the translated node does not,
// this scenario may be caused by one of two situations:
//
// 1) The element was duplicated by translation but then not given text
// content. This happens on Wikipedia articles for example.
//
// 2) The translator messed up and could not translate the text. This
// happens on YouTube in the language selector. In that case, having the
// original text is much better than no text at all.
//
// To make sure it is case 1) and not case 2), check whether this is the only occurrence.
for (let i = 0; i < translatedNodes.length; i++) {
if (translatedIndex === i) {
// This is the current node, not a sibling.
continue;
}
const sibling = translatedNodes[i];
const siblingDataset = getDataset(asElement(sibling));
if (
// Only consider other element nodes.
sibling.nodeType === Node.ELEMENT_NODE &&
// If the sibling's mozTranslationsId matches, then use the sibling's
// node instead.
liveElementId === siblingDataset?.mozTranslationsId
) {
// This is case 1 from above. Remove this element's original text nodes,
// since a sibling text node now has all of the text nodes.
removeTextNodes(liveElement);
}
}
// Report this issue to the console.
lazy.console.warn(
"The translated element has no text even though the original did.",
{
path: createNodePath(translatedNode, translationsDocument.body),
translatedNode,
liveElement,
}
);
} else if (!isNodeTextEmpty(liveElement)) {
// There are still text nodes to find and update, recursively merge.
merge(liveElement, translatedNode);
}
// Put the live node back in the live branch. But now t has been synced with the
// translated text and order.
liveTree.appendChild(liveElement);
}
}
const unhandledElements = [...liveElementsById].filter(
([, liveElement]) => !liveElement.parentNode
);
for (node of liveTree.querySelectorAll("*")) {
const dataset = getDataset(asElement(node));
if (dataset) {
// Clean-up the live element ids.
delete dataset.mozTranslationsId;
}
}
if (unhandledElements.length) {
lazy.console.warn(
`${createNodePath(
translatedTree,
translationsDocument.body
)} Not all nodes unified`,
{
unhandledElements,
clonedNodes,
originalHTML,
translatedContent: translationsDocument.body?.innerHTML,
liveTree: liveTree.outerHTML,
translatedTree: asElement(translatedTree)?.outerHTML,
}
);
}
}
}
/**
* For debug purposes, compute a string path to an element.
*
* e.g. "div/div#header/p.bold.string/a"
*
* @param {Node} node
* @param {HTMLElement | null} [root]
*
* @returns {string}
*/
function createNodePath(node, root) {
let path = "";
if (!node.ownerDocument) {
return path;
}
if (root === null) {
root = node.ownerDocument.body;
}
if (node.parentNode && node.parentNode !== root) {
path = createNodePath(node.parentNode, root);
}
path += `/${node.nodeName}`;
const element = asElement(node);
if (element) {
if (element.id) {
path += `#${element.id}`;
} else if (element.className) {
for (const className of element.classList) {
path += "." + className;
}
}
}
return path;
}
/**
* Returns true if the content of this node's text is empty, otherwise false.
*
* @param {Node} node
*
* @returns {boolean}
*/
function isNodeTextEmpty(node) {
const htmlElement = asHTMLElement(node);
if (htmlElement) {
return htmlElement.innerText.trim().length === 0;
}
if (node.nodeType === Node.TEXT_NODE && node.nodeValue) {
return node.nodeValue.trim().length === 0;
}
return true;
}
/**
* Recursively removes text nodes from the given element and all of its children.
*
* @param {Node} node
*/
function removeTextNodes(node) {
for (const child of node.childNodes) {
switch (child?.nodeType) {
case Node.TEXT_NODE: {
node.removeChild(child);
break;
}
case Node.ELEMENT_NODE: {
removeTextNodes(child);
break;
}
default: {
break;
}
}
}
}
/**
* Test whether any of the direct child text nodes of are non-whitespace text nodes.
*
* For example:
* - `<p>test</p>`: yes
* - `<p> </p>`: no
* - `<p><b>test</b></p>`: no
*
* @param {Node} node
*
* @returns {boolean}
*/
function hasNonWhitespaceTextNodes(node) {
if (node.nodeType !== Node.ELEMENT_NODE) {
// Only check element nodes.
return false;
}
for (const child of node.childNodes) {
const textNode = asTextNode(child);
if (textNode) {
if (!textNode.textContent?.trim()) {
// This is just whitespace.
continue;
}
// A text node with content was found.
return true;
}
}
// No text nodes were found.
return false;
}
/**
* Like `#isExcludedNode` but looks at the full subtree. Used to see whether
* we can submit a subtree, or whether we should split it into smaller
* branches first to try to exclude more of the non-translatable content.
*
* @param {Node} node
* @param {string} excludedNodeSelector
*
* @returns {boolean}
*/
function containsExcludedNode(node, excludedNodeSelector) {
return Boolean(asElement(node)?.querySelector(excludedNodeSelector));
}
/**
*
* Check if this node or its parent's node is already included in the given Map or Set.
*
* @param {Node} node
* @param { Map<Node, Set<Node>> } map
*
* @returns {boolean}
*/
function nodeOrParentIncludesItself(node, map) {
if (map.size === 0) {
return false;
}
if (map.get(node)?.has(node)) {
return true;
}
// If the immediate parent is the body, it is allowed.
if (node.parentNode === node.ownerDocument?.body) {
return false;
}
// Accessing the parentNode is expensive here according to performance profiling. This
// is due to XrayWrappers. Minimize reading attributes by storing a reference to the
// `parentNode` in a named variable, rather than re-accessing it.
/** @type {Node | null} */
let parentNode;
let lastNode = node;
while ((parentNode = lastNode.parentNode)) {
if (map.get(parentNode)?.has(parentNode)) {
return true;
}
lastNode = parentNode;
}
return false;
}
/**
* Reads the elements computed style and determines if the element is a block-like
* element or not. Every element that lays out like a block should be sent in as one
* cohesive unit to be translated.
*
* @param {Node} node
*
* @returns {boolean}
*/
function getIsBlockLike(node) {
const element = asElement(node);
if (!element) {
return false;
}
const { ownerGlobal } = element;
if (!ownerGlobal) {
return false;
}
if (element.namespaceURI === "http://www.w3.org/2000/svg") {
// SVG elements will report as inline, but there is no block layout in SVG.
// Treat every SVG element as being block so that every node will be subdivided.
return true;
}
/** @type {Record<string, string>} */
// @ts-expect-error - This is a workaround for the CSSStyleDeclaration not being indexable.
const style = ownerGlobal.getComputedStyle(element) ?? { display: null };
return style.display !== "inline" && style.display !== "none";
}
/**
* Determine if this element is an inline element or a block element. Inline elements
* should be sent as a contiguous chunk of text, while block elements should be further
* subdivided before sending them in for translation.
*
* @param {Node} node
*
* @returns {boolean}
*/
function nodeNeedsSubdividing(node) {
const element = asElement(node);
if (!element) {
// Only elements need to be further subdivided.
return false;
}
for (let childNode of element.childNodes) {
if (!childNode) {
continue;
}
switch (childNode.nodeType) {
case Node.TEXT_NODE: {
// Keep checking for more inline or text nodes.
continue;
}
case Node.ELEMENT_NODE: {
if (getIsBlockLike(childNode)) {
// This node is a block node, so it needs further subdividing.
return true;
} else if (nodeNeedsSubdividing(childNode)) {
// This non-block-like node may contain other block-like nodes.
return true;
}
// Keep checking for more inline or text nodes.
continue;
}
default: {
return true;
}
}
}
return false;
}
/**
* Returns an iterator of a node's ancestors.
*
* @param {Node} node
*
* @returns {Generator<Node>}
*/
function* getAncestorsIterator(node) {
const document = node.ownerDocument;
if (!document) {
return;
}
for (
let parent = node.parentNode;
parent && parent !== document.documentElement;
parent = parent.parentNode
) {
yield parent;
}
}
/**
* Determines whether an attribute on a given element is translatable based on the specified
* criteria for TRANSLATABLE_ATTRIBUTES.
*
* @see TRANSLATABLE_ATTRIBUTES
*
* @param {Node} node - The DOM node on which the attribute is being checked.
* @param {string} attribute - The attribute name to check for translatability.
*
* @returns {boolean}
*/
function isAttributeTranslatable(node, attribute) {
const element = asHTMLElement(node);
if (!element) {
return false;
}
if (!element.hasAttribute(attribute)) {
// The element does not have this attribute, so there is nothing to translate.
return false;
}
if (!TRANSLATABLE_ATTRIBUTES.has(attribute)) {
// The attribute is not listed in our translatable attributes, so we will not translate it.
return false;
}
const criteria = TRANSLATABLE_ATTRIBUTES.get(attribute);
if (!criteria) {
// There are no further criteria specified for this attribute, so we translate this attribute for all elements.
return true;
}
// There are further criteria specified, so attempt to find a matching criterion for the given element.
return criteria.some(({ tagName, conditions }) => {
if (tagName !== element.tagName) {
// The tagName does not match the given element. Try the next criterion.
return false;
}
if (!conditions) {
// The tagName matches and there are no further conditions, so we always translate this attribute for this element.
return true;
}
// The tagName matches, but further conditions are specified. Attempt to find a matching condition.
return Object.entries(conditions).some(([key, values]) =>
values.some(value => element.getAttribute(key) === value)
);
});
}
/**
* Returns true if the node is dead or detached from the DOM, otherwise false if the nod is still live.
*
* @param {Node} node
*
* @returns {boolean}
*/
function isNodeDetached(node) {
return (
// This node is out of the DOM and already garbage collected.
Cu.isDeadWrapper(node) ||
// The node is detached, but not yet garbage collected,
// or it has been re-parented to a parent that itself is not connected.
!node.isConnected ||
// Normally you could just check `node.parentElement` to see if an element is
// part of the DOM, but the Chrome-only flattenedTreeParentNode is used to include
// Shadow DOM elements, which have a null parentElement.
!node.flattenedTreeParentNode
);
}
/**
* Use TypeScript to determine if the Node is an Element.
*
* @param {Node | null | undefined} node
*
* @returns {Element | null}
*/
function asElement(node) {
if (node?.nodeType === Node.ELEMENT_NODE) {
return /** @type {HTMLElement} */ (node);
}
return null;
}
/**
* Use TypeScript to determine if the Node is an Element.
*
* @param {Node | null} node
*
* @returns {Text | null}
*/
function asTextNode(node) {
if (node?.nodeType === Node.TEXT_NODE) {
return /** @type {Text} */ (node);
}
return null;
}
/**
* Use TypeScript to determine if the Node is an HTMLElement.
*
* @param {Node | null} node
*
* @returns {HTMLElement | null}
*/
function asHTMLElement(node) {
// This is a chrome-only function, and is the recommended function for chrome
// contexts. The TranslationsDocument could be used in non-chrome contexts in the
// future, so ensure that this doesn't break future implementations.
//
// See - https://icecat-source-docs.mozilla.org/code-quality/lint/linters/eslint-plugin-mozilla/rules/use-isInstance.html
if (HTMLElement.isInstance) {
if (HTMLElement.isInstance(node)) {
return /** @type {HTMLElement} */ (node);
}
} else if (
// eslint-disable-next-line mozilla/use-isInstance
node instanceof HTMLElement
) {
return /** @type {HTMLElement} */ (node);
}
return null;
}
/**
* @template T
* @param {T | null | undefined} item
*
* @returns {T}
*/
function ensureExists(item, message = "Item did not exist") {
if (item === null || item === undefined) {
throw new Error(message);
}
return item;
}
/**
* Get the ShadowRoot from the chrome-only openOrClosedShadowRoot API.
*
* @param {Node} node
*
* @returns {ShadowRoot | null}
*/
function getShadowRoot(node) {
return asElement(node)?.openOrClosedShadowRoot ?? null;
}
/**
* Workaround the Gecko DOM TypeScript definition for dataset.
*
* @param {Element | null | undefined} element
*
* @returns {Record<string, string> | null}
*/
function getDataset(element) {
// @ts-expect-error Type 'DOMStringMap' is not assignable to type 'Record<string, string>'.
return element?.dataset ?? null;
}
/**
* Removes any data-moz-translations-id values from a node and its children.
*
* @param {Node} node
*/
function removeMozTranslationsIds(node) {
const element = asHTMLElement(node);
if (!element) {
return;
}
if (isNodeDetached(element)) {
return;
}
const dataset = getDataset(element);
if (dataset) {
delete dataset.mozTranslationsId;
}
for (const childNode of element.querySelectorAll(
"[data-moz-translations-id]"
)) {
delete childNode.dataset.mozTranslationsId;
}
}
/**
* Removes the entry pertaining to the inner key of a nested map structure,
* ensuring that if the inner structure becomes empty, then the outer key
* will also be removed from the outer structure.
*
* @typedef {Element} OuterKey
* @typedef {Node | string} InnerKey
* @typedef {number} Value
*
* @param {Map<OuterKey, (Set<InnerKey> | Map<InnerKey, Value>)>} outerMap
* @param {OuterKey} outerKey
* @param {InnerKey} innerKey
*
* @returns {{ didDeleteOuterEntry: boolean, didDeleteInnerEntry: boolean }}
*/
function deleteFromNestedMap(outerMap, outerKey, innerKey) {
const innerStructure = outerMap.get(outerKey);
const didDeleteInnerEntry =
!!innerStructure && innerStructure.delete(innerKey);
const didDeleteOuterEntry = !innerStructure || innerStructure.size === 0;
if (didDeleteOuterEntry) {
// The inner structure is now empty after removing the inner-key entry.
// Ensure that the inner structure itself is removed from the outer map.
outerMap.delete(outerKey);
}
return { didDeleteOuterEntry, didDeleteInnerEntry };
}