Web performance case study: Wikipedia page previews

Preview popups are common and requires careful scripting and styling; they can generate useful learning about performance as a reference for other front-end tasks.

By Noam Rosenthal, Wikimedia Performance Team

Wikipedia page previews

Wikipedia page previews, internally known as the MediaWiki “Popups” extension, are those balloons with snippets of text and an image you see when you hover over a link on Wikipedia and other MediaWiki-powered sites.

When you hover over a link, a snippet of the linked document is accessed through the MediaWiki API. This snippet is formatted and wrapped in a balloon that floats above the article’s text together with a thumbnail image.

Example for a Wikipedia page preview:

“Sloth” by Wikipedia contributors, Myresluger by Malene Thyssen, CC BY-SA 3.0.

Analyzing and profiling the code

As a part of my work with the Performance Team, I analyzed and profiled the popup code in search of more performant ways to achieve the same result.

Wikipedia page previews are a combination of something that’s both common on the web and requires careful scripting and styling; they can generate useful learnings about performance as a reference for other front-end tasks. So, in addition to implementing direct optimizations in the JavaScript/CSS code, I looked for takeaways for the Web platform.

I wanted to answer the question, “What can be improved in browsers so that things like Wikipedia page previews are easier to build in a performant way?” I also wanted to follow up with these takeaways and see if I could materialize them into evolving, new Web standards and browser engine contributions.

Even with a straightforward front-end task, there are challenges

This is a seemingly simple front-end task, and very common in many websites and frameworks. For example, the popper.js library which accomplishes similar use-cases is used in almost 4% of all websites.

Example from popper:

Screenshot from popper.js.org, MIT License

The Wikipedia page preview works in the following ways:

  • The popup can appear in one of four placements, either above or below the link, and either aligned to the left or right of the link, depending on the position of the link relative to the viewport. For example, if the link is close to the bottom of the viewport, the popup would appear above it.
  • The popup cursor should be horizontally placed as close as possible to the horizontal hover position of the mouse.
  • The balloon should clip the image, but not the text.
  • The native tooltip should not appear.
  • The balloon should appear in front of other menus and navigation elements on the page.

Digging through the profiler results, trying different things, and a few rewrite attempts, I found six places that affect the performance of the Wikipedia page previews. For several of these, I also identified ways where changes to the Web platform, not always complicated ones, could make a big difference.

Two issues that can be fixed without changes to the Web platform

The first two  issues can be fixed without changes to the Web platform:

  1. The code makes heavy use of jQuery’s Sizzle, which is a heavy piece of JavaScript. This can easily be substituted for document.querySelectorAll and Element.matches(). Link to code
  2. The code parses HTML on the fly with jQuery parseHTML, using template elements and cloneNode() instead can make a big difference.

Two Issues with synchronous measurements

Issue one: getClientRects

The first synchronous measurement issue was getClientRects. This function, alongside getBoundingClientRect, is a very useful and interoperable mechanism for measurements. However, I’ve seen it appearing at the top of the JS profiling lists more than once.

That’s because:

  • It forces a layout, sometimes causing layout thrashing.
  • It is very accurate and takes transforms into account, so it has to calculate all the transforms and rectangles of split inline-elements.
  • It is affected by scrolling and animations, so it’s not easily cacheable.

The expensive synchronous measuring is called in the following places:

  1. A little after the mouseover event arrives, after it goes through delays and Redux, to find the proper placement of the popup based on the current position of the anchor element relative to the viewport and the horizontal offset of the mouse from the element (for positioning the cursor). This sometimes creates redundant and expensive layout/style calculation (AKA ‘layout thrashing’).  Link to code
  2. To measure the popup’s height so that it can be translated to the right placement. That’s because the popup is not a descendant of the anchor element, which would have allowed us to position it automatically using position: relative, but rather a separate DOM element somewhere on the page. Link to code

Previous implementation:

Diagram by Noam Rosenthal, CC BY-SA 3.0

Could I get the same results without getClientRects?

I tried to get the same results without getClientRects and layout thrashing and got very close, but it wasn’t easy. I could see how in some other similar cases it wouldn’t be possible at all.

My plan was to do the following:

  • Use the event’s clientX/clientY to determine the placement without an additional measurement.
  • Make the popup a child of the link. It would make more sense in terms of document semantics, and then I can use position: relative on the anchor and place the popup using top/left/right/bottom without the additional synchronous measurement.
  • Use offsetX to determine the requisite cursor position.
Diagram by Noam Rosenthal, CC BY-SA 3.0

As I found out, this was not possible, because:

  • MouseEvent.offsetX doesn’t actually work with inline elements in Safari and Chrome and has some additional interesting compatibility and performance bugs like this one.
  • Making the popup a child of the link changed its place in the stacking context, making it appear behind certain elements on the page.

So, I had to keep the initial getClientRects to calculate offsetX correctly, and I made sure it happens right when the event comes to avoid thrashing as much as possible. 

What I would really like is for MouseEvent.offsetX (or an equivalent alternative) to work according to spec in all browsers or have an interoperable alternative.

Issue two: jQuery.outerHeight

The second synchronous measurement issue was jQuery.outerHeight. To fix this, I would have to change the z-index of the body-text from 0 to auto, but unfortunately, that would cause different undesirable side effects – the inner wiki text could potentially go above links, navigations, and promotions in the external area.

