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
@@ -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(
Expand Down Expand Up @@ -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('<p>{{this.#greeting}}</p>', {
component: this,
scope: (instance) => ({
'#greeting': instance ? instance.#greeting : undefined,
}),
});
}
}
return TestComponent;
});

this.assertHTML('<p>Hello, world!</p>');
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('<p>{{this.#firstName}} {{this.#lastName}}</p>', {
component: this,
scope: (instance?: InstanceType<typeof TestComponent>) => ({
'#firstName': instance ? instance.#firstName : undefined,
'#lastName': instance ? instance.#lastName : undefined,
}),
});
}
}
return TestComponent;
});

this.assertHTML('<p>Jane Doe</p>');
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('<button type="button" {{on "click" this.#updateMessage}}>Click</button>', {
component: this,
scope: (instance?: InstanceType<typeof TestComponent>) => ({
on,
'#updateMessage': instance ? instance.#updateMessage : undefined,
}),
});
}
}
return TestComponent;
});

this.assertHTML('<button type="button">Click</button>');
this.assertStableRerender();
}

async '@test Can mix private fields with local scope variables'() {
await this.renderComponentModule(() => {
let Greeting = template('<span>{{yield}}</span>');

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

static {
template('<Greeting>Hello, {{this.#name}}!</Greeting>', {
component: this,
scope: (instance?: InstanceType<typeof TestComponent>) => ({
Greeting,
'#name': instance ? instance.#name : undefined,
}),
});
}
}
return TestComponent;
});

this.assertHTML('<span>Hello, Ember!</span>');
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(
'<button {{on "click" (fn checkValue this.#secretValue)}}>Click</button>',
{
component: this,
scope: (instance?: InstanceType<typeof TestComponent>) => ({
on,
fn,
checkValue,
'#secretValue': instance ? instance.#secretValue : undefined,
}),
}
);
}
}
return TestComponent;
});

this.click('button');
}
}
);
Original file line number Diff line number Diff line change
Expand Up @@ -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(
'<p>Count: {{this.#count}}</p><button {{on "click" this.#increment}}>Increment</button>',
{
component: this,
eval() {
return eval(arguments[0]);
},
}
);
}
};
});

this.assertHTML('<p>Count: 0</p><button>Increment</button>');
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('<button type="button" {{on "click" this.#updateMessage}}>Click</button>', {
component: this,
eval() {
return eval(arguments[0]);
},
});
}
};
});

this.assertHTML('<button type="button">Click</button>');
this.assertStableRerender();
}
}
);

Expand Down
25 changes: 25 additions & 0 deletions packages/@ember/-internals/metal/lib/property_get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, (obj: object) => 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') {
Expand Down
12 changes: 12 additions & 0 deletions packages/@ember/template-compiler/lib/compile-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading