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