From 996d28e46c0e1a12ae5b04896e6636261cc71bb8 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Fri, 10 Apr 2026 11:36:28 -0400 Subject: [PATCH 1/3] RFC#389 - {{element}} as keyword Register element as a built-in keyword so it no longer needs to be imported in strict-mode (gjs/gts) templates. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../template-compiler/lib/compile-options.ts | 3 +- .../lib/plugins/auto-import-builtins.ts | 13 +++ .../test/keywords/element-runtime-test.ts | 84 +++++++++++++++++++ .../test/keywords/element-test.ts | 79 +++++++++++++++++ smoke-tests/scenarios/basic-test.ts | 24 ++++++ 5 files changed, 202 insertions(+), 1 deletion(-) create mode 100644 packages/@glimmer-workspace/integration-tests/test/keywords/element-runtime-test.ts create mode 100644 packages/@glimmer-workspace/integration-tests/test/keywords/element-test.ts diff --git a/packages/@ember/template-compiler/lib/compile-options.ts b/packages/@ember/template-compiler/lib/compile-options.ts index 097786e9353..4087ce766e0 100644 --- a/packages/@ember/template-compiler/lib/compile-options.ts +++ b/packages/@ember/template-compiler/lib/compile-options.ts @@ -1,4 +1,4 @@ -import { array, fn, hash } from '@ember/helper'; +import { array, element, fn, hash } from '@ember/helper'; import { on } from '@ember/modifier'; import { assert } from '@ember/debug'; import { @@ -26,6 +26,7 @@ export const RUNTIME_KEYWORDS_NAME = '__ember_keywords__'; export const keywords: Record = { array, + element, fn, hash, on, diff --git a/packages/@ember/template-compiler/lib/plugins/auto-import-builtins.ts b/packages/@ember/template-compiler/lib/plugins/auto-import-builtins.ts index e7c9a6bd1bc..f92838deeda 100644 --- a/packages/@ember/template-compiler/lib/plugins/auto-import-builtins.ts +++ b/packages/@ember/template-compiler/lib/plugins/auto-import-builtins.ts @@ -30,6 +30,9 @@ export default function autoImportBuiltins(env: EmberASTPluginEnvironment): ASTP if (isArray(node, hasLocal)) { rewriteKeyword(env, node, 'array', '@ember/helper'); } + if (isElement(node, hasLocal)) { + rewriteKeyword(env, node, 'element', '@ember/helper'); + } if (isFn(node, hasLocal)) { rewriteKeyword(env, node, 'fn', '@ember/helper'); } @@ -41,6 +44,9 @@ export default function autoImportBuiltins(env: EmberASTPluginEnvironment): ASTP if (isArray(node, hasLocal)) { rewriteKeyword(env, node, 'array', '@ember/helper'); } + if (isElement(node, hasLocal)) { + rewriteKeyword(env, node, 'element', '@ember/helper'); + } if (isFn(node, hasLocal)) { rewriteKeyword(env, node, 'fn', '@ember/helper'); } @@ -94,3 +100,10 @@ function isHash( ): node is (AST.MustacheStatement | AST.SubExpression) & { path: AST.PathExpression } { return isPath(node.path) && node.path.original === 'hash' && !hasLocal('hash'); } + +function isElement( + node: AST.MustacheStatement | AST.SubExpression, + hasLocal: (k: string) => boolean +): node is (AST.MustacheStatement | AST.SubExpression) & { path: AST.PathExpression } { + return isPath(node.path) && node.path.original === 'element' && !hasLocal('element'); +} diff --git a/packages/@glimmer-workspace/integration-tests/test/keywords/element-runtime-test.ts b/packages/@glimmer-workspace/integration-tests/test/keywords/element-runtime-test.ts new file mode 100644 index 00000000000..50e031e784a --- /dev/null +++ b/packages/@glimmer-workspace/integration-tests/test/keywords/element-runtime-test.ts @@ -0,0 +1,84 @@ +import { castToBrowser } from '@glimmer/debug-util'; +import { + GlimmerishComponent, + jitSuite, + RenderTest, + test, +} from '@glimmer-workspace/integration-tests'; + +import { template } from '@ember/template-compiler/runtime'; + +class KeywordElement extends RenderTest { + static suiteName = 'keyword helper: element (runtime)'; + + @test + 'explicit scope'(assert: Assert) { + const compiled = template('{{#let (element "h1") as |Tag|}}Hello{{/let}}', { + strictMode: true, + scope: () => ({}), + }); + + this.renderComponent(compiled); + + let h1 = castToBrowser(this.element, 'div').querySelector('h1'); + assert.ok(h1, 'h1 element exists'); + assert.strictEqual(h1!.textContent, 'Hello'); + } + + @test + 'implicit scope'(assert: Assert) { + const compiled = template('{{#let (element "h1") as |Tag|}}Hello{{/let}}', { + strictMode: true, + eval() { + return eval(arguments[0]); + }, + }); + + this.renderComponent(compiled); + + let h1 = castToBrowser(this.element, 'div').querySelector('h1'); + assert.ok(h1, 'h1 element exists'); + assert.strictEqual(h1!.textContent, 'Hello'); + } + + @test + 'MustacheStatement with explicit scope'(assert: Assert) { + const Child = template('{{#let @tag as |Tag|}}World{{/let}}', { + strictMode: true, + scope: () => ({}), + }); + + const compiled = template('', { + strictMode: true, + scope: () => ({ + Child, + }), + }); + + this.renderComponent(compiled); + + let span = castToBrowser(this.element, 'div').querySelector('span'); + assert.ok(span, 'span element exists'); + assert.strictEqual(span!.textContent, 'World'); + } + + @test + 'no eval and no scope'(assert: Assert) { + class Foo extends GlimmerishComponent { + static { + template('{{#let (element "h1") as |Tag|}}Hello{{/let}}', { + strictMode: true, + component: this, + }); + } + } + + this.renderComponent(Foo); + + let h1 = castToBrowser(this.element, 'div').querySelector('h1'); + assert.ok(h1, 'h1 element exists'); + assert.strictEqual(h1!.textContent, 'Hello'); + } +} + +jitSuite(KeywordElement); diff --git a/packages/@glimmer-workspace/integration-tests/test/keywords/element-test.ts b/packages/@glimmer-workspace/integration-tests/test/keywords/element-test.ts new file mode 100644 index 00000000000..fbf278f82a7 --- /dev/null +++ b/packages/@glimmer-workspace/integration-tests/test/keywords/element-test.ts @@ -0,0 +1,79 @@ +import { castToBrowser } from '@glimmer/debug-util'; +import { jitSuite, RenderTest, test } from '@glimmer-workspace/integration-tests'; + +import { template } from '@ember/template-compiler'; +import { element } from '@ember/helper'; + +class KeywordElement extends RenderTest { + static suiteName = 'keyword helper: element'; + + @test + 'it works as a SubExpression'(assert: Assert) { + const compiled = template('{{#let (element "h1") as |Tag|}}Hello{{/let}}', { + strictMode: true, + scope: () => ({ element }), + }); + + this.renderComponent(compiled); + + let h1 = castToBrowser(this.element, 'div').querySelector('h1'); + assert.ok(h1, 'h1 element exists'); + assert.strictEqual(h1!.textContent, 'Hello'); + } + + @test + 'it works with the runtime compiler'(assert: Assert) { + hide(element); + + const compiled = template('{{#let (element "h1") as |Tag|}}Hello{{/let}}', { + strictMode: true, + eval() { + return eval(arguments[0]); + }, + }); + + this.renderComponent(compiled); + + let h1 = castToBrowser(this.element, 'div').querySelector('h1'); + assert.ok(h1, 'h1 element exists'); + assert.strictEqual(h1!.textContent, 'Hello'); + } + + @test + 'it works as a MustacheStatement'(assert: Assert) { + const Child = template('{{#let @tag as |Tag|}}World{{/let}}', { + strictMode: true, + scope: () => ({}), + }); + + const compiled = template('', { + strictMode: true, + scope: () => ({ + element, + Child, + }), + }); + + this.renderComponent(compiled); + + let span = castToBrowser(this.element, 'div').querySelector('span'); + assert.ok(span, 'span element exists'); + assert.strictEqual(span!.textContent, 'World'); + } +} + +jitSuite(KeywordElement); + +/** + * This function is used to hide a variable from the transpiler, so that it + * doesn't get removed as "unused". It does not actually do anything with the + * variable, it just makes it be part of an expression that the transpiler + * won't remove. + * + * It's a bit of a hack, but it's necessary for testing. + * + * @param variable The variable to hide. + */ +const hide = (variable: unknown) => { + new Function(`return (${JSON.stringify(variable)});`); +}; diff --git a/smoke-tests/scenarios/basic-test.ts b/smoke-tests/scenarios/basic-test.ts index 6e5d6424bf4..d74361c7800 100644 --- a/smoke-tests/scenarios/basic-test.ts +++ b/smoke-tests/scenarios/basic-test.ts @@ -401,6 +401,30 @@ function basicTest(scenarios: Scenarios, appName: string) { }); }); `, + 'element-as-keyword-test.gjs': ` + import { module, test } from 'qunit'; + import { setupRenderingTest } from 'ember-qunit'; + import { render } from '@ember/test-helpers'; + + import Component from '@glimmer/component'; + + class Demo extends Component { + + } + + module('{{element}} as keyword', function(hooks) { + setupRenderingTest(hooks); + + test('it works', async function(assert) { + await render(Demo); + assert.dom('h1.greeting').hasText('Hello from element keyword'); + }); + }); + `, 'fn-as-keyword-but-its-shadowed-test.gjs': ` import QUnit, { module, test } from 'qunit'; import { setupRenderingTest } from 'ember-qunit'; From dd39f783230660ff23c584eccba2c5d163b13489 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Sun, 26 Apr 2026 12:52:29 -0400 Subject: [PATCH 2/3] Cleanup --- .../test/keywords/element-runtime-test.ts | 38 +++++++----------- .../test/keywords/element-test.ts | 40 ++++++++----------- smoke-tests/scenarios/basic-test.ts | 28 ++++++++----- 3 files changed, 47 insertions(+), 59 deletions(-) diff --git a/packages/@glimmer-workspace/integration-tests/test/keywords/element-runtime-test.ts b/packages/@glimmer-workspace/integration-tests/test/keywords/element-runtime-test.ts index 50e031e784a..b8e25ea0205 100644 --- a/packages/@glimmer-workspace/integration-tests/test/keywords/element-runtime-test.ts +++ b/packages/@glimmer-workspace/integration-tests/test/keywords/element-runtime-test.ts @@ -8,7 +8,7 @@ import { import { template } from '@ember/template-compiler/runtime'; -class KeywordElement extends RenderTest { +class KeywordElementRuntime extends RenderTest { static suiteName = 'keyword helper: element (runtime)'; @test @@ -26,40 +26,30 @@ class KeywordElement extends RenderTest { } @test - 'implicit scope'(assert: Assert) { - const compiled = template('{{#let (element "h1") as |Tag|}}Hello{{/let}}', { + 'explicit scope (shadowed)'() { + const compiled = template('{{element "h1"}}', { strictMode: true, - eval() { - return eval(arguments[0]); - }, + scope: () => ({ element: () => 'surprise' }), }); this.renderComponent(compiled); - - let h1 = castToBrowser(this.element, 'div').querySelector('h1'); - assert.ok(h1, 'h1 element exists'); - assert.strictEqual(h1!.textContent, 'Hello'); + this.assertHTML('surprise'); } @test - 'MustacheStatement with explicit scope'(assert: Assert) { - const Child = template('{{#let @tag as |Tag|}}World{{/let}}', { - strictMode: true, - scope: () => ({}), - }); - - const compiled = template('', { + 'implicit scope (eval)'(assert: Assert) { + const compiled = template('{{#let (element "h1") as |Tag|}}Hello{{/let}}', { strictMode: true, - scope: () => ({ - Child, - }), + eval() { + return eval(arguments[0]); + }, }); this.renderComponent(compiled); - let span = castToBrowser(this.element, 'div').querySelector('span'); - assert.ok(span, 'span element exists'); - assert.strictEqual(span!.textContent, 'World'); + let h1 = castToBrowser(this.element, 'div').querySelector('h1'); + assert.ok(h1, 'h1 element exists'); + assert.strictEqual(h1!.textContent, 'Hello'); } @test @@ -81,4 +71,4 @@ class KeywordElement extends RenderTest { } } -jitSuite(KeywordElement); +jitSuite(KeywordElementRuntime); diff --git a/packages/@glimmer-workspace/integration-tests/test/keywords/element-test.ts b/packages/@glimmer-workspace/integration-tests/test/keywords/element-test.ts index fbf278f82a7..efaf5f7e442 100644 --- a/packages/@glimmer-workspace/integration-tests/test/keywords/element-test.ts +++ b/packages/@glimmer-workspace/integration-tests/test/keywords/element-test.ts @@ -2,16 +2,15 @@ import { castToBrowser } from '@glimmer/debug-util'; import { jitSuite, RenderTest, test } from '@glimmer-workspace/integration-tests'; import { template } from '@ember/template-compiler'; -import { element } from '@ember/helper'; class KeywordElement extends RenderTest { static suiteName = 'keyword helper: element'; @test - 'it works as a SubExpression'(assert: Assert) { + 'explicit scope'(assert: Assert) { const compiled = template('{{#let (element "h1") as |Tag|}}Hello{{/let}}', { strictMode: true, - scope: () => ({ element }), + scope: () => ({}), }); this.renderComponent(compiled); @@ -22,9 +21,19 @@ class KeywordElement extends RenderTest { } @test - 'it works with the runtime compiler'(assert: Assert) { - hide(element); + 'explicit scope (shadowed)'() { + let element = () => 'surprise'; + const compiled = template('{{element "h1"}}', { + strictMode: true, + scope: () => ({ element }), + }); + this.renderComponent(compiled); + this.assertHTML('surprise'); + } + + @test + 'implicit scope (eval)'(assert: Assert) { const compiled = template('{{#let (element "h1") as |Tag|}}Hello{{/let}}', { strictMode: true, eval() { @@ -40,7 +49,7 @@ class KeywordElement extends RenderTest { } @test - 'it works as a MustacheStatement'(assert: Assert) { + 'MustacheStatement'(assert: Assert) { const Child = template('{{#let @tag as |Tag|}}World{{/let}}', { strictMode: true, scope: () => ({}), @@ -48,10 +57,7 @@ class KeywordElement extends RenderTest { const compiled = template('', { strictMode: true, - scope: () => ({ - element, - Child, - }), + scope: () => ({ Child }), }); this.renderComponent(compiled); @@ -63,17 +69,3 @@ class KeywordElement extends RenderTest { } jitSuite(KeywordElement); - -/** - * This function is used to hide a variable from the transpiler, so that it - * doesn't get removed as "unused". It does not actually do anything with the - * variable, it just makes it be part of an expression that the transpiler - * won't remove. - * - * It's a bit of a hack, but it's necessary for testing. - * - * @param variable The variable to hide. - */ -const hide = (variable: unknown) => { - new Function(`return (${JSON.stringify(variable)});`); -}; diff --git a/smoke-tests/scenarios/basic-test.ts b/smoke-tests/scenarios/basic-test.ts index d74361c7800..037bb2dbc73 100644 --- a/smoke-tests/scenarios/basic-test.ts +++ b/smoke-tests/scenarios/basic-test.ts @@ -406,23 +406,29 @@ function basicTest(scenarios: Scenarios, appName: string) { import { setupRenderingTest } from 'ember-qunit'; import { render } from '@ember/test-helpers'; - import Component from '@glimmer/component'; - - class Demo extends Component { - - } - module('{{element}} as keyword', function(hooks) { setupRenderingTest(hooks); test('it works', async function(assert) { - await render(Demo); + await render( + + ); assert.dom('h1.greeting').hasText('Hello from element keyword'); }); + + test('can be shadowed', async function(assert) { + let element = () => 'surprise'; + await render( + + ); + assert.dom('[data-test]').hasText('surprise'); + }); }); `, 'fn-as-keyword-but-its-shadowed-test.gjs': ` From 28b332b8bacf0cddc7e484ba890157e96a2a744c Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Sun, 26 Apr 2026 13:08:50 -0400 Subject: [PATCH 3/3] lint:fix --- .../integration-tests/test/keywords/element-test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@glimmer-workspace/integration-tests/test/keywords/element-test.ts b/packages/@glimmer-workspace/integration-tests/test/keywords/element-test.ts index efaf5f7e442..5d977653acd 100644 --- a/packages/@glimmer-workspace/integration-tests/test/keywords/element-test.ts +++ b/packages/@glimmer-workspace/integration-tests/test/keywords/element-test.ts @@ -49,7 +49,7 @@ class KeywordElement extends RenderTest { } @test - 'MustacheStatement'(assert: Assert) { + MustacheStatement(assert: Assert) { const Child = template('{{#let @tag as |Tag|}}World{{/let}}', { strictMode: true, scope: () => ({}),