diff --git a/packages/core/src/render3/component.ts b/packages/core/src/render3/component.ts index fdb25d51965919..6a43cb3917ccea 100644 --- a/packages/core/src/render3/component.ts +++ b/packages/core/src/render3/component.ts @@ -17,6 +17,7 @@ import {NG_HOST_SYMBOL, createError, createLView, createTView, directiveCreate, import {ComponentDef, ComponentType} from './interfaces/definition'; import {LElementNode} from './interfaces/node'; import {RElement, Renderer3, RendererFactory3, domRendererFactory3} from './interfaces/renderer'; +import {RootContext} from './interfaces/view'; import {notImplemented, stringify} from './util'; @@ -43,6 +44,19 @@ export interface CreateComponentOptions { * Example: PublicFeature is a function that makes the component public to the DI system. */ features?: ((component: T, componentDef: ComponentDef) => void)[]; + + /** + * A function which is used to schedule change detection work in the future. + * + * When marking components as dirty, it is necessary to schedule the work of + * change detection in the future. This is done to coalesce multiple + * {@link markDirty} calls into a single changed detection processing. + * + * The default value of the scheduler is the `requestAnimationFrame` function. + * + * It is also useful to override this function for testing purposes. + */ + scheduler?: (work: () => void) => void; } @@ -155,11 +169,22 @@ export const NULL_INJECTOR: Injector = { } }; +/** + * A permanent marker promise which signifies that the current CD tree is + * clean. + */ +const CLEAN_PROMISE = Promise.resolve(null); /** * Bootstraps a Component into an existing host element and returns an instance * of the component. * + * Use this function to bootstrap a component into the DOM tree. Each invocation + * of this function will create a separate tree of components, injectors and + * change detection cycles and lifetimes. To dynamically insert a new component + * into an existing tree such that it shares the same injection, change detection + * and object lifetime, use {@link ViewContainer#createComponent}. + * * @param componentType Component to bootstrap * @param options Optional parameters which control bootstrapping */ @@ -170,15 +195,23 @@ export function renderComponent( if (componentDef.type != componentType) componentDef.type = componentType; let component: T; const hostNode = locateHostElement(rendererFactory, opts.host || componentDef.tag); + const rootContext: RootContext = { + // Incomplete initialization due to circular reference. + component: null !, + scheduler: opts.scheduler || requestAnimationFrame, + clean: CLEAN_PROMISE, + }; const oldView = enterView( createLView( - -1, rendererFactory.createRenderer(hostNode, componentDef.rendererType), createTView()), + -1, rendererFactory.createRenderer(hostNode, componentDef.rendererType), createTView(), + null, rootContext), null !); try { // Create element node at index 0 in data array hostElement(hostNode, componentDef); // Create directive instance with n() and store at index 1 in data array (el is 0) - component = getDirectiveInstance(directiveCreate(1, componentDef.n(), componentDef)); + component = rootContext.component = + getDirectiveInstance(directiveCreate(1, componentDef.n(), componentDef)); } finally { leaveView(oldView); } @@ -188,27 +221,120 @@ export function renderComponent( return component; } -export function detectChanges(component: T) { - ngDevMode && assertNotNull(component, 'detectChanges should be called with a component'); - const hostNode = (component as any)[NG_HOST_SYMBOL] as LElementNode; - if (ngDevMode && !hostNode) { - createError('Not a directive instance', component); - } +/** + * Synchronously perform change detection on a component (and possibly its sub-components). + * + * This function triggers change detection in a synchronous way on a component. There should + * be very little reason to call this function directly since a preferred way to do change + * detection is to {@link markDirty} the component and wait for the scheduler to call this method + * at some future point in time. This is because a single user action often results in many + * components being invalidated and calling change detection on each component synchronously + * would be inefficient. It is better to wait until all components are marked as dirty and + * then perform single change detection across all of the components + * + * @param component The component which the change detection should be performed on. + */ +export function detectChanges(component: T): void { + const hostNode = _getComponentHostLElementNode(component); ngDevMode && assertNotNull(hostNode.data, 'Component host node should be attached to an LView'); renderComponentOrTemplate(hostNode, hostNode.view, component); - isDirty = false; } -let isDirty = false; -export function markDirty( - component: T, scheduler: (fn: () => void) => void = requestAnimationFrame) { - ngDevMode && assertNotNull(component, 'markDirty should be called with a component'); - if (!isDirty) { - isDirty = true; - scheduler(() => detectChanges(component)); +/** + * Mark the component as dirty (needing change detection). + * + * Marking a component dirty will schedule a change detection on this + * component at some point in the future. Marking an already dirty + * component as dirty is a noop. Only one outstanding change detection + * can be scheduled per component tree. (Two components bootstrapped with + * separate `renderComponent` will have separate schedulers) + * + * When the root component is bootstrapped with `renderComponent` a scheduler + * can be provided. + * + * @param component Component to mark as dirty. + */ +export function markDirty(component: T) { + const rootContext = getRootContext(component); + if (rootContext.clean == CLEAN_PROMISE) { + let res: null|((val: null) => void); + rootContext.clean = new Promise((r) => res = r); + rootContext.scheduler(() => { + detectChanges(rootContext.component); + res !(null); + rootContext.clean = CLEAN_PROMISE; + }); } } -export function getHostElement(component: T): RElement { - return ((component as any)[NG_HOST_SYMBOL] as LElementNode).native; +/** + * Retrieve the root component of any component by walking the parent `LView` until + * reaching the root `LView`. + * + * @param component any component + */ +function getRootContext(component: any): RootContext { + ngDevMode && assertNotNull(component, 'component'); + const lElementNode = _getComponentHostLElementNode(component); + let lView = lElementNode.view; + while (lView.parent) { + lView = lView.parent; + } + const rootContext = lView.context as RootContext; + ngDevMode && assertNotNull(rootContext, 'rootContext'); + return rootContext; +} + +function _getComponentHostLElementNode(component: T): LElementNode { + ngDevMode && assertNotNull(component, 'expecting component got null'); + const lElementNode = (component as any)[NG_HOST_SYMBOL] as LElementNode; + ngDevMode && assertNotNull(component, 'object is not a component'); + return lElementNode; +} + +/** + * Retrieve the host element of the component. + * + * Use this function to retrieve the host element of the component. The host + * element is the element which the component is associated with. + * + * @param component Component for which the host element should be retrieved. + */ +export function getHostElement(component: T): HTMLElement { + return _getComponentHostLElementNode(component).native as any; +} + +/** + * Retrieves the rendered text for a given component. + * + * This function retrieves the host element of a component and + * and then returns the `textContent` for that element. This implies + * that the text returned will include re-projected content of + * the component as well. + * + * @param component The component to return the content text for. + */ +export function getRenderedText(component: any): string { + const hostElement = getHostElement(component); + return hostElement.textContent || ''; +} + +/** + * Wait on component until it is rendered. + * + * This function returns a `Promise` which is resolved when the component's + * change detection is executed. This is determined by finding the scheduler + * associated with the `component`'s render tree and waiting until the scheduler + * flushes. If nothing is scheduled, the function returns a resolved promise. + * + * Example: + * ``` + * await whenRendered(myComponent); + * ``` + * + * @param component Component to wait upon + * @returns Promise which resolves when the component is rendered. + */ +export function whenRendered(component: any): Promise { + return getRootContext(component).clean; } diff --git a/packages/core/src/render3/index.ts b/packages/core/src/render3/index.ts index 69861de784f809..670c2da6109a68 100644 --- a/packages/core/src/render3/index.ts +++ b/packages/core/src/render3/index.ts @@ -6,12 +6,14 @@ * found in the LICENSE file at https://angular.io/license */ -import {createComponentRef, detectChanges, getHostElement, markDirty, renderComponent} from './component'; +import {createComponentRef, detectChanges, getHostElement, getRenderedText, markDirty, renderComponent, whenRendered} from './component'; import {NgOnChangesFeature, PublicFeature, defineComponent, defineDirective, definePipe} from './definition'; import {InjectFlags} from './di'; import {ComponentDef, ComponentTemplate, ComponentType, DirectiveDef, DirectiveDefFlags, DirectiveType} from './interfaces/definition'; export {InjectFlags, QUERY_READ_CONTAINER_REF, QUERY_READ_ELEMENT_REF, QUERY_READ_FROM_NODE, QUERY_READ_TEMPLATE_REF, inject, injectElementRef, injectTemplateRef, injectViewContainerRef} from './di'; +export {CssSelector} from './interfaces/projection'; + // Naming scheme: // - Capital letters are for creating things: T(Text), E(Element), D(Directive), V(View), @@ -106,6 +108,11 @@ export { defineComponent, defineDirective, definePipe, + detectChanges, + createComponentRef, + getHostElement, + getRenderedText, + markDirty, + renderComponent, + whenRendered, }; -export {createComponentRef, detectChanges, getHostElement, markDirty, renderComponent}; -export {CssSelector} from './interfaces/projection'; diff --git a/packages/core/src/render3/instructions.ts b/packages/core/src/render3/instructions.ts index 0865bc6e2c7048..819d200f2b892b 100644 --- a/packages/core/src/render3/instructions.ts +++ b/packages/core/src/render3/instructions.ts @@ -70,10 +70,15 @@ let isParent: boolean; */ let tData: TData; -/** State of the current view being processed. */ -let currentView: LView; -// The initialization has to be after the `let`, otherwise `createLView` can't see `let`. -currentView = createLView(null !, null !, createTView()); +/** + * State of the current view being processed. + * + * NOTE: we cheat here and initialize it to `null` even thought the type does not + * contain `null`. This is because we expect this value to be not `null` as soon + * as we enter the view. Declaring the type as `null` would require us to place `!` + * in most instructions since they all assume that `currentView` is defined. + */ +let currentView: LView = null !; let currentQueries: LQueries|null; @@ -131,13 +136,13 @@ const enum BindingDirection { */ export function enterView(newView: LView, host: LElementNode | LViewNode | null): LView { const oldView = currentView; - data = newView.data; - bindingIndex = newView.bindingStartIndex || 0; - tData = newView.tView.data; - creationMode = newView.creationMode; + data = newView && newView.data; + bindingIndex = newView && newView.bindingStartIndex || 0; + tData = newView && newView.tView.data; + creationMode = newView && newView.creationMode; - cleanup = newView.cleanup; - renderer = newView.renderer; + cleanup = newView && newView.cleanup; + renderer = newView && newView.renderer; if (host != null) { previousOrParentNode = host; @@ -145,7 +150,7 @@ export function enterView(newView: LView, host: LElementNode | LViewNode | null) } currentView = newView; - currentQueries = newView.queries; + currentQueries = newView && newView.queries; return oldView !; } @@ -165,8 +170,8 @@ export function leaveView(newView: LView): void { } export function createLView( - viewId: number, renderer: Renderer3, tView: TView, - template: ComponentTemplate| null = null, context: any | null = null): LView { + viewId: number, renderer: Renderer3, tView: TView, template: ComponentTemplate| null, + context: any | null): LView { const newView = { parent: currentView, id: viewId, // -1 for component views @@ -300,7 +305,8 @@ export function renderTemplate( host = createLNode( null, LNodeFlags.Element, hostNode, createLView( - -1, providedRendererFactory.createRenderer(null, null), getOrCreateTView(template))); + -1, providedRendererFactory.createRenderer(null, null), getOrCreateTView(template), + null, null)); } const hostView = host.data !; ngDevMode && assertNotNull(hostView, 'Host node should have an LView defined in host.data.'); @@ -406,7 +412,8 @@ export function elementStart( if (isHostElement) { const tView = getOrCreateTView(hostComponentDef !.template); componentView = addToViewTree(createLView( - -1, rendererFactory.createRenderer(native, hostComponentDef !.rendererType), tView)); + -1, rendererFactory.createRenderer(native, hostComponentDef !.rendererType), tView, + null, null)); } // Only component views should be added to the view tree directly. Embedded views are @@ -556,7 +563,8 @@ export function locateHostElement( export function hostElement(rNode: RElement | null, def: ComponentDef) { resetApplicationState(); createLNode( - 0, LNodeFlags.Element, rNode, createLView(-1, renderer, getOrCreateTView(def.template))); + 0, LNodeFlags.Element, rNode, + createLView(-1, renderer, getOrCreateTView(def.template), null, null)); } @@ -1114,8 +1122,8 @@ export function embeddedViewStart(viewBlockId: number): boolean { enterView((existingView as LViewNode).data, previousOrParentNode as LViewNode); } else { // When we create a new LView, we always reset the state of the instructions. - const newView = - createLView(viewBlockId, renderer, getOrCreateEmbeddedTView(viewBlockId, container)); + const newView = createLView( + viewBlockId, renderer, getOrCreateEmbeddedTView(viewBlockId, container), null, null); if (lContainer.queries) { newView.queries = lContainer.queries.enterView(lContainer.nextIndex); } diff --git a/packages/core/src/render3/interfaces/view.ts b/packages/core/src/render3/interfaces/view.ts index d64a48922e0719..89d32647556b8e 100644 --- a/packages/core/src/render3/interfaces/view.ts +++ b/packages/core/src/render3/interfaces/view.ts @@ -160,9 +160,11 @@ export interface LView { template: ComponentTemplate<{}>|null; /** - * For embedded views, the context with which to render the template. + * - For embedded views, the context with which to render the template. + * - For root view of the root component the context contains change detection data. + * - `null` otherwise. */ - context: {}|null; + context: {}|RootContext|null; /** * A count of dynamic views that are children of this view (indirectly via containers). @@ -261,6 +263,31 @@ export interface TView { destroyHooks: HookData|null; } +/** + * RootContext contains information which is shared for all components which + * were bootstrapped with {@link renderComponent}. + */ +export interface RootContext { + /** + * A function used for scheduling change detection in the future. Usually + * this is `requestAnimationFrame`. + */ + scheduler: (workFn: () => void) => void; + + /** + * A promise which is resolved when all components are considered clean (not dirty). + * + * This promise is overwritten every time a first call to {@link markDirty} is invoked. + */ + clean: Promise; + + /** + * RootComponent - The component which was instantiated by the call to + * {@link renderComponent}. + */ + component: {}; +} + /** * Array of hooks that should be executed for a view and their directive indices. * diff --git a/packages/core/test/bundling/hello_world/BUILD.bazel b/packages/core/test/bundling/hello_world/BUILD.bazel index 5954284f1807e2..897b7f7f56f086 100644 --- a/packages/core/test/bundling/hello_world/BUILD.bazel +++ b/packages/core/test/bundling/hello_world/BUILD.bazel @@ -34,6 +34,7 @@ ts_library( srcs = ["domino_typings.d.ts"] + glob(["*_spec.ts"]), deps = [ "//packages:types", + "//packages/core/testing", ], ) diff --git a/packages/core/test/bundling/hello_world/bundle.golden_symbols.json b/packages/core/test/bundling/hello_world/bundle.golden_symbols.json index 22e9147b83fe2c..d5c3e8f52c213f 100644 --- a/packages/core/test/bundling/hello_world/bundle.golden_symbols.json +++ b/packages/core/test/bundling/hello_world/bundle.golden_symbols.json @@ -1,4 +1,7 @@ [ + { + "name": "CLEAN_PROMISE" + }, { "name": "EMPTY$1" }, @@ -32,6 +35,9 @@ { "name": "createLView" }, + { + "name": "currentView" + }, { "name": "domRendererFactory3" }, diff --git a/packages/core/test/bundling/hello_world/treeshaking_spec.ts b/packages/core/test/bundling/hello_world/treeshaking_spec.ts index 69a6c7d256f202..de629388cfaea1 100644 --- a/packages/core/test/bundling/hello_world/treeshaking_spec.ts +++ b/packages/core/test/bundling/hello_world/treeshaking_spec.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ +import {withBody} from '@angular/core/testing'; import * as fs from 'fs'; import * as path from 'path'; @@ -32,39 +33,22 @@ describe('treeshaking with uglify', () => { it('should not contain zone.js', () => { expect(content).not.toContain('scheduleMicroTask'); }); describe('functional test in domino', () => { - let document: Document; - - beforeEach(() => { - const window = domino.createWindow('', 'http://localhost'); - (global as any).document = document = window.document; - // Trick to avoid Event patching from - // https://github.com/angular/angular/blob/7cf5e95ac9f0f2648beebf0d5bd9056b79946970/packages/platform-browser/src/dom/events/dom_events.ts#L112-L132 - // It fails with Domino with TypeError: Cannot assign to read only property - // 'stopImmediatePropagation' of object '#' - (global as any).Event = null; - - document.body.innerHTML = ''; - }); - - afterEach(() => { - (global as any).document = undefined; - (global as any).Element = undefined; - }); - - - it('should render hello world when not minified', () => { - require(path.join(PACKAGE, 'bundle.js')); - expect(document.body.textContent).toEqual('Hello World!'); - }); - - it('should render hello world when debug minified', () => { - require(path.join(PACKAGE, 'bundle.min_debug.js')); - expect(document.body.textContent).toEqual('Hello World!'); - }); - - it('should render hello world when fully minified', () => { - require(path.join(PACKAGE, 'bundle.min.js')); - expect(document.body.textContent).toEqual('Hello World!'); - }); + it('should render hello world when not minified', + withBody('', () => { + require(path.join(PACKAGE, 'bundle.js')); + expect(document.body.textContent).toEqual('Hello World!'); + })); + + it('should render hello world when debug minified', + withBody('', () => { + require(path.join(PACKAGE, 'bundle.min_debug.js')); + expect(document.body.textContent).toEqual('Hello World!'); + })); + + it('should render hello world when fully minified', + withBody('', () => { + require(path.join(PACKAGE, 'bundle.min.js')); + expect(document.body.textContent).toEqual('Hello World!'); + })); }); }); \ No newline at end of file diff --git a/packages/core/test/render3/BUILD.bazel b/packages/core/test/render3/BUILD.bazel index 79b074e72995d4..222540d20f3e54 100644 --- a/packages/core/test/render3/BUILD.bazel +++ b/packages/core/test/render3/BUILD.bazel @@ -24,6 +24,7 @@ ts_library( "//packages/animations/browser/testing", "//packages/common", "//packages/core", + "//packages/core/testing", "//packages/platform-browser", "//packages/platform-browser/animations", "//packages/platform-browser/testing", diff --git a/packages/core/test/render3/compiler_canonical/normative.md b/packages/core/test/render3/compiler_canonical/normative.md new file mode 100644 index 00000000000000..96653d9d745f8a --- /dev/null +++ b/packages/core/test/render3/compiler_canonical/normative.md @@ -0,0 +1,5 @@ +This folder contains canonical examples of how the Ivy compiler translates annotations into code + +- The specs are marked with `NORMATIVE` => `/NORMATIVE` comments which designates what the compiler is expected to generate. +- All local variable names are considered non-normative (informative). + diff --git a/packages/core/test/render3/compiler_canonical/small_app_spec.ts b/packages/core/test/render3/compiler_canonical/small_app_spec.ts new file mode 100644 index 00000000000000..c551db697f8d58 --- /dev/null +++ b/packages/core/test/render3/compiler_canonical/small_app_spec.ts @@ -0,0 +1,199 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {NgForOf, NgForOfContext} from '@angular/common'; +import {Component, ContentChild, Directive, EventEmitter, Injectable, Input, NgModule, OnDestroy, Optional, Output, Pipe, PipeTransform, QueryList, SimpleChanges, TemplateRef, Type, ViewChild, ViewContainerRef} from '@angular/core'; +import {withBody} from '@angular/core/testing'; + +import * as r3 from '../../../src/render3/index'; + + + +// TODO: remove once https://github.com/angular/angular/pull/22005 lands +export class pending_pull_22005 { + static defineInjectable({scope, factory}: {scope?: Type, factory: () => T}): + {scope: Type| null, factory: () => T} { + return {scope: scope || null, factory: factory}; + } + + static defineInjector({factory, providers}: {factory: () => T, providers: any[]}): + {factory: () => T, providers: any[]} { + return {factory: factory, providers: providers}; + } +} + + + +interface ToDo { + text: string; + done: boolean; +} + +@Injectable() +class AppState { + todos: ToDo[] = [ + {text: 'Demonstrate Components', done: false}, + {text: 'Demonstrate Structural Directives', done: false}, + {text: 'Demonstrate NgModules', done: false}, + {text: 'Demonstrate zoneless changed detection', done: false}, + {text: 'Demonstrate internationalization', done: false}, + ]; + + // NORMATIVE + static ngInjectableDef = pending_pull_22005.defineInjectable({factory: () => new AppState()}); + // /NORMATIVE +} + +@Component({ + selector: 'todo-app', + template: ` +

ToDo Application

+
+ +
+ count: {{appState.todos.length}}. + ` +}) +class ToDoAppComponent { + constructor(public appState: AppState) {} + + onArchive(item: ToDo) { + const todos = this.appState.todos; + todos.splice(todos.indexOf(item)); + r3.markDirty(this); + } + + // NORMATIVE + static ngComponentDef = r3.defineComponent({ + type: ToDoAppComponent, + tag: 'todo-app', + factory: function ToDoAppComponent_Factory() { + return new ToDoAppComponent(r3.inject(AppState)); + }, + template: function ToDoAppComponent_Template(ctx: ToDoAppComponent, cm: boolean) { + if (cm) { + const ToDoAppComponent_NgForOf_Template = function ToDoAppComponent_NgForOf_Template( + ctx1: NgForOfContext, cm: boolean) { + if (cm) { + r3.E(0, ToDoItemComponent); + r3.L('archive', ctx.onArchive.bind(ctx)); + r3.e(); + } + r3.p(0, 'todo', r3.b(ctx1.$implicit)); + }; + r3.E(0, 'h1'); + r3.T(1, 'ToDo Application'); + r3.e(); + r3.E(2, 'div'); + r3.C(3, c3_directives, ToDoAppComponent_NgForOf_Template); + r3.e(); + r3.E(4, 'span'); + r3.T(5); + r3.e(); + } + r3.t(5, r3.i1('count: ', ctx.appState.todos.length, '')); + } + }); + // /NORMATIVE +} + +// NORMATIVE +const c3_directives = [NgForOf as r3.DirectiveType>]; +// /NORMATIVE + +@Component({ + selector: 'todo', + template: ` +
+ + {{todo.text}} + +
+ ` +}) +class ToDoItemComponent { + static DEFAULT_TODO: ToDo = {text: '', done: false}; + + @Input() + todo: ToDo = ToDoItemComponent.DEFAULT_TODO; + + @Output() + archive = new EventEmitter(); + + onCheckboxClick() { + this.todo.done = !this.todo.done; + r3.markDirty(this); + } + + onArchiveClick() { this.archive.emit(this.todo); } + + // NORMATIVE + static ngComponentDef = r3.defineComponent({ + type: ToDoItemComponent, + tag: 'todo', + factory: function ToDoItemComponent_Factory() { return new ToDoItemComponent(); }, + template: function ToDoItemComponent_Template(ctx: ToDoItemComponent, cm: boolean) { + if (cm) { + r3.E(0, 'div'); + r3.E(1, 'input', e1_attrs); + r3.L('click', ctx.onCheckboxClick.bind(ctx)); + r3.e(); + r3.E(2, 'span'); + r3.T(3); + r3.e(); + r3.E(4, 'button'); + r3.L('click', ctx.onArchiveClick.bind(ctx)); + r3.T(5, 'archive'); + r3.e(); + r3.e(); + } + r3.p(1, 'value', r3.b(ctx.todo.done)); + r3.t(3, r3.b(ctx.todo.text)); + }, + inputs: {todo: 'todo'}, + }); + // /NORMATIVE +} +// NORMATIVE +const e1_attrs = ['type', 'checkbox']; +// /NORMATIVE + + +@NgModule({ + declarations: [ToDoAppComponent, ToDoItemComponent], + providers: [AppState], +}) +class ToDoAppModule { + // NORMATIVE + static ngInjectorDef = pending_pull_22005.defineInjector({ + factory: () => new ToDoAppModule(), + providers: [AppState], + }); + // /NORMATIVE +} + + +describe('small_app', () => { + xit('should render', + () => withBody('', async() => { + // TODO: Implement this method once all of the pieces of this application can execute. + // TODO: add i18n example by translating to french. + const todoApp = r3.renderComponent(ToDoAppComponent); + await r3.whenRendered(todoApp); + expect(r3.getRenderedText(todoApp)).toEqual('...'); + const firstCheckBox = + r3.getHostElement(todoApp).querySelector('input[type=checkbox]') as HTMLElement; + firstCheckBox.click(); + await r3.whenRendered(todoApp); + expect(r3.getRenderedText(todoApp)).toEqual('...'); + const firstArchive = r3.getHostElement(todoApp).querySelector('button') as HTMLElement; + firstArchive.click; + await r3.whenRendered(todoApp); + expect(r3.getRenderedText(todoApp)).toEqual('...'); + })); +}); diff --git a/packages/core/test/render3/component_spec.ts b/packages/core/test/render3/component_spec.ts index 7467ec569215ea..5b4f0f61372aa3 100644 --- a/packages/core/test/render3/component_spec.ts +++ b/packages/core/test/render3/component_spec.ts @@ -6,7 +6,10 @@ * found in the LICENSE file at https://angular.io/license */ -import {ViewEncapsulation} from '../../src/core'; +import {withBody} from '@angular/core/testing'; + +import {DoCheck, ViewEncapsulation} from '../../src/core'; +import {detectChanges, getRenderedText, whenRendered} from '../../src/render3/component'; import {defineComponent, markDirty} from '../../src/render3/index'; import {bind, componentRefresh, container, containerRefreshEnd, containerRefreshStart, elementEnd, elementProperty, elementStart, embeddedViewEnd, embeddedViewStart, text, textBinding} from '../../src/render3/instructions'; import {createRendererType2} from '../../src/view/index'; @@ -45,12 +48,12 @@ describe('component', () => { const component = renderComponent(CounterComponent); expect(toHtml(containerEl)).toEqual('0'); component.count = 123; - markDirty(component, requestAnimationFrame); + markDirty(component); expect(toHtml(containerEl)).toEqual('0'); requestAnimationFrame.flush(); expect(toHtml(containerEl)).toEqual('123'); component.increment(); - markDirty(component, requestAnimationFrame); + markDirty(component); expect(toHtml(containerEl)).toEqual('123'); requestAnimationFrame.flush(); expect(toHtml(containerEl)).toEqual('124'); @@ -232,4 +235,69 @@ describe('encapsulation', () => { .toMatch( /
bar<\/span><\/leaf><\/div>/); }); + + describe('markDirty, detectChanges, whenRendered, getRenderedText', () => { + class MyComponent implements DoCheck { + value: string = 'works'; + doCheckCount = 0; + ngDoCheck(): void { this.doCheckCount++; } + + static ngComponentDef = defineComponent({ + type: MyComponent, + tag: 'my-comp', + factory: () => new MyComponent(), + template: (ctx: MyComponent, cm: boolean) => { + if (cm) { + elementStart(0, 'span'); + text(1); + elementEnd(); + } + textBinding(1, bind(ctx.value)); + } + }); + } + + it('should mark a component dirty and schedule change detection', withBody('my-comp', () => { + const myComp = renderComponent(MyComponent); + expect(getRenderedText(myComp)).toEqual('works'); + myComp.value = 'updated'; + markDirty(myComp); + expect(getRenderedText(myComp)).toEqual('works'); + requestAnimationFrame.flush(); + expect(getRenderedText(myComp)).toEqual('updated'); + })); + + it('should detectChanges on a component', withBody('my-comp', () => { + const myComp = renderComponent(MyComponent); + expect(getRenderedText(myComp)).toEqual('works'); + myComp.value = 'updated'; + detectChanges(myComp); + expect(getRenderedText(myComp)).toEqual('updated'); + })); + + it('should detectChanges only once if markDirty is called multiple times', + withBody('my-comp', () => { + const myComp = renderComponent(MyComponent); + expect(getRenderedText(myComp)).toEqual('works'); + expect(myComp.doCheckCount).toBe(1); + myComp.value = 'ignore'; + markDirty(myComp); + myComp.value = 'updated'; + markDirty(myComp); + expect(getRenderedText(myComp)).toEqual('works'); + requestAnimationFrame.flush(); + expect(getRenderedText(myComp)).toEqual('updated'); + expect(myComp.doCheckCount).toBe(2); + })); + + it('should notify whenRendered', withBody('my-comp', async() => { + const myComp = renderComponent(MyComponent); + await whenRendered(myComp); + myComp.value = 'updated'; + markDirty(myComp); + setTimeout(requestAnimationFrame.flush, 0); + await whenRendered(myComp); + expect(getRenderedText(myComp)).toEqual('updated'); + })); + }); }); diff --git a/packages/core/test/render3/di_spec.ts b/packages/core/test/render3/di_spec.ts index 6be88d96e7a84f..b04c90b272f54d 100644 --- a/packages/core/test/render3/di_spec.ts +++ b/packages/core/test/render3/di_spec.ts @@ -324,7 +324,7 @@ describe('di', () => { describe('getOrCreateNodeInjector', () => { it('should handle initial undefined state', () => { - const contentView = createLView(-1, null !, createTView()); + const contentView = createLView(-1, null !, createTView(), null, null); const oldView = enterView(contentView, null !); try { const parent = createLNode(0, LNodeFlags.Element, null, null); diff --git a/packages/core/test/render3/load_domino.ts b/packages/core/test/render3/load_domino.ts index 49e7b406dfd09a..d8aeb80d3e9327 100644 --- a/packages/core/test/render3/load_domino.ts +++ b/packages/core/test/render3/load_domino.ts @@ -27,4 +27,6 @@ if (typeof window == 'undefined') { // For animation tests, see // https://github.com/angular/angular/blob/master/packages/animations/browser/src/render/shared.ts#L140 (global as any).Element = domino.impl.Element; + (global as any).isBrowser = false; + (global as any).isNode = true; } diff --git a/packages/core/test/render3/render_util.ts b/packages/core/test/render3/render_util.ts index 20b8b1e52ba514..7ca0b73d70d4d1 100644 --- a/packages/core/test/render3/render_util.ts +++ b/packages/core/test/render3/render_util.ts @@ -61,8 +61,11 @@ export function renderToHtml( beforeEach(resetDOM); export function renderComponent(type: ComponentType, rendererFactory?: RendererFactory3): T { - return _renderComponent( - type, {rendererFactory: rendererFactory || testRendererFactory, host: containerEl}); + return _renderComponent(type, { + rendererFactory: rendererFactory || testRendererFactory, + host: containerEl, + scheduler: requestAnimationFrame, + }); } export function toHtml(componentOrElement: T | RElement): string { diff --git a/packages/core/test/render3/renderer_factory_spec.ts b/packages/core/test/render3/renderer_factory_spec.ts index 861fecd0696cd3..396744076d8d66 100644 --- a/packages/core/test/render3/renderer_factory_spec.ts +++ b/packages/core/test/render3/renderer_factory_spec.ts @@ -181,7 +181,7 @@ describe('animation renderer factory', () => { expect(toHtml(containerEl)).toEqual('foo'); }); - it('should work with animated components', (done) => { + isBrowser && it('should work with animated components', (done) => { const factory = getAnimationRendererFactory2(document); const component = renderComponent(SomeComponentWithAnimation, factory); expect(toHtml(containerEl)) diff --git a/packages/core/test/render3/testing_spec.ts b/packages/core/test/render3/testing_spec.ts new file mode 100644 index 00000000000000..28e6af9cde4c5c --- /dev/null +++ b/packages/core/test/render3/testing_spec.ts @@ -0,0 +1,48 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {withBody} from '@angular/core/testing'; + +describe('testing', () => { + describe('withBody', () => { + let passed: boolean; + + beforeEach(() => passed = false); + afterEach(() => expect(passed).toEqual(true)); + + it('should set up body', withBody('works!', () => { + expect(document.body.innerHTML).toEqual('works!'); + passed = true; + })); + + it('should support promises', withBody('works!', () => { + return Promise.resolve(true).then(() => passed = true); + })); + + it('should support async and await', withBody('works!', async() => { + await Promise.resolve(true); + passed = true; + })); + }); + + + describe('domino', () => { + it('should have document present', () => { + // In Browser this tests passes, bun we also want to make sure we pass in node.js + // We expect that node.js will load domino for us. + expect(document).toBeTruthy(); + }); + }); + + describe('requestAnimationFrame', () => { + it('should have requestAnimationFrame', (done) => { + // In Browser we have requestAnimationFrame, but verify that we also have it node.js + requestAnimationFrame(done); + }); + }); +}); \ No newline at end of file diff --git a/packages/core/testing/src/fake_async.ts b/packages/core/testing/src/fake_async.ts index 96b3c4cf2d0991..38d7d773f800f9 100644 --- a/packages/core/testing/src/fake_async.ts +++ b/packages/core/testing/src/fake_async.ts @@ -7,12 +7,13 @@ */ -const FakeAsyncTestZoneSpec = (Zone as any)['FakeAsyncTestZoneSpec']; +const _Zone: any = typeof Zone !== 'undefined' ? Zone : null; +const FakeAsyncTestZoneSpec = _Zone && _Zone['FakeAsyncTestZoneSpec']; type ProxyZoneSpec = { setDelegate(delegateSpec: ZoneSpec): void; getDelegate(): ZoneSpec; resetDelegate(): void; }; const ProxyZoneSpec: {get(): ProxyZoneSpec; assertPresent: () => ProxyZoneSpec} = - (Zone as any)['ProxyZoneSpec']; + _Zone && _Zone['ProxyZoneSpec']; let _fakeAsyncTestZoneSpec: any = null; @@ -24,7 +25,8 @@ let _fakeAsyncTestZoneSpec: any = null; */ export function resetFakeAsyncZone() { _fakeAsyncTestZoneSpec = null; - ProxyZoneSpec.assertPresent().resetDelegate(); + // in node.js testing we may not have ProxyZoneSpec in which case there is nothing to reset. + ProxyZoneSpec && ProxyZoneSpec.assertPresent().resetDelegate(); } let _inFakeAsyncCall = false; diff --git a/packages/core/testing/src/render3.ts b/packages/core/testing/src/render3.ts new file mode 100644 index 00000000000000..e665354d0ebbf4 --- /dev/null +++ b/packages/core/testing/src/render3.ts @@ -0,0 +1,115 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +/** +* Wraps a function in a new function which sets up document and HTML for running a test. +* +* This function is intended to wrap an existing testing function. The wrapper +* adds HTML to the `body` element of the `document` and subsequently tears it down. +* +* This function is intended to be used with `async await` and `Promise`s. If the wrapped +* function returns a promise (or is `async`) then the teardown is delayed until that `Promise` +* is resolved. +* +* On `node` this function detects if `document` is present and if not it will create one by +* loading `domino` and installing it. +* +* Example: +* +* ``` +* describe('something', () => { +* it('should do something', withBody('', async () => { +* const myApp = renderComponent(MyApp); +* await whenRendered(myApp); +* expect(getRenderedText(myApp)).toEqual('Hello World!'); +* })); +* }); +* ``` +* +* @param html HTML which should be inserted into `body` of the `document`. +* @param blockFn function to wrap. The function can return promise or be `async`. +* @experimental +*/ +export function withBody(html: string, blockFn: T): T { + return function(done: {(): void, fail(): void}) { + ensureDocument(); + let returnValue: any = undefined; + if (typeof blockFn === 'function') { + document.body.innerHTML = html; + let blockReturn = blockFn(); + if (blockReturn instanceof Promise) { + blockReturn = blockReturn.then(done, done.fail); + } else { + done(); + } + } + } as any; +} + +let savedDocument: Document|undefined = undefined; +let savedRequestAnimationFrame: ((callback: FrameRequestCallback) => number)|undefined = undefined; +let requestAnimationFrameCount = 0; + +/** + * System.js uses regexp to look for `require` statements. `domino` has to be + * extracted into a constant so that the regexp in the System.js does not match + * and does not try to load domino in the browser. + */ +const domino: any = (function(domino) { + if (typeof global == 'object' && global.process && typeof require == 'function') { + try { + return require(domino); + } catch (e) { + // It is possible that we don't have domino available in which case just give up. + } + } + // Seems like we don't have domino, give up. + return null; +})('domino'); + +/** + * Ensure that global has `Document` if we are in node.js + * @experimental + */ +export function ensureDocument(): void { + if (domino) { + // we are in node.js. + const window = domino.createWindow('', 'http://localhost'); + savedDocument = (global as any).document; + (global as any).document = window.document; + // Trick to avoid Event patching from + // https://github.com/angular/angular/blob/7cf5e95ac9f0f2648beebf0d5bd9056b79946970/packages/platform-browser/src/dom/events/dom_events.ts#L112-L132 + // It fails with Domino with TypeError: Cannot assign to read only property + // 'stopImmediatePropagation' of object '#' + (global as any).Event = null; + + savedRequestAnimationFrame = (global as any).requestAnimationFrame; + (global as any).requestAnimationFrame = function(cb: FrameRequestCallback): number { + setImmediate(cb); + return requestAnimationFrameCount++; + }; + } +} + +/** + * Restore the state of `Document` between tests. + * @experimental + */ +export function cleanupDocument(): void { + if (savedDocument) { + (global as any).document = savedDocument; + savedDocument = undefined; + } + if (savedRequestAnimationFrame) { + (global as any).requestAnimationFrame = savedRequestAnimationFrame; + savedRequestAnimationFrame = undefined; + } +} + +if (typeof beforeEach == 'function') beforeEach(ensureDocument); +if (typeof afterEach == 'function') beforeEach(cleanupDocument); \ No newline at end of file diff --git a/packages/core/testing/src/testing.ts b/packages/core/testing/src/testing.ts index 9051a017e28a3c..d2d9476c80e7c6 100644 --- a/packages/core/testing/src/testing.ts +++ b/packages/core/testing/src/testing.ts @@ -19,3 +19,4 @@ export * from './test_bed'; export * from './before_each'; export * from './metadata_override'; export * from './private_export_testing'; +export * from './render3'; diff --git a/packages/core/testing/tsconfig-build.json b/packages/core/testing/tsconfig-build.json index 47785208d0c155..adc2efbeff1ed4 100644 --- a/packages/core/testing/tsconfig-build.json +++ b/packages/core/testing/tsconfig-build.json @@ -14,7 +14,8 @@ "files": [ "public_api.ts", "../../../node_modules/zone.js/dist/zone.js.d.ts", - "../../system.d.ts" + "../../system.d.ts", + "../../types.d.ts" ], "angularCompilerOptions": { diff --git a/tools/public_api_guard/core/testing.d.ts b/tools/public_api_guard/core/testing.d.ts index bb02e167ee97b2..d4c5452bad790a 100644 --- a/tools/public_api_guard/core/testing.d.ts +++ b/tools/public_api_guard/core/testing.d.ts @@ -1,6 +1,9 @@ /** @stable */ export declare function async(fn: Function): (done: any) => any; +/** @experimental */ +export declare function cleanupDocument(): void; + /** @stable */ export declare class ComponentFixture { changeDetectorRef: ChangeDetectorRef; @@ -29,6 +32,9 @@ export declare const ComponentFixtureNoNgZone: InjectionToken; /** @experimental */ export declare function discardPeriodicTasks(): void; +/** @experimental */ +export declare function ensureDocument(): void; + /** @experimental */ export declare function fakeAsync(fn: Function): (...args: any[]) => any; @@ -145,5 +151,8 @@ export declare type TestModuleMetadata = { /** @experimental */ export declare function tick(millis?: number): void; +/** @experimental */ +export declare function withBody(html: string, blockFn: T): T; + /** @experimental */ export declare function withModule(moduleDef: TestModuleMetadata): InjectSetupWrapper;