Skip to content

Commit 9d4544e

Browse files
NullVoxPopuliclaude
andcommitted
Share options.locals by reference so scope.crawl mutations are visible
This fixes two smoke-test failures from commit 571a94d: {{fn}} as keyword (but it is shadowed): it works {{on}} as keyword (but it is shadowed): it works These `.gjs` tests declare a JS-level lexical (`const fn = ...` or `const on = ...`) and expect the template reference to resolve to the shadowing local, not the built-in keyword. They passed on main but regressed on the PR because of how `preprocess` wired `template.blockParams`. ## Root cause `babel-plugin-ember-template-compilation` passes `options.locals` as a live `readOnlyArray` proxy over its internal `ScopeLocals` array. The plugin order at precompile time is, roughly: [embroider macros, scope.crawl, AutoImportBuiltins, ...other ember strict-mode transforms] `scope.crawl` walks the template, detects `PathExpression` upvars that have matching JS-level bindings (via `astNodeHasBinding` and Babel scope lookup), and calls `scope.add(name)` for each — mutating the underlying locals array. When it runs *before* `AutoImportBuiltins`, its mutations need to be visible through `template.blockParams` so that `AutoImportBuiltins`' `trackLocals.hasLocal('fn')` returns `true` for a shadowed lexical and skips the rewrite. Main achieves this by passing `options.locals` directly to `b.template({ blockParams: options.locals, ... })` in `handlebars-node-visitors.ts`. The assignment is by reference: the Template literally points at the proxy, and later reads see the latest state of the underlying array in real time. The PR's preprocess was instead doing: template.blockParams = [...options.locals]; That takes a *snapshot* of the proxy at preprocess-start time (when it is still empty). When `scope.crawl` later mutates the underlying array, `template.blockParams` stays frozen at `[]`. `AutoImportBuiltins` then sees an empty locals table, fails to detect the shadow, calls `jsutils.bindImport('fn')` which — because the JS scope has `const fn` — imports `@ember/helper#fn` under a renamed local like `fn0`, and rewrites `node.path.original = 'fn0'`. The `buildLegacyPath` setter rebuilds `head` to `VarHead{ name: 'fn0' }`, the v2 normalizer resolves `fn0` as a lexical, and the runtime ends up calling the imported helper instead of the shadowing `const fn`. Hence the failure. ## Fix Assign `options.locals` to `template.blockParams` by reference, matching main's behavior. The post-plugin "refresh" block is no longer needed since the live proxy already tracks mutations. Also drops the `parts` accessor cast-free behavior: the narrowing uses `if (options.locals)` rather than a length check, so an empty array still points at the proxy and picks up later mutations. ## Verified - Smoke tests: `{{fn}}/{{on}} as keyword (but it is shadowed)` — pass in all 3 classic-basics scenarios (19/19 tests, 0 fail × 3). - Browser test suite: 9138/9138 pass. - Lint, prettier, build:types, type-check:internals — all clean. - `auto-import-builtins.ts` unchanged from main. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
1 parent 571a94d commit 9d4544e

3 files changed

Lines changed: 31 additions & 35 deletions

File tree

packages/@ember/template-compiler/lib/plugins/auto-import-builtins.ts

Lines changed: 1 addition & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -51,24 +51,7 @@ function rewriteKeyword(
5151
name,
5252
});
5353
} else if (env.meta?.emberRuntime) {
54-
const rewritten = env.meta.emberRuntime.lookupKeyword(name);
55-
node.path.original = rewritten;
56-
// Also update head and tail so the v2 normalizer resolves correctly.
57-
// e.g., "__ember_keywords__.on" → head="__ember_keywords__", tail=["on"]
58-
// (The Peggy grammar emits PathExpression nodes as plain objects where
59-
// `original`, `head`, and `tail` are independent data properties, so we
60-
// need to keep them in sync by hand here.)
61-
const parts = rewritten.split('.');
62-
if (parts.length >= 2) {
63-
const headName = parts[0] as string;
64-
node.path.head = {
65-
type: 'VarHead',
66-
name: headName,
67-
original: headName,
68-
loc: node.path.head.loc,
69-
} as AST.VarHead;
70-
node.path.tail = parts.slice(1);
71-
}
54+
node.path.original = env.meta.emberRuntime.lookupKeyword(name);
7255
}
7356
}
7457

