-
Notifications
You must be signed in to change notification settings - Fork 293
Add per-render-node reactivity introspection: what does it depend on, and why did it re-render? #2776
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
NullVoxPopuli-ai-agent
wants to merge
6
commits into
emberjs:main
Choose a base branch
from
NullVoxPopuli-ai-agent:render-node-reactivity
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Add per-render-node reactivity introspection: what does it depend on, and why did it re-render? #2776
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
71330e7
Add per-render-node reactivity introspection (why did this render?)
NullVoxPopuli bc24361
Merge reactivity info into the existing object inspector display
NullVoxPopuli 7634c42
Baseline render nodes at first capture when attached late
NullVoxPopuli 9e6d404
Name getter dependencies without tag debug annotations
NullVoxPopuli 6b026d3
Render nested dependent keys like top-level ones
NullVoxPopuli 4260138
Expose render node args as rows when the instance has no args property
NullVoxPopuli File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Object>} 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); | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
a little todo for us to add in ember-source.
which means, ember-source should probably add tests for "reactivity introspection" from the debug render tree.... which probably means I need to figure out how to not ship the debug render tree (opt in, obvs)