diff --git a/packages/@ember/-internals/glimmer/tests/integration/components/runtime-template-compiler-explicit-test.ts b/packages/@ember/-internals/glimmer/tests/integration/components/runtime-template-compiler-explicit-test.ts
index 99dd11c8a8d..5c74f98a722 100644
--- a/packages/@ember/-internals/glimmer/tests/integration/components/runtime-template-compiler-explicit-test.ts
+++ b/packages/@ember/-internals/glimmer/tests/integration/components/runtime-template-compiler-explicit-test.ts
@@ -1,7 +1,7 @@
import { template } from '@ember/template-compiler/runtime';
import { RenderingTestCase, defineSimpleModifier, moduleFor } from 'internal-test-helpers';
import GlimmerishComponent from '../../utils/glimmerish-component';
-import { on } from '@ember/modifier/on';
+import { on } from '@ember/modifier';
import { fn } from '@ember/helper';
moduleFor(
@@ -336,3 +336,141 @@ moduleFor(
}
}
);
+
+moduleFor(
+ 'Strict Mode - Runtime Template Compiler (explicit) - private fields',
+ class extends RenderingTestCase {
+ async '@test Can render a private field value'() {
+ await this.renderComponentModule(() => {
+ class TestComponent extends GlimmerishComponent {
+ // eslint-disable-next-line no-unused-private-class-members
+ #greeting = 'Hello, world!';
+
+ static {
+ template('
{{this.#greeting}}
', {
+ component: this,
+ scope: (instance) => ({
+ '#greeting': instance ? instance.#greeting : undefined,
+ }),
+ });
+ }
+ }
+ return TestComponent;
+ });
+
+ this.assertHTML('Hello, world!
');
+ this.assertStableRerender();
+ }
+
+ async '@test Can render multiple private fields'() {
+ await this.renderComponentModule(() => {
+ class TestComponent extends GlimmerishComponent {
+ // eslint-disable-next-line no-unused-private-class-members
+ #firstName = 'Jane';
+ // eslint-disable-next-line no-unused-private-class-members
+ #lastName = 'Doe';
+
+ static {
+ template('{{this.#firstName}} {{this.#lastName}}
', {
+ component: this,
+ scope: (instance?: InstanceType) => ({
+ '#firstName': instance ? instance.#firstName : undefined,
+ '#lastName': instance ? instance.#lastName : undefined,
+ }),
+ });
+ }
+ }
+ return TestComponent;
+ });
+
+ this.assertHTML('Jane Doe
');
+ this.assertStableRerender();
+ }
+
+ async '@test Can use private field method with on modifier'() {
+ await this.renderComponentModule(() => {
+ class TestComponent extends GlimmerishComponent {
+ // eslint-disable-next-line no-unused-private-class-members
+ #message = 'Hello';
+
+ // eslint-disable-next-line no-unused-private-class-members
+ #updateMessage = () => {
+ this.#message = 'Updated!';
+ };
+
+ static {
+ template('', {
+ component: this,
+ scope: (instance?: InstanceType) => ({
+ on,
+ '#updateMessage': instance ? instance.#updateMessage : undefined,
+ }),
+ });
+ }
+ }
+ return TestComponent;
+ });
+
+ this.assertHTML('');
+ this.assertStableRerender();
+ }
+
+ async '@test Can mix private fields with local scope variables'() {
+ await this.renderComponentModule(() => {
+ let Greeting = template('{{yield}}');
+
+ class TestComponent extends GlimmerishComponent {
+ // eslint-disable-next-line no-unused-private-class-members
+ #name = 'Ember';
+
+ static {
+ template('Hello, {{this.#name}}!', {
+ component: this,
+ scope: (instance?: InstanceType) => ({
+ Greeting,
+ '#name': instance ? instance.#name : undefined,
+ }),
+ });
+ }
+ }
+ return TestComponent;
+ });
+
+ this.assertHTML('Hello, Ember!');
+ this.assertStableRerender();
+ }
+
+ async '@test Can use private field with on modifier and fn helper'(assert: QUnit['assert']) {
+ assert.expect(1);
+
+ await this.renderComponentModule(() => {
+ let checkValue = (value: number) => {
+ assert.equal(value, 42);
+ };
+
+ class TestComponent extends GlimmerishComponent {
+ // eslint-disable-next-line no-unused-private-class-members
+ #secretValue = 42;
+
+ static {
+ template(
+ '',
+ {
+ component: this,
+ scope: (instance?: InstanceType) => ({
+ on,
+ fn,
+ checkValue,
+ '#secretValue': instance ? instance.#secretValue : undefined,
+ }),
+ }
+ );
+ }
+ }
+ return TestComponent;
+ });
+
+ this.click('button');
+ }
+ }
+);
diff --git a/packages/@ember/-internals/glimmer/tests/integration/components/runtime-template-compiler-implicit-test.ts b/packages/@ember/-internals/glimmer/tests/integration/components/runtime-template-compiler-implicit-test.ts
index 5d4e975acc1..b4b186a5db3 100644
--- a/packages/@ember/-internals/glimmer/tests/integration/components/runtime-template-compiler-implicit-test.ts
+++ b/packages/@ember/-internals/glimmer/tests/integration/components/runtime-template-compiler-implicit-test.ts
@@ -463,6 +463,63 @@ moduleFor(
this.assertText('[before]after');
this.assertStableRerender();
}
+
+ async '@test Can access private fields in templates'() {
+ await this.renderComponentModule(() => {
+ return class extends GlimmerishComponent {
+ // eslint-disable-next-line no-unused-private-class-members
+ #count = 0;
+
+ // eslint-disable-next-line no-unused-private-class-members
+ #increment = () => {
+ this.#count++;
+ };
+
+ static {
+ template(
+ 'Count: {{this.#count}}
',
+ {
+ component: this,
+ eval() {
+ return eval(arguments[0]);
+ },
+ }
+ );
+ }
+ };
+ });
+
+ this.assertHTML('Count: 0
');
+ this.assertStableRerender();
+ }
+
+ async '@test Private field methods work with on modifier'() {
+ await this.renderComponentModule(() => {
+ hide(on);
+
+ return class extends GlimmerishComponent {
+ // eslint-disable-next-line no-unused-private-class-members
+ #message = 'Hello';
+
+ // eslint-disable-next-line no-unused-private-class-members
+ #updateMessage = () => {
+ this.#message = 'Updated!';
+ };
+
+ static {
+ template('', {
+ component: this,
+ eval() {
+ return eval(arguments[0]);
+ },
+ });
+ }
+ };
+ });
+
+ this.assertHTML('');
+ this.assertStableRerender();
+ }
}
);
diff --git a/packages/@ember/-internals/metal/lib/property_get.ts b/packages/@ember/-internals/metal/lib/property_get.ts
index ef86dd3b8d4..8e170e888c5 100644
--- a/packages/@ember/-internals/metal/lib/property_get.ts
+++ b/packages/@ember/-internals/metal/lib/property_get.ts
@@ -102,11 +102,36 @@ export function get(obj: unknown, keyName: string): unknown {
return isPath(keyName) ? _getPath(obj, keyName) : _getProp(obj, keyName);
}
+/**
+ * Well-known symbol key for private field getter closures stored on a
+ * component class. Must match the symbol defined in
+ * `@ember/template-compiler/lib/template.ts`.
+ */
+const PRIVATE_FIELD_GETTERS = Symbol.for('ember:private-field-getters');
+
export function _getProp(obj: unknown, keyName: string) {
if (obj == null) {
return;
}
+ // Private field access: use the getter closure stored on the class
+ // constructor instead of bracket notation (which cannot access # fields).
+ if (keyName.length > 0 && keyName[0] === '#') {
+ const getters = (obj as any)?.constructor?.[PRIVATE_FIELD_GETTERS] as
+ | Record unknown>
+ | undefined;
+ if (getters?.[keyName]) {
+ let value = getters[keyName]!(obj as object);
+
+ if (isTracking()) {
+ consumeTag(tagFor(obj, keyName));
+ }
+
+ return value;
+ }
+ return undefined;
+ }
+
let value: unknown;
if (typeof obj === 'object' || typeof obj === 'function') {
diff --git a/packages/@ember/template-compiler/lib/compile-options.ts b/packages/@ember/template-compiler/lib/compile-options.ts
index a5459e412c4..48a2433cbb3 100644
--- a/packages/@ember/template-compiler/lib/compile-options.ts
+++ b/packages/@ember/template-compiler/lib/compile-options.ts
@@ -110,7 +110,19 @@ type Evaluator = (value: string) => unknown;
// https://tc39.es/ecma262/2020/#prod-IdentifierName
const IDENT = /^[\p{ID_Start}$_][\p{ID_Continue}$_\u200C\u200D]*$/u;
+// https://tc39.es/ecma262/#prod-PrivateIdentifier
+const PRIVATE_IDENT = /^#[\p{ID_Start}$_][\p{ID_Continue}$_\u200C\u200D]*$/u;
+
function inScope(variable: string, evaluator: Evaluator): boolean {
+ // Check if it's a private field syntax
+ if (PRIVATE_IDENT.exec(variable)) {
+ // Private fields are always considered "in scope" when referenced in a template
+ // since they are class members, not lexical variables. The actual access check
+ // will happen at runtime when the template accesses `this.#fieldName`.
+ // We just need to ensure they're treated as valid identifiers and passed through.
+ return true;
+ }
+
// If the identifier is not a valid JS identifier, it's definitely not in scope
if (!IDENT.exec(variable)) {
return false;
diff --git a/packages/@ember/template-compiler/lib/template.ts b/packages/@ember/template-compiler/lib/template.ts
index ca83645457e..4022ecbc825 100644
--- a/packages/@ember/template-compiler/lib/template.ts
+++ b/packages/@ember/template-compiler/lib/template.ts
@@ -87,7 +87,7 @@ export interface ExplicitTemplateOnlyOptions extends BaseTemplateOptions {
* static {
* template('{{this.#greeting}}, {{@place}}!',
* { component: this },
- * scope: (instance) => ({ '#greeting': instance.#greeting }),
+ * scope: (instance) => ({ '#greeting': instance ? instance.#greeting : undefined }),
* );
* }
* }
@@ -96,7 +96,7 @@ export interface ExplicitTemplateOnlyOptions extends BaseTemplateOptions {
export interface ExplicitClassOptions<
C extends ComponentClass,
> extends BaseClassTemplateOptions {
- scope(instance?: InstanceType): Record;
+ scope: (instance: InstanceType) => Record;
}
/**
@@ -129,7 +129,7 @@ export interface ExplicitClassOptions<
* ### The Technical Requirements of the `eval` Option
*
* The `eval` function is passed a single parameter that is a JavaScript
- * identifier. This will be extended in the future to support private fields.
+ * identifier or a private field identifier (starting with `#`).
*
* Since keywords in JavaScript are contextual (e.g. `await` and `yield`), the
* parameter might be a keyword. The `@ember/template-compiler/runtime` expects
@@ -214,35 +214,146 @@ export type ImplicitTemplateOnlyOptions = BaseTemplateOptions & ImplicitEvalOpti
* }
* ```
*
- * ## Note on Private Fields
+ * ## Private Fields Support
*
- * The current implementation of `@ember/template-compiler` does not support
- * private fields, but once the Handlebars parser adds support for private field
- * syntax and it's implemented in the Glimmer compiler, the implicit form should
- * be able to support them.
+ * The implicit form now supports private fields. You can reference private
+ * class members in templates using the `this.#fieldName` syntax:
+ *
+ * ```ts
+ * class MyComponent extends Component {
+ * #count = 0;
+ * #increment = () => this.#count++;
+ *
+ * static {
+ * template(
+ * '',
+ * { component: this },
+ * eval() { return arguments[0] }
+ * );
+ * }
+ * }
+ * ```
*/
export type ImplicitClassOptions = BaseClassTemplateOptions &
ImplicitEvalOption;
+/**
+ * Well-known symbol key used to store private field getter closures on a
+ * component class. The getters are created via `eval` inside the class's
+ * `static` block so they retain access to the class's private field brand.
+ *
+ * Shape: `Record unknown>`
+ */
+export const PRIVATE_FIELD_GETTERS = Symbol.for('ember:private-field-getters');
+
+/**
+ * Extract private field names (e.g. `#count`, `#increment`) referenced
+ * via `this.#field` paths in a Handlebars template string.
+ */
+function extractPrivateFields(templateString: string): string[] {
+ const re = /this\.#([\p{ID_Start}$_][\p{ID_Continue}$_\u200C\u200D]*)/gu;
+ const fields = new Set();
+ let match: RegExpExecArray | null;
+ while ((match = re.exec(templateString)) !== null) {
+ fields.add(`#${match[1]}`);
+ }
+ return [...fields];
+}
+
export function template(
templateString: string,
- options?: ExplicitTemplateOnlyOptions | ImplicitTemplateOnlyOptions
+ options?: (ExplicitTemplateOnlyOptions | ImplicitTemplateOnlyOptions) & { component?: never }
): TemplateOnlyComponent;
export function template(
templateString: string,
- options: ExplicitClassOptions | ImplicitClassOptions | BaseClassTemplateOptions
+ options: ExplicitClassOptions
+): C;
+
+export function template(
+ templateString: string,
+ options: (ImplicitClassOptions | BaseClassTemplateOptions & { scope?: never })
): C;
export function template(
templateString: string,
providedOptions?: BaseTemplateOptions | BaseClassTemplateOptions
): object {
const options: EmberPrecompileOptions = { strictMode: true, ...providedOptions };
+
+ const privateFields = extractPrivateFields(templateString);
+
+ // When using the explicit scope form with private fields, the scope
+ // function has the shape:
+ //
+ // (instance) => ({ Section, '#secret': instance?.#secret })
+ //
+ // The optional chaining (`?.`) on the instance parameter is required so
+ // that calling the scope function without an instance (at compile time)
+ // returns `undefined` for private field entries instead of crashing.
+ //
+ // At compile time we use the scope to:
+ // 1. Discover which variables are in lexical scope (non-private entries)
+ // 2. Build an evaluator that can resolve the compiled wire format
+ //
+ // Private fields are not resolved through the evaluator. Instead, at
+ // runtime they are accessed via PRIVATE_FIELD_GETTERS stored on the
+ // component class, which _getProp looks up when it encounters a `#` key.
+ let originalScopeWithInstance: ((instance: any) => Record) | undefined;
+
+ if (privateFields.length > 0 && options.scope && !options.eval && options.component) {
+ originalScopeWithInstance = options.scope as (instance: any) => Record;
+ const origScope = originalScopeWithInstance;
+
+ // Wrap the scope function so that compile-time callers (compileOptions
+ // and buildEvaluator) receive only non-private entries. The private
+ // field names are injected as placeholders so lexicalScope recognises
+ // them as in-scope.
+ options.scope = () => {
+ // Call with no instance — private fields evaluate to `undefined`
+ // thanks to optional chaining in the scope function.
+ const fullScope = origScope(undefined);
+ const safeScope: Record = {};
+ for (const key of Object.keys(fullScope)) {
+ if (key[0] !== '#') {
+ safeScope[key] = fullScope[key];
+ }
+ }
+ // Inject private field names so lexicalScope reports them as in-scope.
+ for (const field of privateFields) {
+ safeScope[field] = true;
+ }
+ return safeScope;
+ };
+ }
+
const evaluate = buildEvaluator(options);
const normalizedOptions = compileOptions(options);
const component = normalizedOptions.component ?? templateOnly();
+ // If the template references private fields (this.#field), create getter
+ // closures that can access the private fields at runtime. These are stored
+ // on the component class and looked up by _getProp.
+ if (privateFields.length > 0) {
+ if (originalScopeWithInstance) {
+ // Explicit form: build getters from the scope(instance) function.
+ const scopeFn = originalScopeWithInstance;
+ const getters: Record unknown> = {};
+ for (const field of privateFields) {
+ getters[field] = (obj: object) => scopeFn(obj)[field];
+ }
+ (component as any)[PRIVATE_FIELD_GETTERS] = getters;
+ } else if (evaluate !== evaluator) {
+ // Implicit (eval) form: generate getter closures via eval.
+ const getterEntries = privateFields.map((f) => `${JSON.stringify(f)}: (obj) => obj.${f}`);
+ const getters = evaluate(`({${getterEntries.join(', ')}})`) as Record<
+ string,
+ (obj: object) => unknown
+ >;
+ (component as any)[PRIVATE_FIELD_GETTERS] = getters;
+ }
+ }
+
const source = glimmerPrecompile(templateString, normalizedOptions);
const template = templateFactory(evaluate(`(${source})`) as SerializedTemplateWithLazyBlock);
@@ -269,10 +380,23 @@ function buildEvaluator(options: Partial | undefined) {
return evaluator;
}
- return (source: string) => {
- const argNames = Object.keys(scope);
- const argValues = Object.values(scope);
+ // Filter out private field entries (#-prefixed keys) — those are not
+ // lexical variables and cannot be used as Function parameter names.
+ // Private fields are handled separately via PRIVATE_FIELD_GETTERS.
+ const argNames: string[] = [];
+ const argValues: unknown[] = [];
+ for (const [key, value] of Object.entries(scope)) {
+ if (key[0] !== '#') {
+ argNames.push(key);
+ argValues.push(value);
+ }
+ }
+ if (argNames.length === 0) {
+ return evaluator;
+ }
+
+ return (source: string) => {
return new Function(...argNames, `return (${source})`)(...argValues);
};
}