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