Objective
- Avoid uncanny valley by minimising exposure to non-ready user interfaces that are missing or non-functional before the "Modern" JS layer arrives.
- Faster page load time by loading less JavaScript code (both by default and overall), and due to developers choosing the faster solution by default (because "faster" and "easier" will be the same instead of different solutions).
- Improved developer ergonimics by removing the need to create disparate "init" modules in every feature.
Preamble
As laid out in mw:Compatibility#Browsers, MediaWiki must not depend on JavaScript for critical user actions. This has led us to evolve an architecture that explicilty disallows extensions from introducing "render-blocking" JavaScript (T107399). This helps us in significant ways, as summarised from https://wikitech.wikimedia.org/wiki/MediaWiki_Engineering/Guides/Frontend_performance_practices:
- Performance: This approach guarantee the browser can render the article and skin layout as soon as possible (i.e. metrics such as First Paint, Largest Contentful Paint, and Visually Complete). Requiring the browser to block renderinb by first downloading, parsing, compiling, and executing JavaScript would significant and needlessly slow down render times. And, it would do so in a way that disproportionally affects low-end devices (unlike e.g. a fixed time delay due to server-side overhead that applies equally to everyone).
- Availability: This approach makes the ability to read and contribute highly evailable, without adding up and multiplying a long tail of failure scenarios due to JS failing to download, parse, or execute for any reason. See also https://www.kryogenix.org/code/browser/everyonehasjs.html.
- Reach: With limited time and effort, we can't support all versions and variations of JavaScript engines and web browsers. We use a capability test in our JavaScript environment that aggressively cuts off old browsers by deciding to forego the JavaScript payload. This is fine because the Basic layer renders first and is not meant to introduce visible failures, or otherwise cause significant differences in appearance of ability.
In short, the way it works today is that every page starts in Basic, and the ResourceLoader Startup module determines whether to try to initialise optional "Modern" layer (i.e. JavaScript-based enhancements). Even in Grade A browsers, every page load experience starts de-facto in Basic mode, and even in Grade A browsers that is sometimes all you get due to the inherent time cost and unreliabilty of JavaScript.
Problem
There is typically a considerable amount of time between the page first appearing and JavaScript-based functionality being ready to receive input. This problem can manifest in a number of different ways. The exact way it manifests dpends on whether a feature uses JS to enhance a base functionality (recommended), or whether it swaps out a no-js for a js-required version.
(TODO: Add more examples of features we have today and their failure modes.)
- T183624: Two date pickers (calendars) appear on Contributions page if form is clicked before page load
- Clicking the watch/unwatch star too fast shows a confirmation page users don't expect.
High-level approach
- Remove delays. By removing the need custom JavaScript that needs to be written and loaded for each interactive feature on every page. Instead, the server-side components that we already have, can be annotated with an attribute that ResourceLoader automatically understands and allows to become interactive from the first render with no custom JavaScript.
- Reduce delays
- Smaller overall JS payload size, downloads faster, executes faster.
- Fewer HTTP requests, by removing now-redundant and needless lazy-loading of "init" modules.
- Remove idle time between JavaScript arriving and the "document ready" event, by embracing a delegate event handler.
- Fix uncanny valley. By eliminating the gap where interfaces are visible but not functional, this essentially removes the uncanny valley.
- Improve wait time experience. We have no standard mechanism for this today, and many features don't account for this important intermediate stage between "click" and "response". We can add a class name upon first interaction to quickly acknowledge the interaction through a standardised class name while work continues in the background.
For example:
(function () { // Wait for document-ready (aka "DOMContentLoaded" or "document.readyState=interactive") $(function () { // Synchronously query the document and build a node list. // Iterate over each of the found elements and attach an event handler to each element $('.mw-stuff').on('click', mw.stuff.handle); }); }());
(function () { // Immediately attach delegate event handler to <body> $(document.body).on('click', '.mw-stuff', mw.stuff.handle); }());
Implementation thoughts
While some features will become faster overall, others may take (almost) the same amount of time in total but re-ordered in a way that is less jarring to the user experience. For example, instead of presenting a button that doesn't work for 1 second, then you click it, and wait another 0.1 seconds. We would now present a button that immediately works and takes 1.1s to respond.
There is no excuse today for presenting a broken button with no accessible explanation for why it isn't functional yet. There is also no excuse for why some features have no "waiting" acknowledgement. Basically, if you have a slow internet connection, a button will not appear to work even after JS is ready.
Things we can do to improve the "waiting" experience:
- Clearly communicate non-interactive state. We should avoid a situation where the user wrongly believes something can be interacted with, or at least once they try, they should know that no interaction took place. For example:
- By being visually muted (greyed out, or reduced opacity).
- Marked as disabled (disabled, aria-disabled).
- Through explicit negative response when hovering or touching (e.g. cursor: not-allowed) or absence of any positive response (e.g. pointer-events: none, no hover/focus state appears).
- Provide a fallback while waiting. Depending on how different the no-js and js-only interactions are, it might make sense not to disable the js-only button, but instead have the no-js button be visible and enabled during the wait. A good example of this is the "Add to watchlist" button ("Watch star" in Vector skin). If clicked before js loads, it loads the action=watch form. Once js is loaded and initialised, it becomes an AJAX button instead. Either way it works, there is no disabled or waiting state.
- Capture early interactions and provide a "pending" experience.
The last point, "Capture early interactions", is something we do not currently do, but I have been thinking about it for a long time. It might be interesting to provide a generic way in MediaWiki to capture interactions as early as possible (with a small amount of blocking JavaScript that registers a delegate event handler for click/focus etc.), and immediately acknowledge it to the user in a generic way, and once the JS pipeline has initialised, forward the event accordingly.
For example, we could do something like this:
- (Like now) Server outputs JS-only button with .client-js styles, hidden for .client-nojs.
- (Different) Instead of showing it as disabled until the JS is ready, show it as normal always.
- New:
- Page output has a generic inline script that listens for clicks on any element with a special attribute. And when clicked, does nothing, except add a class name.
- The component can immediately respond to (acknowledge) the click, from the stylesheet, with styles for the added class name.
- The component's JS payload will retroactive handle events by interacting with the embedded script in some way (e.g. via mw.hook or something else).
<html> <body> <script>{{ inline/script.js }}</script> .. <button class="mw-thing" data-mw="capture">..</button>
var captureQueue = []; document.body.addEventListener('click', function (e) { if (e.target.getAttribute('data-mw') === 'capture') { e.target.className += ' mw-captured-pending'; // Add class captureQueue.push( e ); // Enqueue for later e.preventDefault(); } });
mw.stuff = { handle: function (..) { .. } }; mw.capture('.mw-thing', 'click', function (e) { mw.stuff.handle(e.target); });
This can has the benefit of allowing the user to start interaction immediately, whilst the rest is being prepared behind the scenes. It also reduces the number of perceived stages of page loading by eliminating the intermediary state where the interface is visible but intentionally disabled.