Skip to content

Add per-render-node reactivity introspection: what does it depend on, and why did it re-render?#2776

Open
NullVoxPopuli-ai-agent wants to merge 6 commits into
emberjs:mainfrom
NullVoxPopuli-ai-agent:render-node-reactivity
Open

Add per-render-node reactivity introspection: what does it depend on, and why did it re-render?#2776
NullVoxPopuli-ai-agent wants to merge 6 commits into
emberjs:mainfrom
NullVoxPopuli-ai-agent:render-node-reactivity

Conversation

@NullVoxPopuli-ai-agent

@NullVoxPopuli-ai-agent NullVoxPopuli-ai-agent commented Jul 4, 2026

Copy link
Copy Markdown

What

Every {{ }} region / component invocation in an Ember app is a reactive output, but today the inspector can't answer the two questions people actually debug with:

  1. What does this thing depend on?
  2. What caused it to change most recently?

This PR adds a per-render-node reactivity report to ember-debug and merges it into the existing object inspector display (no new panel — the current property panes stay the source of truth and get augmented):

  • summary line above the properties: how many times the node re-rendered, and what caused the most recent re-render
  • property rows matching instance properties consumed by tracking frames (via tagMetaFor) get a 🔸 "changed in the most recent re-render" marker (with the revision in the tooltip)
  • the args property row gains the existing dependent-keys disclosure, listing each @arg (and, where tag debug info is available, the tracked properties behind it) with the same changed markers computed properties use

Whenever the inspected node re-renders, ember-debug pushes fresh flags (objectInspector:updateReactivity), applied to the property rows via the same set() mechanism as updateProperty — so the display stays live while you interact with the app.

Reactivity introspection demo

How

ember-debug

  • lib/render-tree-reactivity.js (new) — patches the instance of the app's debug render tree (create / update / captureNode); instance-level patches work in classic and Vite apps, unlike module-export patches.
    • update(state) runs exactly for the nodes on the path to a change (it's driven by DebugRenderTreeUpdateOpcode, which only evaluates when the enclosing block's tag is invalid), so it's the right hook to record "this node re-rendered at revision X". We store { updateCount, revision, previousRevision, timestamp } per internal node in a WeakMap.
    • A dependency caused the most recent re-render iff its tag's revision is > previousRevision. That's how changed flags are computed — pure revision arithmetic, no guessing.
    • captureNode records a "render-node:N" → internal-node map (reset on each capture) so a captured/serialized id can be resolved back to reference-level args (ref.tag, ref.lastRevision, ref.debugLabel) rather than the reified values the captured tree carries.
  • lib/tracked-tags.js (new) — getTagTrackedTags extracted from object-inspector.js so both dependency walkers share one implementation.
  • object-inspector.jsobjectInspector:inspectById accepts an optional renderNodeId; when present, the reactivity report is joined into the updateObject payload (per-property reactivity fields, args dependent keys, summary), and each subsequent re-render of the node pushes objectInspector:updateReactivity from the existing updateCurrentObject runloop hook.
  • Getter dependent keys work on modern Ember again: they relied on _propertyKey/_object tag annotations from the patched tagFor, which recent ember-source never hits (internals are inlined at build time — the reason the ES6-class dependency-names test is test.skipped upstream). getTrackedDependencies now also resolves anonymous dependency tags via the validator's tag meta (this.<prop>) and the args proxy's custom property tags (args.<name>), keeping _propertyKey naming where it exists.
  • Also hardens the existing tagFor/trackedData export patches with try/catch: recent ember-source classic builds expose getter-only module namespaces (like Vite), and the assignment crash previously prevented ember_debug from booting at all on such apps.

inspector app

  • The component tree passes renderNodeId when pinning a component.
  • routes/application.js applies updateReactivity messages to the property models with set() (same pattern as updateProperty).
  • object-inspector.hbs renders the summary line; property.hbs renders the changed marker. Args reuse the existing <ObjectInspector::DependentKeys> disclosure — no new panel components.

Notes / limitations

  • Granularity is the debug render tree's: components, route templates, outlets, modifiers, {{in-element}}. Individual {{ }} curlies inside a template are attributed to their enclosing render node. Going finer would need glimmer-vm to expose block-level tags on the debug render tree — noted as future work.
  • Dependency names: getter dependencies and args are named through the tag meta / args-proxy mechanism above, which works in every build type. The nested dependency lists behind the render node's arg references (state owned by other objects, e.g. the parent component) still depend on the _propertyKey annotations and stay anonymous on recent ember-source; arg names, values, debugLabels, and all changed flags are unaffected.
  • All new hooks are wrapped in try/catch so they can never break rendering, and the whole feature no-ops (reactivity: null, unaugmented properties) on very old Embers.

