diff --git a/packages/metal-component/src/Component.js b/packages/metal-component/src/Component.js index a0097f88..24d82686 100644 --- a/packages/metal-component/src/Component.js +++ b/packages/metal-component/src/Component.js @@ -28,12 +28,21 @@ import { EventEmitter, EventHandler } from 'metal-events'; * created() { * } * + * willRender() { + * } + * * rendered() { * } * + * willAttach() { + * } + * * attached() { * } * + * willDetach() { + * } + * * detached() { * } * @@ -125,6 +134,7 @@ class Component extends EventEmitter { this.setUpDataManager_(); this.setUpSyncUpdates_(); + this.on('stateWillChange', this.handleStateWillChange_); this.on('stateChanged', this.handleComponentStateChanged_); this.on('eventsChanged', this.onEventsChanged_); this.addListenersFromObj_(this.dataManager_.get(this, 'events')); @@ -172,6 +182,8 @@ class Component extends EventEmitter { */ attach(opt_parentElement, opt_siblingElement) { if (!this.inDocument) { + this.emit('willAttach'); + this.willAttach(); this.attachElement(opt_parentElement, opt_siblingElement); this.inDocument = true; this.attachData_ = { @@ -238,6 +250,8 @@ class Component extends EventEmitter { */ detach() { if (this.inDocument) { + this.emit('willDetach'); + this.willDetach(); if (this.element && this.element.parentNode) { this.element.parentNode.removeChild(this.element); } @@ -377,6 +391,18 @@ class Component extends EventEmitter { }); } + /** + * Fires before state batch changes. Provides hook point for modifying + * state. + * @param {Event} event + * @protected + */ + handleStateWillChange_(event) { + if (this.willReceiveState) { + this.willReceiveState(event.changes); + } + } + /** * Checks if this component has sync updates enabled. * @return {boolean} @@ -501,6 +527,9 @@ class Component extends EventEmitter { * be called manually later to actually attach it to the dom. */ renderComponent(opt_parentElement) { + const firstRender = !this.hasRendererRendered_; + this.emit('willRender', firstRender); + this.willRender(firstRender); if (!this.hasRendererRendered_) { if (!this.serverSide_ && window.__METAL_DEV_TOOLS_HOOK__) { window.__METAL_DEV_TOOLS_HOOK__(this); @@ -650,6 +679,23 @@ class Component extends EventEmitter { validatorEventsFn_(val) { return !isDefAndNotNull(val) || isObject(val); } + + /** + * Lifecycle. Fires before the component has been attached to the DOM. + */ + willAttach() {} + + /** + * Lifecycle. Fires before component is detached from the DOM. + */ + willDetach() {} + + /** + * Lifecycle. Fires whenever the component is about to render. + * @param {boolean} firstRender Flag indicating if this will be the + * component's first render. + */ + willRender() {} } /** diff --git a/packages/metal-component/test/Component.js b/packages/metal-component/test/Component.js index 79a5f038..19382930 100644 --- a/packages/metal-component/test/Component.js +++ b/packages/metal-component/test/Component.js @@ -94,6 +94,29 @@ describe('Component', function() { assert.ok(comp.inDocument); }); + it('should run "willAttach" lifecycle method when the component about to attach', function() { + class TestComponent extends Component { + } + sinon.spy(TestComponent.prototype, 'willAttach'); + comp = new TestComponent(); + + assert.ok(comp.willAttach.calledBefore(Component.prototype.attached)); + assert.strictEqual(1, comp.willAttach.callCount); + }); + + it('should emit "willAttach" lifecycle method when the component about to attach', function() { + var listener = sinon.stub(); + class TestComponent extends Component { + created() { + this.on('willAttach', listener); + } + } + comp = new TestComponent(); + + assert.ok(listener.calledBefore(Component.prototype.attached)); + assert.strictEqual(1, listener.callCount); + }); + it('should emit "attached" event when component is attached', function() { comp = new Component({}, false); var listener = sinon.stub(); @@ -113,6 +136,33 @@ describe('Component', function() { assert.strictEqual('.sibling', attachData.sibling); }); + it('should run "willRender" lifecycle method when the component about to render', function() { + class TestComponent extends Component { + } + sinon.spy(TestComponent.prototype, 'willRender'); + sinon.spy(TestComponent.prototype, 'rendered'); + comp = new TestComponent(); + + assert.ok(comp.willRender.calledBefore(comp.rendered)); + assert.strictEqual(1, comp.willRender.callCount); + assert.ok(comp.willRender.args[0][0]); + }); + + it('should emit "willRender" event when the component about to render', function() { + var listener = sinon.stub(); + class TestComponent extends Component { + created() { + this.on('willRender', listener); + } + } + sinon.spy(TestComponent.prototype, 'rendered'); + comp = new TestComponent(); + + assert.ok(listener.calledBefore(comp.rendered)); + assert.strictEqual(1, listener.callCount); + assert.ok(listener.args[0][0]); + }); + it('should run "rendered" lifecycle method when the component is rendered', function() { class TestComponent extends Component { } @@ -142,6 +192,37 @@ describe('Component', function() { assert.strictEqual(comp, comp.attach()); }); + it('should run "willDetach" lifecycle method when the component about to detach', function() { + class TestComponent extends Component { + } + sinon.spy(TestComponent.prototype, 'willDetach'); + comp = new TestComponent(); + + assert.strictEqual(0, comp.willDetach.callCount); + + comp.detach(); + + assert.ok(comp.willDetach.calledBefore(Component.prototype.detached)); + assert.strictEqual(1, comp.willDetach.callCount); + }); + + it('should emit "willDetach" lifecycle method when the component about to detach', function() { + var listener = sinon.stub(); + class TestComponent extends Component { + created() { + this.on('willDetach', listener); + } + } + comp = new TestComponent(); + + assert.strictEqual(0, listener.callCount); + + comp.detach(); + + assert.ok(listener.calledBefore(Component.prototype.detached)); + assert.strictEqual(1, listener.callCount); + }); + it('should dispose component', function() { comp = new Component(); @@ -480,6 +561,83 @@ describe('Component', function() { new CustomComponent(); }); }); + + it('should allow changes to state in "willReceiveState" without triggering multiple renders', function(done) { + const renderStub = sinon.stub(); + let count = 0; + + class CustomRenderer extends ComponentRenderer.constructor { + update(component) { + renderStub(); + + component.element.innerHTML = `${component.bar}:${component.foo}`; + component.informRendered(); + } + } + + class TestComponent extends Component { + willReceiveState(changes) { + this.foo = 'foo' + count; + + count++; + } + } + TestComponent.STATE = { + bar: { + value: 'bar' + }, + + foo: { + value: 'foo' + } + }; + TestComponent.RENDERER = new CustomRenderer(); + + comp = new TestComponent(); + + comp.bar = 'bar2'; + comp.once('rendered', function() { + assert.equal(comp.element.innerHTML, 'bar2:foo0'); + + async.nextTick(function() { + comp.bar = 'bar3'; + comp.once('rendered', function() { + assert.equal(renderStub.callCount, 2); + assert.equal(comp.element.innerHTML, 'bar3:foo1'); + + done(); + }); + }); + }); + }); + + it('should pass changed state data to "willReceiveState" method', function(done) { + class TestComponent extends Component { + } + TestComponent.prototype.willReceiveState = sinon.stub(); + TestComponent.STATE = { + foo: { + value: 'foo' + } + }; + + comp = new TestComponent(); + + comp.foo = 'foo2'; + + async.nextTick(function() { + assert.equal(comp.willReceiveState.callCount, 1); + assert.deepEqual(comp.willReceiveState.args[0][0], { + foo: { + key: 'foo', + newVal: 'foo2', + prevVal: 'foo' + } + }); + + done(); + }); + }); }); describe('Render', function() { diff --git a/packages/metal-jsx/src/JSXComponent.js b/packages/metal-jsx/src/JSXComponent.js index aad72fa3..94a0e190 100644 --- a/packages/metal-jsx/src/JSXComponent.js +++ b/packages/metal-jsx/src/JSXComponent.js @@ -33,6 +33,18 @@ class JSXComponent extends Component { return IncrementalDomRenderer.render(...args); } + /** + * Fires before state batch changes. Provides hook point for modifying + * state. + * @param {Event} event + * @protected + */ + handleStateWillChange_(event) { + if (event.type !== 'state' && this.willReceiveProps) { + this.willReceiveProps(event.changes); + } + } + /** * Returns props that are not used or declared in the component. * @return {Object} Object containing props diff --git a/packages/metal-jsx/test/JSXComponent.js b/packages/metal-jsx/test/JSXComponent.js index 2bad8758..26ede454 100644 --- a/packages/metal-jsx/test/JSXComponent.js +++ b/packages/metal-jsx/test/JSXComponent.js @@ -1,5 +1,6 @@ 'use strict'; +import { async } from 'metal'; import dom from 'metal-dom'; import JSXComponent from '../src/JSXComponent'; @@ -307,6 +308,81 @@ describe('JSXComponent', function() { }); }); + it('should allow changes to state in "willReceiveProps" without triggering multiple renders', function(done) { + const renderStub = sinon.stub(); + let count = 0; + + class TestComponent extends JSXComponent { + render() { + renderStub(); + + return