feat(ivy): avoid unnecessary recompilations in TestBed (#29294)

Prior to this change, we always recompile all Components/Directives/Pipes even if they were AOT-compiled and had no overrides. This is causing problems in case we try to recompile a Component with "templateUrl" or "styleUrls" (which were already resolved in case of AOT) and generally this unnecessary work that TestBed was doing is not required. This commit adds extra logic to check whether a Component/Directive/Pipe already have compiled NG def (like ngComponentDef) and whether there are no overrides present - in this case recompilation is skipped. Recompilation is also skipped in case a Component/Directive has only Provider overrides - in this situation providers resolver function is patched to reflect overrides. Provider overrides are very common in g3, thus this code path ensures no full recompilation.

PR Close #29294
This commit is contained in:
Andrew Kushnir 2019-03-11 10:35:25 -07:00 committed by Matias Niemelä
parent 86aba1e8f3
commit 0244a2433e
11 changed files with 207 additions and 60 deletions

View File

@ -365,3 +365,9 @@ export interface ClassProvider extends ClassSansProvider {
*/
export type Provider = TypeProvider | ValueProvider | ClassProvider | ConstructorProvider |
ExistingProvider | FactoryProvider | any[];
/**
* Describes a function that is used to process provider list (for example in case of provider
* overrides).
*/
export type ProcessProvidersFunction = (providers: Provider[]) => Provider[];

View File

@ -6,6 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/
import {Type} from '../interface/type';
import {Component} from './directives';
@ -42,9 +43,9 @@ import {Component} from './directives';
* contents of the resolved URL. Browser's `fetch()` method is a good default implementation.
*/
export function resolveComponentResources(
resourceResolver: (url: string) => (Promise<string|{text(): Promise<string>}>)): Promise<null> {
resourceResolver: (url: string) => (Promise<string|{text(): Promise<string>}>)): Promise<void> {
// Store all promises which are fetching the resources.
const urlFetches: Promise<string>[] = [];
const componentResolved: Promise<void>[] = [];
// Cache so that we don't fetch the same resource more than once.
const urlMap = new Map<string, Promise<string>>();
@ -53,50 +54,68 @@ export function resolveComponentResources(
if (!promise) {
const resp = resourceResolver(url);
urlMap.set(url, promise = resp.then(unwrapResponse));
urlFetches.push(promise);
}
return promise;
}
componentResourceResolutionQueue.forEach((component: Component) => {
componentResourceResolutionQueue.forEach((component: Component, type: Type<any>) => {
const promises: Promise<void>[] = [];
if (component.templateUrl) {
cachedResourceResolve(component.templateUrl).then((template) => {
promises.push(cachedResourceResolve(component.templateUrl).then((template) => {
component.template = template;
});
}));
}
const styleUrls = component.styleUrls;
const styles = component.styles || (component.styles = []);
const styleOffset = component.styles.length;
styleUrls && styleUrls.forEach((styleUrl, index) => {
styles.push(''); // pre-allocate array.
cachedResourceResolve(styleUrl).then((style) => {
promises.push(cachedResourceResolve(styleUrl).then((style) => {
styles[styleOffset + index] = style;
styleUrls.splice(styleUrls.indexOf(styleUrl), 1);
if (styleUrls.length == 0) {
component.styleUrls = undefined;
}
});
}));
});
const fullyResolved = Promise.all(promises).then(() => componentDefResolved(type));
componentResolved.push(fullyResolved);
});
clearResolutionOfComponentResourcesQueue();
return Promise.all(urlFetches).then(() => null);
return Promise.all(componentResolved).then(() => undefined);
}
const componentResourceResolutionQueue: Set<Component> = new Set();
let componentResourceResolutionQueue = new Map<Type<any>, Component>();
export function maybeQueueResolutionOfComponentResources(metadata: Component) {
// Track when existing ngComponentDef for a Type is waiting on resources.
const componentDefPendingResolution = new Set<Type<any>>();
export function maybeQueueResolutionOfComponentResources(type: Type<any>, metadata: Component) {
if (componentNeedsResolution(metadata)) {
componentResourceResolutionQueue.add(metadata);
componentResourceResolutionQueue.set(type, metadata);
componentDefPendingResolution.add(type);
}
}
export function isComponentDefPendingResolution(type: Type<any>): boolean {
return componentDefPendingResolution.has(type);
}
export function componentNeedsResolution(component: Component): boolean {
return !!(
(component.templateUrl && !component.template) ||
component.styleUrls && component.styleUrls.length);
}
export function clearResolutionOfComponentResourcesQueue() {
componentResourceResolutionQueue.clear();
export function clearResolutionOfComponentResourcesQueue(): Map<Type<any>, Component> {
const old = componentResourceResolutionQueue;
componentResourceResolutionQueue = new Map();
return old;
}
export function restoreComponentResolutionQueue(queue: Map<Type<any>, Component>): void {
componentDefPendingResolution.clear();
queue.forEach((_, type) => componentDefPendingResolution.add(type));
componentResourceResolutionQueue = queue;
}
export function isComponentResourceResolutionQueueEmpty() {
@ -106,3 +125,7 @@ export function isComponentResourceResolutionQueueEmpty() {
function unwrapResponse(response: string | {text(): Promise<string>}): string|Promise<string> {
return typeof response == 'string' ? response : response.text();
}
function componentDefResolved(type: Type<any>): void {
componentDefPendingResolution.delete(type);
}

View File

@ -5,7 +5,7 @@
* 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 {Provider} from '../../di/interface/provider';
import {ProcessProvidersFunction, Provider} from '../../di/interface/provider';
import {providersResolver} from '../di_setup';
import {DirectiveDef} from '../interfaces/definition';
@ -39,7 +39,12 @@ import {DirectiveDef} from '../interfaces/definition';
*/
export function ProvidersFeature<T>(providers: Provider[], viewProviders: Provider[] = []) {
return (definition: DirectiveDef<T>) => {
definition.providersResolver = (def: DirectiveDef<T>) =>
providersResolver(def, providers, viewProviders);
definition.providersResolver =
(def: DirectiveDef<T>, processProvidersFn?: ProcessProvidersFunction) => {
return providersResolver(
def, //
processProvidersFn ? processProvidersFn(providers) : providers, //
viewProviders);
};
};
}

View File

@ -7,6 +7,7 @@
*/
import {SchemaMetadata, ViewEncapsulation} from '../../core';
import {ProcessProvidersFunction} from '../../di/interface/provider';
import {Type} from '../../interface/type';
import {CssSelectorList} from './projection';
@ -138,7 +139,9 @@ export interface DirectiveDef<T> extends BaseDef<T> {
type: Type<T>;
/** Function that resolves providers and publishes them into the DI system. */
providersResolver: (<U extends T>(def: DirectiveDef<U>) => void)|null;
providersResolver:
(<U extends T>(def: DirectiveDef<U>, processProvidersFn?: ProcessProvidersFunction) =>
void)|null;
/** The selectors that will be used to match nodes to this directive. */
readonly selectors: CssSelectorList;

View File

@ -37,7 +37,7 @@ import {flushModuleScopingQueueAsMuchAsPossible, patchComponentDefWithScope, tra
export function compileComponent(type: Type<any>, metadata: Component): void {
let ngComponentDef: any = null;
// Metadata may have resources which need to be resolved.
maybeQueueResolutionOfComponentResources(metadata);
maybeQueueResolutionOfComponentResources(type, metadata);
Object.defineProperty(type, NG_COMPONENT_DEF, {
get: () => {
const compiler = getCompilerFacade();

View File

@ -357,6 +357,12 @@ export function patchComponentDefWithScope<C>(
componentDef.pipeDefs = () =>
Array.from(transitiveScopes.compilation.pipes).map(pipe => getPipeDef(pipe) !);
componentDef.schemas = transitiveScopes.schemas;
// Since we avoid Components/Directives/Pipes recompiling in case there are no overrides, we
// may face a problem where previously compiled defs available to a given Component/Directive
// are cached in TView and may become stale (in case any of these defs gets recompiled). In
// order to avoid this problem, we force fresh TView to be created.
componentDef.template.ngPrivateData = undefined;
}
/**

View File

@ -136,6 +136,15 @@ function declareTests(config?: {useJit: boolean}) {
}
function createComp<T>(compType: Type<T>, moduleType: Type<any>): ComponentFixture<T> {
const componentDef = (compType as any).ngComponentDef;
if (componentDef) {
// Since we avoid Components/Directives/Pipes recompiling in case there are no overrides, we
// may face a problem where previously compiled defs available to a given
// Component/Directive are cached in TView and may become stale (in case any of these defs
// gets recompiled). In order to avoid this problem, we force fresh TView to be created.
componentDef.template.ngPrivateData = null;
}
const ngModule = createModule(moduleType, injector);
const cf = ngModule.componentFactoryResolver.resolveComponentFactory(compType) !;

View File

@ -294,8 +294,8 @@ describe('TestBed', () => {
TestBed.resetTestingModule();
const defAfterReset = (SomeComponent as any).ngComponentDef;
expect(defAfterReset.pipeDefs().length).toEqual(0);
expect(defAfterReset.directiveDefs().length).toEqual(1); // component
expect(defAfterReset.pipeDefs).toBe(null);
expect(defAfterReset.directiveDefs).toBe(null);
});
it('should cleanup ng defs for classes with no ng annotations (in case of inheritance)',

View File

@ -51,11 +51,12 @@ import {
CompilerOptions,
StaticProvider,
COMPILER_OPTIONS,
ɵDirectiveDef as DirectiveDef,
} from '@angular/core';
// clang-format on
import {ResourceLoader} from '@angular/compiler';
import {clearResolutionOfComponentResourcesQueue, componentNeedsResolution, resolveComponentResources} from '../../src/metadata/resource_loading';
import {clearResolutionOfComponentResourcesQueue, componentNeedsResolution, resolveComponentResources, maybeQueueResolutionOfComponentResources, isComponentDefPendingResolution, restoreComponentResolutionQueue} from '../../src/metadata/resource_loading';
import {ComponentFixture} from './component_fixture';
import {MetadataOverride} from './metadata_override';
import {ComponentResolver, DirectiveResolver, NgModuleResolver, PipeResolver, Resolver} from './resolvers';
@ -260,10 +261,12 @@ export class TestBedRender3 implements Injector, TestBed {
private _instantiated: boolean = false;
private _globalCompilationChecked = false;
private _originalComponentResolutionQueue: Map<Type<any>, Component>|null = null;
// Map that keeps initial version of component/directive/pipe defs in case
// we compile a Type again, thus overriding respective static fields. This is
// required to make sure we restore defs to their initial states between test runs
private _initiaNgDefs: Map<Type<any>, [string, PropertyDescriptor|undefined]> = new Map();
private _initialNgDefs: Map<Type<any>, [string, PropertyDescriptor|undefined]> = new Map();
/**
* Initialize the environment for testing with a compiler factory, a PlatformRef, and an
@ -337,7 +340,7 @@ export class TestBedRender3 implements Injector, TestBed {
this._activeFixtures = [];
// restore initial component/directive/pipe defs
this._initiaNgDefs.forEach((value: [string, PropertyDescriptor], type: Type<any>) => {
this._initialNgDefs.forEach((value: [string, PropertyDescriptor], type: Type<any>) => {
const [prop, descriptor] = value;
if (!descriptor) {
// Delete operations are generally undesirable since they have performance implications on
@ -351,8 +354,8 @@ export class TestBedRender3 implements Injector, TestBed {
Object.defineProperty(type, prop, descriptor);
}
});
this._initiaNgDefs.clear();
clearResolutionOfComponentResourcesQueue();
this._initialNgDefs.clear();
this._restoreComponentResolutionQueue();
}
configureCompiler(config: {providers?: any[]; useJit?: boolean;}): void {
@ -383,21 +386,40 @@ export class TestBedRender3 implements Injector, TestBed {
}
compileComponents(): Promise<any> {
this._clearComponentResolutionQueue();
const resolvers = this._getResolvers();
const declarations: Type<any>[] = flatten(this._declarations || EMPTY_ARRAY, resolveForwardRef);
const componentOverrides: [Type<any>, Component][] = [];
const providerOverrides: (() => void)[] = [];
let hasAsyncResources = false;
// Compile the components declared by this module
// TODO(FW-1178): `compileComponents` should not duplicate `_compileNgModule` logic
declarations.forEach(declaration => {
const component = resolvers.component.resolve(declaration);
if (component) {
// We make a copy of the metadata to ensure that we don't mutate the original metadata
const metadata = {...component};
compileComponent(declaration, metadata);
componentOverrides.push([declaration, metadata]);
hasAsyncResources = hasAsyncResources || componentNeedsResolution(component);
if (!declaration.hasOwnProperty(NG_COMPONENT_DEF) ||
isComponentDefPendingResolution(declaration) || //
// Compiler provider overrides (like ResourceLoader) might affect the outcome of
// compilation, so we trigger `compileComponent` in case we have compilers overrides.
this._compilerProviders.length > 0 ||
this._hasTypeOverrides(declaration, this._componentOverrides) ||
this._hasTemplateOverrides(declaration)) {
this._storeNgDef(NG_COMPONENT_DEF, declaration);
// We make a copy of the metadata to ensure that we don't mutate the original metadata
const metadata = {...component};
compileComponent(declaration, metadata);
componentOverrides.push([declaration, metadata]);
hasAsyncResources = hasAsyncResources || componentNeedsResolution(component);
} else if (this._hasProviderOverrides(component.providers)) {
// Queue provider override operations, since fetching ngComponentDef (to patch it) might
// trigger re-compilation, which will fail because component resources are not yet fully
// resolved at this moment. The queue is drained once all resources are resolved.
providerOverrides.push(
() => this._patchDefWithProviderOverrides(declaration, NG_COMPONENT_DEF));
}
}
});
@ -407,6 +429,7 @@ export class TestBedRender3 implements Injector, TestBed {
// are only available until the next TestBed reset (when `resetTestingModule` is called)
this.overrideComponent(override[0], {set: override[1]});
});
providerOverrides.forEach((overrideFn: () => void) => overrideFn());
};
// If the component has no async resources (templateUrl, styleUrls), we can finish
@ -558,9 +581,9 @@ export class TestBedRender3 implements Injector, TestBed {
}
private _storeNgDef(prop: string, type: Type<any>) {
if (!this._initiaNgDefs.has(type)) {
if (!this._initialNgDefs.has(type)) {
const currentDef = Object.getOwnPropertyDescriptor(type, prop);
this._initiaNgDefs.set(type, [prop, currentDef]);
this._initialNgDefs.set(type, [prop, currentDef]);
}
}
@ -651,16 +674,56 @@ export class TestBedRender3 implements Injector, TestBed {
return this._compilerInjector;
}
/**
* Clears current components resolution queue, but stores the state of the queue, so we can
* restore it later. Clearing the queue is required before we try to compile components (via
* `TestBed.compileComponents`), so that component defs are in sync with the resolution queue.
*/
private _clearComponentResolutionQueue() {
if (this._originalComponentResolutionQueue === null) {
this._originalComponentResolutionQueue = new Map();
}
clearResolutionOfComponentResourcesQueue().forEach(
(value, key) => this._originalComponentResolutionQueue !.set(key, value));
}
/**
* Restores component resolution queue to the previously saved state. This operation is performed
* as a part of restoring the state after completion of the current set of tests (that might
* potentially mutate the state).
*/
private _restoreComponentResolutionQueue() {
if (this._originalComponentResolutionQueue !== null) {
restoreComponentResolutionQueue(this._originalComponentResolutionQueue);
this._originalComponentResolutionQueue = null;
}
}
// TODO(FW-1179): define better types for all Provider-related operations, avoid using `any`.
private _getProvidersOverrides(providers: any): any[] {
if (!providers || !providers.length) return [];
// There are two flattening operations here. The inner flatten() operates on the metadata's
// providers and applies a mapping function which retrieves overrides for each incoming
// provider. The outer flatten() then flattens the produced overrides array. If this is not
// done, the array can contain other empty arrays (e.g. `[[], []]`) which leak into the
// providers array and contaminate any error messages that might be generated.
return flatten(flatten(providers, (provider: any) => this._getProviderOverrides(provider)));
}
private _hasProviderOverrides(providers: any) {
return this._getProvidersOverrides(providers).length > 0;
}
private _hasTypeOverrides(type: Type<any>, overrides: [Type<any>, MetadataOverride<any>][]) {
return overrides.some((override: [Type<any>, MetadataOverride<any>]) => override[0] === type);
}
private _hasTemplateOverrides(type: Type<any>) { return this._templateOverrides.has(type); }
private _getMetaWithOverrides(meta: Component|Directive|NgModule, type?: Type<any>) {
const overrides: {providers?: any[], template?: string} = {};
if (meta.providers && meta.providers.length) {
// There are two flattening operations here. The inner flatten() operates on the metadata's
// providers and applies a mapping function which retrieves overrides for each incoming
// provider. The outer flatten() then flattens the produced overrides array. If this is not
// done, the array can contain other empty arrays (e.g. `[[], []]`) which leak into the
// providers array and contaminate any error messages that might be generated.
const providerOverrides =
flatten(flatten(meta.providers, (provider: any) => this._getProviderOverrides(provider)));
const providerOverrides = this._getProvidersOverrides(meta.providers);
if (providerOverrides.length) {
overrides.providers = [...meta.providers, ...providerOverrides];
}
@ -672,6 +735,19 @@ export class TestBedRender3 implements Injector, TestBed {
return Object.keys(overrides).length ? {...meta, ...overrides} : meta;
}
private _patchDefWithProviderOverrides(declaration: Type<any>, field: string) {
const def = (declaration as any)[field];
if (def && def.providersResolver) {
this._storeNgDef(field, declaration);
const resolver = def.providersResolver;
const processProvidersFn = (providers: any[]) => {
const overrides = this._getProvidersOverrides(providers);
return [...providers, ...overrides];
};
def.providersResolver = (ngDef: DirectiveDef<any>) => resolver(ngDef, processProvidersFn);
}
}
/**
* @internal
*/
@ -694,31 +770,45 @@ export class TestBedRender3 implements Injector, TestBed {
const declarations: Type<any>[] =
flatten(ngModule.declarations || EMPTY_ARRAY, resolveForwardRef);
const compiledComponents: Type<any>[] = [];
const declaredComponents: Type<any>[] = [];
// Compile the components, directives and pipes declared by this module
declarations.forEach(declaration => {
const component = this._resolvers.component.resolve(declaration);
if (component) {
this._storeNgDef(NG_COMPONENT_DEF, declaration);
const metadata = this._getMetaWithOverrides(component, declaration);
compileComponent(declaration, metadata);
compiledComponents.push(declaration);
if (!declaration.hasOwnProperty(NG_COMPONENT_DEF) ||
this._hasTypeOverrides(declaration, this._componentOverrides) ||
this._hasTemplateOverrides(declaration)) {
this._storeNgDef(NG_COMPONENT_DEF, declaration);
const metadata = this._getMetaWithOverrides(component, declaration);
compileComponent(declaration, metadata);
} else if (this._hasProviderOverrides(component.providers)) {
this._patchDefWithProviderOverrides(declaration, NG_COMPONENT_DEF);
}
declaredComponents.push(declaration);
return;
}
const directive = this._resolvers.directive.resolve(declaration);
if (directive) {
this._storeNgDef(NG_DIRECTIVE_DEF, declaration);
const metadata = this._getMetaWithOverrides(directive);
compileDirective(declaration, metadata);
if (!declaration.hasOwnProperty(NG_DIRECTIVE_DEF) ||
this._hasTypeOverrides(declaration, this._directiveOverrides)) {
this._storeNgDef(NG_DIRECTIVE_DEF, declaration);
const metadata = this._getMetaWithOverrides(directive);
compileDirective(declaration, metadata);
} else if (this._hasProviderOverrides(directive.providers)) {
this._patchDefWithProviderOverrides(declaration, NG_DIRECTIVE_DEF);
}
return;
}
const pipe = this._resolvers.pipe.resolve(declaration);
if (pipe) {
this._storeNgDef(NG_PIPE_DEF, declaration);
compilePipe(declaration, pipe);
if (!declaration.hasOwnProperty(NG_PIPE_DEF) ||
this._hasTypeOverrides(declaration, this._pipeOverrides)) {
this._storeNgDef(NG_PIPE_DEF, declaration);
compilePipe(declaration, pipe);
}
return;
}
});
@ -727,7 +817,7 @@ export class TestBedRender3 implements Injector, TestBed {
const calcTransitiveScopesFor = (moduleType: NgModuleType) => transitiveScopesFor(
moduleType, (ngModule: NgModuleType) => this._compileNgModule(ngModule));
const transitiveScope = calcTransitiveScopesFor(moduleType);
compiledComponents.forEach(cmp => {
declaredComponents.forEach(cmp => {
const scope = this._templateOverrides.has(cmp) ?
// if we have template override via `TestBed.overrideTemplateUsingTestingModule` -
// define Component scope as TestingModule scope, instead of the scope of NgModule

View File

@ -144,9 +144,7 @@ if (isBrowser) {
});
}),
10000); // Long timeout here because this test makes an actual ResourceLoader
// request, and
// is slow
// on Edge.
// request, and is slow on Edge.
});
});
}

View File

@ -900,17 +900,24 @@ class CompWithUrlTemplate {
() => {
const itPromise = patchJasmineIt();
@Component({
selector: 'comp',
templateUrl: '/base/angular/packages/platform-browser/test/static_assets/test.html'
})
class InlineCompWithUrlTemplate {
}
expect(
() =>
it('should fail', withModule(
{declarations: [CompWithUrlTemplate]},
() => TestBed.createComponent(CompWithUrlTemplate))))
() => it(
'should fail', withModule(
{declarations: [InlineCompWithUrlTemplate]},
() => TestBed.createComponent(InlineCompWithUrlTemplate))))
.toThrowError(
ivyEnabled ?
`Component 'CompWithUrlTemplate' is not resolved:
`Component 'InlineCompWithUrlTemplate' is not resolved:
- templateUrl: /base/angular/packages/platform-browser/test/static_assets/test.html
Did you run and wait for 'resolveComponentResources()'?` :
`This test module uses the component ${stringify(CompWithUrlTemplate)} which is using a "templateUrl" or "styleUrls", but they were never compiled. ` +
`This test module uses the component ${stringify(InlineCompWithUrlTemplate)} which is using a "templateUrl" or "styleUrls", but they were never compiled. ` +
`Please call "TestBed.compileComponents" before your test.`);
restoreJasmineIt();