Repository navigation
Using Fluent with a lot of roots is slow #648
Description
See the bugzilla bug here => https://bugzilla.mozilla.org/show_bug.cgi?id=1988776
STR:
- Open the attachment of that bug
- Click the button at the top
and note that it takes ~1 seconde to update.
The source code for this is https://github.com/julienw/mutationobserver-issue
The context: I'm building a Lit-based app using mozilla-central MozLitElement. This is a wrapper on the upstream LitElement class that handles localization through Fluent by using Fluent's connectRoot on every shadow root: https://searchfox.org/firefox-main/rev/938e8f38c6765875e998d5c2965ad5864f5a5ee2/toolkit/content/widgets/lit-utils.mjs#160
This in turns calls observe on this root:
fluent.js/fluent-dom/src/dom_localization.js
Line 149 in 9a18331
| this.mutationObserver.observe(newRoot, this.observerConfig); |
What happens is that Lit updates the page by removing or adding some nodes.
Then a lot of these nodes are registering or unregistering themselves with Fluent as roots.
When the node is removed from the DOM, disconnectRoot is called, then it self calls observer.disconnect() (from pauseObserving) then observer.observe() (from resumeObserving) on all roots again. And we end up doing that a lot as a result of lit removing elements. The testcase shows this case when it displays less rows than the previous step.
Also applyTranslations does that, which means we do that once per root that has a change. The testcase shows this case when it displays more rows than the previous step.
So if I'm not wrong the complexity is n².
Now if inside the MutationObserver implementation itself there's another loop (for example in getReceiverFor), does that make it n³?
Possible solutions:
- change the MutationObserver API to expose an
unobservemethod so that we can unobserve just one root. But this would need a spec change :/ - have one MutationObserver object for each root. (but I recall a performance issue when doing that for ResizeObserver, so we might have surprises doing that too)
- instead of
disconnectandobserve-ing again, use a local boolean state that would be used inside the MutationObserver callback. One drawback is that when translating a large page, Firefox will collect all changes to call the callback, that would be discarded, and that might be a performance issue. Also I'm not sure of the timing of calling the MutationObserver callback and so this could be prone to races.
For now I fixed my issue by, instead of adding/removing nodes, controlling their CSS display property. Moving forward it could be a good idea to integrate into Lit's localization library in https://github.com/lit/lit/tree/main/packages/localize-tools/src/formatters. Another option would be to have a converter from FTL files to XLIFF files (but I don't know if all features of FTL are convertible to XLIFF) so that the Lit machinery could consome them.
Activity
eemeli commentedon Sep 18, 2025
There might be some better balance achievable by batching roots into observers. Tuning this to work well in all/many different systems would be tricky though.
There's a risk here of introducing another multiplicative slowdown, as I don't think the MutationRecord the callback gets includes the observed root. We'd need to look it up from each record's ancestry, and if we need to compare all the ancestor nodes to all roots for all mutations, that seems bad in a case where we might have many roots, yes?
From what I can tell,
@lit/localizedoesn't support DOM localization, so connecting Fluent strings to specific attributes would need to be done manually. But in general, yes, having a more natively Lit localization would be Quite Good.We've tools that can already enable a generic FTL↔️ XLIFF conversion, but
@lit/localizeuses a rather specific subset of features that's a bit distant from Fluent: No message attributes, no plural or other selector support, no message or term references, and indexed rather than named variable placeholders.julienw commentedon Sep 18, 2025
Well currently
pauseObservingpauses for every roots by callingdisconnectso I don't think we'd need to look at each record's ancestry. Or am I missing something?They don't mention it explicitely, but it's possible that
msg()works on an attribute. Although that' would be very different to how Fluent works... (localizing an element vs localizing a string).Ah I see, that's very limited indeed.
julienw commentedon Sep 18, 2025
Smaug mentioned whatwg/dom#126 to me, looks like zibi already thought of that back then.
julienw commentedon Sep 11, 2026
I think one part of the problem is we translate twice.
In
MozLitElementwe forcefully calltranslateFragment(this.renderRoot)in theupdatefunction (https://searchfox.org/firefox-main/rev/938e8f38c6765875e998d5c2965ad5864f5a5ee2/toolkit/content/widgets/lit-utils.mjs#223).This already calls
applyTranslationsonce, and therefore alreadypauseObservingandresumeObservingonce.Probably this should be in
firstUpdatedinstead?Because the MutationObserver also translates the changes.
This would reduce the problem by half already. Even if the main issue is still the use of the MutationObserver.
So to avoid the MutationObserver completely when we don't need it, I suggest a solution in #671.
feat(fluent-dom): Add an option to connectRoot to opt out of the muta…