Skip to content

Commit a6513b6

Browse files
Merge pull request #21299 from NullVoxPopuli-ai-agent/nvp/fn-as-keyword
RFC#998 - {{fn}} as keyword
2 parents 8629fdc + 0b65f8b commit a6513b6

File tree

7 files changed

+337
-7
lines changed

7 files changed

+337
-7
lines changed

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { fn } from '@ember/helper';
12
import { on } from '@ember/modifier';
23
import { assert } from '@ember/debug';
34
import {
@@ -24,6 +25,7 @@ function malformedComponentLookup(string: string) {
2425
export const RUNTIME_KEYWORDS_NAME = '__ember_keywords__';
2526

2627
export const keywords: Record<string, unknown> = {
28+
fn,
2729
on,
2830
};
2931

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

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,22 +23,48 @@ export default function autoImportBuiltins(env: EmberASTPluginEnvironment): ASTP
2323
...visitor,
2424
ElementModifierStatement(node: AST.ElementModifierStatement) {
2525
if (isOn(node, hasLocal)) {
26-
if (env.meta?.jsutils) {
27-
node.path.original = env.meta.jsutils.bindImport('@ember/modifier', 'on', node, {
28-
name: 'on',
29-
});
30-
} else if (env.meta?.emberRuntime) {
31-
node.path.original = env.meta.emberRuntime.lookupKeyword('on');
32-
}
26+
rewriteKeyword(env, node, 'on', '@ember/modifier');
27+
}
28+
},
29+
SubExpression(node: AST.SubExpression) {
30+
if (isFn(node, hasLocal)) {
31+
rewriteKeyword(env, node, 'fn', '@ember/helper');
32+
}
33+
},
34+
MustacheStatement(node: AST.MustacheStatement) {
35+
if (isFn(node, hasLocal)) {
36+
rewriteKeyword(env, node, 'fn', '@ember/helper');
3337
}
3438
},
3539
},
3640
};
3741
}
3842

