Skip to content

Accept any expression as a scope value - #125

Open
NullVoxPopuli-ai-agent wants to merge 7 commits into
emberjs:mainfrom
NullVoxPopuli-ai-agent:nvp/literal-scope-values
Open

NullVoxPopuli-ai-agent wants to merge 7 commits into
emberjs:mainfrom
NullVoxPopuli-ai-agent:nvp/literal-scope-values

Conversation

@NullVoxPopuli-ai-agent

@NullVoxPopuli-ai-agent NullVoxPopuli-ai-agent commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

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 from

import { EMPTY } from './constants.js';
precompileTemplate('{{EMPTY}}', { strictMode: true, scope: () => ({ EMPTY }) });

to

precompileTemplate('{{EMPTY}}', { strictMode: true, scope: () => ({ EMPTY: "—" }) });

On main (4.0.1), parseScope rejects this in the consuming app with "Scope objects for precompileTemplate may only contain direct references to in-scope values … Found StringLiteral".

What #120 fixed, and what this PR adds

#116 has two parts:

  1. Scope object keys were renamed together with their values. When the compiler emits the scope as an object, { Foo: Setup } became { Setup }, and the template lost the name Foo. Don't remap scope locals in non-reference positions #120 fixed this and shipped in 4.0.1.
  2. parseScope accepts only identifiers and this as 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.

  • parseScope passes every expression to ScopeLocals. Only destructuring patterns are rejected.
  • ScopeLocals maps each template name to a t.Expression. A reference is an Identifier, and this is a ThisExpression. The parser, buildScope and the remap each have one path for all values.
  • For the wire format, the compiler output gets a copy of the expression in place of the identifier: scope: () => ({ EMPTY: "—" }).
  • For targetFormat: 'hbs', buildScope writes the expression back into the scope object, so a later wire compile accepts the output again.
  • The remap skips each replacement. Its names are JS names, not hbs names. Without the skip, { a: f(a) } overflows the stack.

Two behaviors to know about:

  • Unused entries are pruned, as before for identifiers. An unused { x: track() } is removed, so its side effect does not happen.
  • The plugin writes its own arrow function for the scope. If the source scope is a function or a method, this and arguments inside a value can change meaning. This was already true for { foo: this }.

#126 fixes a related bug that main has 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:

  • the constant shapes that bundlers and minifiers emit, plus null and BigInt values
  • other expressions: a call, a template literal with an expression, a regex, -foo, and a member expression
  • no remap inside a copied expression
  • an import that only a scope expression uses, with @babel/plugin-transform-typescript
  • pruning of an unused expression
  • references and literals in one scope, with an unused entry between them, so each name keeps its value in the array form
  • a literal value that has the same name as a JS binding
  • the explicit form of template() from @ember/template-compiler, in both target formats

The wireScope() test helper named array-form entries in locals order. 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-source and on 6.4. On 5.12 and 3.28 with NO_LEXICAL_THIS=true, 136 pass and 4 are skipped. tsc --noEmit and pnpm lint also 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 from ember-cli new. In the app, an addon dist file 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

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>
@NullVoxPopuli
NullVoxPopuli requested a review from ef4 October 16, 2026 19:11
@NullVoxPopuli NullVoxPopuli added the bug Something isn't working label Oct 16, 2026
@NullVoxPopuli

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>
@NullVoxPopuli

Copy link
Copy Markdown
Contributor

Demo:

input
import { 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 
  }),
});
      
output
import { 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

NullVoxPopuli and others added 2 commits October 20, 2026 11:43
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>
@NullVoxPopuli-ai-agent NullVoxPopuli-ai-agent changed the title Accept literal scope values produced by bundler constant inlining Accept any expression as a scope value Oct 20, 2026
Comment thread src/scope-locals.ts Outdated
NullVoxPopuli and others added 2 commits October 20, 2026 13:39
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>
Comment thread src/plugin.ts Outdated
Comment thread src/plugin.ts Outdated
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

3 participants