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.foo)}}
+ }
+
+ 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.obj.method) }}
+ }
+
+ 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.obj.method) }}
+ }
+
+ 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 {
+ {{ (@cb) }}
+ }
+
+ 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();
+ }
+
+
+ {{#let this.obj as |o|}}
+ {{(o.method "did it")}}
+ {{/let}}
+
+ }
+
+ 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;
+ }
+
+
+
+ {{#each this.items as |item|}}
+ - {{item.greet}}
+ {{/each}}
+
+
+ }
+
+ 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.foo}}
+ }
+
+ 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;
+ };
+
+
+ {{#let (fn this.foo) as |fned|}}
+ {{ (fned) }}
+ {{/let}}
+
+ }
+
+ 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.obj.customHelper)}}
+ }
+
+ 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();
+ }
+
+ {{#let (fn this.obj.method) as |f|}}{{(f)}}{{/let}}
+ }
+
+ 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