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.
This commit is contained in:
David Taylor 2023-09-26 13:16:48 +01:00 committed by GitHub
parent 42070d49da
commit 2e950eb07a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 233 additions and 6 deletions

View File

@ -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);
}

View File

@ -0,0 +1,17 @@
import Component from "@glimmer/component";
import { inject as service } from "@ember/service";
export default class RenderGlimmerContainer extends Component {
<template>
{{#each this.renderGlimmer._registrations as |info|}}
{{#in-element info.element insertBefore=null}}
<info.component
@data={{info.data}}
@setWrapperElementAttrs={{info.setWrapperElementAttrs}}
/>
{{/in-element}}
{{/each}}
</template>
@service renderGlimmer;
}

View File

@ -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());
}
}

View File

@ -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", <template>Hello {{@data.name}}</template>, { 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(" ")}"></${type}>`;
}

View File

@ -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);
}
});
}
}

View File

@ -101,6 +101,7 @@
<DialogHolder />
<TopicEntrance />
<ComposerContainer />
<RenderGlimmerContainer />
{{#if this.showFooterNav}}
<FooterNav />

View File

@ -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(<template>
<span>{{raw "raw-test" someArg="foo"}}</span>
</template>);
assert.dom(`span`).hasText("raw test foo");
});
test("can render glimmer inside", async function (assert) {
let willDestroyCalled = false;
class MyComponent extends Component {
<template>
Hello from glimmer {{@data.someArg}}
</template>
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(<template>
{{! RenderGlimmerContainer is normally rendered by application.hbs
but this is not an acceptance test so we gotta include it manually }}
<RenderGlimmerContainer />
<span>
{{#if testState.showRawTemplate}}
{{raw "raw-test" someArg="foo"}}
{{/if}}
</span>
</template>);
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");
});
});