Stacking contexts are, in many situations, intertwined with a content/trust mode; different parts of the DOM are generated from different sources and by creating a stacking context MediaWiki (and probably in many other places on the web).

In the current MediaWiki code, as previously noted, this is handled by taking the popup out of the tree and positioning it using synchronous JavaScript measurements relative to the anchor.

Because of stacking contexts, I would have to keep that, but I was able to remove the layout thrashing by using bottom positioning and reusing the originally measured getClientRects code.

This, together with calling getClientRects earlier, would make it possible to avoid most layout thrashing cases. It cannot, however, eliminate all corner cases of layout/style redundant calculations: there would still be corner cases where some other DOM element would capture the mouse event and change the style before the specific handler will be called. Also, the use of the :hover pseudo-class may cause a style calculation before the event handler was called.

Diagram by Noam Rosenthal, CC BY-SA 3.0

To do the above is far from simple, and it requires me to break the intuitive DOM tree. I wish the Web platform provided us with better tools to do this while still maintaining the content separation concerns with stacking contexts. 

Here are some possible directions for the future:

  • Advance the “top layer” research or some other form to control z-index, in a more flexible way than what stacking contexts allow us today.
  • Allow relative positioning to an out-of-tree element, like this proposal.
  • Perhaps find a markup solution to tooltips, like this discussion.

Additional issues

Create the balloon in a way that clips the image

First, to create the balloon in a way that clips the image, like here: SVG was built on the fly with an internal <image/> element. This is somewhat complex and potentially sluggish. Link to code

Noam Rosenthal, CC BY-SA 3.0

The picture of the ant-eaters is clipped by the shape of the balloon cursor.

To replace the SVG clip, I wanted to use the new and shiny clip-path which wasn’t available at the time of developing this extension. However, drawing a flexible-width balloon with an unknown location for its arrow using a clip-path is not an easy task:

  • Clip-path: polygon() does not support rounded corners / curves.
  • Clip-path: path() was not yet supported in Chrome, so I contributed a patch to fix that.
  • Clip-path: path(), even when supported, is not flexible – does not get affected by percentages or CSS properties. So a fixed-width path has to be built knowing the width of the popup and position of the arrow.

The optimal way I found for doing this with current clip-path support, is to use a polygon and use pseudo-radius— since the radius for preview-popups is only 2-pixels, use a diagonal line. This works but is not a flexible solution for popups.

What I wish the Web platform had was a flexible CSS way to define shapes. I’m currently working to define that standard.

Event handler removes the title attribute from hovered links

The last thing that popped up in profiling, is that in order to prevent the native tooltip behavior, the event handler removes the title attribute from the links when they’re hovered over and returns it afterward. This is sluggish, as it’s an unnecessary DOM manipulation. Link to code

Fixing it inside the MediaWiki codebase would not be too hard. We could remove the title attribute from all links once at startups. However, it would be nice if browsers respected preventDefault for mouseenter events and canceled the native tooltip behavior. This would be a much easier solution.

A summary of workarounds and solutions

What’s slowHow it would be done ideallyWhat’s missing in the Web platformWorkaround
Issue 1: selecting and matching DOM elements Link to code
jQuery SizzlequerySelectorAll / Element.match / template elementsNothingUnnecessary
Issue 2: determining the horizontal offset of the mouse event Link to related code
getClientRects and MouseEvent.clientXMouseEvent.offsetXFix bugs with MouseEvent relative offsetsCall getClientRects as early as possible to avoid layout thrashing
Issue 3: correctly position the popup relatively to the anchor Link to related code
Layout thrashing with synchronous measuringThe popup should be a child of the linkMake z-index more flexible, e.g. top-layer research, or allow relative-to-element positioning.Reduce thrashing but keep synchronous measurements
Issue 4: clipping the popup as a balloon with dynamic dimension/offset Link to code
Generating SVG on the flyWith clip-path and CSS variablesDefine syntax for dynamic shapeMimic radius behavior with straight angles and polygon
Issue 5: preventing the OS default tooltips from appearing Link to code
Changing the DOM of the link just for the title attribute, causing a style calculationWithout special Javascriptmouseenter’s preventDefault should prevent OS tooltip behaviorRemove all title attributes when popup extension loads
Summary of workarounds and solutions

In summary

  • Some performance problems come from web developers trying to do their best and encountering missing APIs, bugs, and compatibility problems, resorting to expensive workarounds like synchronous measuring.
  • The particular web APIs / bugs that make them better would have made it easier for popups:
    • Fix MouseEvent.offsetX/offsetY, pass some measurements together with the event.
    • Allow more flexibility with stacking contexts, e.g. the top layer research.
    • Create a flexible syntax for clip-path shapes.
    • Respect preventDefault to prevent native tooltip behavior.
  • As a web developer, look for the following:
    • Synchronous measurements and layout/style thrashing. It’s A=an old problem and still a huge one. If you really must use synchronous measurements, perform all of them right at the event listener callback.
    • Be careful with your stacking context, plan it well so you won’t have to work around it.
    • Avoid JS layouting.
    • Use native DOM APIs.
    • Check if you actually need title attributes.
    • Try clip-path before resorting to SVG.

About this post

Featured image credit: Giant anteater (Myrmecophaga tridactyla) at Copenhagen Zoo 2005, Malene Thyssen, CC BY-SA 3.0.

Leave a Reply

Your email address will not be published. Required fields are marked *