Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,8 @@
"esbuild"
],
"patchedDependencies": {
"@tracerbench/core@8.0.1": "patches/@tracerbench__core@8.0.1.patch"
"@tracerbench/core@8.0.1": "patches/@tracerbench__core@8.0.1.patch",
"babel-plugin-ember-template-compilation@3.0.0-alpha.4": "patches/babel-plugin-ember-template-compilation@3.0.0-alpha.4.patch"
}
},
"peerDependencies": {
Expand Down Expand Up @@ -295,6 +296,7 @@
"@ember/template-compiler/lib/-internal/primitives.js": "ember-source/@ember/template-compiler/lib/-internal/primitives.js",
"@ember/template-compiler/lib/compile-options.js": "ember-source/@ember/template-compiler/lib/compile-options.js",
"@ember/template-compiler/lib/dasherize-component-name.js": "ember-source/@ember/template-compiler/lib/dasherize-component-name.js",
"@ember/template-compiler/lib/plugins/allowed-globals.js": "ember-source/@ember/template-compiler/lib/plugins/allowed-globals.js",
"@ember/template-compiler/lib/plugins/assert-against-attrs.js": "ember-source/@ember/template-compiler/lib/plugins/assert-against-attrs.js",
"@ember/template-compiler/lib/plugins/assert-against-named-outlets.js": "ember-source/@ember/template-compiler/lib/plugins/assert-against-named-outlets.js",
"@ember/template-compiler/lib/plugins/assert-input-helper-without-block.js": "ember-source/@ember/template-compiler/lib/plugins/assert-input-helper-without-block.js",
Expand Down Expand Up @@ -346,7 +348,6 @@
"@simple-dom/document/index.js": "ember-source/@simple-dom/document/index.js",
"backburner.js/index.js": "ember-source/backburner.js/index.js",
"dag-map/index.js": "ember-source/dag-map/index.js",
"ember-template-compiler/index.js": "ember-source/ember-template-compiler/index.js",
"ember-testing/index.js": "ember-source/ember-testing/index.js",
"ember-testing/lib/adapters/adapter.js": "ember-source/ember-testing/lib/adapters/adapter.js",
"ember-testing/lib/adapters/qunit.js": "ember-source/ember-testing/lib/adapters/qunit.js",
Expand Down Expand Up @@ -390,4 +391,4 @@
}
},
"packageManager": "pnpm@10.30.3"
}
}
82 changes: 24 additions & 58 deletions packages/@ember/template-compiler/lib/compile-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,41 +36,22 @@ function buildCompileOptions(_options: EmberPrecompileOptions): EmberPrecompileO
};

if ('eval' in options) {
const localScopeEvaluator = options.eval as (value: string) => unknown;
const globalScopeEvaluator = (value: string) => new Function(`return ${value};`)();

options.lexicalScope = (variable: string) => {
if (ALLOWED_GLOBALS.has(variable)) {
return variable in globalThis;
}

if (inScope(variable, localScopeEvaluator)) {
return !inScope(variable, globalScopeEvaluator);
}

return false;
};
const evalFn = options.eval as (value: string) => unknown;
const globalEval = (value: string) => new Function(`return ${value};`)();

options.scope = new Proxy({} as Record<string, unknown>, {
has(_, name) {
if (typeof name !== 'string' || !IDENT.test(name)) return false;
if (ALLOWED_GLOBALS.has(name)) return name in globalThis;
return inScope(name, evalFn) && !inScope(name, globalEval);
},
});

delete options.eval;
}

if ('scope' in options) {
const scope = (options.scope as () => Record<string, unknown>)();

options.lexicalScope = (variable: string) => {
return variable in scope;
};

delete options.scope;
}

