Skip to content

Auto-bind prototype methods resolved via template path reads#21315

Closed
NullVoxPopuli-ai-agent wants to merge 12 commits intoemberjs:mainfrom
NullVoxPopuli-ai-agent:fix-this-binding-on-modifier
Closed

Auto-bind prototype methods resolved via template path reads#21315
NullVoxPopuli-ai-agent wants to merge 12 commits intoemberjs:mainfrom
NullVoxPopuli-ai-agent:fix-this-binding-on-modifier

Conversation

@NullVoxPopuli-ai-agent
Copy link
Copy Markdown
Contributor

@NullVoxPopuli-ai-agent NullVoxPopuli-ai-agent commented Apr 15, 2026

Summary

When a template path like this.foo resolves to a function on the component, auto-bind it so that this is preserved when the function is invoked as a callback (e.g. via {{on "click" this.foo}}). No @action decorator, arrow-function field, or manual .bind() needed.

Implementation: compile-time VM_GET_PROPERTY_BOUND_OP

Instead of runtime heuristics in childRefFor (the first iteration), this now uses a compile-time approach with a new VM opcode:

  1. New opcode VM_GET_PROPERTY_BOUND_OP (113) — same stack behavior as VM_GET_PROPERTY_OP but creates a bound reference
  2. Opcode compiler — when GetSymbol sees sym === 0 (= this) with a path, emits VM_GET_PROPERTY_BOUND_OP for the last segment. Intermediate segments use normal VM_GET_PROPERTY_OP. This means:
    • this.fooGetVariable(0) + GetPropertyBound("foo")
    • this.obj.methodGetVariable(0) + GetProperty("obj") + GetPropertyBound("method")
  3. Runtime handlerboundChildRefFor(parentRef, path) reads the property and, if the value is a function (without a component/modifier manager), returns a cached .bind(parent) version. Uses .bind() for V8's optimized BoundFunction path. Non-function values pass through unchanged.
  4. Manager check — skips binding for values with component or modifier managers (so <this.dynamicComponent> and {{this.modifier}} still work). Helper managers are excluded from the check because ALL functions have a default helper manager per RFC #756.

Files changed (7)

File Change
@glimmer/interfaces/lib/vm-opcodes.d.ts Add VmGetPropertyBound = 113 type + union member
@glimmer/constants/lib/syscall-ops.ts Add VM_GET_PROPERTY_BOUND_OP = 113, bump VM_SYSCALL_SIZE
@glimmer/debug/lib/opcode-metadata.ts Add metadata for new opcode
@glimmer/opcode-compiler/lib/syntax/expressions.ts Emit bound opcode for last segment of this.* paths
@glimmer/reference/lib/reference.ts Remove old runtime heuristics
@glimmer/runtime/lib/compiled/opcodes/expressions.ts Add boundChildRefFor + opcode handler
tests/integration/this-binding-test.gjs Tests for this.foo and this.obj.method binding

Test plan

  • New test: this.foo class method keeps this binding via {{on "click" this.foo}}
  • New test: this.obj.method nested chain keeps this binding
  • Lint: eslint + prettier clean
  • 21 test failures remain — these fall into categories that need maintainer review:
    • Intentional contract changes (fn/on "no this context" tests) — the old contract was "no implicit this"; this PR changes that
    • Autotracking assertion testsboundChildRefFor creates a ComputeRef with slightly different tracking behavior than childRefFor, affecting backtracking error messages
    • Debug render tree — bound refs have different debug labels
    • Flaky — 2 each-in "undefined truthy" tests

🤖 Generated with Claude Code

