diff --git a/core.ts b/core.ts index 5dbca04..e3ecc00 100644 --- a/core.ts +++ b/core.ts @@ -14,3 +14,4 @@ export { } from './src/core/directives'; export { Pipe, PipeTransform } from './src/core/pipes'; export * from './src/core/linker'; +export * from './src/core/change_detection' diff --git a/src/core/change_detection.ts b/src/core/change_detection.ts new file mode 100644 index 0000000..bb54fec --- /dev/null +++ b/src/core/change_detection.ts @@ -0,0 +1 @@ +export { SimpleChange } from './change_detection/change_detection_util'; diff --git a/src/core/change_detection/change_detection_util.ts b/src/core/change_detection/change_detection_util.ts new file mode 100644 index 0000000..c66108b --- /dev/null +++ b/src/core/change_detection/change_detection_util.ts @@ -0,0 +1,35 @@ +import { isPresent, isBlank } from '../../facade/lang'; +/** + * Represents a basic change from a previous to a new value. + */ +export class SimpleChange { + constructor(public previousValue: any, public currentValue: any) {} + + /** + * Check whether the new value is the first value assigned. + */ + isFirstChange(): boolean { return this.previousValue === ChangeDetectionUtil.uninitialized; } +} + +export class UninitializedValue {} + +function _simpleChange(previousValue, currentValue): SimpleChange { + return new SimpleChange(previousValue, currentValue); +} + +function _uninitializedValue(){ + return new UninitializedValue(); +} + +/* tslint:disable:requireParameterType */ +export class ChangeDetectionUtil { + static uninitialized: UninitializedValue = _uninitializedValue(); + + static simpleChange(previousValue: any, currentValue: any): SimpleChange { + return _simpleChange(previousValue, currentValue); + } + + static isValueBlank(value: any): boolean { return isBlank(value); } + + static s(value: any): string { return isPresent(value) ? `${value}` : ''; } +} diff --git a/src/core/change_detection/changes_queue.ts b/src/core/change_detection/changes_queue.ts new file mode 100644 index 0000000..90d4669 --- /dev/null +++ b/src/core/change_detection/changes_queue.ts @@ -0,0 +1,63 @@ +// @TODO this needs to be in a singleton +import { isFunction } from '../../facade/lang'; + +const TTL = 10; +let onChangesTtl = TTL; + + +export class ChangesQueue{ + + // The onChanges hooks should all be run together in a single digest + // When changes occur, the call to trigger their hooks will be added to this queue + onChangesQueue: Function[]; + + flushOnChangesQueue: () => void; + + buildFlushOnChanges( $rootScope: ng.IRootScopeService ){ + + const _context = this; + + buildFlushOnChangesCb( $rootScope ); + + function buildFlushOnChangesCb( $rootScope: ng.IRootScopeService ): () => void { + + if(isFunction(_context.flushOnChangesQueue)){ + return _context.flushOnChangesQueue; + } + _context.flushOnChangesQueue = getFlushOnChangesQueueCb($rootScope); + return _context.flushOnChangesQueue; + + } + + function getFlushOnChangesQueueCb( $rootScope: ng.IRootScopeService ): () => void { + + // This function is called in a $$postDigest to trigger all the onChanges hooks in a single digest + return function _flushOnChangesQueue() { + try { + if (!(--onChangesTtl)) { + // We have hit the TTL limit so reset everything + _context.onChangesQueue = undefined; + throw new Error(`infchng, ${TTL} ngOnChanges() iterations reached. Aborting!\n`); + } + // We must run this hook in an apply since the $$postDigest runs outside apply + $rootScope.$apply(function() { + for (var i = 0, ii = _context.onChangesQueue.length; i < ii; ++i) { + _context.onChangesQueue[i](); + } + // Reset the queue to trigger a new schedule next time there is a change + _context.onChangesQueue = undefined; + }); + } finally { + onChangesTtl++; + } + } + + + } + + + } + +} + +export const changesQueueService = new ChangesQueue(); diff --git a/src/core/directives/directive_provider.ts b/src/core/directives/directive_provider.ts index 58ce486..107b222 100644 --- a/src/core/directives/directive_provider.ts +++ b/src/core/directives/directive_provider.ts @@ -19,7 +19,8 @@ import { OnInit, OnDestroy, OnChildrenChanged, - ChildrenChangeHook + ChildrenChangeHook, + OnChanges } from '../linker/directive_lifecycle_interfaces'; import { DirectiveMetadata, ComponentMetadata, LegacyDirectiveDefinition } from './metadata_directives'; import { @@ -55,6 +56,7 @@ export interface DirectiveCtrl extends AfterViewChecked, OnInit, OnDestroy, + OnChanges, OnChildrenChanged { __readChildrenOrderScheduled?: boolean __readViewChildrenOrderScheduled?: boolean diff --git a/src/core/directives/util/util.ts b/src/core/directives/util/util.ts index c167973..e6ec6ba 100644 --- a/src/core/directives/util/util.ts +++ b/src/core/directives/util/util.ts @@ -15,8 +15,10 @@ import { DirectiveMetadata, ComponentMetadata } from '../metadata_directives'; import { ListWrapper, StringMapWrapper } from '../../../facade/collections'; import { ChildrenChangeHook } from '../../linker/directive_lifecycle_interfaces'; import { QueryMetadata } from '../metadata_di'; -import { DirectiveCtrl, NgmDirective, _setupDestroyHandler } from '../directive_provider'; +import { DirectiveCtrl, NgmDirective } from '../directive_provider'; import { StringWrapper } from '../../../facade/primitives'; +import { ChangeDetectionUtil } from '../../change_detection/change_detection_util'; +import { changesQueueService } from '../../change_detection/changes_queue'; const REQUIRE_PREFIX_REGEXP = /^(?:(\^\^?)?(\?)?(\^\^?)?)?/; @@ -258,7 +260,8 @@ export function directiveControllerFactory( '$parse' ), - $interpolate: $injector.get( '$interpolate' ) + $interpolate: $injector.get( '$interpolate' ), + $rootScope: $injector.get( '$rootScope' ) }; const { $scope, $element, $attrs } = locals; @@ -275,7 +278,7 @@ export function directiveControllerFactory removeWatches ); // change injectables to proper inject directives // we wanna do this only if we inject some locals/directives @@ -306,8 +309,9 @@ export function directiveControllerFactory { @@ -622,8 +641,11 @@ export function _createDirectiveBindings( const parentGet = $parse( attributes[ attrName ] ); ctrl[ propName ] = parentGet( scope ); + initialChanges[ propName ] = ChangeDetectionUtil.simpleChange( ChangeDetectionUtil.uninitialized, ctrl[ propName ] ); return scope.$watch( parentGet, function parentValueWatchAction( newParentValue ) { + const oldValue = ctrl[ propName ]; + recordChanges( propName, newParentValue, oldValue ); ctrl[ propName ] = newParentValue; }, parentGet.literal ); @@ -711,6 +733,8 @@ export function _createDirectiveBindings( const _disposeObserver = attributes.$observe( attrName, function ( value ) { if ( isString( value ) ) { + const oldValue = ctrl[ propName ]; + recordChanges( propName, value, oldValue ); ctrl[ propName ] = value; } } ); @@ -727,13 +751,49 @@ export function _createDirectiveBindings( ctrl[ propName ] = lastValue; } + initialChanges[ propName ] = ChangeDetectionUtil.simpleChange( ChangeDetectionUtil.uninitialized, ctrl[ propName ] ); return _disposeObserver; } + function recordChanges( key: string, currentValue: T, previousValue: T ): void { + if (isFunction(ctrl.ngOnChanges) && currentValue !== previousValue) { + // If we have not already scheduled the top level onChangesQueue handler then do so now + if (!changesQueueService.onChangesQueue) { + (scope as any).$$postDigest(changesQueueService.flushOnChangesQueue); + changesQueueService.onChangesQueue = []; + } + // If we have not already queued a trigger of onChanges for this controller then do so now + if (!changes) { + changes = {}; + changesQueueService.onChangesQueue.push(triggerOnChangesHook); + } + // If the has been a change on this property already then we need to reuse the previous value + if (changes[key]) { + previousValue = changes[key].previousValue; + } + // Store this change + changes[key] = ChangeDetectionUtil.simpleChange(previousValue, currentValue); + } + } + + function triggerOnChangesHook(): void { + ctrl.ngOnChanges( changes ); + // Now clear the changes so that we schedule onChanges when more changes arrive + changes = undefined; + } + + function removeWatches(): void { + const removeWatchCollection = [ ..._internalWatchers, ..._internalObservers ]; + for ( var i = 0, ii = removeWatchCollection.length; i < ii; ++i ) { + removeWatchCollection[ i ](); + } + } + return { - watchers: _internalWatchers, - observers: _internalObservers + initialChanges, + removeWatches, + _watchers: { watchers: _internalWatchers, observers: _internalObservers } }; } diff --git a/src/core/linker.ts b/src/core/linker.ts index 7e0c667..e0159c9 100644 --- a/src/core/linker.ts +++ b/src/core/linker.ts @@ -4,5 +4,6 @@ export { AfterContentChecked, AfterViewChecked, OnDestroy, - OnInit + OnInit, + OnChanges } from './linker/directive_lifecycle_interfaces'; diff --git a/src/core/linker/directive_lifecycle_interfaces.ts b/src/core/linker/directive_lifecycle_interfaces.ts index 4980486..032a6e9 100644 --- a/src/core/linker/directive_lifecycle_interfaces.ts +++ b/src/core/linker/directive_lifecycle_interfaces.ts @@ -1,6 +1,8 @@ +import { SimpleChange } from '../change_detection/change_detection_util'; export enum LifecycleHooks { OnInit, OnDestroy, + OnChanges, AfterContentInit, AfterContentChecked, AfterViewInit, @@ -14,6 +16,7 @@ export enum LifecycleHooks { export var LIFECYCLE_HOOKS_VALUES = [ LifecycleHooks.OnInit, LifecycleHooks.OnDestroy, + LifecycleHooks.OnChanges, LifecycleHooks.AfterContentInit, LifecycleHooks.AfterContentChecked, LifecycleHooks.AfterViewInit, @@ -28,6 +31,7 @@ export enum ChildrenChangeHook{ /** * Lifecycle hooks are guaranteed to be called in the following order: + * - `OnChanges` (if any bindings have changed), * - `OnInit` (after the first check only), * - `AfterContentInit`, * - `AfterContentChecked`, @@ -36,6 +40,46 @@ export enum ChildrenChangeHook{ * - `OnDestroy` (at the very end before destruction) */ +/** + * Implement this interface to get notified when any data-bound property of your directive changes. + * + * `ngOnChanges` is called right after the data-bound properties have been checked and before view + * and content children are checked if at least one of them has changed. + * + * The `changes` parameter contains an entry for each of the changed data-bound property. The key is + * the property name and the value is an instance of {@link SimpleChange}. + * + * ### Example ([live example](http://plnkr.co/edit/AHrB6opLqHDBPkt4KpdT?p=preview)): + * + * ```typescript + * @Component({ + * selector: 'my-cmp', + * template: `