43+
function rewriteKeyword(
44+
env: EmberASTPluginEnvironment,
45+
node: { path: AST.PathExpression },
46+
name: string,
47+
moduleSpecifier: string
48+
) {
49+
if (env.meta?.jsutils) {
50+
node.path.original = env.meta.jsutils.bindImport(moduleSpecifier, name, node, {
51+
name,
52+
});
53+
} else if (env.meta?.emberRuntime) {
54+
node.path.original = env.meta.emberRuntime.lookupKeyword(name);
55+
}
56+
}
57+
3958
function isOn(
4059
node: AST.ElementModifierStatement | AST.MustacheStatement | AST.SubExpression,
4160
hasLocal: (k: string) => boolean
4261
): node is AST.ElementModifierStatement & { path: AST.PathExpression } {
4362
return isPath(node.path) && node.path.original === 'on' && !hasLocal('on');
4463
}
64+
65+
function isFn(
66+
node: AST.MustacheStatement | AST.SubExpression,
67+
hasLocal: (k: string) => boolean
68+
): node is (AST.MustacheStatement | AST.SubExpression) & { path: AST.PathExpression } {
69+
return isPath(node.path) && node.path.original === 'fn' && !hasLocal('fn');
70+
}
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 {
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 KeywordFn extends RenderTest {
12+
static suiteName = 'keyword helper: fn (runtime)';
13+
14+
@test
15+
'explicit scope'(assert: Assert) {
16+
let greet = (greeting: string) => {
17+
assert.step(greeting);
18+
};
19+
20+
const compiled = template('<button {{on "click" (fn greet "hello")}}>Click</button>', {
21+
strictMode: true,
22+
scope: () => ({
23+
greet,
24+
}),
25+
});
26+
27+
this.renderComponent(compiled);
28+
29+
castToBrowser(this.element, 'div').querySelector('button')!.click();
30+
assert.verifySteps(['hello']);
31+
}
32+
33+
@test
34+
'implicit scope'(assert: Assert) {
35+
let greet = (greeting: string) => {
36+
assert.step(greeting);
37+
};
38+
39+
hide(greet);
40+
41+
const compiled = template('<button {{on "click" (fn greet "hello")}}>Click</button>', {
42+
strictMode: true,
43+
eval() {
44+
return eval(arguments[0]);
45+
},
46+
});
47+
48+
this.renderComponent(compiled);
49+
50+
castToBrowser(this.element, 'div').querySelector('button')!.click();
51+
assert.verifySteps(['hello']);
52+
}
53+
54+
@test
55+
'MustacheStatement with explicit scope'(assert: Assert) {
56+
let greet = (greeting: string) => {
57+
assert.step(greeting);
58+
};
59+
60+
const Child = template('<button {{on "click" @callback}}>Click</button>', {
61+
strictMode: true,
62+
scope: () => ({}),
63+
});
64+
65+
const compiled = template('<Child @callback={{fn greet "hello"}} />', {
66+
strictMode: true,
67+
scope: () => ({
68+
greet,
69+
Child,
70+
}),
71+
});
72+
73+
this.renderComponent(compiled);
74+
75+
castToBrowser(this.element, 'div').querySelector('button')!.click();
76+
assert.verifySteps(['hello']);
77+
}
78+
79+
@test
80+
'no eval and no scope'(assert: Assert) {
81+
class Foo extends GlimmerishComponent {
82+
static {
83+
template('<button {{on "click" (fn this.greet "hello")}}>Click</button>', {
84+
strictMode: true,
85+
component: this,
86+
});
87+
}
88+
89+
greet = (greeting: string) => assert.step(greeting);
90+
}
91+
92+
this.renderComponent(Foo);
93+
94+
castToBrowser(this.element, 'div').querySelector('button')!.click();
95+
assert.verifySteps(['hello']);
96+
}
97+
}
98+
99+
jitSuite(KeywordFn);
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+
};
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
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 } from '@ember/helper';
6+
import { on } from '@ember/modifier';
7+
8+
class KeywordFn extends RenderTest {
9+
static suiteName = 'keyword helper: fn';
10+
11+
/**
12+
* We require the babel compiler to emit keywords, so this is actually no different than normal usage
13+
* prior to RFC 998.
14+
*
15+
* We are required to have the compiler that emits this low-level format to detect if fn is in scope and then
16+
* _not_ add the `fn` helper from `@ember/helper` import.
17+
*/
18+
@test
19+
'it works'(assert: Assert) {
20+
let greet = (greeting: string) => {
21+
assert.step(greeting);
22+
};
23+
24+
const compiled = template('<button {{on "click" (fn greet "hello")}}>Click</button>', {
25+
strictMode: true,
26+
scope: () => ({
27+
greet,
28+
fn,
29+
on,
30+
}),
31+
});
32+
33+
this.renderComponent(compiled);
34+
35+
castToBrowser(this.element, 'div').querySelector('button')!.click();
36+
assert.verifySteps(['hello']);
37+
}
38+
39+
@test
40+
'it works with the runtime compiler'(assert: Assert) {
41+
let greet = (greeting: string) => {
42+
assert.step(greeting);
43+
};
44+
45+
hide(greet);
46+
47+
const compiled = template('<button {{on "click" (fn greet "hello")}}>Click</button>', {
48+
strictMode: true,
49+
eval() {
50+
return eval(arguments[0]);
51+
},
52+
});
53+
54+
this.renderComponent(compiled);
55+
56+
castToBrowser(this.element, 'div').querySelector('button')!.click();
57+
assert.verifySteps(['hello']);
58+
}
59+
60+
@test
61+
'it works as a MustacheStatement'(assert: Assert) {
62+
let greet = (greeting: string) => {
63+
assert.step(greeting);
64+
};
65+
66+
const Child = template('<button {{on "click" @callback}}>Click</button>', {
67+
strictMode: true,
68+
scope: () => ({ on }),
69+
});
70+
71+
const compiled = template('<Child @callback={{fn greet "hello"}} />', {
72+
strictMode: true,
73+
scope: () => ({
74+
greet,
75+
fn,
76+
Child,
77+
}),
78+
});
79+
80+
this.renderComponent(compiled);
81+
82+
castToBrowser(this.element, 'div').querySelector('button')!.click();
83+
assert.verifySteps(['hello']);
84+
}
85+
86+
@test
87+
'can be shadowed'(assert: Assert) {
88+
let fn = () => {
89+
assert.step('shadowed:success');
90+
return () => {};
91+
};
92+
93+
let greet = () => {};
94+
95+
const compiled = template('<button {{on "click" (fn greet "hello")}}>Click</button>', {
96+
strictMode: true,
97+
scope: () => ({ fn, greet, on }),
98+
});
99+
100+
this.renderComponent(compiled);
101+
assert.verifySteps(['shadowed:success']);
102+
}
103+
}
104+
105+
jitSuite(KeywordFn);
106+
107+
/**
108+
* This function is used to hide a variable from the transpiler, so that it
109+
* doesn't get removed as "unused". It does not actually do anything with the
110+
* variable, it just makes it be part of an expression that the transpiler
111+
* won't remove.
112+
*
113+
* It's a bit of a hack, but it's necessary for testing.
114+
*
115+
* @param variable The variable to hide.
116+
*/
117+
const hide = (variable: unknown) => {
118+
new Function(`return (${JSON.stringify(variable)});`);
119+
};

