Add per-render-node reactivity introspection: what does it depend on, and why did it re-render?#2776
Conversation
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>
|
Here's a walkthrough of the feature in action (demo app on the left, inspector on the right): How to use it:
The demo runs the bookmarklet build against 🤖 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>
|
Second walkthrough, this time centered on a derived value — the
This needed one more commit (9e6d404): getter dependent keys previously relied on the
🤖 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) { |
There was a problem hiding this comment.
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)
|
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 Tracked state in the same class as a getter dependency
Toggling flags Modifiers and helpersModifiers are debug render tree nodes, so this works with zero extra code: pin Helpers are the one gap: glimmer-vm's debug render tree doesn't capture helper invocations (only components, route templates, outlets, engines, modifiers, and 🤖 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>
|
Good catch — modifier args didn't show as property rows before: a modifier's inspected instance has no And yes — there's a helper demo (it was the last frame of the previous GIF); here it is as its own clip: The nuance: glimmer-vm's debug render tree doesn't capture helper invocations (only components, route templates, outlets, engines, modifiers, and Tests: two new ones — 🤖 Generated with Claude Code |
|
Upstream follow-up: emberjs/ember.js#21484 moves these capabilities into the VM itself — helper invocations become first-class debug render tree nodes ( 🤖 Generated with Claude Code |






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:This PR adds a per-render-node reactivity report to
ember-debugand merges it into the existing object inspector display (no new panel — the current property panes stay the source of truth and get augmented):tagMetaFor) get a 🔸 "changed in the most recent re-render" marker (with the revision in the tooltip)argsproperty 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 useWhenever the inspected node re-renders,
ember-debugpushes fresh flags (objectInspector:updateReactivity), applied to the property rows via the sameset()mechanism asupdateProperty— so the display stays live while you interact with the app.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 byDebugRenderTreeUpdateOpcode, 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.> previousRevision. That's howchangedflags are computed — pure revision arithmetic, no guessing.captureNoderecords 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) —getTagTrackedTagsextracted fromobject-inspector.jsso both dependency walkers share one implementation.object-inspector.js—objectInspector:inspectByIdaccepts an optionalrenderNodeId; when present, the reactivity report is joined into theupdateObjectpayload (per-propertyreactivityfields,argsdependent keys, summary), and each subsequent re-render of the node pushesobjectInspector:updateReactivityfrom the existingupdateCurrentObjectrunloop hook._propertyKey/_objecttag annotations from the patchedtagFor, which recent ember-source never hits (internals are inlined at build time — the reason the ES6-class dependency-names test istest.skipped upstream).getTrackedDependenciesnow also resolves anonymous dependency tags via the validator's tag meta (this.<prop>) and the args proxy's custom property tags (args.<name>), keeping_propertyKeynaming where it exists.tagFor/trackedDataexport patches with try/catch: recent ember-source classic builds expose getter-only module namespaces (like Vite), and the assignment crash previously preventedember_debugfrom booting at all on such apps.inspector app
renderNodeIdwhen pinning a component.routes/application.jsappliesupdateReactivitymessages to the property models withset()(same pattern asupdateProperty).object-inspector.hbsrenders the summary line;property.hbsrenders the changed marker. Args reuse the existing<ObjectInspector::DependentKeys>disclosure — no new panel components.Notes / limitations
{{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._propertyKeyannotations and stay anonymous on recent ember-source; arg names, values,debugLabels, and allchangedflags are unaffected.reactivity: null, unaugmented properties) on very old Embers.Testing
tests/ember_debug/reactivity-test.js: inspects a Glimmer component render node viainspectById {objectId, renderNodeId}, asserts the augmentedupdateObjectpayload (summary,argsdependent keys, per-property reactivity), then mutates internal tracked state and the arg and asserts the pushedupdateReactivitymessages flag the right cause each time; also asserts plaininspectById(no render node) is unchanged.🤖 Generated with Claude Code