diff --git a/packages/@ember/template-compiler/lib/compile-options.ts b/packages/@ember/template-compiler/lib/compile-options.ts index 10fb8e0bf1a..4055acf3e3e 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, eq, fn, hash, neq } from '@ember/helper'; +import { array, element, eq, fn, hash, neq } from '@ember/helper'; import { on } from '@ember/modifier'; import { assert } from '@ember/debug'; import { @@ -27,6 +27,7 @@ export const RUNTIME_KEYWORDS_NAME = '__ember_keywords__'; export const keywords: Record = { array, eq, + element, fn, hash, neq, 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 bf3ee5b993d..c9bb1ea0d0d 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'); } @@ -47,6 +50,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'); } @@ -120,3 +126,10 @@ function isNeq( ): node is (AST.MustacheStatement | AST.SubExpression) & { path: AST.PathExpression } { return isPath(node.path) && node.path.original === 'neq' && !hasLocal('neq'); } + +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..b8e25ea0205 --- /dev/null +++ b/packages/@glimmer-workspace/integration-tests/test/keywords/element-runtime-test.ts @@ -0,0 +1,74 @@ +import { castToBrowser } from '@glimmer/debug-util'; +import { + GlimmerishComponent, + jitSuite, + RenderTest, + test, +} from '@glimmer-workspace/integration-tests'; + +import { template } from '@ember/template-compiler/runtime'; + +class KeywordElementRuntime 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 + 'explicit scope (shadowed)'() { + const compiled = template('{{element "h1"}}', { + strictMode: true, + scope: () => ({ element: () => 'surprise' }), + }); + + 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() { + 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 + '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(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 new file mode 100644 index 00000000000..5d977653acd --- /dev/null +++ b/packages/@glimmer-workspace/integration-tests/test/keywords/element-test.ts @@ -0,0 +1,71 @@ +import { castToBrowser } from '@glimmer/debug-util'; +import { jitSuite, RenderTest, test } from '@glimmer-workspace/integration-tests'; + +import { template } from '@ember/template-compiler'; + +class KeywordElement extends RenderTest { + static suiteName = 'keyword helper: element'; + + @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 + '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() { + 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(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'); + } +} + +jitSuite(KeywordElement); diff --git a/smoke-tests/scenarios/basic-test.ts b/smoke-tests/scenarios/basic-test.ts index e1c1afc3cc3..91d66355022 100644 --- a/smoke-tests/scenarios/basic-test.ts +++ b/smoke-tests/scenarios/basic-test.ts @@ -442,6 +442,36 @@ 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'; + + module('{{element}} as keyword', function(hooks) { + setupRenderingTest(hooks); + + test('it works', async function(assert) { + 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': ` import QUnit, { module, test } from 'qunit'; import { setupRenderingTest } from 'ember-qunit';