Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions packages/@ember/-internals/metal/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
34 changes: 34 additions & 0 deletions packages/@ember/-internals/metal/lib/private_field_reader.ts
Original file line number Diff line number Diff line change
@@ -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<object, PrivateFieldReader>();

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;
}
18 changes: 18 additions & 0 deletions packages/@ember/-internals/metal/lib/property_get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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));
}
}
},
},
};
}
4 changes: 4 additions & 0 deletions packages/@ember/template-compiler/lib/plugins/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -17,6 +18,7 @@ export const INTERNAL_PLUGINS = {
AssertAgainstNamedOutlets,
AssertInputHelperWithoutBlock,
AssertReservedNamedArguments,
CollectPrivateFields,
TransformActionSyntax,
TransformEachInIntoEach,
TransformEachTrackArray,
Expand All @@ -28,6 +30,7 @@ export const INTERNAL_PLUGINS = {

// order of plugins is important
export const RESOLUTION_MODE_TRANSFORMS = Object.freeze([
CollectPrivateFields,
TransformQuotedBindingsIntoJustBindings,
AssertReservedNamedArguments,
TransformActionSyntax,
Expand All @@ -42,6 +45,7 @@ export const RESOLUTION_MODE_TRANSFORMS = Object.freeze([
]);

export const STRICT_MODE_TRANSFORMS = Object.freeze([
CollectPrivateFields,
AutoImportBuiltins,
TransformQuotedBindingsIntoJustBindings,
AssertReservedNamedArguments,
Expand Down
104 changes: 84 additions & 20 deletions packages/@ember/template-compiler/lib/template.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<C extends ComponentClass> = BaseClassTemplateOptions<C> &
ImplicitEvalOption;
Expand All @@ -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<string>();
normalizedOptions.meta!.privateFields = privateFields;

const component = normalizedOptions.component ?? templateOnly();

const source = glimmerPrecompile(templateString, normalizedOptions);
Expand All @@ -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<string>,
providedOptions: BaseTemplateOptions | BaseClassTemplateOptions<any> | 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<string>,
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
*
Expand Down
10 changes: 10 additions & 0 deletions packages/@ember/template-compiler/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,16 @@ export interface EmberPrecompileOptions extends Omit<PrecompileOptions, 'meta'>
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<string>;
};

/**
Expand Down
Loading
Loading