diff --git a/__tests__/all.test.ts b/__tests__/all.test.ts
index 9a95acd..219dea1 100644
--- a/__tests__/all.test.ts
+++ b/__tests__/all.test.ts
@@ -1904,16 +1904,85 @@ describe('htmlbars-inline-precompile', function () {
);
});
- it('errors if scope contains any non-reference values', async function () {
+ it('errors if scope contains any non-reference, non-literal values', async function () {
await expect(() =>
transform(
- "import { precompileTemplate } from '@ember/template-compilation';\nvar compiled = precompileTemplate('hello', { scope: () => ({ foo, bar: 123 }) });"
+ "import { precompileTemplate } from '@ember/template-compilation';\nvar compiled = precompileTemplate('hello', { scope: () => ({ foo, bar: foo() }) });"
)
).rejects.toThrow(
- /Scope objects for `precompileTemplate` may only contain direct references to in-scope values, e.g. { bar } or { bar: bar }/
+ /Scope objects for `precompileTemplate` may only contain direct references to in-scope values, e.g. { bar } or { bar: bar }, or literal values. Found CallExpression/
);
});
+ it('errors if scope contains a template literal', async function () {
+ // a template literal can interpolate expressions that reference bindings
+ // we know nothing about, so it isn't a value we can inline
+ await expect(() =>
+ transform(
+ 'import { precompileTemplate } from "@ember/template-compilation";\nvar compiled = precompileTemplate("hello", { scope: () => ({ bar: `hi` }) });'
+ )
+ ).rejects.toThrow(/or literal values. Found TemplateLiteral/);
+ });
+
+ it('errors if scope contains a regex literal', async function () {
+ // a regex literal would construct a new stateful object on every call
+ await expect(() =>
+ transform(
+ 'import { precompileTemplate } from "@ember/template-compilation";\nvar compiled = precompileTemplate("hello", { scope: () => ({ bar: /hi/ }) });'
+ )
+ ).rejects.toThrow(/or literal values. Found RegExpLiteral/);
+ });
+
+ it('accepts literal scope values, as produced by a bundler inlining constants', async function () {
+ // e.g. rolldown's `optimization.inlineConst` rewrites
+ // `import { EMPTY } from './constants.js'; ... scope: () => ({ EMPTY })`
+ // into `scope: () => ({ EMPTY: "—" })` when the constant's value is
+ // statically known
+ let transformed = await transform(`
+ import { precompileTemplate } from '@ember/template-compilation';
+ import { setComponentTemplate } from '@ember/component';
+ import templateOnly from '@ember/component/template-only';
+ export default setComponentTemplate(precompileTemplate("{{EMPTY}}{{COUNT}}{{FLAG}}{{NEGATIVE}}", {
+ strictMode: true,
+ scope: () => ({ EMPTY: "—", COUNT: 3, FLAG: false, NEGATIVE: -1 }),
+ }), templateOnly());
+ `);
+
+ expect(wireScope(transformed)).toEqual({
+ EMPTY: '"—"',
+ COUNT: '3',
+ FLAG: 'false',
+ NEGATIVE: '-1',
+ });
+ });
+
+ it('keeps literal scope values when emitting hbs target format', async function () {
+ plugins = [[HTMLBarsInlinePrecompile, { targetFormat: 'hbs' }]];
+
+ let transformed = await transform(`
+ import { precompileTemplate } from '@ember/template-compilation';
+ const template = precompileTemplate("{{EMPTY}}", {
+ strictMode: true,
+ scope: () => ({ EMPTY: "—" }),
+ });
+ `);
+
+ expect(transformed).equalCode(`
+ import { precompileTemplate } from '@ember/template-compilation';
+ const template = precompileTemplate("{{EMPTY}}", {
+ strictMode: true,
+ scope: () => ({ EMPTY: "—" }),
+ });
+ `);
+ });
+
+ it('prunes unused literal scope values', async function () {
+ await transform(
+ "import { precompileTemplate } from '@ember/template-compilation';\nvar compiled = precompileTemplate('{{foo}}', { scope: () => ({ foo, unused: 123 }) });"
+ );
+ expect(precompileSpy.mock.lastCall?.at(-1)).toHaveProperty('locals', ['foo']);
+ });
+
it('correctly removes not used scope', async function () {
await transform(`
import { precompileTemplate } from '@ember/template-compilation';
diff --git a/src/expression-parser.ts b/src/expression-parser.ts
index 0f17371..55ce07d 100644
--- a/src/expression-parser.ts
+++ b/src/expression-parser.ts
@@ -1,7 +1,7 @@
import type { NodePath } from '@babel/traverse';
import type * as Babel from '@babel/core';
import type { types as t } from '@babel/core';
-import { ScopeLocals } from './scope-locals.js';
+import { ScopeLocals, type LiteralScopeValue, type SignedNumericLiteral } from './scope-locals.js';
export class ExpressionParser {
constructor(private babel: typeof Babel) {}
@@ -109,9 +109,18 @@ export class ExpressionParser {
res.add(propName, 'this');
break;
default:
- throw path.buildCodeFrameError(
- `Scope objects for \`${invokedName}\` may only contain direct references to in-scope values, e.g. { ${propName} } or { ${propName}: ${propName} }. Found ${value.type}`
- );
+ if (isLiteralScopeValue(this.t, value)) {
+ // A bundler's constant inlining can legally rewrite a reference
+ // to an imported constant into its literal value, turning
+ // `{ EMPTY }` into `{ EMPTY: "" }` before this plugin sees it.
+ // The literal still tells us everything we need: the
+ // template-visible name and the value it resolves to.
+ res.add(propName, value);
+ } else {
+ throw path.buildCodeFrameError(
+ `Scope objects for \`${invokedName}\` may only contain direct references to in-scope values, e.g. { ${propName} } or { ${propName}: ${propName} }, or literal values. Found ${value.type}`
+ );
+ }
}
return res;
},
@@ -252,3 +261,20 @@ function name(node: t.StringLiteral | t.Identifier): string {
return node.name;
}
}
+
+function isSignedNumericLiteral(t: typeof Babel.types, node: t.Node): node is SignedNumericLiteral {
+ return (
+ t.isUnaryExpression(node) &&
+ node.operator === '-' &&
+ (t.isNumericLiteral(node.argument) || t.isBigIntLiteral(node.argument))
+ );
+}
+
+// the value shapes a bundler's constant inlining can substitute for a
+// reference to an imported constant
+function isLiteralScopeValue(t: typeof Babel.types, node: t.Node): node is LiteralScopeValue {
+ if (isSignedNumericLiteral(t, node)) {
+ return true;
+ }
+ return t.isLiteral(node) && !t.isRegExpLiteral(node) && !t.isTemplateLiteral(node);
+}
diff --git a/src/plugin.ts b/src/plugin.ts
index 00dcba6..6e9d1ec 100644
--- a/src/plugin.ts
+++ b/src/plugin.ts
@@ -443,10 +443,18 @@ function remapAndBindIdentifiers(target: NodePath, babel: typeof Babel, scopeLoc
// rewriting it there would drop the name the template refers to.
return;
}
+ let jsValue = scopeLocals.get(path.node.name);
+ if (typeof jsValue !== 'string') {
+ // this identifier's scope entry is a literal value (a bundler's
+ // constant inlining may produce those), so the template compiler
+ // output should contain the literal itself
+ path.replaceWith(babel.types.cloneNode(jsValue, true));
+ return;
+ }
// this identifier has different names in hbs vs js, so we need to
// replace the hbs name in the template compiler output with the js
// name
- path.replaceWith(babel.types.identifier(scopeLocals.get(path.node.name)));
+ path.replaceWith(babel.types.identifier(jsValue));
}
// this is where we tell babel's scope system about the new reference we
// just introduced. @babel/plugin-transform-typescript in particular
@@ -662,8 +670,10 @@ function buildScope(babel: typeof Babel, locals: ScopeLocals) {
t.objectExpression(
locals
.entries()
- .map(([name, identifier]) =>
- t.objectProperty(t.identifier(name), t.identifier(identifier), false, name !== 'this')
+ .map(([name, value]) =>
+ typeof value === 'string'
+ ? t.objectProperty(t.identifier(name), t.identifier(value), false, name !== 'this')
+ : t.objectProperty(t.identifier(name), t.cloneNode(value, true), false, false)
)
)
);
diff --git a/src/scope-locals.ts b/src/scope-locals.ts
index 35daf37..96bcab5 100644
--- a/src/scope-locals.ts
+++ b/src/scope-locals.ts
@@ -8,6 +8,7 @@
*/
import type { NodePath } from '@babel/traverse';
+import type { types as t } from '@babel/core';
import type { ASTPluginEnvironment, NodeVisitor } from '@glimmer/syntax';
import { astNodeHasBinding } from './hbs-utils.js';
import { readOnlyArray } from './read-only-array.js';
@@ -103,12 +104,32 @@ type Params =
mayUseLexicalThis: boolean;
};
+// A negative number is a UnaryExpression rather than a literal, but it's
+// still a constant value with no dependency on any binding.
+export type SignedNumericLiteral = t.UnaryExpression & {
+ operator: '-';
+ argument: t.NumericLiteral | t.BigIntLiteral;
+};
+
+// The constant values a scope entry can hold. Regexes and template literals
+// are deliberately excluded: a regex literal would construct a new (stateful)
+// object on every call, and a template literal can contain expressions that
+// reference bindings we know nothing about.
+export type LiteralScopeValue =
+ | Exclude
+ | SignedNumericLiteral;
+
+// Each hbs name in the template's scope maps to either the name of a JS
+// binding or, when a bundler's constant inlining has replaced the original
+// binding with its statically-known value, that value.
+export type ScopeValue = string | LiteralScopeValue;
+
export class ScopeLocals {
constructor(params: Params) {
this.#params = params;
}
- #mapping: Record = {};
+ #mapping: Record = {};
#locals: string[] = [];
#params: Params;
@@ -123,7 +144,7 @@ export class ScopeLocals {
return key in this.#mapping;
}
- get(key: string): string {
+ get(key: string): ScopeValue {
return this.#mapping[key];
}
@@ -135,16 +156,20 @@ export class ScopeLocals {
return Object.entries(this.#mapping);
}
- add(hbsName: string, jsName?: string) {
- this.#mapping[hbsName] = jsName ?? hbsName;
+ add(hbsName: string, jsValue?: ScopeValue) {
+ this.#mapping[hbsName] = jsValue ?? hbsName;
if (!this.#locals.includes(hbsName)) {
this.#locals.push(hbsName);
}
}
#isInJsScope(hbsName: string, jsPath: NodePath) {
- let jsName = this.#mapping[hbsName] ?? hbsName;
- return ALLOWED_GLOBALS.has(jsName) || jsPath.scope.getBinding(jsName);
+ let jsValue = this.#mapping[hbsName] ?? hbsName;
+ if (typeof jsValue !== 'string') {
+ // a constant value doesn't need any binding
+ return true;
+ }
+ return ALLOWED_GLOBALS.has(jsValue) || jsPath.scope.getBinding(jsValue);
}
// this AST transform discovers all possible upvars in HBS that refer to valid