Avoid or minimise impact of "unready" Grade A state
Open, NormalPublic

Description

Preamble

The convention for MediaWiki features is to not depend on JavaScript for critical user actions. For various reasons, this means the initial page render must not depend on blocking JavaScript.

  1. Performance: Things render a lot faster if you don't wait for blocking JS to download, parse and execute before anything useful can be shown.
  1. Availability: Minimise risk of failure by being able to recover if the JS payload fails to download, parse or execute for any reason.
  1. Reach: With the limited (human) resources we have, we can't support all historical versions and variations of JavaScript engines in web browsers. For that reason, we use a capability test in our JavaScript environment that decides early whether to continue with or without the JavaScript payload. This means we can still reach users of browsers we can't currently support the JavaScript engine of. (Without errors)

See also https://www.mediawiki.org/wiki/Compatibility

In summary, our pages typically load without dependency on any JavaScript for the first render, and without depending on JavaScript for critical user actions. Which means the most important user actions (e.g. search, create account, login, editing, preferences, etc.) are available via regular anchor links and HTML forms, that are handled by the server.

From an asynchronous request, we also try to initialise Grade A ("the JavaScript environment") in supported browsers. But even in supported browsers, the experience will de-facto fall back to Grade C if this fails.

Problem

In the common case of Grade A being supported and initialising without issue, there is typically a considerable amount of time spent waiting, at which point the user is sometimes unable to interact with the page (despite it being visible). This problem can manifest in a number of different ways. The exact problem varies based on how closely related the no-js and js experiences are.

(TODO: Enumerate examples of current problems)

Goal

Avoid

Where possible, avoid waiting time. Most likely by avoiding JavaScript for the initial render, e.g. by rendering the interactive widget server-side and sending only HTML/CSS. If needed, use the .client-js or .client-nojs selectors to hide widgets from the no-js experience.

Minimise

Minimise waiting time. For example:

  • By reducing the JS payload size.
  • By reducing number of network roundtrips (e.g. avoid indirect discovery or lazy-load, or supplement with preloading).
  • Avoiding idle waiting for an event like "document ready" or "window load".

Wait time to document-ready can be avoided by using delegate event handlers on a parent element that always exists. For example:

slow.js
(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);
  });

}());
faster.js
(function () {

  // Immediately attach delegate event handler to <body>
  $(document.body).on('click', '.mw-stuff', mw.stuff.handle);

}());

Improve

While avoiding is sometimes possible, and minimising can sometimes result in a wait time short enough to not need communication, there will still be plenty of cases where we need to improve the wait experience. This can take form in a number of ways:

  • 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).
page.html
<html>
<body>
  <script>{{ inline/script.js }}</script>
  ..
  <button class="mw-thing" data-mw="capture">..</button>
inline/script.js
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();
  }
});
async/stuff.js
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.

Proposed solutions

(None at the moment, please suggest ideas below.)

Krinkle created this task.Wed, Dec 27, 3:04 PM
Restricted Application added a subscriber: Aklapper. · View Herald TranscriptWed, Dec 27, 3:04 PM
Krinkle updated the task description. (Show Details)Wed, Dec 27, 3:05 PM
Krinkle updated the task description. (Show Details)Wed, Dec 27, 4:13 PM
Izno added a subscriber: Izno.Thu, Dec 28, 1:53 PM

This problem manifests on changes lists with the beta filters option (possibly specific to ORES/Wikidata changes/enhanced RC/WL).

@Mooeypoo has just proposed a proof of concept of "capturing early interactions" in T184028.

matmarex added a comment.EditedWed, Jan 3, 3:49 PM

After reading both of these tasks, I had the idea that re-triggering the click events after page load could allow this to be implemented more easily. Along the lines of:

<a href="blahblah" onclick="$(function(){ $(this).trigger('click') }.bind(this))"></a>

But this won't work for two reasons:

  • We'd need to wait for the code handling these clicks to load before re-triggering the event
  • The HTML element may no longer exist if that code replaces it with something else (e.g. an OOUI button)
Mooeypoo added a comment.EditedWed, Jan 3, 10:09 PM

After reading both of these tasks, I had the idea that re-triggering the click events after page load could allow this to be implemented more easily. Along the lines of:

<a href="blahblah" onclick="$(function(){ $(this).trigger('click') }.bind(this))"></a>

But this won't work for two reasons:

  • We'd need to wait for the code handling these clicks to load before re-triggering the event
  • The HTML element may no longer exist if that code replaces it with something else (e.g. an OOUI button)

Yeah, I think we need to let the components decide for themselves when they trigger the deferred click. In my example in Echo, for instance, shows two main issues with this:

  • We first attach the proper click handlers and then trigger the deferred actions, because the click itself is also lazy-loading the modules (so we need to control the timing)
  • In the notification badges, it doesn't make sense to trigger all clicks, you just want the latest click. Consider a user clicking "alert / message / alert / message" in sequence. When Notifications JS load, you only care about the latest click to open the 'message' badge, and not all clicks.

But different components may need to deal with the click queue differently. Something like Structured Discussions, for instance, may want to process some multiple clicks but not others. Consider the user clicking on the sequence "reply topic 1 / thank reply1 / thank reply2 / reply topic2 / thank reply3" -- in this case, the system probably wants to process all the "thank" clicks but only the last "reply" click.

So, I think the best option is to have the initialization collect the clicks (in sequence) and have some sort of check in the function that allows components to optionally process the sequences as they need to.

