DEV: Introduce DecoratedHtml component and use for discourse-banner (#31305)

Eventually, this new component will be used everywhere that we render
'decorated' HTML (e.g. all posts and chat messages). It takes the best
bits from our main widget-based post rendering and re-creates it in a
more ember-native way. For example:

- The HTML is first rendered in a detached DOM, so that requests for
images/iframes/etc. are not triggered until after the decoration

- HTML generation and decoration is done in a helper (i.e. during the
'render' phase of the runloop). I think that's the 'most Ember
compatible' way for us to do this. It means that components added via
`renderGlimmer` will be rendered in the same runloop, and it means that
things like `schedule("afterRender")` will work exactly as expected.

- HTML will be re-rendered and re-decorated whenever the `@html` or
`@decorate` arguments change

- BUT, `untrack` is used to ensure that reactive state accessed inside
the decorate function will not trigger a re-render. This is mostly for
compatibility with existing decorators, and we may want to make
reactivity opt-in in future

- A self-contained `renderGlimmer` system is included. This will allow
`helper.renderGlimmer` to be used for any content in these components.
Implementing it in a self-contained way rather than using the service
means that the component will work ok in unit tests, and that rendered
components will show up in the right place in the Ember inspector.

This commit only introduces the new component in DiscourseBanner.
Followups will introduce it elsewhere.
This commit is contained in:
David Taylor 2025-02-13 12:20:52 +00:00 committed by GitHub
parent 3f0e84054b
commit 8d709aeb9c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 141 additions and 16 deletions

View File

@ -0,0 +1,76 @@
import Component from "@glimmer/component";
import { untrack } from "@glimmer/validator";
import { htmlSafe, isHTMLSafe } from "@ember/template";
import { TrackedArray } from "@ember-compat/tracked-built-ins";
import helperFn from "discourse/helpers/helper-fn";
const detachedDocument = document.implementation.createHTMLDocument("detached");
/**
* Reactively renders cooked HTML with decorations applied.
*/
export default class DecoratedHtml extends Component {
renderGlimmerInfos = new TrackedArray();
decoratedContent = helperFn((args, on) => {
const cookedDiv = this.elementToDecorate;
const helper = new DecorateHtmlHelper({
renderGlimmerInfos: this.renderGlimmerInfos,
});
on.cleanup(() => helper.teardown());
const decorateFn = this.args.decorate;
untrack(() => decorateFn?.(cookedDiv, helper));
document.adoptNode(cookedDiv);
return cookedDiv;
});
get elementToDecorate() {
const cooked = this.args.html || htmlSafe("");
if (!isHTMLSafe(cooked)) {
throw "@cooked must be an htmlSafe string";
}
const cookedDiv = detachedDocument.createElement("div");
cookedDiv.innerHTML = cooked.toString();
if (this.args.id) {
cookedDiv.id = this.args.id;
}
if (this.args.className) {
cookedDiv.className = this.args.className;
}
return cookedDiv;
}
<template>
{{~this.decoratedContent~}}
{{~#each this.renderGlimmerInfos as |info|~}}
{{~#in-element info.element insertBefore=null~}}
<info.component @data={{info.data}} />
{{~/in-element~}}
{{~/each~}}
</template>
}
class DecorateHtmlHelper {
constructor({ renderGlimmerInfos }) {
this.renderGlimmerInfos = renderGlimmerInfos;
}
renderGlimmer(element, component, data) {
const info = { element, component, data };
this.renderGlimmerInfos.push(info);
}
getModel() {
return null;
}
teardown() {
this.renderGlimmerInfos.length = 0;
}
}

View File

@ -1,12 +1,13 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import { service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import DButton from "discourse/components/d-button";
import icon from "discourse/helpers/d-icon";
import { bind } from "discourse/lib/decorators";
import { i18n } from "discourse-i18n";
import DecoratedHtml from "./decorated-html";
export default class DiscourseBanner extends Component {
@service appEvents;
@ -27,7 +28,7 @@ export default class DiscourseBanner extends Component {
newDiv.querySelectorAll("[id^='heading--']").forEach((el) => {
el.removeAttribute("id");
});
return newDiv.innerHTML;
return htmlSafe(newDiv.innerHTML);
}
get visible() {
@ -47,9 +48,13 @@ export default class DiscourseBanner extends Component {
return !this.hide && bannerKey && dismissedBannerKey !== bannerKey;
}
@action
decorate(element) {
this.appEvents.trigger("decorate-non-stream-cooked-element", element);
@bind
decorateContent(element, helper) {
this.appEvents.trigger(
"decorate-non-stream-cooked-element",
element,
helper
);
}
@action
@ -66,7 +71,7 @@ export default class DiscourseBanner extends Component {
}
<template>
<div {{didInsert this.decorate}}>
<div>
{{#if this.visible}}
<div class="row">
<div id="banner">
@ -91,9 +96,11 @@ export default class DiscourseBanner extends Component {
/>
</div>
<div id="banner-content">
{{htmlSafe this.content}}
</div>
<DecoratedHtml
@html={{this.content}}
@decorate={{this.decorateContent}}
@id="banner-content"
/>
</div>
</div>
{{/if}}

View File

@ -0,0 +1,46 @@
import { tracked } from "@glimmer/tracking";
import { htmlSafe } from "@ember/template";
import { render, settled } from "@ember/test-helpers";
import { module, test } from "qunit";
import DecoratedHtml from "discourse/components/decorated-html";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
module("Integration | Component | <DecoratedHtml />", function (hooks) {
setupRenderingTest(hooks);
test("renders and re-renders content", async function (assert) {
const state = new (class {
@tracked html = htmlSafe("<h1>Initial</h1>");
})();
await render(<template><DecoratedHtml @html={{state.html}} /></template>);
assert.dom("h1").hasText("Initial");
state.html = htmlSafe("<h1>Updated</h1>");
await settled();
assert.dom("h1").hasText("Updated");
});
test("can decorate content, including renderGlimmer", async function (assert) {
const state = new (class {
@tracked html = htmlSafe("<h1>Initial</h1>");
})();
const decorate = (element, helper) => {
element.innerHTML += "<div id='appended'>Appended</div>";
helper.renderGlimmer(element, <template>
<div id="render-glimmer">Hello from Glimmer Component</div>
</template>);
};
await render(<template>
<DecoratedHtml @html={{state.html}} @decorate={{decorate}} />
</template>);
assert.dom("h1").hasText("Initial");
assert.dom("#appended").hasText("Appended");
assert.dom("#render-glimmer").hasText("Hello from Glimmer Component");
});
});

View File

@ -55,14 +55,10 @@ export function checklistSyntax(elem, postDecorator) {
const boxes = [...elem.getElementsByClassName("chcklst-box")];
addUlClasses(boxes);
if (!postDecorator) {
return;
}
const postWidget = postDecorator?.widget;
const postModel = postDecorator?.getModel();
const postWidget = postDecorator.widget;
const postModel = postDecorator.getModel();
if (!postModel.can_edit) {
if (!postModel?.can_edit) {
return;
}