import { schedule } from '@ember/runloop'; import { set, setProperties } from '@ember/-internals/metal'; import { A as emberA } from '@ember/-internals/runtime'; import { Component } from '../../utils/helpers'; import { strip } from '../../utils/abstract-test-case'; import { moduleFor, RenderingTest } from '../../utils/test-case'; import { getViewId, getViewElement, jQueryDisabled } from '@ember/-internals/views'; import { classes } from '../../utils/test-helpers'; import { tryInvoke } from '@ember/-internals/utils'; import { runAppend } from 'internal-test-helpers'; class LifeCycleHooksTest extends RenderingTest { constructor() { super(...arguments); this.hooks = []; this.components = {}; this.componentRegistry = []; this.teardownAssertions = []; this.viewRegistry = this.owner.lookup('-view-registry:main'); } afterEach() { super.afterEach(); for (let i = 0; i < this.teardownAssertions.length; i++) { this.teardownAssertions[i](); } } get isInteractive() { return true; } getBootOptions() { return { isInteractive: this.isInteractive, }; } /* abstract */ get ComponentClass() { throw new Error('Not implemented: `ComponentClass`'); } /* abstract */ invocationFor(/* name, namedArgs = {} */) { throw new Error('Not implemented: `invocationFor`'); } /* abstract */ attrFor(/* name */) { throw new Error('Not implemented: `attrFor`'); } get boundHelpers() { return { invoke: bind(this.invocationFor, this), attr: bind(this.attrFor, this), }; } assertRegisteredViews(label) { let viewRegistry = this.viewRegistry; let topLevelId = getViewId(this.component); let actual = Object.keys(viewRegistry) .sort() .filter(id => id !== topLevelId); if (this.isInteractive) { let expected = this.componentRegistry.sort(); this.assert.deepEqual(actual, expected, 'registered views - ' + label); } else { this.assert.deepEqual(actual, [], 'no views should be registered for non-interactive mode'); } } registerComponent(name, { template = null }) { let pushComponent = instance => { this.components[name] = instance; this.componentRegistry.push(getViewId(instance)); }; let removeComponent = instance => { let index = this.componentRegistry.indexOf(instance); this.componentRegistry.splice(index, 1); delete this.components[name]; }; let pushHook = (hookName, args) => { this.hooks.push(hook(name, hookName, args)); }; let assertParentView = (hookName, instance) => { this.assert.ok(instance.parentView, `parentView should be present in ${hookName}`); if (hookName === 'willDestroyElement') { this.assert.ok( instance.parentView.childViews.indexOf(instance) !== -1, `view is still connected to parentView in ${hookName}` ); } }; let assertElement = (hookName, instance, inDOM = true) => { if (instance.tagName === '') { return; } this.assert.ok( getViewElement(instance), `element should be present on ${instance} during ${hookName}` ); if (this.isInteractive) { this.assert.ok( instance.element, `this.element should be present on ${instance} during ${hookName}` ); this.assert.equal( document.body.contains(instance.element), inDOM, `element for ${instance} ${ inDOM ? 'should' : 'should not' } be in the DOM during ${hookName}` ); } else { this.assert.throws( () => instance.element, /Accessing `this.element` is not allowed in non-interactive environments/ ); } }; let assertNoElement = (hookName, instance) => { this.assert.strictEqual( getViewElement(instance), null, `element should not be present in ${hookName}` ); if (this.isInteractive) { this.assert.strictEqual( instance.element, null, `this.element should not be present in ${hookName}` ); } else { this.assert.throws( () => instance.element, /Accessing `this.element` is not allowed in non-interactive environments/ ); } }; let assertState = (hookName, expectedState, instance) => { this.assert.equal( instance._state, expectedState, `within ${hookName} the expected _state is ${expectedState}` ); }; let { isInteractive } = this; let ComponentClass = this.ComponentClass.extend({ init() { this._super(...arguments); this.isInitialRender = true; this.componentName = name; pushHook('init'); pushComponent(this); assertParentView('init', this); assertNoElement('init', this); assertState('init', 'preRender', this); this.on('init', () => pushHook('on(init)')); schedule('afterRender', () => { this.isInitialRender = false; }); }, didReceiveAttrs(options) { pushHook('didReceiveAttrs', options); assertParentView('didReceiveAttrs', this); if (this.isInitialRender) { assertNoElement('didReceiveAttrs', this); assertState('didReceiveAttrs', 'preRender', this); } else { assertElement('didReceiveAttrs', this); if (isInteractive) { assertState('didReceiveAttrs', 'inDOM', this); } else { assertState('didReceiveAttrs', 'hasElement', this); } } }, willInsertElement() { pushHook('willInsertElement'); assertParentView('willInsertElement', this); assertElement('willInsertElement', this, false); assertState('willInsertElement', 'hasElement', this); }, willRender() { pushHook('willRender'); assertParentView('willRender', this); if (this.isInitialRender) { assertNoElement('willRender', this, false); assertState('willRender', 'preRender', this); } else { assertElement('willRender', this); assertState('willRender', 'inDOM', this); } }, didInsertElement() { pushHook('didInsertElement'); assertParentView('didInsertElement', this); assertElement('didInsertElement', this); assertState('didInsertElement', 'inDOM', this); }, didRender() { pushHook('didRender'); assertParentView('didRender', this); assertElement('didRender', this); assertState('didRender', 'inDOM', this); }, didUpdateAttrs(options) { pushHook('didUpdateAttrs', options); assertParentView('didUpdateAttrs', this); if (isInteractive) { assertState('didUpdateAttrs', 'inDOM', this); } else { assertState('didUpdateAttrs', 'hasElement', this); } }, willUpdate(options) { pushHook('willUpdate', options); assertParentView('willUpdate', this); assertElement('willUpdate', this); assertState('willUpdate', 'inDOM', this); }, didUpdate(options) { pushHook('didUpdate', options); assertParentView('didUpdate', this); assertElement('didUpdate', this); assertState('didUpdate', 'inDOM', this); }, willDestroyElement() { pushHook('willDestroyElement'); assertParentView('willDestroyElement', this); assertElement('willDestroyElement', this); assertState('willDestroyElement', 'inDOM', this); }, willClearRender() { pushHook('willClearRender'); assertParentView('willClearRender', this); assertElement('willClearRender', this); assertState('willClearRender', 'inDOM', this); }, didDestroyElement() { pushHook('didDestroyElement'); assertNoElement('didDestroyElement', this); assertState('didDestroyElement', 'destroying', this); }, willDestroy() { pushHook('willDestroy'); removeComponent(this); this._super(...arguments); }, }); super.registerComponent(name, { ComponentClass, template }); } assertHooks({ label, interactive, nonInteractive }) { let rawHooks = this.isInteractive ? interactive : nonInteractive; let hooks = rawHooks.map(raw => hook(...raw)); this.assert.deepEqual(json(this.hooks), json(hooks), label); this.hooks = []; } ['@test lifecycle hooks are invoked in a predictable order']() { let { attr, invoke } = this.boundHelpers; this.registerComponent('the-top', { template: strip`
Twitter: {{${attr('twitter')}}}| ${invoke('the-middle', { name: string('Tom Dale') })}
`, }); this.registerComponent('the-middle', { template: strip`
Name: {{${attr('name')}}}| ${invoke('the-bottom', { website: string('tomdale.net') })}
`, }); this.registerComponent('the-bottom', { template: strip`
Website: {{${attr('website')}}}
`, }); this.render(invoke('the-top', { twitter: expr('twitter') }), { twitter: '@tomdale', }); this.assertText('Twitter: @tomdale|Name: Tom Dale|Website: tomdale.net'); this.assertRegisteredViews('intial render'); this.assertHooks({ label: 'after initial render', interactive: [ // Sync hooks ['the-top', 'init'], ['the-top', 'on(init)'], ['the-top', 'didReceiveAttrs'], ['the-top', 'willRender'], ['the-top', 'willInsertElement'], ['the-middle', 'init'], ['the-middle', 'on(init)'], ['the-middle', 'didReceiveAttrs'], ['the-middle', 'willRender'], ['the-middle', 'willInsertElement'], ['the-bottom', 'init'], ['the-bottom', 'on(init)'], ['the-bottom', 'didReceiveAttrs'], ['the-bottom', 'willRender'], ['the-bottom', 'willInsertElement'], // Async hooks ['the-bottom', 'didInsertElement'], ['the-bottom', 'didRender'], ['the-middle', 'didInsertElement'], ['the-middle', 'didRender'], ['the-top', 'didInsertElement'], ['the-top', 'didRender'], ], nonInteractive: [ // Sync hooks ['the-top', 'init'], ['the-top', 'on(init)'], ['the-top', 'didReceiveAttrs'], ['the-middle', 'init'], ['the-middle', 'on(init)'], ['the-middle', 'didReceiveAttrs'], ['the-bottom', 'init'], ['the-bottom', 'on(init)'], ['the-bottom', 'didReceiveAttrs'], ], }); this.runTask(() => this.components['the-bottom'].rerender()); this.assertText('Twitter: @tomdale|Name: Tom Dale|Website: tomdale.net'); this.assertHooks({ label: 'after no-op rerender (bottom)', interactive: [ // Sync hooks ['the-top', 'willUpdate'], ['the-top', 'willRender'], ['the-middle', 'willUpdate'], ['the-middle', 'willRender'], ['the-bottom', 'willUpdate'], ['the-bottom', 'willRender'], // Async hooks ['the-bottom', 'didUpdate'], ['the-bottom', 'didRender'], ['the-middle', 'didUpdate'], ['the-middle', 'didRender'], ['the-top', 'didUpdate'], ['the-top', 'didRender'], ], nonInteractive: [], }); this.runTask(() => this.components['the-middle'].rerender()); this.assertText('Twitter: @tomdale|Name: Tom Dale|Website: tomdale.net'); this.assertHooks({ label: 'after no-op rerender (middle)', interactive: [ // Sync hooks ['the-top', 'willUpdate'], ['the-top', 'willRender'], ['the-middle', 'willUpdate'], ['the-middle', 'willRender'], // Async hooks ['the-middle', 'didUpdate'], ['the-middle', 'didRender'], ['the-top', 'didUpdate'], ['the-top', 'didRender'], ], nonInteractive: [], }); this.runTask(() => this.components['the-top'].rerender()); this.assertText('Twitter: @tomdale|Name: Tom Dale|Website: tomdale.net'); this.assertHooks({ label: 'after no-op rerender (top)', interactive: [ // Sync hooks ['the-top', 'willUpdate'], ['the-top', 'willRender'], // Async hooks ['the-top', 'didUpdate'], ['the-top', 'didRender'], ], nonInteractive: [], }); this.runTask(() => set(this.context, 'twitter', '@horsetomdale')); this.assertText('Twitter: @horsetomdale|Name: Tom Dale|Website: tomdale.net'); // Because the `twitter` attr is only used by the topmost component, // and not passed down, we do not expect to see lifecycle hooks // called for child components. If the `didReceiveAttrs` hook used // the new attribute to rerender itself imperatively, that would result // in lifecycle hooks being invoked for the child. this.assertHooks({ label: 'after update', interactive: [ // Sync hooks ['the-top', 'didUpdateAttrs'], ['the-top', 'didReceiveAttrs'], ['the-top', 'willUpdate'], ['the-top', 'willRender'], // Async hooks ['the-top', 'didUpdate'], ['the-top', 'didRender'], ], nonInteractive: [ // Sync hooks ['the-top', 'didUpdateAttrs'], ['the-top', 'didReceiveAttrs'], ], }); this.teardownAssertions.push(() => { this.assertHooks({ label: 'destroy', interactive: [ ['the-top', 'willDestroyElement'], ['the-top', 'willClearRender'], ['the-middle', 'willDestroyElement'], ['the-middle', 'willClearRender'], ['the-bottom', 'willDestroyElement'], ['the-bottom', 'willClearRender'], ['the-top', 'didDestroyElement'], ['the-middle', 'didDestroyElement'], ['the-bottom', 'didDestroyElement'], ['the-top', 'willDestroy'], ['the-middle', 'willDestroy'], ['the-bottom', 'willDestroy'], ], nonInteractive: [ ['the-top', 'willDestroy'], ['the-middle', 'willDestroy'], ['the-bottom', 'willDestroy'], ], }); this.assertRegisteredViews('after destroy'); }); } ['@test lifecycle hooks are invoked in a correct sibling order']() { let { attr, invoke } = this.boundHelpers; this.registerComponent('the-parent', { template: strip`
${invoke('the-first-child', { twitter: expr(attr('twitter')) })}| ${invoke('the-second-child', { name: expr(attr('name')) })}| ${invoke('the-last-child', { website: expr(attr('website')) })}
`, }); this.registerComponent('the-first-child', { template: `Twitter: {{${attr('twitter')}}}`, }); this.registerComponent('the-second-child', { template: `Name: {{${attr('name')}}}`, }); this.registerComponent('the-last-child', { template: `Website: {{${attr('website')}}}`, }); this.render( invoke('the-parent', { twitter: expr('twitter'), name: expr('name'), website: expr('website'), }), { twitter: '@tomdale', name: 'Tom Dale', website: 'tomdale.net', } ); this.assertText('Twitter: @tomdale|Name: Tom Dale|Website: tomdale.net'); this.assertRegisteredViews('intial render'); this.assertHooks({ label: 'after initial render', interactive: [ // Sync hooks ['the-parent', 'init'], ['the-parent', 'on(init)'], ['the-parent', 'didReceiveAttrs'], ['the-parent', 'willRender'], ['the-parent', 'willInsertElement'], ['the-first-child', 'init'], ['the-first-child', 'on(init)'], ['the-first-child', 'didReceiveAttrs'], ['the-first-child', 'willRender'], ['the-first-child', 'willInsertElement'], ['the-second-child', 'init'], ['the-second-child', 'on(init)'], ['the-second-child', 'didReceiveAttrs'], ['the-second-child', 'willRender'], ['the-second-child', 'willInsertElement'], ['the-last-child', 'init'], ['the-last-child', 'on(init)'], ['the-last-child', 'didReceiveAttrs'], ['the-last-child', 'willRender'], ['the-last-child', 'willInsertElement'], // Async hooks ['the-first-child', 'didInsertElement'], ['the-first-child', 'didRender'], ['the-second-child', 'didInsertElement'], ['the-second-child', 'didRender'], ['the-last-child', 'didInsertElement'], ['the-last-child', 'didRender'], ['the-parent', 'didInsertElement'], ['the-parent', 'didRender'], ], nonInteractive: [ // Sync hooks ['the-parent', 'init'], ['the-parent', 'on(init)'], ['the-parent', 'didReceiveAttrs'], ['the-first-child', 'init'], ['the-first-child', 'on(init)'], ['the-first-child', 'didReceiveAttrs'], ['the-second-child', 'init'], ['the-second-child', 'on(init)'], ['the-second-child', 'didReceiveAttrs'], ['the-last-child', 'init'], ['the-last-child', 'on(init)'], ['the-last-child', 'didReceiveAttrs'], ], }); this.runTask(() => this.components['the-first-child'].rerender()); this.assertText('Twitter: @tomdale|Name: Tom Dale|Website: tomdale.net'); this.assertHooks({ label: 'after no-op rerender (first child)', interactive: [ // Sync hooks ['the-parent', 'willUpdate'], ['the-parent', 'willRender'], ['the-first-child', 'willUpdate'], ['the-first-child', 'willRender'], // Async hooks ['the-first-child', 'didUpdate'], ['the-first-child', 'didRender'], ['the-parent', 'didUpdate'], ['the-parent', 'didRender'], ], nonInteractive: [], }); this.runTask(() => this.components['the-second-child'].rerender()); this.assertText('Twitter: @tomdale|Name: Tom Dale|Website: tomdale.net'); this.assertHooks({ label: 'after no-op rerender (second child)', interactive: [ // Sync hooks ['the-parent', 'willUpdate'], ['the-parent', 'willRender'], ['the-second-child', 'willUpdate'], ['the-second-child', 'willRender'], // Async hooks ['the-second-child', 'didUpdate'], ['the-second-child', 'didRender'], ['the-parent', 'didUpdate'], ['the-parent', 'didRender'], ], nonInteractive: [], }); this.runTask(() => this.components['the-last-child'].rerender()); this.assertText('Twitter: @tomdale|Name: Tom Dale|Website: tomdale.net'); this.assertHooks({ label: 'after no-op rerender (last child)', interactive: [ // Sync hooks ['the-parent', 'willUpdate'], ['the-parent', 'willRender'], ['the-last-child', 'willUpdate'], ['the-last-child', 'willRender'], // Async hooks ['the-last-child', 'didUpdate'], ['the-last-child', 'didRender'], ['the-parent', 'didUpdate'], ['the-parent', 'didRender'], ], nonInteractive: [], }); this.runTask(() => this.components['the-parent'].rerender()); this.assertText('Twitter: @tomdale|Name: Tom Dale|Website: tomdale.net'); this.assertHooks({ label: 'after no-op rerender (parent)', interactive: [ // Sync hooks ['the-parent', 'willUpdate'], ['the-parent', 'willRender'], // Async hooks ['the-parent', 'didUpdate'], ['the-parent', 'didRender'], ], nonInteractive: [], }); this.runTask(() => setProperties(this.context, { twitter: '@horsetomdale', name: 'Horse Tom Dale', website: 'horsetomdale.net', }) ); this.assertText('Twitter: @horsetomdale|Name: Horse Tom Dale|Website: horsetomdale.net'); this.assertHooks({ label: 'after update', interactive: [ // Sync hooks ['the-parent', 'didUpdateAttrs'], ['the-parent', 'didReceiveAttrs'], ['the-parent', 'willUpdate'], ['the-parent', 'willRender'], ['the-first-child', 'didUpdateAttrs'], ['the-first-child', 'didReceiveAttrs'], ['the-first-child', 'willUpdate'], ['the-first-child', 'willRender'], ['the-second-child', 'didUpdateAttrs'], ['the-second-child', 'didReceiveAttrs'], ['the-second-child', 'willUpdate'], ['the-second-child', 'willRender'], ['the-last-child', 'didUpdateAttrs'], ['the-last-child', 'didReceiveAttrs'], ['the-last-child', 'willUpdate'], ['the-last-child', 'willRender'], // Async hooks ['the-first-child', 'didUpdate'], ['the-first-child', 'didRender'], ['the-second-child', 'didUpdate'], ['the-second-child', 'didRender'], ['the-last-child', 'didUpdate'], ['the-last-child', 'didRender'], ['the-parent', 'didUpdate'], ['the-parent', 'didRender'], ], nonInteractive: [ // Sync hooks ['the-parent', 'didUpdateAttrs'], ['the-parent', 'didReceiveAttrs'], ['the-first-child', 'didUpdateAttrs'], ['the-first-child', 'didReceiveAttrs'], ['the-second-child', 'didUpdateAttrs'], ['the-second-child', 'didReceiveAttrs'], ['the-last-child', 'didUpdateAttrs'], ['the-last-child', 'didReceiveAttrs'], ], }); this.teardownAssertions.push(() => { this.assertHooks({ label: 'destroy', interactive: [ ['the-parent', 'willDestroyElement'], ['the-parent', 'willClearRender'], ['the-first-child', 'willDestroyElement'], ['the-first-child', 'willClearRender'], ['the-second-child', 'willDestroyElement'], ['the-second-child', 'willClearRender'], ['the-last-child', 'willDestroyElement'], ['the-last-child', 'willClearRender'], ['the-parent', 'didDestroyElement'], ['the-first-child', 'didDestroyElement'], ['the-second-child', 'didDestroyElement'], ['the-last-child', 'didDestroyElement'], ['the-parent', 'willDestroy'], ['the-first-child', 'willDestroy'], ['the-second-child', 'willDestroy'], ['the-last-child', 'willDestroy'], ], nonInteractive: [ ['the-parent', 'willDestroy'], ['the-first-child', 'willDestroy'], ['the-second-child', 'willDestroy'], ['the-last-child', 'willDestroy'], ], }); this.assertRegisteredViews('after destroy'); }); } ['@test passing values through attrs causes lifecycle hooks to fire if the attribute values have changed']() { let { attr, invoke } = this.boundHelpers; this.registerComponent('the-top', { template: strip`
Top: ${invoke('the-middle', { twitterTop: expr(attr('twitter')) })}
`, }); this.registerComponent('the-middle', { template: strip`
Middle: ${invoke('the-bottom', { twitterMiddle: expr(attr('twitterTop')), })}
`, }); this.registerComponent('the-bottom', { template: strip`
Bottom: {{${attr('twitterMiddle')}}}
`, }); this.render(invoke('the-top', { twitter: expr('twitter') }), { twitter: '@tomdale', }); this.assertText('Top: Middle: Bottom: @tomdale'); this.assertRegisteredViews('intial render'); this.assertHooks({ label: 'after initial render', interactive: [ // Sync hooks ['the-top', 'init'], ['the-top', 'on(init)'], ['the-top', 'didReceiveAttrs'], ['the-top', 'willRender'], ['the-top', 'willInsertElement'], ['the-middle', 'init'], ['the-middle', 'on(init)'], ['the-middle', 'didReceiveAttrs'], ['the-middle', 'willRender'], ['the-middle', 'willInsertElement'], ['the-bottom', 'init'], ['the-bottom', 'on(init)'], ['the-bottom', 'didReceiveAttrs'], ['the-bottom', 'willRender'], ['the-bottom', 'willInsertElement'], // Async hooks ['the-bottom', 'didInsertElement'], ['the-bottom', 'didRender'], ['the-middle', 'didInsertElement'], ['the-middle', 'didRender'], ['the-top', 'didInsertElement'], ['the-top', 'didRender'], ], nonInteractive: [ // Sync hooks ['the-top', 'init'], ['the-top', 'on(init)'], ['the-top', 'didReceiveAttrs'], ['the-middle', 'init'], ['the-middle', 'on(init)'], ['the-middle', 'didReceiveAttrs'], ['the-bottom', 'init'], ['the-bottom', 'on(init)'], ['the-bottom', 'didReceiveAttrs'], ], }); this.runTask(() => set(this.context, 'twitter', '@horsetomdale')); this.assertText('Top: Middle: Bottom: @horsetomdale'); // Because the `twitter` attr is used by the all of the components, // the lifecycle hooks are invoked for all components. this.assertHooks({ label: 'after updating (root)', interactive: [ // Sync hooks ['the-top', 'didUpdateAttrs'], ['the-top', 'didReceiveAttrs'], ['the-top', 'willUpdate'], ['the-top', 'willRender'], ['the-middle', 'didUpdateAttrs'], ['the-middle', 'didReceiveAttrs'], ['the-middle', 'willUpdate'], ['the-middle', 'willRender'], ['the-bottom', 'didUpdateAttrs'], ['the-bottom', 'didReceiveAttrs'], ['the-bottom', 'willUpdate'], ['the-bottom', 'willRender'], // Async hooks ['the-bottom', 'didUpdate'], ['the-bottom', 'didRender'], ['the-middle', 'didUpdate'], ['the-middle', 'didRender'], ['the-top', 'didUpdate'], ['the-top', 'didRender'], ], nonInteractive: [ // Sync hooks ['the-top', 'didUpdateAttrs'], ['the-top', 'didReceiveAttrs'], ['the-middle', 'didUpdateAttrs'], ['the-middle', 'didReceiveAttrs'], ['the-bottom', 'didUpdateAttrs'], ['the-bottom', 'didReceiveAttrs'], ], }); this.runTask(() => this.rerender()); this.assertText('Top: Middle: Bottom: @horsetomdale'); // In this case, because the attrs are passed down, all child components are invoked. this.assertHooks({ label: 'after no-op rernder (root)', interactive: [], nonInteractive: [], }); this.teardownAssertions.push(() => { this.assertHooks({ label: 'destroy', interactive: [ ['the-top', 'willDestroyElement'], ['the-top', 'willClearRender'], ['the-middle', 'willDestroyElement'], ['the-middle', 'willClearRender'], ['the-bottom', 'willDestroyElement'], ['the-bottom', 'willClearRender'], ['the-top', 'didDestroyElement'], ['the-middle', 'didDestroyElement'], ['the-bottom', 'didDestroyElement'], ['the-top', 'willDestroy'], ['the-middle', 'willDestroy'], ['the-bottom', 'willDestroy'], ], nonInteractive: [ ['the-top', 'willDestroy'], ['the-middle', 'willDestroy'], ['the-bottom', 'willDestroy'], ], }); this.assertRegisteredViews('after destroy'); }); } ['@test components rendered from `{{each}}` have correct life-cycle hooks to be called']() { let { invoke } = this.boundHelpers; this.registerComponent('nested-item', { template: `{{yield}}` }); this.registerComponent('an-item', { template: strip` {{#nested-item}}Item: {{count}}{{/nested-item}} `, }); this.registerComponent('no-items', { template: strip` {{#nested-item}}Nothing to see here{{/nested-item}} `, }); this.render( strip` {{#each items as |item|}} ${invoke('an-item', { count: expr('item') })} {{else}} ${invoke('no-items')} {{/each}} `, { items: [1, 2, 3, 4, 5], } ); this.assertText('Item: 1Item: 2Item: 3Item: 4Item: 5'); this.assertRegisteredViews('intial render'); let initialHooks = () => { let ret = [['an-item', 'init'], ['an-item', 'on(init)'], ['an-item', 'didReceiveAttrs']]; if (this.isInteractive) { ret.push(['an-item', 'willRender'], ['an-item', 'willInsertElement']); } ret.push( ['nested-item', 'init'], ['nested-item', 'on(init)'], ['nested-item', 'didReceiveAttrs'] ); if (this.isInteractive) { ret.push(['nested-item', 'willRender'], ['nested-item', 'willInsertElement']); } return ret; }; let initialAfterRenderHooks = () => { if (this.isInteractive) { return [ ['nested-item', 'didInsertElement'], ['nested-item', 'didRender'], ['an-item', 'didInsertElement'], ['an-item', 'didRender'], ]; } else { return []; } }; this.assertHooks({ label: 'after initial render', interactive: [ // Sync hooks ...initialHooks(1), ...initialHooks(2), ...initialHooks(3), ...initialHooks(4), ...initialHooks(5), // Async hooks ...initialAfterRenderHooks(5), ...initialAfterRenderHooks(4), ...initialAfterRenderHooks(3), ...initialAfterRenderHooks(2), ...initialAfterRenderHooks(1), ], nonInteractive: [ // Sync hooks ...initialHooks(1), ...initialHooks(2), ...initialHooks(3), ...initialHooks(4), ...initialHooks(5), // Async hooks ...initialAfterRenderHooks(5), ...initialAfterRenderHooks(4), ...initialAfterRenderHooks(3), ...initialAfterRenderHooks(2), ...initialAfterRenderHooks(1), ], }); // TODO: Is this correct? Should childViews be populated in non-interactive mode? if (this.isInteractive) { this.assert.equal(this.component.childViews.length, 5, 'childViews precond'); } this.runTask(() => set(this.context, 'items', [])); // TODO: Is this correct? Should childViews be populated in non-interactive mode? if (this.isInteractive) { this.assert.equal(this.component.childViews.length, 1, 'childViews updated'); } this.assertText('Nothing to see here'); this.assertHooks({ label: 'reset to empty array', interactive: [ ['an-item', 'willDestroyElement'], ['an-item', 'willClearRender'], ['nested-item', 'willDestroyElement'], ['nested-item', 'willClearRender'], ['an-item', 'willDestroyElement'], ['an-item', 'willClearRender'], ['nested-item', 'willDestroyElement'], ['nested-item', 'willClearRender'], ['an-item', 'willDestroyElement'], ['an-item', 'willClearRender'], ['nested-item', 'willDestroyElement'], ['nested-item', 'willClearRender'], ['an-item', 'willDestroyElement'], ['an-item', 'willClearRender'], ['nested-item', 'willDestroyElement'], ['nested-item', 'willClearRender'], ['an-item', 'willDestroyElement'], ['an-item', 'willClearRender'], ['nested-item', 'willDestroyElement'], ['nested-item', 'willClearRender'], ['no-items', 'init'], ['no-items', 'on(init)'], ['no-items', 'didReceiveAttrs'], ['no-items', 'willRender'], ['no-items', 'willInsertElement'], ['nested-item', 'init'], ['nested-item', 'on(init)'], ['nested-item', 'didReceiveAttrs'], ['nested-item', 'willRender'], ['nested-item', 'willInsertElement'], ['an-item', 'didDestroyElement'], ['nested-item', 'didDestroyElement'], ['an-item', 'didDestroyElement'], ['nested-item', 'didDestroyElement'], ['an-item', 'didDestroyElement'], ['nested-item', 'didDestroyElement'], ['an-item', 'didDestroyElement'], ['nested-item', 'didDestroyElement'], ['an-item', 'didDestroyElement'], ['nested-item', 'didDestroyElement'], ['nested-item', 'didInsertElement'], ['nested-item', 'didRender'], ['no-items', 'didInsertElement'], ['no-items', 'didRender'], ['an-item', 'willDestroy'], ['nested-item', 'willDestroy'], ['an-item', 'willDestroy'], ['nested-item', 'willDestroy'], ['an-item', 'willDestroy'], ['nested-item', 'willDestroy'], ['an-item', 'willDestroy'], ['nested-item', 'willDestroy'], ['an-item', 'willDestroy'], ['nested-item', 'willDestroy'], ], nonInteractive: [ ['no-items', 'init'], ['no-items', 'on(init)'], ['no-items', 'didReceiveAttrs'], ['nested-item', 'init'], ['nested-item', 'on(init)'], ['nested-item', 'didReceiveAttrs'], ['an-item', 'willDestroy'], ['nested-item', 'willDestroy'], ['an-item', 'willDestroy'], ['nested-item', 'willDestroy'], ['an-item', 'willDestroy'], ['nested-item', 'willDestroy'], ['an-item', 'willDestroy'], ['nested-item', 'willDestroy'], ['an-item', 'willDestroy'], ['nested-item', 'willDestroy'], ], }); this.teardownAssertions.push(() => { this.assertHooks({ label: 'destroy', interactive: [ ['no-items', 'willDestroyElement'], ['no-items', 'willClearRender'], ['nested-item', 'willDestroyElement'], ['nested-item', 'willClearRender'], ['no-items', 'didDestroyElement'], ['nested-item', 'didDestroyElement'], ['no-items', 'willDestroy'], ['nested-item', 'willDestroy'], ], nonInteractive: [['no-items', 'willDestroy'], ['nested-item', 'willDestroy']], }); this.assertRegisteredViews('after destroy'); }); } } class CurlyComponentsTest extends LifeCycleHooksTest { get ComponentClass() { return Component; } invocationFor(name, namedArgs = {}) { let attrs = Object.keys(namedArgs) .map(k => `${k}=${this.val(namedArgs[k])}`) .join(' '); return `{{${name} ${attrs}}}`; } attrFor(name) { return `${name}`; } /* private */ val(value) { if (value.isString) { return JSON.stringify(value.value); } else if (value.isExpr) { return `(readonly ${value.value})`; } else { throw new Error(`Unknown value: ${value}`); } } } moduleFor( 'Components test: interactive lifecycle hooks (curly components)', class extends CurlyComponentsTest { get isInteractive() { return true; } } ); moduleFor( 'Components test: non-interactive lifecycle hooks (curly components)', class extends CurlyComponentsTest { get isInteractive() { return false; } } ); moduleFor( 'Components test: interactive lifecycle hooks (tagless curly components)', class extends CurlyComponentsTest { get ComponentClass() { return Component.extend({ tagName: '' }); } get isInteractive() { return true; } } ); moduleFor( 'Components test: non-interactive lifecycle hooks (tagless curly components)', class extends CurlyComponentsTest { get ComponentClass() { return Component.extend({ tagName: '' }); } get isInteractive() { return false; } } ); moduleFor( 'Run loop and lifecycle hooks', class extends RenderingTest { ['@test afterRender set']() { let ComponentClass = Component.extend({ width: '5', didInsertElement() { schedule('afterRender', () => { this.set('width', '10'); }); }, }); let template = `{{width}}`; this.registerComponent('foo-bar', { ComponentClass, template }); this.render('{{foo-bar}}'); this.assertText('10'); this.runTask(() => this.rerender()); this.assertText('10'); } ['@test afterRender set on parent']() { let ComponentClass = Component.extend({ didInsertElement() { schedule('afterRender', () => { let parent = this.get('parent'); parent.set('foo', 'wat'); }); }, }); let template = `{{foo}}`; this.registerComponent('foo-bar', { ComponentClass, template }); this.render('{{foo-bar parent=this foo=foo}}'); this.assertText('wat'); this.runTask(() => this.rerender()); this.assertText('wat'); } ['@test `willRender` can set before render (GH#14458)']() { let ComponentClass = Component.extend({ tagName: 'a', customHref: 'http://google.com', attributeBindings: ['customHref:href'], willRender() { this.set('customHref', 'http://willRender.com'); }, }); let template = `Hello World`; this.registerComponent('foo-bar', { ComponentClass, template }); this.render(`{{foo-bar id="foo"}}`); this.assertElement(this.firstChild, { tagName: 'a', attrs: { id: 'foo', href: 'http://willRender.com', class: classes('ember-view'), }, }); } ['@test that thing about destroying'](assert) { let ParentDestroyedElements = []; let ChildDestroyedElements = []; let ParentComponent = Component.extend({ willDestroyElement() { ParentDestroyedElements.push({ id: this.itemId, name: 'parent-component', hasParent: !!this.element.parentNode, nextSibling: !!this.element.nextSibling, previousSibling: !!this.element.previousSibling, }); }, }); let PartentTemplate = strip` {{yield}} `; let NestedComponent = Component.extend({ willDestroyElement() { ChildDestroyedElements.push({ id: this.nestedId, name: 'nested-component', hasParent: !!this.element.parentNode, nextSibling: !!this.element.nextSibling, previousSibling: !!this.element.previousSibling, }); }, }); let NestedTemplate = `{{yield}}`; this.registerComponent('parent-component', { ComponentClass: ParentComponent, template: PartentTemplate, }); this.registerComponent('nested-component', { ComponentClass: NestedComponent, template: NestedTemplate, }); let array = emberA([{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }, { id: 5 }]); this.render( strip` {{#each items as |item|}} {{#parent-component itemId=item.id}}{{item.id}}{{/parent-component}} {{/each}} {{#if model.shouldShow}} {{#parent-component itemId=6}}6{{/parent-component}} {{/if}} {{#if model.shouldShow}} {{#parent-component itemId=7}}7{{/parent-component}} {{/if}} `, { items: array, model: { shouldShow: true }, } ); this.assertText('1AB2AB3AB4AB5AB6AB7AB'); this.runTask(() => { array.removeAt(2); array.removeAt(2); set(this.context, 'model.shouldShow', false); }); this.assertText('1AB2AB5AB'); assertDestroyHooks( assert, [...ParentDestroyedElements], [ { id: 3, hasParent: true, nextSibling: true, previousSibling: true, }, { id: 4, hasParent: true, nextSibling: true, previousSibling: true, }, { id: 6, hasParent: true, nextSibling: true, previousSibling: true, }, { id: 7, hasParent: true, nextSibling: false, previousSibling: true, }, ] ); assertDestroyHooks( assert, [...ChildDestroyedElements], [ { id: '3-A', hasParent: true, nextSibling: true, previousSibling: false, }, { id: '3-B', hasParent: true, nextSibling: false, previousSibling: true, }, { id: '4-A', hasParent: true, nextSibling: true, previousSibling: false, }, { id: '4-B', hasParent: true, nextSibling: false, previousSibling: true, }, { id: '6-A', hasParent: true, nextSibling: true, previousSibling: false, }, { id: '6-B', hasParent: true, nextSibling: false, previousSibling: true, }, { id: '7-A', hasParent: true, nextSibling: true, previousSibling: false, }, { id: '7-B', hasParent: true, nextSibling: false, previousSibling: true, }, ] ); } ['@test lifecycle hooks exist on the base class'](assert) { // Make sure we get the finalized component prototype let prototype = Component.proto(); assert.equal(typeof prototype.didDestroyElement, 'function', 'didDestroyElement exists'); assert.equal(typeof prototype.didInsertElement, 'function', 'didInsertElement exists'); assert.equal(typeof prototype.didReceiveAttrs, 'function', 'didReceiveAttrs exists'); assert.equal(typeof prototype.didRender, 'function', 'didRender exists'); assert.equal(typeof prototype.didUpdate, 'function', 'didUpdate exists'); assert.equal(typeof prototype.didUpdateAttrs, 'function', 'didUpdateAttrs exists'); assert.equal(typeof prototype.willClearRender, 'function', 'willClearRender exists'); assert.equal(typeof prototype.willDestroy, 'function', 'willDestroy exists'); assert.equal(typeof prototype.willDestroyElement, 'function', 'willDestroyElement exists'); assert.equal(typeof prototype.willInsertElement, 'function', 'willInsertElement exists'); assert.equal(typeof prototype.willRender, 'function', 'willRender exists'); assert.equal(typeof prototype.willUpdate, 'function', 'willUpdate exists'); } } ); if (!jQueryDisabled) { moduleFor( 'Run loop and lifecycle hooks - jQuery only', class extends RenderingTest { ['@test lifecycle hooks have proper access to this.$()'](assert) { assert.expect(6); let component; let FooBarComponent = Component.extend({ tagName: 'div', init() { assert.notOk(this.$(), 'no access to element via this.$() on init() enter'); this._super(...arguments); assert.notOk(this.$(), 'no access to element via this.$() after init() finished'); }, willInsertElement() { component = this; assert.ok(this.$(), 'willInsertElement has access to element via this.$()'); }, didInsertElement() { assert.ok(this.$(), 'didInsertElement has access to element via this.$()'); }, willDestroyElement() { assert.ok(this.$(), 'willDestroyElement has access to element via this.$()'); }, didDestroyElement() { assert.notOk( this.$(), 'didDestroyElement does not have access to element via this.$()' ); }, }); this.registerComponent('foo-bar', { ComponentClass: FooBarComponent, template: 'hello', }); let { owner } = this; let comp = owner.lookup('component:foo-bar'); runAppend(comp); this.runTask(() => tryInvoke(component, 'destroy')); } } ); } function assertDestroyHooks(assert, _actual, _expected) { _expected.forEach((expected, i) => { let name = expected.name; assert.equal(expected.id, _actual[i].id, `${name} id is the same`); assert.equal(expected.hasParent, _actual[i].hasParent, `${name} has parent node`); assert.equal(expected.nextSibling, _actual[i].nextSibling, `${name} has next sibling node`); assert.equal( expected.previousSibling, _actual[i].previousSibling, `${name} has previous sibling node` ); }); } function bind(func, thisArg) { return (...args) => func.apply(thisArg, args); } function string(value) { return { isString: true, value }; } function expr(value) { return { isExpr: true, value }; } function hook(name, hook, { attrs, oldAttrs, newAttrs } = {}) { return { name, hook, args: { attrs, oldAttrs, newAttrs } }; } function json(serializable) { return JSON.parse(JSON.stringify(serializable)); }