Skip to content

Commit 64946ff

Browse files
Merge pull request #21334 from NullVoxPopuli-ai-agent/nvp/hash-as-keyword
RFC#999 - {{hash}} as keyword
2 parents 2297ebc + 5600ca5 commit 64946ff

5 files changed

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

smoke-tests/scenarios/basic-test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -477,6 +477,55 @@ function basicTest(scenarios: Scenarios, appName: string) {
477477
});
478478
});
479479
`,
480+
'hash-as-keyword-test.gjs': `
481+
import { module, test } from 'qunit';
482+
import { setupRenderingTest } from 'ember-qunit';
483+
import { render, click } from '@ember/test-helpers';
484+
485+
import Component from '@glimmer/component';
486+
import { tracked } from '@glimmer/tracking';
487+
488+
class Demo extends Component {
489+
@tracked data = null;
490+
setData = (d) => this.data = d;
491+
492+
<template>
493+
<button {{on 'click' (fn this.setData (hash greeting="hello" farewell="goodbye"))}}>
494+
{{#if this.data}}
495+
{{this.data.greeting}} {{this.data.farewell}}
496+
{{else}}
497+
click me
498+
{{/if}}
499+
</button>
500+
</template>
501+
}
502+
503+
module('{{hash}} as keyword', function(hooks) {
504+
setupRenderingTest(hooks);
505+
506+
test('it works', async function(assert) {
507+
await render(Demo);
508+
assert.dom('button').hasText('click me');
509+
await click('button');
510+
assert.dom('button').hasText('hello goodbye');
511+
});
512+
});
513+
`,
514+
'hash-as-keyword-shadowed-test.gjs': `
515+
import { module, test } from 'qunit';
516+
import { setupRenderingTest } from 'ember-qunit';
517+
import { render } from '@ember/test-helpers';
518+
519+
module('{{hash}} as keyword', function(hooks) {
520+
setupRenderingTest(hooks);
521+
522+
test('it works', async function(assert) {
523+
const hash = (data) => data;
524+
await render(<template>{{hash "hello"}}</template>);
525+
assert.dom().hasText('hello');
526+
});
527+
});
528+
`,
480529
},
481530
},
482531
});

0 commit comments

Comments
 (0)