mustache.js replaced with JavaScript template literals in Extension:Popups

The Popups MediaWiki extension previously used HTML UI templates inflated by the mustache.js template system. This provided good readability but added an 8.1 KiB dependency* for functionality that was only used in a few places. We replaced Mustache with ES6 syntax without changing existing device support or readability and now ship 7.8 KiB less of minified uncompressed assets to desktop views where Popups was the only consumer.

Background

Given that ES6 template literals provided similar readability** and are part of JavaScript itself, we considered this to be a favorable and sustainable alternative to Mustache templates. Additionally, although the usage of template strings require transpilation, adding support enabled other ES6 syntaxes to be used, such as let / const, arrow functions, and destructuring, all of which Extension:Popups now leverages in many areas.

We compared the sizes before and after transpiling templates and they proved favorable:

index.js (gzip)index.jsext.popupsext.popups.mainext.popups.imagesmediawiki.template.mustacheTotal
Before10.84 KiB32.88 KiB96 B52.5 KiB3.1 KiB8.1 KiB65224 B
After11.46 KiB35.15 KiB96 B52.7 KiB3.1 KiB0.0 KiB57193 B

Where “index.js (gzip)” is the minified gzipped size of the resources/dist/index.js Webpack build product as reported by bundlesize, “index.js” is the minified uncompressed size of the same bundle as reported by source-map-explorer and Webpack performance hints, and the remaining columns are the sum of minified uncompressed assets for each relevant module as reported by [[ https://gerrit.wikimedia.org/r/plugins/gitiles/mediawiki/core/+/master/resources/src/mediawiki/mediawiki.inspect.js | mw.loader.inspect() ]] with the last column being a total of these inspect() modules.

The conclusions to draw from this table are that transpiling templates does minimally increase the size of the Webpack bundle but that the overhead is less than that of the mustache.js dependency so the overall effect is a size improvement. Additionally, note that the transpiled bundle now encompasses the HTML templates which source-map-explorer reports as contributing a 2.53 KiB minified uncompressed portion of the 35.15 KiB bundle. (Previously, templates were part of ext.popups.main but only via ResourceLoader aggregation; now templates are part of index.js.) Allowing for rounding errors and inlining, this brings the approximate overhead of transpilation itself to nearly zero, 35.15 KiB - 32.88 KiB - 2.53 KiB ≈ 0, which suggests transpiling as a viable solution for improving code elsewhere that must be written in modern form without compromising on compatibility or performance.

We used the Babel transpiler with babel-preset-env to translate only the necessary JavaScript from ES6 to ES5 for grade A browsers. The overhead for this functionality may be nonzero in some cases but is expected to diminish in time and always be less than the size of the mustache.js dependency. Please note that while most ES6 syntaxes are supported, the transpiler does not provide polyfills for new APIs (e.g., Array.prototype.includes()) unless configured to do so via babel-polyfill. As polyfills add more overhead and are related but independent of syntax, API changes were not considered in this refactoring.

Manual HTML escaping of template parameters was a necessary part of this change. This functionality is built into the double-curly brace syntax of mustache.js but is now performed using [[ https://www.mediawiki.org/wiki/ResourceLoader/Core_modules#mediaWiki.html | mw.html.escape() ]]. These calls are a blemish on the code but appear only in the templates themselves and would be replaced transparently in a UI library with declarative rendering (such as Preact). We also anticipate that the template literal syntax would transition neatly to such a library. We don't know that Extension:Popups will ever want to use a UI library and accept these shortcomings may always exist.

*As reported by [[ https://www.mediawiki.org/wiki/ResourceLoader/Core_modules#mw.loader.inspect | mw.loader.inspect() ]] on March 22nd, 2018.
**The Mustache version of previews:

<div class="mwe-popups" role="tooltip" aria-hidden>
  <div class="mwe-popups-container">
    {{#hasThumbnail}}
    <a href="{{url}}" class="mwe-popups-discreet"></a>
    {{/hasThumbnail}}
    <a dir="{{languageDirection}}" lang="{{languageCode}}" class="mwe-popups-extract" href="{{url}}"></a>
    <footer>
      <a class="mwe-popups-settings-icon mw-ui-icon mw-ui-icon-element mw-ui-icon-popups-settings"></a>
    </footer>
  </div>
</div>

The ES6 version of the same template explicates dependencies but must manually escape them. The HTML snippet is quite similar but a call trim() is made so that parsing the result only creates a single text Node.

/**
 * @param {ext.popups.PreviewModel} model
 * @param {boolean} hasThumbnail
 * @return {string} HTML string.
 */
export function renderPagePreview(
	{ url, languageCode, languageDirection }, hasThumbnail
) {
	return `
		<div class='mwe-popups' role='tooltip' aria-hidden>
			<div class='mwe-popups-container'>
				${hasThumbnail ? `<a href='${url}' class='mwe-popups-discreet'></a>` : ''}
				<a dir='${languageDirection}' lang='${languageCode}' class='mwe-popups-extract' href='${url}'></a>
				<footer>
					<a class='mwe-popups-settings-icon mw-ui-icon mw-ui-icon-element mw-ui-icon-popups-settings'></a>
				</footer>
			</div>
		</div>
	`.trim();
}
Written by Niedzielski on Apr 3 2018, 5:21 PM.
Programmer
Projects
None
Subscribers
awight, Nikerabbit, Tgr, Jhernandez
Tokens
"Doubloon" token, awarded by awight."Like" token, awarded by Mholloway.

Event Timeline

I've added lang to the code snippets for syntax coloring. Nice post!

Why not use tagged template literals for escaping?

@Jhernandez thank you!

@Tgr, thanks for your comment! Our usage is quite simple so your idea nearly works perfectly but I think our heavy reliance on conditionalized HTML substrings ultimately prevents tagged literals from being preferable. e.g.:

const showHeader = true
const text = 'This text should and will be escaped.'
render`<div>${showHeader ? '<h1>This HTML substring should not but will be escaped</h1>' : ''}<span>${text}</span></div>`;

There are workarounds but I think this problem otherwise ruins a very nice idea. I've tried to clarify a little in this proof of concept. If you have any suggestions, I'd love to hear them!

Where is the escaping happening in the ES6 example for the three variables?

@Nikerabbit In that template specifically there is no escaping as there is no user input (it wasn't escaped in the mustache templates either).

You can see on the other templates the escaping is done manually: https://gerrit.wikimedia.org/r/plugins/gitiles/mediawiki/extensions/Popups/+/4281670/src/ui/templates/settingsDialog.js#50

/**
 * @param {SettingsModel} model
 * @return {string} HTML string.
 */
export function renderSettingsDialog( model ) {
	var
		heading = escapeHTML( model.heading ),
		saveLabel = escapeHTML( model.saveLabel ),
		helpText = escapeHTML( model.helpText ),
		okLabel = escapeHTML( model.okLabel ),
		choices = escapeChoices( model.choices );
	return `
		<section id='mwe-popups-settings'>
			<header>
			...
    `
}

There is an interesting new tiny library from the creator of Preact, HTM, that I think is closer to what Gergo was describing.