From 2e950eb07ae4828ab66c54ddf9b343ff24176004 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Tue, 26 Sep 2023 13:16:48 +0100 Subject: [PATCH] DEV: Introduce RenderGlimmer for raw hbs (#23592) A new `rawRenderGlimmer` function is introduced which can be used to render glimmer components inside our legacy 'raw hbs' views. See discourse/lib/raw-render-glimmer for more information. This will help as we work to move away from raw-hbs use. --- .../discourse-common/addon/lib/helpers.js | 19 +++- .../components/render-glimmer-container.gjs | 17 ++++ .../javascripts/discourse/app/helpers/raw.js | 27 +++++- .../discourse/app/lib/raw-render-glimmer.js | 53 +++++++++++ .../discourse/app/services/render-glimmer.js | 31 +++++++ .../discourse/app/templates/application.hbs | 1 + .../tests/integration/helpers/raw-test.gjs | 91 +++++++++++++++++++ 7 files changed, 233 insertions(+), 6 deletions(-) create mode 100644 app/assets/javascripts/discourse/app/components/render-glimmer-container.gjs create mode 100644 app/assets/javascripts/discourse/app/lib/raw-render-glimmer.js create mode 100644 app/assets/javascripts/discourse/app/services/render-glimmer.js create mode 100644 app/assets/javascripts/discourse/tests/integration/helpers/raw-test.gjs diff --git a/app/assets/javascripts/discourse-common/addon/lib/helpers.js b/app/assets/javascripts/discourse-common/addon/lib/helpers.js index 1d63899b41f..a0d08262d32 100644 --- a/app/assets/javascripts/discourse-common/addon/lib/helpers.js +++ b/app/assets/javascripts/discourse-common/addon/lib/helpers.js @@ -82,7 +82,23 @@ function resolveParams(ctx, options) { return params; } +/** + * Register a helper for Ember and raw-hbs. This exists for + * legacy reasons, and should be avoided in new code. Instead, you should + * do `export default ...` from a `helpers/*.js` file. + */ export function registerUnbound(name, fn) { + _helpers[name] = Helper.extend({ + compute: (params, args) => fn(...params, args), + }); + + registerRawHelper(name, fn); +} + +/** + * Register a helper for raw-hbs only + */ +export function registerRawHelper(name, fn) { const func = function (...args) { const options = args.pop(); const properties = args; @@ -99,8 +115,5 @@ export function registerUnbound(name, fn) { return fn.call(this, ...properties, resolveParams(this, options)); }; - _helpers[name] = Helper.extend({ - compute: (params, args) => fn(...params, args), - }); RawHandlebars.registerHelper(name, func); } diff --git a/app/assets/javascripts/discourse/app/components/render-glimmer-container.gjs b/app/assets/javascripts/discourse/app/components/render-glimmer-container.gjs new file mode 100644 index 00000000000..353afff47cc --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/render-glimmer-container.gjs @@ -0,0 +1,17 @@ +import Component from "@glimmer/component"; +import { inject as service } from "@ember/service"; + +export default class RenderGlimmerContainer extends Component { + + + @service renderGlimmer; +} diff --git a/app/assets/javascripts/discourse/app/helpers/raw.js b/app/assets/javascripts/discourse/app/helpers/raw.js index ad7c2a70ea3..a1d7c6b1235 100644 --- a/app/assets/javascripts/discourse/app/helpers/raw.js +++ b/app/assets/javascripts/discourse/app/helpers/raw.js @@ -1,8 +1,13 @@ -import { helperContext, registerUnbound } from "discourse-common/lib/helpers"; +import { helperContext, registerRawHelper } from "discourse-common/lib/helpers"; import { findRawTemplate } from "discourse-common/lib/raw-templates"; import { htmlSafe } from "@ember/template"; import { RUNTIME_OPTIONS } from "discourse-common/lib/raw-handlebars-helpers"; import { getOwner, setOwner } from "@ember/application"; +import Helper from "@ember/component/helper"; +import { registerDestructor } from "@ember/destroyable"; +import { schedule } from "@ember/runloop"; +import { bind } from "discourse-common/utils/decorators"; +import { inject as service } from "@ember/service"; function renderRaw(ctx, template, templateName, params) { params = { ...params }; @@ -25,7 +30,7 @@ function renderRaw(ctx, template, templateName, params) { return htmlSafe(template(params, RUNTIME_OPTIONS)); } -registerUnbound("raw", function (templateName, params) { +const helperFunction = function (templateName, params) { templateName = templateName.replace(".", "/"); const template = findRawTemplate(templateName); @@ -35,4 +40,20 @@ registerUnbound("raw", function (templateName, params) { return; } return renderRaw(this, template, templateName, params); -}); +}; + +registerRawHelper("raw", helperFunction); + +export default class RawHelper extends Helper { + @service renderGlimmer; + + compute(args, params) { + registerDestructor(this, this.cleanup); + return helperFunction(...args, params); + } + + @bind + cleanup() { + schedule("afterRender", () => this.renderGlimmer.cleanup()); + } +} diff --git a/app/assets/javascripts/discourse/app/lib/raw-render-glimmer.js b/app/assets/javascripts/discourse/app/lib/raw-render-glimmer.js new file mode 100644 index 00000000000..a42428107cf --- /dev/null +++ b/app/assets/javascripts/discourse/app/lib/raw-render-glimmer.js @@ -0,0 +1,53 @@ +import { schedule } from "@ember/runloop"; +import { getOwner } from "@ember/application"; + +let counter = 0; + +/** + * Generate HTML which can be inserted into a raw-hbs template to render a Glimmer component. + * The result of this function must be rendered immediately, so that an `afterRender` hook + * can access the element in the DOM and attach the glimmer component. + * + * Example usage: + * + * ```hbs + * {{! raw-templates/something-cool.hbr }} + * {{{view.html}}} + * ``` + * + * ```gjs + * // raw-views/something-cool.gjs + * import EmberObject from "@ember/object"; + * import rawRenderGlimmer from "discourse/lib/raw-render-glimmer"; + * + * export default class SomethingCool extends EmberObject { + * get html(){ + * return rawRenderGlimmer(this, "div", , { name: this.name }); + * } + * ``` + * + * And then this can be invoked from any other raw view (including raw plugin outlets) like: + * + * ```hbs + * {{raw "something-cool" name="david"}} + * ``` + */ +export default function rawRenderGlimmer(owner, renderInto, component, data) { + const renderGlimmerService = getOwner(owner).lookup("service:render-glimmer"); + + counter++; + const id = `_render_glimmer_${counter}`; + const [type, ...classNames] = renderInto.split("."); + + schedule("afterRender", () => { + const element = document.getElementById(id); + const componentInfo = { + element, + component, + data, + }; + renderGlimmerService.add(componentInfo); + }); + + return `<${type} id="${id}" class="${classNames.join(" ")}">`; +} diff --git a/app/assets/javascripts/discourse/app/services/render-glimmer.js b/app/assets/javascripts/discourse/app/services/render-glimmer.js new file mode 100644 index 00000000000..6d296869576 --- /dev/null +++ b/app/assets/javascripts/discourse/app/services/render-glimmer.js @@ -0,0 +1,31 @@ +import Service from "@ember/service"; +import { TrackedSet } from "@ember-compat/tracked-built-ins"; + +/** + * This service is responsible for rendering glimmer components into HTML generated + * by raw-hbs. It is not intended to be used directly. + * + * See discourse/lib/raw-render-glimmer.js for usage instructions. + */ +export default class RenderGlimmerService extends Service { + _registrations = new TrackedSet(); + + add(info) { + this._registrations.add(info); + } + + remove(info) { + this._registrations.delete(info); + } + + /** + * Removes registrations for elements which are no longer in the DOM. + */ + cleanup() { + this._registrations.forEach((info) => { + if (!document.body.contains(info.element)) { + this.remove(info); + } + }); + } +} diff --git a/app/assets/javascripts/discourse/app/templates/application.hbs b/app/assets/javascripts/discourse/app/templates/application.hbs index 60ba0ca5faa..b08663389cd 100644 --- a/app/assets/javascripts/discourse/app/templates/application.hbs +++ b/app/assets/javascripts/discourse/app/templates/application.hbs @@ -101,6 +101,7 @@ + {{#if this.showFooterNav}} diff --git a/app/assets/javascripts/discourse/tests/integration/helpers/raw-test.gjs b/app/assets/javascripts/discourse/tests/integration/helpers/raw-test.gjs new file mode 100644 index 00000000000..5cee8589cac --- /dev/null +++ b/app/assets/javascripts/discourse/tests/integration/helpers/raw-test.gjs @@ -0,0 +1,91 @@ +import { module, test } from "qunit"; +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import { render, settled } from "@ember/test-helpers"; +import { + addRawTemplate, + removeRawTemplate, +} from "discourse-common/lib/raw-templates"; +import raw from "discourse/helpers/raw"; +import rawRenderGlimmer from "discourse/lib/raw-render-glimmer"; +import RenderGlimmerContainer from "discourse/components/render-glimmer-container"; +import { tracked } from "@glimmer/tracking"; +import { getOwner } from "@ember/application"; +import Component from "@glimmer/component"; + +// We don't have any way to actually compile raw hbs inside tests, so this is only testing +// the helper itself, not the actual rendering of templates. +module("Integration | Helper | raw", function (hooks) { + setupRenderingTest(hooks); + + hooks.afterEach(() => { + removeRawTemplate("raw-test"); + }); + + test("can render a template", async function (assert) { + addRawTemplate("raw-test", (params) => `raw test ${params.someArg}`); + + await render(); + + assert.dom(`span`).hasText("raw test foo"); + }); + + test("can render glimmer inside", async function (assert) { + let willDestroyCalled = false; + + class MyComponent extends Component { + + + willDestroy() { + willDestroyCalled = true; + } + } + + addRawTemplate("raw-test", (params) => + rawRenderGlimmer(this, "div", MyComponent, { someArg: params.someArg }) + ); + + class TestState { + @tracked showRawTemplate = true; + } + + const testState = new TestState(); + + const renderGlimmerService = getOwner(this).lookup( + "service:render-glimmer" + ); + + await render(); + + assert.dom(`span`).hasText("Hello from glimmer foo"); + assert.strictEqual( + renderGlimmerService._registrations.size, + 1, + "renderGlimmer service has one registration" + ); + + testState.showRawTemplate = false; + await settled(); + + assert.dom(`span`).hasText(""); + assert.strictEqual( + renderGlimmerService._registrations.size, + 0, + "renderGlimmer service has no registrations" + ); + + assert.true(willDestroyCalled, "component was cleaned up correctly"); + }); +});