diff --git a/packages/@ember/-internals/glimmer/lib/helpers/internal-helper.ts b/packages/@ember/-internals/glimmer/lib/helpers/internal-helper.ts index c11617194c9..fd1075d00ac 100644 --- a/packages/@ember/-internals/glimmer/lib/helpers/internal-helper.ts +++ b/packages/@ember/-internals/glimmer/lib/helpers/internal-helper.ts @@ -1,7 +1,10 @@ import type { InternalOwner } from '@ember/-internals/owner'; import type { Helper, HelperDefinitionState } from '@glimmer/interfaces'; -import { setInternalHelperManager } from '@glimmer/manager/lib/internal/api'; +import { internalHelper as glimmerInternalHelper } from '@glimmer/runtime/lib/helpers/internal-helper'; export function internalHelper(helper: Helper): HelperDefinitionState { - return setInternalHelperManager(helper, {}); + // Registering through the VM's own `internalHelper` marks the helper as an + // implementation detail, keeping it out of debug tooling like the debug + // render tree (the same treatment `fn`, `hash`, etc. get). + return glimmerInternalHelper(helper as Helper); } diff --git a/packages/@ember/-internals/glimmer/tests/integration/application/debug-render-tree-test.ts b/packages/@ember/-internals/glimmer/tests/integration/application/debug-render-tree-test.ts index ebec9f08f2d..7ed250bff45 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/application/debug-render-tree-test.ts +++ b/packages/@ember/-internals/glimmer/tests/integration/application/debug-render-tree-test.ts @@ -5,6 +5,7 @@ import { Component, setComponentManager } from '@ember/-internals/glimmer'; import type { InternalOwner } from '@ember/-internals/owner'; import Route from '@ember/routing/route'; import Controller from '@ember/controller'; +import { helper } from '@ember/component/helper'; import { assert, captureRenderTree } from '@ember/debug'; import Engine from '@ember/engine'; import type { EngineInstanceOptions } from '@ember/engine/instance'; @@ -40,10 +41,64 @@ interface ExpectedRenderNode { children: Expected | ExpectedRenderNode[]; } +function collectByType( + nodes: CapturedRenderNode[], + type: CapturedRenderNode['type'] +): CapturedRenderNode[] { + let result: CapturedRenderNode[] = []; + + for (let node of nodes) { + if (node.type === type) { + result.push(node); + } + result.push(...collectByType(node.children, type)); + } + + return result; +} + if (ENV._DEBUG_RENDER_TREE) { moduleFor( 'Application test: debug render tree', class extends ApplicationTestCase { + async '@test helpers'() { + this.add( + 'helper:up-case', + helper(([value]: [unknown]) => String(value).toUpperCase()) + ); + this.add( + 'template:index', + precompileTemplate('{{up-case "hello"}} {{array "a" "b"}}', { + moduleName: 'my-app/templates/index.hbs', + }) + ); + + await this.visit('/'); + + let helperNodes = collectByType(captureRenderTree(this.owner), 'helper'); + + this.assert.strictEqual( + helperNodes.length, + 1, + 'user helpers are captured; internal helpers ({{array}}, the outlet machinery) are not' + ); + + let [node] = helperNodes; + assert('expected node', node); + + this.assert.strictEqual(node.bounds, null, 'helper nodes have no bounds'); + this.assert.deepEqual( + node.args, + { positional: ['hello'], named: {} }, + 'helper args are captured' + ); + this.assert.strictEqual( + node.reactivity.updateCount, + 0, + 'no re-renders after initial render' + ); + } + async '@test routes'() { this.add( 'template:index', diff --git a/packages/@glimmer-workspace/integration-tests/test/debug-render-tree-test.ts b/packages/@glimmer-workspace/integration-tests/test/debug-render-tree-test.ts index 90d4bb8bbd6..7367e675d17 100644 --- a/packages/@glimmer-workspace/integration-tests/test/debug-render-tree-test.ts +++ b/packages/@glimmer-workspace/integration-tests/test/debug-render-tree-test.ts @@ -19,6 +19,7 @@ import { BaseEnv, createTemplate, defComponent, + defineSimpleHelper, defineSimpleModifier, GlimmerishComponent, JitRenderDelegate, @@ -108,6 +109,83 @@ class DebugRenderTreeTest extends RenderTest { ]); } + @test({ skip: !DEBUG }) 'helpers are captured in the render tree'() { + const state = trackedObj({ value: 'first' }); + + const upcase = defineSimpleHelper((value: string) => value.toUpperCase()); + const Root = defComponent(`

{{upcase state.value}}

`, { + scope: { upcase, state }, + }); + + this.renderComponent(Root); + + let rootChildren = this.delegate.getCapturedRenderTree()[0]?.children ?? []; + let helperNode = rootChildren.find((n: CapturedRenderNode) => n.type === 'helper'); + + this.assert.ok(helperNode, 'found a helper node'); + this.assert.strictEqual(helperNode?.bounds, null, 'helper nodes have no bounds'); + this.assert.strictEqual(helperNode?.instance, null, 'helper nodes have no instance'); + this.assert.deepEqual( + helperNode?.args, + { positional: ['first'], named: {} }, + 'helper args are captured' + ); + this.assert.strictEqual( + helperNode?.reactivity.updateCount, + 0, + 'no updates after the initial render' + ); + + state['value'] = 'second'; + this.rerender(); + + rootChildren = this.delegate.getCapturedRenderTree()[0]?.children ?? []; + helperNode = rootChildren.find((n: CapturedRenderNode) => n.type === 'helper'); + + this.assert.deepEqual( + helperNode?.args, + { positional: ['second'], named: {} }, + 'helper args are captured after update' + ); + this.assert.strictEqual(helperNode?.reactivity.updateCount, 1, 'one update'); + this.assert.true( + helperNode?.reactivity.args.positional[0]?.changed, + 'the changed argument is flagged as the cause of the update' + ); + } + + @test({ skip: !DEBUG }) 'reactivity introspection on component nodes'() { + const state = trackedObj({ value: 'first' }); + + const HelloWorld = defComponent('{{@arg}}'); + const Root = defComponent(``, { + scope: { HelloWorld, state }, + }); + + this.renderComponent(Root); + + let child = this.delegate.getCapturedRenderTree()[0]?.children[0]; + + this.assert.strictEqual(child?.reactivity.updateCount, 0, 'no updates yet'); + this.assert.strictEqual(child?.reactivity.previousRevision, null, 'no previous render yet'); + this.assert.false(child?.reactivity.args.named['arg']?.changed, '@arg has not changed'); + + state['value'] = 'second'; + this.rerender(); + + child = this.delegate.getCapturedRenderTree()[0]?.children[0]; + + this.assert.strictEqual(child?.reactivity.updateCount, 1, 'one update'); + this.assert.true( + typeof child?.reactivity.previousRevision === 'number', + 'previous render revision is recorded' + ); + this.assert.true( + child?.reactivity.args.named['arg']?.changed, + '@arg is flagged as the cause of the update' + ); + } + @test 'strict-mode components preserve names from scope'() { const HelloWorld = defComponent('{{@arg}}'); const Root = defComponent(``, { diff --git a/packages/@glimmer/interfaces/lib/references.d.ts b/packages/@glimmer/interfaces/lib/references.d.ts index 9bb1415eb57..defc41c7b3b 100644 --- a/packages/@glimmer/interfaces/lib/references.d.ts +++ b/packages/@glimmer/interfaces/lib/references.d.ts @@ -24,6 +24,11 @@ export type ReferenceSymbol = typeof REFERENCE; export interface Reference { [REFERENCE]: ReferenceType; debugLabel?: string | false | undefined; + /** + * The untransformed name the reference was created with (e.g. the helper + * name for compute references). Debug builds only. + */ + debugName?: string | undefined; compute: Nullable<() => T>; children: null | Map; } diff --git a/packages/@glimmer/interfaces/lib/runtime/debug-render-tree.d.ts b/packages/@glimmer/interfaces/lib/runtime/debug-render-tree.d.ts index 7f5bcc2c9ec..12ff403069d 100644 --- a/packages/@glimmer/interfaces/lib/runtime/debug-render-tree.d.ts +++ b/packages/@glimmer/interfaces/lib/runtime/debug-render-tree.d.ts @@ -1,6 +1,8 @@ import type { SimpleElement, SimpleNode } from '@simple-dom/interface'; +import type { Nullable } from '../core.js'; import type { Bounds } from '../dom/bounds.js'; +import type { Revision } from '../tags.js'; import type { Arguments, CapturedArguments } from './arguments.js'; export type RenderNodeType = @@ -9,7 +11,8 @@ export type RenderNodeType = | 'route-template' | 'component' | 'modifier' - | 'keyword'; + | 'keyword' + | 'helper'; export interface RenderNode { type: RenderNodeType; @@ -18,6 +21,36 @@ export interface RenderNode { instance: unknown; } +/** + * Reactivity introspection for a single argument of a render node: the + * revision of its most recently computed value, and whether that value + * was invalidated since the node's previous render (i.e. whether this + * argument caused the node's most recent re-render). + */ +export interface CapturedRenderNodeArgReactivity { + debugLabel: string | undefined; + revision: Revision; + changed: boolean; +} + +/** + * Reactivity introspection for a render node, so debug tooling (e.g. the + * Ember Inspector) can answer "how often did this re-render, and what + * caused the most recent re-render?" without patching the VM. + */ +export interface CapturedRenderNodeReactivity { + /** Number of times the node re-rendered after its initial render. */ + updateCount: number; + /** The global revision when the node last (re-)rendered. */ + revision: Revision; + /** The global revision of the render before that, if it re-rendered. */ + previousRevision: Nullable; + args: { + positional: CapturedRenderNodeArgReactivity[]; + named: Record; + }; +} + export interface CapturedRenderNode { id: string; type: RenderNodeType; @@ -29,6 +62,7 @@ export interface CapturedRenderNode { firstNode: SimpleNode; lastNode: SimpleNode; }; + reactivity: CapturedRenderNodeReactivity; children: CapturedRenderNode[]; } @@ -39,7 +73,7 @@ export interface DebugRenderTree { update(state: Bucket): void; - didRender(state: Bucket, bounds: Bounds): void; + didRender(state: Bucket, bounds: Nullable): void; willDestroy(state: Bucket): void; diff --git a/packages/@glimmer/reference/lib/reference.ts b/packages/@glimmer/reference/lib/reference.ts index c7232d0469a..9de393230e7 100644 --- a/packages/@glimmer/reference/lib/reference.ts +++ b/packages/@glimmer/reference/lib/reference.ts @@ -46,12 +46,21 @@ class ReferenceImpl implements Reference { public update: Nullable<(val: T) => void> = null; public debugLabel?: string; + public debugName?: string; constructor(type: ReferenceType) { this[REFERENCE] = type; } } +/** + * Debug-only introspection used by the debug render tree: the revision of + * the reference's most recently computed value. + */ +export function lastRevisionForRef(ref: Reference): Revision { + return (ref as ReferenceImpl).lastRevision; +} + export function createPrimitiveRef( value: T ): Reference { @@ -80,6 +89,9 @@ export function createConstRef(value: T, debugLabel: false | string): Referen if (DEBUG) { ref.debugLabel = debugLabel as string; + if (debugLabel) { + ref.debugName = debugLabel; + } } return ref; @@ -110,6 +122,9 @@ export function createComputeRef( if (DEBUG) { ref.debugLabel = `(result of a \`${debugLabel}\` helper)`; + if (debugLabel) { + ref.debugName = debugLabel; + } } return ref; diff --git a/packages/@glimmer/runtime/lib/compiled/opcodes/component.ts b/packages/@glimmer/runtime/lib/compiled/opcodes/component.ts index ec5b2c206d1..055c540ecc3 100644 --- a/packages/@glimmer/runtime/lib/compiled/opcodes/component.ts +++ b/packages/@glimmer/runtime/lib/compiled/opcodes/component.ts @@ -102,6 +102,7 @@ import { CheckInvocation, CheckReference, } from './-debug-strip'; +import { DebugRenderTreeDidRenderOpcode, DebugRenderTreeUpdateOpcode } from './debug-render-tree'; import { UpdateDynamicAttributeOpcode } from './dom'; /** @@ -951,22 +952,3 @@ export class DidUpdateLayoutOpcode implements UpdatingOpcode { vm.env.didUpdate(component); } } - -class DebugRenderTreeUpdateOpcode implements UpdatingOpcode { - constructor(private bucket: object) {} - - evaluate(vm: UpdatingVM) { - vm.env.debugRenderTree?.update(this.bucket); - } -} - -class DebugRenderTreeDidRenderOpcode implements UpdatingOpcode { - constructor( - private bucket: object, - private bounds: Bounds - ) {} - - evaluate(vm: UpdatingVM) { - vm.env.debugRenderTree?.didRender(this.bucket, this.bounds); - } -} diff --git a/packages/@glimmer/runtime/lib/compiled/opcodes/debug-render-tree.ts b/packages/@glimmer/runtime/lib/compiled/opcodes/debug-render-tree.ts new file mode 100644 index 00000000000..e09a1956a4b --- /dev/null +++ b/packages/@glimmer/runtime/lib/compiled/opcodes/debug-render-tree.ts @@ -0,0 +1,20 @@ +import type { Bounds, Nullable, UpdatingOpcode, UpdatingVM } from '@glimmer/interfaces'; + +export class DebugRenderTreeUpdateOpcode implements UpdatingOpcode { + constructor(private bucket: object) {} + + evaluate(vm: UpdatingVM) { + vm.env.debugRenderTree?.update(this.bucket); + } +} + +export class DebugRenderTreeDidRenderOpcode implements UpdatingOpcode { + constructor( + private bucket: object, + private bounds: Nullable + ) {} + + evaluate(vm: UpdatingVM) { + vm.env.debugRenderTree?.didRender(this.bucket, this.bounds); + } +} diff --git a/packages/@glimmer/runtime/lib/compiled/opcodes/expressions.ts b/packages/@glimmer/runtime/lib/compiled/opcodes/expressions.ts index 0e99c73c52f..7a5c3f043c2 100644 --- a/packages/@glimmer/runtime/lib/compiled/opcodes/expressions.ts +++ b/packages/@glimmer/runtime/lib/compiled/opcodes/expressions.ts @@ -39,13 +39,19 @@ import { } from '@glimmer/debug/lib/stack-check'; import debugToString from '@glimmer/debug-util/lib/debug-to-string'; import assert from '@glimmer/debug-util/lib/assert'; -import { _hasDestroyableChildren, associateDestroyableChild, destroy } from '@glimmer/destroyable'; +import { + _hasDestroyableChildren, + associateDestroyableChild, + destroy, + registerDestructor, +} from '@glimmer/destroyable'; import { debugAssert, toBool } from '@glimmer/global-context'; import { getInternalHelperManager } from '@glimmer/manager/lib/internal/api'; import { childRefFor, createComputeRef, FALSE_REFERENCE, + NULL_REFERENCE, TRUE_REFERENCE, UNDEFINED_REFERENCE, valueForRef, @@ -55,6 +61,7 @@ import { isIndexable } from '@glimmer/util/lib/collections'; import { $v0 } from '@glimmer/vm/lib/registers'; import { isCurriedType, resolveCurriedValue } from '../../curried-value'; +import { isInternalHelper } from '../../helpers/internal-helper'; import { APPEND_OPCODES } from '../../opcodes'; import createCurryRef from '../../references/curry-value'; import { reifyPositional } from '../../vm/arguments'; @@ -69,6 +76,7 @@ import { CheckScopeBlock, CheckUndefinedReference, } from './-debug-strip'; +import { DebugRenderTreeDidRenderOpcode, DebugRenderTreeUpdateOpcode } from './debug-render-tree'; APPEND_OPCODES.add(VM_CURRY_OP, (vm, { op1: type, op2: _isStrict }) => { let stack = vm.stack; @@ -143,6 +151,30 @@ APPEND_OPCODES.add(VM_DYNAMIC_HELPER_OP, (vm) => { }); vm.associateDestroyable(helperInstanceRef); + + let debugRenderTree = vm.env.debugRenderTree; + + if ( + DEBUG && + debugRenderTree !== undefined && + typeof ref.debugLabel === 'string' && + ref.debugLabel && + ref.debugLabel !== 'unknown' + ) { + debugRenderTree.create(helperInstanceRef, { + type: 'helper', + name: ref.debugLabel, + args, + instance: null, + }); + debugRenderTree.didRender(helperInstanceRef, null); + + registerDestructor(helperInstanceRef, () => debugRenderTree.willDestroy(helperInstanceRef)); + + vm.updateWith(new DebugRenderTreeUpdateOpcode(helperInstanceRef)); + vm.updateWith(new DebugRenderTreeDidRenderOpcode(helperInstanceRef, null)); + } + vm.loadValue($v0, helperValueRef); }); @@ -176,15 +208,59 @@ APPEND_OPCODES.add(VM_HELPER_OP, (vm, { op1: handle }) => { let stack = vm.stack; let helper = check(vm.constants.getValue(handle), CheckHelper); let args = check(stack.pop(), CheckArguments); - let value = helper(args.capture(), vm.getOwner(), vm.dynamicScope()); + let capturedArgs = args.capture(); + let value = helper(capturedArgs, vm.getOwner(), vm.dynamicScope()); + + let debugRenderTree = vm.env.debugRenderTree; + + if ( + DEBUG && + debugRenderTree !== undefined && + !isInternalHelper(helper) && + isCapturableHelperRef(value) + ) { + debugRenderTree.create(value, { + type: 'helper', + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- checked in isCapturableHelperRef + name: value.debugName!, + args: capturedArgs, + instance: null, + }); + debugRenderTree.didRender(value, null); - if (_hasDestroyableChildren(value)) { + vm.associateDestroyable(value); + registerDestructor(value, () => debugRenderTree.willDestroy(value)); + + vm.updateWith(new DebugRenderTreeUpdateOpcode(value)); + vm.updateWith(new DebugRenderTreeDidRenderOpcode(value, null)); + } else if (_hasDestroyableChildren(value)) { vm.associateDestroyable(value); } vm.loadValue($v0, value); }); +/** + * Helper invocations only show up in the debug render tree when we can + * present them meaningfully: + * + * - shared singleton references cannot serve as tree buckets, because the + * tree keys nodes and destructors on the reference's identity, and + * - references without a real debug name are implementation-detail refs + * (e.g. the internal helpers behind `{{outlet}}` and `{{mount}}`), not + * user-observable helper invocations. + */ +function isCapturableHelperRef(ref: Reference): boolean { + return ( + ref !== UNDEFINED_REFERENCE && + ref !== NULL_REFERENCE && + ref !== TRUE_REFERENCE && + ref !== FALSE_REFERENCE && + typeof ref.debugName === 'string' && + ref.debugName !== 'unknown' + ); +} + APPEND_OPCODES.add(VM_GET_VARIABLE_OP, (vm, { op1: symbol }) => { let expr = vm.referenceForSymbol(symbol); diff --git a/packages/@glimmer/runtime/lib/debug-render-tree.ts b/packages/@glimmer/runtime/lib/debug-render-tree.ts index 8b09ce748d6..43f245c0915 100644 --- a/packages/@glimmer/runtime/lib/debug-render-tree.ts +++ b/packages/@glimmer/runtime/lib/debug-render-tree.ts @@ -1,21 +1,35 @@ import { DEBUG } from '@glimmer/env'; import type { Bounds, + CapturedArguments, CapturedRenderNode, + CapturedRenderNodeArgReactivity, + CapturedRenderNodeReactivity, ComponentDefinition, DebugRenderTree, Nullable, + Reference, RenderNode, + Revision, } from '@glimmer/interfaces'; import { expect } from '@glimmer/debug-util/lib/platform-utils'; +import { lastRevisionForRef } from '@glimmer/reference/lib/reference'; import { assign } from '@glimmer/util/lib/object-utils'; import { StackImpl as Stack } from '@glimmer/util/lib/collections'; +import { CURRENT_TAG, valueForTag } from '@glimmer/validator/lib/validators'; import { reifyArgsDebug } from './vm/arguments'; +interface RenderNodeUpdates { + count: number; + revision: Revision; + previousRevision: Nullable; +} + interface InternalRenderNode extends RenderNode { bounds: Nullable; refs: Set>; + updates: RenderNodeUpdates; parent?: InternalRenderNode; } @@ -74,6 +88,11 @@ export default class DebugRenderTreeImpl< let internalNode: InternalRenderNode = assign({}, node, { bounds: null, refs: new Set>(), + updates: { + count: 0, + revision: valueForTag(CURRENT_TAG), + previousRevision: null, + }, }); this.nodes.set(state, internalNode); this.appendChild(internalNode, state); @@ -81,10 +100,15 @@ export default class DebugRenderTreeImpl< } update(state: TBucket): void { + let updates = this.nodeFor(state).updates; + updates.previousRevision = updates.revision; + updates.revision = valueForTag(CURRENT_TAG); + updates.count++; + this.enter(state); } - didRender(state: TBucket, bounds: Bounds): void { + didRender(state: TBucket, bounds: Nullable): void { if (DEBUG && this.stack.current !== state) { // eslint-disable-next-line @typescript-eslint/no-base-to-string throw new Error(`BUG: expecting ${this.stack.current}, got ${state}`); @@ -184,12 +208,22 @@ export default class DebugRenderTreeImpl< private captureNode(id: string, state: TBucket): CapturedRenderNode { let node = this.nodeFor(state); let { type, name, args, instance, refs } = node; + // Reify before capturing reactivity so the argument references carry + // their current revisions. + let reifiedArgs = reifyArgsDebug(args); + let reactivity = captureReactivity(node.updates, args); let bounds = this.captureBounds(node); let children = this.captureRefs(refs); - return { id, type, name, args: reifyArgsDebug(args), instance, bounds, children }; + return { id, type, name, args: reifiedArgs, instance, bounds, reactivity, children }; } private captureBounds(node: InternalRenderNode): CapturedRenderNode['bounds'] { + // Helper nodes are not rendered into the DOM and legitimately have no + // bounds; everything else must have rendered by capture time. + if (node.type === 'helper' && node.bounds === null) { + return null; + } + let bounds = expect(node.bounds, 'BUG: missing bounds'); let parentElement = bounds.parentElement(); let firstNode = bounds.firstNode(); @@ -198,6 +232,42 @@ export default class DebugRenderTreeImpl< } } +function captureArgReactivity( + ref: Reference, + previousRevision: Nullable +): CapturedRenderNodeArgReactivity { + let revision = lastRevisionForRef(ref); + + return { + debugLabel: typeof ref.debugLabel === 'string' ? ref.debugLabel : undefined, + revision, + // Arguments whose value was invalidated after the node's previous + // render are what caused its most recent re-render. + changed: previousRevision !== null && revision > previousRevision, + }; +} + +function captureReactivity( + updates: RenderNodeUpdates, + args: CapturedArguments +): CapturedRenderNodeReactivity { + let { count, revision, previousRevision } = updates; + + let positional = args.positional.map((ref) => captureArgReactivity(ref, previousRevision)); + + let named: Record = {}; + for (let [key, ref] of Object.entries(args.named)) { + named[key] = captureArgReactivity(ref, previousRevision); + } + + return { + updateCount: count, + revision, + previousRevision, + args: { positional, named }, + }; +} + export function getDebugName( definition: ComponentDefinition, manager = definition.manager diff --git a/packages/@glimmer/runtime/lib/helpers/internal-helper.ts b/packages/@glimmer/runtime/lib/helpers/internal-helper.ts index a5bc752e73c..faecb45ccd3 100644 --- a/packages/@glimmer/runtime/lib/helpers/internal-helper.ts +++ b/packages/@glimmer/runtime/lib/helpers/internal-helper.ts @@ -1,6 +1,18 @@ import type { Helper, HelperDefinitionState } from '@glimmer/interfaces'; import { setInternalHelperManager } from '@glimmer/manager/lib/internal/api'; +const INTERNAL_HELPERS = new WeakSet(); + export function internalHelper(helper: Helper): HelperDefinitionState { + INTERNAL_HELPERS.add(helper); return setInternalHelperManager(helper, {}); } + +/** + * VM-internal helpers (`fn`, `hash`, `array`, …) are implementation details + * of the templating language rather than user-observable invocations, so + * debug tooling (e.g. the debug render tree) leaves them out. + */ +export function isInternalHelper(helper: object): boolean { + return INTERNAL_HELPERS.has(helper); +}