if ('locals' in options && !options.locals) {
// Glimmer's precompile options declare `locals` like:
// locals?: string[]
// but many in-use versions of babel-plugin-htmlbars-inline-precompile will
// set locals to `null`. This used to work but only because glimmer was
// ignoring locals for non-strict templates, and now it supports that case.
delete options.locals;
if ('scope' in options && typeof options.scope === 'function') {
options.scope = (options.scope as () => Record<string, unknown>)();
}

// move `moduleName` into `meta` property
Expand All @@ -91,6 +72,18 @@ function transformsFor(options: EmberPrecompileOptions): readonly PluginFunc[] {
return options.strictMode ? STRICT_MODE_TRANSFORMS : RESOLUTION_MODE_TRANSFORMS;
}

// https://tc39.es/ecma262/2020/#prod-IdentifierName
const IDENT = /^[\p{ID_Start}$_][\p{ID_Continue}$_\u200C\u200D]*$/u;

function inScope(variable: string, evaluator: (value: string) => unknown): boolean {
try {
return evaluator(`typeof ${variable} !== "undefined"`) === true;
} catch (e) {
if (e instanceof SyntaxError) return false;
throw e;
}
}

export default function compileOptions(
_options: Partial<EmberPrecompileOptions> = {}
): EmberPrecompileOptions {
Expand All @@ -111,30 +104,3 @@ export default function compileOptions(

return options;
}

type Evaluator = (value: string) => unknown;

// https://tc39.es/ecma262/2020/#prod-IdentifierName
const IDENT = /^[\p{ID_Start}$_][\p{ID_Continue}$_\u200C\u200D]*$/u;

function inScope(variable: string, evaluator: Evaluator): boolean {
// If the identifier is not a valid JS identifier, it's definitely not in scope
if (!IDENT.exec(variable)) {
return false;
}

try {
return evaluator(`typeof ${variable} !== "undefined"`) === true;
} catch (e) {
// This occurs when attempting to evaluate a reserved word using eval (`eval('typeof let')`).
// If the variable is a reserved word, it's definitely not in scope, so return false. Since
// reserved words are somewhat contextual, we don't try to identify them purely by their
// name. See https://tc39.es/ecma262/#sec-keywords-and-reserved-words
if (e && e instanceof SyntaxError) {
return false;
}

// If it's another kind of error, don't swallow it.
throw e;
}
}
2 changes: 1 addition & 1 deletion packages/@ember/template-compiler/lib/plugins/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export function isStringLiteral(node: AST.Expression): node is AST.StringLiteral
}

export function inScope(env: EmberASTPluginEnvironment, name: string): boolean {
return Boolean(env.lexicalScope?.(name));
return env.scope != null && name in env.scope;
}

function getLocalName(node: string | AST.VarHead): string {
Expand Down
6 changes: 4 additions & 2 deletions packages/@ember/template-compiler/lib/template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { precompile as glimmerPrecompile } from '@glimmer/compiler';
import type { SerializedTemplateWithLazyBlock } from '@glimmer/interfaces';
import { setComponentTemplate } from '@glimmer/manager';
import { templateFactory } from '@glimmer/opcode-compiler';
import type { PrecompileOptions } from '@glimmer/syntax';
import compileOptions from './compile-options';
import type { EmberPrecompileOptions } from './types';

Expand Down Expand Up @@ -243,7 +244,8 @@ export function template(
const normalizedOptions = compileOptions(options);
const component = normalizedOptions.component ?? templateOnly();

const source = glimmerPrecompile(templateString, normalizedOptions);
// compileOptions() resolves function-form scope to a plain object, so the cast is safe
const source = glimmerPrecompile(templateString, normalizedOptions as PrecompileOptions);
const template = templateFactory(evaluate(`(${source})`) as SerializedTemplateWithLazyBlock);

setComponentTemplate(template, component);
Expand All @@ -263,7 +265,7 @@ function buildEvaluator(options: Partial<EmberPrecompileOptions> | undefined) {
if (options.eval) {
return options.eval;
} else {
const scope = options.scope?.();
const scope = typeof options.scope === 'function' ? options.scope() : options.scope;

if (!scope) {
return evaluator;
Expand Down
18 changes: 4 additions & 14 deletions packages/@ember/template-compiler/lib/types.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,4 @@
import type {
ASTPluginEnvironment,
builders,
PrecompileOptions,
PrecompileOptionsWithLexicalScope,
} from '@glimmer/syntax';
import type { ASTPluginEnvironment, builders, PrecompileOptions } from '@glimmer/syntax';

export type Builders = typeof builders;

Expand All @@ -13,21 +8,16 @@ export type Builders = typeof builders;
* typing. Here export the interface subclass with no modification.
*/

export type PluginFunc = NonNullable<
NonNullable<PrecompileOptionsWithLexicalScope['plugins']>['ast']
>[number];

export type LexicalScope = NonNullable<PrecompileOptionsWithLexicalScope['lexicalScope']>;
export type PluginFunc = NonNullable<NonNullable<PrecompileOptions['plugins']>['ast']>[number];

interface Plugins {
ast: PluginFunc[];
}

export interface EmberPrecompileOptions extends PrecompileOptions {
export interface EmberPrecompileOptions extends Omit<PrecompileOptions, 'scope'> {
isProduction?: boolean;
moduleName?: string;
plugins?: Plugins;
lexicalScope?: LexicalScope;
/**
* This supports template blocks defined in class bodies.
*
Expand All @@ -54,7 +44,7 @@ export interface EmberPrecompileOptions extends PrecompileOptions {
*/
component?: object;
eval?: (value: string) => unknown;
scope?: () => Record<string, unknown>;
scope?: (() => Record<string, unknown>) | Record<string, unknown>;
}

export type EmberASTPluginEnvironment = ASTPluginEnvironment & EmberPrecompileOptions;
16 changes: 7 additions & 9 deletions packages/@glimmer-workspace/integration-tests/lib/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type {
Template,
TemplateFactory,
} from '@glimmer/interfaces';
import type { PrecompileOptions, PrecompileOptionsWithLexicalScope } from '@glimmer/syntax';
import type { PrecompileOptions } from '@glimmer/syntax';
import { precompileJSON } from '@glimmer/compiler';
import { templateFactory } from '@glimmer/opcode-compiler';

Expand All @@ -19,22 +19,20 @@ let templateId = 0;

export function createTemplate(
templateSource: Nullable<string>,
options: PrecompileOptions | PrecompileOptionsWithLexicalScope = {},
options: PrecompileOptions = {},
scopeValues: Record<string, unknown> = {}
): TemplateFactory {
options.locals = options.locals ?? Object.keys(scopeValues ?? {});
options.scope = options.scope ?? scopeValues ?? {};
let [block, usedLocals] = precompileJSON(templateSource, options);
let reifiedScopeValues = usedLocals.map((key) => scopeValues[key]);

if ('emit' in options && options.emit?.debugSymbols) {
block.push(usedLocals);
}
let reifiedScopeValues: Record<string, unknown> = Object.fromEntries(
usedLocals.map((key) => [key, scopeValues[key]])
);

let templateBlock: SerializedTemplateWithLazyBlock = {
id: String(templateId++),
block: JSON.stringify(block),
moduleName: options.meta?.moduleName ?? '(unknown template module)',
scope: reifiedScopeValues.length > 0 ? () => reifiedScopeValues : null,
scope: usedLocals.length > 0 ? () => reifiedScopeValues : null,
isStrictMode: options.strictMode ?? false,
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type {
ModifierManager,
Owner,
} from '@glimmer/interfaces';
import type { PrecompileOptionsWithLexicalScope } from '@glimmer/syntax';
import type { PrecompileOptions } from '@glimmer/syntax';
import { registerDestructor } from '@glimmer/destroyable';
import {
helperCapabilities,
Expand Down Expand Up @@ -101,7 +101,7 @@ export interface DefineComponentOptions {
// additional strict-mode keywords
keywords?: string[];

emit?: PrecompileOptionsWithLexicalScope['emit'];
emit?: PrecompileOptions['emit'];
}

export function defComponent(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,41 +28,47 @@ module('[glimmer-compiler] precompile', ({ test }) => {
assert.strictEqual(wire.moduleName, 'my/module-name', 'Template has correct meta');
});

function compile(
template: string,
locals: string[],
evaluate: (source: string) => WireFormat.SerializedTemplateWithLazyBlock
) {
function compile(template: string, scopeObj: Record<string, unknown>) {
let source = precompile(template, {
lexicalScope: (variable: string) => locals.includes(variable),
scope: scopeObj,
});

let wire = evaluate(`(${source})`);
// Build an evaluator that puts scope values into local variable scope.
// We avoid using `new Function(name, ...)` because some scope keys like
// `this` are reserved words and can't be used as parameter names.
const argNames = Object.keys(scopeObj).filter((n) => n !== 'this');
const argValues = argNames.map((n) => scopeObj[n]);
const hasThis = 'this' in scopeObj;
const fn = new Function(...argNames, `return (${source})`);
let wire: WireFormat.SerializedTemplateWithLazyBlock;
if (hasThis) {
wire = fn.call(scopeObj['this'], ...argValues) as WireFormat.SerializedTemplateWithLazyBlock;
} else {
wire = fn(...argValues) as WireFormat.SerializedTemplateWithLazyBlock;
}

return {
...wire,
block: JSON.parse(wire.block),
};
}

test('lexicalScope is used if present', (assert) => {
let wire = compile(`<hello /><div />`, ['hello'], (source) => eval(source));

test('scope is used if present', (assert) => {
const hello = { varname: 'hello' };
assert.ok(hello, 'avoid unused variable lint');
let wire = compile(`<hello /><div />`, { hello });

let [statements] = wire.block;
let [[, componentNameExpr], ...divExpr] = statements as [
WireFormat.Statements.Component,
...WireFormat.Statement[],
];

assert.deepEqual(wire.scope?.(), [hello]);
assert.deepEqual(wire.scope?.(), { hello });

assert.deepEqual(
componentNameExpr,
[SexpOpcodes.GetLexicalSymbol, 0],
'The component invocation is for the lexical symbol `hello` (the 0th lexical entry)'
[SexpOpcodes.GetLexicalSymbol, 'hello'],
'The component invocation is for the lexical symbol `hello`'
);

assert.deepEqual(divExpr, [
Expand All @@ -72,23 +78,21 @@ module('[glimmer-compiler] precompile', ({ test }) => {
]);
});

test('lexicalScope works if the component name is a path', (assert) => {
let wire = compile(`<f.hello /><div />`, ['f'], (source) => eval(source));

test('scope works if the component name is a path', (assert) => {
const f = {};
assert.ok(f, 'avoid unused variable lint');
let wire = compile(`<f.hello /><div />`, { f });

let [statements] = wire.block;
let [[, componentNameExpr], ...divExpr] = statements as [
WireFormat.Statements.Component,
...WireFormat.Statement[],
];

assert.deepEqual(wire.scope?.(), [f]);
assert.deepEqual(wire.scope?.(), { f });
assert.deepEqual(
componentNameExpr,
[SexpOpcodes.GetLexicalSymbol, 0, ['hello']],
'The component invocation is for the lexical symbol `hello` (the 0th lexical entry)'
[SexpOpcodes.GetLexicalSymbol, 'f', ['hello']],
'The component invocation is for the lexical symbol `f` with path `hello`'
);

assert.deepEqual(divExpr, [
Expand Down Expand Up @@ -213,19 +217,15 @@ module('[glimmer-compiler] precompile', ({ test }) => {

test('when "this" in in locals, it compiles to GetLexicalSymbol', (assert) => {
let target = { message: 'hello' };
let _wire: ReturnType<typeof compile>;
(function () {
_wire = compile(`{{this.message}}`, ['this'], (source) => eval(source));
}).call(target);
let wire = _wire!;
assert.deepEqual(wire.scope?.(), [target]);
let wire = compile(`{{this.message}}`, { this: target });
assert.deepEqual(wire.scope?.(), { this: target });
assert.deepEqual(wire.block[0], [
[SexpOpcodes.Append, [SexpOpcodes.GetLexicalSymbol, 0, ['message']]],
[SexpOpcodes.Append, [SexpOpcodes.GetLexicalSymbol, 'this', ['message']]],
]);
});

test('when "this" is not in locals, it compiles to GetSymbol', (assert) => {
let wire = compile(`{{this.message}}`, [], (source) => eval(source));
let wire = compile(`{{this.message}}`, {});
assert.strictEqual(wire.scope, undefined);
assert.deepEqual(wire.block[0], [
[SexpOpcodes.Append, [SexpOpcodes.GetSymbol, 0, ['message']]],
Expand Down
Loading
Loading