From 71330e7341083c5db7d3930e58f887bb0e1b77cf Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Sat, 4 Jul 2026 02:48:02 -0400 Subject: [PATCH 1/6] Add per-render-node reactivity introspection (why did this render?) 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 --- .../ember-debug/lib/render-tree-reactivity.js | 314 ++++++++++++++++++ packages/ember-debug/lib/render-tree.js | 51 ++- packages/ember-debug/lib/tracked-tags.js | 33 ++ packages/ember-debug/object-inspector.js | 23 +- packages/ember-debug/view-debug.js | 7 + .../app/components/object-inspector.hbs | 1 + .../object-inspector/reactivity.hbs | 113 +++++++ .../components/object-inspector/reactivity.js | 113 +++++++ .../tests/ember_debug/reactivity-test.js | 244 ++++++++++++++ 9 files changed, 874 insertions(+), 25 deletions(-) create mode 100644 packages/ember-debug/lib/render-tree-reactivity.js create mode 100644 packages/ember-debug/lib/tracked-tags.js create mode 100644 packages/ember-inspector/app/components/object-inspector/reactivity.hbs create mode 100644 packages/ember-inspector/app/components/object-inspector/reactivity.js create mode 100644 packages/ember-inspector/tests/ember_debug/reactivity-test.js diff --git a/packages/ember-debug/lib/render-tree-reactivity.js b/packages/ember-debug/lib/render-tree-reactivity.js new file mode 100644 index 0000000000..34b149ded3 --- /dev/null +++ b/packages/ember-debug/lib/render-tree-reactivity.js @@ -0,0 +1,314 @@ +import { GlimmerReference, GlimmerValidator } from './ember.js'; +import getObjectName from './get-object-name.js'; +import { inspect } from './type-check.js'; +import { getTagTrackedTags } from './tracked-tags.js'; + +// Read-only accessors into @glimmer/validator and @glimmer/reference. +// Unlike the export patches in object-inspector.js, reading these is safe +// in every build type (classic AMD builds as well as Vite builds, where +// the module namespaces are read-only). +const valueForTag = GlimmerValidator?.valueForTag || GlimmerValidator?.value; +const CURRENT_TAG = GlimmerValidator?.CURRENT_TAG; +const tagMetaFor = GlimmerValidator?.tagMetaFor; +const valueForRef = GlimmerReference?.valueForRef; +const REFERENCE = GlimmerReference?.REFERENCE; + +function currentRevision() { + return valueForTag(CURRENT_TAG); +} + +function isRef(value) { + if (!value || typeof value !== 'object') { + return false; + } + if (REFERENCE) { + return REFERENCE in value; + } + return 'lastRevision' in value && 'tag' in value; +} + +/** + * Tracks *why* render tree nodes (components, route templates, modifiers โ€” + * every reactive output the debug render tree knows about) re-render. + * + * It hooks the debug render tree's lifecycle to record when each node last + * re-rendered (and the global revision at that point), and walks the tags + * behind the node's args and the consumed properties of its instance, so + * the inspector can show: + * + * - what a render node depends on, and + * - which of those dependencies were dirtied since the previous render, + * i.e. what caused it to change most recently. + */ +export default class RenderTreeReactivity { + constructor(debugRenderTree) { + if (!debugRenderTree || !valueForTag || !CURRENT_TAG) { + throw new Error( + 'Reactivity introspection is not supported by this app/Ember version', + ); + } + + this.debugRenderTree = debugRenderTree; + + // internal render tree node -> update info. WeakMap so we never retain + // nodes (and through them, component instances) of destroyed subtrees. + this.updates = new WeakMap(); + + // "render-node:N" -> internal render tree node, rebuilt on each capture. + this.capturedNodes = Object.create(null); + + this._patch(); + } + + _patch() { + const self = this; + const { debugRenderTree } = this; + + const create = debugRenderTree.create; + debugRenderTree.create = function (state, node) { + const result = create.call(this, state, node); + try { + self.updates.set(this.nodeFor(state), { + updateCount: 0, + revision: currentRevision(), + previousRevision: null, + timestamp: Date.now(), + initialRender: true, + }); + } catch { + // never break rendering + } + return result; + }; + + // The update hook runs exactly for the nodes on the path to a change, + // which makes it the right place to snapshot "this node re-rendered at + // revision X". Dependencies dirtied between the previous revision and + // this one are what caused the re-render. + const update = debugRenderTree.update; + debugRenderTree.update = function (state) { + try { + const node = this.nodeFor(state); + let info = self.updates.get(node); + if (!info) { + info = self._newUpdateInfo(); + self.updates.set(node, info); + } + info.previousRevision = info.revision; + info.revision = currentRevision(); + info.updateCount++; + info.timestamp = Date.now(); + info.initialRender = false; + } catch { + // never break rendering + } + return update.call(this, state); + }; + + const captureNode = debugRenderTree.captureNode; + debugRenderTree.captureNode = function (id, state) { + try { + self.capturedNodes[id] = this.nodeFor(state); + } catch { + // never break capturing + } + return captureNode.call(this, id, state); + }; + + this.originals = { create, update, captureNode }; + } + + _newUpdateInfo() { + return { + updateCount: 0, + revision: null, + previousRevision: null, + timestamp: null, + initialRender: true, + }; + } + + teardown() { + Object.assign(this.debugRenderTree, this.originals); + this.capturedNodes = Object.create(null); + } + + /** + * Called right before a render tree capture so ids of removed render + * nodes don't pile up (and don't retain their nodes/instances). + */ + beginCapture() { + this.capturedNodes = Object.create(null); + } + + /** + * Build the reactivity report for a captured render node id. + * + * @param {string} id The unprefixed render node id ("render-node:N"). + * @return {Option} The report, or null if the node is unknown. + */ + getReport(id) { + const node = this.capturedNodes[id]; + + if (!node) { + return null; + } + + const info = this.updates.get(node) || null; + + // Everything dirtied after the previous re-render (or after the initial + // render, if the node never updated) caused the most recent change. + const baseline = info ? (info.previousRevision ?? info.revision) : null; + + const report = { + updateCount: info ? info.updateCount : 0, + lastRender: + info && info.timestamp !== null + ? { + revision: info.revision, + timestamp: info.timestamp, + initial: info.initialRender, + } + : null, + args: { named: [], positional: [] }, + tracked: [], + }; + + this._collectArgs(report.args, node.args, baseline); + this._collectTracked(report.tracked, node.instance, baseline); + + return report; + } + + _collectArgs(out, args, baseline) { + if (!args || typeof args !== 'object') { + return; + } + + const named = args.named || {}; + const positional = args.positional || []; + + Object.keys(named).forEach((name) => { + const ref = named[name]; + + // `{{component}}`/curried invocations pass their named args bundled + // in a single __ARGS__ reference; unpack it so the individual args + // show up under their own names. + if (name === '__ARGS__' && isRef(ref)) { + const inner = this._safeValueForRef(ref); + if (inner && typeof inner === 'object') { + Object.keys(inner).forEach((innerName) => { + if (isRef(inner[innerName])) { + out.named.push( + this._describeRef(innerName, inner[innerName], baseline), + ); + } + }); + return; + } + } + + out.named.push(this._describeRef(name, ref, baseline)); + }); + + positional.forEach((ref, index) => { + out.positional.push(this._describeRef(String(index), ref, baseline)); + }); + } + + _safeValueForRef(ref) { + if (!valueForRef) { + return undefined; + } + try { + return valueForRef(ref); + } catch { + return undefined; + } + } + + _describeRef(name, ref, baseline) { + const entry = { name, dependencies: [] }; + + if (!isRef(ref)) { + // in-element/html-element nodes store plain values in their args + entry.inspect = inspect(ref); + return entry; + } + + if (typeof ref.debugLabel === 'string') { + // only present in debug builds of glimmer-vm + entry.debugLabel = ref.debugLabel; + } + + entry.inspect = inspect(this._safeValueForRef(ref)); + + const tag = ref.tag; + if (tag) { + entry.revision = valueForTag(tag); + entry.changed = baseline !== null && entry.revision > baseline; + entry.dependencies = this._collectTagDependencies(tag, baseline); + } + + return entry; + } + + _collectTagDependencies(tag, baseline) { + const tags = getTagTrackedTags(tag, null); + + // A reference that consumed a single tag gets that very tag instead of + // a combinator, so the walked list misses it. + if (tag._propertyKey && !tags.includes(tag)) { + tags.unshift(tag); + } + + const byName = new Map(); + + tags.forEach((t) => { + const objectName = t._object ? getObjectName(t._object) : ''; + const name = (objectName ? `${objectName}.` : '') + t._propertyKey; + const revision = valueForTag(t); + const existing = byName.get(name); + if (!existing || revision > existing.revision) { + byName.set(name, { + name, + revision, + changed: baseline !== null && revision > baseline, + }); + } + }); + + return [...byName.values()].sort((a, b) => b.revision - a.revision); + } + + _collectTracked(out, instance, baseline) { + if (!instance || typeof instance !== 'object' || !tagMetaFor) { + return; + } + + let tagMeta; + try { + tagMeta = tagMetaFor(instance); + } catch { + return; + } + + if (!tagMeta || typeof tagMeta.forEach !== 'function') { + return; + } + + // The tag meta holds a tag for every property of the instance that was + // ever consumed by a tracking frame โ€” that is, the instance state this + // render node (or anything else) reactively depends on. + tagMeta.forEach((tag, key) => { + const revision = valueForTag(tag); + out.push({ + name: String(key), + revision, + changed: baseline !== null && revision > baseline, + }); + }); + + out.sort((a, b) => b.revision - a.revision); + } +} diff --git a/packages/ember-debug/lib/render-tree.js b/packages/ember-debug/lib/render-tree.js index c9eb1501b1..7c735599d0 100644 --- a/packages/ember-debug/lib/render-tree.js +++ b/packages/ember-debug/lib/render-tree.js @@ -2,6 +2,7 @@ import captureRenderTree from './capture-render-tree.js'; import { guidFor } from './ember/object/internals.js'; import { inspect } from './type-check.js'; import { isInVersionSpecifier } from '../utils/version.js'; +import RenderTreeReactivity from './render-tree-reactivity.js'; import { VERSION, EmberDestroyable, @@ -11,6 +12,13 @@ import { GlimmerUtil, } from './ember.js'; +function lookupDebugRenderTree(owner) { + return ( + owner.lookup('renderer:-dom')?.debugRenderTree || + owner.lookup('service:-glimmer-environment')._debugRenderTree + ); +} + class InElementSupportProvider { constructor(owner) { this.nodeMap = new Map(); @@ -27,9 +35,7 @@ class InElementSupportProvider { this.DESTROY = GlimmerUtil?.DESTROY; this.registerDestructor = EmberDestroyable?.registerDestructor; - this.debugRenderTree = - owner.lookup('renderer:-dom')?.debugRenderTree || - owner.lookup('service:-glimmer-environment')._debugRenderTree; + this.debugRenderTree = lookupDebugRenderTree(owner); this.NewElementBuilder = this.runtime.NewElementBuilder || this.runtime.NewTreeBuilder; @@ -310,6 +316,13 @@ export default class RenderTree { // not supported } + try { + this.reactivity = new RenderTreeReactivity(lookupDebugRenderTree(owner)); + } catch { + // not supported (very old Ember versions, or no glimmer validator) + this.reactivity = null; + } + // need to have different ids per application / iframe // to distinguish the render nodes it in the inspector // between apps @@ -343,6 +356,7 @@ export default class RenderTree { build() { this._reset(); + this.reactivity?.beginCapture(); this.tree = captureRenderTree(this.owner); let serialized = this._serializeRenderNodes(this.tree); @@ -526,6 +540,37 @@ export default class RenderTree { this._reset(); this._releaseStaleObjects(); this.inElementSupport?.teardown(); + this.reactivity?.teardown(); + } + + /** + * Get the reactivity report for a given render node id: what the node + * depends on (args and consumed instance properties) and which of those + * dependencies changed since its previous render โ€” i.e. what caused it + * to change most recently. + * + * @method getReactivity + * @param {string} id A render node id. + * @return {Option} The report, or null when not available. + */ + getReactivity(id) { + if (!this.reactivity) { + return null; + } + + // The captured ids are prefixed per application (see + // _serializeRenderNode); the debug render tree only knows the raw ids. + let rawId = id; + const prefix = `${this.renderNodeIdPrefix}-`; + if (rawId.startsWith(prefix)) { + rawId = rawId.slice(prefix.length); + } + + const report = this.reactivity.getReport(rawId); + if (report) { + report.id = id; + } + return report; } _reset() { diff --git a/packages/ember-debug/lib/tracked-tags.js b/packages/ember-debug/lib/tracked-tags.js new file mode 100644 index 0000000000..6f68fb9a81 --- /dev/null +++ b/packages/ember-debug/lib/tracked-tags.js @@ -0,0 +1,33 @@ +/** + * Recursively collect the tags a (combined) tag depends on, keeping only + * the ones that carry debug info (`_propertyKey`/`_object`, added by the + * `tagFor` patch in object-inspector.js) so they can be given a + * human-readable name. + * + * @param {Tag} tag The tag to walk. + * @param {Tag} ownTag A tag to exclude from the result (usually the tag of + * the property being inspected itself). + * @param {number} level Current recursion depth. + * @return {Tag[]} The dependency tags. + */ +export function getTagTrackedTags(tag, ownTag, level = 0) { + const props = []; + // do not include tracked properties from dependencies + if (!tag || level > 1) { + return props; + } + const subtags = tag.subtags || (Array.isArray(tag.subtag) ? tag.subtag : []); + if (tag.subtag && !Array.isArray(tag.subtag)) { + if (tag.subtag._propertyKey) props.push(tag.subtag); + + props.push(...getTagTrackedTags(tag.subtag, ownTag, level + 1)); + } + if (subtags) { + subtags.forEach((t) => { + if (t === ownTag) return; + if (t._propertyKey) props.push(t); + props.push(...getTagTrackedTags(t, ownTag, level + 1)); + }); + } + return props; +} diff --git a/packages/ember-debug/object-inspector.js b/packages/ember-debug/object-inspector.js index c4ae7c96e8..23480bb92f 100644 --- a/packages/ember-debug/object-inspector.js +++ b/packages/ember-debug/object-inspector.js @@ -27,6 +27,7 @@ import { cacheFor, guidFor } from './lib/ember/object/internals.js'; import { _backburner, join } from './lib/ember/runloop.js'; import emberNames from './lib/ember-object-names.js'; import getObjectName from './lib/get-object-name.js'; +import { getTagTrackedTags } from './lib/tracked-tags.js'; let tagValue, tagValidate, track, tagForProperty; @@ -139,28 +140,6 @@ function isMandatorySetter(descriptor) { return false; } -function getTagTrackedTags(tag, ownTag, level = 0) { - const props = []; - // do not include tracked properties from dependencies - if (!tag || level > 1) { - return props; - } - const subtags = tag.subtags || (Array.isArray(tag.subtag) ? tag.subtag : []); - if (tag.subtag && !Array.isArray(tag.subtag)) { - if (tag.subtag._propertyKey) props.push(tag.subtag); - - props.push(...getTagTrackedTags(tag.subtag, ownTag, level + 1)); - } - if (subtags) { - subtags.forEach((t) => { - if (t === ownTag) return; - if (t._propertyKey) props.push(t); - props.push(...getTagTrackedTags(t, ownTag, level + 1)); - }); - } - return props; -} - function getTrackedDependencies(object, property, tagInfo) { const tag = tagInfo.tag; const proto = Object.getPrototypeOf(object); diff --git a/packages/ember-debug/view-debug.js b/packages/ember-debug/view-debug.js index 566076c55a..a6f8cdb5c1 100644 --- a/packages/ember-debug/view-debug.js +++ b/packages/ember-debug/view-debug.js @@ -20,6 +20,13 @@ export default class extends DebugPort { this.sendTree(immediate); }, + getReactivity({ id }) { + this.sendMessage('reactivity', { + id, + reactivity: this.renderTree.getReactivity(id), + }); + }, + showInspection({ id, pin }) { this.viewInspection.show(id, pin); }, diff --git a/packages/ember-inspector/app/components/object-inspector.hbs b/packages/ember-inspector/app/components/object-inspector.hbs index cfa1a6c1ee..085332bf59 100644 --- a/packages/ember-inspector/app/components/object-inspector.hbs +++ b/packages/ember-inspector/app/components/object-inspector.hbs @@ -111,6 +111,7 @@ {{/if}} + {{else}} diff --git a/packages/ember-inspector/app/components/object-inspector/reactivity.hbs b/packages/ember-inspector/app/components/object-inspector/reactivity.hbs new file mode 100644 index 0000000000..a390010e80 --- /dev/null +++ b/packages/ember-inspector/app/components/object-inspector/reactivity.hbs @@ -0,0 +1,113 @@ +{{#if @item.renderNode}} + +
+

