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:
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.
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:
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:
- 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
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.
- 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:
- 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
- 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
Could I get the same results without
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/clientYto 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: relativeon the anchor and place the popup using
top/left/right/bottomwithout the additional synchronous measurement.
As I found out, this was not possible, because:
MouseEvent.offsetXdoesn’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.
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).
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
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.
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.
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
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
|How it would be done ideally
|What’s missing in the Web platform
|Issue 1: selecting and matching DOM elements Link to code
|querySelectorAll / Element.match / template elements
|Issue 2: determining the horizontal offset of the mouse event Link to related code
|getClientRects and MouseEvent.clientX
|Fix bugs with MouseEvent relative offsets
|Call 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 measuring
|The popup should be a child of the link
|Make 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 fly
|With clip-path and CSS variables
|Define syntax for dynamic shape
|Mimic 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 calculation
|mouseenter’s preventDefault should prevent OS tooltip behavior
|Remove all title attributes when popup extension loads
- 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.