Skip to content

Commit 7ee05f9

Browse files
NullVoxPopuliclaude
andcommitted
RFC#999 - {{hash}} as keyword
Add hash to the built-in keywords map so it no longer needs to be imported in strict-mode (gjs/gts) templates. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
1 parent b6282e9 commit 7ee05f9

5 files changed

Lines changed: 301 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 { fn, hash } from '@ember/helper';
22
import { on } from '@ember/modifier';
33
import { assert } from '@ember/debug';
44
import {
@@ -26,6 +26,7 @@ export const RUNTIME_KEYWORDS_NAME = '__ember_keywords__';
2626

2727
export const keywords: Record<string, unknown> = {
2828
fn,
29+
hash,
2930
on,
3031
};
3132

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,17 @@ export default function autoImportBuiltins(env: EmberASTPluginEnvironment): ASTP
3030
if (isFn(node, hasLocal)) {
3131
rewriteKeyword(env, node, 'fn', '@ember/helper');
3232
}
33+
if (isHash(node, hasLocal)) {
34+
rewriteKeyword(env, node, 'hash', '@ember/helper');
35+
}
3336
},
3437
MustacheStatement(node: AST.MustacheStatement) {
3538
if (isFn(node, hasLocal)) {
3639
rewriteKeyword(env, node, 'fn', '@ember/helper');
3740
}
41+
if (isHash(node, hasLocal)) {
42+
rewriteKeyword(env, node, 'hash', '@ember/helper');
43+
}
3844
},
3945
},
4046
};
@@ -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 isHash(
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 === 'hash' && !hasLocal('hash');
83+
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
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 KeywordHashRuntime extends RenderTest {
12+
static suiteName = 'keyword helper: hash (runtime)';
13+
14+
@test
15+
'explicit scope'(assert: Assert) {
16+
let receivedData: Record<string, unknown> | undefined;
17+
18+
let capture = (data: Record<string, unknown>) => {
19+
receivedData = data;
20+
assert.step('captured');
21+
};
22+
23+
const compiled = template(
24+
'<button {{on "click" (fn capture (hash greeting="hello"))}}>Click</button>',
25+
{
26+
strictMode: true,
27+
scope: () => ({
28+
capture,
29+
}),
30+
}
31+
);
32+
33+
this.renderComponent(compiled);
34+
35+
castToBrowser(this.element, 'div').querySelector('button')!.click();
36+
assert.verifySteps(['captured']);
37+
assert.strictEqual(receivedData?.['greeting'], 'hello');
38+
}
39+
40+
@test
41+
'implicit scope'(assert: Assert) {
42+
let receivedData: Record<string, unknown> | undefined;
43+
44+
let capture = (data: Record<string, unknown>) => {
45+
receivedData = data;
46+
assert.step('captured');
47+
};
48+
49+
hide(capture);
50+
51+
const compiled = template(
52+
'<button {{on "click" (fn capture (hash greeting="hello"))}}>Click</button>',
53+
{
54+
strictMode: true,
55+
eval() {
56+
return eval(arguments[0]);
57+
},
58+
}
59+
);
60+
61+
this.renderComponent(compiled);
62+
63+
castToBrowser(this.element, 'div').querySelector('button')!.click();
64+
assert.verifySteps(['captured']);
65+
assert.strictEqual(receivedData?.['greeting'], 'hello');
66+
}
67+
68+
@test
69+
'MustacheStatement with explicit scope'(assert: Assert) {
70+
let receivedData: Record<string, unknown> | undefined;
71+
72+
let capture = (data: Record<string, unknown>) => {
73+
receivedData = data;
74+
assert.step('captured');
75+
};
76+
77+
const Child = template('<button {{on "click" (fn capture @data)}}>Click</button>', {
78+
strictMode: true,
79+
scope: () => ({ capture }),
80+
});
81+
82+
const compiled = template('<Child @data={{hash greeting="hello"}} />', {
83+
strictMode: true,
84+
scope: () => ({
85+
Child,
86+
}),
87+
});
88+
89+
this.renderComponent(compiled);
90+
91+
castToBrowser(this.element, 'div').querySelector('button')!.click();
92+
assert.verifySteps(['captured']);
93+
assert.strictEqual(receivedData?.['greeting'], 'hello');
94+
}
95+
96+
@test
97+
'no eval and no scope'(assert: Assert) {
98+
let receivedData: Record<string, unknown> | undefined;
99+
100+
class Foo extends GlimmerishComponent {
101+
static {
102+
template(
103+
'<button {{on "click" (fn this.capture (hash greeting="hello"))}}>Click</button>',
104+
{
105+
strictMode: true,
106+
component: this,
107+
}
108+
);
109+
}
110+
111+
capture = (data: Record<string, unknown>) => {
112+
receivedData = data;
113+
assert.step('captured');
114+
};
115+
}
116+
117+
this.renderComponent(Foo);
118+
119+
castToBrowser(this.element, 'div').querySelector('button')!.click();
120+
assert.verifySteps(['captured']);
121+
assert.strictEqual(receivedData?.['greeting'], 'hello');
122+
}
123+
}
124+
125+
jitSuite(KeywordHashRuntime);
126+
127+
/**
128+
* This function is used to hide a variable from the transpiler, so that it
129+
* doesn't get removed as "unused". It does not actually do anything with the
130+
* variable, it just makes it be part of an expression that the transpiler
131+
* won't remove.
132+
*
133+
* It's a bit of a hack, but it's necessary for testing.
134+
*
135+
* @param variable The variable to hide.
136+
*/
137+
const hide = (variable: unknown) => {
138+
new Function(`return (${JSON.stringify(variable)});`);
139+
};
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
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 { fn, hash } from '@ember/helper';
6+
import { on } from '@ember/modifier';
7+
8+
class KeywordHash extends RenderTest {
9+
static suiteName = 'keyword helper: hash';
10+
11+
@test
12+
'it works'(assert: Assert) {
13+
let receivedData: Record<string, unknown> | undefined;
14+
15+
let capture = (data: Record<string, unknown>) => {
16+
receivedData = data;
17+
assert.step('captured');
18+
};
19+
20+
const compiled = template(
21+
'<button {{on "click" (fn capture (hash greeting="hello" farewell="goodbye"))}}>Click</button>',
22+
{
23+
strictMode: true,
24+
scope: () => ({
25+
capture,
26+
fn,
27+
hash,
28+
on,
29+
}),
30+
}
31+
);
32+
33+
this.renderComponent(compiled);
34+
35+
castToBrowser(this.element, 'div').querySelector('button')!.click();
36+
assert.verifySteps(['captured']);
37+
assert.strictEqual(receivedData?.['greeting'], 'hello');
38+
assert.strictEqual(receivedData?.['farewell'], 'goodbye');
39+
}
40+
41+
@test
42+
'it works with the runtime compiler'(assert: Assert) {
43+
let receivedData: Record<string, unknown> | undefined;
44+
45+
let capture = (data: Record<string, unknown>) => {
46+
receivedData = data;
47+
assert.step('captured');
48+
};
49+
50+
hide(capture);
51+
52+
const compiled = template(
53+
'<button {{on "click" (fn capture (hash greeting="hello"))}}>Click</button>',
54+
{
55+
strictMode: true,
56+
eval() {
57+
return eval(arguments[0]);
58+
},
59+
}
60+
);
61+
62+
this.renderComponent(compiled);
63+
64+
castToBrowser(this.element, 'div').querySelector('button')!.click();
65+
assert.verifySteps(['captured']);
66+
assert.strictEqual(receivedData?.['greeting'], 'hello');
67+
}
68+
69+
@test
70+
'it works as a MustacheStatement'(assert: Assert) {
71+
let receivedData: Record<string, unknown> | undefined;
72+
73+
let capture = (data: Record<string, unknown>) => {
74+
receivedData = data;
75+
assert.step('captured');
76+
};
77+
78+
const Child = template('<button {{on "click" (fn capture @data)}}>Click</button>', {
79+
strictMode: true,
80+
scope: () => ({ on, fn, capture }),
81+
});
82+
83+
const compiled = template('<Child @data={{hash greeting="hello"}} />', {
84+
strictMode: true,
85+
scope: () => ({
86+
hash,
87+
Child,
88+
}),
89+
});
90+
91+
this.renderComponent(compiled);
92+
93+
castToBrowser(this.element, 'div').querySelector('button')!.click();
94+
assert.verifySteps(['captured']);
95+
assert.strictEqual(receivedData?.['greeting'], 'hello');
96+
}
97+
}
98+
99+
jitSuite(KeywordHash);
100+
101+
/**
102+
* This function is used to hide a variable from the transpiler, so that it
103+
* doesn't get removed as "unused". It does not actually do anything with the
104+
* variable, it just makes it be part of an expression that the transpiler
105+
* won't remove.
106+
*
107+
* It's a bit of a hack, but it's necessary for testing.
108+
*
109+
* @param variable The variable to hide.
110+
*/
111+
const hide = (variable: unknown) => {
112+
new Function(`return (${JSON.stringify(variable)});`);
113+
};

smoke-tests/scenarios/basic-test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,40 @@ function basicTest(scenarios: Scenarios, appName: string) {
454454
});
455455
});
456456
`,
457+
'hash-as-keyword-test.gjs': `
458+
import { module, test } from 'qunit';
459+
import { setupRenderingTest } from 'ember-qunit';
460+
import { render, click } from '@ember/test-helpers';
461+
462+
import Component from '@glimmer/component';
463+
import { tracked } from '@glimmer/tracking';
464+
465+
class Demo extends Component {
466+
@tracked data = null;
467+
setData = (d) => this.data = d;
468+
469+
<template>
470+
<button {{on 'click' (fn this.setData (hash greeting="hello" farewell="goodbye"))}}>
471+
{{#if this.data}}
472+
{{this.data.greeting}} {{this.data.farewell}}
473+
{{else}}
474+
click me
475+
{{/if}}
476+
</button>
477+
</template>
478+
}
479+
480+
module('{{hash}} as keyword', function(hooks) {
481+
setupRenderingTest(hooks);
482+
483+
test('it works', async function(assert) {
484+
await render(Demo);
485+
assert.dom('button').hasText('click me');
486+
await click('button');
487+
assert.dom('button').hasText('hello goodbye');
488+
});
489+
});
490+
`,
457491
},
458492
},
459493
});

0 commit comments

Comments
 (0)