Skip to content

Commit 221e8e3

Browse files
lifeartclaude
andcommitted
fix(gxt-backend): unblock strict-mode runtime template compiler this/curried-helper
The GXT-mode `template()` runtime compiler in @ember/template-compiler/runtime had two latent gaps that the Strict-Mode - Runtime Template Compiler suite exercises but the strict-mode `precompileTemplate` scope-merge work did not cover: 1. `Can use \`this\` from explicit scope` — when the user provides `scope: () => ({ this: state })`, the binding was passed verbatim as a `scopeValues.this` entry. The GXT compiler still emitted literal `this.X` references against the rendering context, so the user's `state` object was never reached. Pre-rewrite `{{this.X}}` / `(this.X` / `<this.X` path heads to a non-keyword alias (`__gxtExplicitThis`) and rebind that alias in `scopeValues`, so GXT compiles the access as a normal binding-path lookup. 2. `Can use a curried dynamic helper` (implicit form) — direct `eval()` in the test method's lexical scope was resolving `helper` to a leaked module-level `let helper;` declared by the in-element null-check helper in the same bundle. The implicit form's free-name extractor then bound `helper` as a scope value, suppressing the strict-mode `helper` keyword path. Filter `helper` and `modifier` from `_extractScopeFromEval` so the keyword path always wins for the implicit form. Users who genuinely want to shadow them can do so via the explicit `scope` form. Also defensively strip a stray `this` entry from the implicit form's extracted scope, so a class-form template using `eval()` plus `component: this` cannot accidentally have its rendering context rewritten. Validation: - testem Basic Test: 15 fail -> 12 fail; both target tests now pass. - Smoke 333/333 unchanged. - No regression in any previously-passing test. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
1 parent 420295b commit 221e8e3

1 file changed

Lines changed: 70 additions & 1 deletion

File tree

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

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,15 @@ function _extractScopeFromEval(
290290

291291
// Try to resolve each identifier via eval
292292
for (const name of identifiers) {
293+
// Skip GXT/Ember built-in keywords. These are part of the template
294+
// language (resolved by the compiler/runtime), so the user must opt-in
295+
// to shadowing them via the explicit `scope` form. The implicit form
296+
// can accidentally pick them up via `eval()` when the surrounding
297+
// bundle exposes a same-named binding (e.g. an internal `let helper`
298+
// declared by a sibling Glimmer runtime module).
299+
if (_GXT_KEYWORD_NAMES.has(name)) {
300+
continue;
301+
}
293302
try {
294303
const value = evalFn(name);
295304
if (value !== undefined) {
@@ -303,6 +312,18 @@ function _extractScopeFromEval(
303312
return Object.keys(scope).length > 0 ? scope : (undefined as any);
304313
}
305314

315+
// GXT/Ember strict-mode keywords whose names are reserved for the curried
316+
// helper / modifier syntactic forms (`{{helper foo "x"}}`,
317+
// `{{modifier foo "x"}}`). These are never imported as bindings in
318+
// idiomatic Ember code — the user invokes them as keywords instead — but
319+
// the bundled test runtime can expose same-named module-level `let`
320+
// declarations (e.g. `let helper;` from the in-element-null-check helper
321+
// scaffolding) that direct `eval()` will resolve to. Skipping them in the
322+
// implicit-form scope extraction prevents the leaked binding from silently
323+
// shadowing the keyword. Users who genuinely want to shadow them can do so
324+
// via the explicit `scope: () => ({ helper: myHelper })` form.
325+
const _GXT_KEYWORD_NAMES = new Set<string>(['helper', 'modifier']);
326+
306327
// HBS syntax words that should not be treated as variable references
307328
const _HBS_SYNTAX_WORDS = new Set([
308329
'as',
@@ -385,20 +406,68 @@ export function template(
385406

386407
// Extract scope values from explicit scope() or implicit eval()
387408
let scopeValues: Record<string, unknown> | undefined;
409+
// Track whether `this` was bound by the *explicit* form. The implicit
410+
// `eval()` form can also accidentally resolve `this` to the eval method's
411+
// outer `this`, but in that case the user did NOT intend to override the
412+
// template's rendering context — so we must not rewrite `{{this.X}}` for
413+
// implicit-form templates.
414+
let explicitlyProvidedThis = false;
388415

389416
if ('scope' in gxtOptions && typeof (gxtOptions as any).scope === 'function') {
390417
// Explicit form: scope: () => ({ Foo, bar })
391418
scopeValues = ((gxtOptions as any).scope as () => Record<string, unknown>)();
419+
if (
420+
scopeValues &&
421+
Object.prototype.hasOwnProperty.call(scopeValues, 'this') &&
422+
scopeValues['this'] !== undefined
423+
) {
424+
explicitlyProvidedThis = true;
425+
}
392426
} else if ('eval' in gxtOptions && typeof (gxtOptions as any).eval === 'function') {
393427
// Implicit form: eval() { return eval(arguments[0]) }
394428
// Extract free variable names from the template and resolve them via eval
395429
scopeValues = _extractScopeFromEval(
396430
templateString,
397431
(gxtOptions as any).eval as (v: string) => unknown
398432
);
433+
// Defensive: even if `_extractScopeFromEval` somehow returns `this`,
434+
// strip it. The implicit form must never override the rendering
435+
// context — see the explicit-form branch above for the intended way
436+
// to shadow `this`.
437+
if (scopeValues && Object.prototype.hasOwnProperty.call(scopeValues, 'this')) {
438+
delete (scopeValues as Record<string, unknown>)['this'];
439+
}
440+
}
441+
442+
// Strict-mode `this` shadowing: when the user explicitly provides `this`
443+
// via `scope: () => ({ this: state })`, the GXT compiler would otherwise
444+
// emit a literal `this.X` reference for `{{this.X}}` — which picks up
445+
// the rendering context, not the user's value. Rewrite the path head to
446+
// a non-`this` alias and rebind it in scopeValues so the GXT compiler
447+
// emits a normal binding-path lookup.
448+
let processedTemplate = templateString;
449+
if (explicitlyProvidedThis && scopeValues) {
450+
const explicitThisAlias = '__gxtExplicitThis';
451+
// Avoid clobbering an existing alias — extremely unlikely, but guard
452+
// against pathological user-supplied scopes.
453+
if (!Object.prototype.hasOwnProperty.call(scopeValues, explicitThisAlias)) {
454+
// Replace path heads in the template:
455+
// {{this.X}} → {{__gxtExplicitThis.X}}
456+
// {{this}} → {{__gxtExplicitThis}} (rare but safe)
457+
// (this.X → (__gxtExplicitThis.X
458+
// <this.X → <__gxtExplicitThis.X
459+
// The substitution is purely textual but only inside expression
460+
// positions: a leading `{{`, `(`, or `<` must precede the `this`.
461+
processedTemplate = processedTemplate.replace(
462+
/(\{\{[#/!]?\s*|\(\s*|<\s*)this\b/g,
463+
(_full, prefix: string) => `${prefix}${explicitThisAlias}`
464+
);
465+
scopeValues = { ...scopeValues, [explicitThisAlias]: scopeValues['this'] };
466+
delete (scopeValues as Record<string, unknown>)['this'];
467+
}
399468
}
400469

401-
const gxtTemplate = gxtCompile(templateString, {
470+
const gxtTemplate = gxtCompile(processedTemplate, {
402471
moduleName: (gxtOptions as any).moduleName,
403472
strictMode: true,
404473
scopeValues,

0 commit comments

Comments
 (0)