Skip to content

[FEATURE] Debug render tree: helper nodes and reactivity introspection#21484

Open
NullVoxPopuli-ai-agent wants to merge 1 commit into
emberjs:mainfrom
NullVoxPopuli-ai-agent:debug-render-tree-helpers-and-reactivity
Open

[FEATURE] Debug render tree: helper nodes and reactivity introspection#21484
NullVoxPopuli-ai-agent wants to merge 1 commit into
emberjs:mainfrom
NullVoxPopuli-ai-agent:debug-render-tree-helpers-and-reactivity

Conversation

@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Contributor

Motivation

Debug tooling — most concretely the Ember Inspector — currently has to answer two questions by monkeypatching VM internals from the outside, which is fragile and increasingly impossible (module namespaces are read-only in modern builds):

  1. What did this template invoke? Helpers never appear in the debug render tree, so a derived value's producer is invisible; its reactive inputs can only be attributed to the enclosing component.
  2. Why did this re-render? There is no first-class way to learn how often a render node re-rendered, or which of its arguments was invalidated since the previous render.

emberjs/ember-inspector#2776 ships "why did this render?" introspection built on instance-level patches of the debug render tree (create/update/captureNode). This PR moves those capabilities where they belong — into the VM — so the inspector (and any other tooling) can consume plain captured data instead of patching. It also closes the helper gap, which was impossible to patch in from the outside.

All new work is DEBUG-gated and only active when the debug render tree exists (_DEBUG_RENDER_TREE), same as the existing component/modifier bookkeeping.

Helper nodes (type: 'helper')

Template helper invocations now register debug render tree nodes, in both invocation paths:

  • Resolved helpers (VM_HELPER_OP): the produced reference is the node bucket; nodes get create/didRender at append, DebugRenderTreeUpdateOpcode/DidRenderOpcode for re-renders, and a destructor tied to the surrounding block.
  • Dynamic/curried helpers (VM_DYNAMIC_HELPER_OP): same lifecycle keyed on the helper instance reference, named from the callee reference's debugLabel (the lexical name in strict-mode templates).

Two deliberate exclusions keep the tree meaningful:

  • VM-internal helpers (fn, hash, array, …) are excluded via a new isInternalHelper marker in internalHelper. Ember's own internal helpers (the machinery behind {{outlet}} etc.) now register through the same function, so they get the same treatment — they're implementation details, not user-observable invocations. (This is also load-bearing: capturing the anonymous outlet-machinery reference as a tree bucket breaks outlet rendering.)
  • Unnamed references: a helper whose reference has no real debug name can't be presented meaningfully and is treated as an implementation detail. To support this, references now carry an untransformed debugName alongside the display-oriented debugLabel ((result of a \x` helper)`).

Helper nodes have no DOM boundsdidRender and captureBounds accept null bounds for them; CapturedRenderNode.bounds was already nullable.

Reactivity introspection (CapturedRenderNode.reactivity)

Every captured node now reports:

interface CapturedRenderNodeReactivity {
  updateCount: number;                 // re-renders after the initial render
  revision: Revision;                  // global revision at the last (re-)render
  previousRevision: Nullable<Revision>;
  args: {
    positional: CapturedRenderNodeArgReactivity[];
    named: Record<string, CapturedRenderNodeArgReactivity>;
  };
}

interface CapturedRenderNodeArgReactivity {
  debugLabel: string | undefined;      // e.g. "this.user.name" (debug builds)
  revision: Revision;                  // revision of the arg's last computed value
  changed: boolean;                    // invalidated since the node's previous render
}
  • create/update record the revision bookkeeping (valueForTag(CURRENT_TAG) reads — no tag consumption).
  • captureNode walks the node's argument references after the existing reifyArgsDebug pass, so revisions are current; changed is pure revision arithmetic: an argument caused the most recent re-render iff its revision is greater than the node's previous render revision.

This applies to components, modifiers, keywords, and the new helper nodes uniformly — e.g. a helper's captured node directly shows which of its inputs invalidated it.

Implementation notes

  • DebugRenderTreeUpdateOpcode/DebugRenderTreeDidRenderOpcode moved out of component.ts into compiled/opcodes/debug-render-tree.ts so the helper opcodes can share them (no behavior change).
  • New reference helpers: lastRevisionForRef(ref) (revision accessor for the debug render tree) and the debugName field described above.
  • Existing captured-node consumers are unaffected structurally: reactivity is a new field, and the existing per-field test matchers pass unchanged.

Relationship to other work

Testing

  • New tests in @glimmer-workspace/integration-tests (debug render tree suite): helper node capture (args, null bounds, update count, changed-argument flagging across a re-render) and reactivity introspection on component nodes.
  • New tests in the Ember-side Application test: debug render tree: user helpers captured in loose-mode templates while {{array}} and the outlet machinery are not.
  • Full suite: 9399 tests, 0 failures (vite build + testem ci).

🤖 Generated with Claude Code

Debug tooling (notably the Ember Inspector) has two long-standing gaps
it currently works around by patching VM internals from the outside:
helpers never appear in the debug render tree, and there is no way to
tell how often a render node re-rendered or which of its arguments
caused the most recent re-render.

Helper nodes ('helper'):

- Template helper invocations register debug render tree nodes in both
  the resolved (VM_HELPER_OP) and dynamic/curried (VM_DYNAMIC_HELPER_OP)
  paths, DEBUG-only and only when the tree is active.
- Helper nodes have no DOM bounds; didRender/captureBounds accept null
  bounds for them.
- Only user-observable helpers are captured: VM-internal helpers (fn,
  hash, array, ...) are excluded via an isInternalHelper marker that
  Ember's own internal helpers (the machinery behind {{outlet}} etc.)
  now share, and references without a real debug name are treated as
  implementation details.
- References now carry an untransformed debugName (the raw label they
  were created with) so helper nodes can be labeled; the helper's
  update opcodes and a destructor keep the node in sync with its
  surrounding block.

Reactivity introspection (CapturedRenderNode.reactivity):

- The tree records, per node, how many times it updated and the global
  revision at its last two renders.
- Each capture walks the node's argument references (after the existing
  reifyArgsDebug pass, so revisions are current) and reports debugLabel,
  revision, and a changed flag: whether that argument was invalidated
  since the node's previous render - i.e. whether it caused the most
  recent re-render.

The shared DebugRenderTreeUpdateOpcode/DebugRenderTreeDidRenderOpcode
moved from component.ts into their own module so the helper opcodes can
use them too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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