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 0d5e73bd544..fe6aa035064 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 @@ -211,6 +211,76 @@ moduleFor( this.assertStableRerender(); } + async '@test Can read a `#private` field'() { + await this.renderComponentModule(() => { + return class extends GlimmerishComponent { + static { + template('{{this.#greeting}}', { + component: this, + eval() { + return eval(arguments[0]); + }, + }); + } + + // eslint-disable-next-line no-unused-private-class-members + #greeting = 'Hello, world!'; + }; + }); + + this.assertHTML('Hello, world!'); + this.assertStableRerender(); + } + + async '@test Can read multiple `#private` fields and pass them to a helper'() { + await this.renderComponentModule(() => { + let join = (a: string, b: string) => `${a}, ${b}!`; + + hide(join); + + return class extends GlimmerishComponent { + static { + template('{{join this.#hello this.#world}}', { + component: this, + eval() { + return eval(arguments[0]); + }, + }); + } + + // eslint-disable-next-line no-unused-private-class-members + #hello = 'Hello'; + // eslint-disable-next-line no-unused-private-class-members + #world = 'world'; + }; + }); + + this.assertHTML('Hello, world!'); + this.assertStableRerender(); + } + + async '@test A `#private` field on a base class is reachable from a subclass template'() { + await this.renderComponentModule(() => { + class Base extends GlimmerishComponent { + // eslint-disable-next-line no-unused-private-class-members + #greeting = 'Hello, world!'; + static { + template('{{this.#greeting}}', { + component: this, + eval() { + return eval(arguments[0]); + }, + }); + } + } + + return class Child extends Base {}; + }); + + this.assertHTML('Hello, world!'); + this.assertStableRerender(); + } + async '@test Can use a curried dynamic helper'() { await this.renderComponentModule(() => { let foo = (v: string) => v; diff --git a/packages/@ember/-internals/metal/index.ts b/packages/@ember/-internals/metal/index.ts index 33ac25deb7c..ac260190cde 100644 --- a/packages/@ember/-internals/metal/index.ts +++ b/packages/@ember/-internals/metal/index.ts @@ -21,6 +21,11 @@ export { hasUnknownProperty, } from './lib/property_get'; export { set, _setProp, trySet } from './lib/property_set'; +export { + type PrivateFieldReader, + setPrivateFieldReader, + getPrivateFieldReader, +} from './lib/private_field_reader'; export { objectAt, replace, diff --git a/packages/@ember/-internals/metal/lib/private_field_reader.ts b/packages/@ember/-internals/metal/lib/private_field_reader.ts new file mode 100644 index 00000000000..f2fe5f4d626 --- /dev/null +++ b/packages/@ember/-internals/metal/lib/private_field_reader.ts @@ -0,0 +1,34 @@ +/** + * Per-class registry of "private field readers" — functions that, given an + * instance and a private-field name, return the field's value. + * + * JavaScript private fields can only be reached via the `obj.#name` syntax, + * which is bound at parse time to the lexically-enclosing class. Glimmer's + * path walker, by contrast, sees `{{this.#foo}}` as a generic property + * access (`instance['#foo']`) and finds nothing. To bridge that gap we let a + * class register a single accessor function for itself; the function is + * constructed inside the class body (so its `#name` syntax binds to the + * class's private slots) and is invoked by `_getProp` whenever the property + * walker hits a `#`-prefixed key. + * + * Subclassing works naturally: the lookup walks the prototype chain to find + * the closest registered reader, and JS already guarantees that a parent + * class's reader can read its own private fields on subclass instances. + */ +export type PrivateFieldReader = (instance: object, fieldName: string) => unknown; + +const PRIVATE_FIELD_READERS = new WeakMap(); + +export function setPrivateFieldReader(component: object, reader: PrivateFieldReader): void { + PRIVATE_FIELD_READERS.set(component, reader); +} + +export function getPrivateFieldReader(instance: object): PrivateFieldReader | undefined { + let cls: object | null = (instance as { constructor?: object | null }).constructor ?? null; + while (cls !== null && cls !== Function.prototype) { + let reader = PRIVATE_FIELD_READERS.get(cls); + if (reader !== undefined) return reader; + cls = Object.getPrototypeOf(cls); + } + return undefined; +} diff --git a/packages/@ember/-internals/metal/lib/property_get.ts b/packages/@ember/-internals/metal/lib/property_get.ts index c797bc38589..12bf11362c7 100644 --- a/packages/@ember/-internals/metal/lib/property_get.ts +++ b/packages/@ember/-internals/metal/lib/property_get.ts @@ -8,9 +8,12 @@ import { assert } from '@ember/debug'; import { DEBUG } from '@glimmer/env'; import { consumeTag, isTracking, tagFor, track } from '@glimmer/validator'; import { isPath } from './path_cache'; +import { getPrivateFieldReader } from './private_field_reader'; export const PROXY_CONTENT = Symbol('PROXY_CONTENT'); +const CHAR_HASH = 0x23; // '#' + export let getPossibleMandatoryProxyValue: (obj: object, keyName: string) => any; if (DEBUG) { @@ -104,6 +107,21 @@ export function _getProp(obj: unknown, keyName: string) { let value: unknown; if (typeof obj === 'object' || typeof obj === 'function') { + // `instance['#foo']` doesn't reach a real private slot — private fields + // can only be accessed via `obj.#foo` syntax, which is bound at parse + // time to the lexically-enclosing class. The class registers a reader + // that knows how to walk into its own private slots; we delegate here. + if (typeof keyName === 'string' && keyName.charCodeAt(0) === CHAR_HASH) { + let reader = getPrivateFieldReader(obj); + if (reader !== undefined) { + value = reader(obj, keyName.slice(1)); + if (isTracking()) { + consumeTag(tagFor(obj, keyName)); + } + return value; + } + } + if (DEBUG) { value = getPossibleMandatoryProxyValue(obj, keyName); } else { diff --git a/packages/@ember/template-compiler/lib/plugins/collect-private-fields.ts b/packages/@ember/template-compiler/lib/plugins/collect-private-fields.ts new file mode 100644 index 00000000000..8486a11020e --- /dev/null +++ b/packages/@ember/template-compiler/lib/plugins/collect-private-fields.ts @@ -0,0 +1,31 @@ +import { type AST, type ASTPlugin, isPrivateFieldSegment, privateFieldName } from '@glimmer/syntax'; +import type { EmberASTPluginEnvironment } from '../types'; + +/** + * Walks the template AST and records every private-field segment it sees in + * a `{{this.#field}}`-style path into `env.meta.privateFields`. The host + * (`template()`) wires up the bag, then uses the collected names to build + * one runtime accessor per class via the user's `eval` and registers it + * with `setPrivateFieldReader`. The plugin itself does **not** rewrite the + * AST — `_getProp` handles the `#`-prefixed key at property-walk time. + */ +export default function collectPrivateFields(env: EmberASTPluginEnvironment): ASTPlugin { + let collected = env.meta?.privateFields; + + if (!collected) { + return { name: 'collect-private-fields', visitor: {} }; + } + + return { + name: 'collect-private-fields', + visitor: { + PathExpression(node: AST.PathExpression) { + for (let segment of node.tail) { + if (isPrivateFieldSegment(segment)) { + collected!.add(privateFieldName(segment)); + } + } + }, + }, + }; +} diff --git a/packages/@ember/template-compiler/lib/plugins/index.ts b/packages/@ember/template-compiler/lib/plugins/index.ts index ace6b99f647..59213663298 100644 --- a/packages/@ember/template-compiler/lib/plugins/index.ts +++ b/packages/@ember/template-compiler/lib/plugins/index.ts @@ -2,6 +2,7 @@ import AssertAgainstAttrs from './assert-against-attrs'; import AssertAgainstNamedOutlets from './assert-against-named-outlets'; import AssertInputHelperWithoutBlock from './assert-input-helper-without-block'; import AssertReservedNamedArguments from './assert-reserved-named-arguments'; +import CollectPrivateFields from './collect-private-fields'; import TransformActionSyntax from './transform-action-syntax'; import TransformEachInIntoEach from './transform-each-in-into-each'; import TransformEachTrackArray from './transform-each-track-array'; @@ -17,6 +18,7 @@ export const INTERNAL_PLUGINS = { AssertAgainstNamedOutlets, AssertInputHelperWithoutBlock, AssertReservedNamedArguments, + CollectPrivateFields, TransformActionSyntax, TransformEachInIntoEach, TransformEachTrackArray, @@ -28,6 +30,7 @@ export const INTERNAL_PLUGINS = { // order of plugins is important export const RESOLUTION_MODE_TRANSFORMS = Object.freeze([ + CollectPrivateFields, TransformQuotedBindingsIntoJustBindings, AssertReservedNamedArguments, TransformActionSyntax, @@ -42,6 +45,7 @@ export const RESOLUTION_MODE_TRANSFORMS = Object.freeze([ ]); export const STRICT_MODE_TRANSFORMS = Object.freeze([ + CollectPrivateFields, AutoImportBuiltins, TransformQuotedBindingsIntoJustBindings, AssertReservedNamedArguments, diff --git a/packages/@ember/template-compiler/lib/template.ts b/packages/@ember/template-compiler/lib/template.ts index c0304a8432f..a7b190260b1 100644 --- a/packages/@ember/template-compiler/lib/template.ts +++ b/packages/@ember/template-compiler/lib/template.ts @@ -1,4 +1,8 @@ import templateOnly, { type TemplateOnlyComponent } from '@ember/component/template-only'; +import { + setPrivateFieldReader, + type PrivateFieldReader, +} from '@ember/-internals/metal'; import { precompile as glimmerPrecompile } from '@glimmer/compiler'; import type { SerializedTemplateWithLazyBlock } from '@glimmer/interfaces'; import { setComponentTemplate } from '@glimmer/manager'; @@ -76,22 +80,14 @@ export interface ExplicitTemplateOnlyOptions extends BaseTemplateOptions { * * ## The Scope Function's `instance` Parameter * - * However, the explicit `scope` function in a *class* also takes an `instance` option - * that provides access to the component's instance. + * The explicit `scope` function in a *class* also takes an `instance` + * parameter that provides access to the component's instance. * - * Once it's supported in Handlebars, this will make it possible to represent private - * fields when using the explicit form. - * - * ```ts - * class MyComponent extends Component { - * static { - * template('{{this.#greeting}}, {{@place}}!', - * { component: this }, - * scope: (instance) => ({ '#greeting': instance.#greeting }), - * ); - * } - * } - * ``` + * Note that the explicit form **does not** support `{{this.#field}}` + * references — its scope arrow is evaluated outside the class body, so + * `instance.#field` won't parse against any private slots. Use the + * implicit (`eval`) form when you need to read private fields from a + * template. */ export interface ExplicitClassOptions< C extends ComponentClass, @@ -214,12 +210,19 @@ export type ImplicitTemplateOnlyOptions = BaseTemplateOptions & ImplicitEvalOpti * } * ``` * - * ## Note on Private Fields + * ## Note on Private Fields + * + * The implicit form supports `{{this.#field}}` references natively. At + * compile time `template()` builds a per-class private-field reader using + * the `eval` option (which sits inside the class body and so has lexical + * access to the private slots) and registers it via + * `setPrivateFieldReader`. At render time, when the property walker hits a + * `#`-prefixed segment it routes through that reader instead of doing a + * plain string property access. * - * 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 explicit `scope` form does **not** support private fields, because + * its scope is evaluated outside the class body — there's no way to + * construct an accessor that reaches the private slot. */ export type ImplicitClassOptions = BaseClassTemplateOptions & ImplicitEvalOption; @@ -241,6 +244,13 @@ export function template( const evaluate = buildEvaluator(options); const normalizedOptions = compileOptions(options); + // `collect-private-fields` writes each `{{this.#field}}` segment it finds + // into this set. After precompile we hand the names to the user's `eval` + // to build a single per-class reader; `_getProp` consults it via the + // private-field reader registry whenever the path walker hits a `#`-key. + const privateFields = new Set(); + normalizedOptions.meta!.privateFields = privateFields; + const component = normalizedOptions.component ?? templateOnly(); const source = glimmerPrecompile(templateString, normalizedOptions); @@ -250,9 +260,63 @@ export function template( setComponentTemplate(template, component); + if (privateFields.size > 0) { + registerPrivateFieldReader(component, privateFields, providedOptions); + } + return component; } +function registerPrivateFieldReader( + component: object, + privateFields: ReadonlySet, + providedOptions: BaseTemplateOptions | BaseClassTemplateOptions | undefined +): void { + let userEval = (providedOptions as { eval?: (source: string) => unknown } | undefined)?.eval; + if (!userEval) { + let firstField = privateFields.values().next().value; + throw new Error( + `Template uses private field access (\`{{this.#${firstField}}}\`) but no \`eval\` option was provided. Private fields can only be reached when the template is compiled with the implicit (\`eval\`) form, since that is the only form whose lexical scope reaches into the class body.` + ); + } + + let reader = buildPrivateFieldReader(privateFields, userEval); + setPrivateFieldReader(component, reader); +} + +const PRIVATE_FIELD_NAME = /^[A-Za-z_$][\w$]*$/; + +function buildPrivateFieldReader( + privateFields: ReadonlySet, + userEval: (source: string) => unknown +): PrivateFieldReader { + // We compile a single switch that closes over the class's private slots. + // Because `userEval` is invoked from inside the class's static block, the + // `#field` syntax inside the function body is resolved at parse time + // against the class's private names, and the resulting closure preserves + // that access for every instance handed to it later. + let cases: string[] = []; + for (let field of privateFields) { + if (!PRIVATE_FIELD_NAME.test(field)) { + throw new Error( + `Refusing to compile private-field reader for \`#${field}\` — name is not a valid JS identifier.` + ); + } + cases.push(`case ${JSON.stringify(field)}:return __inst.#${field};`); + } + + let source = `(function(__inst,__name){switch(__name){${cases.join('')}}})`; + let reader = userEval(source); + + if (typeof reader !== 'function') { + throw new Error( + 'Template private-field reader did not compile to a function. The `eval` option must do `return eval(arguments[0])` from inside the class body.' + ); + } + + return reader as PrivateFieldReader; +} + /** * Builds the source wireformat JSON block * diff --git a/packages/@ember/template-compiler/lib/types.ts b/packages/@ember/template-compiler/lib/types.ts index 10309ac8ab9..d83d0272516 100644 --- a/packages/@ember/template-compiler/lib/types.ts +++ b/packages/@ember/template-compiler/lib/types.ts @@ -45,6 +45,16 @@ export interface EmberPrecompileOptions extends Omit emberRuntime?: { lookupKeyword(name: string): string; }; + + /** + * Mutable bag populated by `collect-private-fields` when the host + * (`template()`) wires it up. Each entry is the bare field name (no + * leading `#`) referenced via `{{this.#field}}`-style paths anywhere in + * the template. The host then constructs a single per-class accessor + * function and registers it via `setPrivateFieldReader` so the + * runtime path walker can reach the private slot. + */ + privateFields?: Set; }; /** diff --git a/packages/@ember/template-compiler/tests/template_test.ts b/packages/@ember/template-compiler/tests/template_test.ts index b2d6fcea199..67c0709780d 100644 --- a/packages/@ember/template-compiler/tests/template_test.ts +++ b/packages/@ember/template-compiler/tests/template_test.ts @@ -1,5 +1,6 @@ import { moduleFor, AbstractTestCase } from 'internal-test-helpers'; -import { template } from '@ember/template-compiler'; +import { template } from '@ember/template-compiler/runtime'; +import { getPrivateFieldReader } from '@ember/-internals/metal'; import { getComponentTemplate, getInternalComponentManager } from '@glimmer/manager'; moduleFor( @@ -19,5 +20,61 @@ moduleFor( const internalManager = getInternalComponentManager(component); assert.ok(internalManager, 'manager is not null'); } + + ['@test template() registers a private-field reader for `{{this.#field}}` paths']( + assert: QUnit['assert'] + ) { + class Greeter { + #greeting = 'hello'; + readGreeting() { + return this.#greeting; + } + static { + template('{{this.#greeting}} {{this.#audience}}', { + component: this, + eval() { + return eval(arguments[0]); + }, + }); + } + // eslint-disable-next-line no-unused-private-class-members + #audience = 'world'; + } + + assert.ok(getComponentTemplate(Greeter), 'template is attached to the class'); + + let reader = getPrivateFieldReader(new Greeter()); + assert.strictEqual(typeof reader, 'function', 'a reader is registered for the class'); + assert.strictEqual( + reader!(new Greeter(), 'greeting'), + 'hello', + 'reader reaches the first declared private field' + ); + assert.strictEqual( + reader!(new Greeter(), 'audience'), + 'world', + 'reader reaches the second declared private field' + ); + assert.strictEqual( + new Greeter().readGreeting(), + 'hello', + 'private field is reachable on instances after compilation' + ); + } + + ['@test template() throws when private fields are referenced without an `eval`']( + assert: QUnit['assert'] + ) { + class Broken { + // eslint-disable-next-line no-unused-private-class-members + #greeting = 'hello'; + } + + assert.throws( + () => template('{{this.#greeting}}', { component: Broken }), + /private field access/, + 'a clear error is thrown when no eval option is provided' + ); + } } ); diff --git a/packages/@glimmer/syntax/index.ts b/packages/@glimmer/syntax/index.ts index b762de481b8..575d89377ff 100644 --- a/packages/@glimmer/syntax/index.ts +++ b/packages/@glimmer/syntax/index.ts @@ -31,6 +31,7 @@ export { default as WalkerPath } from './lib/traversal/path'; export { default as traverse } from './lib/traversal/traverse'; export type { NodeVisitor } from './lib/traversal/visitor'; export { default as Walker } from './lib/traversal/walker'; +export { isPrivateFieldSegment, privateFieldName } from './lib/utils'; export type * as ASTv1 from './lib/v1/api'; export { default as builders } from './lib/v1/public-builders'; export { default as visitorKeys } from './lib/v1/visitor-keys'; diff --git a/packages/@glimmer/syntax/lib/utils.ts b/packages/@glimmer/syntax/lib/utils.ts index ec6f6ed06b5..b94ada3bf4f 100644 --- a/packages/@glimmer/syntax/lib/utils.ts +++ b/packages/@glimmer/syntax/lib/utils.ts @@ -49,3 +49,22 @@ export function isUpperCase(tag: string): boolean { export function isLowerCase(tag: string): boolean { return tag[0] === tag[0]?.toLowerCase() && tag[0] !== tag[0]?.toUpperCase(); } + +/** + * A path tail segment that begins with `#` is a JavaScript private field + * reference (e.g. the `#foo` in `{{this.#foo}}`). The Handlebars tokenizer + * already preserves the `#` prefix as part of the segment string; this + * helper just gives consumers a stable predicate so they don't have to + * string-match in their own code. + */ +export function isPrivateFieldSegment(segment: string): boolean { + return segment.charCodeAt(0) === 0x23 /* '#' */; +} + +/** + * Strip the leading `#` from a private-field segment, returning the bare + * field name. Returns the input unchanged if it's not a private segment. + */ +export function privateFieldName(segment: string): string { + return isPrivateFieldSegment(segment) ? segment.slice(1) : segment; +}