Page MenuHomePhabricator

QuickEvents: Launch declarative event-to-module binding
Open, HighPublic

Description

Objective

  1. Avoid uncanny valley by minimising exposure to non-ready user interfaces that are missing or non-functional before the "Modern" JS layer arrives.
  2. 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).
  3. 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.)

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:

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);

}());

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

Related Objects

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 can be extended in the future to support other events (e.g. focus and/or contextmenu click seem like plausible things we might want at some point), without posing compat or cohesion problems in the future.

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 remove the type property for now. We can instead store the Event only (and has its own type and target properties actually).

As for the body event listener, all UI event types bubble up to document.body much the same way as clicks do. So we can start with a hardcoded document.body.addEventListener("click", fn) today, and add more when we need it. 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();
 }
}
document.body.addEventListener('click', add);
}());</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.
Krinkle renamed this task from Avoid or minimise impact of unready Grade A state (Quick Events proposal) to QuickEvents: Launch declarative event-to-module binding.Nov 7 2023, 5:33 AM
Krinkle updated the task description. (Show Details)

While working with @Krinkle on this, Here are a few things we’d like to document for future reference.

One of the few things this task was going to fix was removing multiple lazy-loading init modules, slightly saving the download size of the startup module, and avoiding broken buttons.

This was how we calculated and got our estimate;

On my localhost, with 250 modules, The download size for the startup module was 11.1KB/33.2KB, 33.2KB being the size after it was uncompressed. To get how much each module cost, we did (33200-22100)/250 equals 44B per module. This means each module is costing us 44B. Yes, 44B looks like a small figure. However, this will be more noticeable when a large number of modules are removed.

Breakdown of the figures above:

The 33200 is the size of the startup manifest with 250 modules loaded, the 22100 is the size of the startup module with 0 modules loaded.

Technically, removing the lazy-loading init modules could slightly reduce the size of the JS modules we load by default since we'll no longer have to create the init modules manually.

We also looked at how long it took a server-rendered button to become interactive from the time it was rendered on the webpage.

In this case, we used the watch star button for example.

With browser cache enabled on a 3G network, the First Contentful Paint was around 1.25s, and the watch star button function was available at around 1.4s. This means any clicks between 1.26 and 1.39s would result in broken buttons.

With the browser cache disabled on the same speed, we got FCP around 1.49s and the init function available at 2.3s.

Henceforth, We’ll document Ideas or discussions around this subject as we go along :-)

Thanks for sharing @Hokwelum !

On my localhost, with 250 modules, The download size for the startup module was 11.1KB/33.2KB, 33.2KB being the size after it was uncompressed. To get how much each module cost, we did (33200-22100)/250 equals 44B per module. This means each module is costing us 44B. Yes, 44B looks like a small figure. However, this will be more noticeable when a large number of modules are removed.

This is really interesting and I've wondered this for a while. Could perhaps this be documented on https://www.mediawiki.org/wiki/ResourceLoader/Developing_with_ResourceLoader ? Often we take for granted that modules are "cheap" and just add them. Do we have any rough estimates of how many modules could be made private e.g. how many bytes we would save if we do T225842?

It's already documented there (paragraph starting with "Registering many modules is highly discouraged"), but I suppose someone could add citations to it.