PERF: Refactor decorateCooked to run in a detached DOM (#9517)

This means that decorateCooked can be used to modify HTML without triggering the download of remote resources (e.g. images)

In some rare cases (e.g. IntersectionObservers in Chromium), decorating needs to happen in the real DOM. For this, pass `afterAdopt: true` to `decorateCooked`
This commit is contained in:
David Taylor 2020-04-23 11:03:46 +01:00 committed by GitHub
parent 8dccf0521c
commit 8e28ccb2ea
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 78 additions and 31 deletions

View File

@ -84,6 +84,14 @@ function show(image) {
}
}
function forEachImage($post, callback) {
$post[0].querySelectorAll("img").forEach(img => {
if (img.width >= MINIMUM_SIZE && img.height >= MINIMUM_SIZE) {
callback(img);
}
});
}
export function setupLazyLoading(api) {
const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
@ -96,15 +104,20 @@ export function setupLazyLoading(api) {
});
}, OBSERVER_OPTIONS);
api.decorateCooked($post => forEachImage($post, img => hide(img)), {
onlyStream: true,
id: "discourse-lazy-load"
});
// IntersectionObserver.observe must be called after the cooked
// content is adopted by the document element in chrome
// https://bugs.chromium.org/p/chromium/issues/detail?id=1073469
api.decorateCooked(
$post => {
$("img", $post).each((_, img) => {
if (img.width >= MINIMUM_SIZE && img.height >= MINIMUM_SIZE) {
hide(img);
observer.observe(img);
}
});
},
{ onlyStream: true, id: "discourse-lazy-load" }
$post => forEachImage($post, img => observer.observe(img)),
{
onlyStream: true,
id: "discourse-lazy-load-after-adopt",
afterAdopt: true
}
);
}

View File

@ -54,7 +54,7 @@ import { on } from "@ember/object/evented";
import KeyboardShortcuts from "discourse/lib/keyboard-shortcuts";
// If you add any methods to the API ensure you bump up this number
const PLUGIN_API_VERSION = "0.8.40";
const PLUGIN_API_VERSION = "0.8.41";
class PluginApi {
constructor(version, container) {
@ -200,6 +200,9 @@ class PluginApi {
* Use `options.onlyStream` if you only want to decorate posts within a topic,
* and not in other places like the user stream.
*
* Decoration normally happens in a detached DOM. Use `options.afterAdopt`
* to decorate html content after it is adopted by the main `document`.
*
* For example, to add a yellow background to all posts you could do this:
*
* ```
@ -215,7 +218,7 @@ class PluginApi {
decorateCooked(callback, opts) {
opts = opts || {};
addDecorator(callback);
addDecorator(callback, { afterAdopt: !!opts.afterAdopt });
if (!opts.onlyStream) {
decorate(ComposerEditor, "previewRefreshed", callback, opts.id);

View File

@ -8,15 +8,27 @@ import {
unhighlightHTML
} from "discourse/lib/highlight-html";
let _decorators = [];
let _beforeAdoptDecorators = [];
let _afterAdoptDecorators = [];
// Don't call this directly: use `plugin-api/decorateCooked`
export function addDecorator(cb) {
_decorators.push(cb);
export function addDecorator(callback, { afterAdopt = false } = {}) {
if (afterAdopt) {
_afterAdoptDecorators.push(callback);
} else {
_beforeAdoptDecorators.push(callback);
}
}
export function resetDecorators() {
_decorators = [];
_beforeAdoptDecorators = [];
_afterAdoptDecorators = [];
}
let detachedDocument = document.implementation.createHTMLDocument("detached");
function createDetachedElement(nodeName) {
return detachedDocument.createElement(nodeName);
}
export default class PostCooked {
@ -41,14 +53,27 @@ export default class PostCooked {
}
init() {
const $html = this._computeCooked();
this._insertQuoteControls($html);
this._showLinkCounts($html);
this._fixImageSizes($html);
this._applySearchHighlight($html);
const cookedDiv = this._computeCooked();
const $cookedDiv = $(cookedDiv);
_decorators.forEach(cb => cb($html, this.decoratorHelper));
return $html[0];
this._insertQuoteControls($cookedDiv);
this._showLinkCounts($cookedDiv);
this._fixImageSizes($cookedDiv);
this._applySearchHighlight($cookedDiv);
this._decorateAndAdopt(cookedDiv);
return cookedDiv;
}
_decorateAndAdopt(cooked) {
const $cooked = $(cooked);
_beforeAdoptDecorators.forEach(d => d($cooked, this.decoratorHelper));
document.adoptNode(cooked);
_afterAdoptDecorators.forEach(d => d($cooked, this.decoratorHelper));
}
_applySearchHighlight($html) {
@ -175,12 +200,14 @@ export default class PostCooked {
quotedPosts[result.id] = result;
post.set("quoted", quotedPosts);
const div = $("<div class='expanded-quote'></div>");
div.data("post-id", result.id);
div.html(result.cooked);
_decorators.forEach(cb => cb(div, this.decoratorHelper));
const div = createDetachedElement("div");
div.classList.add("expanded-quote");
div.dataset.postId = result.id;
div.innerHTML = result.cooked;
highlightHTML(div[0], originalText, {
this._decorateAndAdopt(div);
highlightHTML(div, originalText, {
matchCase: true
});
$blockQuote.showHtml(div, "fast", finished);
@ -275,18 +302,22 @@ export default class PostCooked {
}
_computeCooked() {
const cookedDiv = createDetachedElement("div");
cookedDiv.classList.add("cooked");
if (
(this.attrs.firstPost || this.attrs.embeddedPost) &&
this.ignoredUsers &&
this.ignoredUsers.length > 0 &&
this.ignoredUsers.includes(this.attrs.username)
) {
return $(
`<div class='cooked post-ignored'>${I18n.t("post.ignored")}</div>`
);
cookedDiv.classList.add("post-ignored");
cookedDiv.innerHTML = I18n.t("post.ignored");
} else {
cookedDiv.innerHTML = this.attrs.cooked;
}
return $(`<div class='cooked'>${this.attrs.cooked}</div>`);
return cookedDiv;
}
}