Testing

  • New tests/ember_debug/reactivity-test.js: inspects a Glimmer component render node via inspectById {objectId, renderNodeId}, asserts the augmented updateObject payload (summary, args dependent keys, per-property reactivity), then mutates internal tracked state and the arg and asserts the pushed updateReactivity messages flag the right cause each time; also asserts plain inspectById (no render node) is unchanged.
  • Full suite: 205 tests, 0 failures.

🤖 Generated with Claude Code

NullVoxPopuli and others added 3 commits July 4, 2026 02:48
Every component invocation / render node is a reactive output. This adds
a way to see, per render tree node:

- what it depends on: its args (with values, revisions, and - where
  available - the tracked properties behind them) and the instance
  properties consumed by tracking frames (via tagMetaFor)
- what caused it to change most recently: the debug render tree's update
  hook records the global revision at each re-render, and dependencies
  dirtied since the previous render are flagged as changed

ember-debug:
- lib/render-tree-reactivity.js: patches the debug render tree instance
  (create/update/captureNode) to record per-node update info and map
  captured ids to internal nodes; builds the reactivity report
- lib/tracked-tags.js: getTagTrackedTags extracted from object-inspector
  so both dependency walkers share it
- view-debug.js: new view:getReactivity message -> view:reactivity reply

inspector UI:
- new Reactivity accordion in the object inspector pane (shown when a
  component tree item is selected): re-render count, what caused the
  last re-render, args and consumed tracked properties with changed
  markers; refreshes on every view:renderTree message

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per review feedback: instead of a separate Reactivity accordion that
duplicated properties already shown in the mixin panes, the existing
property display is the source of truth and gets augmented:

- the component tree passes renderNodeId along with
  objectInspector:inspectById when pinning a component
- ember-debug joins the render node's reactivity report into the
  updateObject payload: property rows matching consumed instance
  properties get a reactivity {revision, changed} field, and the args
  property lists the node's args (and the tracked state behind them)
  through the existing dependent-keys UI
- a slim summary line above the properties shows the re-render count
  and what caused the most recent re-render
- on every re-render of the inspected node, ember-debug pushes
  objectInspector:updateReactivity with fresh flags, applied to the
  property rows with the same set()-based mechanism as updateProperty
- the view:getReactivity message and the separate accordion are gone

Also wraps the tagFor/trackedData export patches in try/catch: recent
ember-source classic builds expose getter-only module namespaces
(like Vite), and the assignment crash previously took down all of
ember_debug on such apps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
When the inspector attaches to an already-running app (bookmarklet, or
opening the inspector after boot), render nodes created before the
patches were applied have no create-time revision, so their first
re-render couldn't report what changed. Stamp a baseline at first
capture instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Author

Here's a walkthrough of the feature in action (demo app on the left, inspector on the right):

Reactivity introspection demo — pin a component, then watch "re-renders / last change" and the 🔸 markers update as you interact with the app

How to use it:

  1. Open the Components tab and click a component to pin it — the object inspector opens as usual.
  2. Above the properties you now get re-renders: N · last change: <what caused it>.
  3. The args row has a disclosure triangle listing each @arg; args that were dirtied in the most recent re-render get the 🔸 marker (same styling as computed dependent keys).
  4. Property rows for instance state consumed by rendering (@tracked fields, etc.) get the same 🔸 marker when they caused the last re-render.
  5. Everything updates live as the app re-renders — in the demo you can see the cause flip between @likes (arg), this.expanded (internal tracked state), and @name as different things trigger renders.

The demo runs the bookmarklet build against classic-test-app (ember-source 7). That flow also exercised the two hardening fixes in this PR: the try/catch around the export patches (ember-source 7 classic builds have getter-only module namespaces, which previously crashed ember_debug on boot) and the late-attach baseline (nodes rendered before the inspector attaches are baselined at first capture, so their first re-render can still report what changed).

🤖 Generated with Claude Code

The dependent keys of getters relied on the _propertyKey/_object
annotations added by the patched tagFor, which recent ember-source
builds never hit (internals are inlined at build time), leaving getter
rows with no dependency info at all on modern apps.

Resolve names for anonymous dependency tags from two sources that work
everywhere:

- the validator's tag meta for the inspected object names consumed own
  properties (this.x)
- the args proxy's custom property tags name named args (args.x)

getTagSubtags (tracked-tags.js) collects all dependency tags, named or
not; getTrackedDependencies keeps the _propertyKey naming when present
and falls back to the local map otherwise.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Author

Second walkthrough, this time centered on a derived value — the summary getter (`${this.args.name} — ${this.args.likes} likes`) rather than args:

Getter dependency demo — expand a getter's dependent keys and watch the changed flag move between its dependencies

  1. The summary getter row now has the dependent-keys disclosure: expanding it shows args → name, likes.
  2. Click "+1 like" → summary recomputes live in the inspector, and the likes dependency gets the changed flag — that's what invalidated the getter.
  3. Rename → the flag moves to name; likes is no longer flagged. You can pinpoint which dependency invalidated a derived value on each change.

