Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 5 commits into from Jul 15, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
38 changes: 32 additions & 6 deletions 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(" ");
}
});
4 changes: 2 additions & 2 deletions app/assets/javascripts/admin/models/theme.js
Expand Up @@ -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];
Expand Down Expand Up @@ -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)
Expand Down
@@ -1 +1 @@
<pre><code class={{lang}}>{{code}}</code></pre>
<pre><code class={{codeClasses}}>{{displayCode}}</code></pre>
@@ -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" });
Expand Down
191 changes: 166 additions & 25 deletions 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
};

_counter++;

return promise;
});
}

export function registerHighlightJSLanguage(name, fn) {
_moreLanguages.push({ name: name, fn: fn });
function getWorker() {
if (_worker) return Promise.resolve(_worker);
if (_workerPromise) return _workerPromise;

const w = new Worker(highlightJsWorkerUrl);
w.onmessage = onWorkerMessage;
w.postMessage({
type: "loadHighlightJs",
path: fullHighlightJsUrl()
});

_workerPromise = setupCustomLanguages(w).then(() => (_worker = w));
return _workerPromise;
}

function customHighlightJSLanguages() {
_moreLanguages.forEach(l => {
if (hljs.getLanguage(l.name) === undefined) {
hljs.registerLanguage(l.name, l.fn);
}
function setupCustomLanguages(worker) {
if (_moreLanguages.length === 0) return Promise.resolve();
// To build custom language definitions we need to have hljs loaded
// Plugins/themes can't run code in a worker, so we have to load hljs in the main thread
// But the actual highlighting will still be done in the worker

return loadScript(highlightJsUrl).then(() => {
_moreLanguages.forEach(({ name, fn }) => {
const definition = fn(window.hljs);
worker.postMessage({
type: "registerLanguage",
definition,
name
});
});
});
}

function onWorkerMessage(message) {
const id = message.data.id;
const request = _pendingResolution[id];
delete _pendingResolution[id];
request.resolve({ result: message.data.result, fromCache: false });

cacheResult({
text: request.text,
language: request.language,
result: message.data.result
});
}

function cacheResult({ text, language, result }) {
_cachedResultsMap.set(cacheKey(text, language), result);
while (_cachedResultsMap.size > CACHE_SIZE) {
_cachedResultsMap.delete(_cachedResultsMap.entries().next().value[0]);
}
}

function cacheKey(text, lang) {
return `${lang}:${text}`;
}

function fullHighlightJsUrl() {
let hljsUrl = getURLWithCDN(highlightJsUrl);

// Need to use full URL including protocol/domain
// for use in a worker
if (hljsUrl.startsWith("/")) {
hljsUrl = window.location.protocol + "//" + window.location.host + hljsUrl;
}

return hljsUrl;
}

// To be used in qunit tests. Running highlight in a worker means that the
// normal system which waits for ember rendering in tests doesn't work.
// This promise will resolve once all pending highlights are done
export function waitForHighlighting() {
if (!isTesting()) {
throw "This function should only be called in a test environment";
}
const promises = Object.values(_pendingResolution).map(r => r.promise);
return new Promise(resolve => {
Promise.all(promises).then(() => next(resolve));
});
}
Expand Up @@ -9,6 +9,7 @@ import {
} from "discourse-common/config/environment";
import { setupURL, setupS3CDN } from "discourse-common/lib/get-url";
import deprecated from "discourse-common/lib/deprecated";
import { setupHighlightJs } from "discourse/lib/highlight-syntax";

export default {
name: "discourse-bootstrap",
Expand Down Expand Up @@ -81,7 +82,11 @@ export default {
Session.currentProp("safe_mode", setupData.safeMode);
}

app.HighlightJSPath = setupData.highlightJsPath;
setupHighlightJs({
highlightJsUrl: setupData.highlightJsUrl,
highlightJsWorkerUrl: setupData.highlightJsWorkerUrl
});

app.SvgSpritePath = setupData.svgSpritePath;

if (app.Environment === "development") {
Expand Down
47 changes: 47 additions & 0 deletions app/assets/javascripts/highlightjs-worker.js
@@ -0,0 +1,47 @@
// Standalone worker for highlightjs syntax generation

// The highlightjs path changes based on site settings,
// so we wait for Discourse to pass the path into the worker
const loadHighlightJs = path => {
self.importScripts(path);
};

const highlight = ({ id, text, language }) => {
if (!self.hljs) {
throw "HighlightJS is not loaded";
}

const result = language
? self.hljs.highlight(language, text, true).value
: self.hljs.highlightAuto(text).value;

postMessage({
type: "highlightResult",
id: id,
result: result
});
};

const registerLanguage = ({ name, definition }) => {
if (!self.hljs) {
throw "HighlightJS is not loaded";
}
self.hljs.registerLanguage(name, () => {
return definition;
});
};

onmessage = event => {
const data = event.data;
const messageType = data.type;

if (messageType === "loadHighlightJs") {
loadHighlightJs(data.path);
} else if (messageType === "registerLanguage") {
registerLanguage(data);
} else if (messageType === "highlight") {
highlight(data);
} else {
throw `Unknown message type: ${messageType}`;
}
};
3 changes: 2 additions & 1 deletion app/helpers/application_helper.rb
Expand Up @@ -469,7 +469,8 @@ def client_side_setup_data
default_locale: SiteSetting.default_locale,
asset_version: Discourse.assets_digest,
disable_custom_css: loading_admin?,
highlight_js_path: HighlightJs.path,
highlight_js_url: HighlightJs.path,
highlight_js_worker_url: script_asset_path('highlightjs-worker'),
svg_sprite_path: SvgSprite.path(theme_ids),
enable_js_error_reporting: GlobalSetting.enable_js_error_reporting,
}
Expand Down
1 change: 1 addition & 0 deletions config/application.rb
Expand Up @@ -171,6 +171,7 @@ def config.database_configuration
confirm-new-email/bootstrap.js
onpopstate-handler.js
embed-application.js
highlightjs-worker.js
}

# Precompile all available locales
Expand Down