Skip to content

Compile {{this.#field}} via runtime accessor injection#21376

Open
NullVoxPopuli-ai-agent wants to merge 1 commit into
emberjs:nvp/fix-private-field-accessfrom
NullVoxPopuli-ai-agent:nvp/fix-private-field-access-impl
Open

Compile {{this.#field}} via runtime accessor injection#21376
NullVoxPopuli-ai-agent wants to merge 1 commit into
emberjs:nvp/fix-private-field-accessfrom
NullVoxPopuli-ai-agent:nvp/fix-private-field-access-impl

Conversation

@NullVoxPopuli-ai-agent
Copy link
Copy Markdown
Contributor

@NullVoxPopuli-ai-agent NullVoxPopuli-ai-agent commented May 6, 2026

Summary

Closes the gap on #21375 — templates that read a class's private field
({{this.#foo}}) used to render empty because Glimmer's path walker
(_getProp) tried instance['#foo'], which never reaches a real
private slot in JS.

This change teaches the property walker about private fields directly,
so the existing path-resolution machinery handles {{this.#foo}} end-to-end:

  1. Per-class reader registry in @ember/-internals/metal. A class
    registers a single (instance, fieldName) => unknown reader; the
    reader is constructed inside the class body so its __inst.#name
    syntax is bound at parse time to the class's private slots, and its
    closure preserves that access for any future instance handed to it.
    Lookup walks the receiver's prototype chain — correct for subclasses
    since a parent class's reader can read its own private fields on
    subclass instances.

  2. _getProp short-circuit: when the key starts with #, it grabs
    the reader for the receiver's class and delegates. This is the only
    seam in the runtime that needed teaching.

  3. Lightweight collect-private-fields AST plugin (no rewrite). It
    walks PathExpressions and writes each #-prefixed tail segment
    into meta.privateFields. The host (template()) wires that bag up
    before precompile and consumes the names afterwards.

  4. template() wires up the reader via the user's eval after
    precompile. Because eval sits inside the class's static {} block,
    the __inst.#name syntax in the generated reader binds to the
    class's private slots; the resulting closure is registered against
    the component class.

  5. AST formalization in @glimmer/syntax: new isPrivateFieldSegment
    / privateFieldName helpers so consumers don't have to string-match.
    The tail: string[] shape is preserved so existing AST consumers
    keep working.

The explicit scope form does not support {{this.#field}} — its
scope arrow is evaluated outside the class body, so there's no way to
bind #name in it. The implicit (eval) form covers gjs / <template>,
which is what RFC 0921 / content-tag emit.

Test plan

  • Smoke test added in [BUGFIX] #private field access in class-based components #21375 (private field access > it works)
    passes in classic, embroiderWebpack, and embroiderVite scenarios.
  • Three integration tests in
    runtime-template-compiler-implicit-test.ts: single field,
    multiple fields piped through a helper, and a base-class reader
    read on a subclass instance.
  • Two unit tests in template_test.ts: registry registration and
    the no-eval error path.
  • Full local pnpm testem ci run — 9313 pass / 17 skip / 0 fail.
  • pnpm type-check clean. (pnpm lint:eslint has the same 24
    pre-existing errors in vendored glimmer-vm code as main/this
    PR's base; none of my files have lint errors.)

🤖 Generated with Claude Code

Glimmer's path walker hits `instance['#foo']` for private-field tail
segments, which never reaches a real private slot — JS private fields are
only accessible through the lexically-scoped `obj.#foo` syntax. Earlier
attempts at AST-rewriting the template into a synthetic helper invocation
duplicated work the path walker already does and pushed the JavaScript
private-field semantics into a separate compile-time channel; this
replaces that with a single seam at the property walker.

Plumbing:

- New WeakMap registry in `@ember/-internals/metal/lib/private_field_reader`.
  A class registers a single `(instance, fieldName) => unknown` reader; the
  reader closes over the class's lexical scope, so `__inst.#name` inside it
  is bound at parse time to the class's private names. Lookup walks the
  prototype chain, which is correct for subclass instances since a parent
  class's reader can read its own private fields on subclass instances.

- `_getProp` short-circuits when the key starts with `#`: it grabs the
  registered reader for the receiver's class and delegates. This is the
  only seam that needed teaching; nothing else in the runtime changes.

- Lightweight `collect-private-fields` AST plugin (no rewrite). It walks
  PathExpressions and writes each `#`-prefixed tail segment into
  `meta.privateFields`. The host (`template()`) wires that bag up before
  precompile and consumes the names afterwards.

- After precompile, if any private fields were referenced, `template()`
  asks the user's `eval` to compile a single switch-based reader function.
  Because `eval` is invoked from inside the class's `static {}` block, the
  `#name` syntax in the reader's body parses against the class's private
  slots; the resulting closure is registered against the component class.

- Glimmer-side AST formalization: `@glimmer/syntax` exports
  `isPrivateFieldSegment` / `privateFieldName` so consumers don't have to
  string-match. The `tail: string[]` shape is preserved (every existing
  AST consumer keeps working).

The explicit `scope` form still does not support private fields — its
scope arrow is evaluated outside the class body, so there's no way to
bind `#name` in it. The implicit (`eval`) form covers gjs/`<template>`,
which is what RFC 0921 / content-tag emit.

Coverage:
- Three integration tests in `runtime-template-compiler-implicit-test.ts`:
  single field, multiple fields piped through a helper, and a base-class
  reader read on a subclass instance.
- Two unit tests in `template_test.ts`: registry registration and the
  no-eval error path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@NullVoxPopuli-ai-agent NullVoxPopuli-ai-agent force-pushed the nvp/fix-private-field-access-impl branch from 1605ad8 to b0ce1e0 Compare May 8, 2026 06:40
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