This needed one more commit (9e6d404): getter dependent keys previously relied on the _propertyKey/_object tag annotations from the patched tagFor, which never fire on recent ember-source (internals are inlined at build time — the reason the ES6-class dependency-names test is test.skipped upstream). Getter rows therefore showed no dependency info at all on modern apps. getTrackedDependencies now resolves anonymous dependency tags from two sources that work regardless of build type:

  • the validator's tag meta for the inspected object → this.<prop> names
  • the args proxy's custom property tagsargs.<name> names

_propertyKey naming still takes precedence where it exists (older Embers), so previous behavior is preserved there. Covered by a new test (getter dependencies are named and flag what changed); full suite is green (206 tests, 0 failures).

🤖 Generated with Claude Code

Child entries used a plain-text "• --" marker at a slightly different
offset; they now get the same bullet icon and styling as top-level
dependencies, indented one level deeper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
const { debugRenderTree } = this;

const create = debugRenderTree.create;
debugRenderTree.create = function (state, node) {

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.

a little todo for us to add in ember-source.

which means, ember-source should probably add tests for "reactivity introspection" from the debug render tree.... which probably means I need to figure out how to not ship the debug render tree (opt in, obvs)

@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Author

Two more walkthroughs, plus a visual cleanup (6b026d3): nested dependency entries now render exactly like top-level ones — same bullet icon and styling, one indent level deeper — instead of the plain-text • -- marker.

Tracked state in the same class as a getter dependency

toggleLabel derives from @tracked expanded on the same class:

Same-class tracked dependency demo

Toggling flags this.expanded in three places at once: the getter's dependency list, the expanded property row, and the "last change" summary — while summary's dependencies (args → name, likes) stay unflagged.

Modifiers and helpers

Modifier and helper demo

Modifiers are debug render tree nodes, so this works with zero extra code: pin {{pulse @likes}} in the component tree and you get the same summary line — after "+1 like" it reads re-renders: 2 · last change: @0 (the modifier's positional arg).

Helpers are the one gap: glimmer-vm's debug render tree doesn't capture helper invocations (only components, route templates, outlets, engines, modifiers, and {{in-element}}), so there's no node to pin. A helper's reactive inputs attribute to the enclosing component instead — in the last frame, {{formatLikes @likes}} recomputed and UserCard's summary shows last change: @likes. Helper-level granularity needs glimmer-vm to expose helper nodes on the debug render tree; that's the same upstream-work item as per-{{ }}-region granularity already noted in the PR description.

🤖 Generated with Claude Code

Modifiers (and classic components) have no `args` property on their
inspected instance, so their args were invisible in the property list —
only the reactivity summary hinted at them. When the reactivity report
is joined and no `args` property exists, the node's args are now
synthesized as read-only @-prefixed rows (value, dependent keys, changed
marker), refreshed through updateReactivity like everything else.

Arg entries now also carry their value (inspect + type) so the rows stay
current as the app re-renders.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Author

Good catch — modifier args didn't show as property rows before: a modifier's inspected instance has no args property (unlike components), so only the summary line hinted at them. Fixed in 4260138: when the joined render node has no args property, its args are synthesized as read-only @-prefixed rows (value, dependent keys, changed marker), and they refresh live through the same updateReactivity push as everything else. This also covers classic components. Arg entries now carry their value (inspect + type) so the rows stay current as the app re-renders.

Modifier args demo — pin {{pulse @likes}}, the @0 row shows the arg value, updates live, and gets flagged when it caused the re-run

And yes — there's a helper demo (it was the last frame of the previous GIF); here it is as its own clip:

Helper demo — the helper recomputes and the cause shows on its enclosing component

The nuance: glimmer-vm's debug render tree doesn't capture helper invocations (only components, route templates, outlets, engines, modifiers, and {{in-element}}), so there is no helper node to pin. A helper's reactive inputs attribute to the enclosing component — when {{formatLikes @likes}} recomputes, UserCard's summary reads last change: @likes. Helper-level pinning would need glimmer-vm to expose helper nodes on the debug render tree (same upstream item as per-{{ }}-region granularity from the PR notes).

Tests: two new ones — {{on}}'s args listing under its args property, and synthesized @label rows for a render node whose instance lacks args. Full suite: 208 tests, 0 failures.

🤖 Generated with Claude Code

@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Author

Upstream follow-up: emberjs/ember.js#21484 moves these capabilities into the VM itself — helper invocations become first-class debug render tree nodes (type: 'helper'), and every captured node carries a reactivity field (update count, last/previous render revisions, and per-argument changed flags). Once that lands and is released, this PR's instance patches on the debug render tree can be dropped in favor of reading node.reactivity directly, and the inspector gains helper-level display that no amount of patching could provide today.

🤖 Generated with Claude Code

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