Skip to content
Open
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
75 changes: 72 additions & 3 deletions __tests__/all.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("<span>{{EMPTY}}{{COUNT}}{{FLAG}}{{NEGATIVE}}</span>", {
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("<span>{{EMPTY}}</span>", {
strictMode: true,
scope: () => ({ EMPTY: "—" }),
});
`);

expect(transformed).equalCode(`
import { precompileTemplate } from '@ember/template-compilation';
const template = precompileTemplate("<span>{{EMPTY}}</span>", {
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';
Expand Down
34 changes: 30 additions & 4 deletions src/expression-parser.ts
Original file line number Diff line number Diff line change
@@ -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) {}
Expand Down Expand Up @@ -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;
},
Expand Down Expand Up @@ -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);
}
16 changes: 13 additions & 3 deletions src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
)
)
);
Expand Down
37 changes: 31 additions & 6 deletions src/scope-locals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<t.Literal, t.RegExpLiteral | t.TemplateLiteral>
| 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<string, string> = {};
#mapping: Record<string, ScopeValue> = {};
#locals: string[] = [];
#params: Params;

Expand All @@ -123,7 +144,7 @@ export class ScopeLocals {
return key in this.#mapping;
}

get(key: string): string {
get(key: string): ScopeValue {
return this.#mapping[key];
}

Expand All @@ -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
Expand Down
Loading