diff --git a/packages/@ember/canary-features/index.ts b/packages/@ember/canary-features/index.ts index f1f69a527fe..613714cd722 100644 --- a/packages/@ember/canary-features/index.ts +++ b/packages/@ember/canary-features/index.ts @@ -14,6 +14,7 @@ export const DEFAULT_FEATURES = { EMBER_ENGINES_MOUNT_PARAMS: true, EMBER_MODULE_UNIFICATION: null, GLIMMER_CUSTOM_COMPONENT_MANAGER: true, + GLIMMER_COMPONENT_MANAGER_BOUNDS: true, EMBER_TEMPLATE_BLOCK_LET_HELPER: true, EMBER_METAL_TRACKED_PROPERTIES: null, EMBER_GLIMMER_ANGLE_BRACKET_INVOCATION: true, @@ -80,6 +81,9 @@ export const EMBER_METAL_TRACKED_PROPERTIES = featureValue(FEATURES.EMBER_METAL_ export const GLIMMER_CUSTOM_COMPONENT_MANAGER = featureValue( FEATURES.GLIMMER_CUSTOM_COMPONENT_MANAGER ); +export const GLIMMER_COMPONENT_MANAGER_BOUNDS = featureValue( + FEATURES.GLIMMER_COMPONENT_MANAGER_BOUNDS +); export const EMBER_TEMPLATE_BLOCK_LET_HELPER = featureValue( FEATURES.EMBER_TEMPLATE_BLOCK_LET_HELPER ); diff --git a/packages/ember-glimmer/lib/component-managers/custom.ts b/packages/ember-glimmer/lib/component-managers/custom.ts index 8f376780644..6c6ec6fcb77 100644 --- a/packages/ember-glimmer/lib/component-managers/custom.ts +++ b/packages/ember-glimmer/lib/component-managers/custom.ts @@ -1,3 +1,4 @@ +import { GLIMMER_COMPONENT_MANAGER_BOUNDS } from '@ember/canary-features'; import { assert } from '@ember/debug'; import { ComponentCapabilities, @@ -9,6 +10,7 @@ import { import { PathReference, Tag } from '@glimmer/reference'; import { Arguments, + Bounds as VMBounds, CapturedArguments, ComponentDefinition, Invocation, @@ -41,17 +43,35 @@ const CAPABILITIES = { export interface OptionalCapabilities { asyncLifecycleCallbacks?: boolean; destructor?: boolean; + bounds?: boolean; } export function capabilities(managerAPI: '3.4', options: OptionalCapabilities = {}): Capabilities { assert('Invalid component manager compatibility specified', managerAPI === '3.4'); return { + bounds: !!options.bounds, asyncLifeCycleCallbacks: !!options.asyncLifecycleCallbacks, destructor: !!options.destructor, }; } +class Bounds { + constructor(private _bounds: VMBounds) {} + + get firstNode(): Node { + return this._bounds.firstNode() as Node; + } + + get lastNode(): Node { + return this._bounds.lastNode() as Node; + } + + get parentElement(): Element { + return this._bounds.parentElement() as Element; + } +} + export interface DefinitionState { name: string; ComponentClass: Factory; @@ -62,6 +82,7 @@ export interface DefinitionState { export interface Capabilities { asyncLifeCycleCallbacks: boolean; destructor: boolean; + bounds: boolean; } export interface CustomComponentManagerArgs { @@ -71,9 +92,11 @@ export interface CustomComponentManagerArgs { export interface ManagerDelegate { capabilities: Capabilities; + didCreateBounds(state: CustomComponentState, bounds: Bounds): void; createComponent(factory: Opaque, args: CustomComponentManagerArgs): ComponentInstance; updateComponent(instance: ComponentInstance, args: CustomComponentManagerArgs): void; getContext(instance: ComponentInstance): Opaque; + willDestroyBounds(state: CustomComponentState): void; } export function hasAsyncLifeCycleCallbacks( @@ -94,6 +117,10 @@ export function hasDestructors( return delegate.capabilities.destructor; } +export function hasBounds(delegate: ManagerDelegate) { + return delegate.capabilities.bounds; +} + export interface ManagerDelegateWithDestructors extends ManagerDelegate { destroyComponent(instance: ComponentInstance): void; @@ -206,7 +233,11 @@ export default class CustomComponentManager return args.tag; } - didRenderLayout() {} + didRenderLayout(state: CustomComponentState, bounds: VMBounds) { + if (GLIMMER_COMPONENT_MANAGER_BOUNDS && hasBounds(state.delegate)) { + state.delegate.didCreateBounds(state, new Bounds(bounds)); + } + } getLayout(state: DefinitionState): Invocation { return { @@ -231,6 +262,9 @@ export class CustomComponentState { const { delegate, component } = this; if (hasDestructors(delegate)) { + if (GLIMMER_COMPONENT_MANAGER_BOUNDS && hasBounds(delegate)) { + delegate.willDestroyBounds(this); + } delegate.destroyComponent(component); } } diff --git a/packages/ember-glimmer/tests/integration/custom-component-manager-test.js b/packages/ember-glimmer/tests/integration/custom-component-manager-test.js index 9de02a05d7d..0a835da353a 100644 --- a/packages/ember-glimmer/tests/integration/custom-component-manager-test.js +++ b/packages/ember-glimmer/tests/integration/custom-component-manager-test.js @@ -15,12 +15,20 @@ if (GLIMMER_CUSTOM_COMPONENT_MANAGER) { this.registerComponentManager( 'basic', EmberObject.extend({ - capabilities: capabilities('3.4'), + capabilities: capabilities('3.4', { + bounds: true, + }), createComponent(factory, args) { return factory.create({ args }); }, + didCreateBounds({ component }, bounds) { + assert.step('didCreateBounds'); + component.bounds = bounds; + component.didInsertElement && component.didInsertElement(); + }, + updateComponent(component, args) { set(component, 'args', args); }, @@ -37,6 +45,7 @@ if (GLIMMER_CUSTOM_COMPONENT_MANAGER) { capabilities: capabilities('3.4', { destructor: true, asyncLifecycleCallbacks: true, + bounds: true, }), createComponent(factory, args) { @@ -54,11 +63,18 @@ if (GLIMMER_CUSTOM_COMPONENT_MANAGER) { component.destroy(); }, + willDestroyBounds() {}, + getContext(component) { assert.step('getContext'); return component; }, + didCreateBounds({ component }, bounds) { + assert.step('didCreateBounds'); + component.bounds = bounds; + }, + didCreateComponent(component) { assert.step('didCreateComponent'); component.didRender(); @@ -94,6 +110,88 @@ if (GLIMMER_CUSTOM_COMPONENT_MANAGER) { this.assertHTML(`

hello world

`); } + ['@test it gets the component bounds']() { + let self = this; + let ComponentClass = setComponentManager( + 'basic', + EmberObject.extend({ + greeting: 'hello', + didInsertElement() { + self.assert.ok(true, 'Called the didInsertElement hook'); + self.assert.ok(this.bounds); + self.assert.ok(this.bounds.firstNode); + self.assert.ok(this.bounds.lastNode); + self.assert.equal(this.bounds.firstNode, this.bounds.lastNode); + }, + }) + ); + + this.registerComponent('foo-bar', { + template: `

{{greeting}} world

`, + ComponentClass, + }); + + this.render('{{foo-bar}}'); + + this.assertHTML(`

hello world

`); + } + + ['@test firstChild and lastChild of bounds can be different']() { + let self = this; + let ComponentClass = setComponentManager( + 'basic', + EmberObject.extend({ + greeting: 'hello', + didInsertElement() { + self.assert.equal(this.bounds.firstNode.tagName.toLowerCase(), 'h1'); + self.assert.equal(this.bounds.lastNode.tagName.toLowerCase(), 'h2'); + }, + }) + ); + + this.registerComponent('foo-bar', { + template: `

Hello!

How are you doing?

`, + ComponentClass, + }); + + this.render('{{foo-bar}}'); + + this.assertHTML(`

Hello!

How are you doing?

`); + } + + ['@test bounds is subject to change']() { + let component; + let ComponentClass = setComponentManager( + 'basic', + EmberObject.extend({ + greeting: 'hello', + didInsertElement() { + component = this; + }, + }) + ); + + this.registerComponent('foo-bar', { + template: `{{#if @bar}}

Hello!

{{/if}}

How are you doing?

`, + ComponentClass, + }); + + this.render('{{foo-bar bar=bar}}', { bar: true }); + + this.assertHTML(`

Hello!

How are you doing?

`); + + this.assert.equal(component.bounds.firstNode.tagName.toLowerCase(), 'h1'); + this.assert.equal(component.bounds.lastNode.tagName.toLowerCase(), 'h2'); + + this.runTask(() => set(this.context, 'bar', false)); + + this.rerender(); + + this.assert.equal(component.bounds.firstNode.nodeType, 8); + this.assert.equal(component.bounds.lastNode.tagName.toLowerCase(), 'h2'); + this.assertHTML(`

How are you doing?

`); + } + ['@test it can have no template context']() { this.registerComponentManager( 'pseudo-template-only', @@ -104,6 +202,8 @@ if (GLIMMER_CUSTOM_COMPONENT_MANAGER) { return null; }, + didCreateBounds() {}, + updateComponent() {}, getContext() { @@ -135,6 +235,8 @@ if (GLIMMER_CUSTOM_COMPONENT_MANAGER) { return new Klass(args); }, + didCreateBounds() {}, + updateComponent() {}, getContext(component) { @@ -197,6 +299,8 @@ if (GLIMMER_CUSTOM_COMPONENT_MANAGER) { return factory.create(); }, + didCreateBounds() {}, + getContext() { return customContext; }, @@ -285,6 +389,7 @@ if (GLIMMER_CUSTOM_COMPONENT_MANAGER) { EmberObject.extend({ capabilities: capabilities('3.4', { destructor: true, + bounds: true, }), createComponent(factory) { @@ -296,8 +401,14 @@ if (GLIMMER_CUSTOM_COMPONENT_MANAGER) { return component; }, + didCreateBounds() {}, + updateComponent() {}, + willDestroyBounds() { + assert.step('willDestroyBounds'); + }, + destroyComponent(component) { assert.step('destroyComponent'); component.destroy(); @@ -329,7 +440,12 @@ if (GLIMMER_CUSTOM_COMPONENT_MANAGER) { this.assertText(''); - assert.verifySteps(['createComponent', 'destroyComponent', 'component.destroy()']); + assert.verifySteps([ + 'createComponent', + 'willDestroyBounds', + 'destroyComponent', + 'component.destroy()', + ]); } ['@test it can opt-in to running async lifecycle hooks'](assert) { @@ -338,6 +454,7 @@ if (GLIMMER_CUSTOM_COMPONENT_MANAGER) { EmberObject.extend({ capabilities: capabilities('3.4', { asyncLifecycleCallbacks: true, + bounds: true, }), createComponent(factory, args) { @@ -350,6 +467,8 @@ if (GLIMMER_CUSTOM_COMPONENT_MANAGER) { set(component, 'args', args); }, + didCreateBounds() {}, + destroyComponent(component) { assert.step('destroyComponent'); component.destroy(); @@ -498,7 +617,12 @@ if (GLIMMER_CUSTOM_COMPONENT_MANAGER) { this.render('', { value: 'foo' }); this.assertHTML(`

Hello world!

`); - assert.verifySteps(['createComponent', 'getContext', 'didCreateComponent']); + assert.verifySteps([ + 'createComponent', + 'getContext', + 'didCreateBounds', + 'didCreateComponent', + ]); this.runTask(() => this.context.set('value', 'bar')); assert.verifySteps(['didUpdateComponent']);