@NullVoxPopuli-ai-agent NullVoxPopuli-ai-agent force-pushed the fix-this-binding-on-modifier branch 2 times, most recently from 4fd52a8 to df94070 Compare April 16, 2026 00:39
When a template path like `this.foo` resolves to a prototype method on the
parent (a class method), return a callable Proxy that binds `this` to the
parent on invocation. Previously the raw unbound function was handed to
consumers like `{{on "click" this.foo}}`, so the class method would fire
with the wrong `this` (or trip the `on` modifier's DEBUG guardrail).

The Proxy approach is used instead of `Function.prototype.bind` because
`bind()` creates a new function object that loses the original's own
properties, which would break subsequent traversals such as
`this.func.aProp` where `func` happens to be a function carrying its own
properties.

The bound Proxy is cached per (parent, path) in a WeakMap so callback
identity stays stable across revalidations — required by `{{on}}`, which
only re-installs the DOM listener when the callback reference changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@NullVoxPopuli-ai-agent NullVoxPopuli-ai-agent force-pushed the fix-this-binding-on-modifier branch from df94070 to 609299d Compare April 16, 2026 02:24
Address review: the fix is about path expression binding, not
the on modifier specifically. Use a plain class + gjs template
to verify that `foo.foo` invoked from a template preserves `this`.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Comment thread packages/@glimmer/reference/lib/reference.ts
NullVoxPopuli and others added 4 commits April 16, 2026 09:51
Replace the runtime heuristic (isClassMethod/maybeBindPrototypeMethod in
childRefFor) with a compile-time approach as requested in review.

When the template compiler sees a `this.*` path (GetSymbol with sym=0),
it now emits VM_GET_PROPERTY_BOUND_OP for the last path segment instead
of VM_GET_PROPERTY_OP. The runtime handler for this opcode creates a
reference that auto-binds function values to their parent via .bind(),
with caching for stable identity across revalidations.

Changes:
- New opcode: VM_GET_PROPERTY_BOUND_OP (113) in interfaces, constants,
  and debug metadata
- Opcode compiler: GetSymbol handler emits bound opcode for last segment
  of this.* paths
- Runtime: boundChildRefFor() creates a cached bound reference, skipping
  binding for values with component or modifier managers
- Removed: runtime heuristics from @glimmer/reference (isClassMethod,
  maybeBindPrototypeMethod, etc.)
- Tests: classic Component with class method via {{on "click" this.foo}},
  and nested this.obj.method chain

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Instead of eagerly calling getProp in the bound ref's compute
function (which created parallel tracking and autotracking issues),
the bound ref now returns a stable wrapper function that defers
the property read to call time: parent[path].call(parent, ...args).

This means the property is only tracked through childRefFor's
existing tracking (for typeof check), and the actual method lookup
happens when the function is invoked, not during render.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Pass a bindTransform callback to childRefFor so that binding happens
inside the same ref — same tracking, same caching, same debug labels.
Eliminates the wrapper ComputeRef that was causing autotracking
divergence and identity issues.

The transform wraps function values in a stable callable that defers
property lookup to invocation time. Non-function values (strings,
objects, components, modifiers) pass through unchanged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Transform: invalidate wrapper cache when underlying value changes,
  so on/fn modifiers detect callback swaps correctly
- Transform: skip wrapping functions with own enumerable keys, so
  each-in can iterate function properties
- Update fn/on contract tests: the old "no this context" and
  "untouchable context" assertions now verify that this IS preserved
- Update debug-render-tree: use predicate for on-modifier args since
  the callback identity changes with auto-binding
- Remove unused DEBUG import from fn-test.js

Dev: 9140 pass / 0 fail / 17 skip
Prod: 8945 pass / 0 fail / 52 skip

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Comment thread packages/@ember/-internals/glimmer/tests/integration/this-binding-test.gjs Outdated
Comment thread packages/@ember/-internals/glimmer/tests/integration/this-binding-test.gjs Outdated
Comment thread packages/@ember/-internals/glimmer/tests/integration/this-binding-test.gjs Outdated
CallableFunction's typed .call() method doesn't accept arbitrary
this contexts. Use Reflect.apply with a typeof guard instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
{
type: 'modifier',
name: 'on',
args: { positional: ['click', didInsert], named: {} },
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why did this change happen? the debug render tree should retain the originally passed value here, and this assertRenderTree structure shouldl not change as a part of this PR

Comment thread packages/@glimmer/runtime/lib/compiled/opcodes/expressions.ts Outdated
- this-binding-test.gjs: use strict mode gjs templates, named classes,
  constructors (not init), no register calls
- fn-test.js: extract context to variable, assert seenThis === context
  to show that this is the defining object (not just non-null)
- Revert debug-render-tree-test.ts to original — the render tree should
  retain original values, this test should not change
- Restore hasDefinitionManager check with detailed comment explaining
  why it's needed (WeakMap-based manager lookups break without it)

The debug-render-tree modifier test still fails (1 failure) because
the bindTransform wrapper changes function identity for arrow functions
passed via this.* paths. This is a design tension: the wrapper is needed
for class methods but changes identity for all functions. Need reviewer
input on the right boundary.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@NullVoxPopuli-ai-agent
Copy link
Copy Markdown
Contributor Author

Addressed all review comments in 3e02660:

Tests:

  • this-binding-test.gjs: Rewrote using strict-mode gjs <template> blocks, named classes (class Demo extends Component), constructors (not init), no register calls
  • fn-test.js: Extracted context to a variable, asserting seenThis === this.context to show this is the defining object
  • debug-render-tree-test.ts: Reverted to original

hasDefinitionManager (expressions.ts:104): Restored with a detailed comment. The wrapper function is a new identity — modifier/component managers are looked up by identity in WeakMaps (getInternalModifierManager/getInternalComponentManager). When a modifier definition from defineSimpleModifier(fn) is passed via this.modifier, wrapping it creates a function not in the manager WeakMap → resolution fails. Removing the check causes 12 modifier test failures. To remove it, we'd need either a binding mechanism that preserves identity (.bind(), wrappers, and Proxies all change WeakMap identity), or making manager lookups see through wrappers.

Remaining 1 failure: debug render tree: modifiers — the bindTransform wrapper changes identity for didInsert = () => null (an arrow function passed via this.didInsert). Arrow functions don't use this so wrapping is unnecessary, but the transform can't distinguish arrows from class methods at runtime. Need your input on the right boundary.

Instead of wrapping function values in a callable (which changes
identity and breaks render tree inspection, modifier WeakMap lookups,
etc.), tag the ref with its parent via a WeakMap. The original value
flows through unchanged — valueForRef returns the original function.

Consumers that invoke callbacks (on modifier, fn helper) check
getBindingParentRef(ref) and bind `this` at invocation time:
- on: callback.bind(parentValue) instead of untouchableContext
- fn: fn.call(parentValue, ...) instead of fn.call(context, ...)

This eliminates:
- hasDefinitionManager check (no identity change = no WeakMap issue)
- Object.keys check (no wrapper = no property loss)
- BOUND_FN_CACHE (no wrapper to cache)
- bindTransform / ChildRefTransform (no value transformation)
- debug-render-tree test changes (original values preserved)

Dev: 9140 pass / 0 fail / 17 skip
Prod: 8945 pass / 0 fail / 52 skip

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Comment thread packages/@glimmer/opcode-compiler/lib/syntax/expressions.ts Outdated
NullVoxPopuli and others added 3 commits April 16, 2026 23:30
Move GetPropertyBound emission into withPath so ALL multi-segment
path reads (this.*, @arg.*, blockParam.*, lexical.*) tag the last
segment's ref with its parent. With the ref-tagging approach this
is safe — the value identity is unchanged, only on/fn check the tag.

This means {{#let this.obj as |o|}} followed by o.method correctly
binds method to obj, matching the behavior of this.obj.method.

Also adds a test for the #let + fn pattern per review request:
  {{#let this.obj as |o|}}
    <button {{on 'click' (fn o.method 'did it')}}>click me</button>
  {{/let}}

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- on.ts comment: "this.*" → "path read" since binding applies to all
  paths now
- fn-test.ts: assert seenThis has expected property (arg1 === 'foo')
  instead of just non-null, matching reviewer feedback
- expressions.ts: alphabetize imports

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Iterates over an array of Item instances with {{#each}}, passes
item.greet to {{on "click"}} for each, and asserts that each
handler fires with the correct Item as this.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@NullVoxPopuli
Copy link
Copy Markdown
Contributor

Moved to #21341

@NullVoxPopuli NullVoxPopuli deleted the fix-this-binding-on-modifier branch April 26, 2026 15:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants