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