Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ivy resource loader #24637

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/core/src/core_private_export.ts
Expand Up @@ -18,6 +18,7 @@ export {APP_ROOT as ɵAPP_ROOT} from './di/scope';
export {ivyEnabled as ɵivyEnabled} from './ivy_switch';
export {ComponentFactory as ɵComponentFactory} from './linker/component_factory';
export {CodegenComponentFactoryResolver as ɵCodegenComponentFactoryResolver} from './linker/component_factory_resolver';
export {resolveComponentResources as ɵresolveComponentResources} from './metadata/resource_loading';
export {ReflectionCapabilities as ɵReflectionCapabilities} from './reflection/reflection_capabilities';
export {GetterFn as ɵGetterFn, MethodFn as ɵMethodFn, SetterFn as ɵSetterFn} from './reflection/types';
export {DirectRenderer as ɵDirectRenderer, RenderDebugInfo as ɵRenderDebugInfo} from './render/api';
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/ivy_switch_on.ts
Expand Up @@ -6,12 +6,12 @@
* found in the LICENSE file at https://angular.io/license
*/

import {compileComponentDecorator, compileDirective} from './render3/jit/directive';
import {compileComponent, compileDirective} from './render3/jit/directive';
import {compileInjectable} from './render3/jit/injectable';
import {compileNgModule} from './render3/jit/module';

export const ivyEnabled = true;
export const R3_COMPILE_COMPONENT = compileComponentDecorator;
export const R3_COMPILE_COMPONENT = compileComponent;
export const R3_COMPILE_DIRECTIVE = compileDirective;
export const R3_COMPILE_INJECTABLE = compileInjectable;
export const R3_COMPILE_NGMODULE = compileNgModule;
103 changes: 103 additions & 0 deletions packages/core/src/metadata/resource_loading.ts
@@ -0,0 +1,103 @@
/**
* @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 {Component} from './directives';


/**
* Used to resolve resource URLs on `@Component` when used with JIT compilation.
*
* Example:
* ```
* @Component({
* selector: 'my-comp',
* templateUrl: 'my-comp.html', // This requires asynchronous resolution
* })
* class MyComponnent{
* }
*
* // Calling `renderComponent` will fail because `MyComponent`'s `@Compenent.templateUrl`
* // needs to be resolved because `renderComponent` is synchronous process.
* // renderComponent(MyComponent);
*
* // Calling `resolveComponentResources` will resolve `@Compenent.templateUrl` into
* // `@Compenent.template`, which would allow `renderComponent` to proceed in synchronous manner.
* // Use browser's `fetch` function as the default resource resolution strategy.
* resolveComponentResources(fetch).then(() => {
* // After resolution all URLs have been converted into strings.
* renderComponent(MyComponent);
* });
*
* ```
*
* NOTE: In AOT the resolution happens during compilation, and so there should be no need
* to call this method outside JIT mode.
*
* @param resourceResolver a function which is responsible to returning a `Promise` 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> {
// Store all promises which are fetching the resources.
const urlFetches: Promise<string>[] = [];

// Cache so that we don't fetch the same resource more than once.
const urlMap = new Map<string, Promise<string>>();
function cachedResourceResolve(url: string): Promise<string> {
let promise = urlMap.get(url);
if (!promise) {
const resp = resourceResolver(url);
urlMap.set(url, promise = resp.then(unwrapResponse));
urlFetches.push(promise);
}
return promise;
}

componentResourceResolutionQueue.forEach((component: Component) => {
if (component.templateUrl) {
cachedResourceResolve(component.templateUrl).then((template) => {
component.template = template;
component.templateUrl = undefined;
});
}
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) => {
styles[styleOffset + index] = style;
styleUrls.splice(styleUrls.indexOf(styleUrl), 1);
if (styleUrls.length == 0) {
component.styleUrls = undefined;
}
});
});
});
componentResourceResolutionQueue.clear();
return Promise.all(urlFetches).then(() => null);
}

const componentResourceResolutionQueue: Set<Component> = new Set();

export function maybeQueueResolutionOfComponentResources(metadata: Component) {
if (componentNeedsResolution(metadata)) {
componentResourceResolutionQueue.add(metadata);
}
}

export function componentNeedsResolution(component: Component) {
return component.templateUrl || component.styleUrls && component.styleUrls.length;
}
export function clearResolutionOfComponentResourcesQueue() {
componentResourceResolutionQueue.clear();
}

function unwrapResponse(response: string | {text(): Promise<string>}): string|Promise<string> {
return typeof response == 'string' ? response : response.text();
}
66 changes: 25 additions & 41 deletions packages/core/src/render3/jit/directive.ts
Expand Up @@ -9,16 +9,16 @@
import {ConstantPool, R3DirectiveMetadata, WrappedNodeExpr, compileComponentFromMetadata as compileR3Component, compileDirectiveFromMetadata as compileR3Directive, jitExpression, makeBindingParser, parseHostBindings, parseTemplate} from '@angular/compiler';

import {Component, Directive, HostBinding, HostListener, Input, Output} from '../../metadata/directives';
import {componentNeedsResolution, maybeQueueResolutionOfComponentResources} from '../../metadata/resource_loading';
import {ReflectionCapabilities} from '../../reflection/reflection_capabilities';
import {Type} from '../../type';
import {stringify} from '../../util';

import {angularCoreEnv} from './environment';
import {NG_COMPONENT_DEF, NG_DIRECTIVE_DEF} from './fields';
import {patchComponentDefWithScope} from './module';
import {getReflect, reflectDependencies} from './util';

let _pendingPromises: Promise<void>[] = [];

type StringMap = {
[key: string]: string
};
Expand All @@ -29,30 +29,39 @@ type StringMap = {
*
* Compilation may be asynchronous (due to the need to resolve URLs for the component template or
* other resources, for example). In the event that compilation is not immediate, `compileComponent`
* will return a `Promise` which will resolve when compilation completes and the component becomes
* usable.
* will enqueue resource resolution into a global queue and will fail to return the `ngComponentDef`
* until the global queue has been resolved with a call to `resolveComponentResources`.
*/
export function compileComponent(type: Type<any>, metadata: Component): Promise<void>|null {
// TODO(alxhub): implement ResourceLoader support for template compilation.
if (!metadata.template) {
throw new Error('templateUrl not yet supported');
}
const templateStr = metadata.template;

export function compileComponent(type: Type<any>, metadata: Component): void {
let def: any = null;
// Metadata may have resources which need to be resolved.
maybeQueueResolutionOfComponentResources(metadata);
Object.defineProperty(type, NG_COMPONENT_DEF, {
get: () => {
if (def === null) {
if (componentNeedsResolution(metadata)) {
const error = [`Component '${stringify(type)}' is not resolved:`];
if (metadata.templateUrl) {
error.push(` - templateUrl: ${stringify(metadata.templateUrl)}`);
}
if (metadata.styleUrls && metadata.styleUrls.length) {
error.push(` - styleUrls: ${JSON.stringify(metadata.styleUrls)}`);
}
error.push(`Did you run and wait for 'resolveComponentResources()'?`);
throw new Error(error.join('\n'));
}
// The ConstantPool is a requirement of the JIT'er.
const constantPool = new ConstantPool();

// Parse the template and check for errors.
const template = parseTemplate(templateStr, `ng://${type.name}/template.html`, {
preserveWhitespaces: metadata.preserveWhitespaces || false,
});
const template =
parseTemplate(metadata.template !, `ng://${stringify(type)}/template.html`, {
preserveWhitespaces: metadata.preserveWhitespaces || false,
});
if (template.errors !== undefined) {
const errors = template.errors.map(err => err.toString()).join(', ');
throw new Error(`Errors during JIT compilation of template for ${type.name}: ${errors}`);
throw new Error(
`Errors during JIT compilation of template for ${stringify(type)}: ${errors}`);
}

// Compile the component metadata, including template, into an expression.
Expand Down Expand Up @@ -81,8 +90,6 @@ export function compileComponent(type: Type<any>, metadata: Component): Promise<
return def;
},
});

return null;
}

function hasSelectorScope<T>(component: Type<T>): component is Type<T>&
Expand All @@ -97,7 +104,7 @@ function hasSelectorScope<T>(component: Type<T>): component is Type<T>&
* In the event that compilation is not immediate, `compileDirective` will return a `Promise` which
* will resolve when compilation completes and the directive becomes usable.
*/
export function compileDirective(type: Type<any>, directive: Directive): Promise<void>|null {
export function compileDirective(type: Type<any>, directive: Directive): void {
let def: any = null;
Object.defineProperty(type, NG_DIRECTIVE_DEF, {
get: () => {
Expand All @@ -111,31 +118,8 @@ export function compileDirective(type: Type<any>, directive: Directive): Promise
return def;
},
});
return null;
}

/**
* A wrapper around `compileComponent` which is intended to be used for the `@Component` decorator.
*
* This wrapper keeps track of the `Promise` returned by `compileComponent` and will cause
* `awaitCurrentlyCompilingComponents` to wait on the compilation to be finished.
*/
export function compileComponentDecorator(type: Type<any>, metadata: Component): void {
const res = compileComponent(type, metadata);
if (res !== null) {
_pendingPromises.push(res);
}
}

/**
* Returns a promise which will await the compilation of any `@Component`s which have been defined
* since the last time `awaitCurrentlyCompilingComponents` was called.
*/
export function awaitCurrentlyCompilingComponents(): Promise<void> {
const res = Promise.all(_pendingPromises).then(() => undefined);
_pendingPromises = [];
return res;
}

/**
* Extract the `R3DirectiveMetadata` for a particular directive (either a `Directive` or a
Expand Down
136 changes: 136 additions & 0 deletions packages/core/test/metadata/resource_loading_spec.ts
@@ -0,0 +1,136 @@
/**
* @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 {jasmineAwait} from '@angular/core/testing';

import {Component} from '../../src/core';
import {clearResolutionOfComponentResourcesQueue, resolveComponentResources} from '../../src/metadata/resource_loading';
import {ComponentType} from '../../src/render3/interfaces/definition';
import {compileComponent} from '../../src/render3/jit/directive';

describe('resource_loading', () => {
describe('error handling', () => {
afterEach(clearResolutionOfComponentResourcesQueue);
it('should throw an error when compiling component that has unresolved templateUrl', () => {
const MyComponent: ComponentType<any> = (class MyComponent{}) as any;
compileComponent(MyComponent, {templateUrl: 'someUrl'});
expect(() => MyComponent.ngComponentDef).toThrowError(`
Component 'MyComponent' is not resolved:
- templateUrl: someUrl
Did you run and wait for 'resolveComponentResources()'?`.trim());
});

it('should throw an error when compiling component that has unresolved styleUrls', () => {
const MyComponent: ComponentType<any> = (class MyComponent{}) as any;
compileComponent(MyComponent, {styleUrls: ['someUrl1', 'someUrl2']});
expect(() => MyComponent.ngComponentDef).toThrowError(`
Component 'MyComponent' is not resolved:
- styleUrls: ["someUrl1","someUrl2"]
Did you run and wait for 'resolveComponentResources()'?`.trim());
});

it('should throw an error when compiling component that has unresolved templateUrl and styleUrls',
() => {
const MyComponent: ComponentType<any> = (class MyComponent{}) as any;
compileComponent(
MyComponent, {templateUrl: 'someUrl', styleUrls: ['someUrl1', 'someUrl2']});
expect(() => MyComponent.ngComponentDef).toThrowError(`
Component 'MyComponent' is not resolved:
- templateUrl: someUrl
- styleUrls: ["someUrl1","someUrl2"]
Did you run and wait for 'resolveComponentResources()'?`.trim());
});
});

describe('resolution', () => {
const URLS: {[url: string]: Promise<string>} = {
'test://content': Promise.resolve('content'),
'test://style1': Promise.resolve('style1'),
'test://style2': Promise.resolve('style2'),
};
let resourceFetchCount: number;
function testResolver(url: string): Promise<string> {
resourceFetchCount++;
return URLS[url] || Promise.reject('NOT_FOUND: ' + url);
}
beforeEach(() => resourceFetchCount = 0);

it('should resolve template', jasmineAwait(async() => {
const MyComponent: ComponentType<any> = (class MyComponent{}) as any;
const metadata: Component = {templateUrl: 'test://content'};
compileComponent(MyComponent, metadata);
await resolveComponentResources(testResolver);
expect(MyComponent.ngComponentDef).toBeDefined();
expect(metadata.templateUrl).toBe(undefined);
expect(metadata.template).toBe('content');
expect(resourceFetchCount).toBe(1);
}));

it('should resolve styleUrls', jasmineAwait(async() => {
const MyComponent: ComponentType<any> = (class MyComponent{}) as any;
const metadata: Component = {template: '', styleUrls: ['test://style1', 'test://style2']};
compileComponent(MyComponent, metadata);
await resolveComponentResources(testResolver);
expect(MyComponent.ngComponentDef).toBeDefined();
expect(metadata.styleUrls).toBe(undefined);
expect(metadata.styles).toEqual(['style1', 'style2']);
expect(resourceFetchCount).toBe(2);
}));

it('should cache multiple resolution to same URL', jasmineAwait(async() => {
const MyComponent: ComponentType<any> = (class MyComponent{}) as any;
const metadata: Component = {template: '', styleUrls: ['test://style1', 'test://style1']};
compileComponent(MyComponent, metadata);
await resolveComponentResources(testResolver);
expect(MyComponent.ngComponentDef).toBeDefined();
expect(metadata.styleUrls).toBe(undefined);
expect(metadata.styles).toEqual(['style1', 'style1']);
expect(resourceFetchCount).toBe(1);
}));

it('should keep order even if the resolution is out of order', jasmineAwait(async() => {
const MyComponent: ComponentType<any> = (class MyComponent{}) as any;
const metadata: Component = {
template: '',
styles: ['existing'],
styleUrls: ['test://style1', 'test://style2']
};
compileComponent(MyComponent, metadata);
const resolvers: any[] = [];
const resolved = resolveComponentResources(
(url) => new Promise((resolve, response) => resolvers.push(url, resolve)));
// Out of order resolution
expect(resolvers[0]).toEqual('test://style1');
expect(resolvers[2]).toEqual('test://style2');
resolvers[3]('second');
resolvers[1]('first');
await resolved;
expect(metadata.styleUrls).toBe(undefined);
expect(metadata.styles).toEqual(['existing', 'first', 'second']);
}));

});

describe('fetch', () => {
function fetch(url: string): Promise<Response> {
return Promise.resolve({
text() { return 'response for ' + url; }
} as any as Response);
}

it('should work with fetch', jasmineAwait(async() => {
const MyComponent: ComponentType<any> = (class MyComponent{}) as any;
const metadata: Component = {templateUrl: 'test://content'};
compileComponent(MyComponent, metadata);
await resolveComponentResources(fetch);
expect(MyComponent.ngComponentDef).toBeDefined();
expect(metadata.templateUrl).toBe(undefined);
expect(metadata.template).toBe('response for test://content');
}));
});
});