+ Reactivity +

+ {{#if accordion.isExpanded}} + {{#if this.reactivity}} +
    +
  • + re-renders + + {{this.reactivity.updateCount}} + +
  • + + {{#if this.causes.length}} +
  • + last change caused by +
  • + {{#each this.causes as |cause|}} +
  • + {{cause}} ๐Ÿ”ธ +
  • + {{/each}} + {{/if}} + + {{#each this.namedArgs as |arg|}} +
  • + + @{{arg.name}} + + + {{arg.inspect}} + + {{#if arg.changed}}๐Ÿ”ธ{{/if}} +
  • + {{#if arg.dependencies.length}} + + {{/if}} + {{/each}} + + {{#each this.positionalArgs as |arg|}} +
  • + + @{{arg.name}} + + + {{arg.inspect}} + + {{#if arg.changed}}๐Ÿ”ธ{{/if}} +
  • + {{#if arg.dependencies.length}} + + {{/if}} + {{/each}} + + {{#each this.trackedProps as |prop|}} +
  • + + this.{{prop.name}} + + {{#if prop.changed}}๐Ÿ”ธ{{/if}} +
  • + {{/each}} +
+ {{else}} +
    +
  • + Not available for this app +
  • +
+ {{/if}} + {{/if}} +
+
+{{/if}} diff --git a/packages/ember-inspector/app/components/object-inspector/reactivity.js b/packages/ember-inspector/app/components/object-inspector/reactivity.js new file mode 100644 index 0000000000..b0d42298c7 --- /dev/null +++ b/packages/ember-inspector/app/components/object-inspector/reactivity.js @@ -0,0 +1,113 @@ +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; + +/** + * Shows why a render node (component invocation, route template, โ€ฆ) + * rendered: what it depends on โ€” its args and the instance properties it + * consumes โ€” and which of those dependencies changed most recently. + * + * The data comes from the `view:getReactivity` / `view:reactivity` + * messages; it is re-requested whenever the app sends a new render tree so + * the pane stays live while the app re-renders. + */ +export default class Reactivity extends Component { + @service port; + + @tracked reactivity = null; + + constructor() { + super(...arguments); + + this.port.on('view:reactivity', this, this.receiveReactivity); + this.port.on('view:renderTree', this, this.requestReactivity); + } + + willDestroy() { + super.willDestroy(...arguments); + + this.port.off('view:reactivity', this, this.receiveReactivity); + this.port.off('view:renderTree', this, this.requestReactivity); + } + + get accordionMixin() { + return { + name: 'reactivity', + expand: true, + properties: { + length: 1, + }, + }; + } + + get renderNodeId() { + return this.args.item?.renderNode?.id; + } + + @action requestReactivity() { + if (this.renderNodeId) { + this.port.send('view:getReactivity', { id: this.renderNodeId }); + } + } + + @action receiveReactivity({ id, reactivity }) { + if (id === this.renderNodeId) { + this.reactivity = reactivity; + } + } + + get namedArgs() { + return this.reactivity?.args?.named ?? []; + } + + get positionalArgs() { + return this.reactivity?.args?.positional ?? []; + } + + get trackedProps() { + return this.reactivity?.tracked ?? []; + } + + /** + * The dependencies that were dirtied since the previous render โ€” what + * caused the most recent re-render of this node. + */ + get causes() { + if (!this.reactivity || !this.reactivity.updateCount) { + return []; + } + + const causes = new Set(); + + for (const arg of this.namedArgs) { + if (arg.changed) { + causes.add(`@${arg.name}`); + } + for (const dep of arg.dependencies ?? []) { + if (dep.changed) { + causes.add(dep.name); + } + } + } + + for (const arg of this.positionalArgs) { + if (arg.changed) { + causes.add(`@${arg.name}`); + } + for (const dep of arg.dependencies ?? []) { + if (dep.changed) { + causes.add(dep.name); + } + } + } + + for (const prop of this.trackedProps) { + if (prop.changed) { + causes.add(`this.${prop.name}`); + } + } + + return [...causes]; + } +} diff --git a/packages/ember-inspector/tests/ember_debug/reactivity-test.js b/packages/ember-inspector/tests/ember_debug/reactivity-test.js new file mode 100644 index 0000000000..acbd51d67e --- /dev/null +++ b/packages/ember-inspector/tests/ember_debug/reactivity-test.js @@ -0,0 +1,244 @@ +import { rerender, visit } from '@ember/test-helpers'; +import { setComponentTemplate } from '@ember/component'; +import Controller from '@ember/controller'; +import QUnit, { module, test } from 'qunit'; +import { hbs } from 'ember-cli-htmlbars'; +import GlimmerComponent from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; + +import setupEmberDebugTest from '../helpers/setup-ember-debug-test'; +import EmberDebugImport from 'ember-debug/main'; + +let EmberDebug; + +// TODO switch to an adapter architecture, similar to the acceptance tests +async function captureMessage(type, callback) { + if (!EmberDebug.port) { + throw new Error('Cannot call captureMessage without a port'); + } + + let send = EmberDebug.port.send; + + try { + let captured; + + const receivedPromise = new Promise((resolve) => { + setTimeout(resolve, 500); + EmberDebug.port.send = (name, message) => { + if (!captured && name === type) { + resolve(); + captured = JSON.parse(JSON.stringify(message)); + } else { + send.call(EmberDebug.port, name, message); + } + }; + }); + + await callback(); + await receivedPromise; + + if (captured) { + return captured; + } else { + throw new Error(`Did not send a message of type ${type}`); + } + } finally { + EmberDebug.port.send = send; + } +} + +async function getRenderTree() { + let message = await captureMessage('view:renderTree', async () => { + EmberDebug.port.trigger('view:getTree', {}); + }); + + if (message) { + return message.tree; + } +} + +async function getReactivity(id) { + let message = await captureMessage('view:reactivity', async () => { + EmberDebug.port.trigger('view:getReactivity', { id }); + }); + + QUnit.assert.strictEqual(message.id, id, 'reactivity reply echoes the id'); + + return message.reactivity; +} + +function findNode(nodes, predicate) { + for (let node of nodes) { + if (predicate(node)) { + return node; + } + + let found = findNode(node.children, predicate); + + if (found) { + return found; + } + } + + return null; +} + +async function getCounterReactivity() { + let tree = await getRenderTree(); + let node = findNode( + tree, + (n) => n.type === 'component' && n.name === 'reactive-counter', + ); + + QUnit.assert.ok(node, 'the reactive-counter render node was found'); + + return getReactivity(node.id); +} + +let counterInstance = null; + +module('Ember Debug - Reactivity', function (hooks) { + hooks.before(async function () { + EmberDebug = (await EmberDebugImport).default(); + }); + + setupEmberDebugTest(hooks, { + routes() { + this.route('reactive'); + }, + }); + + hooks.beforeEach(function () { + EmberDebug.IGNORE_DEPRECATIONS = true; + counterInstance = null; + + this.owner.register( + 'controller:reactive', + class ReactiveController extends Controller { + @tracked title = 'first title'; + }, + ); + + this.owner.register( + 'component:reactive-counter', + setComponentTemplate( + hbs( + '
{{@title}}: {{this.count}}
', + { moduleName: 'my-app/components/reactive-counter.hbs' }, + ), + class ReactiveCounter extends GlimmerComponent { + @tracked count = 0; + + constructor(...args) { + super(...args); + counterInstance = this; + } + }, + ), + ); + + this.owner.register( + 'template:reactive', + hbs('', { + moduleName: 'my-app/templates/reactive.hbs', + }), + ); + }); + + hooks.afterEach(function () { + counterInstance = null; + }); + + test('it reports args, consumed tracked properties, and what changed', async function (assert) { + await visit('/reactive'); + + let reactivity = await getCounterReactivity(); + + assert.ok(reactivity, 'a reactivity report is available'); + assert.strictEqual( + reactivity.updateCount, + 0, + 'no re-renders after the initial render', + ); + + let title = reactivity.args.named.find((arg) => arg.name === 'title'); + assert.ok(title, 'the @title arg is reported'); + assert.notOk(title.changed, '@title has not changed'); + assert.ok( + Array.isArray(title.dependencies), + '@title has a (possibly empty) dependency list', + ); + assert.strictEqual(title.inspect, '"first title"', '@title value shown'); + + let count = reactivity.tracked.find((prop) => prop.name === 'count'); + assert.ok(count, 'the consumed tracked property is reported'); + assert.notOk(count.changed, 'count has not changed'); + + // Change internal tracked state + counterInstance.count++; + await rerender(); + + reactivity = await getCounterReactivity(); + + assert.strictEqual( + reactivity.updateCount, + 1, + 're-rendered once after count changed', + ); + assert.ok(reactivity.lastRender, 'last render info is available'); + assert.notOk( + reactivity.lastRender.initial, + 'last render is no longer the initial one', + ); + + count = reactivity.tracked.find((prop) => prop.name === 'count'); + assert.ok( + count.changed, + 'count is flagged as the cause of the re-render', + ); + + title = reactivity.args.named.find((arg) => arg.name === 'title'); + assert.notOk(title.changed, '@title did not cause the re-render'); + + // Change the arg + this.owner.lookup('controller:reactive').title = 'second title'; + await rerender(); + + reactivity = await getCounterReactivity(); + + assert.strictEqual( + reactivity.updateCount, + 2, + 're-rendered again after the arg changed', + ); + + title = reactivity.args.named.find((arg) => arg.name === 'title'); + assert.ok( + title.changed, + '@title is flagged as the cause of the re-render', + ); + assert.strictEqual( + title.inspect, + '"second title"', + '@title shows the new value', + ); + + count = reactivity.tracked.find((prop) => prop.name === 'count'); + assert.notOk(count.changed, 'count did not cause the re-render'); + }); + + test('it returns null for unknown render node ids', async function (assert) { + await visit('/reactive'); + + // Populate the captured node map + await getRenderTree(); + + let reactivity = await getReactivity('render-node:does-not-exist'); + + assert.strictEqual( + reactivity, + null, + 'no reactivity report for unknown nodes', + ); + }); +}); From bc24361998d3412ed68474aa126a69c9209d2e19 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Sat, 4 Jul 2026 10:43:58 -0400 Subject: [PATCH 2/6] Merge reactivity info into the existing object inspector display 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 --- packages/ember-debug/object-inspector.js | 200 +++++++++++++++--- packages/ember-debug/view-debug.js | 7 - .../app/components/object-inspector.hbs | 22 +- .../components/object-inspector/property.hbs | 7 + .../object-inspector/reactivity.hbs | 113 ---------- .../components/object-inspector/reactivity.js | 113 ---------- .../app/controllers/component-tree.js | 1 + .../ember-inspector/app/routes/application.js | 43 ++++ .../tests/ember_debug/reactivity-test.js | 153 ++++++++------ 9 files changed, 333 insertions(+), 326 deletions(-) delete mode 100644 packages/ember-inspector/app/components/object-inspector/reactivity.hbs delete mode 100644 packages/ember-inspector/app/components/object-inspector/reactivity.js diff --git a/packages/ember-debug/object-inspector.js b/packages/ember-debug/object-inspector.js index 23480bb92f..0a8eac6506 100644 --- a/packages/ember-debug/object-inspector.js +++ b/packages/ember-debug/object-inspector.js @@ -45,34 +45,40 @@ if (GlimmerValidator && !globalThis.emberInspectorApps) { tagValidate = GlimmerValidator.validate || GlimmerValidator.validateTag; track = GlimmerValidator.track; - // patch tagFor to add debug info, older versions already have _propertyKey - const tagFor = GlimmerValidator.tagFor; - GlimmerValidator.tagFor = function (...args) { - const tag = tagFor.call(this, ...args); - const [obj, key] = args; - if ( - (!tag._propertyKey || !tag._object) && - typeof obj === 'object' && - typeof key === 'string' - ) { - tag._propertyKey = key; - tag._object = obj; - } - return tag; - }; - const trackedData = GlimmerValidator.trackedData; - GlimmerValidator.trackedData = function (...args) { - const r = trackedData.call(this, ...args); - if (r.getter && args.length === 2) { - const [key] = args; - const getter = r.getter; - r.getter = function (self) { - GlimmerValidator.tagFor(self, key); - return getter.call(this, self); - }; - } - return r; - }; + try { + // patch tagFor to add debug info, older versions already have _propertyKey + const tagFor = GlimmerValidator.tagFor; + GlimmerValidator.tagFor = function (...args) { + const tag = tagFor.call(this, ...args); + const [obj, key] = args; + if ( + (!tag._propertyKey || !tag._object) && + typeof obj === 'object' && + typeof key === 'string' + ) { + tag._propertyKey = key; + tag._object = obj; + } + return tag; + }; + const trackedData = GlimmerValidator.trackedData; + GlimmerValidator.trackedData = function (...args) { + const r = trackedData.call(this, ...args); + if (r.getter && args.length === 2) { + const [key] = args; + const getter = r.getter; + r.getter = function (self) { + GlimmerValidator.tagFor(self, key); + return getter.call(this, self); + }; + } + return r; + }; + } catch { + // The module namespace has getter-only exports (recent ember-source + // classic builds, similar to Vite): skip the debug-info patches. + // Dependency names degrade gracefully without them. + } } else if (GlimmerReference) { tagValue = GlimmerReference.value; tagValidate = GlimmerReference.validate; @@ -140,6 +146,79 @@ function isMandatorySetter(descriptor) { return false; } +function reactivityArgEntries(report) { + const entries = []; + report.args.named.forEach((arg) => { + entries.push({ name: `@${arg.name}`, changed: arg.changed || false }); + (arg.dependencies || []).forEach((dep) => { + const child = { child: dep.name }; + if (dep.changed) { + child.changed = true; + } + entries.push(child); + }); + }); + report.args.positional.forEach((arg) => { + entries.push({ name: `@${arg.name}`, changed: arg.changed || false }); + }); + return entries; +} + +function reactivitySummary(report) { + const causes = new Set(); + + [...report.args.named, ...report.args.positional].forEach((arg) => { + if (arg.changed) { + causes.add(`@${arg.name}`); + } + (arg.dependencies || []).forEach((dep) => { + if (dep.changed) { + causes.add(dep.name); + } + }); + }); + report.tracked.forEach((prop) => { + if (prop.changed) { + causes.add(`this.${prop.name}`); + } + }); + + return { + updateCount: report.updateCount, + lastRender: report.lastRender, + causes: [...causes], + }; +} + +function applyReactivityToProperties(mixins, report) { + const tracked = Object.create(null); + report.tracked.forEach((prop) => { + tracked[prop.name] = prop; + }); + + const argEntries = reactivityArgEntries(report); + + mixins.forEach((mixin) => { + mixin.properties.forEach((item) => { + const t = tracked[item.name]; + if (t) { + item.reactivity = { revision: t.revision, changed: t.changed }; + } + + // Surface the render node's args on the `args` property using the + // same dependent-keys UI computed properties use, unless something + // (e.g. a computed property) already claimed it. + if ( + item.name === 'args' && + argEntries.length && + !item.dependentKeys?.length + ) { + item.dependentKeys = argEntries; + } + }); + }); +} + function getTrackedDependencies(object, property, tagInfo) { const tag = tagInfo.tag; const proto = Object.getPrototypeOf(object); @@ -299,7 +378,38 @@ export default class extends DebugPort { } }); }); + + this.updateCurrentReactivity(); + } + } + + /** + * When the inspected object is a render tree node (pinned from the + * component tree), push fresh reactivity info to the UI whenever the + * node re-renders: an updated summary (re-render count, what caused the + * re-render) plus per-property and per-arg changed flags. + */ + updateCurrentReactivity() { + const { renderNodeId, objectId, lastUpdateCount } = this.currentObject; + + if (!renderNodeId) { + return; } + + const report = this.getReactivityReport(renderNodeId); + + if (!report || report.updateCount === lastUpdateCount) { + return; + } + + this.currentObject.lastUpdateCount = report.updateCount; + + this.sendMessage('updateReactivity', { + objectId, + reactivity: reactivitySummary(report), + properties: report.tracked, + args: reactivityArgEntries(report), + }); } // eslint-disable-next-line ember/classic-decorator-hooks @@ -404,7 +514,7 @@ export default class extends DebugPort { inspectById(message) { const obj = this.sentObjects[message.objectId]; if (obj) { - this.sendObject(obj); + this.sendObject(obj, message.renderNodeId); } }, inspectByContainerLookup(message) { @@ -516,21 +626,51 @@ export default class extends DebugPort { } } - sendObject(object) { + sendObject(object, renderNodeId = null) { if (!this.canSend(object)) { throw new Error( `Can't inspect ${object}. Only Ember objects and arrays are supported.`, ); } let details = this.mixinsForObject(object); + let reactivity = null; + + // When the object is inspected as a render tree node (e.g. pinned in + // the component tree), join the render node's reactivity report into + // the existing property display: mark the properties that changed in + // the node's most recent re-render and expose args as dependent keys + // of the `args` property. + const report = this.getReactivityReport(renderNodeId); + if (report) { + this.currentObject.renderNodeId = renderNodeId; + this.currentObject.lastUpdateCount = report.updateCount; + applyReactivityToProperties(details.mixins, report); + reactivity = reactivitySummary(report); + } + this.sendMessage('updateObject', { objectId: details.objectId, name: getObjectName(object), details: details.mixins, errors: details.errors, + reactivity, }); } + getReactivityReport(renderNodeId) { + if (!renderNodeId) { + return null; + } + try { + return ( + this.namespace?.viewDebug?.renderTree?.getReactivity(renderNodeId) ?? + null + ); + } catch { + return null; + } + } + retainObject(object) { let meta = emberMeta(object); let guid = guidFor(object); diff --git a/packages/ember-debug/view-debug.js b/packages/ember-debug/view-debug.js index a6f8cdb5c1..566076c55a 100644 --- a/packages/ember-debug/view-debug.js +++ b/packages/ember-debug/view-debug.js @@ -20,13 +20,6 @@ export default class extends DebugPort { this.sendTree(immediate); }, - getReactivity({ id }) { - this.sendMessage('reactivity', { - id, - reactivity: this.renderTree.getReactivity(id), - }); - }, - showInspection({ id, pin }) { this.viewInspection.show(id, pin); }, diff --git a/packages/ember-inspector/app/components/object-inspector.hbs b/packages/ember-inspector/app/components/object-inspector.hbs index 085332bf59..5a520c04fa 100644 --- a/packages/ember-inspector/app/components/object-inspector.hbs +++ b/packages/ember-inspector/app/components/object-inspector.hbs @@ -95,6 +95,27 @@ + {{#if @mixinDetails.reactivity}} +
+ re-renders: + + {{@mixinDetails.reactivity.updateCount}} + + {{#if @mixinDetails.reactivity.causes.length}} + ยท last change: + {{#each @mixinDetails.reactivity.causes as |cause|}} + {{cause}} + {{/each}} + {{/if}} +
+ {{/if}} + {{#if @mixinDetails.errors.length}} {{/if}} - {{else}} diff --git a/packages/ember-inspector/app/components/object-inspector/property.hbs b/packages/ember-inspector/app/components/object-inspector/property.hbs index 70e217d4b6..4f98bd6516 100644 --- a/packages/ember-inspector/app/components/object-inspector/property.hbs +++ b/packages/ember-inspector/app/components/object-inspector/property.hbs @@ -57,6 +57,13 @@ {{/if}} {{/if}} + {{#if @model.reactivity.changed}} + ๐Ÿ”ธ + {{/if}} : {{/unless}} diff --git a/packages/ember-inspector/app/components/object-inspector/reactivity.hbs b/packages/ember-inspector/app/components/object-inspector/reactivity.hbs deleted file mode 100644 index a390010e80..0000000000 --- a/packages/ember-inspector/app/components/object-inspector/reactivity.hbs +++ /dev/null @@ -1,113 +0,0 @@ -{{#if @item.renderNode}} - -
-

- Reactivity -

- {{#if accordion.isExpanded}} - {{#if this.reactivity}} -
    -
  • - re-renders - - {{this.reactivity.updateCount}} - -
  • - - {{#if this.causes.length}} -
  • - last change caused by -
  • - {{#each this.causes as |cause|}} -
  • - {{cause}} ๐Ÿ”ธ -
  • - {{/each}} - {{/if}} - - {{#each this.namedArgs as |arg|}} -
  • - - @{{arg.name}} - - - {{arg.inspect}} - - {{#if arg.changed}}๐Ÿ”ธ{{/if}} -
  • - {{#if arg.dependencies.length}} - - {{/if}} - {{/each}} - - {{#each this.positionalArgs as |arg|}} -
  • - - @{{arg.name}} - - - {{arg.inspect}} - - {{#if arg.changed}}๐Ÿ”ธ{{/if}} -
  • - {{#if arg.dependencies.length}} - - {{/if}} - {{/each}} - - {{#each this.trackedProps as |prop|}} -
  • - - this.{{prop.name}} - - {{#if prop.changed}}๐Ÿ”ธ{{/if}} -
  • - {{/each}} -
- {{else}} -
    -
  • - Not available for this app -
  • -
- {{/if}} - {{/if}} -
-
-{{/if}} diff --git a/packages/ember-inspector/app/components/object-inspector/reactivity.js b/packages/ember-inspector/app/components/object-inspector/reactivity.js deleted file mode 100644 index b0d42298c7..0000000000 --- a/packages/ember-inspector/app/components/object-inspector/reactivity.js +++ /dev/null @@ -1,113 +0,0 @@ -import Component from '@glimmer/component'; -import { action } from '@ember/object'; -import { inject as service } from '@ember/service'; -import { tracked } from '@glimmer/tracking'; - -/** - * Shows why a render node (component invocation, route template, โ€ฆ) - * rendered: what it depends on โ€” its args and the instance properties it - * consumes โ€” and which of those dependencies changed most recently. - * - * The data comes from the `view:getReactivity` / `view:reactivity` - * messages; it is re-requested whenever the app sends a new render tree so - * the pane stays live while the app re-renders. - */ -export default class Reactivity extends Component { - @service port; - - @tracked reactivity = null; - - constructor() { - super(...arguments); - - this.port.on('view:reactivity', this, this.receiveReactivity); - this.port.on('view:renderTree', this, this.requestReactivity); - } - - willDestroy() { - super.willDestroy(...arguments); - - this.port.off('view:reactivity', this, this.receiveReactivity); - this.port.off('view:renderTree', this, this.requestReactivity); - } - - get accordionMixin() { - return { - name: 'reactivity', - expand: true, - properties: { - length: 1, - }, - }; - } - - get renderNodeId() { - return this.args.item?.renderNode?.id; - } - - @action requestReactivity() { - if (this.renderNodeId) { - this.port.send('view:getReactivity', { id: this.renderNodeId }); - } - } - - @action receiveReactivity({ id, reactivity }) { - if (id === this.renderNodeId) { - this.reactivity = reactivity; - } - } - - get namedArgs() { - return this.reactivity?.args?.named ?? []; - } - - get positionalArgs() { - return this.reactivity?.args?.positional ?? []; - } - - get trackedProps() { - return this.reactivity?.tracked ?? []; - } - - /** - * The dependencies that were dirtied since the previous render โ€” what - * caused the most recent re-render of this node. - */ - get causes() { - if (!this.reactivity || !this.reactivity.updateCount) { - return []; - } - - const causes = new Set(); - - for (const arg of this.namedArgs) { - if (arg.changed) { - causes.add(`@${arg.name}`); - } - for (const dep of arg.dependencies ?? []) { - if (dep.changed) { - causes.add(dep.name); - } - } - } - - for (const arg of this.positionalArgs) { - if (arg.changed) { - causes.add(`@${arg.name}`); - } - for (const dep of arg.dependencies ?? []) { - if (dep.changed) { - causes.add(dep.name); - } - } - } - - for (const prop of this.trackedProps) { - if (prop.changed) { - causes.add(`this.${prop.name}`); - } - } - - return [...causes]; - } -} diff --git a/packages/ember-inspector/app/controllers/component-tree.js b/packages/ember-inspector/app/controllers/component-tree.js index d959e784f7..848159df3c 100644 --- a/packages/ember-inspector/app/controllers/component-tree.js +++ b/packages/ember-inspector/app/controllers/component-tree.js @@ -144,6 +144,7 @@ export default class ComponentTreeController extends Controller { if (item.hasInstance) { this.port.send('objectInspector:inspectById', { objectId: item.instance, + renderNodeId: item.renderNode.id, }); } else { this.layout.hideInspector(); diff --git a/packages/ember-inspector/app/routes/application.js b/packages/ember-inspector/app/routes/application.js index 27db5467ba..f6fea5085e 100644 --- a/packages/ember-inspector/app/routes/application.js +++ b/packages/ember-inspector/app/routes/application.js @@ -17,6 +17,7 @@ export default class ApplicationRoute extends Route { let port = this.port; port.on('objectInspector:updateObject', this, this.updateObject); port.on('objectInspector:updateProperty', this, this.updateProperty); + port.on('objectInspector:updateReactivity', this, this.updateReactivity); port.on('objectInspector:updateErrors', this, this.updateErrors); port.on('objectInspector:droppedObject', this, this.droppedObject); port.on('deprecation:count', this, this.setDeprecationCount); @@ -28,6 +29,7 @@ export default class ApplicationRoute extends Route { let port = this.port; port.off('objectInspector:updateObject', this, this.updateObject); port.off('objectInspector:updateProperty', this, this.updateProperty); + port.off('objectInspector:updateReactivity', this, this.updateReactivity); port.off('objectInspector:updateErrors', this, this.updateErrors); port.off('objectInspector:droppedObject', this, this.droppedObject); port.off('deprecation:count', this, this.setDeprecationCount); @@ -63,11 +65,52 @@ export default class ApplicationRoute extends Route { controller.pushMixinDetails(name, property, objectId, details, errors); } else { controller.activateMixinDetails(name, objectId, details, errors); + set(controller.mixinDetails, 'reactivity', options.reactivity || null); } this.layout.showInspector(); } + /** + * The inspected object is a render tree node that just re-rendered: + * update the reactivity summary and refresh the changed markers on the + * matching property rows and on the `args` dependent keys. + */ + updateReactivity(options) { + const mixinDetails = this.controller.mixinDetails; + + if (!mixinDetails || mixinDetails.objectId !== options.objectId) { + return; + } + + set(mixinDetails, 'reactivity', options.reactivity); + + const byName = {}; + options.properties.forEach((prop) => { + byName[prop.name] = prop; + }); + + mixinDetails.mixins.forEach((mixin) => { + mixin.properties.forEach((property) => { + const prop = byName[property.name]; + if (prop) { + set(property, 'reactivity', { + revision: prop.revision, + changed: prop.changed, + }); + } + if (property.name === 'args' && options.args?.length) { + const deps = property.dependentKeys; + const holdsArgEntries = + !deps?.length || deps[0]?.name?.startsWith('@'); + if (holdsArgEntries) { + set(property, 'dependentKeys', options.args); + } + } + }); + }); + } + setDeprecationCount(message) { this.controller.set('deprecationCount', message.count); } diff --git a/packages/ember-inspector/tests/ember_debug/reactivity-test.js b/packages/ember-inspector/tests/ember_debug/reactivity-test.js index acbd51d67e..983bdb7aed 100644 --- a/packages/ember-inspector/tests/ember_debug/reactivity-test.js +++ b/packages/ember-inspector/tests/ember_debug/reactivity-test.js @@ -57,16 +57,6 @@ async function getRenderTree() { } } -async function getReactivity(id) { - let message = await captureMessage('view:reactivity', async () => { - EmberDebug.port.trigger('view:getReactivity', { id }); - }); - - QUnit.assert.strictEqual(message.id, id, 'reactivity reply echoes the id'); - - return message.reactivity; -} - function findNode(nodes, predicate) { for (let node of nodes) { if (predicate(node)) { @@ -83,7 +73,7 @@ function findNode(nodes, predicate) { return null; } -async function getCounterReactivity() { +async function inspectCounterNode() { let tree = await getRenderTree(); let node = findNode( tree, @@ -92,7 +82,27 @@ async function getCounterReactivity() { QUnit.assert.ok(node, 'the reactive-counter render node was found'); - return getReactivity(node.id); + let message = await captureMessage( + 'objectInspector:updateObject', + async () => { + EmberDebug.port.trigger('objectInspector:inspectById', { + objectId: node.instance.id, + renderNodeId: node.id, + }); + }, + ); + + return message; +} + +function findProperty(details, name) { + for (let mixin of details) { + let property = mixin.properties.find((p) => p.name === name); + if (property) { + return property; + } + } + return null; } let counterInstance = null; @@ -149,96 +159,115 @@ module('Ember Debug - Reactivity', function (hooks) { counterInstance = null; }); - test('it reports args, consumed tracked properties, and what changed', async function (assert) { + test('inspecting a render node augments properties with reactivity info', async function (assert) { await visit('/reactive'); - let reactivity = await getCounterReactivity(); + let message = await inspectCounterNode(); - assert.ok(reactivity, 'a reactivity report is available'); + assert.ok(message.reactivity, 'updateObject includes a reactivity summary'); assert.strictEqual( - reactivity.updateCount, + message.reactivity.updateCount, 0, 'no re-renders after the initial render', ); + assert.deepEqual( + message.reactivity.causes, + [], + 'nothing caused a re-render yet', + ); - let title = reactivity.args.named.find((arg) => arg.name === 'title'); - assert.ok(title, 'the @title arg is reported'); - assert.notOk(title.changed, '@title has not changed'); + let args = findProperty(message.details, 'args'); + assert.ok(args, 'the args property is present'); assert.ok( - Array.isArray(title.dependencies), - '@title has a (possibly empty) dependency list', + args.dependentKeys?.some((dep) => dep.name === '@title'), + 'the args property lists @title as a dependent key', ); - assert.strictEqual(title.inspect, '"first title"', '@title value shown'); - let count = reactivity.tracked.find((prop) => prop.name === 'count'); - assert.ok(count, 'the consumed tracked property is reported'); - assert.notOk(count.changed, 'count has not changed'); + let count = findProperty(message.details, 'count'); + assert.ok(count, 'the count property is present'); + assert.ok(count.reactivity, 'count is augmented with reactivity info'); + assert.notOk(count.reactivity.changed, 'count has not changed yet'); + }); - // Change internal tracked state - counterInstance.count++; - await rerender(); + test('re-renders push updated reactivity info for the inspected node', async function (assert) { + await visit('/reactive'); - reactivity = await getCounterReactivity(); + await inspectCounterNode(); + + // Change internal tracked state + let message = await captureMessage( + 'objectInspector:updateReactivity', + async () => { + counterInstance.count++; + await rerender(); + }, + ); assert.strictEqual( - reactivity.updateCount, + message.reactivity.updateCount, 1, 're-rendered once after count changed', ); - assert.ok(reactivity.lastRender, 'last render info is available'); - assert.notOk( - reactivity.lastRender.initial, - 'last render is no longer the initial one', + assert.deepEqual( + message.reactivity.causes, + ['this.count'], + 'count is reported as the cause of the re-render', ); - count = reactivity.tracked.find((prop) => prop.name === 'count'); - assert.ok( - count.changed, - 'count is flagged as the cause of the re-render', - ); + let count = message.properties.find((p) => p.name === 'count'); + assert.ok(count.changed, 'the count property is flagged as changed'); - title = reactivity.args.named.find((arg) => arg.name === 'title'); + let title = message.args.find((a) => a.name === '@title'); assert.notOk(title.changed, '@title did not cause the re-render'); // Change the arg - this.owner.lookup('controller:reactive').title = 'second title'; - await rerender(); - - reactivity = await getCounterReactivity(); + message = await captureMessage( + 'objectInspector:updateReactivity', + async () => { + this.owner.lookup('controller:reactive').title = 'second title'; + await rerender(); + }, + ); assert.strictEqual( - reactivity.updateCount, + message.reactivity.updateCount, 2, 're-rendered again after the arg changed', ); - - title = reactivity.args.named.find((arg) => arg.name === 'title'); assert.ok( - title.changed, - '@title is flagged as the cause of the re-render', - ); - assert.strictEqual( - title.inspect, - '"second title"', - '@title shows the new value', + message.reactivity.causes.includes('@title'), + '@title is reported as a cause of the re-render', ); - count = reactivity.tracked.find((prop) => prop.name === 'count'); - assert.notOk(count.changed, 'count did not cause the re-render'); + title = message.args.find((a) => a.name === '@title'); + assert.ok(title.changed, '@title is flagged as changed'); + + count = message.properties.find((p) => p.name === 'count'); + assert.notOk(count.changed, 'count did not cause this re-render'); }); - test('it returns null for unknown render node ids', async function (assert) { + test('inspecting without a render node id keeps the old behavior', async function (assert) { await visit('/reactive'); - // Populate the captured node map - await getRenderTree(); + let tree = await getRenderTree(); + let node = findNode( + tree, + (n) => n.type === 'component' && n.name === 'reactive-counter', + ); - let reactivity = await getReactivity('render-node:does-not-exist'); + let message = await captureMessage( + 'objectInspector:updateObject', + async () => { + EmberDebug.port.trigger('objectInspector:inspectById', { + objectId: node.instance.id, + }); + }, + ); assert.strictEqual( - reactivity, + message.reactivity, null, - 'no reactivity report for unknown nodes', + 'no reactivity summary without a render node id', ); }); }); From 7634c4235852b9ee4fb9d63a4c07ced4656a82b4 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Sat, 4 Jul 2026 11:02:58 -0400 Subject: [PATCH 3/6] Baseline render nodes at first capture when attached late 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 --- .../ember-debug/lib/render-tree-reactivity.js | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/ember-debug/lib/render-tree-reactivity.js b/packages/ember-debug/lib/render-tree-reactivity.js index 34b149ded3..3f9d44b81b 100644 --- a/packages/ember-debug/lib/render-tree-reactivity.js +++ b/packages/ember-debug/lib/render-tree-reactivity.js @@ -108,7 +108,21 @@ export default class RenderTreeReactivity { const captureNode = debugRenderTree.captureNode; debugRenderTree.captureNode = function (id, state) { try { - self.capturedNodes[id] = this.nodeFor(state); + const node = this.nodeFor(state); + self.capturedNodes[id] = node; + // Nodes rendered before we were attached (e.g. the inspector was + // opened on an already-running app) have no create-time info yet; + // baseline them at first capture so their first re-render can + // still report what changed. + if (!self.updates.has(node)) { + self.updates.set(node, { + updateCount: 0, + revision: currentRevision(), + previousRevision: null, + timestamp: null, + initialRender: true, + }); + } } catch { // never break capturing } From 9e6d404d653bb0dbd985421acbe3b013d801bfce Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Sat, 4 Jul 2026 11:22:12 -0400 Subject: [PATCH 4/6] Name getter dependencies without tag debug annotations 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 --- packages/ember-debug/lib/tracked-tags.js | 31 ++++++++++ packages/ember-debug/object-inspector.js | 62 +++++++++++++++++-- .../tests/ember_debug/reactivity-test.js | 48 +++++++++++++- 3 files changed, 135 insertions(+), 6 deletions(-) diff --git a/packages/ember-debug/lib/tracked-tags.js b/packages/ember-debug/lib/tracked-tags.js index 6f68fb9a81..797cdd1d2e 100644 --- a/packages/ember-debug/lib/tracked-tags.js +++ b/packages/ember-debug/lib/tracked-tags.js @@ -10,6 +10,37 @@ * @param {number} level Current recursion depth. * @return {Tag[]} The dependency tags. */ +/** + * Recursively collect all the tags a (combined) tag depends on, named or + * not. Callers can resolve names for the anonymous ones through other + * sources (e.g. the validator's tag meta). + * + * @param {Tag} tag The tag to walk. + * @param {Tag} ownTag A tag to exclude from the result. + * @param {number} level Current recursion depth. + * @return {Tag[]} The dependency tags. + */ +export function getTagSubtags(tag, ownTag, level = 0) { + const tags = []; + if (!tag || level > 1) { + return tags; + } + const subtags = tag.subtags || (Array.isArray(tag.subtag) ? tag.subtag : []); + if (tag.subtag && !Array.isArray(tag.subtag)) { + if (tag.subtag !== ownTag) tags.push(tag.subtag); + + tags.push(...getTagSubtags(tag.subtag, ownTag, level + 1)); + } + if (subtags) { + subtags.forEach((t) => { + if (t === ownTag) return; + tags.push(t); + tags.push(...getTagSubtags(t, ownTag, level + 1)); + }); + } + return tags; +} + export function getTagTrackedTags(tag, ownTag, level = 0) { const props = []; // do not include tracked properties from dependencies diff --git a/packages/ember-debug/object-inspector.js b/packages/ember-debug/object-inspector.js index 0a8eac6506..b2e64cd25e 100644 --- a/packages/ember-debug/object-inspector.js +++ b/packages/ember-debug/object-inspector.js @@ -27,7 +27,7 @@ import { cacheFor, guidFor } from './lib/ember/object/internals.js'; import { _backburner, join } from './lib/ember/runloop.js'; import emberNames from './lib/ember-object-names.js'; import getObjectName from './lib/get-object-name.js'; -import { getTagTrackedTags } from './lib/tracked-tags.js'; +import { getTagSubtags } from './lib/tracked-tags.js'; let tagValue, tagValidate, track, tagForProperty; @@ -92,6 +92,47 @@ if (InternalsMetal) { const HAS_GLIMMER_TRACKING = tagValue && tagValidate && track && tagForProperty; +// Reading exports is safe in every build type (unlike the patches above). +const tagMetaFor = GlimmerValidator?.tagMetaFor; + +/** + * Build a tag -> human-readable-name map for the dependencies an object's + * getters can consume, without relying on the `_propertyKey` annotations + * (which the patched `tagFor` can't provide when the app's modules are + * inlined at build time, as in recent ember-source): + * + * - the validator's tag meta names every consumed own property (`this.x`) + * - the args proxy's custom tags name named args (`args.x`) + */ +function localTagNames(object) { + const names = new Map(); + + if (tagMetaFor) { + try { + tagMetaFor(object).forEach((t, key) => { + if (typeof key === 'string') { + names.set(t, `this.${key}`); + } + }); + } catch { + // ignore + } + } + + const args = object.args; + if (args && typeof args === 'object' && tagForProperty) { + try { + Object.keys(args).forEach((key) => { + names.set(tagForProperty(args, key), `args.${key}`); + }); + } catch { + // ignore + } + } + + return names; +} + const keys = Object.keys; /** @@ -232,12 +273,25 @@ function getTrackedDependencies(object, property, tagInfo) { } if (HAS_GLIMMER_TRACKING) { const ownTag = tagForProperty(object, property); - const tags = getTagTrackedTags(tag, ownTag); + const tagNames = localTagNames(object); + const tags = getTagSubtags(tag, ownTag); + // A getter that consumed a single value gets that value's tag itself + // instead of a combinator, so the subtag walk misses it. + if (tag !== ownTag && !tags.includes(tag)) { + tags.push(tag); + } const mapping = {}; let maxRevision = tagValue(tag); tags.forEach((t) => { - const p = - (t._object ? getObjectName(t._object) + '.' : '') + t._propertyKey; + let p; + if (t._propertyKey) { + p = (t._object ? getObjectName(t._object) + '.' : '') + t._propertyKey; + } else { + p = tagNames.get(t); + } + if (!p) { + return; + } const [objName, prop] = p.split('.'); mapping[objName] = mapping[objName] || new Set(); const value = tagValue(t); diff --git a/packages/ember-inspector/tests/ember_debug/reactivity-test.js b/packages/ember-inspector/tests/ember_debug/reactivity-test.js index 983bdb7aed..35a27d08be 100644 --- a/packages/ember-inspector/tests/ember_debug/reactivity-test.js +++ b/packages/ember-inspector/tests/ember_debug/reactivity-test.js @@ -12,7 +12,7 @@ import EmberDebugImport from 'ember-debug/main'; let EmberDebug; // TODO switch to an adapter architecture, similar to the acceptance tests -async function captureMessage(type, callback) { +async function captureMessage(type, callback, match = () => true) { if (!EmberDebug.port) { throw new Error('Cannot call captureMessage without a port'); } @@ -25,7 +25,7 @@ async function captureMessage(type, callback) { const receivedPromise = new Promise((resolve) => { setTimeout(resolve, 500); EmberDebug.port.send = (name, message) => { - if (!captured && name === type) { + if (!captured && name === type && match(message)) { resolve(); captured = JSON.parse(JSON.stringify(message)); } else { @@ -139,6 +139,10 @@ module('Ember Debug - Reactivity', function (hooks) { class ReactiveCounter extends GlimmerComponent { @tracked count = 0; + get label() { + return `${this.args.title}: ${this.count}`; + } + constructor(...args) { super(...args); counterInstance = this; @@ -189,6 +193,46 @@ module('Ember Debug - Reactivity', function (hooks) { assert.notOk(count.reactivity.changed, 'count has not changed yet'); }); + test('getter dependencies are named and flag what changed', async function (assert) { + await visit('/reactive'); + + let message = await inspectCounterNode(); + + let label = findProperty(message.details, 'label'); + assert.ok(label, 'the label getter is present'); + + let depNames = (label.dependentKeys ?? []).map((d) => d.name ?? d.child); + assert.ok( + depNames.includes('args.title'), + `label depends on args.title (got: ${depNames.join(', ')})`, + ); + assert.ok( + depNames.includes('this.count'), + `label depends on this.count (got: ${depNames.join(', ')})`, + ); + + // Change one of the dependencies: the pushed update for the getter + // flags the changed dependency, the other one is not flagged. + let update = await captureMessage( + 'objectInspector:updateProperty', + async () => { + counterInstance.count++; + await rerender(); + }, + (m) => m.property === 'label', + ); + + let countDep = update.dependentKeys.find( + (d) => (d.name ?? d.child) === 'this.count', + ); + assert.ok(countDep.changed, 'the changed dependency is flagged'); + + let titleDep = update.dependentKeys.find( + (d) => (d.name ?? d.child) === 'args.title', + ); + assert.notOk(titleDep.changed, 'the unchanged dependency is not flagged'); + }); + test('re-renders push updated reactivity info for the inspected node', async function (assert) { await visit('/reactive'); From 6b026d3524574eb039852b072d56a9b0319e28fb Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Sat, 4 Jul 2026 13:14:52 -0400 Subject: [PATCH 5/6] Render nested dependent keys like top-level ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../components/object-inspector/dependent-keys.hbs | 13 ++----------- .../app/styles/object_inspector.scss | 7 ++++--- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/packages/ember-inspector/app/components/object-inspector/dependent-keys.hbs b/packages/ember-inspector/app/components/object-inspector/dependent-keys.hbs index fdf04ffcbe..260a8087dc 100644 --- a/packages/ember-inspector/app/components/object-inspector/dependent-keys.hbs +++ b/packages/ember-inspector/app/components/object-inspector/dependent-keys.hbs @@ -3,17 +3,9 @@
    {{#each @keys as |dep|}}
  • - {{#if dep.child}} - - โ€ข -- {{dep.child}} - - {{else}} {{svg-jar "dependent-key-bullet" width="9px" @@ -23,9 +15,8 @@ class="mixin-property-dependency-name" data-label="object-property-name" > - {{dep.name}} + {{if dep.child dep.child dep.name}} - {{/if}} {{#if dep.changed}}๐Ÿ”ธ{{/if}}
  • {{/each}} diff --git a/packages/ember-inspector/app/styles/object_inspector.scss b/packages/ember-inspector/app/styles/object_inspector.scss index ce1ad3c1d2..b0f0dd9f3f 100644 --- a/packages/ember-inspector/app/styles/object_inspector.scss +++ b/packages/ember-inspector/app/styles/object_inspector.scss @@ -54,9 +54,10 @@ } } -.mixin-property-dependency-name.subkey { - left: -11px; - position: relative; +// Nested dependencies (e.g. the properties behind an object-level +// dependency) render like top-level ones, just indented one level deeper. +.mixin-property-dependency-item-nested { + margin-left: 75px; } .mixin-property-dependency-item:first-child::before { From 4260138a3bc320268912a688af277751483592be Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Sat, 4 Jul 2026 13:35:02 -0400 Subject: [PATCH 6/6] Expose render node args as rows when the instance has no args property MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../ember-debug/lib/render-tree-reactivity.js | 7 +- packages/ember-debug/object-inspector.js | 72 ++++++++++++-- .../ember-inspector/app/routes/application.js | 14 +++ .../tests/ember_debug/reactivity-test.js | 96 ++++++++++++++++++- 4 files changed, 174 insertions(+), 15 deletions(-) diff --git a/packages/ember-debug/lib/render-tree-reactivity.js b/packages/ember-debug/lib/render-tree-reactivity.js index 3f9d44b81b..39ec636c63 100644 --- a/packages/ember-debug/lib/render-tree-reactivity.js +++ b/packages/ember-debug/lib/render-tree-reactivity.js @@ -1,6 +1,6 @@ import { GlimmerReference, GlimmerValidator } from './ember.js'; import getObjectName from './get-object-name.js'; -import { inspect } from './type-check.js'; +import { inspect, typeOf } from './type-check.js'; import { getTagTrackedTags } from './tracked-tags.js'; // Read-only accessors into @glimmer/validator and @glimmer/reference. @@ -247,6 +247,7 @@ export default class RenderTreeReactivity { if (!isRef(ref)) { // in-element/html-element nodes store plain values in their args entry.inspect = inspect(ref); + entry.type = `type-${typeOf(ref)}`; return entry; } @@ -255,7 +256,9 @@ export default class RenderTreeReactivity { entry.debugLabel = ref.debugLabel; } - entry.inspect = inspect(this._safeValueForRef(ref)); + const value = this._safeValueForRef(ref); + entry.inspect = inspect(value); + entry.type = `type-${typeOf(value)}`; const tag = ref.tag; if (tag) { diff --git a/packages/ember-debug/object-inspector.js b/packages/ember-debug/object-inspector.js index b2e64cd25e..95baf5d54b 100644 --- a/packages/ember-debug/object-inspector.js +++ b/packages/ember-debug/object-inspector.js @@ -190,7 +190,12 @@ function isMandatorySetter(descriptor) { function reactivityArgEntries(report) { const entries = []; report.args.named.forEach((arg) => { - entries.push({ name: `@${arg.name}`, changed: arg.changed || false }); + entries.push({ + name: `@${arg.name}`, + changed: arg.changed || false, + inspect: arg.inspect, + type: arg.type, + }); (arg.dependencies || []).forEach((dep) => { const child = { child: dep.name }; if (dep.changed) { @@ -200,11 +205,47 @@ function reactivityArgEntries(report) { }); }); report.args.positional.forEach((arg) => { - entries.push({ name: `@${arg.name}`, changed: arg.changed || false }); + entries.push({ + name: `@${arg.name}`, + changed: arg.changed || false, + inspect: arg.inspect, + type: arg.type, + }); }); return entries; } +/** + * Build a read-only property item for a render node arg, so args show up + * as regular rows on nodes whose instance has no `args` property (e.g. + * modifiers). + */ +function argPropertyItem(arg) { + return { + name: `@${arg.name}`, + // keep updateCurrentObject from recomputing this from the instance + isRenderNodeArg: true, + isProperty: true, + readOnly: true, + value: { + type: arg.type || 'type-object', + inspect: arg.inspect, + isCalculated: true, + }, + dependentKeys: (arg.dependencies || []).map((dep) => { + const entry = { name: dep.name }; + if (dep.changed) { + entry.changed = true; + } + return entry; + }), + reactivity: { + revision: arg.revision, + changed: arg.changed || false, + }, + }; +} + function reactivitySummary(report) { const causes = new Set(); @@ -238,6 +279,7 @@ function applyReactivityToProperties(mixins, report) { }); const argEntries = reactivityArgEntries(report); + let hasArgsProperty = false; mixins.forEach((mixin) => { mixin.properties.forEach((item) => { @@ -249,15 +291,24 @@ function applyReactivityToProperties(mixins, report) { // Surface the render node's args on the `args` property using the // same dependent-keys UI computed properties use, unless something // (e.g. a computed property) already claimed it. - if ( - item.name === 'args' && - argEntries.length && - !item.dependentKeys?.length - ) { - item.dependentKeys = argEntries; + if (item.name === 'args') { + hasArgsProperty = true; + if (argEntries.length && !item.dependentKeys?.length) { + item.dependentKeys = argEntries; + } } }); }); + + // Nodes whose instance has no `args` property (e.g. modifiers) get the + // node's args as read-only rows instead. + if (!hasArgsProperty && mixins.length) { + const items = [ + ...report.args.named.map((arg) => argPropertyItem(arg)), + ...report.args.positional.map((arg) => argPropertyItem(arg)), + ]; + mixins[0].properties.unshift(...items); + } } function getTrackedDependencies(object, property, tagInfo) { @@ -367,6 +418,11 @@ export default class extends DebugPort { if (item.overridden) { return true; } + if (item.isRenderNodeArg) { + // synthesized from the render node's args, not readable off + // the instance; refreshed through updateReactivity instead + return true; + } try { let cache = cacheFor(object, item.name); if (item.isExpensive && !cache) return true; diff --git a/packages/ember-inspector/app/routes/application.js b/packages/ember-inspector/app/routes/application.js index f6fea5085e..c0efa5ed5a 100644 --- a/packages/ember-inspector/app/routes/application.js +++ b/packages/ember-inspector/app/routes/application.js @@ -107,6 +107,20 @@ export default class ApplicationRoute extends Route { set(property, 'dependentKeys', options.args); } } + // Rows synthesized from the render node's args (e.g. modifiers) + if (property.isRenderNodeArg) { + const entry = options.args?.find((a) => a.name === property.name); + if (entry) { + set(property, 'reactivity', { changed: entry.changed }); + if (entry.inspect !== undefined) { + set(property, 'value', { + type: entry.type || property.value.type, + inspect: entry.inspect, + isCalculated: true, + }); + } + } + } }); }); } diff --git a/packages/ember-inspector/tests/ember_debug/reactivity-test.js b/packages/ember-inspector/tests/ember_debug/reactivity-test.js index 35a27d08be..790993dbdb 100644 --- a/packages/ember-inspector/tests/ember_debug/reactivity-test.js +++ b/packages/ember-inspector/tests/ember_debug/reactivity-test.js @@ -1,5 +1,6 @@ import { rerender, visit } from '@ember/test-helpers'; -import { setComponentTemplate } from '@ember/component'; +// eslint-disable-next-line ember/no-classic-components +import EmberComponent, { setComponentTemplate } from '@ember/component'; import Controller from '@ember/controller'; import QUnit, { module, test } from 'qunit'; import { hbs } from 'ember-cli-htmlbars'; @@ -133,7 +134,7 @@ module('Ember Debug - Reactivity', function (hooks) { 'component:reactive-counter', setComponentTemplate( hbs( - '
    {{@title}}: {{this.count}}
    ', + '
    {{@title}}: {{this.count}}
    ', { moduleName: 'my-app/components/reactive-counter.hbs' }, ), class ReactiveCounter extends GlimmerComponent { @@ -143,6 +144,10 @@ module('Ember Debug - Reactivity', function (hooks) { return `${this.args.title}: ${this.count}`; } + increment = () => { + this.count++; + }; + constructor(...args) { super(...args); counterInstance = this; @@ -151,11 +156,25 @@ module('Ember Debug - Reactivity', function (hooks) { ), ); + this.owner.register( + 'component:classic-badge', + setComponentTemplate( + hbs('{{@label}}', { + moduleName: 'my-app/components/classic-badge.hbs', + }), + // eslint-disable-next-line ember/no-classic-classes + EmberComponent.extend({}), + ), + ); + this.owner.register( 'template:reactive', - hbs('', { - moduleName: 'my-app/templates/reactive.hbs', - }), + hbs( + '', + { + moduleName: 'my-app/templates/reactive.hbs', + }, + ), ); }); @@ -290,6 +309,73 @@ module('Ember Debug - Reactivity', function (hooks) { assert.notOk(count.changed, 'count did not cause this re-render'); }); + test('modifier args are exposed in the object inspector', async function (assert) { + await visit('/reactive'); + + let tree = await getRenderTree(); + let node = findNode( + tree, + (n) => n.type === 'modifier' && n.name === 'on', + ); + + assert.ok(node, 'the {{on}} modifier render node was found'); + + let message = await captureMessage( + 'objectInspector:updateObject', + async () => { + EmberDebug.port.trigger('objectInspector:inspectById', { + objectId: node.instance.id, + renderNodeId: node.id, + }); + }, + ); + + assert.ok(message.reactivity, 'the modifier has a reactivity summary'); + + // The {{on}} modifier's debug instance carries a real `args` property + // here, so its args list as dependent keys of that row. (Instances + // without an `args` property get synthesized rows instead โ€” covered + // by the next test.) + let argsRow = findProperty(message.details, 'args'); + assert.ok(argsRow, 'the args are visible in the property list'); + assert.ok( + argsRow.dependentKeys?.some((dep) => dep.name === '@0'), + 'the positional arg shows under the args property', + ); + }); + + test('render nodes without an args property get synthesized arg rows', async function (assert) { + await visit('/reactive'); + + let tree = await getRenderTree(); + let node = findNode( + tree, + (n) => n.type === 'component' && n.name === 'classic-badge', + ); + + assert.ok(node, 'the classic component render node was found'); + + let message = await captureMessage( + 'objectInspector:updateObject', + async () => { + EmberDebug.port.trigger('objectInspector:inspectById', { + objectId: node.instance.id, + renderNodeId: node.id, + }); + }, + ); + + let label = findProperty(message.details, '@label'); + assert.ok(label, 'the @label arg is exposed as a row'); + assert.strictEqual( + label.value.inspect, + '"first title"', + 'with its value', + ); + assert.true(label.readOnly, 'and is read-only'); + assert.ok(label.reactivity, 'and carries reactivity info'); + }); + test('inspecting without a render node id keeps the old behavior', async function (assert) { await visit('/reactive');