diff --git a/.eslintrc.js b/.eslintrc.js index 8373e0d402e..c59d5867014 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -64,6 +64,7 @@ module.exports = { rules: { // the TypeScript compiler already takes care of this and // leaving it enabled results in false positives for interface imports + 'no-dupe-class-members': 'off', 'no-unused-vars': 'off', 'no-undef': 'off', diff --git a/packages/@ember/-internals/environment/lib/env.ts b/packages/@ember/-internals/environment/lib/env.ts index 65463301d69..ef03681db64 100644 --- a/packages/@ember/-internals/environment/lib/env.ts +++ b/packages/@ember/-internals/environment/lib/env.ts @@ -1,4 +1,5 @@ import { FUNCTION_PROTOTYPE_EXTENSIONS } from '@ember/deprecated-features'; +import { DEBUG } from '@glimmer/env'; import global from './global'; /** @@ -98,6 +99,8 @@ export const ENV = { */ _TEMPLATE_ONLY_GLIMMER_COMPONENTS: false, + _DEBUG_RENDER_TREE: DEBUG, + /** Whether the app is using jQuery. See RFC #294. diff --git a/packages/@ember/-internals/glimmer/index.ts b/packages/@ember/-internals/glimmer/index.ts index b6f4d79a6dd..8ce655282fd 100644 --- a/packages/@ember/-internals/glimmer/index.ts +++ b/packages/@ember/-internals/glimmer/index.ts @@ -388,7 +388,7 @@ export { default as AbstractComponentManager } from './lib/component-managers/ab // it supports for example export { UpdatableReference, INVOKE } from './lib/utils/references'; export { default as iterableFor } from './lib/utils/iterable'; -export { default as DebugStack } from './lib/utils/debug-stack'; +export { default as getDebugStack, DebugStack } from './lib/utils/debug-stack'; export { default as OutletView } from './lib/views/outlet'; export { capabilities } from './lib/component-managers/custom'; export { setComponentManager, getComponentManager } from './lib/utils/custom-component-manager'; @@ -396,3 +396,5 @@ export { setModifierManager, getModifierManager } from './lib/utils/custom-modif export { capabilities as modifierCapabilities } from './lib/modifiers/custom'; export { isSerializationFirstNode } from './lib/utils/serialization-first-node-helpers'; export { setComponentTemplate, getComponentTemplate } from './lib/utils/component-template'; +export { CapturedRenderNode, captureRenderTree } from './lib/utils/debug-render-tree'; +export { WeakRef, WeakRefSet } from './lib/utils/weak'; diff --git a/packages/@ember/-internals/glimmer/lib/component-managers/abstract.ts b/packages/@ember/-internals/glimmer/lib/component-managers/abstract.ts index e53881d85b9..cf5d7012ac0 100644 --- a/packages/@ember/-internals/glimmer/lib/component-managers/abstract.ts +++ b/packages/@ember/-internals/glimmer/lib/component-managers/abstract.ts @@ -1,4 +1,3 @@ -import { DEBUG } from '@glimmer/env'; import { ComponentCapabilities, Simple } from '@glimmer/interfaces'; import { Tag, VersionedPathReference } from '@glimmer/reference'; import { @@ -11,20 +10,14 @@ import { PreparedArguments, } from '@glimmer/runtime'; import { Destroyable, Opaque, Option } from '@glimmer/util'; -import DebugStack from '../utils/debug-stack'; +import { DebugStack } from '../utils/debug-stack'; // implements the ComponentManager interface as defined in glimmer: // tslint:disable-next-line:max-line-length // https://github.com/glimmerjs/glimmer-vm/blob/v0.24.0-beta.4/packages/%40glimmer/runtime/lib/component/interfaces.ts#L21 export default abstract class AbstractManager implements ComponentManager { - public debugStack: typeof DebugStack; - public _pushToDebugStack!: (name: string, environment: any) => void; - public _pushEngineToDebugStack!: (name: string, environment: any) => void; - - constructor() { - this.debugStack = undefined; - } + public debugStack: DebugStack | undefined = undefined; prepareArgs(_state: U, _args: Arguments): Option { return null; @@ -83,15 +76,3 @@ export default abstract class AbstractManager implements ComponentManager< abstract getDestructor(bucket: T): Option; } - -if (DEBUG) { - AbstractManager.prototype._pushToDebugStack = function(name: string, environment) { - this.debugStack = environment.debugStack; - this.debugStack.push(name); - }; - - AbstractManager.prototype._pushEngineToDebugStack = function(name: string, environment) { - this.debugStack = environment.debugStack; - this.debugStack.pushEngine(name); - }; -} diff --git a/packages/@ember/-internals/glimmer/lib/component-managers/curly.ts b/packages/@ember/-internals/glimmer/lib/component-managers/curly.ts index 376f8e5ce51..881b3cc7309 100644 --- a/packages/@ember/-internals/glimmer/lib/component-managers/curly.ts +++ b/packages/@ember/-internals/glimmer/lib/component-managers/curly.ts @@ -1,4 +1,5 @@ import { privatize as P } from '@ember/-internals/container'; +import { ENV } from '@ember/-internals/environment'; import { getOwner } from '@ember/-internals/owner'; import { guidFor } from '@ember/-internals/utils'; import { @@ -239,7 +240,7 @@ export default class CurlyComponentManager hasBlock: boolean ): ComponentStateBucket { if (DEBUG) { - this._pushToDebugStack(`component:${state.name}`, environment); + environment.debugStack.push(`component:${state.name}`); } // Get the nearest concrete component instance from the scope. "Virtual" @@ -275,6 +276,12 @@ export default class CurlyComponentManager props.layout = state.template; } + // caller: + // + // + // callee: + // + // Now that we've built up all of the properties to set on the component instance, // actually create it. let component = factory.create(props); @@ -330,6 +337,15 @@ export default class CurlyComponentManager component.trigger('willRender'); } + if (ENV._DEBUG_RENDER_TREE) { + environment.debugRenderTree.create(bucket, { + type: 'component', + name: state.name, + args: args.capture(), + instance: component, + }); + } + return bucket; } @@ -388,8 +404,12 @@ export default class CurlyComponentManager bucket.component[BOUNDS] = bounds; bucket.finalize(); + if (ENV._DEBUG_RENDER_TREE) { + bucket.environment.debugRenderTree.didRender(bucket, bounds); + } + if (DEBUG) { - this.debugStack.pop(); + bucket.environment.debugStack.pop(); } } @@ -408,8 +428,12 @@ export default class CurlyComponentManager update(bucket: ComponentStateBucket): void { let { component, args, argsRevision, environment } = bucket; + if (ENV._DEBUG_RENDER_TREE) { + environment.debugRenderTree.update(bucket); + } + if (DEBUG) { - this._pushToDebugStack(component._debugContainerKey, environment); + environment.debugStack.push(component._debugContainerKey); } bucket.finalizer = _instrumentStart('render.component', rerenderInstrumentDetails, component); @@ -433,11 +457,15 @@ export default class CurlyComponentManager } } - didUpdateLayout(bucket: ComponentStateBucket): void { + didUpdateLayout(bucket: ComponentStateBucket, bounds: Bounds): void { bucket.finalize(); + if (ENV._DEBUG_RENDER_TREE) { + bucket.environment.debugRenderTree.didRender(bucket, bounds); + } + if (DEBUG) { - this.debugStack.pop(); + bucket.environment.debugStack.pop(); } } @@ -448,8 +476,17 @@ export default class CurlyComponentManager } } - getDestructor(stateBucket: ComponentStateBucket): Option { - return stateBucket; + getDestructor(bucket: ComponentStateBucket): Option { + if (ENV._DEBUG_RENDER_TREE) { + return { + destroy() { + bucket.environment.debugRenderTree.willDestroy(bucket); + bucket.destroy(); + }, + }; + } else { + return bucket; + } } } diff --git a/packages/@ember/-internals/glimmer/lib/component-managers/custom.ts b/packages/@ember/-internals/glimmer/lib/component-managers/custom.ts index a9d86e88338..8db08de87c2 100644 --- a/packages/@ember/-internals/glimmer/lib/component-managers/custom.ts +++ b/packages/@ember/-internals/glimmer/lib/component-managers/custom.ts @@ -15,6 +15,7 @@ import { import { createTag, isConst, PathReference, Tag } from '@glimmer/reference'; import { Arguments, + Bounds, CapturedArguments, ComponentDefinition, Invocation, @@ -22,6 +23,7 @@ import { } from '@glimmer/runtime'; import { Destroyable } from '@glimmer/util'; +import { ENV } from '@ember/-internals/environment'; import Environment from '../environment'; import RuntimeResolver from '../resolver'; import { OwnedTemplate } from '../template'; @@ -184,7 +186,7 @@ export default class CustomComponentManager RuntimeResolver > { create( - _env: Environment, + env: Environment, definition: CustomComponentDefinitionState, args: Arguments ): CustomComponentState { @@ -267,10 +269,27 @@ export default class CustomComponentManager const component = delegate.createComponent(definition.ComponentClass.class, value); - return new CustomComponentState(delegate, component, capturedArgs, namedArgsProxy); + let bucket = new CustomComponentState(delegate, component, capturedArgs, env, namedArgsProxy); + + if (ENV._DEBUG_RENDER_TREE) { + env.debugRenderTree.create(bucket, { + type: 'component', + name: definition.name, + args: args.capture(), + instance: component, + }); + } + + return bucket; } - update({ delegate, component, args, namedArgsProxy }: CustomComponentState) { + update(bucket: CustomComponentState) { + if (ENV._DEBUG_RENDER_TREE) { + bucket.env.debugRenderTree.update(bucket); + } + + let { delegate, component, args, namedArgsProxy } = bucket; + let value; if (EMBER_CUSTOM_COMPONENT_ARG_PROXY) { @@ -308,18 +327,34 @@ export default class CustomComponentManager } getDestructor(state: CustomComponentState): Option { + let destructor: Option = null; + if (hasDestructors(state.delegate)) { - return state; - } else { - return null; + destructor = state; } + + if (ENV._DEBUG_RENDER_TREE) { + let inner = destructor; + + destructor = { + destroy() { + state.env.debugRenderTree.willDestroy(state); + + if (inner) { + inner.destroy(); + } + }, + }; + } + + return destructor; } getCapabilities({ delegate, }: CustomComponentDefinitionState): ComponentCapabilities { return Object.assign({}, CAPABILITIES, { - updateHook: delegate.capabilities.updateHook, + updateHook: ENV._DEBUG_RENDER_TREE || delegate.capabilities.updateHook, }); } @@ -332,7 +367,17 @@ export default class CustomComponentManager } } - didRenderLayout() {} + didRenderLayout(bucket: CustomComponentState, bounds: Bounds) { + if (ENV._DEBUG_RENDER_TREE) { + bucket.env.debugRenderTree.didRender(bucket, bounds); + } + } + + didUpdateLayout(bucket: CustomComponentState, bounds: Bounds) { + if (ENV._DEBUG_RENDER_TREE) { + bucket.env.debugRenderTree.didRender(bucket, bounds); + } + } getLayout(state: DefinitionState): Invocation { return { @@ -351,6 +396,7 @@ export class CustomComponentState { public delegate: ManagerDelegate, public component: ComponentInstance, public args: CapturedArguments, + public env: Environment, public namedArgsProxy?: {} ) {} diff --git a/packages/@ember/-internals/glimmer/lib/component-managers/input.ts b/packages/@ember/-internals/glimmer/lib/component-managers/input.ts index 22f3cf46811..fa0de7ad7f7 100644 --- a/packages/@ember/-internals/glimmer/lib/component-managers/input.ts +++ b/packages/@ember/-internals/glimmer/lib/component-managers/input.ts @@ -1,10 +1,12 @@ +import { ENV } from '@ember/-internals/environment'; import { set } from '@ember/-internals/metal'; import { Owner } from '@ember/-internals/owner'; import { assert, debugFreeze } from '@ember/debug'; import { ComponentCapabilities, Dict } from '@glimmer/interfaces'; -import { CONSTANT_TAG, isConst, VersionedPathReference } from '@glimmer/reference'; -import { Arguments, DynamicScope, Environment, PreparedArguments } from '@glimmer/runtime'; +import { CONSTANT_TAG, createTag, isConst, VersionedPathReference } from '@glimmer/reference'; +import { Arguments, Bounds, DynamicScope, PreparedArguments } from '@glimmer/runtime'; import { Destroyable } from '@glimmer/util'; +import Environment from '../environment'; import { RootReference } from '../utils/references'; import InternalComponentManager, { InternalDefinitionState } from './internal'; @@ -22,6 +24,7 @@ const CAPABILITIES: ComponentCapabilities = { }; export interface InputComponentState { + env: Environment; type: VersionedPathReference; instance: Destroyable; } @@ -53,7 +56,7 @@ export default class InputComponentManager extends InternalComponentManager; + environment: Environment; modelRef?: VersionedPathReference; } @@ -70,7 +77,7 @@ class MountManager extends AbstractManager create(environment: Environment, { name }: EngineDefinitionState, args: Arguments) { if (DEBUG) { - this._pushEngineToDebugStack(`engine:${name}`, environment); + environment.debugStack.pushEngine(`engine:${name}`); } // TODO @@ -78,7 +85,7 @@ class MountManager extends AbstractManager // we should resolve the engine app template in the helper // it also should use the owner that looked up the mount helper. - let engine = environment.owner.buildChildEngineInstance(name); + let engine = environment.owner.buildChildEngineInstance(name); engine.boot(); @@ -96,12 +103,28 @@ class MountManager extends AbstractManager if (modelRef === undefined) { controller = controllerFactory.create(); self = new RootReference(controller); - bucket = { engine, controller, self }; + bucket = { engine, controller, self, environment }; } else { let model = modelRef.value(); controller = controllerFactory.create({ model }); self = new RootReference(controller); - bucket = { engine, controller, self, modelRef }; + bucket = { engine, controller, self, modelRef, environment }; + } + + if (ENV._DEBUG_RENDER_TREE) { + environment.debugRenderTree.create(bucket, { + type: 'engine', + name, + args: args.capture(), + instance: engine, + }); + + environment.debugRenderTree.create(controller, { + type: 'route-template', + name: 'application', + args: args.capture(), + instance: controller, + }); } return bucket; @@ -112,26 +135,64 @@ class MountManager extends AbstractManager } getTag(state: EngineState): Tag { + let tag: Tag = CONSTANT_TAG; + if (state.modelRef) { - return state.modelRef.tag; - } else { - return CONSTANT_TAG; + tag = state.modelRef.tag; + } + + if (ENV._DEBUG_RENDER_TREE && isConstTag(tag)) { + tag = createTag(); } + + return tag; } - getDestructor({ engine }: EngineState): Option { - return engine; + getDestructor(bucket: EngineState): Option { + let { engine, environment, controller } = bucket; + + if (ENV._DEBUG_RENDER_TREE) { + return { + destroy() { + environment.debugRenderTree.willDestroy(controller); + environment.debugRenderTree.willDestroy(bucket); + engine.destroy(); + }, + }; + } else { + return engine; + } } - didRenderLayout(): void { + didRenderLayout(bucket: EngineState, bounds: Bounds): void { if (DEBUG) { - this.debugStack.pop(); + bucket.environment.debugStack.pop(); + } + + if (ENV._DEBUG_RENDER_TREE) { + bucket.environment.debugRenderTree.didRender(bucket.controller, bounds); + bucket.environment.debugRenderTree.didRender(bucket, bounds); + } + } + + update(bucket: EngineState): void { + let { controller, environment, modelRef } = bucket; + + if (modelRef !== undefined) { + controller.set('model', modelRef!.value()); + } + + if (ENV._DEBUG_RENDER_TREE) { + environment.debugRenderTree.update(bucket); + environment.debugRenderTree.update(bucket.controller); } } - update({ controller, modelRef }: EngineState): void { - assert('[BUG] `update` should only be called when modelRef is present', modelRef !== undefined); - controller.set('model', modelRef!.value()); + didUpdateLayout(bucket: EngineState, bounds: Bounds): void { + if (ENV._DEBUG_RENDER_TREE) { + bucket.environment.debugRenderTree.didRender(bucket.controller, bounds); + bucket.environment.debugRenderTree.didRender(bucket, bounds); + } } } diff --git a/packages/@ember/-internals/glimmer/lib/component-managers/outlet.ts b/packages/@ember/-internals/glimmer/lib/component-managers/outlet.ts index b9ed4d57ef6..097733c7cde 100644 --- a/packages/@ember/-internals/glimmer/lib/component-managers/outlet.ts +++ b/packages/@ember/-internals/glimmer/lib/component-managers/outlet.ts @@ -1,21 +1,25 @@ import { ENV } from '@ember/-internals/environment'; import { guidFor } from '@ember/-internals/utils'; import { OwnedTemplateMeta } from '@ember/-internals/views'; +import { assert } from '@ember/debug'; +import EngineInstance from '@ember/engine/instance'; import { _instrumentStart } from '@ember/instrumentation'; import { assign } from '@ember/polyfills'; import { DEBUG } from '@glimmer/env'; import { ComponentCapabilities, Option, Simple } from '@glimmer/interfaces'; -import { CONSTANT_TAG, Tag, VersionedPathReference } from '@glimmer/reference'; +import { CONSTANT_TAG, createTag, Tag, VersionedPathReference } from '@glimmer/reference'; import { Arguments, + Bounds, ComponentDefinition, ElementOperations, - Environment, + EMPTY_ARGS, Invocation, WithDynamicTagName, WithStaticLayout, } from '@glimmer/runtime'; import { Destroyable } from '@glimmer/util'; +import Environment from '../environment'; import { DynamicScope } from '../renderer'; import RuntimeResolver from '../resolver'; import { OwnedTemplate } from '../template'; @@ -30,6 +34,9 @@ function instrumentationPayload(def: OutletDefinitionState) { interface OutletInstanceState { self: VersionedPathReference; + environment: Environment; + outlet?: { name: string }; + engine?: { mountPoint: string }; finalize: () => void; } @@ -46,12 +53,12 @@ const CAPABILITIES: ComponentCapabilities = { dynamicLayout: false, dynamicTag: false, prepareArgs: false, - createArgs: false, + createArgs: ENV._DEBUG_RENDER_TREE, attributeHook: false, elementHook: false, - createCaller: true, + createCaller: false, dynamicScope: true, - updateHook: false, + updateHook: ENV._DEBUG_RENDER_TREE, createInstance: true, }; @@ -66,18 +73,65 @@ class OutletComponentManager extends AbstractManager { - return null; + didUpdateLayout(state: OutletInstanceState, bounds: Bounds): void { + if (ENV._DEBUG_RENDER_TREE) { + state.environment.debugRenderTree.didRender(state, bounds); + + if (state.engine) { + state.environment.debugRenderTree.didRender(state.engine, bounds); + } + + state.environment.debugRenderTree.didRender(state.outlet!, bounds); + } + } + + getDestructor(state: OutletInstanceState): Option { + if (ENV._DEBUG_RENDER_TREE) { + return { + destroy() { + state.environment.debugRenderTree.willDestroy(state); + + if (state.engine) { + state.environment.debugRenderTree.willDestroy(state.engine); + } + + state.environment.debugRenderTree.willDestroy(state.outlet!); + }, + }; + } else { + return null; + } } } diff --git a/packages/@ember/-internals/glimmer/lib/component-managers/root.ts b/packages/@ember/-internals/glimmer/lib/component-managers/root.ts index 813578f7802..d21ce11bbd3 100644 --- a/packages/@ember/-internals/glimmer/lib/component-managers/root.ts +++ b/packages/@ember/-internals/glimmer/lib/component-managers/root.ts @@ -1,9 +1,10 @@ import { FACTORY_FOR } from '@ember/-internals/container'; +import { ENV } from '@ember/-internals/environment'; import { Factory } from '@ember/-internals/owner'; import { _instrumentStart } from '@ember/instrumentation'; import { DEBUG } from '@glimmer/env'; -import { ComponentCapabilities } from '@glimmer/interfaces'; -import { Arguments, ComponentDefinition } from '@glimmer/runtime'; +import { ComponentCapabilities, Option } from '@glimmer/interfaces'; +import { Arguments, ComponentDefinition, EMPTY_ARGS } from '@glimmer/runtime'; import { DIRTY_TAG } from '../component'; import Environment from '../environment'; import { DynamicScope } from '../renderer'; @@ -33,14 +34,14 @@ class RootComponentManager extends CurlyComponentManager { create( environment: Environment, - _state: DefinitionState, - _args: Arguments | null, + state: DefinitionState, + _args: Option, dynamicScope: DynamicScope ) { let component = this.component; if (DEBUG) { - this._pushToDebugStack((component as any)._debugContainerKey, environment); + environment.debugStack.push((component as any)._debugContainerKey); } let finalizer = _instrumentStart('render.component', initialRenderInstrumentDetails, component); @@ -66,7 +67,24 @@ class RootComponentManager extends CurlyComponentManager { processComponentInitializationAssertions(component, {}); } - return new ComponentStateBucket(environment, component, null, finalizer, hasWrappedElement); + let bucket = new ComponentStateBucket( + environment, + component, + null, + finalizer, + hasWrappedElement + ); + + if (ENV._DEBUG_RENDER_TREE) { + environment.debugRenderTree.create(bucket, { + type: 'component', + name: state.name, + args: EMPTY_ARGS, + instance: component, + }); + } + + return bucket; } } diff --git a/packages/@ember/-internals/glimmer/lib/component-managers/template-only.ts b/packages/@ember/-internals/glimmer/lib/component-managers/template-only.ts index 2c6b3606f06..acc159c07df 100644 --- a/packages/@ember/-internals/glimmer/lib/component-managers/template-only.ts +++ b/packages/@ember/-internals/glimmer/lib/component-managers/template-only.ts @@ -1,12 +1,16 @@ +import { ENV } from '@ember/-internals/environment'; import { OwnedTemplateMeta } from '@ember/-internals/views'; -import { ComponentCapabilities } from '@glimmer/interfaces'; -import { CONSTANT_TAG } from '@glimmer/reference'; +import { ComponentCapabilities, Option } from '@glimmer/interfaces'; +import { CONSTANT_TAG, createTag } from '@glimmer/reference'; import { + Arguments, + Bounds, ComponentDefinition, Invocation, NULL_REFERENCE, WithStaticLayout, } from '@glimmer/runtime'; +import Environment from '../environment'; import RuntimeResolver from '../resolver'; import { OwnedTemplate } from '../template'; import AbstractManager from './abstract'; @@ -15,18 +19,29 @@ const CAPABILITIES: ComponentCapabilities = { dynamicLayout: false, dynamicTag: false, prepareArgs: false, - createArgs: false, + createArgs: ENV._DEBUG_RENDER_TREE, attributeHook: false, elementHook: false, createCaller: false, dynamicScope: false, - updateHook: false, + updateHook: ENV._DEBUG_RENDER_TREE, createInstance: true, }; -export default class TemplateOnlyComponentManager extends AbstractManager - implements WithStaticLayout { - getLayout(template: OwnedTemplate): Invocation { +export interface DebugStateBucket { + environment: Environment; +} + +export default class TemplateOnlyComponentManager + extends AbstractManager, TemplateOnlyComponentDefinitionState> + implements + WithStaticLayout< + Option, + TemplateOnlyComponentDefinitionState, + OwnedTemplateMeta, + RuntimeResolver + > { + getLayout({ template }: TemplateOnlyComponentDefinitionState): Invocation { const layout = template.asLayout(); return { handle: layout.compile(), @@ -38,8 +53,23 @@ export default class TemplateOnlyComponentManager extends AbstractManager { + if (ENV._DEBUG_RENDER_TREE) { + let bucket = { environment }; + environment.debugRenderTree.create(bucket, { + type: 'component', + name: name, + args: args.capture(), + instance: null, + }); + return bucket; + } else { + return null; + } } getSelf() { @@ -47,18 +77,61 @@ export default class TemplateOnlyComponentManager extends AbstractManager) { + if (ENV._DEBUG_RENDER_TREE) { + return { + destroy() { + bucket!.environment.debugRenderTree.willDestroy(bucket!); + }, + }; + } else { + return null; + } + } + + didRenderLayout(bucket: Option, bounds: Bounds): void { + if (ENV._DEBUG_RENDER_TREE) { + bucket!.environment.debugRenderTree.didRender(bucket!, bounds); + } } - getDestructor() { - return null; + update(bucket: Option): void { + if (ENV._DEBUG_RENDER_TREE) { + bucket!.environment.debugRenderTree.update(bucket!); + } + } + + didUpdateLayout(bucket: Option, bounds: Bounds): void { + if (ENV._DEBUG_RENDER_TREE) { + bucket!.environment.debugRenderTree.didRender(bucket!, bounds); + } } } const MANAGER = new TemplateOnlyComponentManager(); +export interface TemplateOnlyComponentDefinitionState { + name: string; + template: OwnedTemplate; +} + export class TemplateOnlyComponentDefinition - implements ComponentDefinition { + implements + TemplateOnlyComponentDefinitionState, + ComponentDefinition { manager = MANAGER; - constructor(public state: OwnedTemplate) {} + constructor(public name: string, public template: OwnedTemplate) {} + + get state(): TemplateOnlyComponentDefinitionState { + return this; + } } diff --git a/packages/@ember/-internals/glimmer/lib/environment.ts b/packages/@ember/-internals/glimmer/lib/environment.ts index 65b9e3213fb..de6ba535af0 100644 --- a/packages/@ember/-internals/glimmer/lib/environment.ts +++ b/packages/@ember/-internals/glimmer/lib/environment.ts @@ -10,14 +10,16 @@ import { SimpleDynamicAttribute, } from '@glimmer/runtime'; import { Destroyable, Opaque } from '@glimmer/util'; -import DebugStack from './utils/debug-stack'; +import getDebugStack, { DebugStack } from './utils/debug-stack'; import createIterable from './utils/iterable'; import { ConditionalReference, UpdatableReference } from './utils/references'; import { isHTMLSafe } from './utils/string'; import installPlatformSpecificProtocolForURL from './protocol-for-url'; +import { ENV } from '@ember/-internals/environment'; import { OwnedTemplate } from './template'; +import DebugRenderTree from './utils/debug-render-tree'; export interface CompilerFactory { id: string; @@ -33,13 +35,17 @@ export default class Environment extends GlimmerEnvironment { public isInteractive: boolean; public destroyedComponents: Destroyable[]; - public debugStack: typeof DebugStack; + private _debugStack: DebugStack | undefined; + private _debugRenderTree: DebugRenderTree | undefined; public inTransaction = false; constructor(injections: any) { super(injections); - this.owner = injections[OWNER]; - this.isInteractive = this.owner.lookup('-environment:main').isInteractive; + + let owner: Owner = injections[OWNER]; + + this.owner = owner; + this.isInteractive = owner.lookup('-environment:main').isInteractive; // can be removed once https://github.com/tildeio/glimmer/pull/305 lands this.destroyedComponents = []; @@ -47,7 +53,29 @@ export default class Environment extends GlimmerEnvironment { installPlatformSpecificProtocolForURL(this); if (DEBUG) { - this.debugStack = new DebugStack(); + this._debugStack = getDebugStack(); + } + + if (ENV._DEBUG_RENDER_TREE) { + this._debugRenderTree = new DebugRenderTree(owner); + } + } + + get debugStack(): DebugStack { + if (DEBUG) { + return this._debugStack!; + } else { + throw new Error("Can't access debug stack outside of debug mode"); + } + } + + get debugRenderTree(): DebugRenderTree { + if (ENV._DEBUG_RENDER_TREE) { + return this._debugRenderTree!; + } else { + throw new Error( + "Can't access debug render tree outside of the inspector (_DEBUG_RENDER_TREE flag is disabled)" + ); } } @@ -82,6 +110,10 @@ export default class Environment extends GlimmerEnvironment { } begin(): void { + if (ENV._DEBUG_RENDER_TREE) { + this.debugRenderTree.begin(); + } + this.inTransaction = true; super.begin(); @@ -102,6 +134,10 @@ export default class Environment extends GlimmerEnvironment { } finally { this.inTransaction = false; } + + if (ENV._DEBUG_RENDER_TREE) { + this.debugRenderTree.commit(); + } } } diff --git a/packages/@ember/-internals/glimmer/lib/resolver.ts b/packages/@ember/-internals/glimmer/lib/resolver.ts index 19d342c68ed..89603e35e98 100644 --- a/packages/@ember/-internals/glimmer/lib/resolver.ts +++ b/packages/@ember/-internals/glimmer/lib/resolver.ts @@ -436,13 +436,13 @@ export default class RuntimeResolver implements IRuntimeResolver; + instance: unknown; + bounds: Option<{ + parentElement: Simple.Element; + firstNode: Simple.Node; + lastNode: Simple.Node; + }>; + children: CapturedRenderNode[]; +} + +const RENDER_TREES = new WeakMap(); + +export default class DebugRenderTree { + private stack = new Stack(); + private nodes = new WeakMap(); + private bounds = new WeakMap(); + + constructor(private owner: Owner) { + assert(`BUG: owner already has a DebugRenderTree associated with it`, !RENDER_TREES.has(owner)); + RENDER_TREES.set(owner, this); + } + + begin(): void { + if (this.stack.size !== 0) { + // eslint-disable-next-line no-console + console.warn( + 'Ember encountered an error during the last rendering loop. ' + + 'This will likely trigger undefined behavior and memory leaks ' + + 'as the error left things in an inconsistent state. ' + + 'It is recommended that you refresh the page.' + ); + + while (!this.stack.isEmpty()) { + this.stack.pop(); + } + } + + this.stack.push(this.owner); + } + + create(state: object, node: RenderNode): void { + this.nodes.set(state, node); + this.appendChild(state); + this.enter(state); + } + + update(state: object): void { + this.enter(state); + } + + didRender(state: object, bounds: Bounds): void { + assert(`BUG: Expecting ${this.stack.current}, got ${state}`, this.stack.current === state); + this.bounds.set(state, bounds); + this.exit(); + } + + willDestroy(state: object): void { + WeakRef.release(state); + } + + commit(): void { + assert( + 'BUG: Expecting the owner to be the only node', + this.stack.size === 1 && this.stack.current === this.owner + ); + this.stack.pop(); + } + + capture(): CapturedRenderNode[] { + return this.captureChildren(this.owner); + } + + private enter(state: object): void { + this.stack.push(state); + } + + private exit(): void { + assert('BUG: Cannot pop the root node off the debug render tree', this.stack.size !== 1); + this.stack.pop(); + } + + private childrenFor(parent: object): WeakRefSet { + return WeakRefSet.for(parent, 'render-tree:children'); + } + + private appendChild(child: object): void { + let parent = expect(this.stack.current, 'BUG: Unexpected empty stack'); + this.childrenFor(parent).add(child); + } + + private captureChildren(parent: object): CapturedRenderNode[] { + return this.childrenFor(parent) + .toArray() + .map(child => this.captureNode(child)); + } + + private captureNode(state: object): CapturedRenderNode { + let { type, name, args, instance } = expect(this.nodes.get(state), 'BUG: RenderNode not found'); + let bounds = this.captureBounds(state); + let children = this.captureChildren(state); + return { type, name, args: args.value(), instance, bounds, children }; + } + + private captureBounds(state: object): CapturedRenderNode['bounds'] { + let bounds = expect(this.bounds.get(state), 'BUG: Bounds not found'); + let parentElement = bounds.parentElement(); + let firstNode = bounds.firstNode(); + let lastNode = bounds.lastNode(); + return { parentElement, firstNode, lastNode }; + } +} + +export function captureRenderTree(owner: Owner): CapturedRenderNode[] { + let tree = expect(RENDER_TREES.get(owner), 'BUG: DebugRenderTree not found'); + return tree.capture(); +} diff --git a/packages/@ember/-internals/glimmer/lib/utils/debug-stack.ts b/packages/@ember/-internals/glimmer/lib/utils/debug-stack.ts index 72df23ac3a9..535e72dbcae 100644 --- a/packages/@ember/-internals/glimmer/lib/utils/debug-stack.ts +++ b/packages/@ember/-internals/glimmer/lib/utils/debug-stack.ts @@ -2,7 +2,16 @@ import { DEBUG } from '@glimmer/env'; -let DebugStack: any; +export interface DebugStack { + push(name: string): void; + pushEngine(name: string): void; + pop(): string | void; + peek(): string | void; +} + +let getDebugStack: () => DebugStack = () => { + throw new Error("Can't access the DebugStack class outside of debug mode"); +}; if (DEBUG) { class Element { @@ -12,8 +21,7 @@ if (DEBUG) { class TemplateElement extends Element {} class EngineElement extends Element {} - // tslint:disable-next-line:no-shadowed-variable - DebugStack = class DebugStack { + let DebugStackImpl = class DebugStackImpl implements DebugStack { private _stack: TemplateElement[] = []; push(name: string) { @@ -60,6 +68,8 @@ if (DEBUG) { } } }; + + getDebugStack = () => new DebugStackImpl(); } -export default DebugStack; +export default getDebugStack; diff --git a/packages/@ember/-internals/glimmer/lib/utils/weak.ts b/packages/@ember/-internals/glimmer/lib/utils/weak.ts new file mode 100644 index 00000000000..48051b55866 --- /dev/null +++ b/packages/@ember/-internals/glimmer/lib/utils/weak.ts @@ -0,0 +1,86 @@ +import { Dict } from '@glimmer/interfaces'; +import { dict } from '@glimmer/util'; + +const REFS = new WeakMap(); +const VALS = new WeakMap(); + +export class WeakRef { + static for(value: T | WeakRef): WeakRef { + if (value instanceof WeakRef) { + return value; + } + + let ref = REFS.get(value) as WeakRef | undefined; + + if (ref === undefined) { + ref = new WeakRef(); + VALS.set(ref, value); + REFS.set(value, ref); + } + + return ref; + } + + static release(value: T | WeakRef): void { + let ref = WeakRef.for(value); + let val = VALS.get(ref); + + VALS.delete(ref); + + if (val !== undefined) { + REFS.delete(val); + } + } + + private constructor() {} + + get(): T | undefined { + return VALS.get(this) as T | undefined; + } +} + +const SETS = new WeakMap>(); + +export class WeakRefSet { + static for(parent: object, purpose: string): WeakRefSet { + let map = SETS.get(parent) as Dict> | undefined; + + if (map === undefined) { + map = dict(); + SETS.set(parent, map); + } + + let set = map[purpose] as WeakRefSet | undefined; + + if (set === undefined) { + set = new WeakRefSet(); + map[purpose] = set; + } + + return set; + } + + private refs = new Set(); + + add(value: T | WeakRef): WeakRef { + let ref = WeakRef.for(value); + this.refs.add(ref); + return ref; + } + + toArray(): T[] { + let items: T[] = []; + + this.refs.forEach(ref => { + let value = ref.get(); + + if (value === undefined) { + this.refs.delete(ref); + } else { + items.push(value); + } + }); + + return items; + } +} 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 new file mode 100644 index 00000000000..3b89a781047 --- /dev/null +++ b/packages/@ember/-internals/glimmer/tests/integration/application/debug-render-tree-test.ts @@ -0,0 +1,1620 @@ +import { ApplicationTestCase, moduleFor, strip } from 'internal-test-helpers'; + +import { ENV } from '@ember/-internals/environment'; +import { + capabilities, + CapturedRenderNode, + captureRenderTree, + Component, + setComponentManager, + setComponentTemplate, +} from '@ember/-internals/glimmer'; +import { EngineInstanceOptions } from '@ember/-internals/owner'; +import { Route } from '@ember/-internals/routing'; +import templateOnly from '@ember/component/template-only'; +import Controller from '@ember/controller'; +import Engine from '@ember/engine'; +import EngineInstance from '@ember/engine/instance'; +import { Simple } from '@glimmer/interfaces'; +import { expect } from '@glimmer/util'; +import { compile } from 'ember-template-compiler'; +import { runTask } from 'internal-test-helpers/lib/run'; + +interface CapturedBounds { + parentElement: Simple.Element; + firstNode: Simple.Node; + lastNode: Simple.Node; +} + +type Expected = T | ((actual: T) => boolean); + +function isExpectedFunc(expected: Expected): expected is (actual: T) => boolean { + return typeof expected === 'function'; +} + +interface ExpectedRenderNode { + type: CapturedRenderNode['type']; + name: CapturedRenderNode['name']; + args: Expected; + instance: Expected; + bounds: Expected; + children: Expected | ExpectedRenderNode[]; +} + +if (ENV._DEBUG_RENDER_TREE) { + moduleFor( + 'Application test: debug render tree', + class extends ApplicationTestCase { + constructor() { + super(...arguments); + this._TEMPLATE_ONLY_GLIMMER_COMPONENTS = ENV._TEMPLATE_ONLY_GLIMMER_COMPONENTS; + ENV._TEMPLATE_ONLY_GLIMMER_COMPONENTS = true; + } + + teardown() { + super.teardown(); + ENV._TEMPLATE_ONLY_GLIMMER_COMPONENTS = this._TEMPLATE_ONLY_GLIMMER_COMPONENTS; + } + + async '@test routes'() { + this.addTemplate('index', 'Index'); + this.addTemplate('foo', 'Foo {{outlet}}'); + this.addTemplate('foo.index', 'index'); + this.addTemplate('foo.inner', '{{@model}}'); + this.addTemplate('bar', 'Bar {{outlet}}'); + this.addTemplate('bar.index', 'index'); + this.addTemplate('bar.inner', '{{@model}}'); + + this.router.map(function(this: any) { + this.route('foo', function(this: any) { + this.route('inner', { path: '/:model' }); + }); + this.route('foo', function(this: any) { + this.route('inner', { path: '/:model' }); + }); + }); + + class PassThroughRoute extends Route { + model({ model }: { model: string }) { + return model; + } + } + + this.add('route:foo.inner', PassThroughRoute); + this.add('route:bar.inner', PassThroughRoute); + + await this.visit('/'); + + this.assertRenderTree([ + this.outlet({ + type: 'route-template', + name: 'index', + args: { positional: [], named: { model: undefined } }, + instance: this.controllerFor('index'), + bounds: this.elementBounds(this.element), + children: [], + }), + ]); + + await this.visit('/foo'); + + this.assertRenderTree([ + this.outlet({ + type: 'route-template', + name: 'foo', + args: { positional: [], named: { model: undefined } }, + instance: this.controllerFor('foo'), + bounds: this.elementBounds(this.element), + children: [ + this.outlet({ + type: 'route-template', + name: 'foo.index', + args: { positional: [], named: { model: undefined } }, + instance: this.controllerFor('foo.index'), + bounds: this.nodeBounds(this.element.lastChild), + children: [], + }), + ], + }), + ]); + + await this.visit('/foo/wow'); + + this.assertRenderTree([ + this.outlet({ + type: 'route-template', + name: 'foo', + args: { positional: [], named: { model: undefined } }, + instance: this.controllerFor('foo'), + bounds: this.elementBounds(this.element), + children: [ + this.outlet({ + type: 'route-template', + name: 'foo.inner', + args: { positional: [], named: { model: 'wow' } }, + instance: this.controllerFor('foo.inner'), + bounds: this.nodeBounds(this.element.lastChild), + children: [], + }), + ], + }), + ]); + + await this.visit('/foo/zomg'); + + this.assertRenderTree([ + this.outlet({ + type: 'route-template', + name: 'foo', + args: { positional: [], named: { model: undefined } }, + instance: this.controllerFor('foo'), + bounds: this.elementBounds(this.element), + children: [ + this.outlet({ + type: 'route-template', + name: 'foo.inner', + args: { positional: [], named: { model: 'zomg' } }, + instance: this.controllerFor('foo.inner'), + bounds: this.nodeBounds(this.element.lastChild), + children: [], + }), + ], + }), + ]); + } + + async '@test named outlets'() { + this.addTemplate( + 'application', + strip` + + {{outlet}} + ` + ); + this.addTemplate('header', 'header'); + this.addTemplate('index', 'index'); + + this.add( + 'controller:index', + class extends Controller { + queryParams = ['showHeader']; + showHeader = false; + } + ); + + interface Model { + showHeader: boolean; + } + + this.add( + 'route:index', + class extends Route { + queryParams = { + showHeader: { + refreshModel: true, + }, + }; + + model({ showHeader }: Model): Model { + return { showHeader }; + } + + setupController(controller: Controller, { showHeader }: Model): void { + controller.setProperties({ showHeader }); + } + + renderTemplate(_: Controller, { showHeader }: Model): void { + this.render(); + + if (showHeader) { + this.render('header', { outlet: 'header' }); + } else { + this.disconnectOutlet('header'); + } + } + } + ); + + await this.visit('/'); + + this.assertRenderTree([ + this.outlet({ + type: 'route-template', + name: 'index', + args: { positional: [], named: { model: { showHeader: false } } }, + instance: this.controllerFor('index'), + bounds: this.nodeBounds(this.element.lastChild), + children: [], + }), + ]); + + await this.visit('/?showHeader'); + + this.assertRenderTree([ + this.outlet('header', { + type: 'route-template', + name: 'header', + args: { positional: [], named: { model: { showHeader: true } } }, + instance: this.controllerFor('index'), + bounds: this.elementBounds(this.element.firstChild), + children: [], + }), + this.outlet({ + type: 'route-template', + name: 'index', + args: { positional: [], named: { model: { showHeader: true } } }, + instance: this.controllerFor('index'), + bounds: this.nodeBounds(this.element.lastChild), + children: [], + }), + ]); + + await this.visit('/'); + + this.assertRenderTree([ + this.outlet({ + type: 'route-template', + name: 'index', + args: { positional: [], named: { model: { showHeader: false } } }, + instance: this.controllerFor('index'), + bounds: this.nodeBounds(this.element.lastChild), + children: [], + }), + ]); + } + + async '@test {{mount}}'() { + this.addTemplate( + 'application', + strip` +
{{mount "foo"}}
+
{{mount this.engineName}}
+ {{#if this.showMore}} +
{{mount "foo" model=this.engineModel}}
+
{{mount this.engineName model=this.engineModel}}
+ {{/if}} + ` + ); + + this.add( + 'engine:foo', + class extends Engine { + isFooEngine = true; + + init() { + super.init(...arguments); + this.register( + 'template:application', + compile(strip` + {{outlet}} + + {{#if @model}} + + {{/if}} + `) + ); + this.register('template:index', compile('Foo')); + this.register('template:components/inspect-model', compile('{{@model}}')); + } + + buildInstance(options?: EngineInstanceOptions): EngineInstance { + let instance = super.buildInstance(options); + instance['isFooEngineInstance'] = true; + return instance; + } + } + ); + + this.add( + 'engine:bar', + class extends Engine { + init() { + super.init(...arguments); + this.register( + 'template:application', + compile(strip` + {{outlet}} + + {{#if @model}} + + {{/if}} + `) + ); + this.register('template:index', compile('Bar')); + this.register('template:components/inspect-model', compile('{{@model}}')); + } + + buildInstance(options?: EngineInstanceOptions): EngineInstance { + let instance = super.buildInstance(options); + instance['isBarEngineInstance'] = true; + return instance; + } + } + ); + + await this.visit('/'); + + this.assertRenderTree([ + { + type: 'engine', + name: 'foo', + args: { positional: [], named: {} }, + instance: (instance: object) => instance['isFooEngineInstance'] === true, + bounds: this.elementBounds(this.$('#static')[0]), + children: [ + { + type: 'route-template', + name: 'application', + args: { positional: [], named: {} }, + instance: (instance: object) => + instance.toString() === '(generated application controller)', + bounds: this.elementBounds(this.$('#static')[0]), + children: [ + this.outlet({ + type: 'route-template', + name: 'index', + args: { positional: [], named: { model: undefined } }, + instance: (instance: object) => + instance.toString() === '(generated index controller)', + bounds: this.nodeBounds(this.$('#static')[0].firstChild), + children: [], + }), + ], + }, + ], + }, + ]); + + runTask(() => { + this.controllerFor('application').set('engineName', 'bar'); + }); + + this.assertRenderTree([ + { + type: 'engine', + name: 'foo', + args: { positional: [], named: {} }, + instance: (instance: object) => instance['isFooEngineInstance'] === true, + bounds: this.elementBounds(this.$('#static')[0]), + children: [ + { + type: 'route-template', + name: 'application', + args: { positional: [], named: {} }, + instance: (instance: object) => + instance.toString() === '(generated application controller)', + bounds: this.elementBounds(this.$('#static')[0]), + children: [ + this.outlet({ + type: 'route-template', + name: 'index', + args: { positional: [], named: { model: undefined } }, + instance: (instance: object) => + instance.toString() === '(generated index controller)', + bounds: this.nodeBounds(this.$('#static')[0].firstChild), + children: [], + }), + ], + }, + ], + }, + { + type: 'engine', + name: 'bar', + args: { positional: [], named: {} }, + instance: (instance: object) => instance['isBarEngineInstance'] === true, + bounds: this.elementBounds(this.$('#dynamic')[0]), + children: [ + { + type: 'route-template', + name: 'application', + args: { positional: [], named: {} }, + instance: (instance: object) => + instance.toString() === '(generated application controller)', + bounds: this.elementBounds(this.$('#dynamic')[0]), + children: [ + this.outlet({ + type: 'route-template', + name: 'index', + args: { positional: [], named: { model: undefined } }, + instance: (instance: object) => + instance.toString() === '(generated index controller)', + bounds: this.nodeBounds(this.$('#dynamic')[0].firstChild), + children: [], + }), + ], + }, + ], + }, + ]); + + runTask(() => { + this.controllerFor('application').set('engineName', undefined); + }); + + this.assertRenderTree([ + { + type: 'engine', + name: 'foo', + args: { positional: [], named: {} }, + instance: (instance: object) => instance['isFooEngineInstance'] === true, + bounds: this.elementBounds(this.$('#static')[0]), + children: [ + { + type: 'route-template', + name: 'application', + args: { positional: [], named: {} }, + instance: (instance: object) => + instance.toString() === '(generated application controller)', + bounds: this.elementBounds(this.$('#static')[0]), + children: [ + this.outlet({ + type: 'route-template', + name: 'index', + args: { positional: [], named: { model: undefined } }, + instance: (instance: object) => + instance.toString() === '(generated index controller)', + bounds: this.nodeBounds(this.$('#static')[0].firstChild), + children: [], + }), + ], + }, + ], + }, + ]); + + let model = { + toStrin() { + return 'some model'; + }, + }; + + runTask(() => { + this.controllerFor('application').setProperties({ + showMore: true, + engineModel: model, + }); + }); + + this.assertRenderTree([ + { + type: 'engine', + name: 'foo', + args: { positional: [], named: {} }, + instance: (instance: object) => instance['isFooEngineInstance'] === true, + bounds: this.elementBounds(this.$('#static')[0]), + children: [ + { + type: 'route-template', + name: 'application', + args: { positional: [], named: {} }, + instance: (instance: object) => + instance.toString() === '(generated application controller)', + bounds: this.elementBounds(this.$('#static')[0]), + children: [ + this.outlet({ + type: 'route-template', + name: 'index', + args: { positional: [], named: { model: undefined } }, + instance: (instance: object) => + instance.toString() === '(generated index controller)', + bounds: this.nodeBounds(this.$('#static')[0].firstChild), + children: [], + }), + ], + }, + ], + }, + { + type: 'engine', + name: 'foo', + args: { positional: [], named: { model } }, + instance: (instance: object) => instance['isFooEngineInstance'] === true, + bounds: this.elementBounds(this.$('#static-with-model')[0]), + children: [ + { + type: 'route-template', + name: 'application', + args: { positional: [], named: { model } }, + instance: (instance: object) => + instance.toString() === '(generated application controller)', + bounds: this.elementBounds(this.$('#static-with-model')[0]), + children: [ + this.outlet({ + type: 'route-template', + name: 'index', + args: { positional: [], named: { model: undefined } }, + instance: (instance: object) => + instance.toString() === '(generated index controller)', + bounds: this.nodeBounds(this.$('#static-with-model')[0].firstChild), + children: [], + }), + { + type: 'component', + name: 'inspect-model', + args: { positional: [], named: { model } }, + instance: null, + bounds: this.nodeBounds(this.$('#static-with-model')[0].lastChild), + children: [], + }, + ], + }, + ], + }, + ]); + + runTask(() => { + this.controllerFor('application').set('engineName', 'bar'); + }); + + this.assertRenderTree([ + { + type: 'engine', + name: 'foo', + args: { positional: [], named: {} }, + instance: (instance: object) => instance['isFooEngineInstance'] === true, + bounds: this.elementBounds(this.$('#static')[0]), + children: [ + { + type: 'route-template', + name: 'application', + args: { positional: [], named: {} }, + instance: (instance: object) => + instance.toString() === '(generated application controller)', + bounds: this.elementBounds(this.$('#static')[0]), + children: [ + this.outlet({ + type: 'route-template', + name: 'index', + args: { positional: [], named: { model: undefined } }, + instance: (instance: object) => + instance.toString() === '(generated index controller)', + bounds: this.nodeBounds(this.$('#static')[0].firstChild), + children: [], + }), + ], + }, + ], + }, + { + type: 'engine', + name: 'bar', + args: { positional: [], named: {} }, + instance: (instance: object) => instance['isBarEngineInstance'] === true, + bounds: this.elementBounds(this.$('#dynamic')[0]), + children: [ + { + type: 'route-template', + name: 'application', + args: { positional: [], named: {} }, + instance: (instance: object) => + instance.toString() === '(generated application controller)', + bounds: this.elementBounds(this.$('#dynamic')[0]), + children: [ + this.outlet({ + type: 'route-template', + name: 'index', + args: { positional: [], named: { model: undefined } }, + instance: (instance: object) => + instance.toString() === '(generated index controller)', + bounds: this.nodeBounds(this.$('#dynamic')[0].firstChild), + children: [], + }), + ], + }, + ], + }, + { + type: 'engine', + name: 'foo', + args: { positional: [], named: { model } }, + instance: (instance: object) => instance['isFooEngineInstance'] === true, + bounds: this.elementBounds(this.$('#static-with-model')[0]), + children: [ + { + type: 'route-template', + name: 'application', + args: { positional: [], named: { model } }, + instance: (instance: object) => + instance.toString() === '(generated application controller)', + bounds: this.elementBounds(this.$('#static-with-model')[0]), + children: [ + this.outlet({ + type: 'route-template', + name: 'index', + args: { positional: [], named: { model: undefined } }, + instance: (instance: object) => + instance.toString() === '(generated index controller)', + bounds: this.nodeBounds(this.$('#static-with-model')[0].firstChild), + children: [], + }), + { + type: 'component', + name: 'inspect-model', + args: { positional: [], named: { model } }, + instance: null, + bounds: this.nodeBounds(this.$('#static-with-model')[0].lastChild), + children: [], + }, + ], + }, + ], + }, + { + type: 'engine', + name: 'bar', + args: { positional: [], named: { model } }, + instance: (instance: object) => instance['isBarEngineInstance'] === true, + bounds: this.elementBounds(this.$('#dynamic-with-model')[0]), + children: [ + { + type: 'route-template', + name: 'application', + args: { positional: [], named: { model } }, + instance: (instance: object) => + instance.toString() === '(generated application controller)', + bounds: this.elementBounds(this.$('#dynamic-with-model')[0]), + children: [ + this.outlet({ + type: 'route-template', + name: 'index', + args: { positional: [], named: { model: undefined } }, + instance: (instance: object) => + instance.toString() === '(generated index controller)', + bounds: this.nodeBounds(this.$('#dynamic-with-model')[0].firstChild), + children: [], + }), + { + type: 'component', + name: 'inspect-model', + args: { positional: [], named: { model } }, + instance: null, + bounds: this.nodeBounds(this.$('#dynamic-with-model')[0].lastChild), + children: [], + }, + ], + }, + ], + }, + ]); + + runTask(() => { + this.controllerFor('application').setProperties({ + showMore: false, + engineName: undefined, + }); + }); + + this.assertRenderTree([ + { + type: 'engine', + name: 'foo', + args: { positional: [], named: {} }, + instance: (instance: object) => instance['isFooEngineInstance'] === true, + bounds: this.elementBounds(this.$('#static')[0]), + children: [ + { + type: 'route-template', + name: 'application', + args: { positional: [], named: {} }, + instance: (instance: object) => + instance.toString() === '(generated application controller)', + bounds: this.elementBounds(this.$('#static')[0]), + children: [ + this.outlet({ + type: 'route-template', + name: 'index', + args: { positional: [], named: { model: undefined } }, + instance: (instance: object) => + instance.toString() === '(generated index controller)', + bounds: this.nodeBounds(this.$('#static')[0].firstChild), + children: [], + }), + ], + }, + ], + }, + ]); + } + + async '@test routable engine'() { + this.addTemplate('index', 'Index'); + + let instance: EngineInstance; + + this.add( + 'engine:foo', + class extends Engine { + isFooEngine = true; + + init() { + super.init(...arguments); + this.register( + 'template:application', + compile(strip` + {{outlet}} + + {{#if this.message}} + + {{/if}} + `) + ); + this.register('template:index', compile('Foo')); + this.register( + 'template:components/hello', + compile('Hello {{@message}}') + ); + } + + buildInstance(options?: EngineInstanceOptions): EngineInstance { + return (instance = super.buildInstance(options)); + } + } + ); + + this.router.map(function(this: any) { + this.mount('foo'); + }); + + this.add('route-map:foo', function() {}); + + await this.visit('/'); + + this.assertRenderTree([ + this.outlet({ + type: 'route-template', + name: 'index', + args: { positional: [], named: { model: undefined } }, + instance: this.controllerFor('index'), + bounds: this.elementBounds(this.element), + children: [], + }), + ]); + + await this.visit('/foo'); + + this.assertRenderTree([ + this.outlet({ + type: 'engine', + name: 'foo', + args: { positional: [], named: {} }, + instance: instance!, + bounds: this.elementBounds(this.element), + children: [ + { + type: 'route-template', + name: 'application', + args: { positional: [], named: { model: undefined } }, + instance: instance!.lookup('controller:application'), + bounds: this.elementBounds(this.element), + children: [ + this.outlet({ + type: 'route-template', + name: 'index', + args: { positional: [], named: { model: undefined } }, + instance: instance!.lookup('controller:index'), + bounds: this.nodeBounds(this.element.firstChild), + children: [], + }), + ], + }, + ], + }), + ]); + + runTask(() => { + let controller = instance!.lookup('controller:application')!; + controller.set('message', 'World'); + }); + + this.assertRenderTree([ + this.outlet({ + type: 'engine', + name: 'foo', + args: { positional: [], named: {} }, + instance: instance!, + bounds: this.elementBounds(this.element), + children: [ + { + type: 'route-template', + name: 'application', + args: { positional: [], named: { model: undefined } }, + instance: instance!.lookup('controller:application'), + bounds: this.elementBounds(this.element), + children: [ + this.outlet({ + type: 'route-template', + name: 'index', + args: { positional: [], named: { model: undefined } }, + instance: instance!.lookup('controller:index'), + bounds: this.nodeBounds(this.element.firstChild), + children: [], + }), + { + type: 'component', + name: 'hello', + args: { positional: [], named: { message: 'World' } }, + instance: null, + bounds: this.nodeBounds(this.element.lastChild), + children: [], + }, + ], + }, + ], + }), + ]); + + runTask(() => { + let controller = instance!.lookup('controller:application')!; + controller.set('message', undefined); + }); + + this.assertRenderTree([ + this.outlet({ + type: 'engine', + name: 'foo', + args: { positional: [], named: {} }, + instance: instance!, + bounds: this.elementBounds(this.element), + children: [ + { + type: 'route-template', + name: 'application', + args: { positional: [], named: { model: undefined } }, + instance: instance!.lookup('controller:application'), + bounds: this.elementBounds(this.element), + children: [ + this.outlet({ + type: 'route-template', + name: 'index', + args: { positional: [], named: { model: undefined } }, + instance: instance!.lookup('controller:index'), + bounds: this.nodeBounds(this.element.firstChild), + children: [], + }), + ], + }, + ], + }), + ]); + + await this.visit('/'); + + this.assertRenderTree([ + this.outlet({ + type: 'route-template', + name: 'index', + args: { positional: [], named: { model: undefined } }, + instance: this.controllerFor('index'), + bounds: this.elementBounds(this.element), + children: [], + }), + ]); + } + + async '@test template-only components'() { + this.addTemplate( + 'application', + strip` + + + {{#if this.showSecond}} + + {{/if}} + ` + ); + + this.addComponent('hello-world', { + ComponentClass: null, + template: 'Hello World', + }); + + await this.visit('/'); + + this.assertRenderTree([ + { + type: 'component', + name: 'hello-world', + args: { positional: [], named: { name: 'first' } }, + instance: null, + bounds: this.nodeBounds(this.element.firstChild), + children: [], + }, + ]); + + runTask(() => { + this.controllerFor('application').set('showSecond', true); + }); + + this.assertRenderTree([ + { + type: 'component', + name: 'hello-world', + args: { positional: [], named: { name: 'first' } }, + instance: null, + bounds: this.nodeBounds(this.element.firstChild), + children: [], + }, + { + type: 'component', + name: 'hello-world', + args: { positional: [], named: { name: 'second' } }, + instance: null, + bounds: this.nodeBounds(this.element.lastChild), + children: [], + }, + ]); + + runTask(() => { + this.controllerFor('application').set('showSecond', false); + }); + + this.assertRenderTree([ + { + type: 'component', + name: 'hello-world', + args: { positional: [], named: { name: 'first' } }, + instance: null, + bounds: this.nodeBounds(this.element.firstChild), + children: [], + }, + ]); + } + + async '@feature(EMBER_GLIMMER_SET_COMPONENT_TEMPLATE) templateOnlyComponent()'() { + this.addTemplate( + 'application', + strip` + + + {{#if this.showSecond}} + + {{/if}} + ` + ); + + this.addComponent('hello-world', { + ComponentClass: templateOnly(), + template: 'Hello World', + }); + + await this.visit('/'); + + this.assertRenderTree([ + { + type: 'component', + name: 'hello-world', + args: { positional: [], named: { name: 'first' } }, + instance: null, + bounds: this.nodeBounds(this.element.firstChild), + children: [], + }, + ]); + + runTask(() => { + this.controllerFor('application').set('showSecond', true); + }); + + this.assertRenderTree([ + { + type: 'component', + name: 'hello-world', + args: { positional: [], named: { name: 'first' } }, + instance: null, + bounds: this.nodeBounds(this.element.firstChild), + children: [], + }, + { + type: 'component', + name: 'hello-world', + args: { positional: [], named: { name: 'second' } }, + instance: null, + bounds: this.nodeBounds(this.element.lastChild), + children: [], + }, + ]); + + runTask(() => { + this.controllerFor('application').set('showSecond', false); + }); + + this.assertRenderTree([ + { + type: 'component', + name: 'hello-world', + args: { positional: [], named: { name: 'first' } }, + instance: null, + bounds: this.nodeBounds(this.element.firstChild), + children: [], + }, + ]); + } + + async '@feature(EMBER_GLIMMER_SET_COMPONENT_TEMPLATE) templateOnlyComponent() + setComponentTemplate()'() { + this.addTemplate( + 'application', + strip` + + + {{#if this.showSecond}} + + {{/if}} + ` + ); + + this.addComponent('hello-world', { + ComponentClass: setComponentTemplate(compile('Hello World'), templateOnly()), + }); + + await this.visit('/'); + + this.assertRenderTree([ + { + type: 'component', + name: 'hello-world', + args: { positional: [], named: { name: 'first' } }, + instance: null, + bounds: this.nodeBounds(this.element.firstChild), + children: [], + }, + ]); + + runTask(() => { + this.controllerFor('application').set('showSecond', true); + }); + + this.assertRenderTree([ + { + type: 'component', + name: 'hello-world', + args: { positional: [], named: { name: 'first' } }, + instance: null, + bounds: this.nodeBounds(this.element.firstChild), + children: [], + }, + { + type: 'component', + name: 'hello-world', + args: { positional: [], named: { name: 'second' } }, + instance: null, + bounds: this.nodeBounds(this.element.lastChild), + children: [], + }, + ]); + + runTask(() => { + this.controllerFor('application').set('showSecond', false); + }); + + this.assertRenderTree([ + { + type: 'component', + name: 'hello-world', + args: { positional: [], named: { name: 'first' } }, + instance: null, + bounds: this.nodeBounds(this.element.firstChild), + children: [], + }, + ]); + } + + async '@test classic components'() { + this.addTemplate( + 'application', + strip` + + + {{#if this.showSecond}} + + {{/if}} + ` + ); + + this.addComponent('hello-world', { + ComponentClass: Component.extend(), + template: 'Hello World', + }); + + await this.visit('/'); + + this.assertRenderTree([ + { + type: 'component', + name: 'hello-world', + args: { positional: [], named: { name: 'first' } }, + instance: (instance: object) => instance['name'] === 'first', + bounds: this.nodeBounds(this.element.firstChild), + children: [], + }, + ]); + + runTask(() => { + this.controllerFor('application').set('showSecond', true); + }); + + this.assertRenderTree([ + { + type: 'component', + name: 'hello-world', + args: { positional: [], named: { name: 'first' } }, + instance: (instance: object) => instance['name'] === 'first', + bounds: this.nodeBounds(this.element.firstChild), + children: [], + }, + { + type: 'component', + name: 'hello-world', + args: { positional: [], named: { name: 'second' } }, + instance: (instance: object) => instance['name'] === 'second', + bounds: this.nodeBounds(this.element.lastChild), + children: [], + }, + ]); + + runTask(() => { + this.controllerFor('application').set('showSecond', false); + }); + + this.assertRenderTree([ + { + type: 'component', + name: 'hello-world', + args: { positional: [], named: { name: 'first' } }, + instance: (instance: object) => instance['name'] === 'first', + bounds: this.nodeBounds(this.element.firstChild), + children: [], + }, + ]); + } + + async '@test custom components'() { + this.addTemplate( + 'application', + strip` + + + {{#if this.showSecond}} + + {{/if}} + ` + ); + + this.addComponent('hello-world', { + ComponentClass: setComponentManager(_owner => { + return { + capabilities: capabilities('3.13', {}), + + createComponent(_, { named: { name } }) { + return { name }; + }, + + getContext(instances) { + return instances; + }, + }; + }, {}), + template: 'Hello World', + }); + + await this.visit('/'); + + this.assertRenderTree([ + { + type: 'component', + name: 'hello-world', + args: { positional: [], named: { name: 'first' } }, + instance: (instance: object) => instance['name'] === 'first', + bounds: this.nodeBounds(this.element.firstChild), + children: [], + }, + ]); + + runTask(() => { + this.controllerFor('application').set('showSecond', true); + }); + + this.assertRenderTree([ + { + type: 'component', + name: 'hello-world', + args: { positional: [], named: { name: 'first' } }, + instance: (instance: object) => instance['name'] === 'first', + bounds: this.nodeBounds(this.element.firstChild), + children: [], + }, + { + type: 'component', + name: 'hello-world', + args: { positional: [], named: { name: 'second' } }, + instance: (instance: object) => instance['name'] === 'second', + bounds: this.nodeBounds(this.element.lastChild), + children: [], + }, + ]); + + runTask(() => { + this.controllerFor('application').set('showSecond', false); + }); + + this.assertRenderTree([ + { + type: 'component', + name: 'hello-world', + args: { positional: [], named: { name: 'first' } }, + instance: (instance: object) => instance['name'] === 'first', + bounds: this.nodeBounds(this.element.firstChild), + children: [], + }, + ]); + } + + async '@test components'() { + this.addTemplate( + 'application', + strip` + + + {{#if this.showSecond}} + + {{/if}} + ` + ); + + await this.visit('/'); + + let target = this.controllerFor('application'); + + this.assertRenderTree([ + { + type: 'component', + name: 'input', + args: args => args.named.type === 'text' && '__ARGS__' in args.named, + instance: (instance: object) => instance['type'] === 'text', + bounds: this.nodeBounds(this.element.firstChild), + children: [ + { + type: 'component', + name: '-text-field', + args: { positional: [], named: { target, type: 'text', value: 'first' } }, + instance: (instance: object) => instance['value'] === 'first', + bounds: this.nodeBounds(this.element.firstChild), + children: [], + }, + ], + }, + ]); + + runTask(() => target.set('showSecond', true)); + + this.assertRenderTree([ + { + type: 'component', + name: 'input', + args: args => args.named.type === 'text' && '__ARGS__' in args.named, + instance: (instance: object) => instance['type'] === 'text', + bounds: this.nodeBounds(this.element.firstChild), + children: [ + { + type: 'component', + name: '-text-field', + args: { positional: [], named: { target, type: 'text', value: 'first' } }, + instance: (instance: object) => instance['value'] === 'first', + bounds: this.nodeBounds(this.element.firstChild), + children: [], + }, + ], + }, + { + type: 'component', + name: 'input', + args: args => args.named.type === 'checkbox' && '__ARGS__' in args.named, + instance: (instance: object) => instance['type'] === 'checkbox', + bounds: this.nodeBounds(this.element.lastChild), + children: [ + { + type: 'component', + name: '-checkbox', + args: { positional: [], named: { target, type: 'checkbox', checked: false } }, + instance: (instance: object) => instance['checked'] === false, + bounds: this.nodeBounds(this.element.lastChild), + children: [], + }, + ], + }, + ]); + + runTask(() => target.set('showSecond', false)); + + this.assertRenderTree([ + { + type: 'component', + name: 'input', + args: args => args.named.type === 'text' && '__ARGS__' in args.named, + instance: (instance: object) => instance['type'] === 'text', + bounds: this.nodeBounds(this.element.firstChild), + children: [ + { + type: 'component', + name: '-text-field', + args: { positional: [], named: { target, type: 'text', value: 'first' } }, + instance: (instance: object) => instance['value'] === 'first', + bounds: this.nodeBounds(this.element.firstChild), + children: [], + }, + ], + }, + ]); + } + + async '@test