@Mooeypoo Agreed, I don't think we should replay the input events as actual input events, but rather allow the handling code to process the events directly in a way that is explicit about its source (e.g. early captures). Given they may need to be handled differently, or (as you suggested) de-duplicated based on certain parameters.

Below is a slightly more fleshed out version of the "mw.capture" proposal in the task description.

Capture early interactions

  1. From the PHP side, markup can be annotated with a special class that will enable the early-capture. For example class="mw-capture".
    • We should also consider requiring that the element in question has a data-mw attribute so that the relevant JavaScript code cannot be triggered from user-generated content. The generic default for this purpose is data-mw="interface", but we can use a different value if we have a need for it. (See also: Ajax links for patrol and watch, which have recently been protected against this type of authenticated-action clickjacking.)
    • We should consider requiring a component name of some kind so that relevant events can be easily claimed. For example data-mw="capture/my-component". See point 3 for how the claiming could work.
    • Note: If we use data-mw="capture/.. we can do it without the mw-capture class given the data attribute would suffice for identification purposes within JavaScript. For CSS styling of pending state, see point 2.
  2. An early inline snippet of dependency-free synchronous JavaScript code will:
    • Create a queue array.
    • Attach a delegate event listener on <body> for click events. The callback checks if the e.target matches .mw-capture[data-mw="interface"] and if so, adds the mw-capture-pending class, adds the event object to the queue, and returns false to prevent the default action.
  3. A new interface in mediawiki.js will allow components to claim their events. This could be something like mw.capture(componentName, eventType, callback), e.g. mw.capture('my-component', 'click', function (e) { .. }.
    • In its simplest form:
      • I propose that the mw-capture-pending class will not automatically be removed by mw.capture. This will be removed by the code that claims the event. If a handler can synchronously handle the event and respond to the user, it can simply remove the class immediately from the callback. However, if it needs to do some asynchronous processing, it can simply keep the class and continue the loading process (from a visual perspective).
      • I propose that as soon as a component name has been claimed, the delegate handler will cease to queue them internally. So a component should typically set up its own event handlers and then immediately afterward call mw.capture to process any early captures.

For the use case of de-duplication that @Mooeypoo mentioned, a caller should treat the mw.capture callback as a loop over captured events and can decide to handle it after the loop instead of immediately. For example:

// Set up real handler
$('.things-container').on('click', '.thing-item', function (e) { mw.things.handle(e.target); });

// Process first early capture (ignore later ones)
var first = true;
mw.capture('thing', 'click', function (e) {
  if (first) {
    first = false;
    mw.things.handle(e.target);
  }
});

// Alternative: Process last early capture (ignore earlier ones)
var target;
mw.capture('thing', 'click', function (e) {
  target = e.target;
});
if (target) { mw.things.handle(target); }

This isn't very clean or efficient though, so we may want to provide a primitive in mw.capture for this. E.g. mw.capture.last which would only call the callback at most 1 time. Or mw.capture.get which would return the array directly for the caller to process.

Izno removed a subscriber: Izno.Thu, Jan 4, 9:16 PM

I like this.

As a semi-side comment, when I was implementing my initial idea, I realized that there may actually not be a lot of use to separate the "event type" that much. That is, if we use class="mw-capture" and add click listener (on the body, or wherever) then what we literally capture are clicks. Is there ever anything else? The only distinction I'd be pressed to make is whether the user left- or right-clicked something, but overall, will we ever have a case where an event we defer like this (capture for later, etc) is anything but click?

If we do plan to expand to other events, then the global listen event on <body> is not sufficient. I don't know that we want to expand this to other events, though, but if we don't, then we might want to be more specific and target the behavior for clicks specifically, not necessarily a generalized "event" (like for example separate right/left/middle click behavior, etc, but not give the option for any sort of event)

Does that make sense?

Krinkle updated the task description. (Show Details)Mon, Jan 8, 6:51 PM
Krinkle added a comment.EditedMon, Jan 8, 6:56 PM

@Mooeypoo I agree "click" events are the main and only use case so far. However, I do think it's worth thinking ahead to make sure our approach won't pose problems in the future if/when we want to add other events (e.g. focus and/or contextmenu click seem like plausible things we might want at some point).

In an earlier draft, I had the delegate event handler store an object like { event: e, type: 'click' : target: this } but I changed this to only store the Event because Event already has type and target properties.

As for the body event listener, other event types bubble up to document.body much the same way as clicks do. So the only thing we'd need to do is when generating the inline script to repeat the last line for document.body.addEventListener("click", fn) once more with another event type. Something like the below perhaps:

<!-- php: if ( $this->usedCaptureEventTypes ) : -->
<script>(function () {
window.mwCaptureQueue = [];
function add(e) {
 if (mwCaptureQueue !== false && /^capture\//.test(e.target.getAttribute('data-mw'))) {
  mwCaptureQueue.push(e);
  e.target.className += ' mw-capture-pending';
  return false; // e.preventDefault();
 }
}
// php: foreach ( $this->usedCaptureEventTypes as $type ) :
document.body.addEventListener('click', add);
document.body.addEventListener('contextmenu', add);
// php: endforeach
}());</script>
<!-- php: endif -->
Peter added a subscriber: Peter.Mon, Jan 8, 8:56 PM
Imarlier moved this task from Inbox to Backlog on the Performance-Team board.Mon, Jan 8, 9:15 PM
Imarlier moved this task from Limbo to External on the Performance-Team (Radar) board.
Krinkle triaged this task as Normal priority.Thu, Jan 18, 3:06 PM