packages/@ember/template-compiler/lib/plugins/utils.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import type { AST } from '@glimmer/syntax';
22
import type { EmberASTPluginEnvironment } from '../types';
33

4-
export function isPath(node: AST.Node | null | undefined): node is AST.PathExpression {
5-
return node != null && node.type === 'PathExpression';
4+
export function isPath(node: AST.Node): node is AST.PathExpression {
5+
return node.type === 'PathExpression';
66
}
77

8-
export function isSubExpression(node: AST.Node | null | undefined): node is AST.SubExpression {
9-
return node != null && node.type === 'SubExpression';
8+
export function isSubExpression(node: AST.Node): node is AST.SubExpression {
9+
return node.type === 'SubExpression';
1010
}
1111

1212
export function isStringLiteral(node: AST.Expression): node is AST.StringLiteral {

packages/@glimmer/syntax/lib/parser/tokenizer-event-handlers.ts

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import * as src from '../source/api';
1111
import { generateSyntaxError } from '../syntax-error';
1212
import traverse from '../traversal/traverse';
1313
import Walker from '../traversal/walker';
14+
import { buildLegacyPath } from '../v1/legacy-interop';
1415
import publicBuilder from '../v1/public-builders';
1516

1617
// ============================================================================
@@ -472,6 +473,8 @@ function isPeggySpan(
472473

473474
/**
474475
* Recursively convert plain {start, end} location objects to SourceSpan instances.
476+
* PathExpression nodes are upgraded via buildLegacyPath so that setting
477+
* `node.original` propagates to `head`/`tail`.
475478
*/
476479
// eslint-disable-next-line @typescript-eslint/no-explicit-any
477480
function convertLocations(node: any, source: src.Source): any {
@@ -485,6 +488,7 @@ function convertLocations(node: any, source: src.Source): any {
485488
let result: any = {};
486489

487490
for (let key of Object.keys(node)) {
491+
if (node.type === 'PathExpression' && key === 'original') continue;
488492
let val = node[key];
489493
if ((key === 'loc' || key === 'openTag' || key === 'closeTag') && isPeggySpan(val)) {
490494
result[key] = peggySpanToSourceSpan(source, val);
@@ -495,6 +499,14 @@ function convertLocations(node: any, source: src.Source): any {
495499
}
496500
}
497501

502+
if (result.type === 'PathExpression') {
503+
return buildLegacyPath({
504+
head: result.head,
505+
tail: result.tail,
506+
loc: result.loc,
507+
});
508+
}
509+
498510
return result;
499511
}
500512

@@ -730,13 +742,20 @@ export function preprocess(
730742
stripStandalone(template.body);
731743
}
732744

733-
// Pre-seed template.blockParams from options.locals so that AST plugins
734-
// (e.g. transform-each-track-array) can see lexical scope variables via
735-
// trackLocals. The scope crawl plugin from babel-plugin-ember-template-
736-
// compilation may add more locals during execution, so we refresh again
737-
// after all plugins have run.
738-
if (options.locals && options.locals.length > 0) {
739-
template.blockParams = [...options.locals];
745+
// Point template.blockParams at options.locals BY REFERENCE, not by copy.
746+
//
747+
// babel-plugin-ember-template-compilation passes `options.locals` as a
748+
// readOnlyArray proxy over its internal ScopeLocals array. As plugins run
749+
// (notably `scope.crawl` from the babel plugin), they mutate that underlying
750+
// array via `scope.add(name)`. Because the proxy is live, template.blockParams
751+
// automatically reflects those mutations — so by the time later plugins like
752+
// auto-import-builtins read `template.blockParams` in their trackLocals pass,
753+
// they correctly see the lexical scope variables that were discovered by
754+
// earlier plugins. If we copy here (e.g. `[...options.locals]`), we freeze
755+
// the snapshot at preprocess-start time and auto-import-builtins ends up
756+
// rewriting names that should have been recognized as shadowed locals.
757+
if (options.locals) {
758+
template.blockParams = options.locals;
740759
}
741760

742761
// Run AST plugins
@@ -748,11 +767,5 @@ export function preprocess(
748767
}
749768
}
750769

751-
// Refresh blockParams from options.locals after plugins run — the scope
752-
// crawl plugin may have populated additional locals during execution.
753-
if (options.locals && options.locals.length > 0) {
754-
template.blockParams = [...options.locals];
755-
}
756-
757770
return template;
758771
}

0 commit comments

Comments
 (0)