Skip to content

Commit 4b2b7eb

Browse files
NullVoxPopuliclaude
andcommitted
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) <noreply@anthropic.com>
1 parent 1886f8d commit 4b2b7eb

5 files changed

Lines changed: 202 additions & 1 deletion

File tree

packages/@ember/template-compiler/lib/compile-options.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { fn } from '@ember/helper';
1+
import { element, fn } from '@ember/helper';
22
import { on } from '@ember/modifier';
33
import { assert } from '@ember/debug';
44
import {
@@ -25,6 +25,7 @@ function malformedComponentLookup(string: string) {
2525
export const RUNTIME_KEYWORDS_NAME = '__ember_keywords__';
2626

2727
export const keywords: Record<string, unknown> = {
28+
element,
2829
fn,
2930
on,
3031
};

packages/@ember/template-compiler/lib/plugins/auto-import-builtins.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,17 @@ export default function autoImportBuiltins(env: EmberASTPluginEnvironment): ASTP
2727
}
2828
},
2929
SubExpression(node: AST.SubExpression) {
30+
if (isElement(node, hasLocal)) {
31+
rewriteKeyword(env, node, 'element', '@ember/helper');
32+
}
3033
if (isFn(node, hasLocal)) {
3134
rewriteKeyword(env, node, 'fn', '@ember/helper');
3235
}
3336
},
3437
MustacheStatement(node: AST.MustacheStatement) {
38+
if (isElement(node, hasLocal)) {
39+
rewriteKeyword(env, node, 'element', '@ember/helper');
40+
}
3541
if (isFn(node, hasLocal)) {
3642
rewriteKeyword(env, node, 'fn', '@ember/helper');
3743
}
@@ -68,3 +74,10 @@ function isFn(
6874
): node is (AST.MustacheStatement | AST.SubExpression) & { path: AST.PathExpression } {
6975
return isPath(node.path) && node.path.original === 'fn' && !hasLocal('fn');
7076
}
77+
78+
function isElement(
79+
node: AST.MustacheStatement | AST.SubExpression,
80+
hasLocal: (k: string) => boolean
81+
): node is (AST.MustacheStatement | AST.SubExpression) & { path: AST.PathExpression } {
82+
return isPath(node.path) && node.path.original === 'element' && !hasLocal('element');
83+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { castToBrowser } from '@glimmer/debug-util';
2+
import {
3+
GlimmerishComponent,
4+
jitSuite,
5+
RenderTest,
6+
test,
7+
} from '@glimmer-workspace/integration-tests';
8+
9+
import { template } from '@ember/template-compiler/runtime';
10+
11+
class KeywordElement extends RenderTest {
12+
static suiteName = 'keyword helper: element (runtime)';
13+
14+
@test
15+
'explicit scope'(assert: Assert) {
16+
const compiled = template('{{#let (element "h1") as |Tag|}}<Tag>Hello</Tag>{{/let}}', {
17+
strictMode: true,
18+
scope: () => ({}),
19+
});
20+
21+
this.renderComponent(compiled);
22+
23+
let h1 = castToBrowser(this.element, 'div').querySelector('h1');
24+
assert.ok(h1, 'h1 element exists');
25+
assert.strictEqual(h1!.textContent, 'Hello');
26+
}
27+
28+
@test
29+
'implicit scope'(assert: Assert) {
30+
const compiled = template('{{#let (element "h1") as |Tag|}}<Tag>Hello</Tag>{{/let}}', {
31+
strictMode: true,
32+
eval() {
33+
return eval(arguments[0]);
34+
},
35+
});
36+
37+
this.renderComponent(compiled);
38+
39+
let h1 = castToBrowser(this.element, 'div').querySelector('h1');
40+
assert.ok(h1, 'h1 element exists');
41+
assert.strictEqual(h1!.textContent, 'Hello');
42+
}
43+
44+
@test
45+
'MustacheStatement with explicit scope'(assert: Assert) {
46+
const Child = template('{{#let @tag as |Tag|}}<Tag>World</Tag>{{/let}}', {
47+
strictMode: true,
48+
scope: () => ({}),
49+
});
50+
51+
const compiled = template('<Child @tag={{element "span"}} />', {
52+
strictMode: true,
53+
scope: () => ({
54+
Child,
55+
}),
56+
});
57+
58+
this.renderComponent(compiled);
59+
60+
let span = castToBrowser(this.element, 'div').querySelector('span');
61+
assert.ok(span, 'span element exists');
62+
assert.strictEqual(span!.textContent, 'World');
63+
}
64+
65+
@test
66+
'no eval and no scope'(assert: Assert) {
67+
class Foo extends GlimmerishComponent {
68+
static {
69+
template('{{#let (element "h1") as |Tag|}}<Tag>Hello</Tag>{{/let}}', {
70+
strictMode: true,
71+
component: this,
72+
});
73+
}
74+
}
75+
76+
this.renderComponent(Foo);
77+
78+
let h1 = castToBrowser(this.element, 'div').querySelector('h1');
79+
assert.ok(h1, 'h1 element exists');
80+
assert.strictEqual(h1!.textContent, 'Hello');
81+
}
82+
}
83+
84+
jitSuite(KeywordElement);
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { castToBrowser } from '@glimmer/debug-util';
2+
import { jitSuite, RenderTest, test } from '@glimmer-workspace/integration-tests';
3+
4+
import { template } from '@ember/template-compiler/runtime';
5+
import { element } from '@ember/helper';
6+
7+
class KeywordElement extends RenderTest {
8+
static suiteName = 'keyword helper: element';
9+
10+
@test
11+
'it works as a SubExpression'(assert: Assert) {
12+
const compiled = template('{{#let (element "h1") as |Tag|}}<Tag>Hello</Tag>{{/let}}', {
13+
strictMode: true,
14+
scope: () => ({ element }),
15+
});
16+
17+
this.renderComponent(compiled);
18+
19+
let h1 = castToBrowser(this.element, 'div').querySelector('h1');
20+
assert.ok(h1, 'h1 element exists');
21+
assert.strictEqual(h1!.textContent, 'Hello');
22+
}
23+
24+
@test
25+
'it works with the runtime compiler'(assert: Assert) {
26+
hide(element);
27+
28+
const compiled = template('{{#let (element "h1") as |Tag|}}<Tag>Hello</Tag>{{/let}}', {
29+
strictMode: true,
30+
eval() {
31+
return eval(arguments[0]);
32+
},
33+
});
34+
35+
this.renderComponent(compiled);
36+
37+
let h1 = castToBrowser(this.element, 'div').querySelector('h1');
38+
assert.ok(h1, 'h1 element exists');
39+
assert.strictEqual(h1!.textContent, 'Hello');
40+
}
41+
42+
@test
43+
'it works as a MustacheStatement'(assert: Assert) {
44+
const Child = template('{{#let @tag as |Tag|}}<Tag>World</Tag>{{/let}}', {
45+
strictMode: true,
46+
scope: () => ({}),
47+
});
48+
49+
const compiled = template('<Child @tag={{element "span"}} />', {
50+
strictMode: true,
51+
scope: () => ({
52+
element,
53+
Child,
54+
}),
55+
});
56+
57+
this.renderComponent(compiled);
58+
59+
let span = castToBrowser(this.element, 'div').querySelector('span');
60+
assert.ok(span, 'span element exists');
61+
assert.strictEqual(span!.textContent, 'World');
62+
}
63+
}
64+
65+
jitSuite(KeywordElement);
66+
67+
/**
68+
* This function is used to hide a variable from the transpiler, so that it
69+
* doesn't get removed as "unused". It does not actually do anything with the
70+
* variable, it just makes it be part of an expression that the transpiler
71+
* won't remove.
72+
*
73+
* It's a bit of a hack, but it's necessary for testing.
74+
*
75+
* @param variable The variable to hide.
76+
*/
77+
const hide = (variable: unknown) => {
78+
new Function(`return (${JSON.stringify(variable)});`);
79+
};

smoke-tests/scenarios/basic-test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,30 @@ function basicTest(scenarios: Scenarios, appName: string) {
401401
});
402402
});
403403
`,
404+
'element-as-keyword-test.gjs': `
405+
import { module, test } from 'qunit';
406+
import { setupRenderingTest } from 'ember-qunit';
407+
import { render } from '@ember/test-helpers';
408+
409+
import Component from '@glimmer/component';
410+
411+
class Demo extends Component {
412+
<template>
413+
{{#let (element "h1") as |Tag|}}
414+
<Tag class="greeting">Hello from element keyword</Tag>
415+
{{/let}}
416+
</template>
417+
}
418+
419+
module('{{element}} as keyword', function(hooks) {
420+
setupRenderingTest(hooks);
421+
422+
test('it works', async function(assert) {
423+
await render(Demo);
424+
assert.dom('h1.greeting').hasText('Hello from element keyword');
425+
});
426+
});
427+
`,
404428
'fn-as-keyword-but-its-shadowed-test.gjs': `
405429
import QUnit, { module, test } from 'qunit';
406430
import { setupRenderingTest } from 'ember-qunit';

0 commit comments

Comments
 (0)