From 31130d8c53579f696d5eafddae8b2795ea5633ea Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Tue, 30 Jun 2026 15:16:14 -0400 Subject: [PATCH 1/7] Make default function invocation behave like JavaScript (re: this) --- .../tests/integration/this-binding-test.gjs | 303 ++++++++++++++++++ .../@glimmer/interfaces/lib/references.d.ts | 1 + packages/@glimmer/manager/lib/internal/api.ts | 62 +++- .../@glimmer/manager/lib/internal/defaults.ts | 30 +- packages/@glimmer/reference/lib/reference.ts | 24 ++ .../lib/compiled/opcodes/expressions.ts | 19 +- 6 files changed, 422 insertions(+), 17 deletions(-) create mode 100644 packages/@ember/-internals/glimmer/tests/integration/this-binding-test.gjs 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..d4acb35be98 --- /dev/null +++ b/packages/@ember/-internals/glimmer/tests/integration/this-binding-test.gjs @@ -0,0 +1,303 @@ +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( + 'function calls on normal functions retain JS "this" semantics', + class extends RenderingTestCase { + ['@test this.method()'](assert) { + let instance; + + class Demo extends Component { + constructor(...args) { + super(...args); + instance = this; + assert.step('captured:demo'); + } + + foo() { + assert.step(`match:${instance && this === instance}`); + } + + + } + + this.render(``, { Demo }); + + assert.verifySteps(['captured:demo', 'match:true']) + } + + ['@test this.obj.method()'](assert) { + let innerInstance; + + class Inner { + constructor() { + innerInstance = this; + assert.step('captured:inner}'); + } + + method() { + assert.step(`match:${innerInstance && this === innerInstance}`); + } + } + + class Demo extends Component { + constructor(...args) { + super(...args); + this.obj = new Inner(); + } + + + } + + this.render(``, { Demo }); + + assert.verifySteps(['captured:inner', 'match:true']) + } + + ['@test this.obj.method() when this.obj is entirely replaced'](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 passing function references loses the "this"'](assert) { + let instance; + let seenThis; + + class Child extends Component { + + } + + class Demo extends Component { + constructor(...args) { + super(...args); + instance = this; + } + + foo() { + assert.step(`match:${this === instance}`); + } + + + } + + this.render(``, { Demo }); + + assert.verifySteps(['match:false']); + } + + ['@test aliasing through let does not confuse o.method() using "o" for "this"'](assert) { + let innerInstance; + let seenThis; + let receivedArg; + + class Inner { + constructor() { + innerInstance = this; + } + + method(arg) { + assert.step(`match:${innerInstance && this === innerInstance}`); + } + } + + class Demo extends Component { + constructor(...args) { + super(...args); + this.obj = new Inner(); + } + + + } + + this.render(``, { Demo }); + + assert.verifySteps(['match:true']); + } + + ['@test methods called on an iterated item, use the item as the "this"'](assert) { + let seenPairs = []; + + class Item { + constructor(name) { + this.name = name; + } + + greet() { + assert.step(`greet:${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.verifySteps(['greet:alice', 'greet:bob', 'greet:carol']); + } + + ['@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) { + class Inner { + method() { + assert.verifySteps(`calledWith:${this}`); + } + } + + class Demo extends Component { + constructor(...args) { + super(...args); + this.obj = new Inner(); + } + + + } + + this.render(``, { Demo }); + + assert.verifySteps('calledWith:null'); + } + } +); 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..c99acec56fb 100644 --- a/packages/@glimmer/manager/lib/internal/api.ts +++ b/packages/@glimmer/manager/lib/internal/api.ts @@ -1,15 +1,20 @@ import { DEBUG } from '@glimmer/env'; import type { + CapturedArguments, Helper, + HelperDefinitionState, InternalComponentManager, InternalModifierManager, Owner, } from '@glimmer/interfaces'; +import type { Reference } from '@glimmer/reference/lib/reference'; import debugToString from '@glimmer/debug-util/lib/debug-to-string'; import { debugAssert } from '@glimmer/global-context'; +import { createComputeRef, parentRefFor, valueForRef } from '@glimmer/reference/lib/reference'; +import { argsProxyFor } from '../util/args-proxy'; import { CustomHelperManager } from '../public/helper'; -import { FunctionHelperManager } from './defaults'; +import { FunctionHelperManager, invokeFunctionHelper } from './defaults'; type InternalManager = | InternalComponentManager @@ -235,6 +240,61 @@ 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; +} + +/** + * When a plain function is invoked from a path expression (`{{(this.obj.method)}}`, + * `{{item.greet}}`), it should be called with the object it was read from as `this`, + * matching the JavaScript semantics of `obj.method()`. Returns a reference that + * invokes the function with that `this`, or `null` when no such binding applies (in + * which case the value should be resolved as an ordinary helper). + * + * The `this` is applied at the call itself (see `invokeFunctionHelper`), never by + * producing a `.bind()`ed copy, because functions are passed around as references + * constantly (e.g. to the `{{on}}` modifier or the `(fn)` helper) and we must not + * change a function's identity before it is invoked. Functions with an explicitly- + * associated helper manager are left to the normal helper path. + * + * The parent's value is read eagerly here; this runs inside the dynamic-helper + * compute, so replacing the base object re-runs it and rebinds `this` on the next + * revision. + */ +export function functionHelperRefForPath( + definition: HelperDefinitionState, + ref: Reference, + capturedArgs: CapturedArguments +): Reference | null { + if (typeof definition !== 'function' || hasCustomHelperManager(definition)) { + return null; + } + + let parentRef = parentRefFor(ref); + + if (parentRef === null) { + return null; + } + + let self = valueForRef(parentRef); + + if (!((typeof self === 'object' && self !== null) || typeof self === 'function')) { + return null; + } + + let args = argsProxyFor(capturedArgs, 'helper'); + + return createComputeRef(() => + invokeFunctionHelper(definition as (this: unknown, ...args: unknown[]) => unknown, args, self) + ); +} + function hasDefaultComponentManager(_definition: object): boolean { return false; } diff --git a/packages/@glimmer/manager/lib/internal/defaults.ts b/packages/@glimmer/manager/lib/internal/defaults.ts index 3745f9d20e0..133b208c3cf 100644 --- a/packages/@glimmer/manager/lib/internal/defaults.ts +++ b/packages/@glimmer/manager/lib/internal/defaults.ts @@ -1,4 +1,5 @@ import type { + Arguments as HelperArguments, CapturedArguments as Arguments, HelperCapabilities, HelperManagerWithValue, @@ -6,10 +7,6 @@ import type { import { buildCapabilities } from '../util/capabilities'; -type FnArgs = - | [...Args['positional'], Args['named']] - | [...Args['positional']]; - // eslint-disable-next-line @typescript-eslint/no-explicit-any type AnyFunction = (...args: any[]) => unknown; @@ -30,13 +27,7 @@ export class FunctionHelperManager implements HelperManagerWithValue { } getValue({ fn, args }: State): unknown { - if (Object.keys(args.named).length > 0) { - let argsForFn: FnArgs = [...args.positional, args.named]; - - return fn(...argsForFn); - } - - return fn(...args.positional); + return invokeFunctionHelper(fn, args); } getDebugName(fn: AnyFunction): string { @@ -47,3 +38,20 @@ export class FunctionHelperManager implements HelperManagerWithValue { return '(anonymous helper function)'; } } + +/** + * Call a plain function as a helper. `thisArg` is applied at the call itself + * (not by producing a `.bind()`ed copy of `fn`), so the original function + * keeps its identity everywhere it is passed around as a reference. + */ +export function invokeFunctionHelper( + fn: AnyFunction, + args: HelperArguments, + thisArg?: unknown +): unknown { + if (Object.keys(args.named).length > 0) { + return fn.apply(thisArg, [...args.positional, args.named]); + } + + return fn.apply(thisArg, [...args.positional]); +} 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..60f817785b0 100644 --- a/packages/@glimmer/runtime/lib/compiled/opcodes/expressions.ts +++ b/packages/@glimmer/runtime/lib/compiled/opcodes/expressions.ts @@ -41,7 +41,10 @@ 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 { + functionHelperRefForPath, + getInternalHelperManager, +} from '@glimmer/manager/lib/internal/api'; import { childRefFor, createComputeRef, @@ -125,11 +128,17 @@ APPEND_OPCODES.add(VM_DYNAMIC_HELPER_OP, (vm) => { associateDestroyableChild(helperInstanceRef, helperRef); } else if (isIndexable(definition)) { - let helper = resolveHelper(definition, ref); - helperRef = helper(args, initialOwner); + let functionHelperRef = functionHelperRefForPath(definition, ref, args); + + if (functionHelperRef !== null) { + helperRef = functionHelperRef; + } else { + let helper = resolveHelper(definition, ref); + helperRef = helper(args, initialOwner); - if (_hasDestroyableChildren(helperRef)) { - associateDestroyableChild(helperInstanceRef, helperRef); + if (_hasDestroyableChildren(helperRef)) { + associateDestroyableChild(helperInstanceRef, helperRef); + } } } else { helperRef = UNDEFINED_REFERENCE; From 8c75fa297016567b6f700c685bb3cede6cb73bfc Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:36:01 -0400 Subject: [PATCH 2/7] Is this crazy --- .../interfaces/lib/runtime/arguments.d.ts | 12 ++++ packages/@glimmer/manager/lib/internal/api.ts | 62 +------------------ .../@glimmer/manager/lib/internal/defaults.ts | 35 ++++------- .../@glimmer/manager/lib/util/args-proxy.ts | 7 ++- .../lib/compiled/opcodes/expressions.ts | 27 ++++---- .../@glimmer/runtime/lib/helpers/invoke.ts | 3 + 6 files changed, 47 insertions(+), 99 deletions(-) diff --git a/packages/@glimmer/interfaces/lib/runtime/arguments.d.ts b/packages/@glimmer/interfaces/lib/runtime/arguments.d.ts index 92a1c045a85..1a8f7d8b85e 100644 --- a/packages/@glimmer/interfaces/lib/runtime/arguments.d.ts +++ b/packages/@glimmer/interfaces/lib/runtime/arguments.d.ts @@ -16,6 +16,12 @@ export interface VMArguments { export interface CapturedArguments { positional: CapturedPositionalArguments; named: CapturedNamedArguments; + /** + * The reference the helper value was read from a path off of, if any (e.g. the + * `this.obj` in `{{(this.obj.method)}}`). Resolved lazily as `Arguments.context` + * so that helpers which never read it do not entangle this reference. + */ + context?: Reference; [CAPTURED_ARGS]: true; } @@ -59,6 +65,12 @@ export interface CapturedNamedArguments extends Record { export interface Arguments { positional: readonly unknown[]; named: Record; + /** + * The object the helper value was read from, used as `this` when the helper is a + * plain function (matching JS `obj.method()` semantics). Read lazily, so helpers + * that ignore it do not entangle the underlying reference. + */ + context?: unknown; } export interface ArgumentsDebug { diff --git a/packages/@glimmer/manager/lib/internal/api.ts b/packages/@glimmer/manager/lib/internal/api.ts index c99acec56fb..c5b5135e1ae 100644 --- a/packages/@glimmer/manager/lib/internal/api.ts +++ b/packages/@glimmer/manager/lib/internal/api.ts @@ -1,20 +1,15 @@ import { DEBUG } from '@glimmer/env'; import type { - CapturedArguments, Helper, - HelperDefinitionState, InternalComponentManager, InternalModifierManager, Owner, } from '@glimmer/interfaces'; -import type { Reference } from '@glimmer/reference/lib/reference'; import debugToString from '@glimmer/debug-util/lib/debug-to-string'; import { debugAssert } from '@glimmer/global-context'; -import { createComputeRef, parentRefFor, valueForRef } from '@glimmer/reference/lib/reference'; -import { argsProxyFor } from '../util/args-proxy'; import { CustomHelperManager } from '../public/helper'; -import { FunctionHelperManager, invokeFunctionHelper } from './defaults'; +import { FunctionHelperManager } from './defaults'; type InternalManager = | InternalComponentManager @@ -240,61 +235,6 @@ 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; -} - -/** - * When a plain function is invoked from a path expression (`{{(this.obj.method)}}`, - * `{{item.greet}}`), it should be called with the object it was read from as `this`, - * matching the JavaScript semantics of `obj.method()`. Returns a reference that - * invokes the function with that `this`, or `null` when no such binding applies (in - * which case the value should be resolved as an ordinary helper). - * - * The `this` is applied at the call itself (see `invokeFunctionHelper`), never by - * producing a `.bind()`ed copy, because functions are passed around as references - * constantly (e.g. to the `{{on}}` modifier or the `(fn)` helper) and we must not - * change a function's identity before it is invoked. Functions with an explicitly- - * associated helper manager are left to the normal helper path. - * - * The parent's value is read eagerly here; this runs inside the dynamic-helper - * compute, so replacing the base object re-runs it and rebinds `this` on the next - * revision. - */ -export function functionHelperRefForPath( - definition: HelperDefinitionState, - ref: Reference, - capturedArgs: CapturedArguments -): Reference | null { - if (typeof definition !== 'function' || hasCustomHelperManager(definition)) { - return null; - } - - let parentRef = parentRefFor(ref); - - if (parentRef === null) { - return null; - } - - let self = valueForRef(parentRef); - - if (!((typeof self === 'object' && self !== null) || typeof self === 'function')) { - return null; - } - - let args = argsProxyFor(capturedArgs, 'helper'); - - return createComputeRef(() => - invokeFunctionHelper(definition as (this: unknown, ...args: unknown[]) => unknown, args, self) - ); -} - function hasDefaultComponentManager(_definition: object): boolean { return false; } diff --git a/packages/@glimmer/manager/lib/internal/defaults.ts b/packages/@glimmer/manager/lib/internal/defaults.ts index 133b208c3cf..5652ef992a5 100644 --- a/packages/@glimmer/manager/lib/internal/defaults.ts +++ b/packages/@glimmer/manager/lib/internal/defaults.ts @@ -1,9 +1,4 @@ -import type { - Arguments as HelperArguments, - CapturedArguments as Arguments, - HelperCapabilities, - HelperManagerWithValue, -} from '@glimmer/interfaces'; +import type { Arguments, HelperCapabilities, HelperManagerWithValue } from '@glimmer/interfaces'; import { buildCapabilities } from '../util/capabilities'; @@ -27,7 +22,16 @@ export class FunctionHelperManager implements HelperManagerWithValue { } getValue({ fn, args }: State): unknown { - return invokeFunctionHelper(fn, args); + // A plain function read off a path is invoked with the object it was read from + // as `this` (provided lazily via `args.context`), matching the JavaScript + // semantics of `obj.method()`. `this` is applied at the call itself, never by + // producing a `.bind()`ed copy, so the function keeps its identity everywhere it + // is passed around as a reference. + if (Object.keys(args.named).length > 0) { + return fn.apply(args.context, [...args.positional, args.named]); + } + + return fn.apply(args.context, [...args.positional]); } getDebugName(fn: AnyFunction): string { @@ -38,20 +42,3 @@ export class FunctionHelperManager implements HelperManagerWithValue { return '(anonymous helper function)'; } } - -/** - * Call a plain function as a helper. `thisArg` is applied at the call itself - * (not by producing a `.bind()`ed copy of `fn`), so the original function - * keeps its identity everywhere it is passed around as a reference. - */ -export function invokeFunctionHelper( - fn: AnyFunction, - args: HelperArguments, - thisArg?: unknown -): unknown { - if (Object.keys(args.named).length > 0) { - return fn.apply(thisArg, [...args.positional, args.named]); - } - - return fn.apply(thisArg, [...args.positional]); -} diff --git a/packages/@glimmer/manager/lib/util/args-proxy.ts b/packages/@glimmer/manager/lib/util/args-proxy.ts index 88ec478855c..a3720417705 100644 --- a/packages/@glimmer/manager/lib/util/args-proxy.ts +++ b/packages/@glimmer/manager/lib/util/args-proxy.ts @@ -140,7 +140,7 @@ export const argsProxyFor = ( capturedArgs: CapturedArguments, type: 'component' | 'helper' | 'modifier' ): Arguments => { - const { named, positional } = capturedArgs; + const { named, positional, context } = capturedArgs; let getNamedTag = (_obj: object, key: string) => tagForNamedArg(named, key); let getPositionalTag = (_obj: object, key: string) => tagForPositionalArg(positional, key); @@ -184,5 +184,10 @@ export const argsProxyFor = ( // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment named: namedProxy, positional: positionalProxy, + // Read lazily: only helpers that actually use `this` (e.g. the default + // function helper manager) entangle the context reference. + get context() { + return context === undefined ? undefined : valueForRef(context); + }, }; }; diff --git a/packages/@glimmer/runtime/lib/compiled/opcodes/expressions.ts b/packages/@glimmer/runtime/lib/compiled/opcodes/expressions.ts index 60f817785b0..d75fe1606cc 100644 --- a/packages/@glimmer/runtime/lib/compiled/opcodes/expressions.ts +++ b/packages/@glimmer/runtime/lib/compiled/opcodes/expressions.ts @@ -41,14 +41,12 @@ 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 { - functionHelperRefForPath, - getInternalHelperManager, -} from '@glimmer/manager/lib/internal/api'; +import { getInternalHelperManager } from '@glimmer/manager/lib/internal/api'; import { childRefFor, createComputeRef, FALSE_REFERENCE, + parentRefFor, TRUE_REFERENCE, UNDEFINED_REFERENCE, valueForRef, @@ -128,17 +126,20 @@ APPEND_OPCODES.add(VM_DYNAMIC_HELPER_OP, (vm) => { associateDestroyableChild(helperInstanceRef, helperRef); } else if (isIndexable(definition)) { - let functionHelperRef = functionHelperRefForPath(definition, ref, args); + // Make the object this value was read from a path off of available as + // `args.context`, so a plain function helper can be invoked with it as `this` + // (read lazily; helpers that ignore it don't entangle the reference). + let parentRef = parentRefFor(ref); - if (functionHelperRef !== null) { - helperRef = functionHelperRef; - } else { - let helper = resolveHelper(definition, ref); - helperRef = helper(args, initialOwner); + if (parentRef !== null) { + args.context = parentRef; + } + + let helper = resolveHelper(definition, ref); + helperRef = helper(args, initialOwner); - if (_hasDestroyableChildren(helperRef)) { - associateDestroyableChild(helperInstanceRef, helperRef); - } + if (_hasDestroyableChildren(helperRef)) { + associateDestroyableChild(helperInstanceRef, helperRef); } } else { helperRef = UNDEFINED_REFERENCE; diff --git a/packages/@glimmer/runtime/lib/helpers/invoke.ts b/packages/@glimmer/runtime/lib/helpers/invoke.ts index e82e44e28c3..bdd6ebb4856 100644 --- a/packages/@glimmer/runtime/lib/helpers/invoke.ts +++ b/packages/@glimmer/runtime/lib/helpers/invoke.ts @@ -18,6 +18,7 @@ function getArgs(proxy: SimpleArgsProxy): Partial { class SimpleArgsProxy { argsCache?: Cache>; + readonly context: object; constructor( context: object, @@ -25,6 +26,8 @@ class SimpleArgsProxy { ) { let argsCache = createCache(() => computeArgs(context)); + this.context = context; + if (DEBUG) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- @fixme ARGS_CACHES!.set(this, argsCache); From d113eb232fd4cf6047b895253801e1ddcacff581 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Tue, 30 Jun 2026 18:55:28 -0400 Subject: [PATCH 3/7] Fix typo --- .../-internals/glimmer/tests/integration/this-binding-test.gjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@ember/-internals/glimmer/tests/integration/this-binding-test.gjs b/packages/@ember/-internals/glimmer/tests/integration/this-binding-test.gjs index d4acb35be98..54dec63da16 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/this-binding-test.gjs +++ b/packages/@ember/-internals/glimmer/tests/integration/this-binding-test.gjs @@ -35,7 +35,7 @@ moduleFor( class Inner { constructor() { innerInstance = this; - assert.step('captured:inner}'); + assert.step('captured:inner'); } method() { From fb7e2e159b7852d7f62a511f5924f28dd884356f Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Tue, 30 Jun 2026 19:33:45 -0400 Subject: [PATCH 4/7] Bind `this` via a compiler-provided syntactic receiver (drop parentRefFor) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `(this.obj.method)` invokes `method` with `this.obj` as `this` (JS member-call semantics), while `(@cb)` — a function passed as an argument — stays unbound, like `const f = obj.m; f()`. Whether a call has a receiver is a *syntactic* question (does the callee have a path tail?), independent of how the callee reference was derived. So the compiler computes the receiver (`receiverExpressionFor`: the callee minus its last path segment) and pushes it at the call site; `VM_DYNAMIC_HELPER_OP` reads it into `args.context`. `parentRefFor` is no longer consulted at the call site. Bare member-path appends (`{{item.greet}}`) have no syntactic receiver and are unbound — use `{{(item.greet)}}` to bind. Full suite: 9395 tests, 63898 assertions, green (the one red `(fn)` test stringifies an untouchable `this` and fails regardless of this change). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../tests/integration/this-binding-test.gjs | 2 +- .../lib/opcode-builder/helpers/stdlib.ts | 4 ++- .../lib/opcode-builder/helpers/vm.ts | 36 +++++++++++++++++++ .../opcode-compiler/lib/syntax/expressions.ts | 10 ++++-- .../opcode-compiler/lib/syntax/statements.ts | 3 +- .../lib/compiled/opcodes/expressions.ts | 16 ++++----- 6 files changed, 56 insertions(+), 15 deletions(-) diff --git a/packages/@ember/-internals/glimmer/tests/integration/this-binding-test.gjs b/packages/@ember/-internals/glimmer/tests/integration/this-binding-test.gjs index 54dec63da16..ef0bfd7d0e9 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/this-binding-test.gjs +++ b/packages/@ember/-internals/glimmer/tests/integration/this-binding-test.gjs @@ -171,7 +171,7 @@ moduleFor( diff --git a/packages/@glimmer/opcode-compiler/lib/opcode-builder/helpers/stdlib.ts b/packages/@glimmer/opcode-compiler/lib/opcode-builder/helpers/stdlib.ts index 4f4b29746ea..f11b6cf3997 100644 --- a/packages/@glimmer/opcode-compiler/lib/opcode-builder/helpers/stdlib.ts +++ b/packages/@glimmer/opcode-compiler/lib/opcode-builder/helpers/stdlib.ts @@ -63,7 +63,9 @@ export function StdAppend( }); when(ContentType.Helper, () => { - CallDynamic(op, null, null, () => { + // generic append has no syntactic receiver: a bare `{{value}}` that resolves + // to a function is invoked unbound (use `{{(value)}}` to bind `this`). + CallDynamic(op, null, null, null, () => { op(VM_INVOKE_STATIC_OP, nonDynamicAppend); }); }); diff --git a/packages/@glimmer/opcode-compiler/lib/opcode-builder/helpers/vm.ts b/packages/@glimmer/opcode-compiler/lib/opcode-builder/helpers/vm.ts index f2ef561eda3..777d0183e99 100644 --- a/packages/@glimmer/opcode-compiler/lib/opcode-builder/helpers/vm.ts +++ b/packages/@glimmer/opcode-compiler/lib/opcode-builder/helpers/vm.ts @@ -16,6 +16,7 @@ import { } from '@glimmer/constants/lib/syscall-ops'; import { VM_POP_FRAME_OP, VM_PUSH_FRAME_OP } from '@glimmer/constants/lib/vm-ops'; import { $fp, $v0 } from '@glimmer/vm/lib/registers'; +import { opcodes as SexpOpcodes } from '@glimmer/wire-format/lib/opcodes'; import type { PushExpressionOp, PushStatementOp } from '../../syntax/compilers'; @@ -83,15 +84,50 @@ export function Call( * @param positional An optional list of expressions to compile * @param named An optional list of named arguments (name + expression) to compile */ +/** + * The syntactic receiver of a call, i.e. the object a member-call's `this` should be: + * `this.obj.method` → `this.obj`. Returns `null` when the callee has no syntactic + * receiver (a bare variable/argument like `@cb`), so that passing a function as an + * argument and then invoking it is unbound — matching JS `const f = obj.m; f()`. + * + * This is deliberately a *syntactic* question about the call site, independent of how + * the callee reference happens to have been derived. + */ +export function receiverExpressionFor( + expression: WireFormat.Expression +): Nullable { + if (!Array.isArray(expression)) return null; + + let [opcode, symbol, path] = expression as [number, number, (readonly string[])?]; + + if (opcode !== SexpOpcodes.GetSymbol && opcode !== SexpOpcodes.GetLexicalSymbol) { + return null; + } + + if (!Array.isArray(path) || path.length === 0) { + return null; + } + + return [opcode, symbol, path.slice(0, -1)] as unknown as WireFormat.Expression; +} + export function CallDynamic( op: PushExpressionOp, positional: WireFormat.Core.Params, named: WireFormat.Core.Hash, + receiver: Nullable, append?: () => void ): void { op(VM_PUSH_FRAME_OP); SimpleArgs(op, positional, named, false); op(VM_DUP_OP, $fp, 1); + // Push the syntactic receiver so the callee is invoked with it as `this` + // (`undefined` when there is none, e.g. `(@cb)`). + if (receiver) { + expr(op, receiver); + } else { + PushPrimitiveReference(op, undefined); + } op(VM_DYNAMIC_HELPER_OP); if (append) { op(VM_FETCH_OP, $v0); diff --git a/packages/@glimmer/opcode-compiler/lib/syntax/expressions.ts b/packages/@glimmer/opcode-compiler/lib/syntax/expressions.ts index 1815ea34494..fa3b898281e 100644 --- a/packages/@glimmer/opcode-compiler/lib/syntax/expressions.ts +++ b/packages/@glimmer/opcode-compiler/lib/syntax/expressions.ts @@ -23,7 +23,13 @@ import type { PushExpressionOp } from './compilers'; import { expr } from '../opcode-builder/helpers/expr'; import { isGetFreeHelper } from '../opcode-builder/helpers/resolution'; import { SimpleArgs } from '../opcode-builder/helpers/shared'; -import { Call, CallDynamic, Curry, PushPrimitiveReference } from '../opcode-builder/helpers/vm'; +import { + Call, + CallDynamic, + Curry, + PushPrimitiveReference, + receiverExpressionFor, +} from '../opcode-builder/helpers/vm'; import { HighLevelResolutionOpcodes } from '../opcode-builder/opcodes'; import { Compilers } from './compilers'; @@ -44,7 +50,7 @@ EXPRESSIONS.add(SexpOpcodes.Call, (op, [, expression, positional, named]) => { }); } else { expr(op, expression); - CallDynamic(op, positional, named); + CallDynamic(op, positional, named, receiverExpressionFor(expression)); } }); diff --git a/packages/@glimmer/opcode-compiler/lib/syntax/statements.ts b/packages/@glimmer/opcode-compiler/lib/syntax/statements.ts index cfafe8dd329..211c061b645 100644 --- a/packages/@glimmer/opcode-compiler/lib/syntax/statements.ts +++ b/packages/@glimmer/opcode-compiler/lib/syntax/statements.ts @@ -69,6 +69,7 @@ import { CallDynamic, DynamicScope, PushPrimitiveReference, + receiverExpressionFor, } from '../opcode-builder/helpers/vm'; import { HighLevelBuilderOpcodes, HighLevelResolutionOpcodes } from '../opcode-builder/opcodes'; import { debugSymbolsOperand, labelOperand, stdlibOperand } from '../opcode-builder/operands'; @@ -233,7 +234,7 @@ STATEMENTS.add(SexpOpcodes.Append, (op, [, value]) => { }); when(ContentType.Helper, () => { - CallDynamic(op, positional, named, () => { + CallDynamic(op, positional, named, receiverExpressionFor(expression), () => { op(VM_INVOKE_STATIC_OP, stdlibOperand('cautious-non-dynamic-append')); }); }); diff --git a/packages/@glimmer/runtime/lib/compiled/opcodes/expressions.ts b/packages/@glimmer/runtime/lib/compiled/opcodes/expressions.ts index d75fe1606cc..75d5192392d 100644 --- a/packages/@glimmer/runtime/lib/compiled/opcodes/expressions.ts +++ b/packages/@glimmer/runtime/lib/compiled/opcodes/expressions.ts @@ -46,7 +46,6 @@ import { childRefFor, createComputeRef, FALSE_REFERENCE, - parentRefFor, TRUE_REFERENCE, UNDEFINED_REFERENCE, valueForRef, @@ -95,9 +94,15 @@ APPEND_OPCODES.add(VM_CURRY_OP, (vm, { op1: type, op2: _isStrict }) => { APPEND_OPCODES.add(VM_DYNAMIC_HELPER_OP, (vm) => { let stack = vm.stack; + // The compiler pushes the syntactic receiver for member-calls (`this.obj` in + // `(this.obj.method)`), or `undefined` when the call has no syntactic receiver + // (`(@cb)`). A plain function helper is invoked with it as `this`. + let receiver = check(stack.pop(), CheckReference); let ref = check(stack.pop(), CheckReference); let args = check(stack.pop(), CheckArguments).capture(); + args.context = receiver; + let helperRef: Initializable; let initialOwner = vm.getOwner(); @@ -126,15 +131,6 @@ APPEND_OPCODES.add(VM_DYNAMIC_HELPER_OP, (vm) => { associateDestroyableChild(helperInstanceRef, helperRef); } else if (isIndexable(definition)) { - // Make the object this value was read from a path off of available as - // `args.context`, so a plain function helper can be invoked with it as `this` - // (read lazily; helpers that ignore it don't entangle the reference). - let parentRef = parentRefFor(ref); - - if (parentRef !== null) { - args.context = parentRef; - } - let helper = resolveHelper(definition, ref); helperRef = helper(args, initialOwner); From f1096e51e04fc4519ea5d01264856d20e6ba2fd1 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Tue, 30 Jun 2026 20:50:30 -0400 Subject: [PATCH 5/7] Fix test --- .../tests/integration/this-binding-test.gjs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/@ember/-internals/glimmer/tests/integration/this-binding-test.gjs b/packages/@ember/-internals/glimmer/tests/integration/this-binding-test.gjs index ef0bfd7d0e9..0bc92e69f47 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/this-binding-test.gjs +++ b/packages/@ember/-internals/glimmer/tests/integration/this-binding-test.gjs @@ -279,11 +279,17 @@ moduleFor( ); } - ['@test a plain method passed through (fn) is not this-bound'](assert) { + ['@test using "fn" un unbound functions is not allowed'](assert) { + assert.expect(4); + class Inner { method() { - assert.verifySteps(`calledWith:${this}`); - } + // If we didn't throw the error, this could be allowed + assert.step('attempted'); + assert.throws(() => { + assert.step('error'); + String(this)}, 'not bound to a valid') + } } class Demo extends Component { @@ -296,8 +302,7 @@ moduleFor( } this.render(``, { Demo }); - - assert.verifySteps('calledWith:null'); + assert.verifySteps(['attempted', 'error']) } } ); From 7c17ec289d65b567b6b6962443e1fac7a22cfb5d Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Tue, 30 Jun 2026 21:06:16 -0400 Subject: [PATCH 6/7] Clean --- .../@glimmer/interfaces/lib/references.d.ts | 1 - packages/@glimmer/reference/lib/reference.ts | 24 ------------------- 2 files changed, 25 deletions(-) diff --git a/packages/@glimmer/interfaces/lib/references.d.ts b/packages/@glimmer/interfaces/lib/references.d.ts index 86abdaa9d39..9bb1415eb57 100644 --- a/packages/@glimmer/interfaces/lib/references.d.ts +++ b/packages/@glimmer/interfaces/lib/references.d.ts @@ -26,5 +26,4 @@ export interface Reference { debugLabel?: string | false | undefined; compute: Nullable<() => T>; children: null | Map; - parent: Nullable; } diff --git a/packages/@glimmer/reference/lib/reference.ts b/packages/@glimmer/reference/lib/reference.ts index 6e0aa154994..c7232d0469a 100644 --- a/packages/@glimmer/reference/lib/reference.ts +++ b/packages/@glimmer/reference/lib/reference.ts @@ -42,12 +42,6 @@ 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; @@ -244,27 +238,11 @@ 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; @@ -284,8 +262,6 @@ if (DEBUG) { ref[REFERENCE] = inner[REFERENCE]; - ref.parent = inner.parent; - ref.debugLabel = debugLabel; return ref; From 9e08c074a8e5a3efed26ec5e22eb335ad688ef9d Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Wed, 1 Jul 2026 10:16:51 -0400 Subject: [PATCH 7/7] Rename the `context` argument position to `receiver` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matches the `receiver` local in `VM_DYNAMIC_HELPER_OP` and reads more clearly: `Arguments.receiver` / `args.receiver` is the object a member-call's function is invoked with as `this`. Pure rename — no behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/@glimmer/interfaces/lib/runtime/arguments.d.ts | 6 +++--- packages/@glimmer/manager/lib/internal/defaults.ts | 6 +++--- packages/@glimmer/manager/lib/util/args-proxy.ts | 8 ++++---- .../@glimmer/runtime/lib/compiled/opcodes/expressions.ts | 2 +- packages/@glimmer/runtime/lib/helpers/invoke.ts | 4 ++-- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/@glimmer/interfaces/lib/runtime/arguments.d.ts b/packages/@glimmer/interfaces/lib/runtime/arguments.d.ts index 1a8f7d8b85e..10e66eb9598 100644 --- a/packages/@glimmer/interfaces/lib/runtime/arguments.d.ts +++ b/packages/@glimmer/interfaces/lib/runtime/arguments.d.ts @@ -18,10 +18,10 @@ export interface CapturedArguments { named: CapturedNamedArguments; /** * The reference the helper value was read from a path off of, if any (e.g. the - * `this.obj` in `{{(this.obj.method)}}`). Resolved lazily as `Arguments.context` + * `this.obj` in `{{(this.obj.method)}}`). Resolved lazily as `Arguments.receiver` * so that helpers which never read it do not entangle this reference. */ - context?: Reference; + receiver?: Reference; [CAPTURED_ARGS]: true; } @@ -70,7 +70,7 @@ export interface Arguments { * plain function (matching JS `obj.method()` semantics). Read lazily, so helpers * that ignore it do not entangle the underlying reference. */ - context?: unknown; + receiver?: unknown; } export interface ArgumentsDebug { diff --git a/packages/@glimmer/manager/lib/internal/defaults.ts b/packages/@glimmer/manager/lib/internal/defaults.ts index 5652ef992a5..6b70fb25e3c 100644 --- a/packages/@glimmer/manager/lib/internal/defaults.ts +++ b/packages/@glimmer/manager/lib/internal/defaults.ts @@ -23,15 +23,15 @@ export class FunctionHelperManager implements HelperManagerWithValue { getValue({ fn, args }: State): unknown { // A plain function read off a path is invoked with the object it was read from - // as `this` (provided lazily via `args.context`), matching the JavaScript + // as `this` (provided lazily via `args.receiver`), matching the JavaScript // semantics of `obj.method()`. `this` is applied at the call itself, never by // producing a `.bind()`ed copy, so the function keeps its identity everywhere it // is passed around as a reference. if (Object.keys(args.named).length > 0) { - return fn.apply(args.context, [...args.positional, args.named]); + return fn.apply(args.receiver, [...args.positional, args.named]); } - return fn.apply(args.context, [...args.positional]); + return fn.apply(args.receiver, [...args.positional]); } getDebugName(fn: AnyFunction): string { diff --git a/packages/@glimmer/manager/lib/util/args-proxy.ts b/packages/@glimmer/manager/lib/util/args-proxy.ts index a3720417705..18b24857093 100644 --- a/packages/@glimmer/manager/lib/util/args-proxy.ts +++ b/packages/@glimmer/manager/lib/util/args-proxy.ts @@ -140,7 +140,7 @@ export const argsProxyFor = ( capturedArgs: CapturedArguments, type: 'component' | 'helper' | 'modifier' ): Arguments => { - const { named, positional, context } = capturedArgs; + const { named, positional, receiver } = capturedArgs; let getNamedTag = (_obj: object, key: string) => tagForNamedArg(named, key); let getPositionalTag = (_obj: object, key: string) => tagForPositionalArg(positional, key); @@ -185,9 +185,9 @@ export const argsProxyFor = ( named: namedProxy, positional: positionalProxy, // Read lazily: only helpers that actually use `this` (e.g. the default - // function helper manager) entangle the context reference. - get context() { - return context === undefined ? undefined : valueForRef(context); + // function helper manager) entangle the receiver reference. + get receiver() { + return receiver === undefined ? undefined : valueForRef(receiver); }, }; }; diff --git a/packages/@glimmer/runtime/lib/compiled/opcodes/expressions.ts b/packages/@glimmer/runtime/lib/compiled/opcodes/expressions.ts index 75d5192392d..737254687c9 100644 --- a/packages/@glimmer/runtime/lib/compiled/opcodes/expressions.ts +++ b/packages/@glimmer/runtime/lib/compiled/opcodes/expressions.ts @@ -101,7 +101,7 @@ APPEND_OPCODES.add(VM_DYNAMIC_HELPER_OP, (vm) => { let ref = check(stack.pop(), CheckReference); let args = check(stack.pop(), CheckArguments).capture(); - args.context = receiver; + args.receiver = receiver; let helperRef: Initializable; let initialOwner = vm.getOwner(); diff --git a/packages/@glimmer/runtime/lib/helpers/invoke.ts b/packages/@glimmer/runtime/lib/helpers/invoke.ts index bdd6ebb4856..5e4008b71a3 100644 --- a/packages/@glimmer/runtime/lib/helpers/invoke.ts +++ b/packages/@glimmer/runtime/lib/helpers/invoke.ts @@ -18,7 +18,7 @@ function getArgs(proxy: SimpleArgsProxy): Partial { class SimpleArgsProxy { argsCache?: Cache>; - readonly context: object; + readonly receiver: object; constructor( context: object, @@ -26,7 +26,7 @@ class SimpleArgsProxy { ) { let argsCache = createCache(() => computeArgs(context)); - this.context = context; + this.receiver = context; if (DEBUG) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- @fixme