From 174613067cd726c08a5b69b331fe5a6f252bd5d8 Mon Sep 17 00:00:00 2001 From: Rado Kirov Date: Mon, 1 Dec 2014 18:41:55 -0800 Subject: [PATCH] feat(views): adds (de)hydration of views and template vars. Dehydrated views are views that are structurally fixed, but their directive instances and viewports are purged. Support for local bindings is added to the view. --- .../parser/context_with_variable_bindings.js | 22 +- .../context_with_variable_bindings_spec.js | 42 ++++ modules/core/src/application.js | 4 +- modules/core/src/compiler/element_injector.js | 4 + modules/core/src/compiler/view.js | 210 ++++++++++++++---- modules/core/src/compiler/viewport.js | 26 ++- .../test/compiler/element_injector_spec.js | 12 +- .../core/test/compiler/integration_spec.js | 16 +- .../pipeline/element_binder_builder_spec.js | 3 +- modules/core/test/compiler/view_spec.js | 164 +++++++++++--- modules/core/test/compiler/viewport_spec.js | 19 +- 11 files changed, 413 insertions(+), 109 deletions(-) create mode 100644 modules/change_detection/test/parser/context_with_variable_bindings_spec.js diff --git a/modules/change_detection/src/parser/context_with_variable_bindings.js b/modules/change_detection/src/parser/context_with_variable_bindings.js index f390aa42d2..2f4b630e81 100644 --- a/modules/change_detection/src/parser/context_with_variable_bindings.js +++ b/modules/change_detection/src/parser/context_with_variable_bindings.js @@ -1,8 +1,9 @@ import {MapWrapper} from 'facade/collection'; +import {BaseException} from 'facade/lang'; export class ContextWithVariableBindings { parent:any; - /// varBindings are read-only. updating/adding keys is not supported. + /// varBindings' keys are read-only. adding/removing keys is not supported. varBindings:Map; constructor(parent:any, varBindings:Map) { @@ -17,4 +18,21 @@ export class ContextWithVariableBindings { get(name:string) { return MapWrapper.get(this.varBindings, name); } -} \ No newline at end of file + + set(name:string, value) { + // TODO(rado): consider removing this check if we can guarantee this is not + // exposed to the public API. + if (this.hasBinding(name)) { + MapWrapper.set(this.varBindings, name, value); + } else { + throw new BaseException( + 'VariableBindings do not support setting of new keys post-construction.'); + } + } + + clearValues() { + for (var [k, v] of MapWrapper.iterable(this.varBindings)) { + MapWrapper.set(this.varBindings, k, null); + } + } +} diff --git a/modules/change_detection/test/parser/context_with_variable_bindings_spec.js b/modules/change_detection/test/parser/context_with_variable_bindings_spec.js new file mode 100644 index 0000000000..fdf5e90de1 --- /dev/null +++ b/modules/change_detection/test/parser/context_with_variable_bindings_spec.js @@ -0,0 +1,42 @@ +import {ddescribe, describe, it, xit, iit, expect, beforeEach} from 'test_lib/test_lib'; +import {BaseException, isBlank, isPresent} from 'facade/lang'; +import {MapWrapper, ListWrapper} from 'facade/collection'; +import {ContextWithVariableBindings} from 'change_detection/parser/context_with_variable_bindings'; + +export function main() { + describe('ContextWithVariableBindings', () => { + var locals; + beforeEach(() => { + locals = new ContextWithVariableBindings(null, + MapWrapper.createFromPairs([['key', 'value'], ['nullKey', null]])); + }); + + it('should support getting values', () => { + expect(locals.get('key')).toBe('value'); + + var notPresentValue = locals.get('notPresent'); + expect(isPresent(notPresentValue)).toBe(false); + }); + + it('should support checking if key is persent', () => { + expect(locals.hasBinding('key')).toBe(true); + expect(locals.hasBinding('nullKey')).toBe(true); + expect(locals.hasBinding('notPresent')).toBe(false); + }); + + it('should support setting persent keys', () => { + locals.set('key', 'bar'); + expect(locals.get('key')).toBe('bar'); + }); + + it('should not support setting keys that are not present already', () => { + expect(() => locals.set('notPresent', 'bar')).toThrowError(); + }); + + it('should clearValues', () => { + locals.clearValues(); + expect(locals.get('key')).toBe(null); + }); + }) +} + diff --git a/modules/core/src/application.js b/modules/core/src/application.js index 68813cc18f..30ce233e7b 100644 --- a/modules/core/src/application.js +++ b/modules/core/src/application.js @@ -54,7 +54,9 @@ export function documentDependentBindings(appComponentType) { // The light Dom of the app element is not considered part of // the angular application. Thus the context and lightDomInjector are // empty. - return appProtoView.instantiate(new Object(), injector, null, true); + var view = appProtoView.instantiate(null, true); + view.hydrate(injector, null, new Object()); + return view; }); }, [Compiler, Injector, appElementToken, appComponentAnnotatedTypeToken]), diff --git a/modules/core/src/compiler/element_injector.js b/modules/core/src/compiler/element_injector.js index c1043106dd..5dab9c8a67 100644 --- a/modules/core/src/compiler/element_injector.js +++ b/modules/core/src/compiler/element_injector.js @@ -459,6 +459,10 @@ export class ElementInjector extends TreeNode { if (index == 9) return this._obj9; throw new OutOfBoundsAccess(index); } + + hasInstances() { + return this._constructionCounter > 0; + } } class OutOfBoundsAccess extends Error { diff --git a/modules/core/src/compiler/view.js b/modules/core/src/compiler/view.js index 544f2dd9db..df3df0016a 100644 --- a/modules/core/src/compiler/view.js +++ b/modules/core/src/compiler/view.js @@ -8,11 +8,12 @@ import {ProtoElementInjector, ElementInjector, PreBuiltObjects} from './element_ import {ElementBinder} from './element_binder'; import {AnnotatedType} from './annotated_type'; import {SetterFn} from 'reflection/types'; -import {FIELD, IMPLEMENTS, int, isPresent, isBlank} from 'facade/lang'; +import {FIELD, IMPLEMENTS, int, isPresent, isBlank, BaseException} from 'facade/lang'; import {Injector} from 'di/di'; import {NgElement} from 'core/dom/element'; import {ViewPort} from './viewport'; import {OnChange} from './interfaces'; +import {ContextWithVariableBindings} from 'change_detection/parser/context_with_variable_bindings'; const NG_BINDING_CLASS = 'ng-binding'; @@ -33,9 +34,14 @@ export class View { onChangeDispatcher:OnChangeDispatcher; componentChildViews: List; viewPorts: List; - constructor(nodes:List, elementInjectors:List, + preBuiltObjects: List; + proto: ProtoView; + context: Object; + _localBindings: Map; + constructor(proto:ProtoView, nodes:List, elementInjectors:List, rootElementInjectors:List, textNodes:List, bindElements:List, - protoRecordRange:ProtoRecordRange, context) { + protoRecordRange:ProtoRecordRange) { + this.proto = proto; this.nodes = nodes; this.elementInjectors = elementInjectors; this.rootElementInjectors = rootElementInjectors; @@ -43,9 +49,120 @@ export class View { this.textNodes = textNodes; this.bindElements = bindElements; this.recordRange = protoRecordRange.instantiate(this, MapWrapper.create()); - this.recordRange.setContext(context); this.componentChildViews = null; this.viewPorts = null; + this.preBuiltObjects = null; + this.context = null; + + // used to persist the locals part of context inbetween hydrations. + this._localBindings = null; + if (isPresent(this.proto) && MapWrapper.size(this.proto.variableBindings) > 0) { + this._createLocalContext(); + } + } + + _createLocalContext() { + this._localBindings = MapWrapper.create(); + for (var [ctxName, tmplName] of MapWrapper.iterable(this.proto.variableBindings)) { + MapWrapper.set(this._localBindings, tmplName, null); + } + } + + setLocal(contextName: string, value) { + if (!this.hydrated()) throw new BaseException('Cannot set locals on dehydrated view.'); + if (!MapWrapper.contains(this.proto.variableBindings, contextName)) { + throw new BaseException( + `Local binding ${contextName} not defined in the view template.`); + } + var templateName = MapWrapper.get(this.proto.variableBindings, contextName); + this.context.set(templateName, value); + } + + hydrated() { + return isPresent(this.context); + } + + _hydrateContext(newContext) { + if (isPresent(this._localBindings)) { + newContext = new ContextWithVariableBindings(newContext, this._localBindings); + } + this.recordRange.setContext(newContext); + this.context = newContext; + } + + _dehydrateContext() { + if (isPresent(this._localBindings)) { + this.context.clearValues(); + } + this.context = null; + } + + /** + * A dehydrated view is a state of the view that allows it to be moved around + * the view tree, without incurring the cost of recreating the underlying + * injectors and watch records. + * + * A dehydrated view has the following properties: + * + * - all element injectors are empty. + * - all appInjectors are released. + * - all viewports are empty. + * - all context locals are set to null. + * - the view context is null. + * + * A call to hydrate/dehydrate does not attach/detach the view from the view + * tree. + */ + hydrate(appInjector: Injector, hostElementInjector: ElementInjector, + context: Object) { + if (isBlank(this.preBuiltObjects)) { + throw new BaseException('Cannot hydrate a view without pre-built objects.'); + } + this._hydrateContext(context); + + var shadowDomAppInjectors = View._createShadowDomInjectors( + this.proto, appInjector); + + this._hydrateViewPorts(appInjector, hostElementInjector); + this._instantiateDirectives(appInjector, shadowDomAppInjectors); + this._hydrateChildComponentViews(appInjector, shadowDomAppInjectors); + } + + dehydrate() { + // preserve the opposite order of the hydration process. + if (isPresent(this.componentChildViews)) { + for (var i = 0; i < this.componentChildViews.length; i++) { + this.componentChildViews[i].dehydrate(); + } + } + for (var i = 0; i < this.elementInjectors.length; i++) { + this.elementInjectors[i].clearDirectives(); + } + if (isPresent(this.viewPorts)) { + for (var i = 0; i < this.viewPorts.length; i++) { + this.viewPorts[i].dehydrate(); + } + } + this._dehydrateContext(); + } + + static _createShadowDomInjectors(protoView, defaultInjector) { + var binders = protoView.elementBinders; + var shadowDomAppInjectors = ListWrapper.createFixedSize(binders.length); + for (var i = 0; i < binders.length; ++i) { + var componentDirective = binders[i].componentDirective; + if (isPresent(componentDirective)) { + var services = componentDirective.annotation.componentServices; + if (isPresent(services)) + shadowDomAppInjectors[i] = defaultInjector.createChild(services); + else { + shadowDomAppInjectors[i] = defaultInjector; + } + } else { + shadowDomAppInjectors[i] = null; + } + } + return shadowDomAppInjectors; } onRecordChange(groupMemento, records:List) { @@ -108,12 +225,35 @@ export class View { this.recordRange.addRange(childView.recordRange); } - addViewPortChildView(childView: View) { - this.recordRange.addRange(childView.recordRange); + _instantiateDirectives( + lightDomAppInjector: Injector, shadowDomAppInjectors) { + for (var i = 0; i < this.elementInjectors.length; ++i) { + var injector = this.elementInjectors[i]; + if (injector != null) { + injector.instantiateDirectives( + lightDomAppInjector, shadowDomAppInjectors[i], this.preBuiltObjects[i]); + } + } } - removeViewPortChildView(childView: View) { - childView.recordRange.remove(); + _hydrateViewPorts(appInjector, hostElementInjector) { + if (isBlank(this.viewPorts)) return; + for (var i = 0; i < this.viewPorts.length; i++) { + this.viewPorts[i].hydrate(appInjector, hostElementInjector); + } + } + + _hydrateChildComponentViews(appInjector, shadowDomAppInjectors) { + var count = 0; + for (var i = 0; i < shadowDomAppInjectors.length; i++) { + var shadowDomInjector = shadowDomAppInjectors[i]; + var injector = this.elementInjectors[i]; + // replace with protoView.binder. + if (isPresent(shadowDomAppInjectors[i])) { + this.componentChildViews[count++].hydrate(shadowDomInjector, + injector, injector.getComponent()); + } + } } } @@ -135,8 +275,10 @@ export class ProtoView { this.elementsWithBindingCount = 0; } - instantiate(context, lightDomAppInjector:Injector, - hostElementInjector: ElementInjector, inPlace:boolean = false):View { + // TODO(rado): hostElementInjector should be moved to hydrate phase. + // TODO(rado): inPlace is only used for bootstrapping, invastigate whether we can bootstrap without + // rootProtoView. + instantiate(hostElementInjector: ElementInjector, inPlace:boolean = false):View { var clone = inPlace ? this.element : DOM.clone(this.element); var elements; if (clone instanceof TemplateElement) { @@ -157,7 +299,7 @@ export class ProtoView { var rootElementInjectors = ProtoView._rootElementInjectors(elementInjectors); var textNodes = ProtoView._textNodes(elements, binders); var bindElements = ProtoView._bindElements(elements, binders); - var shadowAppInjectors = ProtoView._createShadowAppInjectors(binders, lightDomAppInjector); + var viewNodes; if (clone instanceof TemplateElement) { @@ -165,14 +307,13 @@ export class ProtoView { } else { viewNodes = [clone]; } - var view = new View(viewNodes, elementInjectors, rootElementInjectors, textNodes, - bindElements, this.protoRecordRange, context); + var view = new View(this, viewNodes, elementInjectors, rootElementInjectors, textNodes, + bindElements, this.protoRecordRange); + + view.preBuiltObjects = ProtoView._createPreBuiltObjects(view, elementInjectors, elements, binders); - ProtoView._instantiateDirectives( - view, elements, binders, elementInjectors, lightDomAppInjector, - shadowAppInjectors, hostElementInjector); ProtoView._instantiateChildComponentViews(view, elements, binders, - elementInjectors, shadowAppInjectors); + elementInjectors); return view; } @@ -258,10 +399,8 @@ export class ProtoView { return injectors; } - static _instantiateDirectives( - view, elements:List, binders: List, injectors:List, - lightDomAppInjector: Injector, shadowDomAppInjectors:List, - hostElementInjector: ElementInjector) { + static _createPreBuiltObjects(view, injectors, elements, binders) { + var preBuiltObjects = ListWrapper.createFixedSize(binders.length); for (var i = 0; i < injectors.length; ++i) { var injector = injectors[i]; if (injector != null) { @@ -271,16 +410,17 @@ export class ProtoView { var viewPort = null; if (isPresent(binder.templateDirective)) { viewPort = new ViewPort(view, element, binder.nestedProtoView, injector); - viewPort.attach(lightDomAppInjector, hostElementInjector); view.addViewPort(viewPort); } - var preBuiltObjs = new PreBuiltObjects(view, ngElement, viewPort); - injector.instantiateDirectives( - lightDomAppInjector, shadowDomAppInjectors[i], preBuiltObjs); + preBuiltObjects[i] = new PreBuiltObjects(view, ngElement, viewPort); + } else { + preBuiltObjects[i] = null; } } + return preBuiltObjects; } + static _rootElementInjectors(injectors) { return ListWrapper.filter(injectors, inj => isPresent(inj) && isBlank(inj.parent)); } @@ -313,13 +453,12 @@ export class ProtoView { } static _instantiateChildComponentViews(view: View, elements, binders, - injectors, shadowDomAppInjectors: List) { + injectors) { for (var i = 0; i < binders.length; ++i) { var binder = binders[i]; if (isPresent(binder.componentDirective)) { var injector = injectors[i]; - var childView = binder.nestedProtoView.instantiate( - injector.getComponent(), shadowDomAppInjectors[i], injector); + var childView = binder.nestedProtoView.instantiate(injectors[i]); view.addComponentChildView(childView); var shadowRoot = elements[i].createShadowRoot(); ViewPort.moveViewNodesIntoParent(shadowRoot, childView); @@ -327,21 +466,6 @@ export class ProtoView { } } - static _createShadowAppInjectors(binders: List, lightDomAppInjector: Injector): List { - var injectors = ListWrapper.createFixedSize(binders.length); - for (var i = 0; i < binders.length; ++i) { - var componentDirective = binders[i].componentDirective; - if (isPresent(componentDirective)) { - var services = componentDirective.annotation.componentServices; - injectors[i] = isPresent(services) ? - lightDomAppInjector.createChild(services) : lightDomAppInjector; - } else { - injectors[i] = null; - } - } - return injectors; - } - // Create a rootView as if the compiler encountered , // and the component template is already compiled into protoView. // Used for bootstrapping. diff --git a/modules/core/src/compiler/viewport.js b/modules/core/src/compiler/viewport.js index cb1bf550d5..fa6ac0a529 100644 --- a/modules/core/src/compiler/viewport.js +++ b/modules/core/src/compiler/viewport.js @@ -29,14 +29,17 @@ export class ViewPort { this.hostElementInjector = null; } - attach(appInjector: Injector, hostElementInjector: ElementInjector) { + hydrate(appInjector: Injector, hostElementInjector: ElementInjector) { this.appInjector = appInjector; this.hostElementInjector = hostElementInjector; } - detach() { + dehydrate() { this.appInjector = null; this.hostElementInjector = null; + for (var i = 0; i < this._views.length; i++) { + this.remove(i); + } } get(index: number): View { @@ -52,19 +55,18 @@ export class ViewPort { return ListWrapper.last(this._views[index - 1].nodes); } - get detached() { - return isBlank(this.appInjector); + hydrated() { + return isPresent(this.appInjector); } // TODO(rado): profile and decide whether bounds checks should be added // to the methods below. create(atIndex=-1): View { - if (this.detached) throw new BaseException( - 'Cannot create views on a detached view port'); - // TODO(rado): replace curried defaultProtoView.instantiate(appInjector, - // hostElementInjector) with ViewFactory. - var newView = this.defaultProtoView.instantiate( - null, this.appInjector, this.hostElementInjector); + if (!this.hydrated()) throw new BaseException( + 'Cannot create views on a dehydrated view port'); + // TODO(rado): replace with viewFactory. + var newView = this.defaultProtoView.instantiate(this.hostElementInjector); + newView.hydrate(this.appInjector, this.hostElementInjector, this.parentView.context); return this.insert(newView, atIndex); } @@ -72,7 +74,7 @@ export class ViewPort { if (atIndex == -1) atIndex = this._views.length; ListWrapper.insert(this._views, atIndex, view); ViewPort.moveViewNodesAfterSibling(this._siblingToInsertAfter(atIndex), view); - this.parentView.addViewPortChildView(view); + this.parentView.recordRange.addRange(view.recordRange); this._linkElementInjectors(view); return view; } @@ -82,7 +84,7 @@ export class ViewPort { var removedView = this.get(atIndex); ListWrapper.removeAt(this._views, atIndex); ViewPort.removeViewNodesFromParent(this.templateElement.parentNode, removedView); - this.parentView.removeViewPortChildView(removedView); + removedView.recordRange.remove(); this._unlinkElementInjectors(removedView); return removedView; } diff --git a/modules/core/test/compiler/element_injector_spec.js b/modules/core/test/compiler/element_injector_spec.js index a898acb036..8ebf091c4f 100644 --- a/modules/core/test/compiler/element_injector_spec.js +++ b/modules/core/test/compiler/element_injector_spec.js @@ -12,7 +12,7 @@ import {NgElement} from 'core/dom/element'; //TODO: vsavkin: use a spy object class DummyView extends View { constructor() { - super(null, null, null, null, null, new ProtoRecordRange(), null); + super(null, null, null, null, null, null, new ProtoRecordRange()); } } @@ -150,6 +150,16 @@ export function main() { }); }); + describe("hasInstances", function () { + it("should be false when no directives are instantiated", function () { + expect(injector([]).hasInstances()).toBe(false); + }); + + it("should be true when directives are instantiated", function () { + expect(injector([Directive]).hasInstances()).toBe(true); + }); + }); + describe("instantiateDirectives", function () { it("should instantiate directives that have no dependencies", function () { var inj = injector([Directive]); diff --git a/modules/core/test/compiler/integration_spec.js b/modules/core/test/compiler/integration_spec.js index 7901e1d083..3588aa9ad4 100644 --- a/modules/core/test/compiler/integration_spec.js +++ b/modules/core/test/compiler/integration_spec.js @@ -14,6 +14,7 @@ import {Decorator, Component, Template} from 'core/annotations/annotations'; import {TemplateConfig} from 'core/annotations/template_config'; import {ViewPort} from 'core/compiler/viewport'; +import {MapWrapper} from 'facade/collection'; export function main() { describe('integration tests', function() { @@ -27,7 +28,8 @@ export function main() { var view, ctx, cd; function createView(pv) { ctx = new MyComp(); - view = pv.instantiate(ctx, new Injector([]), null); + view = pv.instantiate(null); + view.hydrate(new Injector([]), null, ctx); cd = new ChangeDetector(view.recordRange); } @@ -79,7 +81,7 @@ export function main() { }); it('should support template directives via `