myProp = {{myProp}}

` + * }) + * class MyComponent implements OnChanges { + * @Input() myProp: any; + * + * ngOnChanges(changes: {[propName: string]: SimpleChange}) { + * console.log('ngOnChanges - myProp = ' + changes['myProp'].currentValue); + * } + * } + * + * @Component({ + * selector: 'app', + * template: ` + * + * `, + * directives: [MyComponent] + * }) + * export class App { + * value = 0; + * } + * + * bootstrap(App).catch(err => console.error(err)); + * ``` + */ +export interface OnChanges { ngOnChanges(changes: {[key: string]: SimpleChange}); } + /** * Implement this interface to execute custom initialization logic after your directive's * data-bound properties have been initialized. diff --git a/test/core/change_detection/changes_queue.spec.ts b/test/core/change_detection/changes_queue.spec.ts new file mode 100644 index 0000000..40ac94f --- /dev/null +++ b/test/core/change_detection/changes_queue.spec.ts @@ -0,0 +1,31 @@ +import { expect } from 'chai'; +import { changesQueueService } from '../../../src/core/change_detection/changes_queue'; +import { $Scope } from '../../../src/testing/utils'; +import { isFunction } from '../../../src/facade/lang'; + +describe( `changes_queue`, () => { + + it( `should have undefined onChangesQueue and flushOnChangesQueue on init`, () => { + + expect( changesQueueService.onChangesQueue ).to.equal( undefined ); + expect( changesQueueService.flushOnChangesQueue ).to.equal( undefined ); + + } ); + + it( `should set #flushOnChangesQueue callback only once`, () => { + + const $rootScope = new $Scope(); + expect( changesQueueService.flushOnChangesQueue ).to.equal( undefined ); + + changesQueueService.buildFlushOnChanges( $rootScope as any ); + + expect( isFunction( changesQueueService.flushOnChangesQueue ) ).to.equal( true ); + + const createdFlushOnChangesQueueCb = changesQueueService.flushOnChangesQueue; + changesQueueService.buildFlushOnChanges( $rootScope as any ); + + expect( changesQueueService.flushOnChangesQueue ).to.equal( createdFlushOnChangesQueueCb ); + + } ); + +} ); diff --git a/test/core/directives/util/util.spec.ts b/test/core/directives/util/util.spec.ts index aa305de..5e51bda 100644 --- a/test/core/directives/util/util.spec.ts +++ b/test/core/directives/util/util.spec.ts @@ -816,7 +816,8 @@ describe( `directives/util`, ()=> { $attrs = new $Attrs(); services = { $interpolate: new $Interpolate(), - $parse: new $Parse() + $parse: new $Parse(), + $rootScope: new $Scope() }; ctrl = {}; } ); @@ -841,7 +842,7 @@ describe( `directives/util`, ()=> { oneAlias: '$ctrl.parentOne' } ); const bindingDisposables = _createDirectiveBindings( false, $scope, $attrs, ctrl, metadata, services ); - const {watchers,observers} = bindingDisposables; + const {watchers,observers} = bindingDisposables._watchers; expect( watchers.length ).to.equal( 2 ); expect( observers.length ).to.equal( 0 ); @@ -876,7 +877,7 @@ describe( `directives/util`, ()=> { $attrs.oneAlias = 'hello one'; const bindingDisposables = _createDirectiveBindings(false, $scope, $attrs, ctrl, metadata, services ); - const {watchers,observers} = bindingDisposables; + const {watchers,observers} = bindingDisposables._watchers; expect( watchers.length ).to.equal( 0 ); expect( observers.length ).to.equal( 2 ); @@ -912,7 +913,7 @@ describe( `directives/util`, ()=> { onOneAlias: '$ctrl.parentOne()' } ); const bindingDisposables = _createDirectiveBindings( false, $scope, $attrs, ctrl, metadata, services ); - const {watchers,observers} = bindingDisposables; + const {watchers,observers} = bindingDisposables._watchers; expect( watchers.length ).to.equal( 0 ); expect( observers.length ).to.equal( 0 ); diff --git a/test/index.ts b/test/index.ts index 2807172..6bd9258 100644 --- a/test/index.ts +++ b/test/index.ts @@ -1,3 +1,4 @@ +import './core/change_detection/changes_queue.spec'; import './core/di/decorators.spec'; import './core/di/provider.spec'; import './core/di/key.spec';