diff --git a/packages/ember-glimmer/lib/component-managers/curly.ts b/packages/ember-glimmer/lib/component-managers/curly.ts index a8f43a3b68e..fc03a351e75 100644 --- a/packages/ember-glimmer/lib/component-managers/curly.ts +++ b/packages/ember-glimmer/lib/component-managers/curly.ts @@ -145,10 +145,12 @@ export default class CurlyComponentManager } getTagName(state: ComponentStateBucket): Option { - const { component } = state; - if (component.tagName === '') { + const { component, hasWrappedElement } = state; + + if (!hasWrappedElement) { return null; } + return (component && component.tagName) || 'div'; } @@ -273,8 +275,10 @@ export default class CurlyComponentManager component.trigger('didReceiveAttrs'); + let hasWrappedElement = component.tagName !== ''; + // We usually do this in the `didCreateElement`, but that hook doesn't fire for tagless components - if (component.tagName === '') { + if (!hasWrappedElement) { if (environment.isInteractive) { component.trigger('willRender'); } @@ -288,7 +292,13 @@ export default class CurlyComponentManager // Track additional lifecycle metadata about this component in a state bucket. // Essentially we're saving off all the state we'll need in the future. - let bucket = new ComponentStateBucket(environment, component, capturedArgs, finalizer); + let bucket = new ComponentStateBucket( + environment, + component, + capturedArgs, + finalizer, + hasWrappedElement + ); if (args.named.has('class')) { bucket.classRef = args.named.get('class'); @@ -298,7 +308,7 @@ export default class CurlyComponentManager processComponentInitializationAssertions(component, props); } - if (environment.isInteractive && component.tagName !== '') { + if (environment.isInteractive && hasWrappedElement) { component.trigger('willRender'); } @@ -310,51 +320,60 @@ export default class CurlyComponentManager } didCreateElement( - { component, classRef, environment }: ComponentStateBucket, + { hasWrappedElement, component, classRef, environment }: ComponentStateBucket, element: HTMLElement, operations: ElementOperations ): void { - setViewElement(component, element); + // TODO: EMBER_GLIMMER_ANGLE_BRACKET_INVOCATION update glimmer-vm to _not_ + // call `didCreateElement` for each `...attributes` + if (hasWrappedElement) { + setViewElement(component, element); - let { attributeBindings, classNames, classNameBindings } = component; + let { attributeBindings, classNames, classNameBindings } = component; - operations.setAttribute('id', PrimitiveReference.create(guidFor(component)), false, null); + operations.setAttribute('id', PrimitiveReference.create(guidFor(component)), false, null); - if (attributeBindings && attributeBindings.length) { - applyAttributeBindings(element, attributeBindings, component, operations); - } else { - if (component.elementId) { - operations.setAttribute('id', PrimitiveReference.create(component.elementId), false, null); + if (attributeBindings && attributeBindings.length) { + applyAttributeBindings(element, attributeBindings, component, operations); + } else { + if (component.elementId) { + operations.setAttribute( + 'id', + PrimitiveReference.create(component.elementId), + false, + null + ); + } + IsVisibleBinding.install(element, component, operations); } - IsVisibleBinding.install(element, component, operations); - } - if (classRef) { - const ref = new SimpleClassNameBindingReference(classRef, classRef['_propertyKey']); - operations.setAttribute('class', ref, false, null); - } + if (classRef) { + const ref = new SimpleClassNameBindingReference(classRef, classRef['_propertyKey']); + operations.setAttribute('class', ref, false, null); + } - if (classNames && classNames.length) { - classNames.forEach((name: string) => { - operations.setAttribute('class', PrimitiveReference.create(name), false, null); - }); - } + if (classNames && classNames.length) { + classNames.forEach((name: string) => { + operations.setAttribute('class', PrimitiveReference.create(name), false, null); + }); + } - if (classNameBindings && classNameBindings.length) { - classNameBindings.forEach((binding: string) => { - ClassNameBinding.install(element, component, binding, operations); - }); - } - operations.setAttribute('class', PrimitiveReference.create('ember-view'), false, null); + if (classNameBindings && classNameBindings.length) { + classNameBindings.forEach((binding: string) => { + ClassNameBinding.install(element, component, binding, operations); + }); + } + operations.setAttribute('class', PrimitiveReference.create('ember-view'), false, null); - if ('ariaRole' in component) { - operations.setAttribute('role', referenceForKey(component, 'ariaRole'), false, null); - } + if ('ariaRole' in component) { + operations.setAttribute('role', referenceForKey(component, 'ariaRole'), false, null); + } - component._transitionTo('hasElement'); + component._transitionTo('hasElement'); - if (environment.isInteractive) { - component.trigger('willInsertElement'); + if (environment.isInteractive) { + component.trigger('willInsertElement'); + } } } diff --git a/packages/ember-glimmer/lib/component-managers/root.ts b/packages/ember-glimmer/lib/component-managers/root.ts index 696ff0b9dfc..e9a6e8f3619 100644 --- a/packages/ember-glimmer/lib/component-managers/root.ts +++ b/packages/ember-glimmer/lib/component-managers/root.ts @@ -48,8 +48,10 @@ class RootComponentManager extends CurlyComponentManager { dynamicScope.view = component; + let hasWrappedElement = component.tagName !== ''; + // We usually do this in the `didCreateElement`, but that hook doesn't fire for tagless components - if (component.tagName === '') { + if (!hasWrappedElement) { if (environment.isInteractive) { component.trigger('willRender'); } @@ -65,7 +67,7 @@ class RootComponentManager extends CurlyComponentManager { processComponentInitializationAssertions(component, {}); } - return new ComponentStateBucket(environment, component, null, finalizer); + return new ComponentStateBucket(environment, component, null, finalizer, hasWrappedElement); } } diff --git a/packages/ember-glimmer/lib/utils/curly-component-state-bucket.ts b/packages/ember-glimmer/lib/utils/curly-component-state-bucket.ts index 2aa55d42513..611fd85f9d6 100644 --- a/packages/ember-glimmer/lib/utils/curly-component-state-bucket.ts +++ b/packages/ember-glimmer/lib/utils/curly-component-state-bucket.ts @@ -41,7 +41,8 @@ export default class ComponentStateBucket { public environment: Environment, public component: Component, public args: CapturedNamedArguments | null, - public finalizer: Finalizer + public finalizer: Finalizer, + public hasWrappedElement: boolean ) { this.classRef = null; this.argsRevision = args === null ? 0 : args.tag.value(); diff --git a/packages/ember-glimmer/tests/integration/components/angle-bracket-invocation-test.js b/packages/ember-glimmer/tests/integration/components/angle-bracket-invocation-test.js index 65545636714..4a7145d01cb 100644 --- a/packages/ember-glimmer/tests/integration/components/angle-bracket-invocation-test.js +++ b/packages/ember-glimmer/tests/integration/components/angle-bracket-invocation-test.js @@ -560,6 +560,224 @@ if (EMBER_GLIMMER_ANGLE_BRACKET_INVOCATION) { content: 'hello', }); } + + '@test includes invocation specified attributes in `...attributes` slot in tagless component ("splattributes")'() { + this.registerComponent('foo-bar', { + ComponentClass: Component.extend({ tagName: '' }), + template: '
hello
', + }); + + this.render('', { foo: 'foo', bar: 'bar' }); + + this.assertElement(this.firstChild, { + tagName: 'div', + attrs: { 'data-foo': 'foo', 'data-bar': 'bar' }, + content: 'hello', + }); + + this.runTask(() => this.rerender()); + + this.assertElement(this.firstChild, { + tagName: 'div', + attrs: { 'data-foo': 'foo', 'data-bar': 'bar' }, + content: 'hello', + }); + + this.runTask(() => { + set(this.context, 'foo', 'FOO'); + set(this.context, 'bar', undefined); + }); + + this.assertElement(this.firstChild, { + tagName: 'div', + attrs: { 'data-foo': 'FOO' }, + content: 'hello', + }); + + this.runTask(() => { + set(this.context, 'foo', 'foo'); + set(this.context, 'bar', 'bar'); + }); + + this.assertElement(this.firstChild, { + tagName: 'div', + attrs: { 'data-foo': 'foo', 'data-bar': 'bar' }, + content: 'hello', + }); + } + + '@test merges attributes with `...attributes` in tagless component ("splattributes")'() { + let instance; + this.registerComponent('foo-bar', { + ComponentClass: Component.extend({ + tagName: '', + init() { + instance = this; + this._super(...arguments); + this.localProp = 'qux'; + }, + }), + template: '
hello
', + }); + + this.render('', { foo: 'foo', bar: 'bar' }); + + this.assertElement(this.firstChild, { + tagName: 'div', + attrs: { 'data-derp': 'qux', 'data-foo': 'foo', 'data-bar': 'bar' }, + content: 'hello', + }); + + this.runTask(() => this.rerender()); + + this.assertElement(this.firstChild, { + tagName: 'div', + attrs: { 'data-derp': 'qux', 'data-foo': 'foo', 'data-bar': 'bar' }, + content: 'hello', + }); + + this.runTask(() => { + set(this.context, 'foo', 'FOO'); + set(this.context, 'bar', undefined); + set(instance, 'localProp', 'QUZ'); + }); + + this.assertElement(this.firstChild, { + tagName: 'div', + attrs: { 'data-derp': 'QUZ', 'data-foo': 'FOO' }, + content: 'hello', + }); + + this.runTask(() => { + set(this.context, 'foo', 'foo'); + set(this.context, 'bar', 'bar'); + set(instance, 'localProp', 'qux'); + }); + + this.assertElement(this.firstChild, { + tagName: 'div', + attrs: { 'data-derp': 'qux', 'data-foo': 'foo', 'data-bar': 'bar' }, + content: 'hello', + }); + } + + '@test merges class attribute with `...attributes` in tagless component ("splattributes")'() { + let instance; + this.registerComponent('foo-bar', { + ComponentClass: Component.extend({ + tagName: '', + init() { + instance = this; + this._super(...arguments); + this.localProp = 'qux'; + }, + }), + template: '
hello
', + }); + + this.render('', { bar: 'bar' }); + + this.assertElement(this.firstChild, { + tagName: 'div', + attrs: { class: classes('qux bar') }, + content: 'hello', + }); + + this.runTask(() => this.rerender()); + + this.assertElement(this.firstChild, { + tagName: 'div', + attrs: { class: classes('qux bar') }, + content: 'hello', + }); + + this.runTask(() => { + set(this.context, 'bar', undefined); + set(instance, 'localProp', 'QUZ'); + }); + + this.assertElement(this.firstChild, { + tagName: 'div', + attrs: { class: classes('QUZ') }, + content: 'hello', + }); + + this.runTask(() => { + set(this.context, 'bar', 'bar'); + set(instance, 'localProp', 'qux'); + }); + + this.assertElement(this.firstChild, { + tagName: 'div', + attrs: { class: classes('qux bar') }, + content: 'hello', + }); + } + + '@test can include `...attributes` in multiple elements in tagless component ("splattributes")'() { + this.registerComponent('foo-bar', { + ComponentClass: Component.extend({ tagName: '' }), + template: '
hello

world

', + }); + + this.render('', { foo: 'foo', bar: 'bar' }); + + this.assertElement(this.firstChild, { + tagName: 'div', + attrs: { 'data-foo': 'foo', 'data-bar': 'bar' }, + content: 'hello', + }); + this.assertElement(this.nthChild(1), { + tagName: 'p', + attrs: { 'data-foo': 'foo', 'data-bar': 'bar' }, + content: 'world', + }); + + this.runTask(() => this.rerender()); + + this.assertElement(this.firstChild, { + tagName: 'div', + attrs: { 'data-foo': 'foo', 'data-bar': 'bar' }, + content: 'hello', + }); + this.assertElement(this.nthChild(1), { + tagName: 'p', + attrs: { 'data-foo': 'foo', 'data-bar': 'bar' }, + content: 'world', + }); + + this.runTask(() => { + set(this.context, 'foo', 'FOO'); + set(this.context, 'bar', undefined); + }); + + this.assertElement(this.firstChild, { + tagName: 'div', + attrs: { 'data-foo': 'FOO' }, + content: 'hello', + }); + this.assertElement(this.nthChild(1), { + tagName: 'p', + attrs: { 'data-foo': 'FOO' }, + content: 'world', + }); + + this.runTask(() => { + set(this.context, 'foo', 'foo'); + set(this.context, 'bar', 'bar'); + }); + + this.assertElement(this.firstChild, { + tagName: 'div', + attrs: { 'data-foo': 'foo', 'data-bar': 'bar' }, + content: 'hello', + }); + this.assertElement(this.nthChild(1), { + tagName: 'p', + attrs: { 'data-foo': 'foo', 'data-bar': 'bar' }, + content: 'world', + }); + } } ); }