Skip to content

Commit d1ad5dc

Browse files
Merge pull request #21068 from emberjs/nvp/on-as-keyword-tremplate-the-transform-way
RFC#997 - {{on}} as keyword
2 parents c2fec5b + 6bbf661 commit d1ad5dc

File tree

14 files changed

+420
-27
lines changed

14 files changed

+420
-27
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,7 @@
286286
"@ember/template-compiler/lib/plugins/assert-against-named-outlets.js": "ember-source/@ember/template-compiler/lib/plugins/assert-against-named-outlets.js",
287287
"@ember/template-compiler/lib/plugins/assert-input-helper-without-block.js": "ember-source/@ember/template-compiler/lib/plugins/assert-input-helper-without-block.js",
288288
"@ember/template-compiler/lib/plugins/assert-reserved-named-arguments.js": "ember-source/@ember/template-compiler/lib/plugins/assert-reserved-named-arguments.js",
289+
"@ember/template-compiler/lib/plugins/auto-import-builtins.js": "ember-source/@ember/template-compiler/lib/plugins/auto-import-builtins.js",
289290
"@ember/template-compiler/lib/plugins/index.js": "ember-source/@ember/template-compiler/lib/plugins/index.js",
290291
"@ember/template-compiler/lib/plugins/transform-action-syntax.js": "ember-source/@ember/template-compiler/lib/plugins/transform-action-syntax.js",
291292
"@ember/template-compiler/lib/plugins/transform-each-in-into-each.js": "ember-source/@ember/template-compiler/lib/plugins/transform-each-in-into-each.js",

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

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { on } from '@ember/modifier';
12
import { assert } from '@ember/debug';
23
import {
34
RESOLUTION_MODE_TRANSFORMS,
@@ -14,11 +15,22 @@ function malformedComponentLookup(string: string) {
1415
return string.indexOf('::') === -1 && string.indexOf(':') > -1;
1516
}
1617

18+
/**
19+
* The variable name used to inject the keywords object into the
20+
* template's evaluation scope. auto-import-builtins rewrites bare
21+
* keyword references (e.g. `on`) to property accesses on this
22+
* variable (e.g. `__ember_keywords__.on`).
23+
*/
24+
export const RUNTIME_KEYWORDS_NAME = '__ember_keywords__';
25+
26+
export const keywords: Record<string, unknown> = {
27+
on,
28+
};
29+
1730
function buildCompileOptions(_options: EmberPrecompileOptions): EmberPrecompileOptions {
1831
let moduleName = _options.moduleName;
1932

20-
let options: EmberPrecompileOptions & Partial<EmberPrecompileOptions> = {
21-
meta: {},
33+
let options = {
2234
isProduction: false,
2335
plugins: { ast: [] },
2436
..._options,
@@ -35,11 +47,29 @@ function buildCompileOptions(_options: EmberPrecompileOptions): EmberPrecompileO
3547
},
3648
};
3749

38-
if ('eval' in options) {
39-
const localScopeEvaluator = options.eval as (value: string) => unknown;
50+
options.meta ||= {};
51+
options.meta.emberRuntime ||= {
52+
lookupKeyword(name: string): string {
53+
assert(
54+
`${name} is not a known keyword. Available keywords: ${Object.keys(keywords).join(', ')}`,
55+
name in keywords
56+
);
57+
58+
return `${RUNTIME_KEYWORDS_NAME}.${name}`;
59+
},
60+
};
61+
62+
if ('eval' in options && options.eval) {
63+
const localScopeEvaluator = options.eval;
4064
const globalScopeEvaluator = (value: string) => new Function(`return ${value};`)();
4165

4266
options.lexicalScope = (variable: string) => {
67+
// The keywords container variable is always "in scope" —
68+
// we inject it via the evaluator in template.ts.
69+
if (variable === RUNTIME_KEYWORDS_NAME) {
70+
return true;
71+
}
72+
4373
if (ALLOWED_GLOBALS.has(variable)) {
4474
return variable in globalThis;
4575
}
@@ -57,13 +87,18 @@ function buildCompileOptions(_options: EmberPrecompileOptions): EmberPrecompileO
5787
if ('scope' in options) {
5888
const scope = (options.scope as () => Record<string, unknown>)();
5989

60-
options.lexicalScope = (variable: string) => {
61-
return variable in scope;
62-
};
90+
options.lexicalScope = (variable: string) =>
91+
variable in scope || variable === RUNTIME_KEYWORDS_NAME;
6392

6493
delete options.scope;
6594
}
6695

96+
// When neither eval nor scope is provided, the keywords container
97+
// still needs to be visible to the compiler.
98+
if (!options.lexicalScope) {
99+
options.lexicalScope = (variable: string) => variable === RUNTIME_KEYWORDS_NAME;
100+
}
101+
67102
if ('locals' in options && !options.locals) {
68103
// Glimmer's precompile options declare `locals` like:
69104
// locals?: string[]
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
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+
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+
}
33+
}
34+
},
35+
},
36+
};
37+
}
38+
39+
function isOn(
40+
node: AST.ElementModifierStatement | AST.MustacheStatement | AST.SubExpression,
41+
hasLocal: (k: string) => boolean
42+
): node is AST.ElementModifierStatement & { path: AST.PathExpression } {
43+
return isPath(node.path) && node.path.original === 'on' && !hasLocal('on');
44+
}

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: 31 additions & 15 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, RUNTIME_KEYWORDS_NAME } from './compile-options';
77
import type { EmberPrecompileOptions } from './types';
88

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

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

246246
const source = glimmerPrecompile(templateString, normalizedOptions);
247-
const template = templateFactory(evaluate(`(${source})`) as SerializedTemplateWithLazyBlock);
247+
const wire = evaluate(`(${source})`) as SerializedTemplateWithLazyBlock;
248+
249+
const template = templateFactory(wire);
248250

249251
setComponentTemplate(template, component);
250252

251253
return component;
252254
}
253255

254-
const evaluator = (source: string) => {
255-
return new Function(`return ${source}`)();
256-
};
256+
/**
257+
* Builds the source wireformat JSON block
258+
*
259+
* @param options
260+
* @returns
261+
*/
262+
function buildEvaluator(options: Partial<EmberPrecompileOptions>) {
263+
if (options.eval) {
264+
const userEval = options.eval;
257265

258-
function buildEvaluator(options: Partial<EmberPrecompileOptions> | undefined) {
259-
if (options === undefined) {
260-
return evaluator;
261-
}
266+
// Wrap the compiled source in a function that receives the keywords
267+
// container as a parameter. The user's eval evaluates this in the
268+
// caller's scope, so local variables (like `handleClick`) are captured
269+
// via closure, while `__keywords__` comes from the function parameter.
270+
return (source: string) => {
271+
let wrapperFn = userEval(`(function(${RUNTIME_KEYWORDS_NAME}){ return (${source}); })`) as (
272+
...args: unknown[]
273+
) => unknown;
262274

263-
if (options.eval) {
264-
return options.eval;
275+
return wrapperFn(keywords);
276+
};
265277
} else {
266-
const scope = options.scope?.();
278+
let scope = options.scope?.();
267279

268280
if (!scope) {
269-
return evaluator;
281+
return (source: string) => {
282+
return new Function(RUNTIME_KEYWORDS_NAME, `return (${source})`)(keywords);
283+
};
270284
}
271285

286+
scope = Object.assign({ [RUNTIME_KEYWORDS_NAME]: keywords }, scope);
287+
272288
return (source: string) => {
273289
let hasThis = Object.prototype.hasOwnProperty.call(scope, 'this');
274290
let thisValue = hasThis ? (scope as { this?: unknown }).this : undefined;

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

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type {
2+
ASTPluginBuilder,
23
ASTPluginEnvironment,
34
builders,
45
PrecompileOptions,
@@ -13,21 +14,39 @@ export type Builders = typeof builders;
1314
* typing. Here export the interface subclass with no modification.
1415
*/
1516

16-
export type PluginFunc = NonNullable<
17-
NonNullable<PrecompileOptionsWithLexicalScope['plugins']>['ast']
18-
>[number];
17+
export type PluginFunc = ASTPluginBuilder<EmberASTPluginEnvironment>;
1918

2019
export type LexicalScope = NonNullable<PrecompileOptionsWithLexicalScope['lexicalScope']>;
2120

2221
interface Plugins {
2322
ast: PluginFunc[];
2423
}
2524

26-
export interface EmberPrecompileOptions extends PrecompileOptions {
25+
export interface EmberPrecompileOptions extends Omit<PrecompileOptions, 'meta'> {
2726
isProduction?: boolean;
2827
moduleName?: string;
2928
plugins?: Plugins;
3029
lexicalScope?: LexicalScope;
30+
meta?: {
31+
/**
32+
* Exists for historical reasons, should not be in new code, as
33+
* the module name does not correspond to anything meaningful at runtime.
34+
*/
35+
moduleName?: string | undefined;
36+
37+
/**
38+
* Not available at runtime
39+
*/
40+
jsutils?: { bindImport: (...args: unknown[]) => string };
41+
42+
/**
43+
* Utils unique to the runtime compiler
44+
*/
45+
emberRuntime?: {
46+
lookupKeyword(name: string): string;
47+
};
48+
};
49+
3150
/**
3251
* This supports template blocks defined in class bodies.
3352
*

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: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
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 KeywordOn extends RenderTest {
12+
static suiteName = 'keyword modifier: on (runtime)';
13+
14+
@test
15+
'explicit scope'(assert: Assert) {
16+
let handleClick = () => {
17+
assert.step('success');
18+
};
19+
20+
const compiled = template('<button {{on "click" handleClick}}>Click</button>', {
21+
strictMode: true,
22+
scope: () => ({
23+
handleClick,
24+
}),
25+
});
26+
27+
this.renderComponent(compiled);
28+
29+
castToBrowser(this.element, 'div').querySelector('button')!.click();
30+
assert.verifySteps(['success']);
31+
}
32+
33+
@test
34+
'implicit scope'(assert: Assert) {
35+
let handleClick = () => {
36+
assert.step('success');
37+
};
38+
39+
hide(handleClick);
40+
41+
const compiled = template('<button {{on "click" handleClick}}>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(['success']);
52+
}
53+
54+
@test
55+
'no eval and no scope'(assert: Assert) {
56+
class Foo extends GlimmerishComponent {
57+
static {
58+
template('<button {{on "click" this.handleClick}}>Click</button>', {
59+
strictMode: true,
60+
component: this,
61+
});
62+
}
63+
64+
handleClick = () => assert.step('success');
65+
}
66+
67+
this.renderComponent(Foo);
68+
69+
castToBrowser(this.element, 'div').querySelector('button')!.click();
70+
assert.verifySteps(['success']);
71+
}
72+
}
73+
74+
jitSuite(KeywordOn);
75+
76+
/**
77+
* This function is used to hide a variable from the transpiler, so that it
78+
* doesn't get removed as "unused". It does not actually do anything with the
79+
* variable, it just makes it be part of an expression that the transpiler
80+
* won't remove.
81+
*
82+
* It's a bit of a hack, but it's necessary for testing.
83+
*
84+
* @param variable The variable to hide.
85+
*/
86+
const hide = (variable: unknown) => {
87+
new Function(`return (${JSON.stringify(variable)});`);
88+
};

0 commit comments

Comments
 (0)