Skip to content

Commit 871bac1

Browse files
committed
Refactor a smidge
1 parent cb878ec commit 871bac1

File tree

4 files changed

+100
-138
lines changed

4 files changed

+100
-138
lines changed

packages/@ember/-internals/glimmer/tests/integration/components/runtime-template-compiler-explicit-test.ts

Lines changed: 10 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -343,7 +343,6 @@ moduleFor(
343343
async '@test Can render a private field value'() {
344344
await this.renderComponentModule(() => {
345345
class TestComponent extends GlimmerishComponent {
346-
// eslint-disable-next-line no-unused-private-class-members
347346
#greeting = 'Hello, world!';
348347

349348
static {
@@ -365,9 +364,8 @@ moduleFor(
365364
async '@test Can render multiple private fields'() {
366365
await this.renderComponentModule(() => {
367366
class TestComponent extends GlimmerishComponent {
368-
// eslint-disable-next-line no-unused-private-class-members
369367
#firstName = 'Jane';
370-
// eslint-disable-next-line no-unused-private-class-members
368+
371369
#lastName = 'Doe';
372370

373371
static {
@@ -393,7 +391,6 @@ moduleFor(
393391
// eslint-disable-next-line no-unused-private-class-members
394392
#message = 'Hello';
395393

396-
// eslint-disable-next-line no-unused-private-class-members
397394
#updateMessage = () => {
398395
this.#message = 'Updated!';
399396
};
@@ -420,7 +417,6 @@ moduleFor(
420417
let Greeting = template('<span>{{yield}}</span>');
421418

422419
class TestComponent extends GlimmerishComponent {
423-
// eslint-disable-next-line no-unused-private-class-members
424420
#name = 'Ember';
425421

426422
static {
@@ -449,22 +445,18 @@ moduleFor(
449445
};
450446

451447
class TestComponent extends GlimmerishComponent {
452-
// eslint-disable-next-line no-unused-private-class-members
453448
#secretValue = 42;
454449

455450
static {
456-
template(
457-
'<button {{on "click" (fn checkValue this.#secretValue)}}>Click</button>',
458-
{
459-
component: this,
460-
scope: (instance?: InstanceType<typeof TestComponent>) => ({
461-
on,
462-
fn,
463-
checkValue,
464-
'#secretValue': instance ? instance.#secretValue : undefined,
465-
}),
466-
}
467-
);
451+
template('<button {{on "click" (fn checkValue this.#secretValue)}}>Click</button>', {
452+
component: this,
453+
scope: (instance?: InstanceType<typeof TestComponent>) => ({
454+
on,
455+
fn,
456+
checkValue,
457+
'#secretValue': instance ? instance.#secretValue : undefined,
458+
}),
459+
});
468460
}
469461
}
470462
return TestComponent;

packages/@ember/-internals/metal/lib/property_get.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ export function get(obj: unknown, keyName: string): unknown {
104104

105105
/**
106106
* Well-known symbol key for private field getter closures stored on a
107-
* component class. Must match the symbol defined in
107+
* component class. Must match the symbol in
108108
* `@ember/template-compiler/lib/template.ts`.
109109
*/
110110
const PRIVATE_FIELD_GETTERS = Symbol.for('ember:private-field-getters');
@@ -114,21 +114,24 @@ export function _getProp(obj: unknown, keyName: string) {
114114
return;
115115
}
116116

117-
// Private field access: use the getter closure stored on the class
118-
// constructor instead of bracket notation (which cannot access # fields).
117+
// Private field access: look up getter closures stored on the class
118+
// constructor. Private fields cannot be accessed via bracket notation,
119+
// so we rely on closures created inside the class's static block.
119120
if (keyName.length > 0 && keyName[0] === '#') {
120121
const getters = (obj as any)?.constructor?.[PRIVATE_FIELD_GETTERS] as
121122
| Record<string, (obj: object) => unknown>
122123
| undefined;
124+
123125
if (getters?.[keyName]) {
124-
let value = getters[keyName]!(obj as object);
126+
const value = getters[keyName]!(obj as object);
125127

126128
if (isTracking()) {
127129
consumeTag(tagFor(obj, keyName));
128130
}
129131

130132
return value;
131133
}
134+
132135
return undefined;
133136
}
134137

packages/@ember/template-compiler/lib/compile-options.ts

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -110,19 +110,7 @@ type Evaluator = (value: string) => unknown;
110110
// https://tc39.es/ecma262/2020/#prod-IdentifierName
111111
const IDENT = /^[\p{ID_Start}$_][\p{ID_Continue}$_\u200C\u200D]*$/u;
112112

113-
// https://tc39.es/ecma262/#prod-PrivateIdentifier
114-
const PRIVATE_IDENT = /^#[\p{ID_Start}$_][\p{ID_Continue}$_\u200C\u200D]*$/u;
115-
116113
function inScope(variable: string, evaluator: Evaluator): boolean {
117-
// Check if it's a private field syntax
118-
if (PRIVATE_IDENT.exec(variable)) {
119-
// Private fields are always considered "in scope" when referenced in a template
120-
// since they are class members, not lexical variables. The actual access check
121-
// will happen at runtime when the template accesses `this.#fieldName`.
122-
// We just need to ensure they're treated as valid identifiers and passed through.
123-
return true;
124-
}
125-
126114
// If the identifier is not a valid JS identifier, it's definitely not in scope
127115
if (!IDENT.exec(variable)) {
128116
return false;

packages/@ember/template-compiler/lib/template.ts

Lines changed: 83 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ export interface ExplicitTemplateOnlyOptions extends BaseTemplateOptions {
8787
* static {
8888
* template('{{this.#greeting}}, {{@place}}!',
8989
* { component: this },
90-
* scope: (instance) => ({ '#greeting': instance ? instance.#greeting : undefined }),
90+
* scope: (instance) => ({ '#greeting': instance.#greeting }),
9191
* );
9292
* }
9393
* }
@@ -216,7 +216,7 @@ export type ImplicitTemplateOnlyOptions = BaseTemplateOptions & ImplicitEvalOpti
216216
*
217217
* ## Private Fields Support
218218
*
219-
* The implicit form now supports private fields. You can reference private
219+
* The implicit form supports private fields. You can reference private
220220
* class members in templates using the `this.#fieldName` syntax:
221221
*
222222
* ```ts
@@ -228,7 +228,7 @@ export type ImplicitTemplateOnlyOptions = BaseTemplateOptions & ImplicitEvalOpti
228228
* template(
229229
* '<button {{on "click" this.#increment}}>{{this.#count}}</button>',
230230
* { component: this },
231-
* eval() { return arguments[0] }
231+
* eval() { return eval(arguments[0]) }
232232
* );
233233
* }
234234
* }
@@ -239,12 +239,13 @@ export type ImplicitClassOptions<C extends ComponentClass> = BaseClassTemplateOp
239239

240240
/**
241241
* Well-known symbol key used to store private field getter closures on a
242-
* component class. The getters are created via `eval` inside the class's
243-
* `static` block so they retain access to the class's private field brand.
242+
* component class. The getters are created inside the class's `static` block
243+
* (via the explicit `scope` function or via `eval`) so they retain access to
244+
* the class's private field brand.
244245
*
245246
* Shape: `Record<string, (instance: object) => unknown>`
246247
*/
247-
export const PRIVATE_FIELD_GETTERS = Symbol.for('ember:private-field-getters');
248+
const PRIVATE_FIELD_GETTERS = Symbol.for('ember:private-field-getters');
248249

249250
/**
250251
* Extract private field names (e.g. `#count`, `#increment`) referenced
@@ -262,96 +263,39 @@ function extractPrivateFields(templateString: string): string[] {
262263

263264
export function template(
264265
templateString: string,
265-
options?: (ExplicitTemplateOnlyOptions | ImplicitTemplateOnlyOptions) & { component?: never }
266+
options?: ExplicitTemplateOnlyOptions | ImplicitTemplateOnlyOptions
266267
): TemplateOnlyComponent;
267268

268-
export function template<C extends ComponentClass>(
269+
// Overload for explicit scope with class component.
270+
// Uses a direct instance type parameter `I` instead of `InstanceType<C>` so
271+
// TypeScript can contextually type the `scope` callback parameter without
272+
// needing to resolve a conditional type during inference.
273+
export function template<I extends object>(
269274
templateString: string,
270-
options: ExplicitClassOptions<C>
271-
): C;
275+
options: {
276+
component: abstract new (...args: any[]) => I;
277+
scope: (instance: I) => Record<string, unknown>;
278+
} & BaseTemplateOptions
279+
): abstract new (...args: any[]) => I;
272280

273281
export function template<C extends ComponentClass>(
274282
templateString: string,
275-
options: (ImplicitClassOptions<C> | BaseClassTemplateOptions<C> & { scope?: never })
283+
options: ImplicitClassOptions<C> | BaseClassTemplateOptions<C>
276284
): C;
285+
277286
export function template(
278287
templateString: string,
279288
providedOptions?: BaseTemplateOptions | BaseClassTemplateOptions<any>
280289
): object {
281290
const options: EmberPrecompileOptions = { strictMode: true, ...providedOptions };
282291

283-
const privateFields = extractPrivateFields(templateString);
284-
285-
// When using the explicit scope form with private fields, the scope
286-
// function has the shape:
287-
//
288-
// (instance) => ({ Section, '#secret': instance?.#secret })
289-
//
290-
// The optional chaining (`?.`) on the instance parameter is required so
291-
// that calling the scope function without an instance (at compile time)
292-
// returns `undefined` for private field entries instead of crashing.
293-
//
294-
// At compile time we use the scope to:
295-
// 1. Discover which variables are in lexical scope (non-private entries)
296-
// 2. Build an evaluator that can resolve the compiled wire format
297-
//
298-
// Private fields are not resolved through the evaluator. Instead, at
299-
// runtime they are accessed via PRIVATE_FIELD_GETTERS stored on the
300-
// component class, which _getProp looks up when it encounters a `#` key.
301-
let originalScopeWithInstance: ((instance: any) => Record<string, unknown>) | undefined;
302-
303-
if (privateFields.length > 0 && options.scope && !options.eval && options.component) {
304-
originalScopeWithInstance = options.scope as (instance: any) => Record<string, unknown>;
305-
const origScope = originalScopeWithInstance;
306-
307-
// Wrap the scope function so that compile-time callers (compileOptions
308-
// and buildEvaluator) receive only non-private entries. The private
309-
// field names are injected as placeholders so lexicalScope recognises
310-
// them as in-scope.
311-
options.scope = () => {
312-
// Call with no instance — private fields evaluate to `undefined`
313-
// thanks to optional chaining in the scope function.
314-
const fullScope = origScope(undefined);
315-
const safeScope: Record<string, unknown> = {};
316-
for (const key of Object.keys(fullScope)) {
317-
if (key[0] !== '#') {
318-
safeScope[key] = fullScope[key];
319-
}
320-
}
321-
// Inject private field names so lexicalScope reports them as in-scope.
322-
for (const field of privateFields) {
323-
safeScope[field] = true;
324-
}
325-
return safeScope;
326-
};
327-
}
328-
329-
const evaluate = buildEvaluator(options);
292+
const { evaluate, privateFieldGetters } = buildEvaluator(templateString, options);
330293

331294
const normalizedOptions = compileOptions(options);
332295
const component = normalizedOptions.component ?? templateOnly();
333296

334-
// If the template references private fields (this.#field), create getter
335-
// closures that can access the private fields at runtime. These are stored
336-
// on the component class and looked up by _getProp.
337-
if (privateFields.length > 0) {
338-
if (originalScopeWithInstance) {
339-
// Explicit form: build getters from the scope(instance) function.
340-
const scopeFn = originalScopeWithInstance;
341-
const getters: Record<string, (obj: object) => unknown> = {};
342-
for (const field of privateFields) {
343-
getters[field] = (obj: object) => scopeFn(obj)[field];
344-
}
345-
(component as any)[PRIVATE_FIELD_GETTERS] = getters;
346-
} else if (evaluate !== evaluator) {
347-
// Implicit (eval) form: generate getter closures via eval.
348-
const getterEntries = privateFields.map((f) => `${JSON.stringify(f)}: (obj) => obj.${f}`);
349-
const getters = evaluate(`({${getterEntries.join(', ')}})`) as Record<
350-
string,
351-
(obj: object) => unknown
352-
>;
353-
(component as any)[PRIVATE_FIELD_GETTERS] = getters;
354-
}
297+
if (privateFieldGetters) {
298+
(component as any)[PRIVATE_FIELD_GETTERS] = privateFieldGetters;
355299
}
356300

357301
const source = glimmerPrecompile(templateString, normalizedOptions);
@@ -366,38 +310,73 @@ const evaluator = (source: string) => {
366310
return new Function(`return ${source}`)();
367311
};
368312

369-
function buildEvaluator(options: Partial<EmberPrecompileOptions> | undefined) {
370-
if (options === undefined) {
371-
return evaluator;
372-
}
313+
type EvaluatorResult = {
314+
evaluate: (source: string) => unknown;
315+
privateFieldGetters?: Record<string, (obj: object) => unknown>;
316+
};
373317

318+
function buildEvaluator(
319+
templateString: string,
320+
options: Partial<EmberPrecompileOptions>
321+
): EvaluatorResult {
374322
if (options.eval) {
375-
return options.eval;
376-
} else {
377-
const scope = options.scope?.();
323+
const evaluate = options.eval;
378324

379-
if (!scope) {
380-
return evaluator;
381-
}
382-
383-
// Filter out private field entries (#-prefixed keys) — those are not
384-
// lexical variables and cannot be used as Function parameter names.
385-
// Private fields are handled separately via PRIVATE_FIELD_GETTERS.
386-
const argNames: string[] = [];
387-
const argValues: unknown[] = [];
388-
for (const [key, value] of Object.entries(scope)) {
389-
if (key[0] !== '#') {
390-
argNames.push(key);
391-
argValues.push(value);
325+
// Implicit (eval) form: extract `this.#field` references from the template
326+
// and build getter closures via eval while we still have access to the
327+
// class's private field brand.
328+
if (options.component) {
329+
const privateFields = extractPrivateFields(templateString);
330+
if (privateFields.length > 0) {
331+
const entries = privateFields.map((f) => `${JSON.stringify(f)}: (obj) => obj.${f}`);
332+
const privateFieldGetters = evaluate(`({${entries.join(', ')}})`) as Record<
333+
string,
334+
(obj: object) => unknown
335+
>;
336+
return { evaluate, privateFieldGetters };
392337
}
393338
}
394339

395-
if (argNames.length === 0) {
396-
return evaluator;
340+
return { evaluate };
341+
}
342+
343+
const scopeFn = options.scope as ((instance?: any) => Record<string, unknown>) | undefined;
344+
345+
if (!scopeFn) {
346+
return { evaluate: evaluator };
347+
}
348+
349+
const scope = scopeFn();
350+
351+
// Separate #-prefixed keys (private fields) from regular lexical variables.
352+
// Private field names can't be used as Function parameter names, so they must
353+
// be filtered out of the evaluator. They're resolved at runtime via getters.
354+
const argNames: string[] = [];
355+
const argValues: unknown[] = [];
356+
const privateKeys: string[] = [];
357+
358+
for (const [key, value] of Object.entries(scope)) {
359+
if (key[0] === '#') {
360+
privateKeys.push(key);
361+
} else {
362+
argNames.push(key);
363+
argValues.push(value);
397364
}
365+
}
398366

399-
return (source: string) => {
400-
return new Function(...argNames, `return (${source})`)(...argValues);
401-
};
367+
const evaluate =
368+
argNames.length > 0
369+
? (source: string) => new Function(...argNames, `return (${source})`)(...argValues)
370+
: evaluator;
371+
372+
// Build private field getters from the scope function if any #-keys exist.
373+
let privateFieldGetters: Record<string, (obj: object) => unknown> | undefined;
374+
if (privateKeys.length > 0 && options.component) {
375+
privateFieldGetters = {};
376+
for (const key of privateKeys) {
377+
privateFieldGetters[key] = (obj: object) => scopeFn(obj)[key];
378+
}
402379
}
380+
381+
return { evaluate, privateFieldGetters };
403382
}

0 commit comments

Comments
 (0)