Page MenuHomePhabricator

Consider adding mw.loader.preload / OutputPage::preloadModules()
Closed, DeclinedPublic

Description

This follows-up from T175916: Investigate 2017-08-29 page load time increase. See also T59952: Pre-fetch VisualEditor modules to improve load speed.


Prior to making our JS stack fully async (T107399) and removing the top/bottom queue distinction (T109837) we've had a habit of loading modules on pages that are commonly needed.

Sometimes developers used the "bottom" queue for this. Other times they call mw.loader.load() from inside another module. Later, at the point it is actually needed, typically mw.loader.using() is used to ensure the dependant code won't run before the dependencies have arrived. This call then naturally tacks onto the existing Promise that is underway (or executes immediately if said module was already loaded by now).

Both of these have down sides:

  • Bottom queue (no longer exists):
    • Developer convenience: Yay. (OutputPage::addModules)
    • Timing: Request starts fairly late. Sometimes good (prioritise HTML/JS processing over JS), sometimes bad (network idle time wasted).
    • Perceived: Still held back the "load" event, which affects the progress bar or spinner that users see. (Browsers keeps pushing back the load event until all subresources have loaded and this includes new resources added before the event fires).
  • Lazy load. Request starts even later, but correctly doesn't hold back the load event.
    • Developer convenience: Meh. (mw.load.load is Yay if the extension already has a setup module, but creating a setup module just for this is Meh).
    • Timing: Requests starts ever later, always later than needed. (waste of user and network time)
    • Perceived: Yay. Does not hold back "load" event.

Let's consider adding a special "preload" queue. This would be similar to the old bottom queue but with a very important distinction: It doesn't execute.

Interface

  • OutputPage::preloadModules() - passed to ResourceLoaderClientHtml.
  • mw.loader.preload()

Behaviour

Server side

ResourceLoaderClientHtml will process the list of preload modules. Ignore any that are (also) loaded by other means already. The rest is output in a call to mw.loader.preload(), much like the existing calls to mw.loader.load() and mw.loader.state(). The output could be either in getHeadHtml or getBodyHtml. I'd recommend we start with the latter at first to keep the head html as small as possible (T127328).

Client side

The mw.loader.preload() method will run a subset of the steps currently performed by mw.loader.load() and the internal mw.loader.work() methods. I'm proposing the following.

Preloading will check the local store (mw.loader.store.get). If an entry exits this means the implement-string was already loaded from LocalStorage into JS memory and eval'ed/parsed, don't do anything else.

If not in local storage, we'll want to fetch it from the network. However we should do so in a way that doesn't force the browser to execute the response as soon as possible. In other words, use script.deferred = true instead of the (default) script.async = true which means it will download (and possibly pre-parse) but it will not execute until after the document is loaded. This way other JS execution and non-JS activities (HTML/CSS) have priority.

In addition, once it does get executed, we'll want to make sure implement() doesn't execute it right away. Instead, we'll want it to stash it in the same way it stashes modules with unresolved dependencies.

Right now mw.loader has no concept of "needed"-ness. If a module is requested, it state progresses from registered => loading. And once the response calls implement(), its state will become loaded, at which point, after dependency resolution, it will execute.

We should think about adding a separate mechanism through which modules can be loading/loaded, without implement/handlePending executing it whenever it can.

Perhaps we can add new states like preloading and preloaded, but that requires existing code that doesn't care about execution to have to check multiple states. It also gets complicated when a module is upgraded from preloading to regular loading.

Alternatively, we could add a needed=false flag (or preload=true flag) to the registry that get checked at various points throughout the loading process. Upgrading a module's loading would be as easy as flipping this flag.

Other things to consider:

  • Should regular and preloaded modules be forced into a separate request? I think we should. Especially because JS execution is blocked on JS parsing, and this doesn't happen progressively. Even if we prevent execution in implement() for preloaded modules, it can still be a lot of parsing. Making the request always separate allows the browser to start executing the other modules sooner.
  • ..

Use cases

  • core - Preloads mediawiki.notification in various places.
  • VisualEditor - Preloads ext.visualEditor.targetLoader
  • Page Previews (Popups) - T176211
  • MediaViewer - ..

Event Timeline

I think the parse/eval should also be done lazily. Ideally we have the data in a "black box", we know it's the contents of the module (we should know that because we know which modules we've requested), and we do all the main thread work for it lazily. This has the advantage of avoiding the parse/eval dogpiling in the local storage case.

We should also consider parse/eval/executing those preload modules in the background when the browser is really idle. What matters is to get out of the way during the initial page render, but we might as well have a trickle of parse/eval/exec happening in the background after that to mitigate work to be done on demand (usually on user action) when the modules are really used.

If done well we'd basically spread out work currently happening on page render on the main thread beyond visual completion, but probably not requiring that much extra time to get everything done.

Krinkle lowered the priority of this task from Medium to Low.

In general I don't think we can justify the bandwidth consumption and CPU jank to just preload large quantities of potentially unused code, and for small amounts we generally just merge them into other bundles and load normally.

We already have a defragmented offline store (Docs) for fast secondary loads, and also long-term HTTP caching in the browser for bundles such as VisualEditor that may be too large for our own module store.

The main use cases I had in mind for preloading would be better catered by T183720, which would also scale better so as to not require much custom handling on server and client side with extra loading commands. Instead it would "just work" as per T183720.

I'm closing this as I don't expect us to ever work on this as such, but I'll note that I'm not opposed to the idea in general, I don't think it would do harm. But, it don't think it's something we need, and the altenatives currently available and proposed elsewhere would, think, result in better developer productivity, require less maintenance, and result in better perceived latency and response times than from using a "preload".