packages/@glimmer-workspace/integration-tests/test/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"@glimmer/util": "workspace:*",
2222
"@glimmer/validator": "workspace:*",
2323
"@glimmer/wire-format": "workspace:*",
24+
"@ember/helper": "workspace:*",
2425
"@ember/modifier": "workspace:*",
2526
"@ember/template-compiler": "workspace:*"
2627
}

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

smoke-tests/scenarios/basic-test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,72 @@ function basicTest(scenarios: Scenarios, appName: string) {
350350
});
351351
});
352352
`,
353+
'fn-as-keyword-test.gjs': `
354+
import { module, test } from 'qunit';
355+
import { setupRenderingTest } from 'ember-qunit';
356+
import { render, click } from '@ember/test-helpers';
357+
358+
import Component from '@glimmer/component';
359+
import { tracked } from '@glimmer/tracking';
360+
361+
class Demo extends Component {
362+
@tracked message = 'hello';
363+
setMessage = (msg) => this.message = msg;
364+
365+
<template>
366+
<button {{on 'click' (fn this.setMessage 'goodbye')}}>{{this.message}}</button>
367+
</template>
368+
}
369+
370+
module('{{fn}} as keyword', function(hooks) {
371+
setupRenderingTest(hooks);
372+
373+
test('it works', async function(assert) {
374+
await render(Demo);
375+
assert.dom('button').hasText('hello');
376+
await click('button');
377+
assert.dom('button').hasText('goodbye');
378+
});
379+
});
380+
`,
381+
'fn-as-keyword-but-its-shadowed-test.gjs': `
382+
import QUnit, { module, test } from 'qunit';
383+
import { setupRenderingTest } from 'ember-qunit';
384+
import { render, click } from '@ember/test-helpers';
385+
386+
import Component from '@glimmer/component';
387+
import { tracked } from '@glimmer/tracking';
388+
389+
module('{{fn}} as keyword (but it is shadowed)', function(hooks) {
390+
setupRenderingTest(hooks);
391+
392+
test('it works', async function(assert) {
393+
// shadows keyword!
394+
const fn = () => {
395+
assert.step('shadowed:fn:invoke');
396+
return () => {};
397+
};
398+
399+
class Demo extends Component {
400+
@tracked message = 'hello';
401+
setMessage = (msg) => this.message = msg;
402+
403+
<template>
404+
<button {{on 'click' (fn this.setMessage 'goodbye')}}>{{this.message}}</button>
405+
</template>
406+
}
407+
408+
await render(Demo);
409+
assert.verifySteps(['shadowed:fn:invoke']);
410+
411+
assert.dom('button').hasText('hello');
412+
await click('button');
413+
assert.dom('button').hasText('hello', 'not changed because the shadowed fn returns a no-op');
414+
415+
assert.verifySteps([]);
416+
});
417+
});
418+
`,
353419
'on-as-keyword-but-its-shadowed-test.gjs': `
354420
import QUnit, { module, test } from 'qunit';
355421
import { setupRenderingTest } from 'ember-qunit';

0 commit comments

Comments
 (0)