Page MenuHomePhabricator

Avoid or minimise impact of unready Grade A state (Quick Events proposal)
Open, HighPublic

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.

See also

Event Timeline

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.

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)

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.

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?

@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 -->
Krinkle triaged this task as Medium priority.Jan 18 2018, 3:06 PM

Example: T181545

For the record, that task does not seem related to this task. Even if implemented in an ideal way, T181545 would still happen if code A (core, rcfilters) outputs HTML marked as "js-only", but has its module filtered out by code B (mobile).

The only difference that this task (T183720) might make is that it could have "review tools" appear in a muted, or have core acknowledge the click in some generic code while waiting for the module to load, which would never arrive given it was filtered out.

The fix for T181545 will likely involve either allowing it to load on mobile, or figuring out a way for code A and B to communicate server-side and agree what should and should not be rendered. E.g. using conditional MobileFrontend/isMobileContext logic, or a hook, or something else.

Krinkle raised the priority of this task from Medium to High.Jun 6 2019, 9:55 AM
Krinkle added a project: Epic.

Some sketches from a brainstorming session today.

Public interface
HTML
<a href="/watch/Foo" data-rlqe="core.watch">Watch this page</a>
PHP
$outputPage->registerQuickEvent('core.watch', 'mediawiki.misc-auth-ajax');
JavaScript
mw.hook('quickevent.core.watch', function (element) {
  /* … */
});
Implementation idea
HTML (internal)
<html><head><script>
RLQE = []; document.addEventListener && document.addEventListener('click', function (e) {
  if (e.target.dataset && e.target.dataset.rlqe) {
    RLQE.push(e.target);
    e.target.className += ' rlqe-clicked';
  }
});
PHP (internal)
class OutputPage {
  public function registerQuickEvent( string $eventName, string $module ) {
    $rlClient->addQuickEvent( $eventName, $module );
  }
}
class ResourceLoader\ClientHtml {
  // @throws RuntimeException If the event name already registered with another module
  public function addQuickEvent( string $eventName, string $module );

  public function getBodyHtml() : string {
    '
  <script>
  mw.hook("resourceloader.quickevents").fire({"core.watch":…});
  </script></body>
   ';
  }
}
mediawiki.base.js (internal)
mw.hook( 'resourceloader.quickevents' ).add(function ( registry ) {
  var buffer = RLQE;
  RLQE = { push: function ( element ) {
    mw.hook( 'quickevent.' + element.dataset.rlqe ).fire( element );
    mw.loader.load( registry[ element.dataset.rlqe ] );
  } };
  buffer.forEach( RLQE.push );
});
Misc thoughts
  • Security: For security, add data-rlqe to output blacklist for user-generated content in Sanitizer.php. Same as we already for for data-mw=interface, data-ooui, etc..
  • Performance: After this, most we will be able to remove the various tiny "init"-type modules we have today, replacing them with this unified approach.
  • Developer productivity: Fully declarative. No need to write or maintain code for the wiring of these elements or the loading of these modules.
  • User experience: The interface will be significantly more responsive than it is today. All user-interface elements, even those that need JavaScript to work, would be instantly reactive, requiring zero JavaScript to from the async pipeline to load. From pure HTML they are able to transition directly into their activated/clicked "loading" state (with CSS), allowing them to perceive the action as being underway. Thus making the loading of the code and the actual server interaction (e.g. adding to watchlist) part of a single process rather than a two-step process.

We had a similar issue on the mobile site. If a user tapped the menu before the JavaScript kicked in, they would find themselves on Special:MobileMenu, a page that was just a giant menu! We worked around this for menus using the checkbox hack which provides a pretty reasonable non-JavaScript experience. I know it seems a little funky but it's not that far from disclosure widgets and I think it actually works pretty well. T243126 will verify.

It may also be possible to use a CSS transition to show a "Still loading... Click here to change pages and force loading" link after so many seconds had passed without JS but it'd be probably be clumsy. Menus seem to work well though.

I would love to have a "delayed click" mechanism pretty much exactly as described in the task description. I recall the WMDE-TechWish running into this issue like once a month. Typically we solve this by disabling buttons and re-enabling them when the JavaScript is ready. Or we accept having a dead <a> element with no href (example). But often we forget and make users run into frustrating experiences.

PS: I'm told it might be worth checking how Underscore.js or RxJS solve this.

Would also be useful for reply links in DiscussionTools, as well as VE's edit buttons.

Krinkle renamed this task from Avoid or minimise impact of "unready" Grade A state to Avoid or minimise impact of unready Grade A state (Quick Events proposal).Apr 15 2021, 8:44 PM

Change 886922 had a related patch set uploaded (by Esanders; author: Esanders):

[mediawiki/core@master] [WIP] Early click handler

https://gerrit.wikimedia.org/r/886922

Change 886923 had a related patch set uploaded (by Esanders; author: Esanders):

[mediawiki/extensions/DiscussionTools@master] WIP Early click handling

https://gerrit.wikimedia.org/r/886923

The last point, "Capture early interactions", is something we do not currently do

We did this for MediaViewer's thumbnail click (where the no-JS behavior would navigate away to the image description page). Some pitfalls:

  • Obviously the capture logic needs to load very early. Back then this was sort of possible with top-loaded RL modules, but those don't exist anymore. I'm skeptical about a class-based implementation being flexible enough.
  • ...in part because loading the deferred event handler might load slow enough that you need a visual clue that yes, something is happening, and that will really depend from case to case (e.g. set the button to disabled, show a loading animation).
  • If the event handler fails to load, the no-JS behavior is inaccessible. This is easy to miss so there should be a standard method for deferred handlers which takes care of this. Also, at least back then ResourceLoader was not great at predictably raising errors (e.g. if loading timed out, the loader.using promise would just never resolve or reject).
  • Well-written event handlers fall through to the browser default (ie. no-JS behavior) on error. With deferred handling this is not straightforward - you need to capture and replay the event (and then browsers might or might not handle that identically). Again, would be nice if the framework took care of this.