Skip to content

Commit afde876

Browse files
committed
Add 'on' keyword support and related tests
1 parent cd9dfad commit afde876

17 files changed

Lines changed: 426 additions & 61 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@
9595
"@babel/preset-env": "^7.16.11",
9696
"@babel/types": "^7.22.5",
9797
"@embroider/macros": "^1.19.6",
98-
"@embroider/shared-internals": "^2.5.0",
98+
"@embroider/shared-internals": "^2.9.2",
9999
"@embroider/vite": "^1.5.0",
100100
"@eslint/js": "^9.21.0",
101101
"@glimmer/component": "workspace:*",

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

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
import { on } from '@ember/modifier';
2+
import type { AST } from '@glimmer/syntax';
13
import { assert } from '@ember/debug';
24
import {
35
RESOLUTION_MODE_TRANSFORMS,
46
STRICT_MODE_KEYWORDS,
57
STRICT_MODE_TRANSFORMS,
68
} from './plugins/index';
7-
import type { EmberPrecompileOptions, PluginFunc } from './types';
9+
import type { EmberPrecompileOptions, JSUtils, PluginFunc } from './types';
810
import COMPONENT_NAME_SIMPLE_DASHERIZE_CACHE from './dasherize-component-name';
911

1012
let USER_PLUGINS: PluginFunc[] = [];
@@ -13,11 +15,19 @@ function malformedComponentLookup(string: string) {
1315
return string.indexOf('::') === -1 && string.indexOf(':') > -1;
1416
}
1517

