Repository navigation
Accept any expression as a scope value - #125
Open
NullVoxPopuli-ai-agent wants to merge 7 commits into
Open
NullVoxPopuli-ai-agent wants to merge 7 commits into
NullVoxPopuli-ai-agent wants to merge 7 commits into
Conversation
A bundler's constant inlining (e.g. rolldown's default
optimization.inlineConst) can legally rewrite
import { EMPTY } from './constants.js';
precompileTemplate('{{EMPTY}}', { scope: () => ({ EMPTY }) })
into
precompileTemplate('{{EMPTY}}', { scope: () => ({ EMPTY: "-" }) })
before this plugin ever sees the module, which parseScope rejected with
"may only contain direct references to in-scope values". This is the
literal-value analog of the renamed-identifier case fixed in 2.0.2: the
scope property still carries everything the compiler needs, the
template-visible name and the value it resolves to.
parseScope now accepts literal values (string, number, boolean, null,
bigint, and negative numbers), ScopeLocals carries them as expressions
alongside plain identifier names, wire-format output emits the literal
directly into the scope array, and hbs-format output re-emits it in the
rebuilt scope object.
Fixes emberjs#116
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This comment was marked as low quality.
This comment was marked as low quality.
Rolldown inlines `undefined` as `void 0`, also without minification. After minification, `true` and `false` become `!0` and `!1`, and a string can become a template literal without expressions. The scope parser rejected all three, so these builds still failed. The parser now also accepts `void`, `!` and `-` applied to a numeric or BigInt literal, and a template literal without expressions. A template literal with expressions is still an error. New tests: - each of these shapes, plus `null` and BigInt values - `-foo`, `!foo` and `void foo` are still errors - references and literals mixed in one scope, with an unused entry between them, keep the correct value for each name - a literal value wins over a JS binding with the same name - the explicit form of `template()`, in wire and hbs target formats - the pruning test also checks the emitted scope The `wireScope()` test helper named array-form entries in `locals` order. The compiler orders them by first use in the template, so the mixed test failed on Ember versions that emit the array form. The helper now reads the names from the compiler output. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Contributor
|
Demo:
inputimport { template } from '@ember/template-compiler';
export default template(`
<pre>
void 0:{{U}}
!0: {{T}}
!1: {{F}}
'-': {{S}}
null: {{NUL}}
10n: {{BIG}}
-10n: {{NEG_BIG}}
</pre>`, {
strictMode: true,
scope: () => ({
U: void 0,
T: !0,
F: !1,
S: `—`,
NUL: null,
BIG: 10n,
NEG_BIG: -10n
}),
});
outputimport { setComponentTemplate } from /*"@ember/component"*/'blob:https://nvp-literal-scope-values-pat.limber-glimdown.pages.dev/9e5ef27b-b1f9-4013-a23f-4f87dd8a85f5';
import { createTemplateFactory } from /*"@ember/template-factory"*/'blob:https://nvp-literal-scope-values-pat.limber-glimdown.pages.dev/b59faa1f-e57d-48ee-8ddc-9f8eacc45b77';
import templateOnly from /*"@ember/component/template-only"*/'blob:https://nvp-literal-scope-values-pat.limber-glimdown.pages.dev/91637849-7312-462b-bdae-6a7b825a90e3';
export default setComponentTemplate(createTemplateFactory(
/*
<pre>
void 0:{{U}}
!0: {{T}}
!1: {{F}}
'-': {{S}}
null: {{NUL}}
10n: {{BIG}}
-10n: {{NEG_BIG}}
</pre>
*/
{
"id": null,
"block": "[[[1,\"\\n \"],[10,\"pre\"],[12],[1,\" void 0:\"],[1,[32,0]],[1,\" !0: \"],[1,[32,1]],[1,\" !1: \"],[1,[32,2]],[1,\" '-': \"],[1,[32,3]],[1,\" null: \"],[1,[32,4]],[1,\" 10n: \"],[1,[32,5]],[1,\" -10n: \"],[1,[32,6]],[1,\" \"],[13]],[],[]]",
"moduleName": "(unknown template module)",
"scope": () => ({
U: void 0,
T: !0,
F: !1,
S: `—`,
NUL: null,
BIG: 10n,
NEG_BIG: -10n
}),
"isStrictMode": true
}), templateOnly());
//# sourceURL=file:///src/index.gjs?v=1 |
Reviewers asked why a scope value can be a string. The doc comment on ScopeValue now lists every caller of ScopeLocals.add: the explicit form, the implicit form, and the jsutils binding methods. Only a literal in the explicit form gives a node. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The explicit form now accepts any expression in the scope object, not
only the constant shapes that bundlers and minifiers emit. The plugin
puts a copy of the expression where the compiler output refers to the
name. This removes the allow-list of literal shapes and its types.
The copied expression contains JS names, not hbs names, so the remap
must not visit it again. Without the skip, `{ a: f(a) }` overflows
the stack.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ef4
reviewed
Oct 20, 2026
An identifier is also an expression, so ScopeLocals no longer keeps references as strings. A reference is an Identifier node, and `this` is a ThisExpression. buildScope, the parser, and the remap now have one path each. The remap skips every replacement, not only non-identifiers, so a renamed local is not remapped a second time. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@babel/types 7.28 added VoidPattern to PatternLike. The case list did not include it, so with floating dependencies the default branch was no longer narrowed to Expression and tsc failed in `pnpm install`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ef4
reviewed
Oct 20, 2026
An identifier that keeps its name already refers to its binding, so only a replacement needs new references. The replacement can be any expression, so every referenced identifier inside it is bound. This removes the early return for non-identifier replacements. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #116. This replaces #117. GitHub closed #117 when #120 merged, because #117 used the #120 branch as its base and that branch was deleted.
A bundler that inlines constants (for example, rolldown with its default
optimization.inlineConst) can rewrite a published module fromto
On
main(4.0.1),parseScoperejects this in the consuming app with "Scope objects forprecompileTemplatemay only contain direct references to in-scope values … Found StringLiteral".What #120 fixed, and what this PR adds
#116 has two parts:
{ Foo: Setup }became{ Setup }, and the template lost the nameFoo. Don't remap scope locals in non-reference positions #120 fixed this and shipped in 4.0.1.parseScopeaccepts only identifiers andthisas scope values. A literal value that a bundler substitutes still fails the build. This PR fixes that part.Part 1 is necessary for part 2. A value only works if its key stays the template name.
Changes
The explicit form now accepts any expression as a scope value, not only an identifier or
this.An earlier version of this PR accepted only the constant shapes that bundlers and minifiers emit:
-1,void 0,!0,`—`and so on. That allow-list was most of the diff, and each new minifier shape needed another case. Any expression covers all of them with less code.parseScopepasses every expression toScopeLocals. Only destructuring patterns are rejected.ScopeLocalsmaps each template name to at.Expression. A reference is anIdentifier, andthisis aThisExpression. The parser,buildScopeand the remap each have one path for all values.scope: () => ({ EMPTY: "—" }).targetFormat: 'hbs',buildScopewrites the expression back into the scope object, so a later wire compile accepts the output again.{ a: f(a) }overflows the stack.Two behaviors to know about:
{ x: track() }is removed, so its side effect does not happen.functionor a method,thisandargumentsinside a value can change meaning. This was already true for{ foo: this }.#126 fixes a related bug that
mainhas for plain renames ({ a: b, b: c }and{ a: b, b: a }). The skip in this PR fixes that bug too, because a rename is now one more expression replacement. #126 has the tests for it.Tests
The new tests cover:
nulland BigInt values-foo, and a member expression@babel/plugin-transform-typescripttemplate()from@ember/template-compiler, in both target formatsThe
wireScope()test helper named array-form entries inlocalsorder. The compiler orders them by first use in the template, so the helper now reads the names from the compiler output.All 140 tests pass on the default
ember-sourceand on 6.4. On 5.12 and 3.28 withNO_LEXICAL_THIS=true, 136 pass and 4 are skipped.tsc --noEmitandpnpm lintalso pass.With the earlier literal-only version, I also checked the build in ember.js
main(all browser tests pass) and in a new app fromember-cli new. In the app, an addondistfile with inlined constants builds and renders. With 4.0.1, the same app fails to build. I did not repeat these two checks for this version.🤖 Generated with Claude Code