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); }; }