18+
export const keywords = {
19+
on,
20+
};
21+
22+
type Options = EmberPrecompileOptions &
23+
Partial<EmberPrecompileOptions> & {
24+
meta?: EmberPrecompileOptions['meta'] & { jsutils?: JSUtils };
25+
};
26+
1627
function buildCompileOptions(_options: EmberPrecompileOptions): EmberPrecompileOptions {
1728
let moduleName = _options.moduleName;
1829

19-
let options: EmberPrecompileOptions & Partial<EmberPrecompileOptions> = {
20-
meta: {},
30+
let options: Options = {
2131
isProduction: false,
2232
plugins: { ast: [] },
2333
..._options,
@@ -34,6 +44,34 @@ function buildCompileOptions(_options: EmberPrecompileOptions): EmberPrecompileO
3444
},
3545
};
3646

47+
options.meta ||= {};
48+
options.meta.jsutils ||= {
49+
/**
50+
* NOTE: when stepping through lexicalScope, or other callbacks here,
51+
* we first detect the keywords as "not in scope",
52+
* and that is what we want, so that we can import them.
53+
*/
54+
bindImport(
55+
module: string,
56+
name: string,
57+
node: AST.ElementModifierStatement | AST.MustacheStatement | AST.SubExpression
58+
) {
59+
if (module === '@ember/modifier' && name === 'on') {
60+
/**
61+
* We rely on an old JS technique of assiging properties to functions.
62+
* Since template() is what was used to runtime-compile the template,
63+
* we know it to be in scope.
64+
*/
65+
if (node.path.type === 'PathExpression') {
66+
node.path.original = 'template.on';
67+
return;
68+
}
69+
}
70+
71+
throw new Error(`Unknown import ${name} from module ${module}`);
72+
},
73+
};
74+
3775
if ('eval' in options) {
3876
const localScopeEvaluator = options.eval as (value: string) => unknown;
3977
const globalScopeEvaluator = (value: string) => new Function(`return ${value};`)();
@@ -52,7 +90,7 @@ function buildCompileOptions(_options: EmberPrecompileOptions): EmberPrecompileO
5290
if ('scope' in options) {
5391
const scope = (options.scope as () => Record<string, unknown>)();
5492

55-
options.lexicalScope = (variable: string) => variable in scope;
93+
options.lexicalScope = (variable: string) => variable in scope || variable in keywords;
5694

5795
delete options.scope;
5896
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import type { AST, ASTPlugin } from '@glimmer/syntax';
2+
import type { EmberASTPluginEnvironment } from '../types';
3+
import { isPath, trackLocals } from './utils';
4+
5+
/**
6+
@module ember
7+
*/
8+
9+
/**
10+
A Glimmer2 AST transformation that makes importable keywords work
11+
12+
@private
13+
@class TransformActionSyntax
14+
*/
15+
16+
export default function autoImportBuiltins(env: EmberASTPluginEnvironment): ASTPlugin {
17+
let { hasLocal, visitor } = trackLocals(env);
18+
19+
return {
20+
name: 'auto-import-built-ins',
21+
22+
visitor: {
23+
...visitor,
24+
ElementModifierStatement(node: AST.ElementModifierStatement) {
25+
if (isOn(node, hasLocal)) {
26+
// @ts-expect-error may not exist in all environments (the deprecated runtime compiler, for example)
27+
env.meta?.jsutils?.bindImport?.('@ember/modifier', 'on', node, { name: 'on' });
28+
}
29+
},
30+
},
31+
};
32+
}
33+
34+
function isOn(
35+
node: AST.ElementModifierStatement | AST.MustacheStatement | AST.SubExpression,
36+
hasLocal: (k: string) => boolean
37+
) {
38+
return isPath(node.path) && node.path.original === 'on' && !hasLocal('on');
39+
}

packages/@ember/template-compiler/lib/plugins/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@ import TransformInElement from './transform-in-element';
99
import TransformQuotedBindingsIntoJustBindings from './transform-quoted-bindings-into-just-bindings';
1010
import TransformResolutions from './transform-resolutions';
1111
import TransformWrapMountAndOutlet from './transform-wrap-mount-and-outlet';
12+
import AutoImportBuiltins from './auto-import-builtins';
1213

1314
export const INTERNAL_PLUGINS = {
15+
AutoImportBuiltins,
1416
AssertAgainstAttrs,
1517
AssertAgainstNamedOutlets,
1618
AssertInputHelperWithoutBlock,
@@ -40,6 +42,7 @@ export const RESOLUTION_MODE_TRANSFORMS = Object.freeze([
4042
]);
4143

4244
export const STRICT_MODE_TRANSFORMS = Object.freeze([
45+
AutoImportBuiltins,
4346
TransformQuotedBindingsIntoJustBindings,
4447
AssertReservedNamedArguments,
4548
TransformActionSyntax,

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

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { precompile as glimmerPrecompile } from '@glimmer/compiler';
33
import type { SerializedTemplateWithLazyBlock } from '@glimmer/interfaces';
44
import { setComponentTemplate } from '@glimmer/manager';
55
import { templateFactory } from '@glimmer/opcode-compiler';
6-
import compileOptions from './compile-options';
6+
import compileOptions, { keywords } from './compile-options';
77
import type { EmberPrecompileOptions } from './types';
88

99
type ComponentClass = abstract new (...args: any[]) => object;
@@ -237,8 +237,8 @@ export function template(
237237
providedOptions?: BaseTemplateOptions | BaseClassTemplateOptions<any>
238238
): object {
239239
const options: EmberPrecompileOptions = { strictMode: true, ...providedOptions };
240-
const evaluate = buildEvaluator(options);
241240

241+
const evaluate = buildEvaluator(options);
242242
const normalizedOptions = compileOptions(options);
243243
const component = normalizedOptions.component ?? templateOnly();
244244

@@ -250,24 +250,32 @@ export function template(
250250
return component;
251251
}
252252

253+
Object.assign(template, keywords);
254+
253255
const evaluator = (source: string) => {
254-
return new Function(`return ${source}`)();
256+
return new Function('template', `return ${source}`)(template);
255257
};
256258

257-
function buildEvaluator(options: Partial<EmberPrecompileOptions> | undefined) {
258-
if (options === undefined) {
259-
return evaluator;
260-
}
261-
259+
/**
260+
* @param options
261+
* @returns
262+
*/
263+
function buildEvaluator(options: Partial<EmberPrecompileOptions>) {
262264
if (options.eval) {
263265
return options.eval;
264266
} else {
265-
const scope = options.scope?.();
267+
/**
268+
* This is ran before the template is compiled,
269+
* so we cannot use any information gathered during template compilation.
270+
*/
271+
let scope = options.scope?.();
266272

267273
if (!scope) {
268274
return evaluator;
269275
}
270276

277+
scope = Object.assign({}, keywords, scope);
278+
271279
return (source: string) => {
272280
const argNames = Object.keys(scope);
273281
const argValues = Object.values(scope);

packages/@ember/template-compiler/lib/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type {
2+
AST,
23
ASTPluginEnvironment,
34
builders,
45
PrecompileOptions,
@@ -23,6 +24,10 @@ interface Plugins {
2324
ast: PluginFunc[];
2425
}
2526

27+
export interface JSUtils {
28+
bindImport(module: string, name: string, node: AST.Node): void;
29+
}
30+
2631
export interface EmberPrecompileOptions extends PrecompileOptions {
2732
isProduction?: boolean;
2833
moduleName?: string;

packages/@ember/template-compiler/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
"@ember/-internals": "workspace:*",
1313
"@ember/component": "workspace:*",
1414
"@ember/debug": "workspace:*",
15+
"@ember/modifier": "workspace:*",
16+
"@ember/helper": "workspace:*",
1517
"@glimmer/compiler": "workspace:*",
1618
"@glimmer/env": "workspace:*",
1719
"@glimmer/interfaces": "workspace:*",
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
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+
6+
class KeywordOn extends RenderTest {
7+
static suiteName = 'keyword modifier: on (runtime)';
8+
9+
@test
10+
'explicit scope'(assert: Assert) {
11+
let handleClick = () => {
12+
assert.step('success');
13+
};
14+
15+
const compiled = template('<button {{on "click" handleClick}}>Click</button>', {
16+
strictMode: true,
17+
scope: () => ({
18+
handleClick,
19+
}),
20+
});
21+
22+
this.renderComponent(compiled);
23+
24+
castToBrowser(this.element, 'div').querySelector('button')!.click();
25+
assert.verifySteps(['success']);
26+
}
27+
28+
@test
29+
'implicit scope'(assert: Assert) {
30+
let handleClick = () => {
31+
assert.step('success');
32+
};
33+
34+
hide(handleClick);
35+
36+
const compiled = template('<button {{on "click" handleClick}}>Click</button>', {
37+
strictMode: true,
38+
eval() {
39+
return eval(arguments[0]);
40+
},
41+
});
42+
43+
this.renderComponent(compiled);
44+
45+
castToBrowser(this.element, 'div').querySelector('button')!.click();
46+
assert.verifySteps(['success']);
47+
}
48+
}
49+
50+
jitSuite(KeywordOn);
51+
52+
/**
53+
* This function is used to hide a variable from the transpiler, so that it
54+
* doesn't get removed as "unused". It does not actually do anything with the
55+
* variable, it just makes it be part of an expression that the transpiler
56+
* won't remove.
57+
*
58+
* It's a bit of a hack, but it's necessary for testing.
59+
*
60+
* @param variable The variable to hide.
61+
*/
62+
const hide = (variable: unknown) => {
63+
new Function(`return (${JSON.stringify(variable)});`);
64+
};
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { castToBrowser } from '@glimmer/debug-util';
2+
import { jitSuite, RenderTest, test } from '@glimmer-workspace/integration-tests';
3+
import { setModifierManager, modifierCapabilities } from '@glimmer/manager';
4+
5+
import { template } from '@ember/template-compiler/runtime';
6+
import { on } from '@ember/modifier';
7+
8+
class KeywordOn extends RenderTest {
9+
static suiteName = 'keyword modifier: on';
10+
11+
/**
12+
* We require the babel compiler to emit keywords, so this is actually no different than normal usage
13+
* prior to RFC 997.
14+
*
15+
* We are required to have the compiler that emits this low-level format to detect if on is in scope and then
16+
* _not_ add the `on` modifier from `@ember/modifier` import.
17+
*/
18+
@test
19+
'it works'(assert: Assert) {
20+
let handleClick = () => {
21+
assert.step('success');
22+
};
23+
24+
const compiled = template('<button {{on "click" handleClick}}>Click</button>', {
25+
strictMode: true,
26+
scope: () => ({
27+
handleClick,
28+
on,
29+
}),
30+
});
31+
32+
this.renderComponent(compiled);
33+
34+
castToBrowser(this.element, 'div').querySelector('button')!.click();
35+
assert.verifySteps(['success']);
36+
}
37+
38+
@test
39+
'it works with the runtime compiler'(assert: Assert) {
40+
let handleClick = () => {
41+
assert.step('success');
42+
};
43+
44+
hide(handleClick);
45+
46+
const compiled = template('<button {{on "click" handleClick}}>Click</button>', {
47+
strictMode: true,
48+
eval() {
49+
return eval(arguments[0]);
50+
},
51+
});
52+
53+
this.renderComponent(compiled);
54+
55+
castToBrowser(this.element, 'div').querySelector('button')!.click();
56+
assert.verifySteps(['success']);
57+
}
58+
59+
@test
60+
'can be shadowed'(assert: Assert) {
61+
let on = setModifierManager(() => {
62+
return {
63+
capabilities: modifierCapabilities('3.22'),
64+
createModifier() {
65+
assert.step('shadowed:success');
66+
},
67+
installModifier() {},
68+
updateModifier() {},
69+
destroyModifier() {},
70+
};
71+
}, {});
72+
73+
const compiled = template('<button {{on "click"}}>Click</button>', {
74+
strictMode: true,
75+
scope: () => ({ on }),
76+
});
77+
78+
this.renderComponent(compiled);
79+
80+
castToBrowser(this.element, 'div').querySelector('button')!.click();
81+
assert.verifySteps(['shadowed:success']);
82+
}
83+
}
84+
85+
jitSuite(KeywordOn);
86+
87+
/**
88+
* This function is used to hide a variable from the transpiler, so that it
89+
* doesn't get removed as "unused". It does not actually do anything with the
90+
* variable, it just makes it be part of an expression that the transpiler
91+
* won't remove.
92+
*
93+
* It's a bit of a hack, but it's necessary for testing.
94+
*
95+
* @param variable The variable to hide.
96+
*/
97+
const hide = (variable: unknown) => {
98+
new Function(`return (${JSON.stringify(variable)});`);
99+
};

0 commit comments

Comments
 (0)