fluent.js icon indicating copy to clipboard operation
fluent.js copied to clipboard

Using Fluent with a lot of roots is slow

Open julienw opened this issue 3 months ago • 3 comments

See the bugzilla bug here => https://bugzilla.mozilla.org/show_bug.cgi?id=1988776

STR:

  1. Open the attachment of that bug
  2. 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: https://github.com/projectfluent/fluent.js/blob/9a183312d4db035d6002c93e03f0c169a58f3234/fluent-dom/src/dom_localization.js#L149

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 unobserve method 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 disconnect and observe-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.

julienw avatar Sep 17 '25 15:09 julienw

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)

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.

instead of disconnect and observe-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.

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?

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.

From what I can tell, @lit/localize doesn'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.

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.

We've tools that can already enable a generic FTL ↔️ XLIFF conversion, but @lit/localize uses 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.

eemeli avatar Sep 18 '25 08:09 eemeli

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)

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.

instead of disconnect and observe-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.

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?

Well currently pauseObserving pauses for every roots by calling disconnect so I don't think we'd need to look at each record's ancestry. Or am I missing something?

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.

From what I can tell, @lit/localize doesn'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.

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).

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.

We've tools that can already enable a generic FTL ↔️ XLIFF conversion, but @lit/localize uses 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.

Ah I see, that's very limited indeed.

julienw avatar Sep 18 '25 09:09 julienw

Possible solutions:

  • change the MutationObserver API to expose an unobserve method so that we can unobserve just one root. But this would need a spec change :/

Smaug mentioned https://github.com/whatwg/dom/issues/126 to me, looks like zibi already thought of that back then.

julienw avatar Sep 18 '25 09:09 julienw