diff --git a/packages/@ember/-internals/glimmer/tests/integration/this-binding-test.gjs b/packages/@ember/-internals/glimmer/tests/integration/this-binding-test.gjs new file mode 100644 index 00000000000..05e77c7664b --- /dev/null +++ b/packages/@ember/-internals/glimmer/tests/integration/this-binding-test.gjs @@ -0,0 +1,326 @@ +import { set } from '@ember/object'; +import { RenderingTestCase, moduleFor, runTask } from 'internal-test-helpers'; +import { fn } from '@ember/helper'; +import { helperCapabilities, setHelperManager } from '@glimmer/manager'; +import { Component } from '../utils/helpers'; + +moduleFor( + 'Automatic this-binding for class methods directly called from the template', + class extends RenderingTestCase { + ['@test class method'](assert) { + let instance; + let seenThis; + + class Demo extends Component { + constructor(...args) { + super(...args); + instance = this; + } + + foo() { + seenThis = this; + } + + + } + + this.render(``, { Demo }); + + assert.ok(instance); + assert.strictEqual(seenThis, instance); + } + + ['@test maintains binding through property chain'](assert) { + let innerInstance; + let seenThis; + + class Inner { + constructor() { + innerInstance = this; + } + + method() { + seenThis = this; + } + } + + class Demo extends Component { + constructor(...args) { + super(...args); + this.obj = new Inner(); + } + + + } + + this.render(``, { Demo }); + + assert.ok(innerInstance); + assert.strictEqual(seenThis, innerInstance); + } + + ['@test replacing the base object uses correct `this`'](assert) { + let instance; + let seenThis; + + class Inner { + method() { + seenThis = this; + } + } + + class Demo extends Component { + constructor(...args) { + super(...args); + instance = this; + this.obj = new Inner(); + } + + + } + + this.render(``, { Demo }); + + let first = instance.obj; + assert.strictEqual(seenThis, first); + + let second = new Inner(); + runTask(() => set(instance, 'obj', second)); + + assert.strictEqual(seenThis, second); + } + + ['@test a method passed as an argument stays bound to its original object'](assert) { + let instance; + let seenThis; + + class Child extends Component { + + } + + class Demo extends Component { + constructor(...args) { + super(...args); + instance = this; + } + + foo() { + seenThis = this; + } + + + } + + this.render(``, { Demo }); + + assert.strictEqual(seenThis, instance); + } + + ['@test #let block param preserves this binding on method access'](assert) { + let innerInstance; + let seenThis; + let receivedArg; + + class Inner { + constructor() { + innerInstance = this; + } + + method(arg) { + seenThis = this; + receivedArg = arg; + } + } + + class Demo extends Component { + constructor(...args) { + super(...args); + this.obj = new Inner(); + } + + + } + + this.render(``, { Demo }); + + assert.ok(innerInstance); + assert.strictEqual(seenThis, innerInstance); + assert.strictEqual(receivedArg, 'did it', 'argument is passed through to the method'); + } + + ['@test each over array of objects preserves this binding on item methods'](assert) { + let seenPairs = []; + + class Item { + constructor(name) { + this.name = name; + } + + greet() { + seenPairs.push({ self: this, name: this.name }); + } + } + + let items = [new Item('alice'), new Item('bob'), new Item('carol')]; + + class Demo extends Component { + constructor(...args) { + super(...args); + this.items = items; + } + + + } + + this.render(``, { Demo }); + + assert.strictEqual(seenPairs.length, 3); + assert.strictEqual(seenPairs[0].self, items[0], 'alice: this is the Item instance'); + assert.strictEqual(seenPairs[0].name, 'alice', 'alice: this.name is correct'); + assert.strictEqual(seenPairs[1].self, items[1], 'bob: this is the Item instance'); + assert.strictEqual(seenPairs[1].name, 'bob', 'bob: this.name is correct'); + assert.strictEqual(seenPairs[2].self, items[2], 'carol: this is the Item instance'); + assert.strictEqual(seenPairs[2].name, 'carol', 'carol: this.name is correct'); + } + + ['@test already-bound functions are unaffected'](assert) { + let instance; + let seenThis; + + class Demo extends Component { + constructor(...args) { + super(...args); + instance = this; + } + + foo = () => { + seenThis = this; + }; + + + } + + this.render(``, { Demo }); + + assert.strictEqual(seenThis, instance); + } + + ['@test (fn) on methods still behaves appropriately'](assert) { + let instance; + let seenThis; + + class Demo extends Component { + constructor(...args) { + super(...args); + instance = this; + } + + foo = () => { + seenThis = this; + }; + + + } + + this.render(``, { Demo }); + + assert.strictEqual(seenThis, instance); + } + + ['@test a function with a custom helper manager read off a path keeps its manager'](assert) { + let sawDefinition; + + class MyHelperManager { + capabilities = helperCapabilities('3.23', { hasValue: true }); + + createHelper(definition, args) { + return { definition, args }; + } + + getValue({ definition }) { + sawDefinition = definition; + return 'CUSTOM_MANAGER_RAN'; + } + + getDebugName() { + return 'my-custom-helper'; + } + } + + function customHelper() { + return 'PLAIN_FN_RAN'; + } + setHelperManager(() => new MyHelperManager(), customHelper); + + class Inner { + customHelper = customHelper; + } + + class Demo extends Component { + constructor(...args) { + super(...args); + this.obj = new Inner(); + } + + + } + + this.render(``, { Demo }); + + this.assertText('CUSTOM_MANAGER_RAN'); + assert.strictEqual( + sawDefinition, + customHelper, + // i.e.: a.foo !== a.foo.bind(a) + 'the custom manager received the original function, not a `.bind()` wrapper' + ); + } + + ['@test a plain method passed through (fn) is not this-bound'](assert) { + let innerInstance; + let seenThis = 'UNSET'; + + class Inner { + constructor() { + innerInstance = this; + } + + method() { + seenThis = this; + } + } + + class Demo extends Component { + constructor(...args) { + super(...args); + this.obj = new Inner(); + } + + + } + + this.render(``, { Demo }); + + assert.notStrictEqual(seenThis, 'UNSET', 'the method actually ran'); + assert.notStrictEqual( + seenThis, + innerInstance, + 'a plain method passed through `(fn)` is NOT bound to the object it was read from — `(fn)` value-passes the function and only the direct template call site binds' + ); + } + } +); diff --git a/packages/@glimmer/interfaces/lib/references.d.ts b/packages/@glimmer/interfaces/lib/references.d.ts index 9bb1415eb57..86abdaa9d39 100644 --- a/packages/@glimmer/interfaces/lib/references.d.ts +++ b/packages/@glimmer/interfaces/lib/references.d.ts @@ -26,4 +26,5 @@ export interface Reference { debugLabel?: string | false | undefined; compute: Nullable<() => T>; children: null | Map; + parent: Nullable; } diff --git a/packages/@glimmer/manager/lib/internal/api.ts b/packages/@glimmer/manager/lib/internal/api.ts index c5b5135e1ae..bf077812c93 100644 --- a/packages/@glimmer/manager/lib/internal/api.ts +++ b/packages/@glimmer/manager/lib/internal/api.ts @@ -235,6 +235,16 @@ export function hasInternalModifierManager(definition: object): boolean { ); } +/** + * Whether a value has a helper manager explicitly associated with it, as + * opposed to a plain function (which falls back to the default function helper + * manager). Used to decide whether a function invoked from a path expression + * may be safely re-bound to the object it was read from. + */ +export function hasCustomHelperManager(definition: object): boolean { + return getManager(HELPER_MANAGERS, definition) !== undefined; +} + function hasDefaultComponentManager(_definition: object): boolean { return false; } diff --git a/packages/@glimmer/reference/lib/reference.ts b/packages/@glimmer/reference/lib/reference.ts index c7232d0469a..6e0aa154994 100644 --- a/packages/@glimmer/reference/lib/reference.ts +++ b/packages/@glimmer/reference/lib/reference.ts @@ -42,6 +42,12 @@ class ReferenceImpl implements Reference { public children: Nullable> = null; + /** + * The reference this one was created from via a property read (`parent.path`), + * if any. See {@linkcode parentRefFor}. + */ + public parent: Nullable = null; + public compute: Nullable<() => T> = null; public update: Nullable<(val: T) => void> = null; @@ -238,11 +244,27 @@ export function childRefFor(_parentRef: Reference, path: string): Reference { } } + if (child !== UNDEFINED_REFERENCE) { + child.parent = parentRef; + } + children.set(path, child); return child; } +/** + * Returns the reference for the object a property was read from, when the + * given reference was created by a property read (e.g. the `obj` in `obj.someFn`). + * + * This allows a function value invoked directly from a template (e.g. + * `{{(this.obj.someFn)}}`) to be called with the same `this` JavaScript would + * use for `obj.someFn()`, rather than requiring the function to be pre-bound. + */ +export function parentRefFor(ref: Reference): Nullable { + return ref.parent; +} + export function childRefFromParts(root: Reference, parts: string[]): Reference { let reference = root; @@ -262,6 +284,8 @@ if (DEBUG) { ref[REFERENCE] = inner[REFERENCE]; + ref.parent = inner.parent; + ref.debugLabel = debugLabel; return ref; diff --git a/packages/@glimmer/runtime/lib/compiled/opcodes/expressions.ts b/packages/@glimmer/runtime/lib/compiled/opcodes/expressions.ts index 0e99c73c52f..a04367e5e15 100644 --- a/packages/@glimmer/runtime/lib/compiled/opcodes/expressions.ts +++ b/packages/@glimmer/runtime/lib/compiled/opcodes/expressions.ts @@ -41,11 +41,15 @@ import debugToString from '@glimmer/debug-util/lib/debug-to-string'; import assert from '@glimmer/debug-util/lib/assert'; import { _hasDestroyableChildren, associateDestroyableChild, destroy } from '@glimmer/destroyable'; import { debugAssert, toBool } from '@glimmer/global-context'; -import { getInternalHelperManager } from '@glimmer/manager/lib/internal/api'; +import { + getInternalHelperManager, + hasCustomHelperManager, +} from '@glimmer/manager/lib/internal/api'; import { childRefFor, createComputeRef, FALSE_REFERENCE, + parentRefFor, TRUE_REFERENCE, UNDEFINED_REFERENCE, valueForRef, @@ -125,7 +129,7 @@ APPEND_OPCODES.add(VM_DYNAMIC_HELPER_OP, (vm) => { associateDestroyableChild(helperInstanceRef, helperRef); } else if (isIndexable(definition)) { - let helper = resolveHelper(definition, ref); + let helper = resolveHelper(bindFunctionToParent(definition, ref), ref); helperRef = helper(args, initialOwner); if (_hasDestroyableChildren(helperRef)) { @@ -146,6 +150,43 @@ APPEND_OPCODES.add(VM_DYNAMIC_HELPER_OP, (vm) => { vm.loadValue($v0, helperValueRef); }); +/** + * When a plain function is invoked from a path expression (`{{(this.obj.method)}}`, + * `{{item.greet}}`), call it with the object it was read from as `this`, matching + * the JavaScript semantics of `obj.method()`. + * + * This is the template-invocation call site, so it only affects functions that are + * actually *invoked* here. Functions merely passed along as values (e.g. to the + * `{{on}}` modifier or the `(fn)` helper) flow through different opcodes and are + * left untouched. Functions with an explicitly-associated helper manager, and + * already-bound functions (arrow functions, `.bind()`ed functions), are unaffected. + * + * The parent's value is read here, inside the helper compute, so that replacing the + * base object rebinds `this` on the next revision. + */ +function bindFunctionToParent( + definition: HelperDefinitionState, + ref: Reference +): HelperDefinitionState { + if (typeof definition !== 'function' || hasCustomHelperManager(definition)) { + return definition; + } + + let parentRef = parentRefFor(ref); + + if (parentRef === null) { + return definition; + } + + let self = valueForRef(parentRef); + + if ((typeof self === 'object' && self !== null) || typeof self === 'function') { + return (definition as (this: unknown, ...args: unknown[]) => unknown).bind(self); + } + + return definition; +} + function resolveHelper(definition: HelperDefinitionState, ref: Reference): Helper { let managerOrHelper = getInternalHelperManager(definition, true); let helper;