Skip to content

Commit a5c1907

Browse files
committed
Add 'on' keyword support and related tests
sketching some updates @ef4 -- it doesn't work Use globalThis lint:fix
1 parent 0531e62 commit a5c1907

14 files changed

Lines changed: 378 additions & 29 deletions

File tree

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

Lines changed: 30 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,18 @@ function malformedComponentLookup(string: string) {
1415
return string.indexOf('::') === -1 && string.indexOf(':') > -1;
1516
}
1617

18+
const RUNTIME_KEYWORDS_NAME = '__ember_keywords__';
19+
export const keywords = {
20+
on,
21+
};
22+
23+
// Not worth adding a type
24+
(globalThis as any)[RUNTIME_KEYWORDS_NAME] = keywords;
25+
1726
function buildCompileOptions(_options: EmberPrecompileOptions): EmberPrecompileOptions {
1827
let moduleName = _options.moduleName;
1928

20-
let options: EmberPrecompileOptions & Partial<EmberPrecompileOptions> = {
21-
meta: {},
29+
let options = {
2230
isProduction: false,
2331
plugins: { ast: [] },
2432
..._options,
@@ -35,8 +43,25 @@ function buildCompileOptions(_options: EmberPrecompileOptions): EmberPrecompileO
3543
},
3644
};
3745

38-
if ('eval' in options) {
39-
const localScopeEvaluator = options.eval as (value: string) => unknown;
46+
options.meta ||= {};
47+
options.meta.emberRuntime ||= {
48+
/**
49+
* NOTE: when stepping through lexicalScope, or other callbacks here,
50+
* we first detect the keywords as "not in scope",
51+
* and that is what we want, so that we can import them.
52+
*/
53+
lookupKeyword(name: string): string {
54+
assert(
55+
`${name} is not a known keyword. Available keywords: ${Object.keys(keywords).join(', ')}`,
56+
name in keywords
57+
);
58+
59+
return `globalThis.${RUNTIME_KEYWORDS_NAME}.${name}`;
60+
},
61+
};
62+
63+
if ('eval' in options && options.eval) {
64+
const localScopeEvaluator = options.eval;
4065
const globalScopeEvaluator = (value: string) => new Function(`return ${value};`)();
4166

4267
options.lexicalScope = (variable: string) => {
@@ -57,9 +82,7 @@ function buildCompileOptions(_options: EmberPrecompileOptions): EmberPrecompileO
5782
if ('scope' in options) {
5883
const scope = (options.scope as () => Record<string, unknown>)();
5984

60-
options.lexicalScope = (variable: string) => {
61-
return variable in scope;
62-
};
85+
options.lexicalScope = (variable: string) => variable in scope || variable in keywords;
6386

6487
delete options.scope;
6588
}
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: 21 additions & 11 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,38 +237,48 @@ 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

254256
const evaluator = (source: string) => {
255-
return new Function(`return ${source}`)();
257+
return new Function(`return ${source}`)();
256258
};
257259

258-
function buildEvaluator(options: Partial<EmberPrecompileOptions> | undefined) {
259-
if (options === undefined) {
260-
return evaluator;
261-
}
262-
260+
/**
261+
* Builds the source wireformat JSON block
262+
*
263+
* @param options
264+
* @returns
265+
*/
266+
function buildEvaluator(options: Partial<EmberPrecompileOptions>) {
263267
if (options.eval) {
264268
return options.eval;
265269
} else {
266-
const scope = options.scope?.();
270+
/**
271+
* This is ran before the template is compiled,
272+
* so we cannot use any information gathered during template compilation.
273+
*/
274+
let scope = options.scope?.();
267275

268276
if (!scope) {
269277
return evaluator;
270278
}
271279

280+
scope = Object.assign({}, keywords, scope);
281+
272282
return (source: string) => {
273283
let hasThis = Object.prototype.hasOwnProperty.call(scope, 'this');
274284
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: 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+
};

0 commit comments

Comments
 (0)