Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<InternalOwner>): 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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -40,10 +41,64 @@ interface ExpectedRenderNode {
children: Expected<CapturedRenderNode['children']> | 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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
BaseEnv,
createTemplate,
defComponent,
defineSimpleHelper,
defineSimpleModifier,
GlimmerishComponent,
JitRenderDelegate,
Expand Down Expand Up @@ -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(`<p>{{upcase state.value}}</p>`, {
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(`<HelloWorld @arg={{state.value}}/>`, {
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(`<HelloWorld @arg="first"/>`, {
Expand Down
5 changes: 5 additions & 0 deletions packages/@glimmer/interfaces/lib/references.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ export type ReferenceSymbol = typeof REFERENCE;
export interface Reference<T = unknown> {
[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<string | Reference, Reference>;
}
38 changes: 36 additions & 2 deletions packages/@glimmer/interfaces/lib/runtime/debug-render-tree.d.ts
Original file line number Diff line number Diff line change
@@ -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 =
Expand All @@ -9,7 +11,8 @@ export type RenderNodeType =
| 'route-template'
| 'component'
| 'modifier'
| 'keyword';
| 'keyword'
| 'helper';

export interface RenderNode {
type: RenderNodeType;
Expand All @@ -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<Revision>;
args: {
positional: CapturedRenderNodeArgReactivity[];
named: Record<string, CapturedRenderNodeArgReactivity>;
};
}

export interface CapturedRenderNode {
id: string;
type: RenderNodeType;
Expand All @@ -29,6 +62,7 @@ export interface CapturedRenderNode {
firstNode: SimpleNode;
lastNode: SimpleNode;
};
reactivity: CapturedRenderNodeReactivity;
children: CapturedRenderNode[];
}

Expand All @@ -39,7 +73,7 @@ export interface DebugRenderTree<Bucket extends object = object> {

update(state: Bucket): void;

didRender(state: Bucket, bounds: Bounds): void;
didRender(state: Bucket, bounds: Nullable<Bounds>): void;

willDestroy(state: Bucket): void;

Expand Down
15 changes: 15 additions & 0 deletions packages/@glimmer/reference/lib/reference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,21 @@ class ReferenceImpl<T = unknown> implements Reference<T> {
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<T extends string | symbol | number | boolean | null | undefined>(
value: T
): Reference<T> {
Expand Down Expand Up @@ -80,6 +89,9 @@ export function createConstRef<T>(value: T, debugLabel: false | string): Referen

if (DEBUG) {
ref.debugLabel = debugLabel as string;
if (debugLabel) {
ref.debugName = debugLabel;
}
}

return ref;
Expand Down Expand Up @@ -110,6 +122,9 @@ export function createComputeRef<T = unknown>(

if (DEBUG) {
ref.debugLabel = `(result of a \`${debugLabel}\` helper)`;
if (debugLabel) {
ref.debugName = debugLabel;
}
}

return ref;
Expand Down
20 changes: 1 addition & 19 deletions packages/@glimmer/runtime/lib/compiled/opcodes/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ import {
CheckInvocation,
CheckReference,
} from './-debug-strip';
import { DebugRenderTreeDidRenderOpcode, DebugRenderTreeUpdateOpcode } from './debug-render-tree';
import { UpdateDynamicAttributeOpcode } from './dom';

/**
Expand Down Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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<Bounds>
) {}

evaluate(vm: UpdatingVM) {
vm.env.debugRenderTree?.didRender(this.bucket, this.bounds);
}
}
Loading
Loading