refactor(compiler-cli): split template parsing into declaration/parse steps (#40561)

To prepare for the optimization of template-only changes, this commit
refactors the `ComponentDecoratorHandler`'s handling of template parsing.
Previously, templates were extracted from the raw decorator metadata and
parsed in a single operation.

To better handle incremental template updates, this commit splits this
operation into a "declaration" step where the template info is extracted
from the decorator metadata, and a "parsing" step where the declared
template is read and parsed. This allows for re-reading and re-parsing of
the declared template at a future point, using the same template declaration
extracted from the decorator.

PR Close #40561
This commit is contained in:
Alex Rickabaugh 2021-01-20 11:40:30 -08:00 committed by Jessica Janiuk
parent 21e24d1474
commit 52aeb5326d
1 changed files with 193 additions and 136 deletions

View File

@ -11,7 +11,7 @@ import * as ts from 'typescript';
import {CycleAnalyzer} from '../../cycles';
import {ErrorCode, FatalDiagnosticError} from '../../diagnostics';
import {absoluteFrom, relative} from '../../file_system';
import {absoluteFrom, AbsoluteFsPath, relative} from '../../file_system';
import {DefaultImportRecorder, ModuleResolver, Reference, ReferenceEmitter} from '../../imports';
import {DependencyTracker} from '../../incremental/api';
import {IndexingContext} from '../../indexer';
@ -246,24 +246,14 @@ export class ComponentDecoratorHandler implements
template = preanalyzed;
} else {
// The template was not already parsed. Either there's a templateUrl, or an inline template.
if (component.has('templateUrl')) {
const templateUrlExpr = component.get('templateUrl')!;
const templateUrl = this.evaluator.evaluate(templateUrlExpr);
if (typeof templateUrl !== 'string') {
throw createValueHasWrongTypeError(
templateUrlExpr, templateUrl, 'templateUrl must be a string');
}
const resourceUrl = this.resourceLoader.resolve(templateUrl, containingFile);
template = this._extractExternalTemplate(node, component, templateUrlExpr, resourceUrl);
} else {
// Expect an inline template to be present.
template = this._extractInlineTemplate(node, decorator, component, containingFile);
}
const templateDecl = this.parseTemplateDeclaration(decorator, component, containingFile);
template = this.extractTemplate(node, templateDecl);
}
const templateResource = template.isInline ?
{path: null, expression: component.get('template')!} :
{path: absoluteFrom(template.templateUrl), expression: template.sourceMapping.node};
const templateResource =
template.isInline ? {path: null, expression: component.get('template')!} : {
path: absoluteFrom(template.declaration.resolvedTemplateUrl),
expression: template.sourceMapping.node
};
// Figure out the set of styles. The ordering here is important: external resources (styleUrls)
// precede inline styles, and styles defined in the template override styles defined in the
@ -732,8 +722,8 @@ export class ComponentDecoratorHandler implements
// URLs to resolve.
if (templatePromise !== undefined) {
return templatePromise.then(() => {
const template =
this._extractExternalTemplate(node, component, templateUrlExpr, resourceUrl);
const templateDecl = this.parseTemplateDeclaration(decorator, component, containingFile);
const template = this.extractTemplate(node, templateDecl);
this.preanalyzeTemplateCache.set(node, template);
return template;
});
@ -741,92 +731,134 @@ export class ComponentDecoratorHandler implements
return Promise.resolve(null);
}
} else {
const template = this._extractInlineTemplate(node, decorator, component, containingFile);
const templateDecl = this.parseTemplateDeclaration(decorator, component, containingFile);
const template = this.extractTemplate(node, templateDecl);
this.preanalyzeTemplateCache.set(node, template);
return Promise.resolve(template);
}
}
private _extractExternalTemplate(
node: ClassDeclaration, component: Map<string, ts.Expression>, templateUrlExpr: ts.Expression,
resourceUrl: string): ParsedTemplateWithSource {
const templateStr = this.resourceLoader.load(resourceUrl);
if (this.depTracker !== null) {
this.depTracker.addResourceDependency(node.getSourceFile(), absoluteFrom(resourceUrl));
}
private extractTemplate(node: ClassDeclaration, template: TemplateDeclaration):
ParsedTemplateWithSource {
if (template.isInline) {
let templateStr: string;
let templateLiteral: ts.Node|null = null;
let templateUrl: string = '';
let templateRange: LexerRange|null = null;
let sourceMapping: TemplateSourceMapping;
let escapedString = false;
// We only support SourceMaps for inline templates that are simple string literals.
if (ts.isStringLiteral(template.expression) ||
ts.isNoSubstitutionTemplateLiteral(template.expression)) {
// the start and end of the `templateExpr` node includes the quotation marks, which we must
// strip
templateRange = getTemplateRange(template.expression);
templateStr = template.expression.getSourceFile().text;
templateLiteral = template.expression;
templateUrl = template.templateUrl;
escapedString = true;
sourceMapping = {
type: 'direct',
node: template.expression,
};
} else {
const resolvedTemplate = this.evaluator.evaluate(template.expression);
if (typeof resolvedTemplate !== 'string') {
throw createValueHasWrongTypeError(
template.expression, resolvedTemplate, 'template must be a string');
}
templateStr = resolvedTemplate;
sourceMapping = {
type: 'indirect',
node: template.expression,
componentClass: node,
template: templateStr,
};
}
const template = this._parseTemplate(
component, templateStr, /* templateLiteral */ null, sourceMapUrl(resourceUrl),
/* templateRange */ undefined,
/* escapedString */ false);
return {
...template,
sourceMapping: {
type: 'external',
componentClass: node,
node: templateUrlExpr,
template: templateStr,
templateUrl: resourceUrl,
},
};
}
private _extractInlineTemplate(
node: ClassDeclaration, decorator: Decorator, component: Map<string, ts.Expression>,
containingFile: string): ParsedTemplateWithSource {
if (!component.has('template')) {
throw new FatalDiagnosticError(
ErrorCode.COMPONENT_MISSING_TEMPLATE, Decorator.nodeForError(decorator),
'component is missing a template');
}
const templateExpr = component.get('template')!;
let templateStr: string;
let templateLiteral: ts.Node|null = null;
let templateUrl: string = '';
let templateRange: LexerRange|undefined = undefined;
let sourceMapping: TemplateSourceMapping;
let escapedString = false;
// We only support SourceMaps for inline templates that are simple string literals.
if (ts.isStringLiteral(templateExpr) || ts.isNoSubstitutionTemplateLiteral(templateExpr)) {
// the start and end of the `templateExpr` node includes the quotation marks, which we
// must
// strip
templateRange = getTemplateRange(templateExpr);
templateStr = templateExpr.getSourceFile().text;
templateLiteral = templateExpr;
templateUrl = containingFile;
escapedString = true;
sourceMapping = {
type: 'direct',
node: templateExpr as (ts.StringLiteral | ts.NoSubstitutionTemplateLiteral),
return {
...this._parseTemplate(template, templateStr, templateRange, escapedString),
sourceMapping,
declaration: template,
};
} else {
const resolvedTemplate = this.evaluator.evaluate(templateExpr);
if (typeof resolvedTemplate !== 'string') {
throw createValueHasWrongTypeError(
templateExpr, resolvedTemplate, 'template must be a string');
const templateStr = this.resourceLoader.load(template.resolvedTemplateUrl);
if (this.depTracker !== null) {
this.depTracker.addResourceDependency(
node.getSourceFile(), absoluteFrom(template.resolvedTemplateUrl));
}
templateStr = resolvedTemplate;
sourceMapping = {
type: 'indirect',
node: templateExpr,
componentClass: node,
template: templateStr,
return {
...this._parseTemplate(
template, templateStr, /* templateRange */ null,
/* escapedString */ false),
sourceMapping: {
type: 'external',
componentClass: node,
// TODO(alxhub): TS in g3 is unable to make this inference on its own, so cast it here
// until g3 is able to figure this out.
node: (template as ExternalTemplateDeclaration).templateUrlExpression,
template: templateStr,
templateUrl: template.resolvedTemplateUrl,
},
declaration: template,
};
}
const template = this._parseTemplate(
component, templateStr, templateLiteral, templateUrl, templateRange, escapedString);
return {...template, sourceMapping};
}
private _parseTemplate(
component: Map<string, ts.Expression>, templateStr: string, templateLiteral: ts.Node|null,
templateUrl: string, templateRange: LexerRange|undefined,
template: TemplateDeclaration, templateStr: string, templateRange: LexerRange|null,
escapedString: boolean): ParsedComponentTemplate {
// We always normalize line endings if the template has been escaped (i.e. is inline).
const i18nNormalizeLineEndingsInICUs = escapedString || this.i18nNormalizeLineEndingsInICUs;
const parsedTemplate = parseTemplate(templateStr, template.sourceMapUrl, {
preserveWhitespaces: template.preserveWhitespaces,
interpolationConfig: template.interpolationConfig,
range: templateRange ?? undefined,
escapedString,
enableI18nLegacyMessageIdFormat: this.enableI18nLegacyMessageIdFormat,
i18nNormalizeLineEndingsInICUs,
isInline: template.isInline,
});
// Unfortunately, the primary parse of the template above may not contain accurate source map
// information. If used directly, it would result in incorrect code locations in template
// errors, etc. There are two main problems:
//
// 1. `preserveWhitespaces: false` annihilates the correctness of template source mapping, as
// the whitespace transformation changes the contents of HTML text nodes before they're
// parsed into Angular expressions.
// 2. By default, the template parser strips leading trivia characters (like spaces, tabs, and
// newlines). This also destroys source mapping information.
//
// In order to guarantee the correctness of diagnostics, templates are parsed a second time
// with the above options set to preserve source mappings.
const {nodes: diagNodes} = parseTemplate(templateStr, template.sourceMapUrl, {
preserveWhitespaces: true,
interpolationConfig: template.interpolationConfig,
range: templateRange ?? undefined,
escapedString,
enableI18nLegacyMessageIdFormat: this.enableI18nLegacyMessageIdFormat,
i18nNormalizeLineEndingsInICUs,
leadingTriviaChars: [],
isInline: template.isInline,
});
return {
...parsedTemplate,
diagNodes,
template: template.isInline ? new WrappedNodeExpr(template.expression) : templateStr,
templateUrl: template.resolvedTemplateUrl,
isInline: template.isInline,
file: new ParseSourceFile(templateStr, template.resolvedTemplateUrl),
};
}
private parseTemplateDeclaration(
decorator: Decorator, component: Map<string, ts.Expression>,
containingFile: string): TemplateDeclaration {
let preserveWhitespaces: boolean = this.defaultPreserveWhitespaces;
if (component.has('preserveWhitespaces')) {
const expr = component.get('preserveWhitespaces')!;
@ -849,52 +881,39 @@ export class ComponentDecoratorHandler implements
interpolationConfig = InterpolationConfig.fromArray(value as [string, string]);
}
// We always normalize line endings if the template has been escaped (i.e. is inline).
const i18nNormalizeLineEndingsInICUs = escapedString || this.i18nNormalizeLineEndingsInICUs;
if (component.has('templateUrl')) {
const templateUrlExpr = component.get('templateUrl')!;
const templateUrl = this.evaluator.evaluate(templateUrlExpr);
if (typeof templateUrl !== 'string') {
throw createValueHasWrongTypeError(
templateUrlExpr, templateUrl, 'templateUrl must be a string');
}
const resourceUrl = this.resourceLoader.resolve(templateUrl, containingFile);
const isInline = component.has('template');
const parsedTemplate = parseTemplate(templateStr, templateUrl, {
preserveWhitespaces,
interpolationConfig,
range: templateRange,
escapedString,
enableI18nLegacyMessageIdFormat: this.enableI18nLegacyMessageIdFormat,
i18nNormalizeLineEndingsInICUs,
isInline,
});
// Unfortunately, the primary parse of the template above may not contain accurate source map
// information. If used directly, it would result in incorrect code locations in template
// errors, etc. There are two main problems:
//
// 1. `preserveWhitespaces: false` annihilates the correctness of template source mapping, as
// the whitespace transformation changes the contents of HTML text nodes before they're
// parsed into Angular expressions.
// 2. By default, the template parser strips leading trivia characters (like spaces, tabs, and
// newlines). This also destroys source mapping information.
//
// In order to guarantee the correctness of diagnostics, templates are parsed a second time
// with the above options set to preserve source mappings.
const {nodes: diagNodes} = parseTemplate(templateStr, templateUrl, {
preserveWhitespaces: true,
interpolationConfig,
range: templateRange,
escapedString,
enableI18nLegacyMessageIdFormat: this.enableI18nLegacyMessageIdFormat,
i18nNormalizeLineEndingsInICUs,
leadingTriviaChars: [],
isInline,
});
return {
...parsedTemplate,
diagNodes,
template: templateLiteral !== null ? new WrappedNodeExpr(templateLiteral) : templateStr,
templateUrl,
isInline,
file: new ParseSourceFile(templateStr, templateUrl),
};
return {
isInline: false,
interpolationConfig,
preserveWhitespaces,
templateUrl,
templateUrlExpression: templateUrlExpr,
resolvedTemplateUrl: resourceUrl,
sourceMapUrl: sourceMapUrl(resourceUrl),
};
} else if (component.has('template')) {
return {
isInline: true,
interpolationConfig,
preserveWhitespaces,
expression: component.get('template')!,
templateUrl: containingFile,
resolvedTemplateUrl: containingFile,
sourceMapUrl: containingFile,
};
} else {
throw new FatalDiagnosticError(
ErrorCode.COMPONENT_MISSING_TEMPLATE, Decorator.nodeForError(decorator),
'component is missing a template');
}
}
private _expressionToImportedFile(expr: Expression, origin: ts.SourceFile): ts.SourceFile|null {
@ -978,4 +997,42 @@ export interface ParsedComponentTemplate extends ParsedTemplate {
export interface ParsedTemplateWithSource extends ParsedComponentTemplate {
sourceMapping: TemplateSourceMapping;
declaration: TemplateDeclaration;
}
/**
* Common fields extracted from the declaration of a template.
*/
interface CommonTemplateDeclaration {
preserveWhitespaces: boolean;
interpolationConfig: InterpolationConfig;
templateUrl: string;
resolvedTemplateUrl: string;
sourceMapUrl: string;
}
/**
* Information extracted from the declaration of an inline template.
*/
interface InlineTemplateDeclaration extends CommonTemplateDeclaration {
isInline: true;
expression: ts.Expression;
}
/**
* Information extracted from the declaration of an external template.
*/
interface ExternalTemplateDeclaration extends CommonTemplateDeclaration {
isInline: false;
templateUrlExpression: ts.Expression;
}
/**
* The declaration of a template extracted from a component decorator.
*
* This data is extracted and stored separately to faciliate re-interpreting the template
* declaration whenever the compiler is notified of a change to a template file. With this
* information, `ComponentDecoratorHandler` is able to re-read the template and update the component
* record without needing to parse the original decorator again.
*/
type TemplateDeclaration = InlineTemplateDeclaration|ExternalTemplateDeclaration;