PERF: Move highlightjs to a background worker, and add result cache (#10191)

PERF: Move highlightjs to a background worker, and add result cache (#10191)

Syntax highlighting is a CPU-intensive process which we run a lot while rendering posts and while using the composer preview. Moving it to a background worker releases the main thread to the browser, which makes the UX much smoother.

diff --git a/app/assets/javascripts/admin/components/highlighted-code.js b/app/assets/javascripts/admin/components/highlighted-code.js
index 9159bb5..30f89e4 100644
--- a/app/assets/javascripts/admin/components/highlighted-code.js
+++ b/app/assets/javascripts/admin/components/highlighted-code.js
@@ -1,11 +1,37 @@
 import Component from "@ember/component";
-import { on, observes } from "discourse-common/utils/decorators";
-import highlightSyntax from "discourse/lib/highlight-syntax";
+import { highlightText } from "discourse/lib/highlight-syntax";
+import { escapeExpression } from "discourse/lib/utilities";
+import discourseComputed from "discourse-common/utils/decorators";
+import { htmlSafe } from "@ember/template";
 
 export default Component.extend({
-  @on("didInsertElement")
-  @observes("code")
-  _refresh: function() {
-    highlightSyntax($(this.element));
+  didReceiveAttrs() {
+    this._super(...arguments);
+    if (this.code === this.previousCode) return;
+
+    this.set("previousCode", this.code);
+    this.set("highlightResult", null);
+    const toHighlight = this.code;
+    highlightText(escapeExpression(toHighlight), this.lang).then(
+      ({ result }) => {
+        if (toHighlight !== this.code) return; // Code has changed since highlight was requested
+        this.set("highlightResult", result);
+      }
+    );
+  },
+
+  @discourseComputed("code", "highlightResult")
+  displayCode(code, highlightResult) {
+    if (highlightResult) return htmlSafe(highlightResult);
+    return code;
+  },
+
+  @discourseComputed("highlightResult", "lang")
+  codeClasses(highlightResult, lang) {
+    const classes = [];
+    if (lang) classes.push(lang);
+    if (highlightResult) classes.push("hljs");
+
+    return classes.join(" ");
   }
 });
diff --git a/app/assets/javascripts/admin/models/theme.js b/app/assets/javascripts/admin/models/theme.js
index 0b96660..d17b87d 100644
--- a/app/assets/javascripts/admin/models/theme.js
+++ b/app/assets/javascripts/admin/models/theme.js
@@ -7,8 +7,8 @@ import discourseComputed from "discourse-common/utils/decorators";
 import { popupAjaxError } from "discourse/lib/ajax-error";
 import { ajax } from "discourse/lib/ajax";
 import { escapeExpression } from "discourse/lib/utilities";
-import highlightSyntax from "discourse/lib/highlight-syntax";
 import { url } from "discourse/lib/computed";
+import highlightSyntax from "discourse/lib/highlight-syntax";
 
 const THEME_UPLOAD_VAR = 2;
 const FIELDS_IDS = [0, 1, 5];
@@ -321,7 +321,7 @@ const Theme = RestModel.extend({
             }
           }
         );
-        highlightSyntax();
+        highlightSyntax(document.querySelector(".bootbox.modal"));
       } else {
         return this.save({ remote_update: true }).then(() =>
           this.set("changed", false)
diff --git a/app/assets/javascripts/admin/templates/components/highlighted-code.hbs b/app/assets/javascripts/admin/templates/components/highlighted-code.hbs
index 4d67dd6..5dd40c0 100644
--- a/app/assets/javascripts/admin/templates/components/highlighted-code.hbs
+++ b/app/assets/javascripts/admin/templates/components/highlighted-code.hbs
@@ -1 +1 @@
-<pre><code class={{lang}}>{{code}}</code></pre>
+<pre><code class={{codeClasses}}>{{displayCode}}</code></pre>
diff --git a/app/assets/javascripts/discourse/app/initializers/post-decorations.js b/app/assets/javascripts/discourse/app/initializers/post-decorations.js
index 7b7d543..d1205bc 100644
--- a/app/assets/javascripts/discourse/app/initializers/post-decorations.js
+++ b/app/assets/javascripts/discourse/app/initializers/post-decorations.js
@@ -1,15 +1,15 @@
-import highlightSyntax from "discourse/lib/highlight-syntax";
 import lightbox from "discourse/lib/lightbox";
 import { setupLazyLoading } from "discourse/lib/lazy-load-images";
 import { setTextDirections } from "discourse/lib/text-direction";
 import { withPluginApi } from "discourse/lib/plugin-api";
+import highlightSyntax from "discourse/lib/highlight-syntax";
 
 export default {
   name: "post-decorations",
   initialize(container) {
     withPluginApi("0.1", api => {
       const siteSettings = container.lookup("site-settings:main");
-      api.decorateCooked(highlightSyntax, {
+      api.decorateCookedElement(highlightSyntax, {
         id: "discourse-syntax-highlighting"
       });
       api.decorateCookedElement(lightbox, { id: "discourse-lightbox" });
diff --git a/app/assets/javascripts/discourse/app/lib/highlight-syntax.js b/app/assets/javascripts/discourse/app/lib/highlight-syntax.js
index 404e73c..302fd4e 100644
--- a/app/assets/javascripts/discourse/app/lib/highlight-syntax.js
+++ b/app/assets/javascripts/discourse/app/lib/highlight-syntax.js
@@ -1,40 +1,181 @@
-/*global hljs:true */
-let _moreLanguages = [];
-
+import { Promise } from "rsvp";
+import { getURLWithCDN } from "discourse-common/lib/get-url";
+import { next, schedule } from "@ember/runloop";
 import loadScript from "discourse/lib/load-script";
+import { isTesting } from "discourse-common/config/environment";
+
+let highlightJsUrl;
+let highlightJsWorkerUrl;
+
+const _moreLanguages = [];
+let _worker = null;
+let _workerPromise = null;
+const _pendingResolution = {};
+let _counter = 0;
+let _cachedResultsMap = new Map();
+
+const CACHE_SIZE = 100;
+
+export function setupHighlightJs(args) {
+  highlightJsUrl = args.highlightJsUrl;
+  highlightJsWorkerUrl = args.highlightJsWorkerUrl;
+}
+
+export function registerHighlightJSLanguage(name, fn) {
+  _moreLanguages.push({ name: name, fn: fn });
+}
+
+export default function highlightSyntax(elem, { autoHighlight = false } = {}) {
+  const selector = autoHighlight ? "pre code" : "pre code[class]";
+
+  elem.querySelectorAll(selector).forEach(e => highlightElement(e));
+}
 
-export default function highlightSyntax($elem) {
-  const selector = Discourse.SiteSettings.autohighlight_all_code
-      ? "pre code"
-      : "pre code[class]",
-    path = Discourse.HighlightJSPath;
+function highlightElement(e) {
+  e.classList.remove("lang-auto");
+  let lang = null;
+  e.classList.forEach(c => {
+    if (c.startsWith("lang-")) {
+      lang = c.slice("lang-".length);
+    }
+  });
+
+  const requestString = e.textContent;
+  highlightText(e.textContent, lang).then(({ result, fromCache }) => {
+    const doRender = () => {
+      // Ensure the code hasn't changed since highlighting was triggered:
+      if (requestString !== e.textContent) return;
+
+      e.innerHTML = result;
+      e.classList.add("hljs");
+    };
 
-  if (!path) {
-    return;
+    if (fromCache) {
+      // This happened synchronously, we can safely add rendering
+      // to the end of the current Runloop
+      schedule("afterRender", null, doRender);
+    } else {
+      // This happened async, we are probably not in a runloop
+      // If we call `schedule`, a new runloop will be triggered immediately
+      // So schedule rendering to happen in the next runloop
+      next(() => schedule("afterRender", null, doRender));
+    }
+  });
+}
+
+export function highlightText(text, language) {
+  // Large code blocks can cause crashes or slowdowns
+  if (text.length > 30000) {
+    return Promise.resolve({ result: text, fromCache: true });
   }
 
-  $(selector, $elem).each(function(i, e) {
-    // Large code blocks can cause crashes or slowdowns
-    if (e.innerHTML.length > 30000) {
-      return;
+  return getWorker().then(w => {
+    let result;
+    if ((result = _cachedResultsMap.get(cacheKey(text, language)))) {
+      return Promise.resolve({ result, fromCache: true });
     }
 
-    $(e).removeClass("lang-auto");
-    loadScript(path).then(() => {
-      customHighlightJSLanguages();
-      hljs.highlightBlock(e);
+    let resolve;
+    const promise = new Promise(f => (resolve = f));
+
+    w.postMessage({
+      type: "highlight",
+      id: _counter,
+      text,
+      language
     });
+
+    _pendingResolution[_counter] = {
+      promise,
+      resolve,
+      text,
+      language
+    };
+

[... diff too long, it was truncated ...]

GitHub sha: d09f283e

1 Like

This commit appears in #10191 which was merged by davidtaylorhq.