diff --git a/packages/core/src/core_private_export.ts b/packages/core/src/core_private_export.ts index 5b35140710..24cddeb5e6 100644 --- a/packages/core/src/core_private_export.ts +++ b/packages/core/src/core_private_export.ts @@ -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'; diff --git a/packages/core/src/ivy_switch_on.ts b/packages/core/src/ivy_switch_on.ts index 7b2d79214a..8d29031c72 100644 --- a/packages/core/src/ivy_switch_on.ts +++ b/packages/core/src/ivy_switch_on.ts @@ -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; diff --git a/packages/core/src/metadata/resource_loading.ts b/packages/core/src/metadata/resource_loading.ts new file mode 100644 index 0000000000..1870d997c0 --- /dev/null +++ b/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}>)): Promise { + // Store all promises which are fetching the resources. + const urlFetches: Promise[] = []; + + // Cache so that we don't fetch the same resource more than once. + const urlMap = new Map>(); + function cachedResourceResolve(url: string): Promise { + 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 = 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|Promise { + return typeof response == 'string' ? response : response.text(); +} \ No newline at end of file diff --git a/packages/core/src/render3/jit/directive.ts b/packages/core/src/render3/jit/directive.ts index b545f72306..e982be0a1c 100644 --- a/packages/core/src/render3/jit/directive.ts +++ b/packages/core/src/render3/jit/directive.ts @@ -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[] = []; - type StringMap = { [key: string]: string }; @@ -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, metadata: Component): Promise|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, 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. @@ -81,8 +90,6 @@ export function compileComponent(type: Type, metadata: Component): Promise< return def; }, }); - - return null; } function hasSelectorScope(component: Type): component is Type& @@ -97,7 +104,7 @@ function hasSelectorScope(component: Type): component is Type& * 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, directive: Directive): Promise|null { +export function compileDirective(type: Type, directive: Directive): void { let def: any = null; Object.defineProperty(type, NG_DIRECTIVE_DEF, { get: () => { @@ -111,31 +118,8 @@ export function compileDirective(type: Type, 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, 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 { - const res = Promise.all(_pendingPromises).then(() => undefined); - _pendingPromises = []; - return res; -} /** * Extract the `R3DirectiveMetadata` for a particular directive (either a `Directive` or a diff --git a/packages/core/test/metadata/resource_loading_spec.ts b/packages/core/test/metadata/resource_loading_spec.ts new file mode 100644 index 0000000000..5ee74007ce --- /dev/null +++ b/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 = (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 = (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 = (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} = { + 'test://content': Promise.resolve('content'), + 'test://style1': Promise.resolve('style1'), + 'test://style2': Promise.resolve('style2'), + }; + let resourceFetchCount: number; + function testResolver(url: string): Promise { + resourceFetchCount++; + return URLS[url] || Promise.reject('NOT_FOUND: ' + url); + } + beforeEach(() => resourceFetchCount = 0); + + it('should resolve template', jasmineAwait(async() => { + const MyComponent: ComponentType = (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 = (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 = (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 = (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 { + return Promise.resolve({ + text() { return 'response for ' + url; } + } as any as Response); + } + + it('should work with fetch', jasmineAwait(async() => { + const MyComponent: ComponentType = (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'); + })); + }); +});