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..39ec636c63 --- /dev/null +++ b/packages/ember-debug/lib/render-tree-reactivity.js @@ -0,0 +1,331 @@ +import { GlimmerReference, GlimmerValidator } from './ember.js'; +import getObjectName from './get-object-name.js'; +import { inspect, typeOf } 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 { + 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 + } + 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); + entry.type = `type-${typeOf(ref)}`; + return entry; + } + + if (typeof ref.debugLabel === 'string') { + // only present in debug builds of glimmer-vm + entry.debugLabel = ref.debugLabel; + } + + const value = this._safeValueForRef(ref); + entry.inspect = inspect(value); + entry.type = `type-${typeOf(value)}`; + + 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..797cdd1d2e --- /dev/null +++ b/packages/ember-debug/lib/tracked-tags.js @@ -0,0 +1,64 @@ +/** + * 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. + */ +/** + * 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 + 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..95baf5d54b 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 { getTagSubtags } from './lib/tracked-tags.js'; let tagValue, tagValidate, track, tagForProperty; @@ -44,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; @@ -85,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; /** @@ -139,26 +187,128 @@ 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); +function reactivityArgEntries(report) { + const entries = []; + report.args.named.forEach((arg) => { + 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) { + child.changed = true; + } + entries.push(child); + }); + }); + report.args.positional.forEach((arg) => { + entries.push({ + name: `@${arg.name}`, + changed: arg.changed || false, + inspect: arg.inspect, + type: arg.type, + }); + }); + return entries; +} - 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)); +/** + * 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(); + + [...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); + let hasArgsProperty = false; + + 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') { + 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); } - return props; } function getTrackedDependencies(object, property, tagInfo) { @@ -174,12 +324,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); @@ -255,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; @@ -320,9 +488,40 @@ 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 init() { super.init(); @@ -425,7 +624,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) { @@ -537,21 +736,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-inspector/app/components/object-inspector.hbs b/packages/ember-inspector/app/components/object-inspector.hbs index cfa1a6c1ee..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}} {{#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/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/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..c0efa5ed5a 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,66 @@ 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); + } + } + // 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, + }); + } + } + } + }); + }); + } + setDeprecationCount(message) { this.controller.set('deprecationCount', message.count); } 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 { 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..790993dbdb --- /dev/null +++ b/packages/ember-inspector/tests/ember_debug/reactivity-test.js @@ -0,0 +1,403 @@ +import { rerender, visit } from '@ember/test-helpers'; +// 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'; +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, match = () => true) { + 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 && match(message)) { + 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; + } +} + +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 inspectCounterNode() { + 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'); + + 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; + +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; + + get label() { + return `${this.args.title}: ${this.count}`; + } + + increment = () => { + this.count++; + }; + + constructor(...args) { + super(...args); + counterInstance = this; + } + }, + ), + ); + + 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', + }, + ), + ); + }); + + hooks.afterEach(function () { + counterInstance = null; + }); + + test('inspecting a render node augments properties with reactivity info', async function (assert) { + await visit('/reactive'); + + let message = await inspectCounterNode(); + + assert.ok(message.reactivity, 'updateObject includes a reactivity summary'); + assert.strictEqual( + message.reactivity.updateCount, + 0, + 'no re-renders after the initial render', + ); + assert.deepEqual( + message.reactivity.causes, + [], + 'nothing caused a re-render yet', + ); + + let args = findProperty(message.details, 'args'); + assert.ok(args, 'the args property is present'); + assert.ok( + args.dependentKeys?.some((dep) => dep.name === '@title'), + 'the args property lists @title as a dependent key', + ); + + 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'); + }); + + 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'); + + await inspectCounterNode(); + + // Change internal tracked state + let message = await captureMessage( + 'objectInspector:updateReactivity', + async () => { + counterInstance.count++; + await rerender(); + }, + ); + + assert.strictEqual( + message.reactivity.updateCount, + 1, + 're-rendered once after count changed', + ); + assert.deepEqual( + message.reactivity.causes, + ['this.count'], + 'count is reported 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'); + + let title = message.args.find((a) => a.name === '@title'); + assert.notOk(title.changed, '@title did not cause the re-render'); + + // Change the arg + message = await captureMessage( + 'objectInspector:updateReactivity', + async () => { + this.owner.lookup('controller:reactive').title = 'second title'; + await rerender(); + }, + ); + + assert.strictEqual( + message.reactivity.updateCount, + 2, + 're-rendered again after the arg changed', + ); + assert.ok( + message.reactivity.causes.includes('@title'), + '@title is reported as a cause of 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('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'); + + let tree = await getRenderTree(); + let node = findNode( + tree, + (n) => n.type === 'component' && n.name === 'reactive-counter', + ); + + let message = await captureMessage( + 'objectInspector:updateObject', + async () => { + EmberDebug.port.trigger('objectInspector:inspectById', { + objectId: node.instance.id, + }); + }, + ); + + assert.strictEqual( + message.reactivity, + null, + 'no reactivity summary without a render node id', + ); + }); +});