Skip to content

Commit 98b7365

Browse files
lifeartclaude
andcommitted
fix(gxt-backend): thread strict-mode scope() callback into precompileTemplate
Strict-mode `precompileTemplate(src, { scope: () => ({ Foo }) })` was silently dropping the `scope` thunk: only `scopeValues` reached the compile pipeline, so `<Foo />` fell through to the kebab-case registry lookup and emitted a raw `<foo>` element. Closes the Strict-Mode renderComponent test cluster (~24 testem failures). Three coordinated changes, all in compile.ts: 1. At precompileTemplate entry, invoke `options.scope()` and merge the returned names into `scopeValues`. Filter to: names that actually appear as referenceable identifiers in the template, excluding `on` (GXT's visitor short-circuits `{{on ...}}` syntactically — adding it as a binding regresses the textarea modifier path). A `_templateMayNeedScopeThreading` pre-check keeps internal Input/Textarea templates byte-identical. 2. In `customizeComponentName`, skip the kebab-case lowering when the name is a scope binding so the GXT compiler emits `$_c(Foo, ...)` against the local variable instead of routing through `$_c('foo', ...)`. 3. Extend the dotted-mustache "not in scope" check to consult `scopeValues` keys alongside block params, so `{{data.count}}` no longer throws when `data` is threaded via scope(). Validation: * GXT smoke runner: 333/333. * Strict-Mode runner: 250/255 (was ~232/255 pre-fix). * Textarea / Input / LinkTo runners: green. * testem CI: 789/808 pass, 15 fail, 4 skip (was 765/808 pass, 39 fail). No textarea regression observed. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
1 parent 730cb46 commit 98b7365

1 file changed

Lines changed: 122 additions & 6 deletions

File tree

packages/@ember/-internals/gxt-backend/compile.ts

Lines changed: 122 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11689,6 +11689,61 @@ function _rewriteShadowedBlockKeyword(
1168911689
return { source: out, changed: true };
1169011690
}
1169111691

11692+
/**
11693+
* Quick gate that decides whether a template can possibly resolve any
11694+
* names from a strict-mode `scope()` callback. Returns true if the
11695+
* template has either a PascalCase angle-bracket invocation or any
11696+
* mustache whose head identifier is not `this`, `@`-prefixed, or `on`.
11697+
*
11698+
* For templates that fail this gate (e.g. the internal Input / Textarea
11699+
* single-element template, with only `<input ...>` and `{{on "..."}}`
11700+
* modifiers), the scope() resolution is skipped entirely so the compile
11701+
* path stays byte-identical to the pre-fix behavior.
11702+
*/
11703+
function _templateMayNeedScopeThreading(template: string): boolean {
11704+
// PascalCase tag — `<Foo` or `<Foo.Bar` style. Skip closing `</…`.
11705+
if (/<[A-Z][A-Za-z0-9_:.-]*[\s/>]/.test(template)) return true;
11706+
// Free-identifier mustache that isn't `this`, `@arg`, `on`, or a
11707+
// GXT/Ember built-in we always want to suppress. We just need a single
11708+
// counter-example, so scan with a regex that captures the head and
11709+
// bail on the first hit.
11710+
const re = /\{\{(?:#|\/|!)?\s*([A-Za-z_][A-Za-z0-9_-]*)/g;
11711+
let m: RegExpExecArray | null;
11712+
while ((m = re.exec(template))) {
11713+
const head = m[1];
11714+
if (head === 'this' || head === 'on') continue;
11715+
return true;
11716+
}
11717+
return false;
11718+
}
11719+
11720+
/**
11721+
* Returns true if `name` appears as a referenceable identifier in
11722+
* `template`. Used to filter out scope() entries that the template
11723+
* never references, so we don't bloat the binding set.
11724+
*
11725+
* The match is intentionally permissive — it accepts the name as:
11726+
* * a PascalCase or kebab-case angle-bracket tag (`<Foo`, `<my-foo`)
11727+
* * a path head inside a mustache (`{{name}}`, `{{name.x}}`,
11728+
* `{{#name ...}}`, `{{(name ...)}}`, `{{... name=...}}` — handled
11729+
* by a single word-boundary check)
11730+
* * a value inside a quoted attribute (`attr="{{name}}"`)
11731+
*
11732+
* False positives are acceptable here (a stray comment match would just
11733+
* inject an unused binding); the only goal is to cheaply prune obvious
11734+
* non-references.
11735+
*/
11736+
function _scopeNameAppearsAsReference(template: string, name: string): boolean {
11737+
if (!name) return false;
11738+
const escaped = name.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
11739+
// Either `<Name` (component tag) or a word-boundary occurrence outside
11740+
// a closing `</...>`. The simple word-boundary regex below covers
11741+
// path heads, helper invocations, and attribute values — anything
11742+
// GXT could surface as a binding reference.
11743+
const re = new RegExp(`(<\\s*${escaped}[\\s/>])|\\b${escaped}\\b`, 'm');
11744+
return re.test(template);
11745+
}
11746+
1169211747
/**
1169311748
* Runtime precompileTemplate implementation using GXT runtime compiler
1169411749
* Returns a template factory function that takes an owner and returns a template.
@@ -11702,6 +11757,54 @@ export function precompileTemplate(
1170211757
scopeValues?: Record<string, unknown>;
1170311758
}
1170411759
) {
11760+
// Strict-mode `precompileTemplate(..., { scope: () => ({ Foo, bar }) })`
11761+
// threading: the test-only / RFC strict-mode form passes a `scope`
11762+
// callback that returns the locally-visible bindings. The downstream
11763+
// compile pipeline only consumes `scopeValues`, so when `scope` is
11764+
// present we invoke it and merge any names that are actually referenced
11765+
// by the template into `scopeValues`.
11766+
//
11767+
// Filter rules (kept narrow on purpose to avoid touching textarea / input
11768+
// / classic templates that worked before the threading was added):
11769+
// * Skip `on` — GXT's visitor short-circuits `{{on "evt" cb}}`
11770+
// syntactically. Adding `on` as a binding causes the GXT compiler
11771+
// to emit a variable reference and that breaks the textarea
11772+
// `<input ... {{on "input" ...}} />` modifier path that the Ember
11773+
// <Textarea> component generates internally.
11774+
// * Skip names that don't actually appear as referenceable identifiers
11775+
// in the template — keeps bookkeeping cheap and avoids churn for
11776+
// large scope() payloads.
11777+
// * Skip the whole pass for templates that have no PascalCase tags
11778+
// and no non-`{this,@,on,!}` mustaches — the internal Input/Textarea
11779+
// template is a single `<input ... />` with `{{on "..."}}` modifiers
11780+
// and `@`-args, so `_templateMayNeedScopeThreading` returns false
11781+
// for it and its compile path stays byte-identical.
11782+
if (typeof options?.scope === 'function') {
11783+
let extra: Record<string, unknown> | undefined;
11784+
try {
11785+
const scopeResult = options.scope();
11786+
if (scopeResult && typeof scopeResult === 'object') {
11787+
extra = scopeResult as Record<string, unknown>;
11788+
}
11789+
} catch {
11790+
/* ignore — scope thunk threw, fall back to scopeValues alone */
11791+
}
11792+
if (extra && _templateMayNeedScopeThreading(templateString)) {
11793+
const existing = options.scopeValues || {};
11794+
let mergedScope: Record<string, unknown> | undefined;
11795+
for (const name of Object.keys(extra)) {
11796+
if (name === 'on') continue;
11797+
if (Object.prototype.hasOwnProperty.call(existing, name)) continue;
11798+
if (!_scopeNameAppearsAsReference(templateString, name)) continue;
11799+
if (!mergedScope) mergedScope = { ...existing };
11800+
mergedScope[name] = extra[name];
11801+
}
11802+
if (mergedScope) {
11803+
options = { ...options, scopeValues: mergedScope };
11804+
}
11805+
}
11806+
}
11807+
1170511808
// Pre-transform shadowed block keywords. When `scopeValues` provides a
1170611809
// binding whose name collides with a GXT block keyword (e.g. `each`,
1170711810
// `if`, `unless`, `let`, `with`, `each-in`), GXT's keyword path runs
@@ -11901,15 +12004,18 @@ export function precompileTemplate(
1190112004

1190212005
// Check for dotted-path mustache expressions like {{foo.bar}} where foo is not in scope.
1190312006
// In Ember, these are errors because foo is a free variable path that can't be resolved.
11904-
// Collect block param names first so we don't flag those.
12007+
// Collect block param names first so we don't flag those. Strict-mode
12008+
// bindings (from scope() / scopeValues) also bring the head into scope.
1190512009
{
1190612010
const blockParamNames = findBlockParamNames(transformedTemplate);
12011+
const scopeValueNames = options?.scopeValues ? new Set(Object.keys(options.scopeValues)) : null;
1190712012
for (const { head, tail } of findDottedMustaches(transformedTemplate)) {
11908-
if (head !== 'this' && !blockParamNames.has(head)) {
11909-
throw new Error(
11910-
`You attempted to render a path (\`{{${head}.${tail}}}\`), but ${head} was not in scope`
11911-
);
11912-
}
12013+
if (head === 'this') continue;
12014+
if (blockParamNames.has(head)) continue;
12015+
if (scopeValueNames && scopeValueNames.has(head)) continue;
12016+
throw new Error(
12017+
`You attempted to render a path (\`{{${head}.${tail}}}\`), but ${head} was not in scope`
12018+
);
1191312019
}
1191412020
}
1191512021

@@ -12136,7 +12242,17 @@ export function precompileTemplate(
1213612242
},
1213712243
// Convert PascalCase component names to kebab-case for Ember registry lookup.
1213812244
// This replaces the regex-based transformCapitalizedComponents() pre-processing.
12245+
//
12246+
// Strict-mode threaded bindings (from `scope: () => ({ Foo })`) must NOT
12247+
// be lowered: when `Foo` is in `scopeBindings`, the GXT compiler emits
12248+
// `$_c(Foo, ...)` against the local variable. Lowering the name here
12249+
// would re-route the call through the kebab-case Ember registry lookup
12250+
// (`$_c('foo', ...)` → raw `<foo>` element), which is exactly the bug
12251+
// that breaks the Strict-Mode renderComponent cluster.
1213912252
customizeComponentName: (name: string) => {
12253+
if (scopeBindings.has(name)) {
12254+
return name;
12255+
}
1214012256
return pascalToKebab(name);
1214112257
},
1214212258
});

0 commit comments

Comments
 (0)