Skip to content

Enable plain methods to work on classes when called from templates#21341

Open
NullVoxPopuli wants to merge 12 commits intomainfrom
nvp/fix-this-binding
Open

Enable plain methods to work on classes when called from templates#21341
NullVoxPopuli wants to merge 12 commits intomainfrom
nvp/fix-this-binding

Conversation

@NullVoxPopuli
Copy link
Copy Markdown
Contributor

@NullVoxPopuli NullVoxPopuli commented Apr 26, 2026

Supersedes #21315

Demo: https://test-ember-source-fix-this-b.limber-glimdown.pages.dev/edit?c=JYWwDg9gTgLgBAYQuCA7Apq%2BAzKy4DkAAgOYA2oI6UA9AMbKQZYEDcAUKJLHAN5wwoAQzoBrdABM4AXzi58xcpWo1BI0cFQk2nFD35oZcvCEJF0IAEYqQECcGzBqO9ugAe3eBPTYhAVzJ4OjIhAGdQuAAJdDIyCAB1aDIpdxhMCQikFGZ4XnY4OCI1MUk4Bj8sOABeOAAGDny4TTooC0wYAAoASj5GgpgAC2BQgDpyyoBqGoBGDgLpdkaAHjTwELSAPj64JbANgE0IPzgBoQA3dDKKEqlBy8s-GBhDXl5B4bGjrGlZGFB0UZLGh7RYFApLB5PQwwACeYHQVQARJDnqhEXxeIZEcFgGJ0e9Rs1WlRvtINghrqIgSi0FtwaoLGB1ugtgsgA&format=gjs

Previous description:

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.

NullVoxPopuli and others added 12 commits April 15, 2026 22:24
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) <[email protected]>
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) <[email protected]>
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) <[email protected]>
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) <[email protected]>
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) <[email protected]>
- 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) <[email protected]>
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) <[email protected]>
- 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) <[email protected]>
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) <[email protected]>
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) <[email protected]>
- 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) <[email protected]>
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) <[email protected]>
@github-actions
Copy link
Copy Markdown
Contributor

📊 Package size report   0.08%↑

File Before (Size / Brotli) After (Size / Brotli)
dist/dev/packages/shared-chunks/dynamic-D9Q4SRQ4.js 116.3 kB / 23.5 kB 0.9%↑117.4 kB / 1%↑23.8 kB
dist/dev/packages/shared-chunks/reference-DynJeoPJ.js 4.9 kB / 1.3 kB 10%↑5.4 kB / 15%↑1.5 kB
dist/prod/packages/shared-chunks/index-C8toCBID.js 59.5 kB / 12 kB 0.4%↑59.7 kB / 0.7%↑12.1 kB
dist/prod/packages/shared-chunks/on-CLT5uKlv.js 96 kB / 19.4 kB 1%↑97.1 kB / 1%↑19.7 kB
dist/prod/packages/shared-chunks/reference-CWZ8moDf.js 4.3 kB / 1.1 kB 11%↑4.8 kB / 14%↑1.3 kB
types/stable/@glimmer/reference/lib/reference.d.ts 1.8 kB / 505 B 9%↑2 kB / 4%↑525 B
Total (Includes all files) 5.4 MB / 1.3 MB 0.08%↑5.4 MB / 0.09%↑1.3 MB
Tarball size 1.2 MB 0.09%↑1.2 MB

🤖 This report was automatically generated by pkg-size-action

@NullVoxPopuli NullVoxPopuli marked this pull request as ready for review April 26, 2026 15:50
@kategengler kategengler requested a review from ef4 May 1, 2026 14:50
Copy link
Copy Markdown
Member

@kategengler kategengler left a comment

Choose a reason for hiding this comment

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

Is there a corresponding RFC? Or other way we make sure this gets documented?

@NullVoxPopuli
Copy link
Copy Markdown
Contributor Author

no need, it's a bugfix. only related RFC is the thing about the @action decorator -- which became an RFC to just update the docs to drop the @action decorator from all the examples

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