fix(ivy): resolve resources using TS module resolution semantics (#27357)
Previously ngtsc assumed resource files (templateUrl, styleUrls) would be physically present in the file system relative to the .ts file which referenced them. However, ngc previously resolved such references in the context of ts.CompilerOptions.rootDirs. Material depends on this functionality in its build. This commit introduces resolution of resources by leveraging the TypeScript module resolver, ts.resolveModuleName(). This resolver is used in a way which will never succeed, but on failure will return a list of locations checked. This list is then filtered to obtain the correct potential locations of the resource. PR Close #27357
This commit is contained in:
parent
cfb67edd85
commit
159788685a
|
@ -6,6 +6,7 @@
|
||||||
* found in the LICENSE file at https://angular.io/license
|
* found in the LICENSE file at https://angular.io/license
|
||||||
*/
|
*/
|
||||||
import {ConstantPool} from '@angular/compiler';
|
import {ConstantPool} from '@angular/compiler';
|
||||||
|
import * as path from 'canonical-path';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as ts from 'typescript';
|
import * as ts from 'typescript';
|
||||||
|
|
||||||
|
@ -46,7 +47,10 @@ export interface MatchingHandler<A, M> {
|
||||||
* `ResourceLoader` which directly uses the filesystem to resolve resources synchronously.
|
* `ResourceLoader` which directly uses the filesystem to resolve resources synchronously.
|
||||||
*/
|
*/
|
||||||
export class FileResourceLoader implements ResourceLoader {
|
export class FileResourceLoader implements ResourceLoader {
|
||||||
load(url: string): string { return fs.readFileSync(url, 'utf8'); }
|
load(url: string, containingFile: string): string {
|
||||||
|
url = path.resolve(path.dirname(containingFile), url);
|
||||||
|
return fs.readFileSync(url, 'utf8');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -7,6 +7,6 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface ResourceLoader {
|
export interface ResourceLoader {
|
||||||
preload?(url: string): Promise<void>|undefined;
|
preload?(url: string, containingFile: string): Promise<void>|undefined;
|
||||||
load(url: string): string;
|
load(url: string, containingFile: string): string;
|
||||||
}
|
}
|
||||||
|
|
|
@ -58,6 +58,7 @@ export class ComponentDecoratorHandler implements
|
||||||
const meta = this._resolveLiteral(decorator);
|
const meta = this._resolveLiteral(decorator);
|
||||||
const component = reflectObjectLiteral(meta);
|
const component = reflectObjectLiteral(meta);
|
||||||
const promises: Promise<void>[] = [];
|
const promises: Promise<void>[] = [];
|
||||||
|
const containingFile = node.getSourceFile().fileName;
|
||||||
|
|
||||||
if (this.resourceLoader.preload !== undefined && component.has('templateUrl')) {
|
if (this.resourceLoader.preload !== undefined && component.has('templateUrl')) {
|
||||||
const templateUrlExpr = component.get('templateUrl') !;
|
const templateUrlExpr = component.get('templateUrl') !;
|
||||||
|
@ -66,8 +67,7 @@ export class ComponentDecoratorHandler implements
|
||||||
throw new FatalDiagnosticError(
|
throw new FatalDiagnosticError(
|
||||||
ErrorCode.VALUE_HAS_WRONG_TYPE, templateUrlExpr, 'templateUrl must be a string');
|
ErrorCode.VALUE_HAS_WRONG_TYPE, templateUrlExpr, 'templateUrl must be a string');
|
||||||
}
|
}
|
||||||
const url = path.posix.resolve(path.dirname(node.getSourceFile().fileName), templateUrl);
|
const promise = this.resourceLoader.preload(templateUrl, containingFile);
|
||||||
const promise = this.resourceLoader.preload(url);
|
|
||||||
if (promise !== undefined) {
|
if (promise !== undefined) {
|
||||||
promises.push(promise);
|
promises.push(promise);
|
||||||
}
|
}
|
||||||
|
@ -76,8 +76,7 @@ export class ComponentDecoratorHandler implements
|
||||||
const styleUrls = this._extractStyleUrls(component);
|
const styleUrls = this._extractStyleUrls(component);
|
||||||
if (this.resourceLoader.preload !== undefined && styleUrls !== null) {
|
if (this.resourceLoader.preload !== undefined && styleUrls !== null) {
|
||||||
for (const styleUrl of styleUrls) {
|
for (const styleUrl of styleUrls) {
|
||||||
const url = path.posix.resolve(path.dirname(node.getSourceFile().fileName), styleUrl);
|
const promise = this.resourceLoader.preload(styleUrl, containingFile);
|
||||||
const promise = this.resourceLoader.preload(url);
|
|
||||||
if (promise !== undefined) {
|
if (promise !== undefined) {
|
||||||
promises.push(promise);
|
promises.push(promise);
|
||||||
}
|
}
|
||||||
|
@ -91,6 +90,7 @@ export class ComponentDecoratorHandler implements
|
||||||
}
|
}
|
||||||
|
|
||||||
analyze(node: ts.ClassDeclaration, decorator: Decorator): AnalysisOutput<ComponentHandlerData> {
|
analyze(node: ts.ClassDeclaration, decorator: Decorator): AnalysisOutput<ComponentHandlerData> {
|
||||||
|
const containingFile = node.getSourceFile().fileName;
|
||||||
const meta = this._resolveLiteral(decorator);
|
const meta = this._resolveLiteral(decorator);
|
||||||
this.literalCache.delete(decorator);
|
this.literalCache.delete(decorator);
|
||||||
|
|
||||||
|
@ -117,8 +117,7 @@ export class ComponentDecoratorHandler implements
|
||||||
throw new FatalDiagnosticError(
|
throw new FatalDiagnosticError(
|
||||||
ErrorCode.VALUE_HAS_WRONG_TYPE, templateUrlExpr, 'templateUrl must be a string');
|
ErrorCode.VALUE_HAS_WRONG_TYPE, templateUrlExpr, 'templateUrl must be a string');
|
||||||
}
|
}
|
||||||
const url = path.posix.resolve(path.dirname(node.getSourceFile().fileName), templateUrl);
|
templateStr = this.resourceLoader.load(templateUrl, containingFile);
|
||||||
templateStr = this.resourceLoader.load(url);
|
|
||||||
} else if (component.has('template')) {
|
} else if (component.has('template')) {
|
||||||
const templateExpr = component.get('template') !;
|
const templateExpr = component.get('template') !;
|
||||||
const resolvedTemplate = staticallyResolve(templateExpr, this.reflector, this.checker);
|
const resolvedTemplate = staticallyResolve(templateExpr, this.reflector, this.checker);
|
||||||
|
@ -210,7 +209,7 @@ export class ComponentDecoratorHandler implements
|
||||||
if (styles === null) {
|
if (styles === null) {
|
||||||
styles = [];
|
styles = [];
|
||||||
}
|
}
|
||||||
styles.push(...styleUrls.map(styleUrl => this.resourceLoader.load(styleUrl)));
|
styles.push(...styleUrls.map(styleUrl => this.resourceLoader.load(styleUrl, containingFile)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let encapsulation: number = 0;
|
let encapsulation: number = 0;
|
||||||
|
|
|
@ -48,9 +48,11 @@ export class NgtscProgram implements api.Program {
|
||||||
this.rootDirs.push(host.getCurrentDirectory());
|
this.rootDirs.push(host.getCurrentDirectory());
|
||||||
}
|
}
|
||||||
this.closureCompilerEnabled = !!options.annotateForClosureCompiler;
|
this.closureCompilerEnabled = !!options.annotateForClosureCompiler;
|
||||||
this.resourceLoader = host.readResource !== undefined ?
|
this.resourceLoader =
|
||||||
new HostResourceLoader(host.readResource.bind(host)) :
|
host.readResource !== undefined && host.resourceNameToFileName !== undefined ?
|
||||||
new FileResourceLoader();
|
new HostResourceLoader(
|
||||||
|
host.resourceNameToFileName.bind(host), host.readResource.bind(host)) :
|
||||||
|
new FileResourceLoader(host, this.options);
|
||||||
const shouldGenerateShims = options.allowEmptyCodegenFiles || false;
|
const shouldGenerateShims = options.allowEmptyCodegenFiles || false;
|
||||||
this.host = host;
|
this.host = host;
|
||||||
let rootFiles = [...rootNames];
|
let rootFiles = [...rootNames];
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
|
import * as ts from 'typescript';
|
||||||
|
|
||||||
import {ResourceLoader} from './annotations';
|
import {ResourceLoader} from './annotations';
|
||||||
|
|
||||||
|
@ -17,46 +18,107 @@ export class HostResourceLoader implements ResourceLoader {
|
||||||
private cache = new Map<string, string>();
|
private cache = new Map<string, string>();
|
||||||
private fetching = new Map<string, Promise<void>>();
|
private fetching = new Map<string, Promise<void>>();
|
||||||
|
|
||||||
constructor(private host: (url: string) => string | Promise<string>) {}
|
constructor(
|
||||||
|
private resolver: (file: string, basePath: string) => string | null,
|
||||||
|
private loader: (url: string) => string | Promise<string>) {}
|
||||||
|
|
||||||
preload(url: string): Promise<void>|undefined {
|
preload(file: string, containingFile: string): Promise<void>|undefined {
|
||||||
if (this.cache.has(url)) {
|
const resolved = this.resolver(file, containingFile);
|
||||||
|
if (resolved === null) {
|
||||||
return undefined;
|
return undefined;
|
||||||
} else if (this.fetching.has(url)) {
|
|
||||||
return this.fetching.get(url);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = this.host(url);
|
if (this.cache.has(resolved)) {
|
||||||
|
return undefined;
|
||||||
|
} else if (this.fetching.has(resolved)) {
|
||||||
|
return this.fetching.get(resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = this.loader(resolved);
|
||||||
if (typeof result === 'string') {
|
if (typeof result === 'string') {
|
||||||
this.cache.set(url, result);
|
this.cache.set(resolved, result);
|
||||||
return undefined;
|
return undefined;
|
||||||
} else {
|
} else {
|
||||||
const fetchCompletion = result.then(str => {
|
const fetchCompletion = result.then(str => {
|
||||||
this.fetching.delete(url);
|
this.fetching.delete(resolved);
|
||||||
this.cache.set(url, str);
|
this.cache.set(resolved, str);
|
||||||
});
|
});
|
||||||
this.fetching.set(url, fetchCompletion);
|
this.fetching.set(resolved, fetchCompletion);
|
||||||
return fetchCompletion;
|
return fetchCompletion;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
load(url: string): string {
|
load(file: string, containingFile: string): string {
|
||||||
if (this.cache.has(url)) {
|
const resolved = this.resolver(file, containingFile);
|
||||||
return this.cache.get(url) !;
|
if (resolved === null) {
|
||||||
|
throw new Error(
|
||||||
|
`HostResourceLoader: could not resolve ${file} in context of ${containingFile})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = this.host(url);
|
if (this.cache.has(resolved)) {
|
||||||
if (typeof result !== 'string') {
|
return this.cache.get(resolved) !;
|
||||||
throw new Error(`HostResourceLoader: host(${url}) returned a Promise`);
|
|
||||||
}
|
}
|
||||||
this.cache.set(url, result);
|
|
||||||
|
const result = this.loader(resolved);
|
||||||
|
if (typeof result !== 'string') {
|
||||||
|
throw new Error(`HostResourceLoader: loader(${resolved}) returned a Promise`);
|
||||||
|
}
|
||||||
|
this.cache.set(resolved, result);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// `failedLookupLocations` is in the name of the type ts.ResolvedModuleWithFailedLookupLocations
|
||||||
|
// but is marked @internal in TypeScript. See https://github.com/Microsoft/TypeScript/issues/28770.
|
||||||
|
type ResolvedModuleWithFailedLookupLocations =
|
||||||
|
ts.ResolvedModuleWithFailedLookupLocations & {failedLookupLocations: ReadonlyArray<string>};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `ResourceLoader` which directly uses the filesystem to resolve resources synchronously.
|
* `ResourceLoader` which directly uses the filesystem to resolve resources synchronously.
|
||||||
*/
|
*/
|
||||||
export class FileResourceLoader implements ResourceLoader {
|
export class FileResourceLoader implements ResourceLoader {
|
||||||
load(url: string): string { return fs.readFileSync(url, 'utf8'); }
|
constructor(private host: ts.CompilerHost, private options: ts.CompilerOptions) {}
|
||||||
|
|
||||||
|
load(file: string, containingFile: string): string {
|
||||||
|
// Attempt to resolve `file` in the context of `containingFile`, while respecting the rootDirs
|
||||||
|
// option from the tsconfig. First, normalize the file name.
|
||||||
|
|
||||||
|
// Strip a leading '/' if one is present.
|
||||||
|
if (file.startsWith('/')) {
|
||||||
|
file = file.substr(1);
|
||||||
|
}
|
||||||
|
// Turn absolute paths into relative paths.
|
||||||
|
if (!file.startsWith('.')) {
|
||||||
|
file = `./${file}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TypeScript provides utilities to resolve module names, but not resource files (which aren't
|
||||||
|
// a part of the ts.Program). However, TypeScript's module resolution can be used creatively
|
||||||
|
// to locate where resource files should be expected to exist. Since module resolution returns
|
||||||
|
// a list of file names that were considered, the loader can enumerate the possible locations
|
||||||
|
// for the file by setting up a module resolution for it that will fail.
|
||||||
|
file += '.$ngresource$';
|
||||||
|
|
||||||
|
// clang-format off
|
||||||
|
const failedLookup = ts.resolveModuleName(file, containingFile, this.options, this.host) as ResolvedModuleWithFailedLookupLocations;
|
||||||
|
// clang-format on
|
||||||
|
if (failedLookup.failedLookupLocations === undefined) {
|
||||||
|
throw new Error(
|
||||||
|
`Internal error: expected to find failedLookupLocations during resolution of resource '${file}' in context of ${containingFile}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidateLocations =
|
||||||
|
failedLookup.failedLookupLocations
|
||||||
|
.filter(candidate => candidate.endsWith('.$ngresource$.ts'))
|
||||||
|
.map(candidate => candidate.replace(/\.\$ngresource\$\.ts$/, ''));
|
||||||
|
|
||||||
|
for (const candidate of candidateLocations) {
|
||||||
|
if (fs.existsSync(candidate)) {
|
||||||
|
return fs.readFileSync(candidate, 'utf8');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`Could not find resource ${file} in context of ${containingFile}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -96,11 +96,17 @@ export class NgtscTestEnvironment {
|
||||||
|
|
||||||
write(fileName: string, content: string) { this.support.write(fileName, content); }
|
write(fileName: string, content: string) { this.support.write(fileName, content); }
|
||||||
|
|
||||||
tsconfig(extraOpts: {[key: string]: string | boolean} = {}): void {
|
tsconfig(extraOpts: {[key: string]: string | boolean} = {}, extraRootDirs?: string[]): void {
|
||||||
const opts = JSON.stringify({...extraOpts, 'enableIvy': 'ngtsc'});
|
const tsconfig: {[key: string]: any} = {
|
||||||
const tsconfig: string =
|
extends: './tsconfig-base.json',
|
||||||
`{"extends": "./tsconfig-base.json", "angularCompilerOptions": ${opts}}`;
|
angularCompilerOptions: {...extraOpts, enableIvy: 'ngtsc'},
|
||||||
this.write('tsconfig.json', tsconfig);
|
};
|
||||||
|
if (extraRootDirs !== undefined) {
|
||||||
|
tsconfig.compilerOptions = {
|
||||||
|
rootDirs: ['.', ...extraRootDirs],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
this.write('tsconfig.json', JSON.stringify(tsconfig, null, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -89,6 +89,25 @@ describe('ngtsc behavioral tests', () => {
|
||||||
expect(jsContents).toContain('Hello World');
|
expect(jsContents).toContain('Hello World');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should compile Components with a templateUrl in a different rootDir', () => {
|
||||||
|
env.tsconfig({}, ['./extraRootDir']);
|
||||||
|
env.write('extraRootDir/test.html', '<p>Hello World</p>');
|
||||||
|
env.write('test.ts', `
|
||||||
|
import {Component} from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'test-cmp',
|
||||||
|
templateUrl: 'test.html',
|
||||||
|
})
|
||||||
|
export class TestCmp {}
|
||||||
|
`);
|
||||||
|
|
||||||
|
env.driveMain();
|
||||||
|
|
||||||
|
const jsContents = env.getContents('test.js');
|
||||||
|
expect(jsContents).toContain('Hello World');
|
||||||
|
});
|
||||||
|
|
||||||
it('should compile components with styleUrls', () => {
|
it('should compile components with styleUrls', () => {
|
||||||
env.tsconfig();
|
env.tsconfig();
|
||||||
env.write('test.ts', `
|
env.write('test.ts', `
|
||||||
|
|
Loading…
Reference in New Issue