From 0de93fd40258e648f68f38039a8e2ddb2ee4af4e Mon Sep 17 00:00:00 2001 From: cindygk <62777672+cindygk@users.noreply.github.com> Date: Fri, 7 Aug 2020 16:59:20 -0700 Subject: [PATCH 001/629] docs: update team contributors page (#38384) Removing Kara, Denny, Judy, Tony, Matias as they are no longer actively working on the project PR Close #38384 --- aio/content/marketing/contributors.json | 38 ------------------------- 1 file changed, 38 deletions(-) diff --git a/aio/content/marketing/contributors.json b/aio/content/marketing/contributors.json index 98d8b337fe..2a2415c0bc 100644 --- a/aio/content/marketing/contributors.json +++ b/aio/content/marketing/contributors.json @@ -86,24 +86,6 @@ "groups": ["Angular"], "lead": "kara" }, - "matsko": { - "name": "Matias Niemela", - "picture": "matias.jpg", - "twitter": "yearofmoo", - "website": "http://yearofmoo.com", - "bio": "Matias Niemela is a fullstack web developer who has been programming & building websites for over 10 years, and a core team member of AngularJS for two years. In the spring of 2015 Matias joined Angular full time at Google. In his free time Matias loves to build complex things and is always up for public speaking, travelling and tweaking his current Vim setup.", - "groups": ["Angular"], - "lead": "kara" - }, - "kara": { - "name": "Kara Erickson", - "picture": "kara-erickson.jpg", - "twitter": "karaforthewin", - "website": "https://github.com/kara", - "bio": "Kara is a software engineer on the Angular team at Google and a co-organizer of the Angular-SF Meetup. Prior to Google, she helped build UI components in Angular for guest management systems at OpenTable. She enjoys snacking indiscriminately and probably other things too.", - "groups": ["Angular"], - "lead": "igorminar" - }, "pkozlowski-opensource": { "name": "Pawel Kozlowski", "picture": "pawel.jpg", @@ -618,26 +600,6 @@ "bio": "Justin (aka Schwarty) is a Google Developer Expert in Web Technologies and Angular, the host and maintainer of the weekly AngularAir live video broadcast, educator, writer and content creator. He has Angular courses available on LinkedIn Learning and Pluralsight and loves passing on years of full stack development knowledge to help empower others to find their inner awesomeness!", "groups": ["GDE"] }, - "dennispbrown": { - "name": "Denny Brown", - "picture": "denny.jpg", - "bio": "Denny is founder of Expert Support, a professional services firm specializing in technical communication, and leads the Angular technical writing team. His lifelong passion has been to reduce the time and effort required to understand complex technical information. Early on, he was Associate Chairman of the Computer Science Department at Stanford, where he taught introductory courses in programming. He also plays old-timers baseball in local leagues and national tournaments.", - "groups": ["Angular"], - "lead": "aikidave" - }, - "jbogarthyde": { - "name": "Judy Bogart", - "picture": "judy.png", - "groups": ["Angular"], - "lead": "dennispbrown" - }, - "rockument69": { - "name": "Tony Bove", - "picture": "rockument69.jpg", - "bio": "Tony is a technical writer with Expert Support. His lifelong passions are helping people use technology, writing fiction, and playing music. When he's not working or playing the harmonica with friends in a bluegrass band, he's swimming and snorkeling on a Kauai beach and playing ball with his Irish Wolfhound. He's worked at home for decades before it became a thing.", - "groups": ["Angular"], - "lead": "aikidave" - }, "kapunahelewong": { "name": "Kapunahele Wong", "picture": "kapunahele.jpg", From 6ff28ac9449ebb48701e848a31ffa70b21cbe5db Mon Sep 17 00:00:00 2001 From: Gillan Martindale Date: Mon, 10 Aug 2020 16:28:39 +0100 Subject: [PATCH 002/629] docs: Remove redundant sentence from Router (#38398) PR Close #38398 --- aio/content/guide/router.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/aio/content/guide/router.md b/aio/content/guide/router.md index abf916b369..b7aea18a7b 100644 --- a/aio/content/guide/router.md +++ b/aio/content/guide/router.md @@ -2,10 +2,9 @@ In a single-page app, you change what the user sees by showing or hiding portions of the display that correspond to particular components, rather than going out to the server to get a new page. As users perform application tasks, they need to move between the different [views](guide/glossary#view "Definition of view") that you have defined. -To implement this kind of navigation within the single page of your app, you use the Angular **`Router`**. -To handle the navigation from one [view](guide/glossary#view) to the next, you use the Angular _router_. -The router enables navigation by interpreting a browser URL as an instruction to change the view. +To handle the navigation from one [view](guide/glossary#view) to the next, you use the Angular **`Router`**. +The **`Router`** enables navigation by interpreting a browser URL as an instruction to change the view. To explore a sample app featuring the router's primary features, see the . From 6d8c73a4d62380bd7cfcaeaa23d5db802467d485 Mon Sep 17 00:00:00 2001 From: Misko Hevery Date: Tue, 4 Aug 2020 12:42:12 -0700 Subject: [PATCH 003/629] fix(core): Store the currently selected ICU in `LView` (#38345) The currently selected ICU was incorrectly being stored it `TNode` rather than in `LView`. Remove: `TIcuContainerNode.activeCaseIndex` Add: `LView[TIcu.currentCaseIndex]` PR Close #38345 --- .../size-tracking/integration-payloads.json | 2 +- packages/core/src/render3/bindings.ts | 4 +- packages/core/src/render3/component.ts | 4 +- packages/core/src/render3/i18n.ts | 194 +++++++----- .../core/src/render3/instructions/advance.ts | 4 +- .../core/src/render3/instructions/element.ts | 4 +- .../render3/instructions/element_container.ts | 4 +- .../core/src/render3/instructions/listener.ts | 4 +- .../src/render3/instructions/lview_debug.ts | 25 +- .../core/src/render3/instructions/shared.ts | 232 ++++++++------- .../core/src/render3/instructions/text.ts | 5 +- packages/core/src/render3/interfaces/i18n.ts | 8 + packages/core/src/render3/interfaces/node.ts | 7 - packages/core/src/render3/pure_function.ts | 4 +- packages/core/src/render3/query.ts | 8 +- .../src/render3/styling/style_binding_list.ts | 4 +- packages/core/src/render3/util/view_utils.ts | 8 +- packages/core/src/util/assert.ts | 2 +- packages/core/test/acceptance/i18n_spec.ts | 275 +++++++++++++++++- packages/core/test/render3/i18n_spec.ts | 109 +++---- 20 files changed, 624 insertions(+), 283 deletions(-) diff --git a/goldens/size-tracking/integration-payloads.json b/goldens/size-tracking/integration-payloads.json index 9a036944cf..19530230df 100644 --- a/goldens/size-tracking/integration-payloads.json +++ b/goldens/size-tracking/integration-payloads.json @@ -62,7 +62,7 @@ "bundle": "TODO(i): we should define ngDevMode to false in Closure, but --define only works in the global scope.", "bundle": "TODO(i): (FW-2164) TS 3.9 new class shape seems to have broken Closure in big ways. The size went from 169991 to 252338", "bundle": "TODO(i): after removal of tsickle from ngc-wrapped / ng_package, we had to switch to SIMPLE optimizations which increased the size from 252338 to 1198917, see PR#37221 and PR#37317 for more info", - "bundle": 1213130 + "bundle": 1213769 } } } diff --git a/packages/core/src/render3/bindings.ts b/packages/core/src/render3/bindings.ts index 333bff96d6..0968e054ad 100644 --- a/packages/core/src/render3/bindings.ts +++ b/packages/core/src/render3/bindings.ts @@ -7,7 +7,7 @@ */ import {devModeEqual} from '../change_detection/change_detection_util'; -import {assertDataInRange, assertLessThan, assertNotSame} from '../util/assert'; +import {assertIndexInRange, assertLessThan, assertNotSame} from '../util/assert'; import {getExpressionChangedErrorDetails, throwErrorIfNoChangesMode} from './errors'; import {LView} from './interfaces/view'; @@ -24,7 +24,7 @@ export function updateBinding(lView: LView, bindingIndex: number, value: any): a /** Gets the current binding value. */ export function getBinding(lView: LView, bindingIndex: number): any { - ngDevMode && assertDataInRange(lView, bindingIndex); + ngDevMode && assertIndexInRange(lView, bindingIndex); ngDevMode && assertNotSame(lView[bindingIndex], NO_CHANGE, 'Stored value should never be NO_CHANGE.'); return lView[bindingIndex]; diff --git a/packages/core/src/render3/component.ts b/packages/core/src/render3/component.ts index 3bd154914f..0f411079a1 100644 --- a/packages/core/src/render3/component.ts +++ b/packages/core/src/render3/component.ts @@ -11,7 +11,7 @@ import {Type} from '../core'; import {Injector} from '../di/injector'; import {Sanitizer} from '../sanitization/sanitizer'; -import {assertDataInRange} from '../util/assert'; +import {assertIndexInRange} from '../util/assert'; import {assertComponentType} from './assert'; import {getComponentDef} from './definition'; @@ -172,7 +172,7 @@ export function createRootComponentView( rNode: RElement|null, def: ComponentDef, rootView: LView, rendererFactory: RendererFactory3, hostRenderer: Renderer3, sanitizer?: Sanitizer|null): LView { const tView = rootView[TVIEW]; - ngDevMode && assertDataInRange(rootView, 0 + HEADER_OFFSET); + ngDevMode && assertIndexInRange(rootView, 0 + HEADER_OFFSET); rootView[0 + HEADER_OFFSET] = rNode; const tNode: TElementNode = getOrCreateTNode(tView, null, 0, TNodeType.Element, null, null); const mergedAttrs = tNode.mergedAttrs = def.hostAttrs; diff --git a/packages/core/src/render3/i18n.ts b/packages/core/src/render3/i18n.ts index ab8e7b4dfc..e7da73a264 100644 --- a/packages/core/src/render3/i18n.ts +++ b/packages/core/src/render3/i18n.ts @@ -13,13 +13,13 @@ import {getTemplateContent, SRCSET_ATTRS, URI_ATTRS, VALID_ATTRS, VALID_ELEMENTS import {getInertBodyHelper} from '../sanitization/inert_body'; import {_sanitizeUrl, sanitizeSrcset} from '../sanitization/url_sanitizer'; import {addAllToArray} from '../util/array_utils'; -import {assertDataInRange, assertDefined, assertEqual} from '../util/assert'; +import {assertDefined, assertEqual, assertGreaterThan, assertIndexInRange} from '../util/assert'; import {bindingUpdated} from './bindings'; import {attachPatchData} from './context_discovery'; import {i18nMutateOpCodesToString, i18nUpdateOpCodesToString} from './i18n_debug'; import {setDelayProjection} from './instructions/all'; -import {allocExpando, elementAttributeInternal, elementPropertyInternal, getOrCreateTNode, setInputsForProperty, setNgReflectProperties, textBindingInternal} from './instructions/shared'; +import {allocExpando, elementAttributeInternal, elementPropertyInternal, getOrCreateTNode, setInputsForProperty, setNgReflectProperties, textBindingInternal as applyTextBinding} from './instructions/shared'; import {LContainer, NATIVE} from './interfaces/container'; import {getDocument} from './interfaces/document'; import {COMMENT_MARKER, ELEMENT_MARKER, I18nMutateOpCode, I18nMutateOpCodes, I18nUpdateOpCode, I18nUpdateOpCodes, IcuType, TI18n, TIcu} from './interfaces/i18n'; @@ -726,7 +726,7 @@ function i18nEndFirstPass(tView: TView, lView: LView) { const lastCreatedNode = getPreviousOrParentTNode(); // Read the instructions to insert/move/remove DOM elements - const visitedNodes = readCreateOpCodes(rootIndex, tI18n.create, tView, lView); + const visitedNodes = applyCreateOpCodes(tView, rootIndex, tI18n.create, lView); // Remove deleted nodes let index = rootIndex + 1; @@ -756,7 +756,7 @@ function createDynamicNodeAtIndex( tView: TView, lView: LView, index: number, type: TNodeType, native: RElement|RText|null, name: string|null): TElementNode|TIcuContainerNode { const previousOrParentTNode = getPreviousOrParentTNode(); - ngDevMode && assertDataInRange(lView, index + HEADER_OFFSET); + ngDevMode && assertIndexInRange(lView, index + HEADER_OFFSET); lView[index + HEADER_OFFSET] = native; // FIXME(misko): Why does this create A TNode??? I would not expect this to be here. const tNode = getOrCreateTNode(tView, lView[T_HOST], index, type as any, name, null); @@ -770,8 +770,16 @@ function createDynamicNodeAtIndex( return tNode; } -function readCreateOpCodes( - index: number, createOpCodes: I18nMutateOpCodes, tView: TView, lView: LView): number[] { +/** + * Apply `I18nMutateOpCodes` OpCodes. + * + * @param tView Current `TView` + * @param rootIndex Pointer to the root (parent) tNode for the i18n. + * @param createOpCodes OpCodes to process + * @param lView Current `LView` + */ +function applyCreateOpCodes( + tView: TView, rootindex: number, createOpCodes: I18nMutateOpCodes, lView: LView): number[] { const renderer = lView[RENDERER]; let currentTNode: TNode|null = null; let previousTNode: TNode|null = null; @@ -792,7 +800,7 @@ function readCreateOpCodes( case I18nMutateOpCode.AppendChild: const destinationNodeIndex = opCode >>> I18nMutateOpCode.SHIFT_PARENT; let destinationTNode: TNode; - if (destinationNodeIndex === index) { + if (destinationNodeIndex === rootindex) { // If the destination node is `i18nStart`, we don't have a // top-level node and we should use the host node instead destinationTNode = lView[T_HOST]!; @@ -852,7 +860,6 @@ function readCreateOpCodes( tView, lView, commentNodeIndex, TNodeType.IcuContainer, commentRNode, null); visitedNodes.push(commentNodeIndex); attachPatchData(commentRNode, lView); - (currentTNode as TIcuContainerNode).activeCaseIndex = null; // We will add the case nodes later, during the update phase setIsNotParent(); break; @@ -881,16 +888,27 @@ function readCreateOpCodes( return visitedNodes; } -function readUpdateOpCodes( - updateOpCodes: I18nUpdateOpCodes, icus: TIcu[]|null, bindingsStartIndex: number, - changeMask: number, tView: TView, lView: LView, bypassCheckBit: boolean) { +/** + * Apply `I18nUpdateOpCodes` OpCodes + * + * @param tView Current `TView` + * @param tIcus If ICUs present than this contains them. + * @param lView Current `LView` + * @param updateOpCodes OpCodes to process + * @param bindingsStartIndex Location of the first `ɵɵi18nApply` + * @param changeMask Each bit corresponds to a `ɵɵi18nExp` (Counting backwards from + * `bindingsStartIndex`) + */ +function applyUpdateOpCodes( + tView: TView, tIcus: TIcu[]|null, lView: LView, updateOpCodes: I18nUpdateOpCodes, + bindingsStartIndex: number, changeMask: number) { let caseCreated = false; for (let i = 0; i < updateOpCodes.length; i++) { // bit code to check if we should apply the next update const checkBit = updateOpCodes[i] as number; // Number of opCodes to skip until next set of update codes const skipCodes = updateOpCodes[++i] as number; - if (bypassCheckBit || (checkBit & changeMask)) { + if (checkBit & changeMask) { // The value has been updated since last checked let value = ''; for (let j = i + 1; j <= (i + skipCodes); j++) { @@ -912,16 +930,16 @@ function readUpdateOpCodes( sanitizeFn, false); break; case I18nUpdateOpCode.Text: - textBindingInternal(lView, nodeIndex, value); + applyTextBinding(lView, nodeIndex, value); break; case I18nUpdateOpCode.IcuSwitch: - caseCreated = icuSwitchCase( - tView, updateOpCodes[++j] as number, nodeIndex, icus!, lView, value); + caseCreated = + applyIcuSwitchCase(tView, tIcus!, updateOpCodes[++j] as number, lView, value); break; case I18nUpdateOpCode.IcuUpdate: - icuUpdateCase( - tView, lView, updateOpCodes[++j] as number, nodeIndex, bindingsStartIndex, - icus!, caseCreated); + applyIcuUpdateCase( + tView, tIcus!, updateOpCodes[++j] as number, bindingsStartIndex, lView, + caseCreated); break; } } @@ -932,68 +950,100 @@ function readUpdateOpCodes( } } -function icuUpdateCase( - tView: TView, lView: LView, tIcuIndex: number, nodeIndex: number, bindingsStartIndex: number, - tIcus: TIcu[], caseCreated: boolean) { +/** + * Apply OpCodes associated with updating an existing ICU. + * + * @param tView Current `TView` + * @param tIcus tIcus ICUs active at this location them. + * @param tIcuIndex Index into `tIcus` to process. + * @param bindingsStartIndex Location of the first `ɵɵi18nApply` + * @param lView Current `LView` + * @param changeMask Each bit corresponds to a `ɵɵi18nExp` (Counting backwards from + * `bindingsStartIndex`) + */ +function applyIcuUpdateCase( + tView: TView, tIcus: TIcu[], tIcuIndex: number, bindingsStartIndex: number, lView: LView, + caseCreated: boolean) { + ngDevMode && assertIndexInRange(tIcus, tIcuIndex); const tIcu = tIcus[tIcuIndex]; - const icuTNode = getTNode(tView, nodeIndex) as TIcuContainerNode; - if (icuTNode.activeCaseIndex !== null) { - readUpdateOpCodes( - tIcu.update[icuTNode.activeCaseIndex], tIcus, bindingsStartIndex, changeMask, tView, lView, - caseCreated); + ngDevMode && assertIndexInRange(lView, tIcu.currentCaseLViewIndex); + const activeCaseIndex = lView[tIcu.currentCaseLViewIndex]; + if (activeCaseIndex !== null) { + const mask = caseCreated ? + -1 : // -1 is same as all bits on, which simulates creation since it marks all bits dirty + changeMask; + applyUpdateOpCodes(tView, tIcus, lView, tIcu.update[activeCaseIndex], bindingsStartIndex, mask); } } -function icuSwitchCase( - tView: TView, tIcuIndex: number, nodeIndex: number, tIcus: TIcu[], lView: LView, - value: string): boolean { - const tIcu = tIcus[tIcuIndex]; - const icuTNode = getTNode(tView, nodeIndex) as TIcuContainerNode; +/** + * Apply OpCodes associated with switching a case on ICU. + * + * This involves tearing down existing case and than building up a new case. + * + * @param tView Current `TView` + * @param tIcus ICUs active at this location. + * @param tIcuIndex Index into `tIcus` to process. + * @param lView Current `LView` + * @param value Value of the case to update to. + * @returns true if a new case was created (needed so that the update executes regardless of the + * bitmask) + */ +function applyIcuSwitchCase( + tView: TView, tIcus: TIcu[], tIcuIndex: number, lView: LView, value: string): boolean { + applyIcuSwitchCaseRemove(tView, tIcus, tIcuIndex, lView); + + // Rebuild a new case for this ICU let caseCreated = false; - // If there is an active case, delete the old nodes - if (icuTNode.activeCaseIndex !== null) { - const removeCodes = tIcu.remove[icuTNode.activeCaseIndex]; + ngDevMode && assertIndexInRange(tIcus, tIcuIndex); + const tIcu = tIcus[tIcuIndex]; + const caseIndex = getCaseIndex(tIcu, value); + lView[tIcu.currentCaseLViewIndex] = caseIndex !== -1 ? caseIndex : null; + if (caseIndex > -1) { + // Add the nodes for the new case + applyCreateOpCodes( + tView, -1, // -1 means we don't have parent node + tIcu.create[caseIndex], lView); + caseCreated = true; + } + return caseCreated; +} + +/** + * Apply OpCodes associated with tearing down of DOM. + * + * This involves tearing down existing case and than building up a new case. + * + * @param tView Current `TView` + * @param tIcus ICUs active at this location. + * @param tIcuIndex Index into `tIcus` to process. + * @param lView Current `LView` + * @returns true if a new case was created (needed so that the update executes regardless of the + * bitmask) + */ +function applyIcuSwitchCaseRemove(tView: TView, tIcus: TIcu[], tIcuIndex: number, lView: LView) { + ngDevMode && assertIndexInRange(tIcus, tIcuIndex); + const tIcu = tIcus[tIcuIndex]; + const activeCaseIndex = lView[tIcu.currentCaseLViewIndex]; + if (activeCaseIndex !== null) { + const removeCodes = tIcu.remove[activeCaseIndex]; for (let k = 0; k < removeCodes.length; k++) { const removeOpCode = removeCodes[k] as number; const nodeOrIcuIndex = removeOpCode >>> I18nMutateOpCode.SHIFT_REF; switch (removeOpCode & I18nMutateOpCode.MASK_INSTRUCTION) { case I18nMutateOpCode.Remove: + // FIXME(misko): this comment is wrong! // Remove DOM element, but do *not* mark TNode as detached, since we are // just switching ICU cases (while keeping the same TNode), so a DOM element // representing a new ICU case will be re-created. removeNode(tView, lView, nodeOrIcuIndex, /* markAsDetached */ false); break; case I18nMutateOpCode.RemoveNestedIcu: - removeNestedIcu( - tView, tIcus, removeCodes, nodeOrIcuIndex, - removeCodes[k + 1] as number >>> I18nMutateOpCode.SHIFT_REF); + applyIcuSwitchCaseRemove(tView, tIcus, nodeOrIcuIndex, lView); break; } } } - - // Update the active caseIndex - const caseIndex = getCaseIndex(tIcu, value); - icuTNode.activeCaseIndex = caseIndex !== -1 ? caseIndex : null; - if (caseIndex > -1) { - // Add the nodes for the new case - readCreateOpCodes( - -1 /* -1 means we don't have parent node */, tIcu.create[caseIndex], tView, lView); - caseCreated = true; - } - return caseCreated; -} - -function removeNestedIcu( - tView: TView, tIcus: TIcu[], removeCodes: I18nMutateOpCodes, nodeIndex: number, - nestedIcuNodeIndex: number) { - const nestedIcuTNode = getTNode(tView, nestedIcuNodeIndex) as TIcuContainerNode; - const activeIndex = nestedIcuTNode.activeCaseIndex; - if (activeIndex !== null) { - const nestedTIcu = tIcus[nodeIndex]; - // FIXME(misko): the fact that we are adding items to parent list looks very suspect! - addAllToArray(nestedTIcu.remove[activeIndex], removeCodes); - } } function removeNode(tView: TView, lView: LView, index: number, markAsDetached: boolean) { @@ -1153,18 +1203,18 @@ export function ɵɵi18nApply(index: number) { if (shiftsCounter) { const tView = getTView(); ngDevMode && assertDefined(tView, `tView should be defined`); - const tI18n = tView.data[index + HEADER_OFFSET]; + const tI18n = tView.data[index + HEADER_OFFSET] as TI18n | I18nUpdateOpCodes; let updateOpCodes: I18nUpdateOpCodes; - let icus: TIcu[]|null = null; + let tIcus: TIcu[]|null = null; if (Array.isArray(tI18n)) { updateOpCodes = tI18n as I18nUpdateOpCodes; } else { updateOpCodes = (tI18n as TI18n).update; - icus = (tI18n as TI18n).icus; + tIcus = (tI18n as TI18n).icus; } const bindingsStartIndex = getBindingIndex() - shiftsCounter - 1; const lView = getLView(); - readUpdateOpCodes(updateOpCodes, icus, bindingsStartIndex, changeMask, tView, lView, false); + applyUpdateOpCodes(tView, tIcus, lView, updateOpCodes, bindingsStartIndex, changeMask); // Reset changeMask & maskBit to default for the next update cycle changeMask = 0b0; @@ -1215,9 +1265,10 @@ function icuStart( const updateCodes: I18nUpdateOpCodes[] = []; const vars = []; const childIcus: number[][] = []; - for (let i = 0; i < icuExpression.values.length; i++) { + const values = icuExpression.values; + for (let i = 0; i < values.length; i++) { // Each value is an array of strings & other ICU expressions - const valueArr = icuExpression.values[i]; + const valueArr = values[i]; const nestedIcus: IcuExpression[] = []; for (let j = 0; j < valueArr.length; j++) { const value = valueArr[j]; @@ -1239,6 +1290,9 @@ function icuStart( const tIcu: TIcu = { type: icuExpression.type, vars, + currentCaseLViewIndex: HEADER_OFFSET + + expandoStartIndex // expandoStartIndex does not include the header so add it. + + 1, // The first item stored is the `` anchor so skip it. childIcus, cases: icuExpression.cases, create: createCodes, @@ -1269,7 +1323,13 @@ function parseIcuCase( throw new Error('Unable to generate inert body element'); } const wrapper = getTemplateContent(inertBodyElement!) as Element || inertBodyElement; - const opCodes: IcuCase = {vars: 0, childIcus: [], create: [], remove: [], update: []}; + const opCodes: IcuCase = { + vars: 1, // allocate space for `TIcu.currentCaseLViewIndex` + childIcus: [], + create: [], + remove: [], + update: [] + }; if (ngDevMode) { attachDebugGetter(opCodes.create, i18nMutateOpCodesToString); attachDebugGetter(opCodes.remove, i18nMutateOpCodesToString); diff --git a/packages/core/src/render3/instructions/advance.ts b/packages/core/src/render3/instructions/advance.ts index 0abc852850..3eb585b93c 100644 --- a/packages/core/src/render3/instructions/advance.ts +++ b/packages/core/src/render3/instructions/advance.ts @@ -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 {assertDataInRange, assertGreaterThan} from '../../util/assert'; +import {assertGreaterThan, assertIndexInRange} from '../../util/assert'; import {executeCheckHooks, executeInitAndCheckHooks} from '../hooks'; import {FLAGS, HEADER_OFFSET, InitPhaseState, LView, LViewFlags, TView} from '../interfaces/view'; import {getCheckNoChangesMode, getLView, getSelectedIndex, getTView, setSelectedIndex} from '../state'; @@ -52,7 +52,7 @@ export function ɵɵselect(index: number): void { export function selectIndexInternal( tView: TView, lView: LView, index: number, checkNoChangesMode: boolean) { ngDevMode && assertGreaterThan(index, -1, 'Invalid index'); - ngDevMode && assertDataInRange(lView, index + HEADER_OFFSET); + ngDevMode && assertIndexInRange(lView, index + HEADER_OFFSET); // Flush the initial hooks for elements in the view that have been added up to this point. // PERF WARNING: do NOT extract this to a separate function without running benchmarks diff --git a/packages/core/src/render3/instructions/element.ts b/packages/core/src/render3/instructions/element.ts index 2b9b82c73f..aaa2f8e4fe 100644 --- a/packages/core/src/render3/instructions/element.ts +++ b/packages/core/src/render3/instructions/element.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {assertDataInRange, assertDefined, assertEqual} from '../../util/assert'; +import {assertDefined, assertEqual, assertIndexInRange} from '../../util/assert'; import {assertFirstCreatePass, assertHasParent} from '../assert'; import {attachPatchData} from '../context_discovery'; import {registerPostOrderHooks} from '../hooks'; @@ -79,7 +79,7 @@ export function ɵɵelementStart( getBindingIndex(), tView.bindingStartIndex, 'elements should be created before any bindings'); ngDevMode && ngDevMode.rendererCreateElement++; - ngDevMode && assertDataInRange(lView, adjustedIndex); + ngDevMode && assertIndexInRange(lView, adjustedIndex); const renderer = lView[RENDERER]; const native = lView[adjustedIndex] = elementCreate(name, renderer, getNamespace()); diff --git a/packages/core/src/render3/instructions/element_container.ts b/packages/core/src/render3/instructions/element_container.ts index 05d5b57052..6a7a6ef7a4 100644 --- a/packages/core/src/render3/instructions/element_container.ts +++ b/packages/core/src/render3/instructions/element_container.ts @@ -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 {assertDataInRange, assertEqual} from '../../util/assert'; +import {assertEqual, assertIndexInRange} from '../../util/assert'; import {assertHasParent} from '../assert'; import {attachPatchData} from '../context_discovery'; import {registerPostOrderHooks} from '../hooks'; @@ -66,7 +66,7 @@ export function ɵɵelementContainerStart( const tView = getTView(); const adjustedIndex = index + HEADER_OFFSET; - ngDevMode && assertDataInRange(lView, adjustedIndex); + ngDevMode && assertIndexInRange(lView, adjustedIndex); ngDevMode && assertEqual( getBindingIndex(), tView.bindingStartIndex, diff --git a/packages/core/src/render3/instructions/listener.ts b/packages/core/src/render3/instructions/listener.ts index 4caf2af8e1..37b8766724 100644 --- a/packages/core/src/render3/instructions/listener.ts +++ b/packages/core/src/render3/instructions/listener.ts @@ -7,7 +7,7 @@ */ -import {assertDataInRange} from '../../util/assert'; +import {assertIndexInRange} from '../../util/assert'; import {isObservable} from '../../util/lang'; import {EMPTY_OBJ} from '../empty'; import {PropertyAliasValue, TNode, TNodeFlags, TNodeType} from '../interfaces/node'; @@ -204,7 +204,7 @@ function listenerInternal( if (propsLength) { for (let i = 0; i < propsLength; i += 2) { const index = props[i] as number; - ngDevMode && assertDataInRange(lView, index); + ngDevMode && assertIndexInRange(lView, index); const minifiedName = props[i + 1]; const directiveInstance = lView[index]; const output = directiveInstance[minifiedName]; diff --git a/packages/core/src/render3/instructions/lview_debug.ts b/packages/core/src/render3/instructions/lview_debug.ts index 7ea4307567..27bf8eeb9c 100644 --- a/packages/core/src/render3/instructions/lview_debug.ts +++ b/packages/core/src/render3/instructions/lview_debug.ts @@ -362,19 +362,24 @@ export function toDebug(obj: any): any { * (will not serialize child elements). */ function toHtml(value: any, includeChildren: boolean = false): string|null { - const node: HTMLElement|null = unwrapRNode(value) as any; + const node: Node|null = unwrapRNode(value) as any; if (node) { - const isTextNode = node.nodeType === Node.TEXT_NODE; - const outerHTML = (isTextNode ? node.textContent : node.outerHTML) || ''; - if (includeChildren || isTextNode) { - return outerHTML; - } else { - const innerHTML = '>' + node.innerHTML + '<'; - return (outerHTML.split(innerHTML)[0]) + '>'; + switch (node.nodeType) { + case Node.TEXT_NODE: + return node.textContent; + case Node.COMMENT_NODE: + return ``; + case Node.ELEMENT_NODE: + const outerHTML = (node as Element).outerHTML; + if (includeChildren) { + return outerHTML; + } else { + const innerHTML = '>' + (node as Element).innerHTML + '<'; + return (outerHTML.split(innerHTML)[0]) + '>'; + } } - } else { - return null; } + return null; } export class LViewDebug implements ILViewDebug { diff --git a/packages/core/src/render3/instructions/shared.ts b/packages/core/src/render3/instructions/shared.ts index 929a5fa78c..c707856b0f 100644 --- a/packages/core/src/render3/instructions/shared.ts +++ b/packages/core/src/render3/instructions/shared.ts @@ -12,7 +12,7 @@ import {CUSTOM_ELEMENTS_SCHEMA, NO_ERRORS_SCHEMA, SchemaMetadata} from '../../me import {ViewEncapsulation} from '../../metadata/view'; import {validateAgainstEventAttributes, validateAgainstEventProperties} from '../../sanitization/sanitization'; import {Sanitizer} from '../../sanitization/sanitizer'; -import {assertDataInRange, assertDefined, assertDomNode, assertEqual, assertGreaterThan, assertLessThan, assertNotEqual, assertNotSame, assertSame} from '../../util/assert'; +import {assertDefined, assertDomNode, assertEqual, assertGreaterThan, assertIndexInRange, assertLessThan, assertNotEqual, assertNotSame, assertSame} from '../../util/assert'; import {createNamedArrayType} from '../../util/named_array_type'; import {initNgDevMode} from '../../util/ng_dev_mode'; import {normalizeDebugBindingName, normalizeDebugBindingValue} from '../../util/ng_reflect'; @@ -236,13 +236,6 @@ export function getOrCreateTNode( const tNode = tView.data[adjustedIndex] as TNode || createTNodeAtIndex(tView, tHostNode, adjustedIndex, type, name, attrs); setPreviousOrParentTNode(tNode, true); - if (ngDevMode) { - // For performance reasons it is important that the tNode retains the same shape during runtime. - // (To make sure that all of the code is monomorphic.) For this reason we seal the object to - // prevent class transitions. - // FIXME(misko): re-enable this once i18n code is compliant with this. - // Object.seal(tNode); - } return tNode as TElementNode & TViewNode & TContainerNode & TElementContainerNode & TProjectionNode & TIcuContainerNode; } @@ -665,44 +658,44 @@ export function createTView( // that has a host binding, we will update the blueprint with that def's hostVars count. const initialViewLength = bindingStartIndex + vars; const blueprint = createViewBlueprint(bindingStartIndex, initialViewLength); - return blueprint[TVIEW as any] = ngDevMode ? + const tView = blueprint[TVIEW as any] = ngDevMode ? new TViewConstructor( - type, - viewIndex, // id: number, - blueprint, // blueprint: LView, - templateFn, // template: ComponentTemplate<{}>|null, - null, // queries: TQueries|null - viewQuery, // viewQuery: ViewQueriesFunction<{}>|null, - null!, // node: TViewNode|TElementNode|null, - cloneToTViewData(blueprint).fill(null, bindingStartIndex), // data: TData, - bindingStartIndex, // bindingStartIndex: number, - initialViewLength, // expandoStartIndex: number, - null, // expandoInstructions: ExpandoInstructions|null, - true, // firstCreatePass: boolean, - true, // firstUpdatePass: boolean, - false, // staticViewQueries: boolean, - false, // staticContentQueries: boolean, - null, // preOrderHooks: HookData|null, - null, // preOrderCheckHooks: HookData|null, - null, // contentHooks: HookData|null, - null, // contentCheckHooks: HookData|null, - null, // viewHooks: HookData|null, - null, // viewCheckHooks: HookData|null, - null, // destroyHooks: DestroyHookData|null, - null, // cleanup: any[]|null, - null, // contentQueries: number[]|null, - null, // components: number[]|null, - typeof directives === 'function' ? - directives() : - directives, // directiveRegistry: DirectiveDefList|null, - typeof pipes === 'function' ? pipes() : pipes, // pipeRegistry: PipeDefList|null, - null, // firstChild: TNode|null, - schemas, // schemas: SchemaMetadata[]|null, - consts, // consts: TConstants|null - false, // incompleteFirstPass: boolean - decls, // ngDevMode only: decls - vars, // ngDevMode only: vars - ) : + type, + viewIndex, // id: number, + blueprint, // blueprint: LView, + templateFn, // template: ComponentTemplate<{}>|null, + null, // queries: TQueries|null + viewQuery, // viewQuery: ViewQueriesFunction<{}>|null, + null!, // node: TViewNode|TElementNode|null, + cloneToTViewData(blueprint).fill(null, bindingStartIndex), // data: TData, + bindingStartIndex, // bindingStartIndex: number, + initialViewLength, // expandoStartIndex: number, + null, // expandoInstructions: ExpandoInstructions|null, + true, // firstCreatePass: boolean, + true, // firstUpdatePass: boolean, + false, // staticViewQueries: boolean, + false, // staticContentQueries: boolean, + null, // preOrderHooks: HookData|null, + null, // preOrderCheckHooks: HookData|null, + null, // contentHooks: HookData|null, + null, // contentCheckHooks: HookData|null, + null, // viewHooks: HookData|null, + null, // viewCheckHooks: HookData|null, + null, // destroyHooks: DestroyHookData|null, + null, // cleanup: any[]|null, + null, // contentQueries: number[]|null, + null, // components: number[]|null, + typeof directives === 'function' ? + directives() : + directives, // directiveRegistry: DirectiveDefList|null, + typeof pipes === 'function' ? pipes() : pipes, // pipeRegistry: PipeDefList|null, + null, // firstChild: TNode|null, + schemas, // schemas: SchemaMetadata[]|null, + consts, // consts: TConstants|null + false, // incompleteFirstPass: boolean + decls, // ngDevMode only: decls + vars, // ngDevMode only: vars + ) : { type: type, id: viewIndex, @@ -736,6 +729,13 @@ export function createTView( consts: consts, incompleteFirstPass: false }; + if (ngDevMode) { + // For performance reasons it is important that the tView retains the same shape during runtime. + // (To make sure that all of the code is monomorphic.) For this reason we seal the object to + // prevent class transitions. + Object.seal(tView); + } + return tView; } function createViewBlueprint(bindingStartIndex: number, initialViewLength: number): LView { @@ -825,71 +825,79 @@ export function createTNode( tagName: string|null, attrs: TAttributes|null): TNode { ngDevMode && ngDevMode.tNode++; let injectorIndex = tParent ? tParent.injectorIndex : -1; - return ngDevMode ? new TNodeDebug( - tView, // tView_: TView - type, // type: TNodeType - adjustedIndex, // index: number - injectorIndex, // injectorIndex: number - -1, // directiveStart: number - -1, // directiveEnd: number - -1, // directiveStylingLast: number - null, // propertyBindings: number[]|null - 0, // flags: TNodeFlags - 0, // providerIndexes: TNodeProviderIndexes - tagName, // tagName: string|null - attrs, // attrs: (string|AttributeMarker|(string|SelectorFlags)[])[]|null - null, // mergedAttrs - null, // localNames: (string|number)[]|null - undefined, // initialInputs: (string[]|null)[]|null|undefined - null, // inputs: PropertyAliases|null - null, // outputs: PropertyAliases|null - null, // tViews: ITView|ITView[]|null - null, // next: ITNode|null - null, // projectionNext: ITNode|null - null, // child: ITNode|null - tParent, // parent: TElementNode|TContainerNode|null - null, // projection: number|(ITNode|RNode[])[]|null - null, // styles: string|null - null, // stylesWithoutHost: string|null - undefined, // residualStyles: string|null - null, // classes: string|null - null, // classesWithoutHost: string|null - undefined, // residualClasses: string|null - 0 as any, // classBindings: TStylingRange; - 0 as any, // styleBindings: TStylingRange; - ) : - { - type: type, - index: adjustedIndex, - injectorIndex: injectorIndex, - directiveStart: -1, - directiveEnd: -1, - directiveStylingLast: -1, - propertyBindings: null, - flags: 0, - providerIndexes: 0, - tagName: tagName, - attrs: attrs, - mergedAttrs: null, - localNames: null, - initialInputs: undefined, - inputs: null, - outputs: null, - tViews: null, - next: null, - projectionNext: null, - child: null, - parent: tParent, - projection: null, - styles: null, - stylesWithoutHost: null, - residualStyles: undefined, - classes: null, - classesWithoutHost: null, - residualClasses: undefined, - classBindings: 0 as any, - styleBindings: 0 as any, - }; + const tNode = ngDevMode ? + new TNodeDebug( + tView, // tView_: TView + type, // type: TNodeType + adjustedIndex, // index: number + injectorIndex, // injectorIndex: number + -1, // directiveStart: number + -1, // directiveEnd: number + -1, // directiveStylingLast: number + null, // propertyBindings: number[]|null + 0, // flags: TNodeFlags + 0, // providerIndexes: TNodeProviderIndexes + tagName, // tagName: string|null + attrs, // attrs: (string|AttributeMarker|(string|SelectorFlags)[])[]|null + null, // mergedAttrs + null, // localNames: (string|number)[]|null + undefined, // initialInputs: (string[]|null)[]|null|undefined + null, // inputs: PropertyAliases|null + null, // outputs: PropertyAliases|null + null, // tViews: ITView|ITView[]|null + null, // next: ITNode|null + null, // projectionNext: ITNode|null + null, // child: ITNode|null + tParent, // parent: TElementNode|TContainerNode|null + null, // projection: number|(ITNode|RNode[])[]|null + null, // styles: string|null + null, // stylesWithoutHost: string|null + undefined, // residualStyles: string|null + null, // classes: string|null + null, // classesWithoutHost: string|null + undefined, // residualClasses: string|null + 0 as any, // classBindings: TStylingRange; + 0 as any, // styleBindings: TStylingRange; + ) : + { + type: type, + index: adjustedIndex, + injectorIndex: injectorIndex, + directiveStart: -1, + directiveEnd: -1, + directiveStylingLast: -1, + propertyBindings: null, + flags: 0, + providerIndexes: 0, + tagName: tagName, + attrs: attrs, + mergedAttrs: null, + localNames: null, + initialInputs: undefined, + inputs: null, + outputs: null, + tViews: null, + next: null, + projectionNext: null, + child: null, + parent: tParent, + projection: null, + styles: null, + stylesWithoutHost: null, + residualStyles: undefined, + classes: null, + classesWithoutHost: null, + residualClasses: undefined, + classBindings: 0 as any, + styleBindings: 0 as any, + }; + if (ngDevMode) { + // For performance reasons it is important that the tNode retains the same shape during runtime. + // (To make sure that all of the code is monomorphic.) For this reason we seal the object to + // prevent class transitions. + Object.seal(tNode); + } + return tNode; } @@ -2040,7 +2048,7 @@ export function setInputsForProperty( const index = inputs[i++] as number; const privateName = inputs[i++] as string; const instance = lView[index]; - ngDevMode && assertDataInRange(lView, index); + ngDevMode && assertIndexInRange(lView, index); const def = tView.data[index] as DirectiveDef; if (def.setInput !== null) { def.setInput!(instance, value, publicName, privateName); @@ -2055,7 +2063,7 @@ export function setInputsForProperty( */ export function textBindingInternal(lView: LView, index: number, value: string): void { ngDevMode && assertNotSame(value, NO_CHANGE as any, 'value should not be NO_CHANGE'); - ngDevMode && assertDataInRange(lView, index + HEADER_OFFSET); + ngDevMode && assertIndexInRange(lView, index + HEADER_OFFSET); const element = getNativeByIndex(index, lView) as any as RText; ngDevMode && assertDefined(element, 'native element should exist'); ngDevMode && ngDevMode.rendererSetText++; diff --git a/packages/core/src/render3/instructions/text.ts b/packages/core/src/render3/instructions/text.ts index a98d9df165..88a27e8656 100644 --- a/packages/core/src/render3/instructions/text.ts +++ b/packages/core/src/render3/instructions/text.ts @@ -5,11 +5,12 @@ * 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 {assertDataInRange, assertEqual} from '../../util/assert'; +import {assertEqual, assertIndexInRange} from '../../util/assert'; import {TElementNode, TNodeType} from '../interfaces/node'; import {HEADER_OFFSET, RENDERER, T_HOST} from '../interfaces/view'; import {appendChild, createTextNode} from '../node_manipulation'; import {getBindingIndex, getLView, getTView, setPreviousOrParentTNode} from '../state'; + import {getOrCreateTNode} from './shared'; @@ -31,7 +32,7 @@ export function ɵɵtext(index: number, value: string = ''): void { assertEqual( getBindingIndex(), tView.bindingStartIndex, 'text nodes should be created before any bindings'); - ngDevMode && assertDataInRange(lView, adjustedIndex); + ngDevMode && assertIndexInRange(lView, adjustedIndex); const tNode = tView.firstCreatePass ? getOrCreateTNode(tView, lView[T_HOST], index, TNodeType.Element, null, null) : diff --git a/packages/core/src/render3/interfaces/i18n.ts b/packages/core/src/render3/interfaces/i18n.ts index f379ed9125..7b85b495f5 100644 --- a/packages/core/src/render3/interfaces/i18n.ts +++ b/packages/core/src/render3/interfaces/i18n.ts @@ -346,6 +346,14 @@ export interface TIcu { */ vars: number[]; + /** + * Currently selected ICU case pointer. + * + * `lView[currentCaseLViewIndex]` stores the currently selected case. This is needed to know how + * to clean up the current case when transitioning no the new case. + */ + currentCaseLViewIndex: number; + /** * An optional array of child/sub ICUs. * diff --git a/packages/core/src/render3/interfaces/node.ts b/packages/core/src/render3/interfaces/node.ts index 4684232065..462105d2f4 100644 --- a/packages/core/src/render3/interfaces/node.ts +++ b/packages/core/src/render3/interfaces/node.ts @@ -709,13 +709,6 @@ export interface TIcuContainerNode extends TNode { parent: TElementNode|TElementContainerNode|null; tViews: null; projection: null; - /** - * Indicates the current active case for an ICU expression. - * It is null when there is no active case. - * - */ - // FIXME(misko): This is at a wrong location as activeCase is `LView` (not `TView`) concern - activeCaseIndex: number|null; } /** Static data for a view */ diff --git a/packages/core/src/render3/pure_function.ts b/packages/core/src/render3/pure_function.ts index 2abb42d10c..6db19df34d 100644 --- a/packages/core/src/render3/pure_function.ts +++ b/packages/core/src/render3/pure_function.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {assertDataInRange} from '../util/assert'; +import {assertIndexInRange} from '../util/assert'; import {bindingUpdated, bindingUpdated2, bindingUpdated3, bindingUpdated4, getBinding, updateBinding} from './bindings'; import {LView} from './interfaces/view'; import {getBindingRoot, getLView} from './state'; @@ -287,7 +287,7 @@ export function ɵɵpureFunctionV( * it to `undefined`. */ function getPureFunctionReturnValue(lView: LView, returnValueIndex: number) { - ngDevMode && assertDataInRange(lView, returnValueIndex); + ngDevMode && assertIndexInRange(lView, returnValueIndex); const lastReturnValue = lView[returnValueIndex]; return lastReturnValue === NO_CHANGE ? undefined : lastReturnValue; } diff --git a/packages/core/src/render3/query.ts b/packages/core/src/render3/query.ts index 63eaf2bd7d..58e587703e 100644 --- a/packages/core/src/render3/query.ts +++ b/packages/core/src/render3/query.ts @@ -15,7 +15,7 @@ import {ElementRef as ViewEngine_ElementRef} from '../linker/element_ref'; import {QueryList} from '../linker/query_list'; import {TemplateRef as ViewEngine_TemplateRef} from '../linker/template_ref'; import {ViewContainerRef} from '../linker/view_container_ref'; -import {assertDataInRange, assertDefined, throwError} from '../util/assert'; +import {assertDefined, assertIndexInRange, throwError} from '../util/assert'; import {stringify} from '../util/stringify'; import {assertFirstCreatePass, assertLContainer} from './assert'; @@ -140,7 +140,7 @@ class TQueries_ implements TQueries { } getByIndex(index: number): TQuery { - ngDevMode && assertDataInRange(this.queries, index); + ngDevMode && assertIndexInRange(this.queries, index); return this.queries[index]; } @@ -358,7 +358,7 @@ function materializeViewResults( // null as a placeholder result.push(null); } else { - ngDevMode && assertDataInRange(tViewData, matchedNodeIdx); + ngDevMode && assertIndexInRange(tViewData, matchedNodeIdx); const tNode = tViewData[matchedNodeIdx] as TNode; result.push(createResultForNode(lView, tNode, tQueryMatches[i + 1], tQuery.metadata.read)); } @@ -551,7 +551,7 @@ export function ɵɵloadQuery(): QueryList { function loadQueryInternal(lView: LView, queryIndex: number): QueryList { ngDevMode && assertDefined(lView[QUERIES], 'LQueries should be defined when trying to load a query'); - ngDevMode && assertDataInRange(lView[QUERIES]!.queries, queryIndex); + ngDevMode && assertIndexInRange(lView[QUERIES]!.queries, queryIndex); return lView[QUERIES]!.queries[queryIndex].queryList; } diff --git a/packages/core/src/render3/styling/style_binding_list.ts b/packages/core/src/render3/styling/style_binding_list.ts index 4fde39fcd4..d05bde5f93 100644 --- a/packages/core/src/render3/styling/style_binding_list.ts +++ b/packages/core/src/render3/styling/style_binding_list.ts @@ -7,7 +7,7 @@ */ import {KeyValueArray, keyValueArrayIndexOf} from '../../util/array_utils'; -import {assertDataInRange, assertEqual, assertNotEqual} from '../../util/assert'; +import {assertEqual, assertIndexInRange, assertNotEqual} from '../../util/assert'; import {assertFirstUpdatePass} from '../assert'; import {TNode} from '../interfaces/node'; import {getTStylingRangeNext, getTStylingRangePrev, setTStylingRangeNext, setTStylingRangeNextDuplicate, setTStylingRangePrev, setTStylingRangePrevDuplicate, toTStylingRange, TStylingKey, TStylingKeyPrimitive, TStylingRange} from '../interfaces/styling'; @@ -370,7 +370,7 @@ function markDuplicates( // - we are a map in which case we have to continue searching even after we find what we were // looking for since we are a wild card and everything needs to be flipped to duplicate. while (cursor !== 0 && (foundDuplicate === false || isMap)) { - ngDevMode && assertDataInRange(tData, cursor); + ngDevMode && assertIndexInRange(tData, cursor); const tStylingValueAtCursor = tData[cursor] as TStylingKey; const tStyleRangeAtCursor = tData[cursor + 1] as TStylingRange; if (isStylingMatch(tStylingValueAtCursor, tStylingKey)) { diff --git a/packages/core/src/render3/util/view_utils.ts b/packages/core/src/render3/util/view_utils.ts index 21bee1c5d6..4876a8a7d1 100644 --- a/packages/core/src/render3/util/view_utils.ts +++ b/packages/core/src/render3/util/view_utils.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {assertDataInRange, assertDefined, assertDomNode, assertGreaterThan, assertLessThan} from '../../util/assert'; +import {assertDefined, assertDomNode, assertGreaterThan, assertIndexInRange, assertLessThan} from '../../util/assert'; import {assertTNodeForLView} from '../assert'; import {LContainer, TYPE} from '../interfaces/container'; import {LContext, MONKEY_PATCH_KEY_NAME} from '../interfaces/context'; @@ -91,7 +91,7 @@ export function getNativeByIndex(index: number, lView: LView): RNode { */ export function getNativeByTNode(tNode: TNode, lView: LView): RNode { ngDevMode && assertTNodeForLView(tNode, lView); - ngDevMode && assertDataInRange(lView, tNode.index); + ngDevMode && assertIndexInRange(lView, tNode.index); const node: RNode = unwrapRNode(lView[tNode.index]); ngDevMode && !isProceduralRenderer(lView[RENDERER]) && assertDomNode(node); return node; @@ -125,13 +125,13 @@ export function getTNode(tView: TView, index: number): TNode { /** Retrieves a value from any `LView` or `TData`. */ export function load(view: LView|TData, index: number): T { - ngDevMode && assertDataInRange(view, index + HEADER_OFFSET); + ngDevMode && assertIndexInRange(view, index + HEADER_OFFSET); return view[index + HEADER_OFFSET]; } export function getComponentLViewByIndex(nodeIndex: number, hostView: LView): LView { // Could be an LView or an LContainer. If LContainer, unwrap to find LView. - ngDevMode && assertDataInRange(hostView, nodeIndex); + ngDevMode && assertIndexInRange(hostView, nodeIndex); const slotValue = hostView[nodeIndex]; const lView = isLView(slotValue) ? slotValue : slotValue[HOST]; return lView; diff --git a/packages/core/src/util/assert.ts b/packages/core/src/util/assert.ts index 1678b3bc53..1627149d56 100644 --- a/packages/core/src/util/assert.ts +++ b/packages/core/src/util/assert.ts @@ -110,7 +110,7 @@ export function assertDomNode(node: any): asserts node is Node { } -export function assertDataInRange(arr: any[], index: number) { +export function assertIndexInRange(arr: any[], index: number) { const maxLen = arr ? arr.length : 0; assertLessThan(index, maxLen, `Index expected to be less than ${maxLen} but got ${index}`); } diff --git a/packages/core/test/acceptance/i18n_spec.ts b/packages/core/test/acceptance/i18n_spec.ts index cbca1e1246..ec106a8381 100644 --- a/packages/core/test/acceptance/i18n_spec.ts +++ b/packages/core/test/acceptance/i18n_spec.ts @@ -14,7 +14,11 @@ import localeEs from '@angular/common/locales/es'; import localeRo from '@angular/common/locales/ro'; import {computeMsgId} from '@angular/compiler'; import {Component, ContentChild, ContentChildren, Directive, ElementRef, HostBinding, Input, LOCALE_ID, NO_ERRORS_SCHEMA, Pipe, PipeTransform, QueryList, RendererFactory2, TemplateRef, Type, ViewChild, ViewContainerRef, ɵsetDocument} from '@angular/core'; +import {getComponentDef} from '@angular/core/src/render3/definition'; import {setDelayProjection} from '@angular/core/src/render3/instructions/projection'; +import {TI18n, TIcu} from '@angular/core/src/render3/interfaces/i18n'; +import {DebugNode, HEADER_OFFSET, TVIEW} from '@angular/core/src/render3/interfaces/view'; +import {getComponentLView, loadLContext} from '@angular/core/src/render3/util/discovery_utils'; import {TestBed} from '@angular/core/testing'; import {clearTranslations, loadTranslations} from '@angular/localize'; import {By, ɵDomRendererFactory2 as DomRendererFactory2} from '@angular/platform-browser'; @@ -599,6 +603,217 @@ onlyInIvy('Ivy i18n logic').describe('runtime i18n', () => { }); }); + describe('dynamic TNodes', () => { + // When translation occurs the i18n system needs to create dynamic TNodes for the text + // nodes so that they can be correctly processed by the `addRemoveViewFromContainer`. + + function toTypeContent(n: DebugNode): string { + return `${n.type}(${n.html})`; + } + + it('should not create dynamic TNode when no i18n', () => { + const fixture = initWithTemplate(AppComp, `Hello World!`); + const lView = getComponentLView(fixture.componentInstance); + const hello_ = (fixture.nativeElement as Element).firstChild!; + const b = hello_.nextSibling!; + const world = b.firstChild!; + const exclamation = b.nextSibling!; + const lViewDebug = lView.debug!; + expect(lViewDebug.nodes.map(toTypeContent)).toEqual([ + 'Element(Hello )', 'Element()', 'Element(!)' + ]); + expect(lViewDebug.decls).toEqual({ + start: HEADER_OFFSET, + end: HEADER_OFFSET + 4, + length: 4, + content: [ + jasmine.objectContaining({index: HEADER_OFFSET + 0, l: hello_}), + jasmine.objectContaining({index: HEADER_OFFSET + 1, l: b}), + jasmine.objectContaining({index: HEADER_OFFSET + 2, l: world}), + jasmine.objectContaining({index: HEADER_OFFSET + 3, l: exclamation}), + ] + }); + expect(lViewDebug.i18n) + .toEqual( + {start: lViewDebug.vars.end, end: lViewDebug.expando.start, length: 0, content: []}); + }); + + it('should create dynamic TNode for text nodes', () => { + const fixture = + initWithTemplate(AppComp, `Hello World!`); + const lView = getComponentLView(fixture.componentInstance); + const hello_ = (fixture.nativeElement as Element).firstChild!; + const b = hello_.nextSibling!; + const world = b.firstChild!; + const exclamation = b.nextSibling!; + const container = exclamation.nextSibling!; + const lViewDebug = lView.debug!; + expect(lViewDebug.nodes.map(toTypeContent)).toEqual([ + 'ElementContainer()' + ]); + // This assertion shows that the translated nodes are correctly linked into the TNode tree. + expect(lViewDebug.nodes[0].children.map(toTypeContent)).toEqual([ + 'Element(Hello )', 'Element()', 'Element(!)' + ]); + // This assertion shows that the translated text is not part of decls + expect(lViewDebug.decls).toEqual({ + start: HEADER_OFFSET, + end: HEADER_OFFSET + 3, + length: 3, + content: [ + jasmine.objectContaining({index: HEADER_OFFSET + 0, l: container}), + jasmine.objectContaining({index: HEADER_OFFSET + 1}), + jasmine.objectContaining({index: HEADER_OFFSET + 2, l: b}), + ] + }); + // This assertion shows that the translated DOM elements (and corresponding TNode's are stored + // in i18n section of LView) + expect(lViewDebug.i18n).toEqual({ + start: lViewDebug.vars.end, + end: lViewDebug.expando.start, + length: 3, + content: [ + jasmine.objectContaining({index: HEADER_OFFSET + 3, l: hello_}), + jasmine.objectContaining({index: HEADER_OFFSET + 4, l: world}), + jasmine.objectContaining({index: HEADER_OFFSET + 5, l: exclamation}), + ] + }); + // This assertion shows the DOM operations which the i18n subsystem performed to update the + // DOM with translated text. The offsets in the debug text should match the offsets in the + // above assertions. + expect((lView[TVIEW]!.data[HEADER_OFFSET + 1]! as TI18n).create.debug).toEqual([ + 'lView[3] = document.createTextNode("Hello ")', + '(lView[0] as Element).appendChild(lView[3])', + '(lView[0] as Element).appendChild(lView[2])', + 'lView[4] = document.createTextNode("World")', + '(lView[2] as Element).appendChild(lView[4])', + 'setPreviousOrParentTNode(tView.data[2] as TNode)', + 'lView[5] = document.createTextNode("!")', + '(lView[0] as Element).appendChild(lView[5])', + ]); + }); + + describe('ICU', () => { + // In the case of ICUs we can't create TNodes for each ICU part, as different ICU instances + // may have different selections active and hence have different shape. In such a case + // a single `TIcuContainerNode` should be generated only. + it('should create a single dynamic TNode for ICU', () => { + const fixture = initWithTemplate(AppComp, ` + {count, plural, + =0 {just now} + =1 {one minute ago} + other {{{count}} minutes ago} + } + `); + const lView = getComponentLView(fixture.componentInstance); + const lViewDebug = lView.debug!; + expect((fixture.nativeElement as Element).textContent).toEqual('just now'); + const text_just_now = (fixture.nativeElement as Element).firstChild!; + const icuComment = text_just_now.nextSibling!; + expect(lViewDebug.nodes.map(toTypeContent)).toEqual(['IcuContainer()']); + // We want to ensure that the ICU container does not have any content! + // This is because the content is instance dependent and therefore can't be shared + // across `TNode`s. + expect(lViewDebug.nodes[0].children.map(toTypeContent)).toEqual([ + 'Element(just now)', // FIXME(misko): This should not be here. The content of the ICU is + // instance specific and as such can't be encoded in the tNodes. + ]); + expect(lViewDebug.decls).toEqual({ + start: HEADER_OFFSET, + end: HEADER_OFFSET + 1, + length: 1, + content: [ + jasmine.objectContaining({ + t: jasmine.objectContaining({ + vars: 3, // one slot for: the `` + // one slot for: the last selected ICU case. + // one slot for: the actual text node to attach. + create: jasmine.any(Object), + update: jasmine.any(Object), + icus: [jasmine.any(Object)], + }), + l: null + }), + ] + }); + expect(((lViewDebug.decls.content[0].t as TI18n).create.debug)).toEqual([ + 'lView[3] = document.createComment("ICU 3")', + '(lView[0] as Element).appendChild(lView[3])', + ]); + expect(((lViewDebug.decls.content[0].t as TI18n).update.debug)).toEqual([ + 'if (mask & 0b1) { icuSwitchCase(lView[3] as Comment, 0, `${lView[1]}`); }', + 'if (mask & 0b11) { icuUpdateCase(lView[3] as Comment, 0); }', + ]); + const tIcu = (lViewDebug.decls.content[0].t as TI18n).icus![0]; + expect(tIcu.cases).toEqual(['0', '1', 'other']); + // Case: '0' + expect(tIcu.create[0].debug).toEqual([ + 'lView[5] = document.createTextNode("just now")', + '(lView[3] as Element).appendChild(lView[5])', + ]); + expect(tIcu.remove[0].debug).toEqual(['(lView[0] as Element).remove(lView[5])']); + expect(tIcu.update[0].debug).toEqual([]); + + // Case: '1' + expect(tIcu.create[1].debug).toEqual([ + 'lView[5] = document.createTextNode("one minute ago")', + '(lView[3] as Element).appendChild(lView[5])', + ]); + expect(tIcu.remove[1].debug).toEqual(['(lView[0] as Element).remove(lView[5])']); + expect(tIcu.update[1].debug).toEqual([]); + + // Case: 'other' + expect(tIcu.create[2].debug).toEqual([ + 'lView[5] = document.createTextNode("")', + '(lView[3] as Element).appendChild(lView[5])', + ]); + expect(tIcu.remove[2].debug).toEqual(['(lView[0] as Element).remove(lView[5])']); + expect(tIcu.update[2].debug).toEqual([ + 'if (mask & 0b10) { (lView[5] as Text).textContent = `${lView[2]} minutes ago`; }' + ]); + + expect(lViewDebug.i18n).toEqual({ + start: lViewDebug.vars.end, + end: lViewDebug.expando.start, + length: 3, + content: [ + // ICU anchor ``. + jasmine.objectContaining({index: HEADER_OFFSET + 3, l: icuComment}), + // ICU `TIcu.currentCaseLViewIndex` storage location + jasmine.objectContaining({ + index: HEADER_OFFSET + 4, + t: null, + l: 0, // The current ICU case + }), + jasmine.objectContaining({index: HEADER_OFFSET + 5, l: text_just_now}), + ] + }); + }); + + // FIXME(misko): re-enable and fix this use case. + xit('should support multiple ICUs', () => { + const fixture = initWithTemplate(AppComp, ` + {count, plural, + =0 {just now} + =1 {one minute ago} + other {{{count}} minutes ago} + } + {count, plural, + =0 {just now} + =1 {one minute ago} + other {{{count}} minutes ago} + } + `); + const lView = getComponentLView(fixture.componentInstance); + expect(lView.debug!.nodes.map(toTypeContent)).toEqual(['IcuContainer()']); + // We want to ensure that the ICU container does not have any content! + // This is because the content is instance dependent and therefore can't be shared + // across `TNode`s. + expect(lView.debug!.nodes[0].children.map(toTypeContent)).toEqual([]); + }); + }); + }); + describe('should support ICU expressions', () => { it('with no root node', () => { loadTranslations({ @@ -690,19 +905,19 @@ onlyInIvy('Ivy i18n logic').describe('runtime i18n', () => { other {({{name}})} }`); expect(fixture.nativeElement.innerHTML) - .toEqual(`
aucun email! - (Angular)
`); + .toEqual(`
aucun email! - (Angular)
`); fixture.componentRef.instance.count = 4; fixture.detectChanges(); expect(fixture.nativeElement.innerHTML) .toEqual( - `
4 emails - (Angular)
`); + `
4 emails - (Angular)
`); fixture.componentRef.instance.count = 0; fixture.componentRef.instance.name = 'John'; fixture.detectChanges(); expect(fixture.nativeElement.innerHTML) - .toEqual(`
aucun email! - (John)
`); + .toEqual(`
aucun email! - (John)
`); }); it('with custom interpolation config', () => { @@ -740,20 +955,20 @@ onlyInIvy('Ivy i18n logic').describe('runtime i18n', () => { }`); expect(fixture.nativeElement.innerHTML) .toEqual( - `
aucun email! - (Angular)
`); + `
aucun email! - (Angular)
`); fixture.componentRef.instance.count = 4; fixture.detectChanges(); expect(fixture.nativeElement.innerHTML) .toEqual( - `
4 emails - (Angular)
`); + `
4 emails - (Angular)
`); fixture.componentRef.instance.count = 0; fixture.componentRef.instance.name = 'John'; fixture.detectChanges(); expect(fixture.nativeElement.innerHTML) .toEqual( - `
aucun email! - (John)
`); + `
aucun email! - (John)
`); }); it('inside template directives', () => { @@ -1435,6 +1650,54 @@ onlyInIvy('Ivy i18n logic').describe('runtime i18n', () => { // checking the second ICU case expect(fixture.nativeElement.textContent.trim()).toBe('deux articles'); }); + + // FIXME(misko): re-enable and fix this use case. Root cause is that + // `addRemoveViewFromContainer` needs to understand ICU + xit('should handle select expressions without an `other` parameter inside a template', () => { + const fixture = initWithTemplate(AppComp, ` + {item.value, select, 0 {A} 1 {B} 2 {C}} + `); + fixture.componentInstance.items = [{value: 0}, {value: 1}, {value: 1337}]; + fixture.detectChanges(); + const p = fixture.nativeElement.querySelector('p'); + const lContext = loadLContext(p); + const lView = lContext.lView; + const nodeIndex = lContext.nodeIndex; + const tView = lView[TVIEW]; + const i18n = tView.data[nodeIndex + 1] as unknown as TI18n; + expect(fixture.nativeElement.textContent.trim()).toBe('AB'); + + fixture.componentInstance.items[0].value = 2; + fixture.detectChanges(); + expect(fixture.nativeElement.textContent.trim()).toBe('CB'); + fail('testing'); + }); + + it('should render an element whose case did not match initially', () => { + const fixture = initWithTemplate(AppComp, ` +

{item.value, select, 0 {A} 1 {B} 2 {C}}

+ `); + fixture.componentInstance.items = [{value: 0}, {value: 1}, {value: 1337}]; + fixture.detectChanges(); + expect(fixture.nativeElement.textContent.trim()).toBe('AB'); + + fixture.componentInstance.items[2].value = 2; + fixture.detectChanges(); + expect(fixture.nativeElement.textContent.trim()).toBe('ABC'); + }); + + it('should remove an element whose case matched initially, but does not anymore', () => { + const fixture = initWithTemplate(AppComp, ` +

{item.value, select, 0 {A} 1 {B} 2 {C}}

+ `); + fixture.componentInstance.items = [{value: 0}, {value: 1}]; + fixture.detectChanges(); + expect(fixture.nativeElement.textContent.trim()).toBe('AB'); + + fixture.componentInstance.items[0].value = 1337; + fixture.detectChanges(); + expect(fixture.nativeElement.textContent.trim()).toBe('B'); + }); }); describe('should support attributes', () => { diff --git a/packages/core/test/render3/i18n_spec.ts b/packages/core/test/render3/i18n_spec.ts index 6d46484845..20f2df3272 100644 --- a/packages/core/test/render3/i18n_spec.ts +++ b/packages/core/test/render3/i18n_spec.ts @@ -261,7 +261,7 @@ describe('Runtime i18n', () => { }, null, nbConsts, index) as TI18n; expect(opCodes).toEqual({ - vars: 5, + vars: 6, update: debugMatch([ 'if (mask & 0b1) { icuSwitchCase(lView[1] as Comment, 0, `${lView[1]}`); }', 'if (mask & 0b11) { icuUpdateCase(lView[1] as Comment, 0); }', @@ -272,60 +272,61 @@ describe('Runtime i18n', () => { ]), icus: [{ type: 1, - vars: [4, 3, 3], + currentCaseLViewIndex: 22, + vars: [5, 4, 4], childIcus: [[], [], []], cases: ['0', '1', 'other'], create: [ debugMatch([ - 'lView[2] = document.createTextNode("no ")', - '(lView[1] as Element).appendChild(lView[2])', - 'lView[3] = document.createElement("b")', + 'lView[3] = document.createTextNode("no ")', '(lView[1] as Element).appendChild(lView[3])', - '(lView[3] as Element).setAttribute("title", "none")', - 'lView[4] = document.createTextNode("emails")', - '(lView[3] as Element).appendChild(lView[4])', - 'lView[5] = document.createTextNode("!")', - '(lView[1] as Element).appendChild(lView[5])' + 'lView[4] = document.createElement("b")', + '(lView[1] as Element).appendChild(lView[4])', + '(lView[4] as Element).setAttribute("title", "none")', + 'lView[5] = document.createTextNode("emails")', + '(lView[4] as Element).appendChild(lView[5])', + 'lView[6] = document.createTextNode("!")', + '(lView[1] as Element).appendChild(lView[6])', ]), debugMatch([ - 'lView[2] = document.createTextNode("one ")', - '(lView[1] as Element).appendChild(lView[2])', - 'lView[3] = document.createElement("i")', + 'lView[3] = document.createTextNode("one ")', '(lView[1] as Element).appendChild(lView[3])', - 'lView[4] = document.createTextNode("email")', - '(lView[3] as Element).appendChild(lView[4])' + 'lView[4] = document.createElement("i")', + '(lView[1] as Element).appendChild(lView[4])', + 'lView[5] = document.createTextNode("email")', + '(lView[4] as Element).appendChild(lView[5])', ]), debugMatch([ - 'lView[2] = document.createTextNode("")', - '(lView[1] as Element).appendChild(lView[2])', - 'lView[3] = document.createElement("span")', + 'lView[3] = document.createTextNode("")', '(lView[1] as Element).appendChild(lView[3])', - 'lView[4] = document.createTextNode("emails")', - '(lView[3] as Element).appendChild(lView[4])' + 'lView[4] = document.createElement("span")', + '(lView[1] as Element).appendChild(lView[4])', + 'lView[5] = document.createTextNode("emails")', + '(lView[4] as Element).appendChild(lView[5])', ]) ], remove: [ debugMatch([ - '(lView[0] as Element).remove(lView[2])', - '(lView[0] as Element).remove(lView[4])', '(lView[0] as Element).remove(lView[3])', '(lView[0] as Element).remove(lView[5])', + '(lView[0] as Element).remove(lView[4])', + '(lView[0] as Element).remove(lView[6])', ]), debugMatch([ - '(lView[0] as Element).remove(lView[2])', - '(lView[0] as Element).remove(lView[4])', '(lView[0] as Element).remove(lView[3])', + '(lView[0] as Element).remove(lView[5])', + '(lView[0] as Element).remove(lView[4])', ]), debugMatch([ - '(lView[0] as Element).remove(lView[2])', - '(lView[0] as Element).remove(lView[4])', '(lView[0] as Element).remove(lView[3])', + '(lView[0] as Element).remove(lView[5])', + '(lView[0] as Element).remove(lView[4])', ]) ], update: [ debugMatch([]), debugMatch([]), debugMatch([ - 'if (mask & 0b1) { (lView[2] as Text).textContent = `${lView[1]} `; }', - 'if (mask & 0b10) { (lView[3] as Element).setAttribute(\'title\', `${lView[2]}`); }' + 'if (mask & 0b1) { (lView[3] as Text).textContent = `${lView[1]} `; }', + 'if (mask & 0b10) { (lView[4] as Element).setAttribute(\'title\', `${lView[2]}`); }' ]) ] }] @@ -355,7 +356,7 @@ describe('Runtime i18n', () => { const nestedTIcuIndex = 0; expect(opCodes).toEqual({ - vars: 6, + vars: 9, create: debugMatch([ 'lView[1] = document.createComment("ICU 1")', '(lView[0] as Element).appendChild(lView[1])' @@ -367,27 +368,28 @@ describe('Runtime i18n', () => { icus: [ { type: 0, - vars: [1, 1, 1], + vars: [2, 2, 2], + currentCaseLViewIndex: 26, childIcus: [[], [], []], cases: ['cat', 'dog', 'other'], create: [ debugMatch([ - 'lView[5] = document.createTextNode("cats")', - '(lView[3] as Element).appendChild(lView[5])' + 'lView[7] = document.createTextNode("cats")', + '(lView[4] as Element).appendChild(lView[7])' ]), debugMatch([ - 'lView[5] = document.createTextNode("dogs")', - '(lView[3] as Element).appendChild(lView[5])' + 'lView[7] = document.createTextNode("dogs")', + '(lView[4] as Element).appendChild(lView[7])' ]), debugMatch([ - 'lView[5] = document.createTextNode("animals")', - '(lView[3] as Element).appendChild(lView[5])' + 'lView[7] = document.createTextNode("animals")', + '(lView[4] as Element).appendChild(lView[7])' ]), ], remove: [ - debugMatch(['(lView[0] as Element).remove(lView[5])']), - debugMatch(['(lView[0] as Element).remove(lView[5])']), - debugMatch(['(lView[0] as Element).remove(lView[5])']) + debugMatch(['(lView[0] as Element).remove(lView[7])']), + debugMatch(['(lView[0] as Element).remove(lView[7])']), + debugMatch(['(lView[0] as Element).remove(lView[7])']) ], update: [ debugMatch([]), @@ -397,36 +399,37 @@ describe('Runtime i18n', () => { }, { type: 1, - vars: [1, 4], + vars: [2, 6], childIcus: [[], [0]], + currentCaseLViewIndex: 22, cases: ['0', 'other'], create: [ debugMatch([ - 'lView[2] = document.createTextNode("zero")', - '(lView[1] as Element).appendChild(lView[2])' + 'lView[3] = document.createTextNode("zero")', + '(lView[1] as Element).appendChild(lView[3])' ]), debugMatch([ - 'lView[2] = document.createTextNode("")', - '(lView[1] as Element).appendChild(lView[2])', - 'lView[3] = document.createComment("nested ICU 0")', + 'lView[3] = document.createTextNode("")', '(lView[1] as Element).appendChild(lView[3])', - 'lView[4] = document.createTextNode("!")', - '(lView[1] as Element).appendChild(lView[4])' + 'lView[4] = document.createComment("nested ICU 0")', + '(lView[1] as Element).appendChild(lView[4])', + 'lView[5] = document.createTextNode("!")', + '(lView[1] as Element).appendChild(lView[5])' ]), ], remove: [ - debugMatch(['(lView[0] as Element).remove(lView[2])']), + debugMatch(['(lView[0] as Element).remove(lView[3])']), debugMatch([ - '(lView[0] as Element).remove(lView[2])', '(lView[0] as Element).remove(lView[4])', - 'removeNestedICU(0)', '(lView[0] as Element).remove(lView[3])' + '(lView[0] as Element).remove(lView[3])', '(lView[0] as Element).remove(lView[5])', + 'removeNestedICU(0)', '(lView[0] as Element).remove(lView[4])' ]), ], update: [ debugMatch([]), debugMatch([ - 'if (mask & 0b1) { (lView[2] as Text).textContent = `${lView[1]} `; }', - 'if (mask & 0b10) { icuSwitchCase(lView[3] as Comment, 0, `${lView[2]}`); }', - 'if (mask & 0b10) { icuUpdateCase(lView[3] as Comment, 0); }' + 'if (mask & 0b1) { (lView[3] as Text).textContent = `${lView[1]} `; }', + 'if (mask & 0b10) { icuSwitchCase(lView[4] as Comment, 0, `${lView[2]}`); }', + 'if (mask & 0b10) { icuUpdateCase(lView[4] as Comment, 0); }' ]), ] } From e34c33cd46535b62b1578d4c632da90d8f2d6654 Mon Sep 17 00:00:00 2001 From: Joey Perrott Date: Thu, 6 Aug 2020 10:59:20 -0700 Subject: [PATCH 004/629] fix(platform-server): remove styles added by ServerStylesHost on destruction (#38367) When a ServerStylesHost instance is destroyed, all of the shared styles added to the DOM head element by that instance should be removed. Without this removal, over time a large number of style rules will build up and cause extra memory pressure. This brings the ServerStylesHost in line with the DomStylesHost used by the platform browser, which performs this same cleanup. PR Close #38367 --- packages/platform-server/src/styles_host.ts | 6 +++ .../test/server_styles_host_spec.ts | 50 +++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 packages/platform-server/test/server_styles_host_spec.ts diff --git a/packages/platform-server/src/styles_host.ts b/packages/platform-server/src/styles_host.ts index 47aeba45d2..e8364cb183 100644 --- a/packages/platform-server/src/styles_host.ts +++ b/packages/platform-server/src/styles_host.ts @@ -13,6 +13,7 @@ import {ɵSharedStylesHost as SharedStylesHost, ɵTRANSITION_ID} from '@angular/ @Injectable() export class ServerStylesHost extends SharedStylesHost { private head: any = null; + private _styleNodes = new Set(); constructor( @Inject(DOCUMENT) private doc: any, @@ -29,9 +30,14 @@ export class ServerStylesHost extends SharedStylesHost { el.setAttribute('ng-transition', this.transitionId); } this.head.appendChild(el); + this._styleNodes.add(el); } onStylesAdded(additions: Set) { additions.forEach(style => this._addStyle(style)); } + + ngOnDestroy() { + this._styleNodes.forEach(styleNode => styleNode.remove()); + } } diff --git a/packages/platform-server/test/server_styles_host_spec.ts b/packages/platform-server/test/server_styles_host_spec.ts new file mode 100644 index 0000000000..5a0056fb13 --- /dev/null +++ b/packages/platform-server/test/server_styles_host_spec.ts @@ -0,0 +1,50 @@ +/** + * @license + * Copyright Google LLC 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 {ɵgetDOM as getDOM} from '@angular/common'; +import {ServerStylesHost} from '@angular/platform-server/src/styles_host'; + + +(function() { +if (getDOM().supportsDOMEvents()) return; // NODE only + +describe('ServerStylesHost', () => { + let ssh: ServerStylesHost; + let documentHead: Element; + beforeEach(() => { + const doc = getDOM().createHtmlDocument(); + ssh = new ServerStylesHost(doc, ''); + documentHead = doc.head; + doc.querySelector('title')?.remove(); + }); + + it('should add existing styles', () => { + ssh.addStyles(['a {};']); + expect(documentHead.innerHTML).toEqual(''); + }); + + it('should add new styles to hosts', () => { + ssh.addStyles(['a {};']); + expect(documentHead.innerHTML).toEqual(''); + }); + + it('should add styles only once to hosts', () => { + ssh.addStyles(['a {};']); + ssh.addStyles(['a {};']); + expect(documentHead.innerHTML).toEqual(''); + }); + + it('should remove style nodes on destroy', () => { + ssh.addStyles(['a {};']); + expect(documentHead.innerHTML).toEqual(''); + + ssh.ngOnDestroy(); + expect(documentHead.innerHTML).toEqual(''); + }); +}); +})(); From 8f708b561cbd288845d1500463e9f4e84197f2a9 Mon Sep 17 00:00:00 2001 From: Zach Pomerantz Date: Wed, 5 Aug 2020 16:06:30 +0000 Subject: [PATCH 005/629] fix(router): defer loading of wildcard module until needed (#38348) Defer loading the wildcard module so that it is not loaded until subscribed to. This fixes an issue where it was being eagerly loaded. As an example, wildcard module loading should only occur after all other potential matches have been exhausted. A test case for this was also added to demonstrate the fix. Fixes #25494 PR Close #38348 --- packages/router/src/apply_redirects.ts | 13 +++++++------ packages/router/test/apply_redirects.spec.ts | 20 ++++++++++++++++++++ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/packages/router/src/apply_redirects.ts b/packages/router/src/apply_redirects.ts index 6827c0b005..5eb4050473 100644 --- a/packages/router/src/apply_redirects.ts +++ b/packages/router/src/apply_redirects.ts @@ -7,7 +7,7 @@ */ import {Injector, NgModuleRef} from '@angular/core'; -import {EmptyError, Observable, Observer, of} from 'rxjs'; +import {defer, EmptyError, Observable, Observer, of} from 'rxjs'; import {catchError, concatAll, first, map, mergeMap, tap} from 'rxjs/operators'; import {LoadedRouterConfig, Route, Routes} from './config'; @@ -247,11 +247,12 @@ class ApplyRedirects { segments: UrlSegment[]): Observable { if (route.path === '**') { if (route.loadChildren) { - return this.configLoader.load(ngModule.injector, route) - .pipe(map((cfg: LoadedRouterConfig) => { - route._loadedConfig = cfg; - return new UrlSegmentGroup(segments, {}); - })); + return defer( + () => this.configLoader.load(ngModule.injector, route) + .pipe(map((cfg: LoadedRouterConfig) => { + route._loadedConfig = cfg; + return new UrlSegmentGroup(segments, {}); + }))); } return of(new UrlSegmentGroup(segments, {})); diff --git a/packages/router/test/apply_redirects.spec.ts b/packages/router/test/apply_redirects.spec.ts index 918d259ce0..d564727a88 100644 --- a/packages/router/test/apply_redirects.spec.ts +++ b/packages/router/test/apply_redirects.spec.ts @@ -9,6 +9,7 @@ import {NgModuleRef} from '@angular/core'; import {TestBed} from '@angular/core/testing'; import {Observable, of} from 'rxjs'; +import {delay} from 'rxjs/operators'; import {applyRedirects} from '../src/apply_redirects'; import {LoadedRouterConfig, Route, Routes} from '../src/config'; @@ -435,6 +436,25 @@ describe('applyRedirects', () => { }); }); + it('should not load the configuration of a wildcard route if there is a match', () => { + const loadedConfig = new LoadedRouterConfig([{path: '', component: ComponentB}], testModule); + + const loader = jasmine.createSpyObj('loader', ['load']); + loader.load.and.returnValue(of(loadedConfig).pipe(delay(0))); + + const config: Routes = [ + {path: '', loadChildren: 'matchChildren'}, + {path: '**', loadChildren: 'children'}, + ]; + + applyRedirects(testModule.injector, loader, serializer, tree(''), config).forEach(r => { + expect(loader.load.calls.count()).toEqual(1); + expect(loader.load.calls.first().args).not.toContain(jasmine.objectContaining({ + loadChildren: 'children' + })); + }); + }); + it('should load the configuration after a local redirect from a wildcard route', () => { const loadedConfig = new LoadedRouterConfig([{path: '', component: ComponentB}], testModule); From 250e299dc32de91b4b94f7f43be84bfd6968b020 Mon Sep 17 00:00:00 2001 From: Misko Hevery Date: Thu, 6 Aug 2020 14:01:59 -0700 Subject: [PATCH 006/629] refactor(core): break `i18n.ts` into smaller files (#38368) This commit contains no changes to code. It only breaks `i18n.ts` file into `i18n.ts` + `i18n_apply.ts` + `i18n_parse.ts` + `i18n_postprocess.ts` for easier maintenance. PR Close #38368 --- goldens/circular-deps/packages.json | 5 +- .../size-tracking/integration-payloads.json | 2 +- packages/core/src/application_module.ts | 2 +- packages/core/src/application_ref.ts | 2 +- packages/core/src/render3/i18n.ts | 1516 ----------------- packages/core/src/render3/{ => i18n}/i18n.md | 0 packages/core/src/render3/i18n/i18n_apply.ts | 503 ++++++ .../core/src/render3/{ => i18n}/i18n_debug.ts | 4 +- .../core/src/render3/i18n/i18n_locale_id.ts | 41 + packages/core/src/render3/i18n/i18n_parse.ts | 733 ++++++++ .../core/src/render3/i18n/i18n_postprocess.ts | 134 ++ packages/core/src/render3/index.ts | 3 +- .../core/src/render3/instructions/i18n.ts | 176 ++ packages/core/src/render3/interfaces/i18n.ts | 38 + packages/core/src/render3/ng_module_ref.ts | 2 +- packages/core/test/render3/i18n_debug_spec.ts | 2 +- packages/core/test/render3/i18n_spec.ts | 5 +- 17 files changed, 1641 insertions(+), 1527 deletions(-) delete mode 100644 packages/core/src/render3/i18n.ts rename packages/core/src/render3/{ => i18n}/i18n.md (100%) create mode 100644 packages/core/src/render3/i18n/i18n_apply.ts rename packages/core/src/render3/{ => i18n}/i18n_debug.ts (98%) create mode 100644 packages/core/src/render3/i18n/i18n_locale_id.ts create mode 100644 packages/core/src/render3/i18n/i18n_parse.ts create mode 100644 packages/core/src/render3/i18n/i18n_postprocess.ts create mode 100644 packages/core/src/render3/instructions/i18n.ts diff --git a/goldens/circular-deps/packages.json b/goldens/circular-deps/packages.json index 924b196222..1da0721b05 100644 --- a/goldens/circular-deps/packages.json +++ b/goldens/circular-deps/packages.json @@ -1758,9 +1758,10 @@ "packages/core/src/render3/index.ts" ], [ - "packages/core/src/render3/i18n.ts", + "packages/core/src/render3/i18n/i18n_apply.ts", "packages/core/src/render3/interfaces/type_checks.ts", - "packages/core/src/render3/index.ts" + "packages/core/src/render3/index.ts", + "packages/core/src/render3/instructions/i18n.ts" ], [ "packages/core/src/render3/interfaces/container.ts", diff --git a/goldens/size-tracking/integration-payloads.json b/goldens/size-tracking/integration-payloads.json index 19530230df..5e0e6dd3ee 100644 --- a/goldens/size-tracking/integration-payloads.json +++ b/goldens/size-tracking/integration-payloads.json @@ -62,7 +62,7 @@ "bundle": "TODO(i): we should define ngDevMode to false in Closure, but --define only works in the global scope.", "bundle": "TODO(i): (FW-2164) TS 3.9 new class shape seems to have broken Closure in big ways. The size went from 169991 to 252338", "bundle": "TODO(i): after removal of tsickle from ngc-wrapped / ng_package, we had to switch to SIMPLE optimizations which increased the size from 252338 to 1198917, see PR#37221 and PR#37317 for more info", - "bundle": 1213769 + "bundle": 1214317 } } } diff --git a/packages/core/src/application_module.ts b/packages/core/src/application_module.ts index 7db809abed..b5eedcede5 100644 --- a/packages/core/src/application_module.ts +++ b/packages/core/src/application_module.ts @@ -21,7 +21,7 @@ import {ComponentFactoryResolver} from './linker'; import {Compiler} from './linker/compiler'; import {NgModule} from './metadata'; import {SCHEDULER} from './render3/component_ref'; -import {setLocaleId} from './render3/i18n'; +import {setLocaleId} from './render3/i18n/i18n_locale_id'; import {NgZone} from './zone'; declare const $localize: {locale?: string}; diff --git a/packages/core/src/application_ref.ts b/packages/core/src/application_ref.ts index a701f7518e..172ad182e4 100644 --- a/packages/core/src/application_ref.ts +++ b/packages/core/src/application_ref.ts @@ -30,7 +30,7 @@ import {InternalViewRef, ViewRef} from './linker/view_ref'; import {isComponentResourceResolutionQueueEmpty, resolveComponentResources} from './metadata/resource_loading'; import {assertNgModuleType} from './render3/assert'; import {ComponentFactory as R3ComponentFactory} from './render3/component_ref'; -import {setLocaleId} from './render3/i18n'; +import {setLocaleId} from './render3/i18n/i18n_locale_id'; import {setJitOptions} from './render3/jit/jit_options'; import {NgModuleFactory as R3NgModuleFactory} from './render3/ng_module_ref'; import {publishDefaultGlobalUtils as _publishDefaultGlobalUtils} from './render3/util/global_utils'; diff --git a/packages/core/src/render3/i18n.ts b/packages/core/src/render3/i18n.ts deleted file mode 100644 index e7da73a264..0000000000 --- a/packages/core/src/render3/i18n.ts +++ /dev/null @@ -1,1516 +0,0 @@ -/** - * @license - * Copyright Google LLC 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 '../util/ng_i18n_closure_mode'; -import '../util/ng_dev_mode'; - -import {DEFAULT_LOCALE_ID, getPluralCase} from '../i18n/localization'; -import {getTemplateContent, SRCSET_ATTRS, URI_ATTRS, VALID_ATTRS, VALID_ELEMENTS} from '../sanitization/html_sanitizer'; -import {getInertBodyHelper} from '../sanitization/inert_body'; -import {_sanitizeUrl, sanitizeSrcset} from '../sanitization/url_sanitizer'; -import {addAllToArray} from '../util/array_utils'; -import {assertDefined, assertEqual, assertGreaterThan, assertIndexInRange} from '../util/assert'; - -import {bindingUpdated} from './bindings'; -import {attachPatchData} from './context_discovery'; -import {i18nMutateOpCodesToString, i18nUpdateOpCodesToString} from './i18n_debug'; -import {setDelayProjection} from './instructions/all'; -import {allocExpando, elementAttributeInternal, elementPropertyInternal, getOrCreateTNode, setInputsForProperty, setNgReflectProperties, textBindingInternal as applyTextBinding} from './instructions/shared'; -import {LContainer, NATIVE} from './interfaces/container'; -import {getDocument} from './interfaces/document'; -import {COMMENT_MARKER, ELEMENT_MARKER, I18nMutateOpCode, I18nMutateOpCodes, I18nUpdateOpCode, I18nUpdateOpCodes, IcuType, TI18n, TIcu} from './interfaces/i18n'; -import {TElementNode, TIcuContainerNode, TNode, TNodeFlags, TNodeType, TProjectionNode} from './interfaces/node'; -import {RComment, RElement, RText} from './interfaces/renderer'; -import {SanitizerFn} from './interfaces/sanitization'; -import {isLContainer} from './interfaces/type_checks'; -import {HEADER_OFFSET, LView, RENDERER, T_HOST, TVIEW, TView} from './interfaces/view'; -import {appendChild, applyProjection, createTextNode, nativeRemoveNode} from './node_manipulation'; -import {getBindingIndex, getIsParent, getLView, getPreviousOrParentTNode, getTView, nextBindingIndex, setIsNotParent, setPreviousOrParentTNode} from './state'; -import {attachDebugGetter} from './util/debug_utils'; -import {renderStringify} from './util/misc_utils'; -import {getNativeByIndex, getNativeByTNode, getTNode, load} from './util/view_utils'; - - -const MARKER = `�`; -const ICU_BLOCK_REGEXP = /^\s*(�\d+:?\d*�)\s*,\s*(select|plural)\s*,/; -const SUBTEMPLATE_REGEXP = /�\/?\*(\d+:\d+)�/gi; -const PH_REGEXP = /�(\/?[#*!]\d+):?\d*�/gi; -const BINDING_REGEXP = /�(\d+):?\d*�/gi; -const ICU_REGEXP = /({\s*�\d+:?\d*�\s*,\s*\S{6}\s*,[\s\S]*})/gi; -const enum TagType { - ELEMENT = '#', - TEMPLATE = '*', - PROJECTION = '!', -} - -// i18nPostprocess consts -const ROOT_TEMPLATE_ID = 0; -const PP_MULTI_VALUE_PLACEHOLDERS_REGEXP = /\[(�.+?�?)\]/; -const PP_PLACEHOLDERS_REGEXP = /\[(�.+?�?)\]|(�\/?\*\d+:\d+�)/g; -const PP_ICU_VARS_REGEXP = /({\s*)(VAR_(PLURAL|SELECT)(_\d+)?)(\s*,)/g; -const PP_ICU_PLACEHOLDERS_REGEXP = /{([A-Z0-9_]+)}/g; -const PP_ICUS_REGEXP = /�I18N_EXP_(ICU(_\d+)?)�/g; -const PP_CLOSE_TEMPLATE_REGEXP = /\/\*/; -const PP_TEMPLATE_ID_REGEXP = /\d+\:(\d+)/; - -// Parsed placeholder structure used in postprocessing (within `i18nPostprocess` function) -// Contains the following fields: [templateId, isCloseTemplateTag, placeholder] -type PostprocessPlaceholder = [number, boolean, string]; - -interface IcuExpression { - type: IcuType; - mainBinding: number; - cases: string[]; - values: (string|IcuExpression)[][]; -} - -interface IcuCase { - /** - * Number of slots to allocate in expando for this case. - * - * This is the max number of DOM elements which will be created by this i18n + ICU blocks. When - * the DOM elements are being created they are stored in the EXPANDO, so that update OpCodes can - * write into them. - */ - vars: number; - - /** - * An optional array of child/sub ICUs. - */ - childIcus: number[]; - - /** - * A set of OpCodes to apply in order to build up the DOM render tree for the ICU - */ - create: I18nMutateOpCodes; - - /** - * A set of OpCodes to apply in order to destroy the DOM render tree for the ICU. - */ - remove: I18nMutateOpCodes; - - /** - * A set of OpCodes to apply in order to update the DOM render tree for the ICU bindings. - */ - update: I18nUpdateOpCodes; -} - -/** - * Breaks pattern into strings and top level {...} blocks. - * Can be used to break a message into text and ICU expressions, or to break an ICU expression into - * keys and cases. - * Original code from closure library, modified for Angular. - * - * @param pattern (sub)Pattern to be broken. - * - */ -function extractParts(pattern: string): (string|IcuExpression)[] { - if (!pattern) { - return []; - } - - let prevPos = 0; - const braceStack = []; - const results: (string|IcuExpression)[] = []; - const braces = /[{}]/g; - // lastIndex doesn't get set to 0 so we have to. - braces.lastIndex = 0; - - let match; - while (match = braces.exec(pattern)) { - const pos = match.index; - if (match[0] == '}') { - braceStack.pop(); - - if (braceStack.length == 0) { - // End of the block. - const block = pattern.substring(prevPos, pos); - if (ICU_BLOCK_REGEXP.test(block)) { - results.push(parseICUBlock(block)); - } else { - results.push(block); - } - - prevPos = pos + 1; - } - } else { - if (braceStack.length == 0) { - const substring = pattern.substring(prevPos, pos); - results.push(substring); - prevPos = pos + 1; - } - braceStack.push('{'); - } - } - - const substring = pattern.substring(prevPos); - results.push(substring); - return results; -} - -/** - * Parses text containing an ICU expression and produces a JSON object for it. - * Original code from closure library, modified for Angular. - * - * @param pattern Text containing an ICU expression that needs to be parsed. - * - */ -function parseICUBlock(pattern: string): IcuExpression { - const cases = []; - const values: (string|IcuExpression)[][] = []; - let icuType = IcuType.plural; - let mainBinding = 0; - pattern = pattern.replace(ICU_BLOCK_REGEXP, function(str: string, binding: string, type: string) { - if (type === 'select') { - icuType = IcuType.select; - } else { - icuType = IcuType.plural; - } - mainBinding = parseInt(binding.substr(1), 10); - return ''; - }); - - const parts = extractParts(pattern) as string[]; - // Looking for (key block)+ sequence. One of the keys has to be "other". - for (let pos = 0; pos < parts.length;) { - let key = parts[pos++].trim(); - if (icuType === IcuType.plural) { - // Key can be "=x", we just want "x" - key = key.replace(/\s*(?:=)?(\w+)\s*/, '$1'); - } - if (key.length) { - cases.push(key); - } - - const blocks = extractParts(parts[pos++]) as string[]; - if (cases.length > values.length) { - values.push(blocks); - } - } - - // TODO(ocombe): support ICU expressions in attributes, see #21615 - return {type: icuType, mainBinding: mainBinding, cases, values}; -} - -/** - * Removes everything inside the sub-templates of a message. - */ -function removeInnerTemplateTranslation(message: string): string { - let match; - let res = ''; - let index = 0; - let inTemplate = false; - let tagMatched; - - while ((match = SUBTEMPLATE_REGEXP.exec(message)) !== null) { - if (!inTemplate) { - res += message.substring(index, match.index + match[0].length); - tagMatched = match[1]; - inTemplate = true; - } else { - if (match[0] === `${MARKER}/*${tagMatched}${MARKER}`) { - index = match.index; - inTemplate = false; - } - } - } - - ngDevMode && - assertEqual( - inTemplate, false, - `Tag mismatch: unable to find the end of the sub-template in the translation "${ - message}"`); - - res += message.substr(index); - return res; -} - -/** - * Extracts a part of a message and removes the rest. - * - * This method is used for extracting a part of the message associated with a template. A translated - * message can span multiple templates. - * - * Example: - * ``` - *
Translate me!
- * ``` - * - * @param message The message to crop - * @param subTemplateIndex Index of the sub-template to extract. If undefined it returns the - * external template and removes all sub-templates. - */ -export function getTranslationForTemplate(message: string, subTemplateIndex?: number) { - if (isRootTemplateMessage(subTemplateIndex)) { - // We want the root template message, ignore all sub-templates - return removeInnerTemplateTranslation(message); - } else { - // We want a specific sub-template - const start = - message.indexOf(`:${subTemplateIndex}${MARKER}`) + 2 + subTemplateIndex.toString().length; - const end = message.search(new RegExp(`${MARKER}\\/\\*\\d+:${subTemplateIndex}${MARKER}`)); - return removeInnerTemplateTranslation(message.substring(start, end)); - } -} - -/** - * Generate the OpCodes to update the bindings of a string. - * - * @param str The string containing the bindings. - * @param destinationNode Index of the destination node which will receive the binding. - * @param attrName Name of the attribute, if the string belongs to an attribute. - * @param sanitizeFn Sanitization function used to sanitize the string after update, if necessary. - */ -function generateBindingUpdateOpCodes( - str: string, destinationNode: number, attrName?: string, - sanitizeFn: SanitizerFn|null = null): I18nUpdateOpCodes { - const updateOpCodes: I18nUpdateOpCodes = [null, null]; // Alloc space for mask and size - if (ngDevMode) { - attachDebugGetter(updateOpCodes, i18nUpdateOpCodesToString); - } - const textParts = str.split(BINDING_REGEXP); - let mask = 0; - - for (let j = 0; j < textParts.length; j++) { - const textValue = textParts[j]; - - if (j & 1) { - // Odd indexes are bindings - const bindingIndex = parseInt(textValue, 10); - updateOpCodes.push(-1 - bindingIndex); - mask = mask | toMaskBit(bindingIndex); - } else if (textValue !== '') { - // Even indexes are text - updateOpCodes.push(textValue); - } - } - - updateOpCodes.push( - destinationNode << I18nUpdateOpCode.SHIFT_REF | - (attrName ? I18nUpdateOpCode.Attr : I18nUpdateOpCode.Text)); - if (attrName) { - updateOpCodes.push(attrName, sanitizeFn); - } - updateOpCodes[0] = mask; - updateOpCodes[1] = updateOpCodes.length - 2; - return updateOpCodes; -} - -function getBindingMask(icuExpression: IcuExpression, mask = 0): number { - mask = mask | toMaskBit(icuExpression.mainBinding); - let match; - for (let i = 0; i < icuExpression.values.length; i++) { - const valueArr = icuExpression.values[i]; - for (let j = 0; j < valueArr.length; j++) { - const value = valueArr[j]; - if (typeof value === 'string') { - while (match = BINDING_REGEXP.exec(value)) { - mask = mask | toMaskBit(parseInt(match[1], 10)); - } - } else { - mask = getBindingMask(value as IcuExpression, mask); - } - } - } - return mask; -} - -const i18nIndexStack: number[] = []; -let i18nIndexStackPointer = -1; - -/** - * Convert binding index to mask bit. - * - * Each index represents a single bit on the bit-mask. Because bit-mask only has 32 bits, we make - * the 32nd bit share all masks for all bindings higher than 32. Since it is extremely rare to have - * more than 32 bindings this will be hit very rarely. The downside of hitting this corner case is - * that we will execute binding code more often than necessary. (penalty of performance) - */ -function toMaskBit(bindingIndex: number): number { - return 1 << Math.min(bindingIndex, 31); -} - -const parentIndexStack: number[] = []; - -/** - * Marks a block of text as translatable. - * - * The instructions `i18nStart` and `i18nEnd` mark the translation block in the template. - * The translation `message` is the value which is locale specific. The translation string may - * contain placeholders which associate inner elements and sub-templates within the translation. - * - * The translation `message` placeholders are: - * - `�{index}(:{block})�`: *Binding Placeholder*: Marks a location where an expression will be - * interpolated into. The placeholder `index` points to the expression binding index. An optional - * `block` that matches the sub-template in which it was declared. - * - `�#{index}(:{block})�`/`�/#{index}(:{block})�`: *Element Placeholder*: Marks the beginning - * and end of DOM element that were embedded in the original translation block. The placeholder - * `index` points to the element index in the template instructions set. An optional `block` that - * matches the sub-template in which it was declared. - * - `�!{index}(:{block})�`/`�/!{index}(:{block})�`: *Projection Placeholder*: Marks the - * beginning and end of that was embedded in the original translation block. - * The placeholder `index` points to the element index in the template instructions set. - * An optional `block` that matches the sub-template in which it was declared. - * - `�*{index}:{block}�`/`�/*{index}:{block}�`: *Sub-template Placeholder*: Sub-templates must be - * split up and translated separately in each angular template function. The `index` points to the - * `template` instruction index. A `block` that matches the sub-template in which it was declared. - * - * @param index A unique index of the translation in the static block. - * @param message The translation message. - * @param subTemplateIndex Optional sub-template index in the `message`. - * - * @codeGenApi - */ -export function ɵɵi18nStart(index: number, message: string, subTemplateIndex?: number): void { - const tView = getTView(); - ngDevMode && assertDefined(tView, `tView should be defined`); - i18nIndexStack[++i18nIndexStackPointer] = index; - // We need to delay projections until `i18nEnd` - setDelayProjection(true); - if (tView.firstCreatePass && tView.data[index + HEADER_OFFSET] === null) { - i18nStartFirstPass(getLView(), tView, index, message, subTemplateIndex); - } -} - -// Count for the number of vars that will be allocated for each i18n block. -// It is global because this is used in multiple functions that include loops and recursive calls. -// This is reset to 0 when `i18nStartFirstPass` is called. -let i18nVarsCount: number; - -function allocNodeIndex(startIndex: number): number { - return startIndex + i18nVarsCount++; -} - -/** - * See `i18nStart` above. - */ -function i18nStartFirstPass( - lView: LView, tView: TView, index: number, message: string, subTemplateIndex?: number) { - const startIndex = tView.blueprint.length - HEADER_OFFSET; - i18nVarsCount = 0; - const previousOrParentTNode = getPreviousOrParentTNode(); - const parentTNode = - getIsParent() ? previousOrParentTNode : previousOrParentTNode && previousOrParentTNode.parent; - let parentIndex = - parentTNode && parentTNode !== lView[T_HOST] ? parentTNode.index - HEADER_OFFSET : index; - let parentIndexPointer = 0; - parentIndexStack[parentIndexPointer] = parentIndex; - const createOpCodes: I18nMutateOpCodes = []; - if (ngDevMode) { - attachDebugGetter(createOpCodes, i18nMutateOpCodesToString); - } - // If the previous node wasn't the direct parent then we have a translation without top level - // element and we need to keep a reference of the previous element if there is one. We should also - // keep track whether an element was a parent node or not, so that the logic that consumes - // the generated `I18nMutateOpCode`s can leverage this information to properly set TNode state - // (whether it's a parent or sibling). - if (index > 0 && previousOrParentTNode !== parentTNode) { - let previousTNodeIndex = previousOrParentTNode.index - HEADER_OFFSET; - // If current TNode is a sibling node, encode it using a negative index. This information is - // required when the `Select` action is processed (see the `readCreateOpCodes` function). - if (!getIsParent()) { - previousTNodeIndex = ~previousTNodeIndex; - } - // Create an OpCode to select the previous TNode - createOpCodes.push(previousTNodeIndex << I18nMutateOpCode.SHIFT_REF | I18nMutateOpCode.Select); - } - const updateOpCodes: I18nUpdateOpCodes = []; - if (ngDevMode) { - attachDebugGetter(updateOpCodes, i18nUpdateOpCodesToString); - } - const icuExpressions: TIcu[] = []; - - if (message === '' && isRootTemplateMessage(subTemplateIndex)) { - // If top level translation is an empty string, do not invoke additional processing - // and just create op codes for empty text node instead. - createOpCodes.push( - message, allocNodeIndex(startIndex), - parentIndex << I18nMutateOpCode.SHIFT_PARENT | I18nMutateOpCode.AppendChild); - } else { - const templateTranslation = getTranslationForTemplate(message, subTemplateIndex); - const msgParts = replaceNgsp(templateTranslation).split(PH_REGEXP); - for (let i = 0; i < msgParts.length; i++) { - let value = msgParts[i]; - if (i & 1) { - // Odd indexes are placeholders (elements and sub-templates) - if (value.charAt(0) === '/') { - // It is a closing tag - if (value.charAt(1) === TagType.ELEMENT) { - const phIndex = parseInt(value.substr(2), 10); - parentIndex = parentIndexStack[--parentIndexPointer]; - createOpCodes.push(phIndex << I18nMutateOpCode.SHIFT_REF | I18nMutateOpCode.ElementEnd); - } - } else { - const phIndex = parseInt(value.substr(1), 10); - const isElement = value.charAt(0) === TagType.ELEMENT; - // The value represents a placeholder that we move to the designated index. - // Note: positive indicies indicate that a TNode with a given index should also be marked - // as parent while executing `Select` instruction. - createOpCodes.push( - (isElement ? phIndex : ~phIndex) << I18nMutateOpCode.SHIFT_REF | - I18nMutateOpCode.Select, - parentIndex << I18nMutateOpCode.SHIFT_PARENT | I18nMutateOpCode.AppendChild); - - if (isElement) { - parentIndexStack[++parentIndexPointer] = parentIndex = phIndex; - } - } - } else { - // Even indexes are text (including bindings & ICU expressions) - const parts = extractParts(value); - for (let j = 0; j < parts.length; j++) { - if (j & 1) { - // Odd indexes are ICU expressions - const icuExpression = parts[j] as IcuExpression; - - // Verify that ICU expression has the right shape. Translations might contain invalid - // constructions (while original messages were correct), so ICU parsing at runtime may - // not succeed (thus `icuExpression` remains a string). - if (typeof icuExpression !== 'object') { - throw new Error( - `Unable to parse ICU expression in "${templateTranslation}" message.`); - } - - // Create the comment node that will anchor the ICU expression - const icuNodeIndex = allocNodeIndex(startIndex); - createOpCodes.push( - COMMENT_MARKER, ngDevMode ? `ICU ${icuNodeIndex}` : '', icuNodeIndex, - parentIndex << I18nMutateOpCode.SHIFT_PARENT | I18nMutateOpCode.AppendChild); - - // Update codes for the ICU expression - const mask = getBindingMask(icuExpression); - icuStart(icuExpressions, icuExpression, icuNodeIndex, icuNodeIndex); - // Since this is recursive, the last TIcu that was pushed is the one we want - const tIcuIndex = icuExpressions.length - 1; - updateOpCodes.push( - toMaskBit(icuExpression.mainBinding), // mask of the main binding - 3, // skip 3 opCodes if not changed - -1 - icuExpression.mainBinding, - icuNodeIndex << I18nUpdateOpCode.SHIFT_REF | I18nUpdateOpCode.IcuSwitch, tIcuIndex, - mask, // mask of all the bindings of this ICU expression - 2, // skip 2 opCodes if not changed - icuNodeIndex << I18nUpdateOpCode.SHIFT_REF | I18nUpdateOpCode.IcuUpdate, tIcuIndex); - } else if (parts[j] !== '') { - const text = parts[j] as string; - // Even indexes are text (including bindings) - const hasBinding = text.match(BINDING_REGEXP); - // Create text nodes - const textNodeIndex = allocNodeIndex(startIndex); - createOpCodes.push( - // If there is a binding, the value will be set during update - hasBinding ? '' : text, textNodeIndex, - parentIndex << I18nMutateOpCode.SHIFT_PARENT | I18nMutateOpCode.AppendChild); - - if (hasBinding) { - addAllToArray(generateBindingUpdateOpCodes(text, textNodeIndex), updateOpCodes); - } - } - } - } - } - } - - if (i18nVarsCount > 0) { - allocExpando(tView, lView, i18nVarsCount); - } - - // NOTE: local var needed to properly assert the type of `TI18n`. - const tI18n: TI18n = { - vars: i18nVarsCount, - create: createOpCodes, - update: updateOpCodes, - icus: icuExpressions.length ? icuExpressions : null, - }; - - tView.data[index + HEADER_OFFSET] = tI18n; -} - -function appendI18nNode( - tView: TView, tNode: TNode, parentTNode: TNode, previousTNode: TNode|null, - lView: LView): TNode { - ngDevMode && ngDevMode.rendererMoveNode++; - const nextNode = tNode.next; - if (!previousTNode) { - previousTNode = parentTNode; - } - - // Re-organize node tree to put this node in the correct position. - if (previousTNode === parentTNode && tNode !== parentTNode.child) { - tNode.next = parentTNode.child; - parentTNode.child = tNode; - } else if (previousTNode !== parentTNode && tNode !== previousTNode.next) { - tNode.next = previousTNode.next; - previousTNode.next = tNode; - } else { - tNode.next = null; - } - - if (parentTNode !== lView[T_HOST]) { - tNode.parent = parentTNode as TElementNode; - } - - // If tNode was moved around, we might need to fix a broken link. - let cursor: TNode|null = tNode.next; - while (cursor) { - if (cursor.next === tNode) { - cursor.next = nextNode; - } - cursor = cursor.next; - } - - // If the placeholder to append is a projection, we need to move the projected nodes instead - if (tNode.type === TNodeType.Projection) { - applyProjection(tView, lView, tNode as TProjectionNode); - return tNode; - } - - appendChild(tView, lView, getNativeByTNode(tNode, lView), tNode); - - const slotValue = lView[tNode.index]; - if (tNode.type !== TNodeType.Container && isLContainer(slotValue)) { - // Nodes that inject ViewContainerRef also have a comment node that should be moved - appendChild(tView, lView, slotValue[NATIVE], tNode); - } - return tNode; -} - -function isRootTemplateMessage(subTemplateIndex: number|undefined): subTemplateIndex is undefined { - return subTemplateIndex === undefined; -} - -/** - * Handles message string post-processing for internationalization. - * - * Handles message string post-processing by transforming it from intermediate - * format (that might contain some markers that we need to replace) to the final - * form, consumable by i18nStart instruction. Post processing steps include: - * - * 1. Resolve all multi-value cases (like [�*1:1��#2:1�|�#4:1�|�5�]) - * 2. Replace all ICU vars (like "VAR_PLURAL") - * 3. Replace all placeholders used inside ICUs in a form of {PLACEHOLDER} - * 4. Replace all ICU references with corresponding values (like �ICU_EXP_ICU_1�) - * in case multiple ICUs have the same placeholder name - * - * @param message Raw translation string for post processing - * @param replacements Set of replacements that should be applied - * - * @returns Transformed string that can be consumed by i18nStart instruction - * - * @codeGenApi - */ -export function ɵɵi18nPostprocess( - message: string, replacements: {[key: string]: (string|string[])} = {}): string { - /** - * Step 1: resolve all multi-value placeholders like [�#5�|�*1:1��#2:1�|�#4:1�] - * - * Note: due to the way we process nested templates (BFS), multi-value placeholders are typically - * grouped by templates, for example: [�#5�|�#6�|�#1:1�|�#3:2�] where �#5� and �#6� belong to root - * template, �#1:1� belong to nested template with index 1 and �#1:2� - nested template with index - * 3. However in real templates the order might be different: i.e. �#1:1� and/or �#3:2� may go in - * front of �#6�. The post processing step restores the right order by keeping track of the - * template id stack and looks for placeholders that belong to the currently active template. - */ - let result: string = message; - if (PP_MULTI_VALUE_PLACEHOLDERS_REGEXP.test(message)) { - const matches: {[key: string]: PostprocessPlaceholder[]} = {}; - const templateIdsStack: number[] = [ROOT_TEMPLATE_ID]; - result = result.replace(PP_PLACEHOLDERS_REGEXP, (m: any, phs: string, tmpl: string): string => { - const content = phs || tmpl; - const placeholders: PostprocessPlaceholder[] = matches[content] || []; - if (!placeholders.length) { - content.split('|').forEach((placeholder: string) => { - const match = placeholder.match(PP_TEMPLATE_ID_REGEXP); - const templateId = match ? parseInt(match[1], 10) : ROOT_TEMPLATE_ID; - const isCloseTemplateTag = PP_CLOSE_TEMPLATE_REGEXP.test(placeholder); - placeholders.push([templateId, isCloseTemplateTag, placeholder]); - }); - matches[content] = placeholders; - } - - if (!placeholders.length) { - throw new Error(`i18n postprocess: unmatched placeholder - ${content}`); - } - - const currentTemplateId = templateIdsStack[templateIdsStack.length - 1]; - let idx = 0; - // find placeholder index that matches current template id - for (let i = 0; i < placeholders.length; i++) { - if (placeholders[i][0] === currentTemplateId) { - idx = i; - break; - } - } - // update template id stack based on the current tag extracted - const [templateId, isCloseTemplateTag, placeholder] = placeholders[idx]; - if (isCloseTemplateTag) { - templateIdsStack.pop(); - } else if (currentTemplateId !== templateId) { - templateIdsStack.push(templateId); - } - // remove processed tag from the list - placeholders.splice(idx, 1); - return placeholder; - }); - } - - // return current result if no replacements specified - if (!Object.keys(replacements).length) { - return result; - } - - /** - * Step 2: replace all ICU vars (like "VAR_PLURAL") - */ - result = result.replace(PP_ICU_VARS_REGEXP, (match, start, key, _type, _idx, end): string => { - return replacements.hasOwnProperty(key) ? `${start}${replacements[key]}${end}` : match; - }); - - /** - * Step 3: replace all placeholders used inside ICUs in a form of {PLACEHOLDER} - */ - result = result.replace(PP_ICU_PLACEHOLDERS_REGEXP, (match, key): string => { - return replacements.hasOwnProperty(key) ? replacements[key] as string : match; - }); - - /** - * Step 4: replace all ICU references with corresponding values (like �ICU_EXP_ICU_1�) in case - * multiple ICUs have the same placeholder name - */ - result = result.replace(PP_ICUS_REGEXP, (match, key): string => { - if (replacements.hasOwnProperty(key)) { - const list = replacements[key] as string[]; - if (!list.length) { - throw new Error(`i18n postprocess: unmatched ICU - ${match} with key: ${key}`); - } - return list.shift()!; - } - return match; - }); - - return result; -} - -/** - * Translates a translation block marked by `i18nStart` and `i18nEnd`. It inserts the text/ICU nodes - * into the render tree, moves the placeholder nodes and removes the deleted nodes. - * - * @codeGenApi - */ -export function ɵɵi18nEnd(): void { - const lView = getLView(); - const tView = getTView(); - ngDevMode && assertDefined(tView, `tView should be defined`); - i18nEndFirstPass(tView, lView); - // Stop delaying projections - setDelayProjection(false); -} - -/** - * See `i18nEnd` above. - */ -function i18nEndFirstPass(tView: TView, lView: LView) { - ngDevMode && - assertEqual( - getBindingIndex(), tView.bindingStartIndex, - 'i18nEnd should be called before any binding'); - - const rootIndex = i18nIndexStack[i18nIndexStackPointer--]; - const tI18n = tView.data[rootIndex + HEADER_OFFSET] as TI18n; - ngDevMode && assertDefined(tI18n, `You should call i18nStart before i18nEnd`); - - // Find the last node that was added before `i18nEnd` - const lastCreatedNode = getPreviousOrParentTNode(); - - // Read the instructions to insert/move/remove DOM elements - const visitedNodes = applyCreateOpCodes(tView, rootIndex, tI18n.create, lView); - - // Remove deleted nodes - let index = rootIndex + 1; - while (index <= lastCreatedNode.index - HEADER_OFFSET) { - if (visitedNodes.indexOf(index) === -1) { - removeNode(tView, lView, index, /* markAsDetached */ true); - } - // Check if an element has any local refs and skip them - const tNode = getTNode(tView, index); - if (tNode && - (tNode.type === TNodeType.Container || tNode.type === TNodeType.Element || - tNode.type === TNodeType.ElementContainer) && - tNode.localNames !== null) { - // Divide by 2 to get the number of local refs, - // since they are stored as an array that also includes directive indexes, - // i.e. ["localRef", directiveIndex, ...] - index += tNode.localNames.length >> 1; - } - index++; - } -} - -/** - * Creates and stores the dynamic TNode, and unhooks it from the tree for now. - */ -function createDynamicNodeAtIndex( - tView: TView, lView: LView, index: number, type: TNodeType, native: RElement|RText|null, - name: string|null): TElementNode|TIcuContainerNode { - const previousOrParentTNode = getPreviousOrParentTNode(); - ngDevMode && assertIndexInRange(lView, index + HEADER_OFFSET); - lView[index + HEADER_OFFSET] = native; - // FIXME(misko): Why does this create A TNode??? I would not expect this to be here. - const tNode = getOrCreateTNode(tView, lView[T_HOST], index, type as any, name, null); - - // We are creating a dynamic node, the previous tNode might not be pointing at this node. - // We will link ourselves into the tree later with `appendI18nNode`. - if (previousOrParentTNode && previousOrParentTNode.next === tNode) { - previousOrParentTNode.next = null; - } - - return tNode; -} - -/** - * Apply `I18nMutateOpCodes` OpCodes. - * - * @param tView Current `TView` - * @param rootIndex Pointer to the root (parent) tNode for the i18n. - * @param createOpCodes OpCodes to process - * @param lView Current `LView` - */ -function applyCreateOpCodes( - tView: TView, rootindex: number, createOpCodes: I18nMutateOpCodes, lView: LView): number[] { - const renderer = lView[RENDERER]; - let currentTNode: TNode|null = null; - let previousTNode: TNode|null = null; - const visitedNodes: number[] = []; - for (let i = 0; i < createOpCodes.length; i++) { - const opCode = createOpCodes[i]; - if (typeof opCode == 'string') { - const textRNode = createTextNode(opCode, renderer); - const textNodeIndex = createOpCodes[++i] as number; - ngDevMode && ngDevMode.rendererCreateTextNode++; - previousTNode = currentTNode; - currentTNode = - createDynamicNodeAtIndex(tView, lView, textNodeIndex, TNodeType.Element, textRNode, null); - visitedNodes.push(textNodeIndex); - setIsNotParent(); - } else if (typeof opCode == 'number') { - switch (opCode & I18nMutateOpCode.MASK_INSTRUCTION) { - case I18nMutateOpCode.AppendChild: - const destinationNodeIndex = opCode >>> I18nMutateOpCode.SHIFT_PARENT; - let destinationTNode: TNode; - if (destinationNodeIndex === rootindex) { - // If the destination node is `i18nStart`, we don't have a - // top-level node and we should use the host node instead - destinationTNode = lView[T_HOST]!; - } else { - destinationTNode = getTNode(tView, destinationNodeIndex); - } - ngDevMode && - assertDefined( - currentTNode!, - `You need to create or select a node before you can insert it into the DOM`); - previousTNode = - appendI18nNode(tView, currentTNode!, destinationTNode, previousTNode, lView); - break; - case I18nMutateOpCode.Select: - // Negative indices indicate that a given TNode is a sibling node, not a parent node - // (see `i18nStartFirstPass` for additional information). - const isParent = opCode >= 0; - // FIXME(misko): This SHIFT_REF looks suspect as it does not have mask. - const nodeIndex = (isParent ? opCode : ~opCode) >>> I18nMutateOpCode.SHIFT_REF; - visitedNodes.push(nodeIndex); - previousTNode = currentTNode; - currentTNode = getTNode(tView, nodeIndex); - if (currentTNode) { - setPreviousOrParentTNode(currentTNode, isParent); - } - break; - case I18nMutateOpCode.ElementEnd: - const elementIndex = opCode >>> I18nMutateOpCode.SHIFT_REF; - previousTNode = currentTNode = getTNode(tView, elementIndex); - setPreviousOrParentTNode(currentTNode, false); - break; - case I18nMutateOpCode.Attr: - const elementNodeIndex = opCode >>> I18nMutateOpCode.SHIFT_REF; - const attrName = createOpCodes[++i] as string; - const attrValue = createOpCodes[++i] as string; - // This code is used for ICU expressions only, since we don't support - // directives/components in ICUs, we don't need to worry about inputs here - elementAttributeInternal( - getTNode(tView, elementNodeIndex), lView, attrName, attrValue, null, null); - break; - default: - throw new Error(`Unable to determine the type of mutate operation for "${opCode}"`); - } - } else { - switch (opCode) { - case COMMENT_MARKER: - const commentValue = createOpCodes[++i] as string; - const commentNodeIndex = createOpCodes[++i] as number; - ngDevMode && - assertEqual( - typeof commentValue, 'string', - `Expected "${commentValue}" to be a comment node value`); - const commentRNode = renderer.createComment(commentValue); - ngDevMode && ngDevMode.rendererCreateComment++; - previousTNode = currentTNode; - currentTNode = createDynamicNodeAtIndex( - tView, lView, commentNodeIndex, TNodeType.IcuContainer, commentRNode, null); - visitedNodes.push(commentNodeIndex); - attachPatchData(commentRNode, lView); - // We will add the case nodes later, during the update phase - setIsNotParent(); - break; - case ELEMENT_MARKER: - const tagNameValue = createOpCodes[++i] as string; - const elementNodeIndex = createOpCodes[++i] as number; - ngDevMode && - assertEqual( - typeof tagNameValue, 'string', - `Expected "${tagNameValue}" to be an element node tag name`); - const elementRNode = renderer.createElement(tagNameValue); - ngDevMode && ngDevMode.rendererCreateElement++; - previousTNode = currentTNode; - currentTNode = createDynamicNodeAtIndex( - tView, lView, elementNodeIndex, TNodeType.Element, elementRNode, tagNameValue); - visitedNodes.push(elementNodeIndex); - break; - default: - throw new Error(`Unable to determine the type of mutate operation for "${opCode}"`); - } - } - } - - setIsNotParent(); - - return visitedNodes; -} - -/** - * Apply `I18nUpdateOpCodes` OpCodes - * - * @param tView Current `TView` - * @param tIcus If ICUs present than this contains them. - * @param lView Current `LView` - * @param updateOpCodes OpCodes to process - * @param bindingsStartIndex Location of the first `ɵɵi18nApply` - * @param changeMask Each bit corresponds to a `ɵɵi18nExp` (Counting backwards from - * `bindingsStartIndex`) - */ -function applyUpdateOpCodes( - tView: TView, tIcus: TIcu[]|null, lView: LView, updateOpCodes: I18nUpdateOpCodes, - bindingsStartIndex: number, changeMask: number) { - let caseCreated = false; - for (let i = 0; i < updateOpCodes.length; i++) { - // bit code to check if we should apply the next update - const checkBit = updateOpCodes[i] as number; - // Number of opCodes to skip until next set of update codes - const skipCodes = updateOpCodes[++i] as number; - if (checkBit & changeMask) { - // The value has been updated since last checked - let value = ''; - for (let j = i + 1; j <= (i + skipCodes); j++) { - const opCode = updateOpCodes[j]; - if (typeof opCode == 'string') { - value += opCode; - } else if (typeof opCode == 'number') { - if (opCode < 0) { - // Negative opCode represent `i18nExp` values offset. - value += renderStringify(lView[bindingsStartIndex - opCode]); - } else { - const nodeIndex = opCode >>> I18nUpdateOpCode.SHIFT_REF; - switch (opCode & I18nUpdateOpCode.MASK_OPCODE) { - case I18nUpdateOpCode.Attr: - const propName = updateOpCodes[++j] as string; - const sanitizeFn = updateOpCodes[++j] as SanitizerFn | null; - elementPropertyInternal( - tView, getTNode(tView, nodeIndex), lView, propName, value, lView[RENDERER], - sanitizeFn, false); - break; - case I18nUpdateOpCode.Text: - applyTextBinding(lView, nodeIndex, value); - break; - case I18nUpdateOpCode.IcuSwitch: - caseCreated = - applyIcuSwitchCase(tView, tIcus!, updateOpCodes[++j] as number, lView, value); - break; - case I18nUpdateOpCode.IcuUpdate: - applyIcuUpdateCase( - tView, tIcus!, updateOpCodes[++j] as number, bindingsStartIndex, lView, - caseCreated); - break; - } - } - } - } - } - i += skipCodes; - } -} - -/** - * Apply OpCodes associated with updating an existing ICU. - * - * @param tView Current `TView` - * @param tIcus tIcus ICUs active at this location them. - * @param tIcuIndex Index into `tIcus` to process. - * @param bindingsStartIndex Location of the first `ɵɵi18nApply` - * @param lView Current `LView` - * @param changeMask Each bit corresponds to a `ɵɵi18nExp` (Counting backwards from - * `bindingsStartIndex`) - */ -function applyIcuUpdateCase( - tView: TView, tIcus: TIcu[], tIcuIndex: number, bindingsStartIndex: number, lView: LView, - caseCreated: boolean) { - ngDevMode && assertIndexInRange(tIcus, tIcuIndex); - const tIcu = tIcus[tIcuIndex]; - ngDevMode && assertIndexInRange(lView, tIcu.currentCaseLViewIndex); - const activeCaseIndex = lView[tIcu.currentCaseLViewIndex]; - if (activeCaseIndex !== null) { - const mask = caseCreated ? - -1 : // -1 is same as all bits on, which simulates creation since it marks all bits dirty - changeMask; - applyUpdateOpCodes(tView, tIcus, lView, tIcu.update[activeCaseIndex], bindingsStartIndex, mask); - } -} - -/** - * Apply OpCodes associated with switching a case on ICU. - * - * This involves tearing down existing case and than building up a new case. - * - * @param tView Current `TView` - * @param tIcus ICUs active at this location. - * @param tIcuIndex Index into `tIcus` to process. - * @param lView Current `LView` - * @param value Value of the case to update to. - * @returns true if a new case was created (needed so that the update executes regardless of the - * bitmask) - */ -function applyIcuSwitchCase( - tView: TView, tIcus: TIcu[], tIcuIndex: number, lView: LView, value: string): boolean { - applyIcuSwitchCaseRemove(tView, tIcus, tIcuIndex, lView); - - // Rebuild a new case for this ICU - let caseCreated = false; - ngDevMode && assertIndexInRange(tIcus, tIcuIndex); - const tIcu = tIcus[tIcuIndex]; - const caseIndex = getCaseIndex(tIcu, value); - lView[tIcu.currentCaseLViewIndex] = caseIndex !== -1 ? caseIndex : null; - if (caseIndex > -1) { - // Add the nodes for the new case - applyCreateOpCodes( - tView, -1, // -1 means we don't have parent node - tIcu.create[caseIndex], lView); - caseCreated = true; - } - return caseCreated; -} - -/** - * Apply OpCodes associated with tearing down of DOM. - * - * This involves tearing down existing case and than building up a new case. - * - * @param tView Current `TView` - * @param tIcus ICUs active at this location. - * @param tIcuIndex Index into `tIcus` to process. - * @param lView Current `LView` - * @returns true if a new case was created (needed so that the update executes regardless of the - * bitmask) - */ -function applyIcuSwitchCaseRemove(tView: TView, tIcus: TIcu[], tIcuIndex: number, lView: LView) { - ngDevMode && assertIndexInRange(tIcus, tIcuIndex); - const tIcu = tIcus[tIcuIndex]; - const activeCaseIndex = lView[tIcu.currentCaseLViewIndex]; - if (activeCaseIndex !== null) { - const removeCodes = tIcu.remove[activeCaseIndex]; - for (let k = 0; k < removeCodes.length; k++) { - const removeOpCode = removeCodes[k] as number; - const nodeOrIcuIndex = removeOpCode >>> I18nMutateOpCode.SHIFT_REF; - switch (removeOpCode & I18nMutateOpCode.MASK_INSTRUCTION) { - case I18nMutateOpCode.Remove: - // FIXME(misko): this comment is wrong! - // Remove DOM element, but do *not* mark TNode as detached, since we are - // just switching ICU cases (while keeping the same TNode), so a DOM element - // representing a new ICU case will be re-created. - removeNode(tView, lView, nodeOrIcuIndex, /* markAsDetached */ false); - break; - case I18nMutateOpCode.RemoveNestedIcu: - applyIcuSwitchCaseRemove(tView, tIcus, nodeOrIcuIndex, lView); - break; - } - } - } -} - -function removeNode(tView: TView, lView: LView, index: number, markAsDetached: boolean) { - const removedPhTNode = getTNode(tView, index); - const removedPhRNode = getNativeByIndex(index, lView); - if (removedPhRNode) { - nativeRemoveNode(lView[RENDERER], removedPhRNode); - } - - const slotValue = load(lView, index) as RElement | RComment | LContainer; - if (isLContainer(slotValue)) { - const lContainer = slotValue as LContainer; - if (removedPhTNode.type !== TNodeType.Container) { - nativeRemoveNode(lView[RENDERER], lContainer[NATIVE]); - } - } - - if (markAsDetached) { - // Define this node as detached to avoid projecting it later - removedPhTNode.flags |= TNodeFlags.isDetached; - } - ngDevMode && ngDevMode.rendererRemoveNode++; -} - -/** - * - * Use this instruction to create a translation block that doesn't contain any placeholder. - * It calls both {@link i18nStart} and {@link i18nEnd} in one instruction. - * - * The translation `message` is the value which is locale specific. The translation string may - * contain placeholders which associate inner elements and sub-templates within the translation. - * - * The translation `message` placeholders are: - * - `�{index}(:{block})�`: *Binding Placeholder*: Marks a location where an expression will be - * interpolated into. The placeholder `index` points to the expression binding index. An optional - * `block` that matches the sub-template in which it was declared. - * - `�#{index}(:{block})�`/`�/#{index}(:{block})�`: *Element Placeholder*: Marks the beginning - * and end of DOM element that were embedded in the original translation block. The placeholder - * `index` points to the element index in the template instructions set. An optional `block` that - * matches the sub-template in which it was declared. - * - `�*{index}:{block}�`/`�/*{index}:{block}�`: *Sub-template Placeholder*: Sub-templates must be - * split up and translated separately in each angular template function. The `index` points to the - * `template` instruction index. A `block` that matches the sub-template in which it was declared. - * - * @param index A unique index of the translation in the static block. - * @param message The translation message. - * @param subTemplateIndex Optional sub-template index in the `message`. - * - * @codeGenApi - */ -export function ɵɵi18n(index: number, message: string, subTemplateIndex?: number): void { - ɵɵi18nStart(index, message, subTemplateIndex); - ɵɵi18nEnd(); -} - -/** - * Marks a list of attributes as translatable. - * - * @param index A unique index in the static block - * @param values - * - * @codeGenApi - */ -export function ɵɵi18nAttributes(index: number, values: string[]): void { - const lView = getLView(); - const tView = getTView(); - ngDevMode && assertDefined(tView, `tView should be defined`); - i18nAttributesFirstPass(lView, tView, index, values); -} - -/** - * See `i18nAttributes` above. - */ -function i18nAttributesFirstPass(lView: LView, tView: TView, index: number, values: string[]) { - const previousElement = getPreviousOrParentTNode(); - const previousElementIndex = previousElement.index - HEADER_OFFSET; - const updateOpCodes: I18nUpdateOpCodes = []; - if (ngDevMode) { - attachDebugGetter(updateOpCodes, i18nUpdateOpCodesToString); - } - for (let i = 0; i < values.length; i += 2) { - const attrName = values[i]; - const message = values[i + 1]; - const parts = message.split(ICU_REGEXP); - for (let j = 0; j < parts.length; j++) { - const value = parts[j]; - - if (j & 1) { - // Odd indexes are ICU expressions - // TODO(ocombe): support ICU expressions in attributes - throw new Error('ICU expressions are not yet supported in attributes'); - } else if (value !== '') { - // Even indexes are text (including bindings) - const hasBinding = !!value.match(BINDING_REGEXP); - if (hasBinding) { - if (tView.firstCreatePass && tView.data[index + HEADER_OFFSET] === null) { - addAllToArray( - generateBindingUpdateOpCodes(value, previousElementIndex, attrName), updateOpCodes); - } - } else { - const tNode = getTNode(tView, previousElementIndex); - // Set attributes for Elements only, for other types (like ElementContainer), - // only set inputs below - if (tNode.type === TNodeType.Element) { - elementAttributeInternal(tNode, lView, attrName, value, null, null); - } - // Check if that attribute is a directive input - const dataValue = tNode.inputs !== null && tNode.inputs[attrName]; - if (dataValue) { - setInputsForProperty(tView, lView, dataValue, attrName, value); - if (ngDevMode) { - const element = getNativeByIndex(previousElementIndex, lView) as RElement | RComment; - setNgReflectProperties(lView, element, tNode.type, dataValue, value); - } - } - } - } - } - } - - if (tView.firstCreatePass && tView.data[index + HEADER_OFFSET] === null) { - tView.data[index + HEADER_OFFSET] = updateOpCodes; - } -} - -let changeMask = 0b0; -let shiftsCounter = 0; - -/** - * Stores the values of the bindings during each update cycle in order to determine if we need to - * update the translated nodes. - * - * @param value The binding's value - * @returns This function returns itself so that it may be chained - * (e.g. `i18nExp(ctx.name)(ctx.title)`) - * - * @codeGenApi - */ -export function ɵɵi18nExp(value: T): typeof ɵɵi18nExp { - const lView = getLView(); - if (bindingUpdated(lView, nextBindingIndex(), value)) { - changeMask = changeMask | (1 << shiftsCounter); - } - shiftsCounter++; - return ɵɵi18nExp; -} - -/** - * Updates a translation block or an i18n attribute when the bindings have changed. - * - * @param index Index of either {@link i18nStart} (translation block) or {@link i18nAttributes} - * (i18n attribute) on which it should update the content. - * - * @codeGenApi - */ -export function ɵɵi18nApply(index: number) { - if (shiftsCounter) { - const tView = getTView(); - ngDevMode && assertDefined(tView, `tView should be defined`); - const tI18n = tView.data[index + HEADER_OFFSET] as TI18n | I18nUpdateOpCodes; - let updateOpCodes: I18nUpdateOpCodes; - let tIcus: TIcu[]|null = null; - if (Array.isArray(tI18n)) { - updateOpCodes = tI18n as I18nUpdateOpCodes; - } else { - updateOpCodes = (tI18n as TI18n).update; - tIcus = (tI18n as TI18n).icus; - } - const bindingsStartIndex = getBindingIndex() - shiftsCounter - 1; - const lView = getLView(); - applyUpdateOpCodes(tView, tIcus, lView, updateOpCodes, bindingsStartIndex, changeMask); - - // Reset changeMask & maskBit to default for the next update cycle - changeMask = 0b0; - shiftsCounter = 0; - } -} - -/** - * Returns the index of the current case of an ICU expression depending on the main binding value - * - * @param icuExpression - * @param bindingValue The value of the main binding used by this ICU expression - */ -function getCaseIndex(icuExpression: TIcu, bindingValue: string): number { - let index = icuExpression.cases.indexOf(bindingValue); - if (index === -1) { - switch (icuExpression.type) { - case IcuType.plural: { - const resolvedCase = getPluralCase(bindingValue, getLocaleId()); - index = icuExpression.cases.indexOf(resolvedCase); - if (index === -1 && resolvedCase !== 'other') { - index = icuExpression.cases.indexOf('other'); - } - break; - } - case IcuType.select: { - index = icuExpression.cases.indexOf('other'); - break; - } - } - } - return index; -} - -/** - * Generate the OpCodes for ICU expressions. - * - * @param tIcus - * @param icuExpression - * @param startIndex - * @param expandoStartIndex - */ -function icuStart( - tIcus: TIcu[], icuExpression: IcuExpression, startIndex: number, - expandoStartIndex: number): void { - const createCodes: I18nMutateOpCodes[] = []; - const removeCodes: I18nMutateOpCodes[] = []; - const updateCodes: I18nUpdateOpCodes[] = []; - const vars = []; - const childIcus: number[][] = []; - const values = icuExpression.values; - for (let i = 0; i < values.length; i++) { - // Each value is an array of strings & other ICU expressions - const valueArr = values[i]; - const nestedIcus: IcuExpression[] = []; - for (let j = 0; j < valueArr.length; j++) { - const value = valueArr[j]; - if (typeof value !== 'string') { - // It is an nested ICU expression - const icuIndex = nestedIcus.push(value as IcuExpression) - 1; - // Replace nested ICU expression by a comment node - valueArr[j] = ``; - } - } - const icuCase: IcuCase = - parseIcuCase(valueArr.join(''), startIndex, nestedIcus, tIcus, expandoStartIndex); - createCodes.push(icuCase.create); - removeCodes.push(icuCase.remove); - updateCodes.push(icuCase.update); - vars.push(icuCase.vars); - childIcus.push(icuCase.childIcus); - } - const tIcu: TIcu = { - type: icuExpression.type, - vars, - currentCaseLViewIndex: HEADER_OFFSET + - expandoStartIndex // expandoStartIndex does not include the header so add it. - + 1, // The first item stored is the `` anchor so skip it. - childIcus, - cases: icuExpression.cases, - create: createCodes, - remove: removeCodes, - update: updateCodes - }; - tIcus.push(tIcu); - // Adding the maximum possible of vars needed (based on the cases with the most vars) - i18nVarsCount += Math.max(...vars); -} - -/** - * Transforms a string template into an HTML template and a list of instructions used to update - * attributes or nodes that contain bindings. - * - * @param unsafeHtml The string to parse - * @param parentIndex - * @param nestedIcus - * @param tIcus - * @param expandoStartIndex - */ -function parseIcuCase( - unsafeHtml: string, parentIndex: number, nestedIcus: IcuExpression[], tIcus: TIcu[], - expandoStartIndex: number): IcuCase { - const inertBodyHelper = getInertBodyHelper(getDocument()); - const inertBodyElement = inertBodyHelper.getInertBodyElement(unsafeHtml); - if (!inertBodyElement) { - throw new Error('Unable to generate inert body element'); - } - const wrapper = getTemplateContent(inertBodyElement!) as Element || inertBodyElement; - const opCodes: IcuCase = { - vars: 1, // allocate space for `TIcu.currentCaseLViewIndex` - childIcus: [], - create: [], - remove: [], - update: [] - }; - if (ngDevMode) { - attachDebugGetter(opCodes.create, i18nMutateOpCodesToString); - attachDebugGetter(opCodes.remove, i18nMutateOpCodesToString); - attachDebugGetter(opCodes.update, i18nUpdateOpCodesToString); - } - parseNodes(wrapper.firstChild, opCodes, parentIndex, nestedIcus, tIcus, expandoStartIndex); - return opCodes; -} - -const NESTED_ICU = /�(\d+)�/; - -/** - * Parses a node, its children and its siblings, and generates the mutate & update OpCodes. - * - * @param currentNode The first node to parse - * @param icuCase The data for the ICU expression case that contains those nodes - * @param parentIndex Index of the current node's parent - * @param nestedIcus Data for the nested ICU expressions that this case contains - * @param tIcus Data for all ICU expressions of the current message - * @param expandoStartIndex Expando start index for the current ICU expression - */ -function parseNodes( - currentNode: Node|null, icuCase: IcuCase, parentIndex: number, nestedIcus: IcuExpression[], - tIcus: TIcu[], expandoStartIndex: number) { - if (currentNode) { - const nestedIcusToCreate: [IcuExpression, number][] = []; - while (currentNode) { - const nextNode: Node|null = currentNode.nextSibling; - const newIndex = expandoStartIndex + ++icuCase.vars; - switch (currentNode.nodeType) { - case Node.ELEMENT_NODE: - const element = currentNode as Element; - const tagName = element.tagName.toLowerCase(); - if (!VALID_ELEMENTS.hasOwnProperty(tagName)) { - // This isn't a valid element, we won't create an element for it - icuCase.vars--; - } else { - icuCase.create.push( - ELEMENT_MARKER, tagName, newIndex, - parentIndex << I18nMutateOpCode.SHIFT_PARENT | I18nMutateOpCode.AppendChild); - const elAttrs = element.attributes; - for (let i = 0; i < elAttrs.length; i++) { - const attr = elAttrs.item(i)!; - const lowerAttrName = attr.name.toLowerCase(); - const hasBinding = !!attr.value.match(BINDING_REGEXP); - // we assume the input string is safe, unless it's using a binding - if (hasBinding) { - if (VALID_ATTRS.hasOwnProperty(lowerAttrName)) { - if (URI_ATTRS[lowerAttrName]) { - addAllToArray( - generateBindingUpdateOpCodes(attr.value, newIndex, attr.name, _sanitizeUrl), - icuCase.update); - } else if (SRCSET_ATTRS[lowerAttrName]) { - addAllToArray( - generateBindingUpdateOpCodes( - attr.value, newIndex, attr.name, sanitizeSrcset), - icuCase.update); - } else { - addAllToArray( - generateBindingUpdateOpCodes(attr.value, newIndex, attr.name), - icuCase.update); - } - } else { - ngDevMode && - console.warn(`WARNING: ignoring unsafe attribute value ${ - lowerAttrName} on element ${tagName} (see http://g.co/ng/security#xss)`); - } - } else { - icuCase.create.push( - newIndex << I18nMutateOpCode.SHIFT_REF | I18nMutateOpCode.Attr, attr.name, - attr.value); - } - } - // Parse the children of this node (if any) - parseNodes( - currentNode.firstChild, icuCase, newIndex, nestedIcus, tIcus, expandoStartIndex); - // Remove the parent node after the children - icuCase.remove.push(newIndex << I18nMutateOpCode.SHIFT_REF | I18nMutateOpCode.Remove); - } - break; - case Node.TEXT_NODE: - const value = currentNode.textContent || ''; - const hasBinding = value.match(BINDING_REGEXP); - icuCase.create.push( - hasBinding ? '' : value, newIndex, - parentIndex << I18nMutateOpCode.SHIFT_PARENT | I18nMutateOpCode.AppendChild); - icuCase.remove.push(newIndex << I18nMutateOpCode.SHIFT_REF | I18nMutateOpCode.Remove); - if (hasBinding) { - addAllToArray(generateBindingUpdateOpCodes(value, newIndex), icuCase.update); - } - break; - case Node.COMMENT_NODE: - // Check if the comment node is a placeholder for a nested ICU - const match = NESTED_ICU.exec(currentNode.textContent || ''); - if (match) { - const nestedIcuIndex = parseInt(match[1], 10); - const newLocal = ngDevMode ? `nested ICU ${nestedIcuIndex}` : ''; - // Create the comment node that will anchor the ICU expression - icuCase.create.push( - COMMENT_MARKER, newLocal, newIndex, - parentIndex << I18nMutateOpCode.SHIFT_PARENT | I18nMutateOpCode.AppendChild); - const nestedIcu = nestedIcus[nestedIcuIndex]; - nestedIcusToCreate.push([nestedIcu, newIndex]); - } else { - // We do not handle any other type of comment - icuCase.vars--; - } - break; - default: - // We do not handle any other type of element - icuCase.vars--; - } - currentNode = nextNode!; - } - - for (let i = 0; i < nestedIcusToCreate.length; i++) { - const nestedIcu = nestedIcusToCreate[i][0]; - const nestedIcuNodeIndex = nestedIcusToCreate[i][1]; - icuStart(tIcus, nestedIcu, nestedIcuNodeIndex, expandoStartIndex + icuCase.vars); - // Since this is recursive, the last TIcu that was pushed is the one we want - const nestTIcuIndex = tIcus.length - 1; - icuCase.vars += Math.max(...tIcus[nestTIcuIndex].vars); - icuCase.childIcus.push(nestTIcuIndex); - const mask = getBindingMask(nestedIcu); - icuCase.update.push( - toMaskBit(nestedIcu.mainBinding), // mask of the main binding - 3, // skip 3 opCodes if not changed - -1 - nestedIcu.mainBinding, - nestedIcuNodeIndex << I18nUpdateOpCode.SHIFT_REF | I18nUpdateOpCode.IcuSwitch, - // FIXME(misko): Index should be part of the opcode - nestTIcuIndex, - mask, // mask of all the bindings of this ICU expression - 2, // skip 2 opCodes if not changed - nestedIcuNodeIndex << I18nUpdateOpCode.SHIFT_REF | I18nUpdateOpCode.IcuUpdate, - nestTIcuIndex); - icuCase.remove.push( - nestTIcuIndex << I18nMutateOpCode.SHIFT_REF | I18nMutateOpCode.RemoveNestedIcu, - // FIXME(misko): Index should be part of the opcode - nestedIcuNodeIndex << I18nMutateOpCode.SHIFT_REF | I18nMutateOpCode.Remove); - } - } -} - -/** - * Angular Dart introduced &ngsp; as a placeholder for non-removable space, see: - * https://github.com/dart-lang/angular/blob/0bb611387d29d65b5af7f9d2515ab571fd3fbee4/_tests/test/compiler/preserve_whitespace_test.dart#L25-L32 - * In Angular Dart &ngsp; is converted to the 0xE500 PUA (Private Use Areas) unicode character - * and later on replaced by a space. We are re-implementing the same idea here, since translations - * might contain this special character. - */ -const NGSP_UNICODE_REGEXP = /\uE500/g; -function replaceNgsp(value: string): string { - return value.replace(NGSP_UNICODE_REGEXP, ' '); -} - -/** - * The locale id that the application is currently using (for translations and ICU expressions). - * This is the ivy version of `LOCALE_ID` that was defined as an injection token for the view engine - * but is now defined as a global value. - */ -let LOCALE_ID = DEFAULT_LOCALE_ID; - -/** - * Sets the locale id that will be used for translations and ICU expressions. - * This is the ivy version of `LOCALE_ID` that was defined as an injection token for the view engine - * but is now defined as a global value. - * - * @param localeId - */ -export function setLocaleId(localeId: string) { - assertDefined(localeId, `Expected localeId to be defined`); - if (typeof localeId === 'string') { - LOCALE_ID = localeId.toLowerCase().replace(/_/g, '-'); - } -} - -/** - * Gets the locale id that will be used for translations and ICU expressions. - * This is the ivy version of `LOCALE_ID` that was defined as an injection token for the view engine - * but is now defined as a global value. - */ -export function getLocaleId(): string { - return LOCALE_ID; -} diff --git a/packages/core/src/render3/i18n.md b/packages/core/src/render3/i18n/i18n.md similarity index 100% rename from packages/core/src/render3/i18n.md rename to packages/core/src/render3/i18n/i18n.md diff --git a/packages/core/src/render3/i18n/i18n_apply.ts b/packages/core/src/render3/i18n/i18n_apply.ts new file mode 100644 index 0000000000..af7728decd --- /dev/null +++ b/packages/core/src/render3/i18n/i18n_apply.ts @@ -0,0 +1,503 @@ +/** + * @license + * Copyright Google LLC 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 {getPluralCase} from '../../i18n/localization'; +import {assertDefined, assertEqual, assertIndexInRange} from '../../util/assert'; +import {attachPatchData} from '../context_discovery'; +import {elementAttributeInternal, elementPropertyInternal, getOrCreateTNode, textBindingInternal} from '../instructions/shared'; +import {LContainer, NATIVE} from '../interfaces/container'; +import {COMMENT_MARKER, ELEMENT_MARKER, I18nMutateOpCode, I18nMutateOpCodes, I18nUpdateOpCode, I18nUpdateOpCodes, IcuType, TI18n, TIcu} from '../interfaces/i18n'; +import {TElementNode, TIcuContainerNode, TNode, TNodeFlags, TNodeType, TProjectionNode} from '../interfaces/node'; +import {RComment, RElement, RText} from '../interfaces/renderer'; +import {SanitizerFn} from '../interfaces/sanitization'; +import {isLContainer} from '../interfaces/type_checks'; +import {HEADER_OFFSET, LView, RENDERER, T_HOST, TView} from '../interfaces/view'; +import {appendChild, applyProjection, createTextNode, nativeRemoveNode} from '../node_manipulation'; +import {getBindingIndex, getLView, getPreviousOrParentTNode, getTView, setIsNotParent, setPreviousOrParentTNode} from '../state'; +import {renderStringify} from '../util/misc_utils'; +import {getNativeByIndex, getNativeByTNode, getTNode, load} from '../util/view_utils'; +import {getLocaleId} from './i18n_locale_id'; + + +const i18nIndexStack: number[] = []; +let i18nIndexStackPointer = -1; + +function popI18nIndex() { + return i18nIndexStack[i18nIndexStackPointer--]; +} + +export function pushI18nIndex(index: number) { + i18nIndexStack[++i18nIndexStackPointer] = index; +} + +let changeMask = 0b0; +let shiftsCounter = 0; + +export function setMaskBit(bit: boolean) { + if (bit) { + changeMask = changeMask | (1 << shiftsCounter); + } + shiftsCounter++; +} + +export function applyI18n(tView: TView, lView: LView, index: number) { + if (shiftsCounter > 0) { + ngDevMode && assertDefined(tView, `tView should be defined`); + const tI18n = tView.data[index + HEADER_OFFSET] as TI18n | I18nUpdateOpCodes; + let updateOpCodes: I18nUpdateOpCodes; + let tIcus: TIcu[]|null = null; + if (Array.isArray(tI18n)) { + updateOpCodes = tI18n as I18nUpdateOpCodes; + } else { + updateOpCodes = (tI18n as TI18n).update; + tIcus = (tI18n as TI18n).icus; + } + const bindingsStartIndex = getBindingIndex() - shiftsCounter - 1; + applyUpdateOpCodes(tView, tIcus, lView, updateOpCodes, bindingsStartIndex, changeMask); + + // Reset changeMask & maskBit to default for the next update cycle + changeMask = 0b0; + shiftsCounter = 0; + } +} + +/** + * Apply `I18nMutateOpCodes` OpCodes. + * + * @param tView Current `TView` + * @param rootIndex Pointer to the root (parent) tNode for the i18n. + * @param createOpCodes OpCodes to process + * @param lView Current `LView` + */ +export function applyCreateOpCodes( + tView: TView, rootindex: number, createOpCodes: I18nMutateOpCodes, lView: LView): number[] { + const renderer = lView[RENDERER]; + let currentTNode: TNode|null = null; + let previousTNode: TNode|null = null; + const visitedNodes: number[] = []; + for (let i = 0; i < createOpCodes.length; i++) { + const opCode = createOpCodes[i]; + if (typeof opCode == 'string') { + const textRNode = createTextNode(opCode, renderer); + const textNodeIndex = createOpCodes[++i] as number; + ngDevMode && ngDevMode.rendererCreateTextNode++; + previousTNode = currentTNode; + currentTNode = + createDynamicNodeAtIndex(tView, lView, textNodeIndex, TNodeType.Element, textRNode, null); + visitedNodes.push(textNodeIndex); + setIsNotParent(); + } else if (typeof opCode == 'number') { + switch (opCode & I18nMutateOpCode.MASK_INSTRUCTION) { + case I18nMutateOpCode.AppendChild: + const destinationNodeIndex = opCode >>> I18nMutateOpCode.SHIFT_PARENT; + let destinationTNode: TNode; + if (destinationNodeIndex === rootindex) { + // If the destination node is `i18nStart`, we don't have a + // top-level node and we should use the host node instead + destinationTNode = lView[T_HOST]!; + } else { + destinationTNode = getTNode(tView, destinationNodeIndex); + } + ngDevMode && + assertDefined( + currentTNode!, + `You need to create or select a node before you can insert it into the DOM`); + previousTNode = + appendI18nNode(tView, currentTNode!, destinationTNode, previousTNode, lView); + break; + case I18nMutateOpCode.Select: + // Negative indices indicate that a given TNode is a sibling node, not a parent node + // (see `i18nStartFirstPass` for additional information). + const isParent = opCode >= 0; + // FIXME(misko): This SHIFT_REF looks suspect as it does not have mask. + const nodeIndex = (isParent ? opCode : ~opCode) >>> I18nMutateOpCode.SHIFT_REF; + visitedNodes.push(nodeIndex); + previousTNode = currentTNode; + currentTNode = getTNode(tView, nodeIndex); + if (currentTNode) { + setPreviousOrParentTNode(currentTNode, isParent); + } + break; + case I18nMutateOpCode.ElementEnd: + const elementIndex = opCode >>> I18nMutateOpCode.SHIFT_REF; + previousTNode = currentTNode = getTNode(tView, elementIndex); + setPreviousOrParentTNode(currentTNode, false); + break; + case I18nMutateOpCode.Attr: + const elementNodeIndex = opCode >>> I18nMutateOpCode.SHIFT_REF; + const attrName = createOpCodes[++i] as string; + const attrValue = createOpCodes[++i] as string; + // This code is used for ICU expressions only, since we don't support + // directives/components in ICUs, we don't need to worry about inputs here + elementAttributeInternal( + getTNode(tView, elementNodeIndex), lView, attrName, attrValue, null, null); + break; + default: + throw new Error(`Unable to determine the type of mutate operation for "${opCode}"`); + } + } else { + switch (opCode) { + case COMMENT_MARKER: + const commentValue = createOpCodes[++i] as string; + const commentNodeIndex = createOpCodes[++i] as number; + ngDevMode && + assertEqual( + typeof commentValue, 'string', + `Expected "${commentValue}" to be a comment node value`); + const commentRNode = renderer.createComment(commentValue); + ngDevMode && ngDevMode.rendererCreateComment++; + previousTNode = currentTNode; + currentTNode = createDynamicNodeAtIndex( + tView, lView, commentNodeIndex, TNodeType.IcuContainer, commentRNode, null); + visitedNodes.push(commentNodeIndex); + attachPatchData(commentRNode, lView); + // We will add the case nodes later, during the update phase + setIsNotParent(); + break; + case ELEMENT_MARKER: + const tagNameValue = createOpCodes[++i] as string; + const elementNodeIndex = createOpCodes[++i] as number; + ngDevMode && + assertEqual( + typeof tagNameValue, 'string', + `Expected "${tagNameValue}" to be an element node tag name`); + const elementRNode = renderer.createElement(tagNameValue); + ngDevMode && ngDevMode.rendererCreateElement++; + previousTNode = currentTNode; + currentTNode = createDynamicNodeAtIndex( + tView, lView, elementNodeIndex, TNodeType.Element, elementRNode, tagNameValue); + visitedNodes.push(elementNodeIndex); + break; + default: + throw new Error(`Unable to determine the type of mutate operation for "${opCode}"`); + } + } + } + + setIsNotParent(); + + return visitedNodes; +} + + +/** + * Apply `I18nUpdateOpCodes` OpCodes + * + * @param tView Current `TView` + * @param tIcus If ICUs present than this contains them. + * @param lView Current `LView` + * @param updateOpCodes OpCodes to process + * @param bindingsStartIndex Location of the first `ɵɵi18nApply` + * @param changeMask Each bit corresponds to a `ɵɵi18nExp` (Counting backwards from + * `bindingsStartIndex`) + */ +export function applyUpdateOpCodes( + tView: TView, tIcus: TIcu[]|null, lView: LView, updateOpCodes: I18nUpdateOpCodes, + bindingsStartIndex: number, changeMask: number) { + let caseCreated = false; + for (let i = 0; i < updateOpCodes.length; i++) { + // bit code to check if we should apply the next update + const checkBit = updateOpCodes[i] as number; + // Number of opCodes to skip until next set of update codes + const skipCodes = updateOpCodes[++i] as number; + if (checkBit & changeMask) { + // The value has been updated since last checked + let value = ''; + for (let j = i + 1; j <= (i + skipCodes); j++) { + const opCode = updateOpCodes[j]; + if (typeof opCode == 'string') { + value += opCode; + } else if (typeof opCode == 'number') { + if (opCode < 0) { + // Negative opCode represent `i18nExp` values offset. + value += renderStringify(lView[bindingsStartIndex - opCode]); + } else { + const nodeIndex = opCode >>> I18nUpdateOpCode.SHIFT_REF; + switch (opCode & I18nUpdateOpCode.MASK_OPCODE) { + case I18nUpdateOpCode.Attr: + const propName = updateOpCodes[++j] as string; + const sanitizeFn = updateOpCodes[++j] as SanitizerFn | null; + elementPropertyInternal( + tView, getTNode(tView, nodeIndex), lView, propName, value, lView[RENDERER], + sanitizeFn, false); + break; + case I18nUpdateOpCode.Text: + textBindingInternal(lView, nodeIndex, value); + break; + case I18nUpdateOpCode.IcuSwitch: + caseCreated = + applyIcuSwitchCase(tView, tIcus!, updateOpCodes[++j] as number, lView, value); + break; + case I18nUpdateOpCode.IcuUpdate: + applyIcuUpdateCase( + tView, tIcus!, updateOpCodes[++j] as number, bindingsStartIndex, lView, + caseCreated); + break; + } + } + } + } + } + i += skipCodes; + } +} + +/** + * Apply OpCodes associated with updating an existing ICU. + * + * @param tView Current `TView` + * @param tIcus ICUs active at this location. + * @param tIcuIndex Index into `tIcus` to process. + * @param bindingsStartIndex Location of the first `ɵɵi18nApply` + * @param lView Current `LView` + * @param changeMask Each bit corresponds to a `ɵɵi18nExp` (Counting backwards from + * `bindingsStartIndex`) + */ +function applyIcuUpdateCase( + tView: TView, tIcus: TIcu[], tIcuIndex: number, bindingsStartIndex: number, lView: LView, + caseCreated: boolean) { + ngDevMode && assertIndexInRange(tIcus, tIcuIndex); + const tIcu = tIcus[tIcuIndex]; + ngDevMode && assertIndexInRange(lView, tIcu.currentCaseLViewIndex); + const activeCaseIndex = lView[tIcu.currentCaseLViewIndex]; + if (activeCaseIndex !== null) { + const mask = caseCreated ? + -1 : // -1 is same as all bits on, which simulates creation since it marks all bits dirty + changeMask; + applyUpdateOpCodes(tView, tIcus, lView, tIcu.update[activeCaseIndex], bindingsStartIndex, mask); + } +} + +/** + * Apply OpCodes associated with switching a case on ICU. + * + * This involves tearing down existing case and than building up a new case. + * + * @param tView Current `TView` + * @param tIcus ICUs active at this location. + * @param tICuIndex Index into `tIcus` to process. + * @param lView Current `LView` + * @param value Value of the case to update to. + * @returns true if a new case was created (needed so that the update executes regardless of the + * bitmask) + */ +function applyIcuSwitchCase( + tView: TView, tIcus: TIcu[], tICuIndex: number, lView: LView, value: string): boolean { + applyIcuSwitchCaseRemove(tView, tIcus, tICuIndex, lView); + + // Rebuild a new case for this ICU + let caseCreated = false; + const tIcu = tIcus[tICuIndex]; + const caseIndex = getCaseIndex(tIcu, value); + lView[tIcu.currentCaseLViewIndex] = caseIndex !== -1 ? caseIndex : null; + if (caseIndex > -1) { + // Add the nodes for the new case + applyCreateOpCodes( + tView, -1, // -1 means we don't have parent node + tIcu.create[caseIndex], lView); + caseCreated = true; + } + return caseCreated; +} + +/** + * Apply OpCodes associated with tearing down of DOM. + * + * This involves tearing down existing case and than building up a new case. + * + * @param tView Current `TView` + * @param tIcus ICUs active at this location. + * @param tIcuIndex Index into `tIcus` to process. + * @param lView Current `LView` + * @returns true if a new case was created (needed so that the update executes regardless of the + * bitmask) + */ +function applyIcuSwitchCaseRemove(tView: TView, tIcus: TIcu[], tIcuIndex: number, lView: LView) { + ngDevMode && assertIndexInRange(tIcus, tIcuIndex); + const tIcu = tIcus[tIcuIndex]; + const activeCaseIndex = lView[tIcu.currentCaseLViewIndex]; + if (activeCaseIndex !== null) { + const removeCodes = tIcu.remove[activeCaseIndex]; + for (let k = 0; k < removeCodes.length; k++) { + const removeOpCode = removeCodes[k] as number; + const nodeOrIcuIndex = removeOpCode >>> I18nMutateOpCode.SHIFT_REF; + switch (removeOpCode & I18nMutateOpCode.MASK_INSTRUCTION) { + case I18nMutateOpCode.Remove: + // FIXME(misko): this comment is wrong! + // Remove DOM element, but do *not* mark TNode as detached, since we are + // just switching ICU cases (while keeping the same TNode), so a DOM element + // representing a new ICU case will be re-created. + removeNode(tView, lView, nodeOrIcuIndex, /* markAsDetached */ false); + break; + case I18nMutateOpCode.RemoveNestedIcu: + applyIcuSwitchCaseRemove(tView, tIcus, nodeOrIcuIndex, lView); + break; + } + } + } +} + +function appendI18nNode( + tView: TView, tNode: TNode, parentTNode: TNode, previousTNode: TNode|null, + lView: LView): TNode { + ngDevMode && ngDevMode.rendererMoveNode++; + const nextNode = tNode.next; + if (!previousTNode) { + previousTNode = parentTNode; + } + + // Re-organize node tree to put this node in the correct position. + if (previousTNode === parentTNode && tNode !== parentTNode.child) { + tNode.next = parentTNode.child; + parentTNode.child = tNode; + } else if (previousTNode !== parentTNode && tNode !== previousTNode.next) { + tNode.next = previousTNode.next; + previousTNode.next = tNode; + } else { + tNode.next = null; + } + + if (parentTNode !== lView[T_HOST]) { + tNode.parent = parentTNode as TElementNode; + } + + // If tNode was moved around, we might need to fix a broken link. + let cursor: TNode|null = tNode.next; + while (cursor) { + if (cursor.next === tNode) { + cursor.next = nextNode; + } + cursor = cursor.next; + } + + // If the placeholder to append is a projection, we need to move the projected nodes instead + if (tNode.type === TNodeType.Projection) { + applyProjection(tView, lView, tNode as TProjectionNode); + return tNode; + } + + appendChild(tView, lView, getNativeByTNode(tNode, lView), tNode); + + const slotValue = lView[tNode.index]; + if (tNode.type !== TNodeType.Container && isLContainer(slotValue)) { + // Nodes that inject ViewContainerRef also have a comment node that should be moved + appendChild(tView, lView, slotValue[NATIVE], tNode); + } + return tNode; +} + +/** + * See `i18nEnd` above. + */ +export function i18nEndFirstPass(tView: TView, lView: LView) { + ngDevMode && + assertEqual( + getBindingIndex(), tView.bindingStartIndex, + 'i18nEnd should be called before any binding'); + + const rootIndex = popI18nIndex(); + const tI18n = tView.data[rootIndex + HEADER_OFFSET] as TI18n; + ngDevMode && assertDefined(tI18n, `You should call i18nStart before i18nEnd`); + + // Find the last node that was added before `i18nEnd` + const lastCreatedNode = getPreviousOrParentTNode(); + + // Read the instructions to insert/move/remove DOM elements + const visitedNodes = applyCreateOpCodes(tView, rootIndex, tI18n.create, lView); + + // Remove deleted nodes + let index = rootIndex + 1; + while (index <= lastCreatedNode.index - HEADER_OFFSET) { + if (visitedNodes.indexOf(index) === -1) { + removeNode(tView, lView, index, /* markAsDetached */ true); + } + // Check if an element has any local refs and skip them + const tNode = getTNode(tView, index); + if (tNode && + (tNode.type === TNodeType.Container || tNode.type === TNodeType.Element || + tNode.type === TNodeType.ElementContainer) && + tNode.localNames !== null) { + // Divide by 2 to get the number of local refs, + // since they are stored as an array that also includes directive indexes, + // i.e. ["localRef", directiveIndex, ...] + index += tNode.localNames.length >> 1; + } + index++; + } +} + +function removeNode(tView: TView, lView: LView, index: number, markAsDetached: boolean) { + const removedPhTNode = getTNode(tView, index); + const removedPhRNode = getNativeByIndex(index, lView); + if (removedPhRNode) { + nativeRemoveNode(lView[RENDERER], removedPhRNode); + } + + const slotValue = load(lView, index) as RElement | RComment | LContainer; + if (isLContainer(slotValue)) { + const lContainer = slotValue as LContainer; + if (removedPhTNode.type !== TNodeType.Container) { + nativeRemoveNode(lView[RENDERER], lContainer[NATIVE]); + } + } + + if (markAsDetached) { + // Define this node as detached to avoid projecting it later + removedPhTNode.flags |= TNodeFlags.isDetached; + } + ngDevMode && ngDevMode.rendererRemoveNode++; +} + +/** + * Creates and stores the dynamic TNode, and unhooks it from the tree for now. + */ +function createDynamicNodeAtIndex( + tView: TView, lView: LView, index: number, type: TNodeType, native: RElement|RText|null, + name: string|null): TElementNode|TIcuContainerNode { + const previousOrParentTNode = getPreviousOrParentTNode(); + ngDevMode && assertIndexInRange(lView, index + HEADER_OFFSET); + lView[index + HEADER_OFFSET] = native; + // FIXME(misko): Why does this create A TNode??? I would not expect this to be here. + const tNode = getOrCreateTNode(tView, lView[T_HOST], index, type as any, name, null); + + // We are creating a dynamic node, the previous tNode might not be pointing at this node. + // We will link ourselves into the tree later with `appendI18nNode`. + if (previousOrParentTNode && previousOrParentTNode.next === tNode) { + previousOrParentTNode.next = null; + } + + return tNode; +} + + +/** + * Returns the index of the current case of an ICU expression depending on the main binding value + * + * @param icuExpression + * @param bindingValue The value of the main binding used by this ICU expression + */ +function getCaseIndex(icuExpression: TIcu, bindingValue: string): number { + let index = icuExpression.cases.indexOf(bindingValue); + if (index === -1) { + switch (icuExpression.type) { + case IcuType.plural: { + const resolvedCase = getPluralCase(bindingValue, getLocaleId()); + index = icuExpression.cases.indexOf(resolvedCase); + if (index === -1 && resolvedCase !== 'other') { + index = icuExpression.cases.indexOf('other'); + } + break; + } + case IcuType.select: { + index = icuExpression.cases.indexOf('other'); + break; + } + } + } + return index; +} diff --git a/packages/core/src/render3/i18n_debug.ts b/packages/core/src/render3/i18n/i18n_debug.ts similarity index 98% rename from packages/core/src/render3/i18n_debug.ts rename to packages/core/src/render3/i18n/i18n_debug.ts index 930f810428..9c89b86e66 100644 --- a/packages/core/src/render3/i18n_debug.ts +++ b/packages/core/src/render3/i18n/i18n_debug.ts @@ -6,9 +6,9 @@ * found in the LICENSE file at https://angular.io/license */ -import {assertNumber, assertString} from '../util/assert'; +import {assertNumber, assertString} from '../../util/assert'; -import {COMMENT_MARKER, ELEMENT_MARKER, getInstructionFromI18nMutateOpCode, getParentFromI18nMutateOpCode, getRefFromI18nMutateOpCode, I18nMutateOpCode, I18nMutateOpCodes, I18nUpdateOpCode, I18nUpdateOpCodes} from './interfaces/i18n'; +import {COMMENT_MARKER, ELEMENT_MARKER, getInstructionFromI18nMutateOpCode, getParentFromI18nMutateOpCode, getRefFromI18nMutateOpCode, I18nMutateOpCode, I18nMutateOpCodes, I18nUpdateOpCode, I18nUpdateOpCodes} from '../interfaces/i18n'; /** * Converts `I18nUpdateOpCodes` array into a human readable format. diff --git a/packages/core/src/render3/i18n/i18n_locale_id.ts b/packages/core/src/render3/i18n/i18n_locale_id.ts new file mode 100644 index 0000000000..66d9074854 --- /dev/null +++ b/packages/core/src/render3/i18n/i18n_locale_id.ts @@ -0,0 +1,41 @@ +/** + * @license + * Copyright Google LLC 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 {DEFAULT_LOCALE_ID} from '../../i18n/localization'; +import {assertDefined} from '../../util/assert'; + + +/** + * The locale id that the application is currently using (for translations and ICU expressions). + * This is the ivy version of `LOCALE_ID` that was defined as an injection token for the view engine + * but is now defined as a global value. + */ +let LOCALE_ID = DEFAULT_LOCALE_ID; + +/** + * Sets the locale id that will be used for translations and ICU expressions. + * This is the ivy version of `LOCALE_ID` that was defined as an injection token for the view engine + * but is now defined as a global value. + * + * @param localeId + */ +export function setLocaleId(localeId: string) { + assertDefined(localeId, `Expected localeId to be defined`); + if (typeof localeId === 'string') { + LOCALE_ID = localeId.toLowerCase().replace(/_/g, '-'); + } +} + +/** + * Gets the locale id that will be used for translations and ICU expressions. + * This is the ivy version of `LOCALE_ID` that was defined as an injection token for the view engine + * but is now defined as a global value. + */ +export function getLocaleId(): string { + return LOCALE_ID; +} diff --git a/packages/core/src/render3/i18n/i18n_parse.ts b/packages/core/src/render3/i18n/i18n_parse.ts new file mode 100644 index 0000000000..3794965243 --- /dev/null +++ b/packages/core/src/render3/i18n/i18n_parse.ts @@ -0,0 +1,733 @@ +/** + * @license + * Copyright Google LLC 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 '../../util/ng_dev_mode'; +import '../../util/ng_i18n_closure_mode'; + +import {getTemplateContent, SRCSET_ATTRS, URI_ATTRS, VALID_ATTRS, VALID_ELEMENTS} from '../../sanitization/html_sanitizer'; +import {getInertBodyHelper} from '../../sanitization/inert_body'; +import {_sanitizeUrl, sanitizeSrcset} from '../../sanitization/url_sanitizer'; +import {addAllToArray} from '../../util/array_utils'; +import {assertEqual} from '../../util/assert'; +import {allocExpando, elementAttributeInternal, setInputsForProperty, setNgReflectProperties} from '../instructions/shared'; +import {getDocument} from '../interfaces/document'; +import {COMMENT_MARKER, ELEMENT_MARKER, I18nMutateOpCode, I18nMutateOpCodes, I18nUpdateOpCode, I18nUpdateOpCodes, IcuCase, IcuExpression, IcuType, TI18n, TIcu} from '../interfaces/i18n'; +import {TNodeType} from '../interfaces/node'; +import {RComment, RElement} from '../interfaces/renderer'; +import {SanitizerFn} from '../interfaces/sanitization'; +import {HEADER_OFFSET, LView, T_HOST, TView} from '../interfaces/view'; +import {getIsParent, getPreviousOrParentTNode} from '../state'; +import {attachDebugGetter} from '../util/debug_utils'; +import {getNativeByIndex, getTNode} from '../util/view_utils'; + +import {i18nMutateOpCodesToString, i18nUpdateOpCodesToString} from './i18n_debug'; + + + +const BINDING_REGEXP = /�(\d+):?\d*�/gi; +const ICU_REGEXP = /({\s*�\d+:?\d*�\s*,\s*\S{6}\s*,[\s\S]*})/gi; +const NESTED_ICU = /�(\d+)�/; +const ICU_BLOCK_REGEXP = /^\s*(�\d+:?\d*�)\s*,\s*(select|plural)\s*,/; + + +// Count for the number of vars that will be allocated for each i18n block. +// It is global because this is used in multiple functions that include loops and recursive calls. +// This is reset to 0 when `i18nStartFirstPass` is called. +let i18nVarsCount: number; + +const parentIndexStack: number[] = []; + +const MARKER = `�`; +const SUBTEMPLATE_REGEXP = /�\/?\*(\d+:\d+)�/gi; +const PH_REGEXP = /�(\/?[#*!]\d+):?\d*�/gi; +const enum TagType { + ELEMENT = '#', + TEMPLATE = '*', + PROJECTION = '!', +} + +/** + * Angular Dart introduced &ngsp; as a placeholder for non-removable space, see: + * https://github.com/dart-lang/angular/blob/0bb611387d29d65b5af7f9d2515ab571fd3fbee4/_tests/test/compiler/preserve_whitespace_test.dart#L25-L32 + * In Angular Dart &ngsp; is converted to the 0xE500 PUA (Private Use Areas) unicode character + * and later on replaced by a space. We are re-implementing the same idea here, since translations + * might contain this special character. + */ +const NGSP_UNICODE_REGEXP = /\uE500/g; +function replaceNgsp(value: string): string { + return value.replace(NGSP_UNICODE_REGEXP, ' '); +} + + +/** + * See `i18nStart` above. + */ +export function i18nStartFirstPass( + lView: LView, tView: TView, index: number, message: string, subTemplateIndex?: number) { + const startIndex = tView.blueprint.length - HEADER_OFFSET; + i18nVarsCount = 0; + const previousOrParentTNode = getPreviousOrParentTNode(); + const parentTNode = + getIsParent() ? previousOrParentTNode : previousOrParentTNode && previousOrParentTNode.parent; + let parentIndex = + parentTNode && parentTNode !== lView[T_HOST] ? parentTNode.index - HEADER_OFFSET : index; + let parentIndexPointer = 0; + parentIndexStack[parentIndexPointer] = parentIndex; + const createOpCodes: I18nMutateOpCodes = []; + if (ngDevMode) { + attachDebugGetter(createOpCodes, i18nMutateOpCodesToString); + } + // If the previous node wasn't the direct parent then we have a translation without top level + // element and we need to keep a reference of the previous element if there is one. We should also + // keep track whether an element was a parent node or not, so that the logic that consumes + // the generated `I18nMutateOpCode`s can leverage this information to properly set TNode state + // (whether it's a parent or sibling). + if (index > 0 && previousOrParentTNode !== parentTNode) { + let previousTNodeIndex = previousOrParentTNode.index - HEADER_OFFSET; + // If current TNode is a sibling node, encode it using a negative index. This information is + // required when the `Select` action is processed (see the `readCreateOpCodes` function). + if (!getIsParent()) { + previousTNodeIndex = ~previousTNodeIndex; + } + // Create an OpCode to select the previous TNode + createOpCodes.push(previousTNodeIndex << I18nMutateOpCode.SHIFT_REF | I18nMutateOpCode.Select); + } + const updateOpCodes: I18nUpdateOpCodes = []; + if (ngDevMode) { + attachDebugGetter(updateOpCodes, i18nUpdateOpCodesToString); + } + const icuExpressions: TIcu[] = []; + + if (message === '' && isRootTemplateMessage(subTemplateIndex)) { + // If top level translation is an empty string, do not invoke additional processing + // and just create op codes for empty text node instead. + createOpCodes.push( + message, allocNodeIndex(startIndex), + parentIndex << I18nMutateOpCode.SHIFT_PARENT | I18nMutateOpCode.AppendChild); + } else { + const templateTranslation = getTranslationForTemplate(message, subTemplateIndex); + const msgParts = replaceNgsp(templateTranslation).split(PH_REGEXP); + for (let i = 0; i < msgParts.length; i++) { + let value = msgParts[i]; + if (i & 1) { + // Odd indexes are placeholders (elements and sub-templates) + if (value.charAt(0) === '/') { + // It is a closing tag + if (value.charAt(1) === TagType.ELEMENT) { + const phIndex = parseInt(value.substr(2), 10); + parentIndex = parentIndexStack[--parentIndexPointer]; + createOpCodes.push(phIndex << I18nMutateOpCode.SHIFT_REF | I18nMutateOpCode.ElementEnd); + } + } else { + const phIndex = parseInt(value.substr(1), 10); + const isElement = value.charAt(0) === TagType.ELEMENT; + // The value represents a placeholder that we move to the designated index. + // Note: positive indicies indicate that a TNode with a given index should also be marked + // as parent while executing `Select` instruction. + createOpCodes.push( + (isElement ? phIndex : ~phIndex) << I18nMutateOpCode.SHIFT_REF | + I18nMutateOpCode.Select, + parentIndex << I18nMutateOpCode.SHIFT_PARENT | I18nMutateOpCode.AppendChild); + + if (isElement) { + parentIndexStack[++parentIndexPointer] = parentIndex = phIndex; + } + } + } else { + // Even indexes are text (including bindings & ICU expressions) + const parts = extractParts(value); + for (let j = 0; j < parts.length; j++) { + if (j & 1) { + // Odd indexes are ICU expressions + const icuExpression = parts[j] as IcuExpression; + + // Verify that ICU expression has the right shape. Translations might contain invalid + // constructions (while original messages were correct), so ICU parsing at runtime may + // not succeed (thus `icuExpression` remains a string). + if (typeof icuExpression !== 'object') { + throw new Error( + `Unable to parse ICU expression in "${templateTranslation}" message.`); + } + + // Create the comment node that will anchor the ICU expression + const icuNodeIndex = allocNodeIndex(startIndex); + createOpCodes.push( + COMMENT_MARKER, ngDevMode ? `ICU ${icuNodeIndex}` : '', icuNodeIndex, + parentIndex << I18nMutateOpCode.SHIFT_PARENT | I18nMutateOpCode.AppendChild); + + // Update codes for the ICU expression + const mask = getBindingMask(icuExpression); + icuStart(icuExpressions, icuExpression, icuNodeIndex, icuNodeIndex); + // Since this is recursive, the last TIcu that was pushed is the one we want + const tIcuIndex = icuExpressions.length - 1; + updateOpCodes.push( + toMaskBit(icuExpression.mainBinding), // mask of the main binding + 3, // skip 3 opCodes if not changed + -1 - icuExpression.mainBinding, + icuNodeIndex << I18nUpdateOpCode.SHIFT_REF | I18nUpdateOpCode.IcuSwitch, tIcuIndex, + mask, // mask of all the bindings of this ICU expression + 2, // skip 2 opCodes if not changed + icuNodeIndex << I18nUpdateOpCode.SHIFT_REF | I18nUpdateOpCode.IcuUpdate, tIcuIndex); + } else if (parts[j] !== '') { + const text = parts[j] as string; + // Even indexes are text (including bindings) + const hasBinding = text.match(BINDING_REGEXP); + // Create text nodes + const textNodeIndex = allocNodeIndex(startIndex); + createOpCodes.push( + // If there is a binding, the value will be set during update + hasBinding ? '' : text, textNodeIndex, + parentIndex << I18nMutateOpCode.SHIFT_PARENT | I18nMutateOpCode.AppendChild); + + if (hasBinding) { + addAllToArray(generateBindingUpdateOpCodes(text, textNodeIndex), updateOpCodes); + } + } + } + } + } + } + + if (i18nVarsCount > 0) { + allocExpando(tView, lView, i18nVarsCount); + } + + // NOTE: local var needed to properly assert the type of `TI18n`. + const tI18n: TI18n = { + vars: i18nVarsCount, + create: createOpCodes, + update: updateOpCodes, + icus: icuExpressions.length ? icuExpressions : null, + }; + + tView.data[index + HEADER_OFFSET] = tI18n; +} + +/** + * See `i18nAttributes` above. + */ +export function i18nAttributesFirstPass( + lView: LView, tView: TView, index: number, values: string[]) { + const previousElement = getPreviousOrParentTNode(); + const previousElementIndex = previousElement.index - HEADER_OFFSET; + const updateOpCodes: I18nUpdateOpCodes = []; + if (ngDevMode) { + attachDebugGetter(updateOpCodes, i18nUpdateOpCodesToString); + } + for (let i = 0; i < values.length; i += 2) { + const attrName = values[i]; + const message = values[i + 1]; + const parts = message.split(ICU_REGEXP); + for (let j = 0; j < parts.length; j++) { + const value = parts[j]; + + if (j & 1) { + // Odd indexes are ICU expressions + // TODO(ocombe): support ICU expressions in attributes + throw new Error('ICU expressions are not yet supported in attributes'); + } else if (value !== '') { + // Even indexes are text (including bindings) + const hasBinding = !!value.match(BINDING_REGEXP); + if (hasBinding) { + if (tView.firstCreatePass && tView.data[index + HEADER_OFFSET] === null) { + addAllToArray( + generateBindingUpdateOpCodes(value, previousElementIndex, attrName), updateOpCodes); + } + } else { + const tNode = getTNode(tView, previousElementIndex); + // Set attributes for Elements only, for other types (like ElementContainer), + // only set inputs below + if (tNode.type === TNodeType.Element) { + elementAttributeInternal(tNode, lView, attrName, value, null, null); + } + // Check if that attribute is a directive input + const dataValue = tNode.inputs !== null && tNode.inputs[attrName]; + if (dataValue) { + setInputsForProperty(tView, lView, dataValue, attrName, value); + if (ngDevMode) { + const element = getNativeByIndex(previousElementIndex, lView) as RElement | RComment; + setNgReflectProperties(lView, element, tNode.type, dataValue, value); + } + } + } + } + } + } + + if (tView.firstCreatePass && tView.data[index + HEADER_OFFSET] === null) { + tView.data[index + HEADER_OFFSET] = updateOpCodes; + } +} + + +/** + * Generate the OpCodes to update the bindings of a string. + * + * @param str The string containing the bindings. + * @param destinationNode Index of the destination node which will receive the binding. + * @param attrName Name of the attribute, if the string belongs to an attribute. + * @param sanitizeFn Sanitization function used to sanitize the string after update, if necessary. + */ +export function generateBindingUpdateOpCodes( + str: string, destinationNode: number, attrName?: string, + sanitizeFn: SanitizerFn|null = null): I18nUpdateOpCodes { + const updateOpCodes: I18nUpdateOpCodes = [null, null]; // Alloc space for mask and size + if (ngDevMode) { + attachDebugGetter(updateOpCodes, i18nUpdateOpCodesToString); + } + const textParts = str.split(BINDING_REGEXP); + let mask = 0; + + for (let j = 0; j < textParts.length; j++) { + const textValue = textParts[j]; + + if (j & 1) { + // Odd indexes are bindings + const bindingIndex = parseInt(textValue, 10); + updateOpCodes.push(-1 - bindingIndex); + mask = mask | toMaskBit(bindingIndex); + } else if (textValue !== '') { + // Even indexes are text + updateOpCodes.push(textValue); + } + } + + updateOpCodes.push( + destinationNode << I18nUpdateOpCode.SHIFT_REF | + (attrName ? I18nUpdateOpCode.Attr : I18nUpdateOpCode.Text)); + if (attrName) { + updateOpCodes.push(attrName, sanitizeFn); + } + updateOpCodes[0] = mask; + updateOpCodes[1] = updateOpCodes.length - 2; + return updateOpCodes; +} + +function getBindingMask(icuExpression: IcuExpression, mask = 0): number { + mask = mask | toMaskBit(icuExpression.mainBinding); + let match; + for (let i = 0; i < icuExpression.values.length; i++) { + const valueArr = icuExpression.values[i]; + for (let j = 0; j < valueArr.length; j++) { + const value = valueArr[j]; + if (typeof value === 'string') { + while (match = BINDING_REGEXP.exec(value)) { + mask = mask | toMaskBit(parseInt(match[1], 10)); + } + } else { + mask = getBindingMask(value as IcuExpression, mask); + } + } + } + return mask; +} + +function allocNodeIndex(startIndex: number): number { + return startIndex + i18nVarsCount++; +} + + +/** + * Convert binding index to mask bit. + * + * Each index represents a single bit on the bit-mask. Because bit-mask only has 32 bits, we make + * the 32nd bit share all masks for all bindings higher than 32. Since it is extremely rare to have + * more than 32 bindings this will be hit very rarely. The downside of hitting this corner case is + * that we will execute binding code more often than necessary. (penalty of performance) + */ +function toMaskBit(bindingIndex: number): number { + return 1 << Math.min(bindingIndex, 31); +} + +export function isRootTemplateMessage(subTemplateIndex: number| + undefined): subTemplateIndex is undefined { + return subTemplateIndex === undefined; +} + + +/** + * Removes everything inside the sub-templates of a message. + */ +function removeInnerTemplateTranslation(message: string): string { + let match; + let res = ''; + let index = 0; + let inTemplate = false; + let tagMatched; + + while ((match = SUBTEMPLATE_REGEXP.exec(message)) !== null) { + if (!inTemplate) { + res += message.substring(index, match.index + match[0].length); + tagMatched = match[1]; + inTemplate = true; + } else { + if (match[0] === `${MARKER}/*${tagMatched}${MARKER}`) { + index = match.index; + inTemplate = false; + } + } + } + + ngDevMode && + assertEqual( + inTemplate, false, + `Tag mismatch: unable to find the end of the sub-template in the translation "${ + message}"`); + + res += message.substr(index); + return res; +} + + +/** + * Extracts a part of a message and removes the rest. + * + * This method is used for extracting a part of the message associated with a template. A translated + * message can span multiple templates. + * + * Example: + * ``` + *
Translate me!
+ * ``` + * + * @param message The message to crop + * @param subTemplateIndex Index of the sub-template to extract. If undefined it returns the + * external template and removes all sub-templates. + */ +export function getTranslationForTemplate(message: string, subTemplateIndex?: number) { + if (isRootTemplateMessage(subTemplateIndex)) { + // We want the root template message, ignore all sub-templates + return removeInnerTemplateTranslation(message); + } else { + // We want a specific sub-template + const start = + message.indexOf(`:${subTemplateIndex}${MARKER}`) + 2 + subTemplateIndex.toString().length; + const end = message.search(new RegExp(`${MARKER}\\/\\*\\d+:${subTemplateIndex}${MARKER}`)); + return removeInnerTemplateTranslation(message.substring(start, end)); + } +} + +/** + * Generate the OpCodes for ICU expressions. + * + * @param tIcus + * @param icuExpression + * @param startIndex + * @param expandoStartIndex + */ +export function icuStart( + tIcus: TIcu[], icuExpression: IcuExpression, startIndex: number, + expandoStartIndex: number): void { + const createCodes: I18nMutateOpCodes[] = []; + const removeCodes: I18nMutateOpCodes[] = []; + const updateCodes: I18nUpdateOpCodes[] = []; + const vars = []; + const childIcus: number[][] = []; + const values = icuExpression.values; + for (let i = 0; i < values.length; i++) { + // Each value is an array of strings & other ICU expressions + const valueArr = values[i]; + const nestedIcus: IcuExpression[] = []; + for (let j = 0; j < valueArr.length; j++) { + const value = valueArr[j]; + if (typeof value !== 'string') { + // It is an nested ICU expression + const icuIndex = nestedIcus.push(value as IcuExpression) - 1; + // Replace nested ICU expression by a comment node + valueArr[j] = ``; + } + } + const icuCase: IcuCase = + parseIcuCase(valueArr.join(''), startIndex, nestedIcus, tIcus, expandoStartIndex); + createCodes.push(icuCase.create); + removeCodes.push(icuCase.remove); + updateCodes.push(icuCase.update); + vars.push(icuCase.vars); + childIcus.push(icuCase.childIcus); + } + const tIcu: TIcu = { + type: icuExpression.type, + vars, + currentCaseLViewIndex: HEADER_OFFSET + + expandoStartIndex // expandoStartIndex does not include the header so add it. + + 1, // The first item stored is the `` anchor so skip it. + childIcus, + cases: icuExpression.cases, + create: createCodes, + remove: removeCodes, + update: updateCodes + }; + tIcus.push(tIcu); + // Adding the maximum possible of vars needed (based on the cases with the most vars) + i18nVarsCount += Math.max(...vars); +} + +/** + * Parses text containing an ICU expression and produces a JSON object for it. + * Original code from closure library, modified for Angular. + * + * @param pattern Text containing an ICU expression that needs to be parsed. + * + */ +export function parseICUBlock(pattern: string): IcuExpression { + const cases = []; + const values: (string|IcuExpression)[][] = []; + let icuType = IcuType.plural; + let mainBinding = 0; + pattern = pattern.replace(ICU_BLOCK_REGEXP, function(str: string, binding: string, type: string) { + if (type === 'select') { + icuType = IcuType.select; + } else { + icuType = IcuType.plural; + } + mainBinding = parseInt(binding.substr(1), 10); + return ''; + }); + + const parts = extractParts(pattern) as string[]; + // Looking for (key block)+ sequence. One of the keys has to be "other". + for (let pos = 0; pos < parts.length;) { + let key = parts[pos++].trim(); + if (icuType === IcuType.plural) { + // Key can be "=x", we just want "x" + key = key.replace(/\s*(?:=)?(\w+)\s*/, '$1'); + } + if (key.length) { + cases.push(key); + } + + const blocks = extractParts(parts[pos++]) as string[]; + if (cases.length > values.length) { + values.push(blocks); + } + } + + // TODO(ocombe): support ICU expressions in attributes, see #21615 + return {type: icuType, mainBinding: mainBinding, cases, values}; +} + + +/** + * Transforms a string template into an HTML template and a list of instructions used to update + * attributes or nodes that contain bindings. + * + * @param unsafeHtml The string to parse + * @param parentIndex + * @param nestedIcus + * @param tIcus + * @param expandoStartIndex + */ +function parseIcuCase( + unsafeHtml: string, parentIndex: number, nestedIcus: IcuExpression[], tIcus: TIcu[], + expandoStartIndex: number): IcuCase { + const inertBodyHelper = getInertBodyHelper(getDocument()); + const inertBodyElement = inertBodyHelper.getInertBodyElement(unsafeHtml); + if (!inertBodyElement) { + throw new Error('Unable to generate inert body element'); + } + const wrapper = getTemplateContent(inertBodyElement!) as Element || inertBodyElement; + const opCodes: IcuCase = { + vars: 1, // allocate space for `TIcu.currentCaseLViewIndex` + childIcus: [], + create: [], + remove: [], + update: [] + }; + if (ngDevMode) { + attachDebugGetter(opCodes.create, i18nMutateOpCodesToString); + attachDebugGetter(opCodes.remove, i18nMutateOpCodesToString); + attachDebugGetter(opCodes.update, i18nUpdateOpCodesToString); + } + parseNodes(wrapper.firstChild, opCodes, parentIndex, nestedIcus, tIcus, expandoStartIndex); + return opCodes; +} + +/** + * Breaks pattern into strings and top level {...} blocks. + * Can be used to break a message into text and ICU expressions, or to break an ICU expression into + * keys and cases. + * Original code from closure library, modified for Angular. + * + * @param pattern (sub)Pattern to be broken. + * + */ +function extractParts(pattern: string): (string|IcuExpression)[] { + if (!pattern) { + return []; + } + + let prevPos = 0; + const braceStack = []; + const results: (string|IcuExpression)[] = []; + const braces = /[{}]/g; + // lastIndex doesn't get set to 0 so we have to. + braces.lastIndex = 0; + + let match; + while (match = braces.exec(pattern)) { + const pos = match.index; + if (match[0] == '}') { + braceStack.pop(); + + if (braceStack.length == 0) { + // End of the block. + const block = pattern.substring(prevPos, pos); + if (ICU_BLOCK_REGEXP.test(block)) { + results.push(parseICUBlock(block)); + } else { + results.push(block); + } + + prevPos = pos + 1; + } + } else { + if (braceStack.length == 0) { + const substring = pattern.substring(prevPos, pos); + results.push(substring); + prevPos = pos + 1; + } + braceStack.push('{'); + } + } + + const substring = pattern.substring(prevPos); + results.push(substring); + return results; +} + + +/** + * Parses a node, its children and its siblings, and generates the mutate & update OpCodes. + * + * @param currentNode The first node to parse + * @param icuCase The data for the ICU expression case that contains those nodes + * @param parentIndex Index of the current node's parent + * @param nestedIcus Data for the nested ICU expressions that this case contains + * @param tIcus Data for all ICU expressions of the current message + * @param expandoStartIndex Expando start index for the current ICU expression + */ +export function parseNodes( + currentNode: Node|null, icuCase: IcuCase, parentIndex: number, nestedIcus: IcuExpression[], + tIcus: TIcu[], expandoStartIndex: number) { + if (currentNode) { + const nestedIcusToCreate: [IcuExpression, number][] = []; + while (currentNode) { + const nextNode: Node|null = currentNode.nextSibling; + const newIndex = expandoStartIndex + ++icuCase.vars; + switch (currentNode.nodeType) { + case Node.ELEMENT_NODE: + const element = currentNode as Element; + const tagName = element.tagName.toLowerCase(); + if (!VALID_ELEMENTS.hasOwnProperty(tagName)) { + // This isn't a valid element, we won't create an element for it + icuCase.vars--; + } else { + icuCase.create.push( + ELEMENT_MARKER, tagName, newIndex, + parentIndex << I18nMutateOpCode.SHIFT_PARENT | I18nMutateOpCode.AppendChild); + const elAttrs = element.attributes; + for (let i = 0; i < elAttrs.length; i++) { + const attr = elAttrs.item(i)!; + const lowerAttrName = attr.name.toLowerCase(); + const hasBinding = !!attr.value.match(BINDING_REGEXP); + // we assume the input string is safe, unless it's using a binding + if (hasBinding) { + if (VALID_ATTRS.hasOwnProperty(lowerAttrName)) { + if (URI_ATTRS[lowerAttrName]) { + addAllToArray( + generateBindingUpdateOpCodes(attr.value, newIndex, attr.name, _sanitizeUrl), + icuCase.update); + } else if (SRCSET_ATTRS[lowerAttrName]) { + addAllToArray( + generateBindingUpdateOpCodes( + attr.value, newIndex, attr.name, sanitizeSrcset), + icuCase.update); + } else { + addAllToArray( + generateBindingUpdateOpCodes(attr.value, newIndex, attr.name), + icuCase.update); + } + } else { + ngDevMode && + console.warn(`WARNING: ignoring unsafe attribute value ${ + lowerAttrName} on element ${tagName} (see http://g.co/ng/security#xss)`); + } + } else { + icuCase.create.push( + newIndex << I18nMutateOpCode.SHIFT_REF | I18nMutateOpCode.Attr, attr.name, + attr.value); + } + } + // Parse the children of this node (if any) + parseNodes( + currentNode.firstChild, icuCase, newIndex, nestedIcus, tIcus, expandoStartIndex); + // Remove the parent node after the children + icuCase.remove.push(newIndex << I18nMutateOpCode.SHIFT_REF | I18nMutateOpCode.Remove); + } + break; + case Node.TEXT_NODE: + const value = currentNode.textContent || ''; + const hasBinding = value.match(BINDING_REGEXP); + icuCase.create.push( + hasBinding ? '' : value, newIndex, + parentIndex << I18nMutateOpCode.SHIFT_PARENT | I18nMutateOpCode.AppendChild); + icuCase.remove.push(newIndex << I18nMutateOpCode.SHIFT_REF | I18nMutateOpCode.Remove); + if (hasBinding) { + addAllToArray(generateBindingUpdateOpCodes(value, newIndex), icuCase.update); + } + break; + case Node.COMMENT_NODE: + // Check if the comment node is a placeholder for a nested ICU + const match = NESTED_ICU.exec(currentNode.textContent || ''); + if (match) { + const nestedIcuIndex = parseInt(match[1], 10); + const newLocal = ngDevMode ? `nested ICU ${nestedIcuIndex}` : ''; + // Create the comment node that will anchor the ICU expression + icuCase.create.push( + COMMENT_MARKER, newLocal, newIndex, + parentIndex << I18nMutateOpCode.SHIFT_PARENT | I18nMutateOpCode.AppendChild); + const nestedIcu = nestedIcus[nestedIcuIndex]; + nestedIcusToCreate.push([nestedIcu, newIndex]); + } else { + // We do not handle any other type of comment + icuCase.vars--; + } + break; + default: + // We do not handle any other type of element + icuCase.vars--; + } + currentNode = nextNode!; + } + + for (let i = 0; i < nestedIcusToCreate.length; i++) { + const nestedIcu = nestedIcusToCreate[i][0]; + const nestedIcuNodeIndex = nestedIcusToCreate[i][1]; + icuStart(tIcus, nestedIcu, nestedIcuNodeIndex, expandoStartIndex + icuCase.vars); + // Since this is recursive, the last TIcu that was pushed is the one we want + const nestTIcuIndex = tIcus.length - 1; + icuCase.vars += Math.max(...tIcus[nestTIcuIndex].vars); + icuCase.childIcus.push(nestTIcuIndex); + const mask = getBindingMask(nestedIcu); + icuCase.update.push( + toMaskBit(nestedIcu.mainBinding), // mask of the main binding + 3, // skip 3 opCodes if not changed + -1 - nestedIcu.mainBinding, + nestedIcuNodeIndex << I18nUpdateOpCode.SHIFT_REF | I18nUpdateOpCode.IcuSwitch, + // FIXME(misko): Index should be part of the opcode + nestTIcuIndex, + mask, // mask of all the bindings of this ICU expression + 2, // skip 2 opCodes if not changed + nestedIcuNodeIndex << I18nUpdateOpCode.SHIFT_REF | I18nUpdateOpCode.IcuUpdate, + nestTIcuIndex); + icuCase.remove.push( + nestTIcuIndex << I18nMutateOpCode.SHIFT_REF | I18nMutateOpCode.RemoveNestedIcu, + // FIXME(misko): Index should be part of the opcode + nestedIcuNodeIndex << I18nMutateOpCode.SHIFT_REF | I18nMutateOpCode.Remove); + } + } +} diff --git a/packages/core/src/render3/i18n/i18n_postprocess.ts b/packages/core/src/render3/i18n/i18n_postprocess.ts new file mode 100644 index 0000000000..10ecf87f09 --- /dev/null +++ b/packages/core/src/render3/i18n/i18n_postprocess.ts @@ -0,0 +1,134 @@ +/** + * @license + * Copyright Google LLC 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 + */ + +// i18nPostprocess consts +const ROOT_TEMPLATE_ID = 0; +const PP_MULTI_VALUE_PLACEHOLDERS_REGEXP = /\[(�.+?�?)\]/; +const PP_PLACEHOLDERS_REGEXP = /\[(�.+?�?)\]|(�\/?\*\d+:\d+�)/g; +const PP_ICU_VARS_REGEXP = /({\s*)(VAR_(PLURAL|SELECT)(_\d+)?)(\s*,)/g; +const PP_ICU_PLACEHOLDERS_REGEXP = /{([A-Z0-9_]+)}/g; +const PP_ICUS_REGEXP = /�I18N_EXP_(ICU(_\d+)?)�/g; +const PP_CLOSE_TEMPLATE_REGEXP = /\/\*/; +const PP_TEMPLATE_ID_REGEXP = /\d+\:(\d+)/; + +// Parsed placeholder structure used in postprocessing (within `i18nPostprocess` function) +// Contains the following fields: [templateId, isCloseTemplateTag, placeholder] +type PostprocessPlaceholder = [number, boolean, string]; + + +/** + * Handles message string post-processing for internationalization. + * + * Handles message string post-processing by transforming it from intermediate + * format (that might contain some markers that we need to replace) to the final + * form, consumable by i18nStart instruction. Post processing steps include: + * + * 1. Resolve all multi-value cases (like [�*1:1��#2:1�|�#4:1�|�5�]) + * 2. Replace all ICU vars (like "VAR_PLURAL") + * 3. Replace all placeholders used inside ICUs in a form of {PLACEHOLDER} + * 4. Replace all ICU references with corresponding values (like �ICU_EXP_ICU_1�) + * in case multiple ICUs have the same placeholder name + * + * @param message Raw translation string for post processing + * @param replacements Set of replacements that should be applied + * + * @returns Transformed string that can be consumed by i18nStart instruction + * + * @codeGenApi + */ +export function i18nPostprocess( + message: string, replacements: {[key: string]: (string|string[])} = {}): string { + /** + * Step 1: resolve all multi-value placeholders like [�#5�|�*1:1��#2:1�|�#4:1�] + * + * Note: due to the way we process nested templates (BFS), multi-value placeholders are typically + * grouped by templates, for example: [�#5�|�#6�|�#1:1�|�#3:2�] where �#5� and �#6� belong to root + * template, �#1:1� belong to nested template with index 1 and �#1:2� - nested template with index + * 3. However in real templates the order might be different: i.e. �#1:1� and/or �#3:2� may go in + * front of �#6�. The post processing step restores the right order by keeping track of the + * template id stack and looks for placeholders that belong to the currently active template. + */ + let result: string = message; + if (PP_MULTI_VALUE_PLACEHOLDERS_REGEXP.test(message)) { + const matches: {[key: string]: PostprocessPlaceholder[]} = {}; + const templateIdsStack: number[] = [ROOT_TEMPLATE_ID]; + result = result.replace(PP_PLACEHOLDERS_REGEXP, (m: any, phs: string, tmpl: string): string => { + const content = phs || tmpl; + const placeholders: PostprocessPlaceholder[] = matches[content] || []; + if (!placeholders.length) { + content.split('|').forEach((placeholder: string) => { + const match = placeholder.match(PP_TEMPLATE_ID_REGEXP); + const templateId = match ? parseInt(match[1], 10) : ROOT_TEMPLATE_ID; + const isCloseTemplateTag = PP_CLOSE_TEMPLATE_REGEXP.test(placeholder); + placeholders.push([templateId, isCloseTemplateTag, placeholder]); + }); + matches[content] = placeholders; + } + + if (!placeholders.length) { + throw new Error(`i18n postprocess: unmatched placeholder - ${content}`); + } + + const currentTemplateId = templateIdsStack[templateIdsStack.length - 1]; + let idx = 0; + // find placeholder index that matches current template id + for (let i = 0; i < placeholders.length; i++) { + if (placeholders[i][0] === currentTemplateId) { + idx = i; + break; + } + } + // update template id stack based on the current tag extracted + const [templateId, isCloseTemplateTag, placeholder] = placeholders[idx]; + if (isCloseTemplateTag) { + templateIdsStack.pop(); + } else if (currentTemplateId !== templateId) { + templateIdsStack.push(templateId); + } + // remove processed tag from the list + placeholders.splice(idx, 1); + return placeholder; + }); + } + + // return current result if no replacements specified + if (!Object.keys(replacements).length) { + return result; + } + + /** + * Step 2: replace all ICU vars (like "VAR_PLURAL") + */ + result = result.replace(PP_ICU_VARS_REGEXP, (match, start, key, _type, _idx, end): string => { + return replacements.hasOwnProperty(key) ? `${start}${replacements[key]}${end}` : match; + }); + + /** + * Step 3: replace all placeholders used inside ICUs in a form of {PLACEHOLDER} + */ + result = result.replace(PP_ICU_PLACEHOLDERS_REGEXP, (match, key): string => { + return replacements.hasOwnProperty(key) ? replacements[key] as string : match; + }); + + /** + * Step 4: replace all ICU references with corresponding values (like �ICU_EXP_ICU_1�) in case + * multiple ICUs have the same placeholder name + */ + result = result.replace(PP_ICUS_REGEXP, (match, key): string => { + if (replacements.hasOwnProperty(key)) { + const list = replacements[key] as string[]; + if (!list.length) { + throw new Error(`i18n postprocess: unmatched ICU - ${match} with key: ${key}`); + } + return list.shift()!; + } + return match; + }); + + return result; +} diff --git a/packages/core/src/render3/index.ts b/packages/core/src/render3/index.ts index 85764cece7..4374446383 100644 --- a/packages/core/src/render3/index.ts +++ b/packages/core/src/render3/index.ts @@ -16,7 +16,7 @@ import {getComponent, getDirectives, getHostElement, getRenderedText} from './ut export {ComponentFactory, ComponentFactoryResolver, ComponentRef, injectComponentFactoryResolver} from './component_ref'; export {ɵɵgetFactoryOf, ɵɵgetInheritedFactory} from './di'; -export {getLocaleId, setLocaleId, ɵɵi18n, ɵɵi18nApply, ɵɵi18nAttributes, ɵɵi18nEnd, ɵɵi18nExp, ɵɵi18nPostprocess, ɵɵi18nStart,} from './i18n'; +export {getLocaleId, setLocaleId} from './i18n/i18n_locale_id'; // clang-format off export { detectChanges, @@ -129,6 +129,7 @@ export { ɵɵtextInterpolate8, ɵɵtextInterpolateV, } from './instructions/all'; +export {ɵɵi18n, ɵɵi18nApply, ɵɵi18nAttributes, ɵɵi18nEnd, ɵɵi18nExp,ɵɵi18nPostprocess, ɵɵi18nStart} from './instructions/i18n'; export {RenderFlags} from './interfaces/definition'; export { AttributeMarker diff --git a/packages/core/src/render3/instructions/i18n.ts b/packages/core/src/render3/instructions/i18n.ts new file mode 100644 index 0000000000..bcc865e8d2 --- /dev/null +++ b/packages/core/src/render3/instructions/i18n.ts @@ -0,0 +1,176 @@ +/** + * @license + * Copyright Google LLC 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 '../../util/ng_dev_mode'; +import '../../util/ng_i18n_closure_mode'; + +import {assertDefined} from '../../util/assert'; +import {bindingUpdated} from '../bindings'; +import {applyI18n, i18nEndFirstPass, pushI18nIndex, setMaskBit} from '../i18n/i18n_apply'; +import {i18nAttributesFirstPass, i18nStartFirstPass} from '../i18n/i18n_parse'; +import {i18nPostprocess} from '../i18n/i18n_postprocess'; +import {HEADER_OFFSET} from '../interfaces/view'; +import {getLView, getTView, nextBindingIndex} from '../state'; + +import {setDelayProjection} from './all'; + +/** + * Marks a block of text as translatable. + * + * The instructions `i18nStart` and `i18nEnd` mark the translation block in the template. + * The translation `message` is the value which is locale specific. The translation string may + * contain placeholders which associate inner elements and sub-templates within the translation. + * + * The translation `message` placeholders are: + * - `�{index}(:{block})�`: *Binding Placeholder*: Marks a location where an expression will be + * interpolated into. The placeholder `index` points to the expression binding index. An optional + * `block` that matches the sub-template in which it was declared. + * - `�#{index}(:{block})�`/`�/#{index}(:{block})�`: *Element Placeholder*: Marks the beginning + * and end of DOM element that were embedded in the original translation block. The placeholder + * `index` points to the element index in the template instructions set. An optional `block` that + * matches the sub-template in which it was declared. + * - `�!{index}(:{block})�`/`�/!{index}(:{block})�`: *Projection Placeholder*: Marks the + * beginning and end of that was embedded in the original translation block. + * The placeholder `index` points to the element index in the template instructions set. + * An optional `block` that matches the sub-template in which it was declared. + * - `�*{index}:{block}�`/`�/*{index}:{block}�`: *Sub-template Placeholder*: Sub-templates must be + * split up and translated separately in each angular template function. The `index` points to the + * `template` instruction index. A `block` that matches the sub-template in which it was declared. + * + * @param index A unique index of the translation in the static block. + * @param message The translation message. + * @param subTemplateIndex Optional sub-template index in the `message`. + * + * @codeGenApi + */ +export function ɵɵi18nStart(index: number, message: string, subTemplateIndex?: number): void { + const tView = getTView(); + ngDevMode && assertDefined(tView, `tView should be defined`); + pushI18nIndex(index); + // We need to delay projections until `i18nEnd` + setDelayProjection(true); + if (tView.firstCreatePass && tView.data[index + HEADER_OFFSET] === null) { + i18nStartFirstPass(getLView(), tView, index, message, subTemplateIndex); + } +} + + + +/** + * Translates a translation block marked by `i18nStart` and `i18nEnd`. It inserts the text/ICU nodes + * into the render tree, moves the placeholder nodes and removes the deleted nodes. + * + * @codeGenApi + */ +export function ɵɵi18nEnd(): void { + const lView = getLView(); + const tView = getTView(); + ngDevMode && assertDefined(tView, `tView should be defined`); + i18nEndFirstPass(tView, lView); + // Stop delaying projections + setDelayProjection(false); +} + +/** + * + * Use this instruction to create a translation block that doesn't contain any placeholder. + * It calls both {@link i18nStart} and {@link i18nEnd} in one instruction. + * + * The translation `message` is the value which is locale specific. The translation string may + * contain placeholders which associate inner elements and sub-templates within the translation. + * + * The translation `message` placeholders are: + * - `�{index}(:{block})�`: *Binding Placeholder*: Marks a location where an expression will be + * interpolated into. The placeholder `index` points to the expression binding index. An optional + * `block` that matches the sub-template in which it was declared. + * - `�#{index}(:{block})�`/`�/#{index}(:{block})�`: *Element Placeholder*: Marks the beginning + * and end of DOM element that were embedded in the original translation block. The placeholder + * `index` points to the element index in the template instructions set. An optional `block` that + * matches the sub-template in which it was declared. + * - `�*{index}:{block}�`/`�/*{index}:{block}�`: *Sub-template Placeholder*: Sub-templates must be + * split up and translated separately in each angular template function. The `index` points to the + * `template` instruction index. A `block` that matches the sub-template in which it was declared. + * + * @param index A unique index of the translation in the static block. + * @param message The translation message. + * @param subTemplateIndex Optional sub-template index in the `message`. + * + * @codeGenApi + */ +export function ɵɵi18n(index: number, message: string, subTemplateIndex?: number): void { + ɵɵi18nStart(index, message, subTemplateIndex); + ɵɵi18nEnd(); +} + +/** + * Marks a list of attributes as translatable. + * + * @param index A unique index in the static block + * @param values + * + * @codeGenApi + */ +export function ɵɵi18nAttributes(index: number, values: string[]): void { + const lView = getLView(); + const tView = getTView(); + ngDevMode && assertDefined(tView, `tView should be defined`); + i18nAttributesFirstPass(lView, tView, index, values); +} + + +/** + * Stores the values of the bindings during each update cycle in order to determine if we need to + * update the translated nodes. + * + * @param value The binding's value + * @returns This function returns itself so that it may be chained + * (e.g. `i18nExp(ctx.name)(ctx.title)`) + * + * @codeGenApi + */ +export function ɵɵi18nExp(value: T): typeof ɵɵi18nExp { + const lView = getLView(); + setMaskBit(bindingUpdated(lView, nextBindingIndex(), value)); + return ɵɵi18nExp; +} + +/** + * Updates a translation block or an i18n attribute when the bindings have changed. + * + * @param index Index of either {@link i18nStart} (translation block) or {@link i18nAttributes} + * (i18n attribute) on which it should update the content. + * + * @codeGenApi + */ +export function ɵɵi18nApply(index: number) { + applyI18n(getTView(), getLView(), index); +} + +/** + * Handles message string post-processing for internationalization. + * + * Handles message string post-processing by transforming it from intermediate + * format (that might contain some markers that we need to replace) to the final + * form, consumable by i18nStart instruction. Post processing steps include: + * + * 1. Resolve all multi-value cases (like [�*1:1��#2:1�|�#4:1�|�5�]) + * 2. Replace all ICU vars (like "VAR_PLURAL") + * 3. Replace all placeholders used inside ICUs in a form of {PLACEHOLDER} + * 4. Replace all ICU references with corresponding values (like �ICU_EXP_ICU_1�) + * in case multiple ICUs have the same placeholder name + * + * @param message Raw translation string for post processing + * @param replacements Set of replacements that should be applied + * + * @returns Transformed string that can be consumed by i18nStart instruction + * + * @codeGenApi + */ +export function ɵɵi18nPostprocess( + message: string, replacements: {[key: string]: (string|string[])} = {}): string { + return i18nPostprocess(message, replacements); +} \ No newline at end of file diff --git a/packages/core/src/render3/interfaces/i18n.ts b/packages/core/src/render3/interfaces/i18n.ts index 7b85b495f5..7270e0ece9 100644 --- a/packages/core/src/render3/interfaces/i18n.ts +++ b/packages/core/src/render3/interfaces/i18n.ts @@ -411,3 +411,41 @@ export interface TIcu { // Note: This hack is necessary so we don't erroneously get a circular dependency // failure based on types. export const unusedValueExportToPlacateAjd = 1; + +export interface IcuExpression { + type: IcuType; + mainBinding: number; + cases: string[]; + values: (string|IcuExpression)[][]; +} + +export interface IcuCase { + /** + * Number of slots to allocate in expando for this case. + * + * This is the max number of DOM elements which will be created by this i18n + ICU blocks. When + * the DOM elements are being created they are stored in the EXPANDO, so that update OpCodes can + * write into them. + */ + vars: number; + + /** + * An optional array of child/sub ICUs. + */ + childIcus: number[]; + + /** + * A set of OpCodes to apply in order to build up the DOM render tree for the ICU + */ + create: I18nMutateOpCodes; + + /** + * A set of OpCodes to apply in order to destroy the DOM render tree for the ICU. + */ + remove: I18nMutateOpCodes; + + /** + * A set of OpCodes to apply in order to update the DOM render tree for the ICU bindings. + */ + update: I18nUpdateOpCodes; +} diff --git a/packages/core/src/render3/ng_module_ref.ts b/packages/core/src/render3/ng_module_ref.ts index 29d19e2b62..4cb7227c6f 100644 --- a/packages/core/src/render3/ng_module_ref.ts +++ b/packages/core/src/render3/ng_module_ref.ts @@ -20,7 +20,7 @@ import {stringify} from '../util/stringify'; import {ComponentFactoryResolver} from './component_ref'; import {getNgLocaleIdDef, getNgModuleDef} from './definition'; -import {setLocaleId} from './i18n'; +import {setLocaleId} from './i18n/i18n_locale_id'; import {maybeUnwrapFn} from './util/misc_utils'; export interface NgModuleType extends Type { diff --git a/packages/core/test/render3/i18n_debug_spec.ts b/packages/core/test/render3/i18n_debug_spec.ts index 50c1abd43d..68fceaf27b 100644 --- a/packages/core/test/render3/i18n_debug_spec.ts +++ b/packages/core/test/render3/i18n_debug_spec.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {i18nMutateOpCodesToString, i18nUpdateOpCodesToString} from '@angular/core/src/render3/i18n_debug'; +import {i18nMutateOpCodesToString, i18nUpdateOpCodesToString} from '@angular/core/src/render3/i18n/i18n_debug'; import {COMMENT_MARKER, ELEMENT_MARKER, I18nMutateOpCode, I18nUpdateOpCode} from '@angular/core/src/render3/interfaces/i18n'; describe('i18n debug', () => { diff --git a/packages/core/test/render3/i18n_spec.ts b/packages/core/test/render3/i18n_spec.ts index 20f2df3272..249a18ffc7 100644 --- a/packages/core/test/render3/i18n_spec.ts +++ b/packages/core/test/render3/i18n_spec.ts @@ -6,12 +6,15 @@ * found in the LICENSE file at https://angular.io/license */ +import {ɵɵi18nAttributes, ɵɵi18nPostprocess, ɵɵi18nStart} from '@angular/core'; +import {getTranslationForTemplate} from '@angular/core/src/render3/i18n/i18n_parse'; + import {noop} from '../../../compiler/src/render3/view/util'; -import {getTranslationForTemplate, ɵɵi18nAttributes, ɵɵi18nPostprocess, ɵɵi18nStart} from '../../src/render3/i18n'; import {setDelayProjection, ɵɵelementEnd, ɵɵelementStart} from '../../src/render3/instructions/all'; import {I18nUpdateOpCodes, TI18n, TIcu} from '../../src/render3/interfaces/i18n'; import {HEADER_OFFSET, LView, TVIEW} from '../../src/render3/interfaces/view'; import {getNativeByIndex} from '../../src/render3/util/view_utils'; + import {TemplateFixture} from './render_util'; import {debugMatch} from './utils'; From 6da9e5851a8dea2bcffc9be690e349ce52465393 Mon Sep 17 00:00:00 2001 From: crisbeto Date: Sun, 9 Aug 2020 10:10:35 +0200 Subject: [PATCH 007/629] fix(compiler-cli): preserve quotes in class member names (#38387) When we were outputting class members for `setClassMetadata` calls, we were using the string representation of the member name. This can lead to us generating invalid code when the name contains dashes and is quoted (e.g. `@Output() 'has-dashes' = new EventEmitter()`), because the quotes will be stripped for the string representation. These changes fix the issue by using the original name AST node that was used for the declaration and which knows whether it's supposed to be quoted or not. Fixes #38311. PR Close #38387 --- .../src/ngtsc/annotations/src/metadata.ts | 6 +++--- .../src/ngtsc/annotations/test/metadata_spec.ts | 13 +++++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/compiler-cli/src/ngtsc/annotations/src/metadata.ts b/packages/compiler-cli/src/ngtsc/annotations/src/metadata.ts index 56676ac84d..3ac72e8d10 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/src/metadata.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/src/metadata.ts @@ -70,8 +70,8 @@ export function generateSetClassMetadataCall( `Duplicate decorated properties found on class '${clazz.name.text}': ` + duplicateDecoratedMemberNames.join(', ')); } - const decoratedMembers = - classMembers.map(member => classMemberToMetadata(member.name, member.decorators!, isCore)); + const decoratedMembers = classMembers.map( + member => classMemberToMetadata(member.nameNode ?? member.name, member.decorators!, isCore)); if (decoratedMembers.length > 0) { metaPropDecorators = ts.createObjectLiteral(decoratedMembers); } @@ -127,7 +127,7 @@ function ctorParameterToMetadata( * Convert a reflected class member to metadata. */ function classMemberToMetadata( - name: string, decorators: Decorator[], isCore: boolean): ts.PropertyAssignment { + name: ts.PropertyName|string, decorators: Decorator[], isCore: boolean): ts.PropertyAssignment { const ngDecorators = decorators.filter(dec => isAngularDecorator(dec, isCore)) .map((decorator: Decorator) => decoratorToMetadata(decorator)); const decoratorMeta = ts.createArrayLiteral(ngDecorators); diff --git a/packages/compiler-cli/src/ngtsc/annotations/test/metadata_spec.ts b/packages/compiler-cli/src/ngtsc/annotations/test/metadata_spec.ts index b830a43326..1745af51a7 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/test/metadata_spec.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/test/metadata_spec.ts @@ -90,6 +90,19 @@ runInEachFileSystem(() => { `); expect(res).toBe(''); }); + + it('should preserve quotes around class member names', () => { + const res = compileAndPrint(` + import {Component, Input} from '@angular/core'; + + @Component('metadata') class Target { + @Input() 'has-dashes-in-name' = 123; + @Input() noDashesInName = 456; + } + `); + expect(res).toContain( + `{ 'has-dashes-in-name': [{ type: Input }], noDashesInName: [{ type: Input }] })`); + }); }); function compileAndPrint(contents: string): string { From 5dc8d287aac1824032aa4bd18a17ba5fdc67a6c3 Mon Sep 17 00:00:00 2001 From: crisbeto Date: Sat, 1 Aug 2020 11:57:29 +0200 Subject: [PATCH 008/629] fix(core): queries not matching string injection tokens (#38321) Queries weren't matching directives that provide themselves via string injection tokens, because the assumption was that any string passed to a query decorator refers to a template reference. These changes make it so we match both template references and providers while giving precedence to the template references. Fixes #38313. Fixes #38315. PR Close #38321 --- packages/core/src/render3/di.ts | 7 +- packages/core/src/render3/query.ts | 17 +- packages/core/test/acceptance/query_spec.ts | 173 ++++++++++++++++++++ 3 files changed, 187 insertions(+), 10 deletions(-) diff --git a/packages/core/src/render3/di.ts b/packages/core/src/render3/di.ts index 5aee59cdd6..0c799cbcf2 100644 --- a/packages/core/src/render3/di.ts +++ b/packages/core/src/render3/di.ts @@ -495,8 +495,8 @@ function searchTokensOnInjector( * @returns Index of a found directive or provider, or null when none found. */ export function locateDirectiveOrProvider( - tNode: TNode, tView: TView, token: Type|InjectionToken, canAccessViewProviders: boolean, - isHostSpecialCase: boolean|number): number|null { + tNode: TNode, tView: TView, token: Type|InjectionToken|string, + canAccessViewProviders: boolean, isHostSpecialCase: boolean|number): number|null { const nodeProviderIndexes = tNode.providerIndexes; const tInjectables = tView.data; @@ -510,7 +510,8 @@ export function locateDirectiveOrProvider( // When the host special case applies, only the viewProviders and the component are visible const endIndex = isHostSpecialCase ? injectablesStart + cptViewProvidersCount : directiveEnd; for (let i = startingIndex; i < endIndex; i++) { - const providerTokenOrDef = tInjectables[i] as InjectionToken| Type| DirectiveDef; + const providerTokenOrDef = + tInjectables[i] as InjectionToken| Type| DirectiveDef| string; if (i < directivesStart && token === providerTokenOrDef || i >= directivesStart && (providerTokenOrDef as DirectiveDef).type === token) { return i; diff --git a/packages/core/src/render3/query.ts b/packages/core/src/render3/query.ts index 58e587703e..3f135d8633 100644 --- a/packages/core/src/render3/query.ts +++ b/packages/core/src/render3/query.ts @@ -227,20 +227,23 @@ class TQuery_ implements TQuery { } private matchTNode(tView: TView, tNode: TNode): void { - if (Array.isArray(this.metadata.predicate)) { - const localNames = this.metadata.predicate; - for (let i = 0; i < localNames.length; i++) { - this.matchTNodeWithReadOption(tView, tNode, getIdxOfMatchingSelector(tNode, localNames[i])); + const predicate = this.metadata.predicate; + if (Array.isArray(predicate)) { + for (let i = 0; i < predicate.length; i++) { + const name = predicate[i]; + this.matchTNodeWithReadOption(tView, tNode, getIdxOfMatchingSelector(tNode, name)); + // Also try matching the name to a provider since strings can be used as DI tokens too. + this.matchTNodeWithReadOption( + tView, tNode, locateDirectiveOrProvider(tNode, tView, name, false, false)); } } else { - const typePredicate = this.metadata.predicate as any; - if (typePredicate === ViewEngine_TemplateRef) { + if ((predicate as any) === ViewEngine_TemplateRef) { if (tNode.type === TNodeType.Container) { this.matchTNodeWithReadOption(tView, tNode, -1); } } else { this.matchTNodeWithReadOption( - tView, tNode, locateDirectiveOrProvider(tNode, tView, typePredicate, false, false)); + tView, tNode, locateDirectiveOrProvider(tNode, tView, predicate, false, false)); } } } diff --git a/packages/core/test/acceptance/query_spec.ts b/packages/core/test/acceptance/query_spec.ts index 3ee127eed3..341f4f45c9 100644 --- a/packages/core/test/acceptance/query_spec.ts +++ b/packages/core/test/acceptance/query_spec.ts @@ -1631,6 +1631,179 @@ describe('query logic', () => { expect(groups[2]).toBeUndefined(); }); }); + + describe('querying for string token providers', () => { + @Directive({ + selector: '[text-token]', + providers: [{provide: 'Token', useExisting: TextTokenDirective}], + }) + class TextTokenDirective { + } + + it('should match string injection token in a ViewChild query', () => { + @Component({template: '
'}) + class App { + @ViewChild('Token') token: any; + } + + TestBed.configureTestingModule({declarations: [App, TextTokenDirective]}); + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + expect(fixture.componentInstance.token).toBeAnInstanceOf(TextTokenDirective); + }); + + it('should give precedence to local reference if both a reference and a string injection token provider match a ViewChild query', + () => { + @Component({template: '
'}) + class App { + @ViewChild('Token') token: any; + } + + TestBed.configureTestingModule({declarations: [App, TextTokenDirective]}); + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + expect(fixture.componentInstance.token).toBeAnInstanceOf(ElementRef); + }); + + it('should match string injection token in a ViewChildren query', () => { + @Component({template: '
'}) + class App { + @ViewChildren('Token') tokens!: QueryList; + } + + TestBed.configureTestingModule({declarations: [App, TextTokenDirective]}); + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + + const tokens = fixture.componentInstance.tokens; + expect(tokens.length).toBe(1); + expect(tokens.first).toBeAnInstanceOf(TextTokenDirective); + }); + + it('should match both string injection token and local reference inside a ViewChildren query', + () => { + @Component({template: '
'}) + class App { + @ViewChildren('Token') tokens!: QueryList; + } + + TestBed.configureTestingModule({declarations: [App, TextTokenDirective]}); + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + + expect(fixture.componentInstance.tokens.toArray()).toEqual([ + jasmine.any(ElementRef), jasmine.any(TextTokenDirective) + ]); + }); + + it('should match string injection token in a ContentChild query', () => { + @Component({selector: 'has-query', template: ''}) + class HasQuery { + @ContentChild('Token') token: any; + } + + @Component({template: '
'}) + class App { + @ViewChild(HasQuery) queryComp!: HasQuery; + } + + TestBed.configureTestingModule({declarations: [App, HasQuery, TextTokenDirective]}); + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + + expect(fixture.componentInstance.queryComp.token).toBeAnInstanceOf(TextTokenDirective); + }); + + it('should give precedence to local reference if both a reference and a string injection token provider match a ContentChild query', + () => { + @Component({selector: 'has-query', template: ''}) + class HasQuery { + @ContentChild('Token') token: any; + } + + @Component({template: '
'}) + class App { + @ViewChild(HasQuery) queryComp!: HasQuery; + } + + TestBed.configureTestingModule({declarations: [App, HasQuery, TextTokenDirective]}); + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + + expect(fixture.componentInstance.queryComp.token).toBeAnInstanceOf(ElementRef); + }); + + it('should match string injection token in a ContentChildren query', () => { + @Component({selector: 'has-query', template: ''}) + class HasQuery { + @ContentChildren('Token') tokens!: QueryList; + } + + @Component({template: '
'}) + class App { + @ViewChild(HasQuery) queryComp!: HasQuery; + } + + TestBed.configureTestingModule({declarations: [App, HasQuery, TextTokenDirective]}); + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + + const tokens = fixture.componentInstance.queryComp.tokens; + expect(tokens.length).toBe(1); + expect(tokens.first).toBeAnInstanceOf(TextTokenDirective); + }); + + it('should match both string injection token and local reference inside a ContentChildren query', + () => { + @Component({selector: 'has-query', template: ''}) + class HasQuery { + @ContentChildren('Token') tokens!: QueryList; + } + + @Component({template: '
'}) + class App { + @ViewChild(HasQuery) queryComp!: HasQuery; + } + + TestBed.configureTestingModule({declarations: [App, HasQuery, TextTokenDirective]}); + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + + expect(fixture.componentInstance.queryComp.tokens.toArray()).toEqual([ + jasmine.any(ElementRef), jasmine.any(TextTokenDirective) + ]); + }); + + it('should match string token specified through the `read` option of a view query', () => { + @Component({template: '
'}) + class App { + @ViewChild('Token', {read: 'Token'}) token: any; + } + + TestBed.configureTestingModule({declarations: [App, TextTokenDirective]}); + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + expect(fixture.componentInstance.token).toBeAnInstanceOf(TextTokenDirective); + }); + + it('should match string token specified through the `read` option of a content query', () => { + @Component({selector: 'has-query', template: ''}) + class HasQuery { + @ContentChild('Token', {read: 'Token'}) token: any; + } + + @Component({template: '
'}) + class App { + @ViewChild(HasQuery) queryComp!: HasQuery; + } + + TestBed.configureTestingModule({declarations: [App, HasQuery, TextTokenDirective]}); + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + + expect(fixture.componentInstance.queryComp.token).toBeAnInstanceOf(TextTokenDirective); + }); + }); }); function initWithTemplate(compType: Type, template: string) { From 3d156162af93fa417d0300ba679908a14a56935b Mon Sep 17 00:00:00 2001 From: Andrew Kushnir Date: Mon, 10 Aug 2020 15:15:24 -0700 Subject: [PATCH 009/629] fix(dev-infra): update i18n-related file locations in PullApprove config (#38403) The changes in https://github.com/angular/angular/pull/38368 split `render3/i18n.ts` files into smaller scripts, but the PullApprove config was not updated to reflect that. This commit updates the PullApprove config to reflect the recent changes in i18n-related files. PR Close #38403 --- .pullapprove.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pullapprove.yml b/.pullapprove.yml index b7f001b6a2..843df422cb 100644 --- a/.pullapprove.yml +++ b/.pullapprove.yml @@ -509,8 +509,8 @@ groups: - > contains_any_globs(files, [ 'packages/core/src/i18n/**', - 'packages/core/src/render3/i18n.ts', - 'packages/core/src/render3/i18n.md', + 'packages/core/src/render3/i18n/**', + 'packages/core/src/render3/instructions/i18n.ts', 'packages/core/src/render3/interfaces/i18n.ts', 'packages/common/locales/**', 'packages/common/src/i18n/**', From df76a2048b92c2cb977fcc6d884a898c712e5068 Mon Sep 17 00:00:00 2001 From: Alexander Vologin Date: Wed, 16 Jan 2019 04:09:03 -0500 Subject: [PATCH 010/629] fix(router): restore 'history.state' object for navigations coming from Angular router (#28108) (#28176) When navigations coming from Angular router we may have a payload stored in state property. When this exists, set extras's state to the payload. PR Close #28176 --- packages/router/src/router.ts | 10 ++++- packages/router/test/integration.spec.ts | 53 ++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/packages/router/src/router.ts b/packages/router/src/router.ts index 83b915fa3c..a4e1dae9fa 100644 --- a/packages/router/src/router.ts +++ b/packages/router/src/router.ts @@ -920,7 +920,15 @@ export class Router { // hybrid apps. setTimeout(() => { const {source, state, urlTree} = currentChange; - this.scheduleNavigation(urlTree, source, state, {replaceUrl: true}); + const extras: NavigationExtras = {replaceUrl: true}; + if (state) { + const stateCopy = {...state}; + delete stateCopy.navigationId; + if (Object.keys(stateCopy).length !== 0) { + extras.state = stateCopy; + } + } + this.scheduleNavigation(urlTree, source, state, extras); }, 0); } this.lastLocationChangeInfo = currentChange; diff --git a/packages/router/test/integration.spec.ts b/packages/router/test/integration.spec.ts index ce343becea..47d4edbbce 100644 --- a/packages/router/test/integration.spec.ts +++ b/packages/router/test/integration.spec.ts @@ -159,6 +159,59 @@ describe('Integration', () => { expect(navigation.extras.state).toEqual({foo: 'bar'}); }))); + it('should set history.state when navigation with browser back and forward', + fakeAsync(inject([Router, Location], (router: Router, location: SpyLocation) => { + router.resetConfig([ + {path: '', component: SimpleCmp}, + {path: 'simple', component: SimpleCmp}, + ]); + + const fixture = createRoot(router, RootCmp); + let navigation: Navigation = null!; + router.events.subscribe(e => { + if (e instanceof NavigationStart) { + navigation = router.getCurrentNavigation()!; + } + }); + + const state = {foo: 'bar'}; + router.navigateByUrl('/simple', {state}); + tick(); + location.back(); + tick(); + location.forward(); + tick(); + + expect(navigation.extras.state).toBeDefined(); + expect(navigation.extras.state).toEqual(state); + }))); + + it('should not error if state is not {[key: string]: any}', + fakeAsync(inject([Router, Location], (router: Router, location: SpyLocation) => { + router.resetConfig([ + {path: '', component: SimpleCmp}, + {path: 'simple', component: SimpleCmp}, + ]); + + const fixture = createRoot(router, RootCmp); + let navigation: Navigation = null!; + router.events.subscribe(e => { + if (e instanceof NavigationStart) { + navigation = router.getCurrentNavigation()!; + } + }); + + location.replaceState('', '', 42); + router.navigateByUrl('/simple'); + tick(); + location.back(); + advance(fixture); + + // Angular does not support restoring state to the primitive. + expect(navigation.extras.state).toEqual(undefined); + expect(location.getState()).toEqual({navigationId: 3}); + }))); + it('should not pollute browser history when replaceUrl is set to true', fakeAsync(inject([Router, Location], (router: Router, location: SpyLocation) => { router.resetConfig([ From 2e9fdbde9eb26fea17e3e68e272dc1c2cc9f4fa3 Mon Sep 17 00:00:00 2001 From: JoostK Date: Sat, 27 Jun 2020 19:27:24 +0200 Subject: [PATCH 011/629] fix(core): prevent NgModule scope being overwritten in JIT compiler (#37795) In JIT compiled apps, component definitions are compiled upon first access. For a component class `A` that extends component class `B`, the `B` component is also compiled when the `InheritDefinitionFeature` runs during the compilation of `A` before it has finalized. A problem arises when the compilation of `B` would flush the NgModule scoping queue, where the NgModule declaring `A` is still pending. The scope information would be applied to the definition of `A`, but its compilation is still in progress so requesting the component definition would compile `A` again from scratch. This "inner compilation" is correctly assigned the NgModule scope, but once the "outer compilation" of `A` finishes it would overwrite the inner compilation's definition, losing the NgModule scope information. In summary, flushing the NgModule scope queue could trigger a reentrant compilation, where JIT compilation is non-reentrant. To avoid the reentrant compilation, a compilation depth counter is introduced to avoid flushing the NgModule scope during nested compilations. Fixes #37105 PR Close #37795 --- packages/core/src/render3/jit/directive.ts | 43 ++++++++++---- .../test/acceptance/ngmodule_scope_spec.ts | 58 +++++++++++++++++++ 2 files changed, 90 insertions(+), 11 deletions(-) create mode 100644 packages/core/test/acceptance/ngmodule_scope_spec.ts diff --git a/packages/core/src/render3/jit/directive.ts b/packages/core/src/render3/jit/directive.ts index 1b214edd79..6da8acbbe9 100644 --- a/packages/core/src/render3/jit/directive.ts +++ b/packages/core/src/render3/jit/directive.ts @@ -26,7 +26,20 @@ import {angularCoreEnv} from './environment'; import {getJitOptions} from './jit_options'; import {flushModuleScopingQueueAsMuchAsPossible, patchComponentDefWithScope, transitiveScopesFor} from './module'; - +/** + * Keep track of the compilation depth to avoid reentrancy issues during JIT compilation. This + * matters in the following scenario: + * + * Consider a component 'A' that extends component 'B', both declared in module 'M'. During + * the compilation of 'A' the definition of 'B' is requested to capture the inheritance chain, + * potentially triggering compilation of 'B'. If this nested compilation were to trigger + * `flushModuleScopingQueueAsMuchAsPossible` it may happen that module 'M' is still pending in the + * queue, resulting in 'A' and 'B' to be patched with the NgModule scope. As the compilation of + * 'A' is still in progress, this would introduce a circular dependency on its compilation. To avoid + * this issue, the module scope queue is only flushed for compilations at the depth 0, to ensure + * all compilations have finished. + */ +let compilationDepth = 0; /** * Compile an Angular component according to its decorator metadata, and patch the resulting @@ -106,18 +119,26 @@ export function compileComponent(type: Type, metadata: Component): void { interpolation: metadata.interpolation, viewProviders: metadata.viewProviders || null, }; - if (meta.usesInheritance) { - addDirectiveDefToUndecoratedParents(type); + + compilationDepth++; + try { + if (meta.usesInheritance) { + addDirectiveDefToUndecoratedParents(type); + } + ngComponentDef = compiler.compileComponent(angularCoreEnv, templateUrl, meta); + } finally { + // Ensure that the compilation depth is decremented even when the compilation failed. + compilationDepth--; } - ngComponentDef = compiler.compileComponent(angularCoreEnv, templateUrl, meta); - - // When NgModule decorator executed, we enqueued the module definition such that - // it would only dequeue and add itself as module scope to all of its declarations, - // but only if if all of its declarations had resolved. This call runs the check - // to see if any modules that are in the queue can be dequeued and add scope to - // their declarations. - flushModuleScopingQueueAsMuchAsPossible(); + if (compilationDepth === 0) { + // When NgModule decorator executed, we enqueued the module definition such that + // it would only dequeue and add itself as module scope to all of its declarations, + // but only if if all of its declarations had resolved. This call runs the check + // to see if any modules that are in the queue can be dequeued and add scope to + // their declarations. + flushModuleScopingQueueAsMuchAsPossible(); + } // If component compilation is async, then the @NgModule annotation which declares the // component may execute and set an ngSelectorScope property on the component type. This diff --git a/packages/core/test/acceptance/ngmodule_scope_spec.ts b/packages/core/test/acceptance/ngmodule_scope_spec.ts new file mode 100644 index 0000000000..3f6e167b9a --- /dev/null +++ b/packages/core/test/acceptance/ngmodule_scope_spec.ts @@ -0,0 +1,58 @@ +/** + * @license + * Copyright Google LLC 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, destroyPlatform, NgModule, Pipe, PipeTransform} from '@angular/core'; +import {BrowserModule} from '@angular/platform-browser'; +import {platformBrowserDynamic} from '@angular/platform-browser-dynamic'; +import {withBody} from '@angular/private/testing'; + +describe('NgModule scopes', () => { + beforeEach(destroyPlatform); + afterEach(destroyPlatform); + + it('should apply NgModule scope to a component that extends another component class', + withBody('', async () => { + // Regression test for https://github.com/angular/angular/issues/37105 + // + // This test reproduces a scenario that used to fail due to a reentrancy issue in Ivy's JIT + // compiler. Extending a component from a decorated baseclass would inadvertently compile + // the subclass twice. NgModule scope information would only be present on the initial + // compilation, but then overwritten during the second compilation. This meant that the + // baseclass did not have a NgModule scope, such that declarations are not available. + // + // The scenario cannot be tested using TestBed as it influences how NgModule + // scopes are applied, preventing the issue from occurring. + + @Pipe({name: 'multiply'}) + class MultiplyPipe implements PipeTransform { + transform(value: number, factor: number): number { + return value * factor; + } + } + + @Component({template: '...'}) + class BaseComponent { + } + + @Component({selector: 'my-app', template: 'App - {{ 3 | multiply:2 }}'}) + class App extends BaseComponent { + } + + @NgModule({ + imports: [BrowserModule], + declarations: [App, BaseComponent, MultiplyPipe], + bootstrap: [App], + }) + class Mod { + } + + const ngModuleRef = await platformBrowserDynamic().bootstrapModule(Mod); + expect(document.body.textContent).toContain('App - 6'); + ngModuleRef.destroy(); + })); +}); From 9514fd90809fcbf8690c526cee6ef678b45f69d2 Mon Sep 17 00:00:00 2001 From: JoostK Date: Fri, 3 Jul 2020 19:35:44 +0200 Subject: [PATCH 012/629] fix(compiler): evaluate safe navigation expressions in correct binding order (#37911) When using the safe navigation operator in a binding expression, a temporary variable may be used for storing the result of a side-effectful call. For example, the following template uses a pipe and a safe property access: ```html ``` The result of the pipe evaluation is stored in a temporary to be able to check whether it is present. The temporary variable needs to be declared in a separate statement and this would also cause the full expression itself to be pulled out into a separate statement. This would compile into the following pseudo-code instructions: ```js var temp = null; var firstName = (temp = pipe('async', ctx.person$)) == null ? null : temp.name; property('enabled', ctx.enabled)('firstName', firstName); ``` Notice that the pipe evaluation happens before evaluating the `enabled` binding, such that the runtime's internal binding index would correspond with `enabled`, not `firstName`. This introduces a problem when the pipe uses `WrappedValue` to force a change to be detected, as the runtime would then mark the binding slot corresponding with `enabled` as dirty, instead of `firstName`. This results in the `enabled` binding to be updated, triggering setters and affecting how `OnChanges` is called. In the pseudo-code above, the intermediate `firstName` variable is not strictly necessary---it only improved readability a bit---and emitting it inline with the binding itself avoids the out-of-order execution of the pipe: ```js var temp = null; property('enabled', ctx.enabled) ('firstName', (temp = pipe('async', ctx.person$)) == null ? null : temp.name); ``` This commit introduces a new `BindingForm` that results in the above code to be generated and adds compiler and acceptance tests to verify the proper behavior. Fixes #37194 PR Close #37911 --- .../r3_view_compiler_binding_spec.ts | 78 +++++++++++ .../compliance/r3_view_compiler_i18n_spec.ts | 6 +- .../src/compiler_util/expression_converter.ts | 9 +- .../compiler/src/render3/view/compiler.ts | 2 +- .../compiler/src/render3/view/template.ts | 2 +- packages/core/test/acceptance/pipe_spec.ts | 130 +++++++++++++++++- 6 files changed, 219 insertions(+), 8 deletions(-) diff --git a/packages/compiler-cli/test/compliance/r3_view_compiler_binding_spec.ts b/packages/compiler-cli/test/compliance/r3_view_compiler_binding_spec.ts index 59b857e7ba..79cb9211be 100644 --- a/packages/compiler-cli/test/compliance/r3_view_compiler_binding_spec.ts +++ b/packages/compiler-cli/test/compliance/r3_view_compiler_binding_spec.ts @@ -180,6 +180,44 @@ describe('compiler compliance: bindings', () => { expectEmit(result.source, template, 'Incorrect template'); }); + it('should emit temporary evaluation within the binding expression for in-order execution', + () => { + // https://github.com/angular/angular/issues/37194 + // Verifies that temporary expressions used for expressions with potential side-effects in + // the LHS of a safe navigation access are emitted within the binding expression itself, to + // ensure that these temporaries are evaluated during the evaluation of the binding. This + // is important for when the LHS contains a pipe, as pipe evaluation depends on the current + // binding index. + const files = { + app: { + 'example.ts': ` + import {Component} from '@angular/core'; + + @Component({ + template: '' + }) + export class MyComponent { + myTitle = 'hello'; + auth?: () => { identity(): any; }; + }` + } + }; + + const result = compile(files, angularFiles); + const template = ` + … + template: function MyComponent_Template(rf, ctx) { + … + if (rf & 2) { + var $tmp0$ = null; + $r3$.ɵɵproperty("title", ctx.myTitle)("id", ($tmp0$ = $r3$.ɵɵpipeBind1(1, 3, ($tmp0$ = ctx.auth()) == null ? null : $tmp0$.identity())) == null ? null : $tmp0$.id)("tabindex", 1); + } + } + `; + + expectEmit(result.source, template, 'Incorrect template'); + }); + it('should chain multiple property bindings into a single instruction', () => { const files = { app: { @@ -685,6 +723,46 @@ describe('compiler compliance: bindings', () => { expectEmit(source, HostBindingDirDeclaration, 'Invalid host binding code'); }); + it('should support host bindings with temporary expressions', () => { + const files = { + app: { + 'spec.ts': ` + import {Directive, NgModule} from '@angular/core'; + + @Directive({ + selector: '[hostBindingDir]', + host: {'[id]': 'getData()?.id'} + }) + export class HostBindingDir { + getData?: () => { id: number }; + } + + @NgModule({declarations: [HostBindingDir]}) + export class MyModule {} + ` + } + }; + + const HostBindingDirDeclaration = ` + HostBindingDir.ɵdir = $r3$.ɵɵdefineDirective({ + type: HostBindingDir, + selectors: [["", "hostBindingDir", ""]], + hostVars: 1, + hostBindings: function HostBindingDir_HostBindings(rf, ctx) { + if (rf & 2) { + var $tmp0$ = null; + $r3$.ɵɵhostProperty("id", ($tmp0$ = ctx.getData()) == null ? null : $tmp0$.id); + } + } + }); + `; + + const result = compile(files, angularFiles); + const source = result.source; + + expectEmit(source, HostBindingDirDeclaration, 'Invalid host binding code'); + }); + it('should support host bindings with pure functions', () => { const files = { app: { diff --git a/packages/compiler-cli/test/compliance/r3_view_compiler_i18n_spec.ts b/packages/compiler-cli/test/compliance/r3_view_compiler_i18n_spec.ts index 2755e0b57a..533046c8ef 100644 --- a/packages/compiler-cli/test/compliance/r3_view_compiler_i18n_spec.ts +++ b/packages/compiler-cli/test/compliance/r3_view_compiler_i18n_spec.ts @@ -805,8 +805,7 @@ describe('i18n support in the template compiler', () => { } if (rf & 2) { var $tmp_0_0$ = null; - const $currVal_0$ = ($tmp_0_0$ = ctx.valueA.getRawValue()) == null ? null : $tmp_0_0$.getTitle(); - $r3$.ɵɵi18nExp($currVal_0$); + $r3$.ɵɵi18nExp(($tmp_0_0$ = ctx.valueA.getRawValue()) == null ? null : $tmp_0_0$.getTitle()); $r3$.ɵɵi18nApply(1); } } @@ -1320,9 +1319,8 @@ describe('i18n support in the template compiler', () => { } if (rf & 2) { var $tmp_2_0$ = null; - const $currVal_2$ = ($tmp_2_0$ = ctx.valueA.getRawValue()) == null ? null : $tmp_2_0$.getTitle(); $r3$.ɵɵadvance(2); - $r3$.ɵɵi18nExp($r3$.ɵɵpipeBind1(2, 3, ctx.valueA))(ctx.valueA == null ? null : ctx.valueA.a == null ? null : ctx.valueA.a.b)($currVal_2$); + $r3$.ɵɵi18nExp($r3$.ɵɵpipeBind1(2, 3, ctx.valueA))(ctx.valueA == null ? null : ctx.valueA.a == null ? null : ctx.valueA.a.b)(($tmp_2_0$ = ctx.valueA.getRawValue()) == null ? null : $tmp_2_0$.getTitle()); $r3$.ɵɵi18nApply(1); } } diff --git a/packages/compiler/src/compiler_util/expression_converter.ts b/packages/compiler/src/compiler_util/expression_converter.ts index 1abb1c39a5..240c35b66f 100644 --- a/packages/compiler/src/compiler_util/expression_converter.ts +++ b/packages/compiler/src/compiler_util/expression_converter.ts @@ -154,6 +154,11 @@ export enum BindingForm { // Try to generate a simple binding (no temporaries or statements) // otherwise generate a general binding TrySimple, + + // Inlines assignment of temporaries into the generated expression. The result may still + // have statements attached for declarations of temporary variables. + // This is the only relevant form for Ivy, the other forms are only used in ViewEngine. + Expression, } /** @@ -168,7 +173,6 @@ export function convertPropertyBinding( if (!localResolver) { localResolver = new DefaultLocalResolver(); } - const currValExpr = createCurrValueExpr(bindingId); const visitor = new _AstToIrVisitor(localResolver, implicitReceiver, bindingId, interpolationFunction); const outputExpr: o.Expression = expressionWithoutBuiltins.visit(visitor, _Mode.Expression); @@ -180,8 +184,11 @@ export function convertPropertyBinding( if (visitor.temporaryCount === 0 && form == BindingForm.TrySimple) { return new ConvertPropertyBindingResult([], outputExpr); + } else if (form === BindingForm.Expression) { + return new ConvertPropertyBindingResult(stmts, outputExpr); } + const currValExpr = createCurrValueExpr(bindingId); stmts.push(currValExpr.set(outputExpr).toDeclStmt(o.DYNAMIC_TYPE, [o.StmtModifier.Final])); return new ConvertPropertyBindingResult(stmts, currValExpr); } diff --git a/packages/compiler/src/render3/view/compiler.ts b/packages/compiler/src/render3/view/compiler.ts index 7adf55fb43..2aaeddc1fe 100644 --- a/packages/compiler/src/render3/view/compiler.ts +++ b/packages/compiler/src/render3/view/compiler.ts @@ -714,7 +714,7 @@ function createHostBindingsFunction( function bindingFn(implicit: any, value: AST) { return convertPropertyBinding( - null, implicit, value, 'b', BindingForm.TrySimple, () => error('Unexpected interpolation')); + null, implicit, value, 'b', BindingForm.Expression, () => error('Unexpected interpolation')); } function convertStylingCall( diff --git a/packages/compiler/src/render3/view/template.ts b/packages/compiler/src/render3/view/template.ts index 4e12be2daf..4cb1601915 100644 --- a/packages/compiler/src/render3/view/template.ts +++ b/packages/compiler/src/render3/view/template.ts @@ -1213,7 +1213,7 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver private convertPropertyBinding(value: AST): o.Expression { const convertedPropertyBinding = convertPropertyBinding( - this, this.getImplicitReceiverExpr(), value, this.bindingContext(), BindingForm.TrySimple, + this, this.getImplicitReceiverExpr(), value, this.bindingContext(), BindingForm.Expression, () => error('Unexpected interpolation')); const valExpr = convertedPropertyBinding.currValExpr; this._tempVariables.push(...convertedPropertyBinding.stmts); diff --git a/packages/core/test/acceptance/pipe_spec.ts b/packages/core/test/acceptance/pipe_spec.ts index 2befc8e240..22a38f7729 100644 --- a/packages/core/test/acceptance/pipe_spec.ts +++ b/packages/core/test/acceptance/pipe_spec.ts @@ -6,9 +6,10 @@ * found in the LICENSE file at https://angular.io/license */ -import {ChangeDetectionStrategy, ChangeDetectorRef, Component, Directive, Inject, Injectable, InjectionToken, Input, NgModule, OnDestroy, Pipe, PipeTransform, ViewChild} from '@angular/core'; +import {ChangeDetectionStrategy, ChangeDetectorRef, Component, Directive, Inject, Injectable, InjectionToken, Input, NgModule, OnChanges, OnDestroy, Pipe, PipeTransform, SimpleChanges, ViewChild, WrappedValue} from '@angular/core'; import {TestBed} from '@angular/core/testing'; import {expect} from '@angular/platform-browser/testing/src/matchers'; +import {ivyEnabled} from '@angular/private/testing'; describe('pipe', () => { @Pipe({name: 'countingPipe'}) @@ -285,6 +286,133 @@ describe('pipe', () => { expect(fixture.nativeElement).toHaveText('a'); }); + describe('pipes within an optional chain', () => { + it('should not dirty unrelated inputs', () => { + // https://github.com/angular/angular/issues/37194 + // https://github.com/angular/angular/issues/37591 + // Using a pipe in the LHS of safe navigation operators would clobber unrelated bindings + // iff the pipe returns WrappedValue, incorrectly causing the unrelated binding + // to be considered changed. + const log: string[] = []; + + @Component({template: ``}) + class App { + value2 = {id: 2}; + } + + @Component({selector: 'my-cmp', template: ''}) + class MyCmp { + @Input() + set value1(value1: number) { + log.push(`set value1=${value1}`); + } + + @Input() + set value2(value2: number) { + log.push(`set value2=${value2}`); + } + } + + @Pipe({name: 'pipe'}) + class MyPipe implements PipeTransform { + transform(value: any): any { + log.push('pipe'); + return WrappedValue.wrap(value); + } + } + + TestBed.configureTestingModule({declarations: [App, MyCmp, MyPipe]}); + const fixture = TestBed.createComponent(App); + fixture.detectChanges(/* checkNoChanges */ false); + + // Both bindings should have been set. Note: ViewEngine evaluates the pipe out-of-order, + // before setting inputs. + expect(log).toEqual( + ivyEnabled ? + [ + 'set value1=1', + 'pipe', + 'set value2=2', + ] : + [ + 'pipe', + 'set value1=1', + 'set value2=2', + ]); + log.length = 0; + + fixture.componentInstance.value2 = {id: 3}; + fixture.detectChanges(/* checkNoChanges */ false); + + // value1 did not change, so it should not have been set. + expect(log).toEqual([ + 'pipe', + 'set value2=3', + ]); + }); + + it('should not include unrelated inputs in ngOnChanges', () => { + // https://github.com/angular/angular/issues/37194 + // https://github.com/angular/angular/issues/37591 + // Using a pipe in the LHS of safe navigation operators would clobber unrelated bindings + // iff the pipe returns WrappedValue, incorrectly causing the unrelated binding + // to be considered changed. + const log: string[] = []; + + @Component({template: ``}) + class App { + value2 = {id: 2}; + } + + @Component({selector: 'my-cmp', template: ''}) + class MyCmp implements OnChanges { + @Input() value1!: number; + + @Input() value2!: number; + + ngOnChanges(changes: SimpleChanges): void { + if (changes.value1) { + const {previousValue, currentValue, firstChange} = changes.value1; + log.push(`change value1: ${previousValue} -> ${currentValue} (${firstChange})`); + } + if (changes.value2) { + const {previousValue, currentValue, firstChange} = changes.value2; + log.push(`change value2: ${previousValue} -> ${currentValue} (${firstChange})`); + } + } + } + + @Pipe({name: 'pipe'}) + class MyPipe implements PipeTransform { + transform(value: any): any { + log.push('pipe'); + return WrappedValue.wrap(value); + } + } + + TestBed.configureTestingModule({declarations: [App, MyCmp, MyPipe]}); + const fixture = TestBed.createComponent(App); + fixture.detectChanges(/* checkNoChanges */ false); + + // Both bindings should have been included in ngOnChanges. + expect(log).toEqual([ + 'pipe', + 'change value1: undefined -> 1 (true)', + 'change value2: undefined -> 2 (true)', + ]); + log.length = 0; + + fixture.componentInstance.value2 = {id: 3}; + fixture.detectChanges(/* checkNoChanges */ false); + + // value1 did not change, so it should not have been included in ngOnChanges + expect(log).toEqual([ + 'pipe', + 'change value2: 2 -> 3 (false)', + ]); + }); + }); + describe('pure', () => { it('should call pure pipes only if the arguments change', () => { @Component({ From 18098d38b8c49c4f1897cd1edf757de4adcbdef2 Mon Sep 17 00:00:00 2001 From: JoostK Date: Fri, 3 Jul 2020 20:12:24 +0200 Subject: [PATCH 013/629] fix(compiler-cli): avoid creating value expressions for symbols from type-only imports (#37912) In TypeScript 3.8 support was added for type-only imports, which only brings in the symbol as a type, not their value. The Angular compiler did not yet take the type-only keyword into account when representing symbols in type positions as value expressions. The class metadata that the compiler emits would include the value expression for its parameter types, generating actual imports as necessary. For type-only imports this should not be done, as it introduces an actual import of the module that was originally just a type-only import. This commit lets the compiler deal with type-only imports specially, preventing a value expression from being created. Fixes #37900 PR Close #37912 --- .../ngcc/src/host/esm2015_host.ts | 13 +- .../ngcc/test/host/commonjs_host_spec.ts | 4 +- .../host/esm2015_host_import_helper_spec.ts | 4 +- .../test/host/esm5_host_import_helper_spec.ts | 4 +- .../ngcc/test/host/esm5_host_spec.ts | 4 +- .../ngcc/test/host/umd_host_spec.ts | 4 +- packages/compiler-cli/ngcc/test/host/util.ts | 17 +- .../src/ngtsc/annotations/src/metadata.ts | 4 +- .../src/ngtsc/annotations/src/util.ts | 120 ++++++++--- .../src/ngtsc/reflection/src/host.ts | 160 ++++++++++++--- .../src/ngtsc/reflection/src/type_to_value.ts | 88 ++++++-- .../src/ngtsc/reflection/src/typescript.ts | 2 - .../src/ngtsc/reflection/test/ts_host_spec.ts | 15 +- .../compiler-cli/test/ngtsc/ngtsc_spec.ts | 191 +++++++++++++++++- 14 files changed, 524 insertions(+), 106 deletions(-) diff --git a/packages/compiler-cli/ngcc/src/host/esm2015_host.ts b/packages/compiler-cli/ngcc/src/host/esm2015_host.ts index 9589818870..579ab00546 100644 --- a/packages/compiler-cli/ngcc/src/host/esm2015_host.ts +++ b/packages/compiler-cli/ngcc/src/host/esm2015_host.ts @@ -10,7 +10,7 @@ import * as ts from 'typescript'; import {absoluteFromSourceFile} from '../../../src/ngtsc/file_system'; import {Logger} from '../../../src/ngtsc/logging'; -import {ClassDeclaration, ClassMember, ClassMemberKind, CtorParameter, Declaration, Decorator, EnumMember, isDecoratorIdentifier, isNamedClassDeclaration, isNamedFunctionDeclaration, isNamedVariableDeclaration, KnownDeclaration, reflectObjectLiteral, SpecialDeclarationKind, TypeScriptReflectionHost, TypeValueReference} from '../../../src/ngtsc/reflection'; +import {ClassDeclaration, ClassMember, ClassMemberKind, CtorParameter, Declaration, Decorator, EnumMember, isDecoratorIdentifier, isNamedClassDeclaration, isNamedFunctionDeclaration, isNamedVariableDeclaration, KnownDeclaration, reflectObjectLiteral, SpecialDeclarationKind, TypeScriptReflectionHost, TypeValueReference, TypeValueReferenceKind, ValueUnavailableKind} from '../../../src/ngtsc/reflection'; import {isWithinPackage} from '../analysis/util'; import {BundleProgram} from '../packages/bundle_program'; import {findAll, getNameText, hasNameIdentifier, isDefined, stripDollarSuffix} from '../utils'; @@ -1594,7 +1594,7 @@ export class Esm2015ReflectionHost extends TypeScriptReflectionHost implements N {decorators: null, typeExpression: null}; const nameNode = node.name; - let typeValueReference: TypeValueReference|null = null; + let typeValueReference: TypeValueReference; if (typeExpression !== null) { // `typeExpression` is an expression in a "type" context. Resolve it to a declared value. // Either it's a reference to an imported type, or a type declared locally. Distinguish the @@ -1603,7 +1603,7 @@ export class Esm2015ReflectionHost extends TypeScriptReflectionHost implements N if (decl !== null && decl.node !== null && decl.viaModule !== null && isNamedDeclaration(decl.node)) { typeValueReference = { - local: false, + kind: TypeValueReferenceKind.IMPORTED, valueDeclaration: decl.node, moduleName: decl.viaModule, importedName: decl.node.name.text, @@ -1611,11 +1611,16 @@ export class Esm2015ReflectionHost extends TypeScriptReflectionHost implements N }; } else { typeValueReference = { - local: true, + kind: TypeValueReferenceKind.LOCAL, expression: typeExpression, defaultImportStatement: null, }; } + } else { + typeValueReference = { + kind: TypeValueReferenceKind.UNAVAILABLE, + reason: {kind: ValueUnavailableKind.MISSING_TYPE}, + }; } return { diff --git a/packages/compiler-cli/ngcc/test/host/commonjs_host_spec.ts b/packages/compiler-cli/ngcc/test/host/commonjs_host_spec.ts index df863ee554..922b88f777 100644 --- a/packages/compiler-cli/ngcc/test/host/commonjs_host_spec.ts +++ b/packages/compiler-cli/ngcc/test/host/commonjs_host_spec.ts @@ -10,7 +10,7 @@ import * as ts from 'typescript'; import {absoluteFrom, getFileSystem, getSourceFileOrError} from '../../../src/ngtsc/file_system'; import {runInEachFileSystem, TestFile} from '../../../src/ngtsc/file_system/testing'; import {MockLogger} from '../../../src/ngtsc/logging/testing'; -import {ClassMemberKind, ConcreteDeclaration, CtorParameter, DownleveledEnum, InlineDeclaration, isNamedClassDeclaration, isNamedFunctionDeclaration, isNamedVariableDeclaration, KnownDeclaration, TypeScriptReflectionHost} from '../../../src/ngtsc/reflection'; +import {ClassMemberKind, ConcreteDeclaration, CtorParameter, DownleveledEnum, InlineDeclaration, isNamedClassDeclaration, isNamedFunctionDeclaration, isNamedVariableDeclaration, KnownDeclaration, TypeScriptReflectionHost, TypeValueReferenceKind} from '../../../src/ngtsc/reflection'; import {getDeclaration} from '../../../src/ngtsc/testing'; import {loadFakeCore, loadTestFiles} from '../../../test/helpers'; import {CommonJsReflectionHost} from '../../src/host/commonjs_host'; @@ -1599,7 +1599,7 @@ exports.MissingClass2 = MissingClass2; isNamedVariableDeclaration); const ctrDecorators = host.getConstructorParameters(classNode)!; const identifierOfViewContainerRef = (ctrDecorators[0].typeValueReference! as { - local: true, + kind: TypeValueReferenceKind.LOCAL, expression: ts.Identifier, defaultImportStatement: null, }).expression; diff --git a/packages/compiler-cli/ngcc/test/host/esm2015_host_import_helper_spec.ts b/packages/compiler-cli/ngcc/test/host/esm2015_host_import_helper_spec.ts index 466484722a..20a8c35a8b 100644 --- a/packages/compiler-cli/ngcc/test/host/esm2015_host_import_helper_spec.ts +++ b/packages/compiler-cli/ngcc/test/host/esm2015_host_import_helper_spec.ts @@ -11,7 +11,7 @@ import * as ts from 'typescript'; import {absoluteFrom, getFileSystem, getSourceFileOrError} from '../../../src/ngtsc/file_system'; import {runInEachFileSystem, TestFile} from '../../../src/ngtsc/file_system/testing'; import {MockLogger} from '../../../src/ngtsc/logging/testing'; -import {ClassMemberKind, isNamedVariableDeclaration} from '../../../src/ngtsc/reflection'; +import {ClassMemberKind, isNamedVariableDeclaration, TypeValueReferenceKind} from '../../../src/ngtsc/reflection'; import {getDeclaration} from '../../../src/ngtsc/testing'; import {loadFakeCore, loadTestFiles, loadTsLib} from '../../../test/helpers'; import {Esm2015ReflectionHost} from '../../src/host/esm2015_host'; @@ -484,7 +484,7 @@ runInEachFileSystem(() => { isNamedVariableDeclaration); const ctrDecorators = host.getConstructorParameters(classNode)!; const identifierOfViewContainerRef = (ctrDecorators[0].typeValueReference! as { - local: true, + kind: TypeValueReferenceKind.LOCAL, expression: ts.Identifier, defaultImportStatement: null, }).expression; diff --git a/packages/compiler-cli/ngcc/test/host/esm5_host_import_helper_spec.ts b/packages/compiler-cli/ngcc/test/host/esm5_host_import_helper_spec.ts index 6b91881644..5e7251bfc3 100644 --- a/packages/compiler-cli/ngcc/test/host/esm5_host_import_helper_spec.ts +++ b/packages/compiler-cli/ngcc/test/host/esm5_host_import_helper_spec.ts @@ -10,7 +10,7 @@ import * as ts from 'typescript'; import {absoluteFrom, getFileSystem, getSourceFileOrError} from '../../../src/ngtsc/file_system'; import {runInEachFileSystem, TestFile} from '../../../src/ngtsc/file_system/testing'; import {MockLogger} from '../../../src/ngtsc/logging/testing'; -import {ClassMemberKind, isNamedFunctionDeclaration, isNamedVariableDeclaration} from '../../../src/ngtsc/reflection'; +import {ClassMemberKind, isNamedFunctionDeclaration, isNamedVariableDeclaration, TypeValueReferenceKind} from '../../../src/ngtsc/reflection'; import {getDeclaration} from '../../../src/ngtsc/testing'; import {loadFakeCore, loadTestFiles, loadTsLib} from '../../../test/helpers'; import {getIifeBody} from '../../src/host/esm2015_host'; @@ -544,7 +544,7 @@ export { AliasedDirective$1 }; isNamedVariableDeclaration); const ctrDecorators = host.getConstructorParameters(classNode)!; const identifierOfViewContainerRef = (ctrDecorators[0].typeValueReference! as { - local: true, + kind: TypeValueReferenceKind.LOCAL, expression: ts.Identifier, defaultImportStatement: null, }).expression; diff --git a/packages/compiler-cli/ngcc/test/host/esm5_host_spec.ts b/packages/compiler-cli/ngcc/test/host/esm5_host_spec.ts index e7663a72ee..6e219658c5 100644 --- a/packages/compiler-cli/ngcc/test/host/esm5_host_spec.ts +++ b/packages/compiler-cli/ngcc/test/host/esm5_host_spec.ts @@ -11,7 +11,7 @@ import * as ts from 'typescript'; import {absoluteFrom, getFileSystem, getSourceFileOrError} from '../../../src/ngtsc/file_system'; import {runInEachFileSystem, TestFile} from '../../../src/ngtsc/file_system/testing'; import {MockLogger} from '../../../src/ngtsc/logging/testing'; -import {ClassMemberKind, ConcreteDeclaration, CtorParameter, Decorator, DownleveledEnum, isNamedClassDeclaration, isNamedFunctionDeclaration, isNamedVariableDeclaration, KnownDeclaration, TypeScriptReflectionHost} from '../../../src/ngtsc/reflection'; +import {ClassMemberKind, ConcreteDeclaration, CtorParameter, Decorator, DownleveledEnum, isNamedClassDeclaration, isNamedFunctionDeclaration, isNamedVariableDeclaration, KnownDeclaration, TypeScriptReflectionHost, TypeValueReferenceKind} from '../../../src/ngtsc/reflection'; import {getDeclaration} from '../../../src/ngtsc/testing'; import {loadFakeCore, loadTestFiles} from '../../../test/helpers'; import {DelegatingReflectionHost} from '../../src/host/delegating_host'; @@ -1670,7 +1670,7 @@ runInEachFileSystem(() => { bundle.program, SOME_DIRECTIVE_FILE.name, 'SomeDirective', isNamedVariableDeclaration); const ctrDecorators = host.getConstructorParameters(classNode)!; const identifierOfViewContainerRef = (ctrDecorators[0].typeValueReference! as { - local: true, + kind: TypeValueReferenceKind.LOCAL, expression: ts.Identifier, defaultImportStatement: null, }).expression; diff --git a/packages/compiler-cli/ngcc/test/host/umd_host_spec.ts b/packages/compiler-cli/ngcc/test/host/umd_host_spec.ts index e4f712f0f6..01a10eb4bb 100644 --- a/packages/compiler-cli/ngcc/test/host/umd_host_spec.ts +++ b/packages/compiler-cli/ngcc/test/host/umd_host_spec.ts @@ -11,7 +11,7 @@ import * as ts from 'typescript'; import {absoluteFrom, getFileSystem, getSourceFileOrError} from '../../../src/ngtsc/file_system'; import {runInEachFileSystem, TestFile} from '../../../src/ngtsc/file_system/testing'; import {MockLogger} from '../../../src/ngtsc/logging/testing'; -import {ClassMemberKind, ConcreteDeclaration, CtorParameter, DownleveledEnum, Import, InlineDeclaration, isNamedClassDeclaration, isNamedFunctionDeclaration, isNamedVariableDeclaration, KnownDeclaration, TypeScriptReflectionHost} from '../../../src/ngtsc/reflection'; +import {ClassMemberKind, ConcreteDeclaration, CtorParameter, DownleveledEnum, Import, InlineDeclaration, isNamedClassDeclaration, isNamedFunctionDeclaration, isNamedVariableDeclaration, KnownDeclaration, TypeScriptReflectionHost, TypeValueReferenceKind} from '../../../src/ngtsc/reflection'; import {getDeclaration} from '../../../src/ngtsc/testing'; import {loadFakeCore, loadTestFiles} from '../../../test/helpers'; import {DelegatingReflectionHost} from '../../src/host/delegating_host'; @@ -1709,7 +1709,7 @@ runInEachFileSystem(() => { bundle.program, SOME_DIRECTIVE_FILE.name, 'SomeDirective', isNamedVariableDeclaration); const ctrDecorators = host.getConstructorParameters(classNode)!; const identifierOfViewContainerRef = (ctrDecorators[0].typeValueReference! as { - local: true, + kind: TypeValueReferenceKind.LOCAL, expression: ts.Identifier, defaultImportStatement: null, }).expression; diff --git a/packages/compiler-cli/ngcc/test/host/util.ts b/packages/compiler-cli/ngcc/test/host/util.ts index 0295155af5..c27594c6cf 100644 --- a/packages/compiler-cli/ngcc/test/host/util.ts +++ b/packages/compiler-cli/ngcc/test/host/util.ts @@ -1,4 +1,3 @@ - /** * @license * Copyright Google LLC All Rights Reserved. @@ -7,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ import * as ts from 'typescript'; -import {CtorParameter} from '../../../src/ngtsc/reflection'; +import {CtorParameter, TypeValueReferenceKind} from '../../../src/ngtsc/reflection'; /** * Check that a given list of `CtorParameter`s has `typeValueReference`s of specific `ts.Identifier` @@ -18,19 +17,21 @@ export function expectTypeValueReferencesForParameters( parameters!.forEach((param, idx) => { const expected = expectedParams[idx]; if (expected !== null) { - if (param.typeValueReference === null) { + if (param.typeValueReference.kind === TypeValueReferenceKind.UNAVAILABLE) { fail(`Incorrect typeValueReference generated, expected ${expected}`); - } else if (param.typeValueReference.local && fromModule !== null) { + } else if ( + param.typeValueReference.kind === TypeValueReferenceKind.LOCAL && fromModule !== null) { fail(`Incorrect typeValueReference generated, expected non-local`); - } else if (!param.typeValueReference.local && fromModule === null) { + } else if ( + param.typeValueReference.kind !== TypeValueReferenceKind.LOCAL && fromModule === null) { fail(`Incorrect typeValueReference generated, expected local`); - } else if (param.typeValueReference.local) { + } else if (param.typeValueReference.kind === TypeValueReferenceKind.LOCAL) { if (!ts.isIdentifier(param.typeValueReference.expression)) { - fail(`Incorrect typeValueReference generated, expected identifer`); + fail(`Incorrect typeValueReference generated, expected identifier`); } else { expect(param.typeValueReference.expression.text).toEqual(expected); } - } else if (param.typeValueReference !== null) { + } else if (param.typeValueReference.kind === TypeValueReferenceKind.IMPORTED) { expect(param.typeValueReference.moduleName).toBe(fromModule!); expect(param.typeValueReference.importedName).toBe(expected); } diff --git a/packages/compiler-cli/src/ngtsc/annotations/src/metadata.ts b/packages/compiler-cli/src/ngtsc/annotations/src/metadata.ts index 3ac72e8d10..af855d3b43 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/src/metadata.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/src/metadata.ts @@ -10,7 +10,7 @@ import {Expression, ExternalExpr, FunctionExpr, Identifiers, InvokeFunctionExpr, import * as ts from 'typescript'; import {DefaultImportRecorder} from '../../imports'; -import {CtorParameter, Decorator, ReflectionHost} from '../../reflection'; +import {CtorParameter, Decorator, ReflectionHost, TypeValueReferenceKind} from '../../reflection'; import {valueReferenceToExpression, wrapFunctionExpressionsInParens} from './util'; @@ -105,7 +105,7 @@ function ctorParameterToMetadata( isCore: boolean): Expression { // Parameters sometimes have a type that can be referenced. If so, then use it, otherwise // its type is undefined. - const type = param.typeValueReference !== null ? + const type = param.typeValueReference.kind !== TypeValueReferenceKind.UNAVAILABLE ? valueReferenceToExpression(param.typeValueReference, defaultImportRecorder) : new LiteralExpr(undefined); diff --git a/packages/compiler-cli/src/ngtsc/annotations/src/util.ts b/packages/compiler-cli/src/ngtsc/annotations/src/util.ts index 53c93fc868..6db327724c 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/src/util.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/src/util.ts @@ -12,13 +12,9 @@ import * as ts from 'typescript'; import {ErrorCode, FatalDiagnosticError, makeDiagnostic, makeRelatedInformation} from '../../diagnostics'; import {DefaultImportRecorder, ImportFlags, Reference, ReferenceEmitter} from '../../imports'; import {ForeignFunctionResolver, PartialEvaluator} from '../../partial_evaluator'; -import {ClassDeclaration, CtorParameter, Decorator, Import, isNamedClassDeclaration, ReflectionHost, TypeValueReference} from '../../reflection'; +import {ClassDeclaration, CtorParameter, Decorator, Import, ImportedTypeValueReference, isNamedClassDeclaration, LocalTypeValueReference, ReflectionHost, TypeValueReference, TypeValueReferenceKind, UnavailableValue, ValueUnavailableKind} from '../../reflection'; import {DeclarationData} from '../../scope'; -export enum ConstructorDepErrorKind { - NO_SUITABLE_TOKEN, -} - export type ConstructorDeps = { deps: R3DependencyMetadata[]; }|{ @@ -29,7 +25,7 @@ export type ConstructorDeps = { export interface ConstructorDepError { index: number; param: CtorParameter; - kind: ConstructorDepErrorKind; + reason: UnavailableValue; } export function getConstructorDependencies( @@ -94,10 +90,14 @@ export function getConstructorDependencies( resolved = R3ResolvedDependencyType.ChangeDetectorRef; } if (token === null) { + if (param.typeValueReference.kind !== TypeValueReferenceKind.UNAVAILABLE) { + throw new Error( + 'Illegal state: expected value reference to be unavailable if no token is present'); + } errors.push({ index: idx, - kind: ConstructorDepErrorKind.NO_SUITABLE_TOKEN, param, + reason: param.typeValueReference.reason, }); } else { deps.push({token, attribute, optional, self, skipSelf, host, resolved}); @@ -118,18 +118,15 @@ export function getConstructorDependencies( * file in which the `TypeValueReference` originated. */ export function valueReferenceToExpression( - valueRef: TypeValueReference, defaultImportRecorder: DefaultImportRecorder): Expression; + valueRef: LocalTypeValueReference|ImportedTypeValueReference, + defaultImportRecorder: DefaultImportRecorder): Expression; export function valueReferenceToExpression( - valueRef: null, defaultImportRecorder: DefaultImportRecorder): null; + valueRef: TypeValueReference, defaultImportRecorder: DefaultImportRecorder): Expression|null; export function valueReferenceToExpression( - valueRef: TypeValueReference|null, defaultImportRecorder: DefaultImportRecorder): Expression| - null; -export function valueReferenceToExpression( - valueRef: TypeValueReference|null, defaultImportRecorder: DefaultImportRecorder): Expression| - null { - if (valueRef === null) { + valueRef: TypeValueReference, defaultImportRecorder: DefaultImportRecorder): Expression|null { + if (valueRef.kind === TypeValueReferenceKind.UNAVAILABLE) { return null; - } else if (valueRef.local) { + } else if (valueRef.kind === TypeValueReferenceKind.LOCAL) { if (defaultImportRecorder !== null && valueRef.defaultImportStatement !== null && ts.isIdentifier(valueRef.expression)) { defaultImportRecorder.recordImportedIdentifier( @@ -137,16 +134,10 @@ export function valueReferenceToExpression( } return new WrappedNodeExpr(valueRef.expression); } else { - // TODO(alxhub): this cast is necessary because the g3 typescript version doesn't narrow here. - const ref = valueRef as { - moduleName: string; - importedName: string; - nestedPath: string[]|null; - }; let importExpr: Expression = - new ExternalExpr({moduleName: ref.moduleName, name: ref.importedName}); - if (ref.nestedPath !== null) { - for (const property of ref.nestedPath) { + new ExternalExpr({moduleName: valueRef.moduleName, name: valueRef.importedName}); + if (valueRef.nestedPath !== null) { + for (const property of valueRef.nestedPath) { importExpr = new ReadPropExpr(importExpr, property); } } @@ -195,17 +186,82 @@ export function validateConstructorDependencies( return deps.deps; } else { // TODO(alxhub): this cast is necessary because the g3 typescript version doesn't narrow here. - const {param, index} = (deps as {errors: ConstructorDepError[]}).errors[0]; // There is at least one error. - throw new FatalDiagnosticError( - ErrorCode.PARAM_MISSING_TOKEN, param.nameNode, - `No suitable injection token for parameter '${param.name || index}' of class '${ - clazz.name.text}'.\n` + - (param.typeNode !== null ? `Found ${param.typeNode.getText()}` : - 'no type or decorator')); + const error = (deps as {errors: ConstructorDepError[]}).errors[0]; + throw createUnsuitableInjectionTokenError(clazz, error); } } +/** + * Creates a fatal error with diagnostic for an invalid injection token. + * @param clazz The class for which the injection token was unavailable. + * @param error The reason why no valid injection token is available. + */ +function createUnsuitableInjectionTokenError( + clazz: ClassDeclaration, error: ConstructorDepError): FatalDiagnosticError { + const {param, index, reason} = error; + let chainMessage: string|undefined = undefined; + let hints: ts.DiagnosticRelatedInformation[]|undefined = undefined; + switch (reason.kind) { + case ValueUnavailableKind.UNSUPPORTED: + chainMessage = 'Consider using the @Inject decorator to specify an injection token.'; + hints = [ + makeRelatedInformation(reason.typeNode, 'This type is not supported as injection token.'), + ]; + break; + case ValueUnavailableKind.NO_VALUE_DECLARATION: + chainMessage = 'Consider using the @Inject decorator to specify an injection token.'; + hints = [ + makeRelatedInformation( + reason.typeNode, + 'This type does not have a value, so it cannot be used as injection token.'), + makeRelatedInformation(reason.decl, 'The type is declared here.'), + ]; + break; + case ValueUnavailableKind.TYPE_ONLY_IMPORT: + chainMessage = + 'Consider changing the type-only import to a regular import, or use the @Inject decorator to specify an injection token.'; + hints = [ + makeRelatedInformation( + reason.typeNode, + 'This type is imported using a type-only import, which prevents it from being usable as an injection token.'), + makeRelatedInformation(reason.importClause, 'The type-only import occurs here.'), + ]; + break; + case ValueUnavailableKind.NAMESPACE: + chainMessage = 'Consider using the @Inject decorator to specify an injection token.'; + hints = [ + makeRelatedInformation( + reason.typeNode, + 'This type corresponds with a namespace, which cannot be used as injection token.'), + makeRelatedInformation(reason.importClause, 'The namespace import occurs here.'), + ]; + break; + case ValueUnavailableKind.UNKNOWN_REFERENCE: + chainMessage = 'The type should reference a known declaration.'; + hints = [makeRelatedInformation(reason.typeNode, 'This type could not be resolved.')]; + break; + case ValueUnavailableKind.MISSING_TYPE: + chainMessage = + 'Consider adding a type to the parameter or use the @Inject decorator to specify an injection token.'; + break; + } + + const chain: ts.DiagnosticMessageChain = { + messageText: `No suitable injection token for parameter '${param.name || index}' of class '${ + clazz.name.text}'.`, + category: ts.DiagnosticCategory.Error, + code: 0, + next: [{ + messageText: chainMessage, + category: ts.DiagnosticCategory.Message, + code: 0, + }], + }; + + return new FatalDiagnosticError(ErrorCode.PARAM_MISSING_TOKEN, param.nameNode, chain, hints); +} + export function toR3Reference( valueRef: Reference, typeRef: Reference, valueContext: ts.SourceFile, typeContext: ts.SourceFile, refEmitter: ReferenceEmitter): R3Reference { diff --git a/packages/compiler-cli/src/ngtsc/reflection/src/host.ts b/packages/compiler-cli/src/ngtsc/reflection/src/host.ts index bb46477213..3d28a12d31 100644 --- a/packages/compiler-cli/src/ngtsc/reflection/src/host.ts +++ b/packages/compiler-cli/src/ngtsc/reflection/src/host.ts @@ -224,25 +224,39 @@ export interface ClassMember { decorators: Decorator[]|null; } +export const enum TypeValueReferenceKind { + LOCAL, + IMPORTED, + UNAVAILABLE, +} + /** - * A reference to a value that originated from a type position. - * - * For example, a constructor parameter could be declared as `foo: Foo`. A `TypeValueReference` - * extracted from this would refer to the value of the class `Foo` (assuming it was actually a - * type). - * - * There are two kinds of such references. A reference with `local: false` refers to a type that was - * imported, and gives the symbol `name` and the `moduleName` of the import. Note that this - * `moduleName` may be a relative path, and thus is likely only valid within the context of the file - * which contained the original type reference. - * - * A reference with `local: true` refers to any other kind of type via a `ts.Expression` that's - * valid within the local file where the type was referenced. + * A type reference that refers to any type via a `ts.Expression` that's valid within the local file + * where the type was referenced. */ -export type TypeValueReference = { - local: true; expression: ts.Expression; defaultImportStatement: ts.ImportDeclaration | null; -}|{ - local: false; +export interface LocalTypeValueReference { + kind: TypeValueReferenceKind.LOCAL; + + /** + * The synthesized expression to reference the type in a value position. + */ + expression: ts.Expression; + + /** + * If the type originates from a default import, the import statement is captured here to be able + * to track its usages, preventing the import from being elided if it was originally only used in + * a type-position. See `DefaultImportTracker` for details. + */ + defaultImportStatement: ts.ImportDeclaration|null; +} + +/** + * A reference that refers to a type that was imported, and gives the symbol `name` and the + * `moduleName` of the import. Note that this `moduleName` may be a relative path, and thus is + * likely only valid within the context of the file which contained the original type reference. + */ +export interface ImportedTypeValueReference { + kind: TypeValueReferenceKind.IMPORTED; /** * The module specifier from which the `importedName` symbol should be imported. @@ -262,7 +276,107 @@ export type TypeValueReference = { nestedPath: string[]|null; valueDeclaration: ts.Declaration; -}; +} + +/** + * A representation for a type value reference that is used when no value is available. This can + * occur due to various reasons, which is indicated in the `reason` field. + */ +export interface UnavailableTypeValueReference { + kind: TypeValueReferenceKind.UNAVAILABLE; + + /** + * The reason why no value reference could be determined for a type. + */ + reason: UnavailableValue; +} + +/** + * The various reasons why the compiler may be unable to synthesize a value from a type reference. + */ +export const enum ValueUnavailableKind { + /** + * No type node was available. + */ + MISSING_TYPE, + + /** + * The type does not have a value declaration, e.g. an interface. + */ + NO_VALUE_DECLARATION, + + /** + * The type is imported using a type-only imports, so it is not suitable to be used in a + * value-position. + */ + TYPE_ONLY_IMPORT, + + /** + * The type reference could not be resolved to a declaration. + */ + UNKNOWN_REFERENCE, + + /** + * The type corresponds with a namespace. + */ + NAMESPACE, + + /** + * The type is not supported in the compiler, for example union types. + */ + UNSUPPORTED, +} + + +export interface UnsupportedType { + kind: ValueUnavailableKind.UNSUPPORTED; + typeNode: ts.TypeNode; +} + +export interface NoValueDeclaration { + kind: ValueUnavailableKind.NO_VALUE_DECLARATION; + typeNode: ts.TypeNode; + decl: ts.Declaration; +} + +export interface TypeOnlyImport { + kind: ValueUnavailableKind.TYPE_ONLY_IMPORT; + typeNode: ts.TypeNode; + importClause: ts.ImportClause; +} + +export interface NamespaceImport { + kind: ValueUnavailableKind.NAMESPACE; + typeNode: ts.TypeNode; + importClause: ts.ImportClause; +} + +export interface UnknownReference { + kind: ValueUnavailableKind.UNKNOWN_REFERENCE; + typeNode: ts.TypeNode; +} + +export interface MissingType { + kind: ValueUnavailableKind.MISSING_TYPE; +} + +/** + * The various reasons why a type node may not be referred to as a value. + */ +export type UnavailableValue = + UnsupportedType|NoValueDeclaration|TypeOnlyImport|NamespaceImport|UnknownReference|MissingType; + +/** + * A reference to a value that originated from a type position. + * + * For example, a constructor parameter could be declared as `foo: Foo`. A `TypeValueReference` + * extracted from this would refer to the value of the class `Foo` (assuming it was actually a + * type). + * + * See the individual types for additional information. + */ +export type TypeValueReference = + LocalTypeValueReference|ImportedTypeValueReference|UnavailableTypeValueReference; /** * A parameter to a constructor. @@ -288,14 +402,10 @@ export interface CtorParameter { * Reference to the value of the parameter's type annotation, if it's possible to refer to the * parameter's type as a value. * - * This can either be a reference to a local value, in which case it has `local` set to `true` and - * contains a `ts.Expression`, or it's a reference to an imported value, in which case `local` is - * set to `false` and the symbol and module name of the imported value are provided instead. - * - * If the type is not present or cannot be represented as an expression, `typeValueReference` is - * `null`. + * This can either be a reference to a local value, a reference to an imported value, or no + * value if no is present or cannot be represented as an expression. */ - typeValueReference: TypeValueReference|null; + typeValueReference: TypeValueReference; /** * TypeScript `ts.TypeNode` representing the type node found in the type position. diff --git a/packages/compiler-cli/src/ngtsc/reflection/src/type_to_value.ts b/packages/compiler-cli/src/ngtsc/reflection/src/type_to_value.ts index 8b71bb3f49..acef675409 100644 --- a/packages/compiler-cli/src/ngtsc/reflection/src/type_to_value.ts +++ b/packages/compiler-cli/src/ngtsc/reflection/src/type_to_value.ts @@ -8,7 +8,7 @@ import * as ts from 'typescript'; -import {TypeValueReference} from './host'; +import {TypeValueReference, TypeValueReferenceKind, UnavailableTypeValueReference, ValueUnavailableKind} from './host'; /** * Potentially convert a `ts.TypeNode` to a `TypeValueReference`, which indicates how to use the @@ -18,22 +18,26 @@ import {TypeValueReference} from './host'; * declaration, or if it is not possible to statically understand. */ export function typeToValue( - typeNode: ts.TypeNode|null, checker: ts.TypeChecker): TypeValueReference|null { + typeNode: ts.TypeNode|null, checker: ts.TypeChecker): TypeValueReference { // It's not possible to get a value expression if the parameter doesn't even have a type. - if (typeNode === null || !ts.isTypeReferenceNode(typeNode)) { - return null; + if (typeNode === null) { + return missingType(); + } + + if (!ts.isTypeReferenceNode(typeNode)) { + return unsupportedType(typeNode); } const symbols = resolveTypeSymbols(typeNode, checker); if (symbols === null) { - return null; + return unknownReference(typeNode); } const {local, decl} = symbols; // It's only valid to convert a type reference to a value reference if the type actually // has a value declaration associated with it. if (decl.valueDeclaration === undefined) { - return null; + return noValueDeclaration(typeNode, decl.declarations[0]); } // The type points to a valid value declaration. Rewrite the TypeReference into an @@ -47,8 +51,13 @@ export function typeToValue( // This is a default import. // import Foo from 'foo'; + if (firstDecl.isTypeOnly) { + // Type-only imports cannot be represented as value. + return typeOnlyImport(typeNode, firstDecl); + } + return { - local: true, + kind: TypeValueReferenceKind.LOCAL, // Copying the name here ensures the generated references will be correctly transformed // along with the import. expression: ts.updateIdentifier(firstDecl.name), @@ -60,6 +69,11 @@ export function typeToValue( // or // import {Foo as Bar} from 'foo'; + if (firstDecl.parent.parent.isTypeOnly) { + // Type-only imports cannot be represented as value. + return typeOnlyImport(typeNode, firstDecl.parent.parent); + } + // Determine the name to import (`Foo`) from the import specifier, as the symbol names of // the imported type could refer to a local alias (like `Bar` in the example above). const importedName = (firstDecl.propertyName || firstDecl.name).text; @@ -70,7 +84,7 @@ export function typeToValue( const moduleName = extractModuleName(firstDecl.parent.parent.parent); return { - local: false, + kind: TypeValueReferenceKind.IMPORTED, valueDeclaration: decl.valueDeclaration, moduleName, importedName, @@ -80,9 +94,14 @@ export function typeToValue( // The import is a namespace import // import * as Foo from 'foo'; + if (firstDecl.parent.isTypeOnly) { + // Type-only imports cannot be represented as value. + return typeOnlyImport(typeNode, firstDecl.parent); + } + if (symbols.symbolNames.length === 1) { // The type refers to the namespace itself, which cannot be represented as a value. - return null; + return namespaceImport(typeNode, firstDecl.parent); } // The first symbol name refers to the local name of the namespace, which is is discarded @@ -92,7 +111,7 @@ export function typeToValue( const moduleName = extractModuleName(firstDecl.parent.parent); return { - local: false, + kind: TypeValueReferenceKind.IMPORTED, valueDeclaration: decl.valueDeclaration, moduleName, importedName, @@ -105,15 +124,60 @@ export function typeToValue( const expression = typeNodeToValueExpr(typeNode); if (expression !== null) { return { - local: true, + kind: TypeValueReferenceKind.LOCAL, expression, defaultImportStatement: null, }; } else { - return null; + return unsupportedType(typeNode); } } +function unsupportedType(typeNode: ts.TypeNode): UnavailableTypeValueReference { + return { + kind: TypeValueReferenceKind.UNAVAILABLE, + reason: {kind: ValueUnavailableKind.UNSUPPORTED, typeNode}, + }; +} + +function noValueDeclaration( + typeNode: ts.TypeNode, decl: ts.Declaration): UnavailableTypeValueReference { + return { + kind: TypeValueReferenceKind.UNAVAILABLE, + reason: {kind: ValueUnavailableKind.NO_VALUE_DECLARATION, typeNode, decl}, + }; +} + +function typeOnlyImport( + typeNode: ts.TypeNode, importClause: ts.ImportClause): UnavailableTypeValueReference { + return { + kind: TypeValueReferenceKind.UNAVAILABLE, + reason: {kind: ValueUnavailableKind.TYPE_ONLY_IMPORT, typeNode, importClause}, + }; +} + +function unknownReference(typeNode: ts.TypeNode): UnavailableTypeValueReference { + return { + kind: TypeValueReferenceKind.UNAVAILABLE, + reason: {kind: ValueUnavailableKind.UNKNOWN_REFERENCE, typeNode}, + }; +} + +function namespaceImport( + typeNode: ts.TypeNode, importClause: ts.ImportClause): UnavailableTypeValueReference { + return { + kind: TypeValueReferenceKind.UNAVAILABLE, + reason: {kind: ValueUnavailableKind.NAMESPACE, typeNode, importClause}, + }; +} + +function missingType(): UnavailableTypeValueReference { + return { + kind: TypeValueReferenceKind.UNAVAILABLE, + reason: {kind: ValueUnavailableKind.MISSING_TYPE}, + }; +} + /** * Attempt to extract a `ts.Expression` that's equivalent to a `ts.TypeNode`, as the two have * different AST shapes but can reference the same symbols. diff --git a/packages/compiler-cli/src/ngtsc/reflection/src/typescript.ts b/packages/compiler-cli/src/ngtsc/reflection/src/typescript.ts index 56b54b24dc..fa8626c78a 100644 --- a/packages/compiler-cli/src/ngtsc/reflection/src/typescript.ts +++ b/packages/compiler-cli/src/ngtsc/reflection/src/typescript.ts @@ -68,8 +68,6 @@ export class TypeScriptReflectionHost implements ReflectionHost { if (childTypeNodes.length === 1) { typeNode = childTypeNodes[0]; - } else { - typeNode = null; } } diff --git a/packages/compiler-cli/src/ngtsc/reflection/test/ts_host_spec.ts b/packages/compiler-cli/src/ngtsc/reflection/test/ts_host_spec.ts index 54ce4125d2..f4cfd540ac 100644 --- a/packages/compiler-cli/src/ngtsc/reflection/test/ts_host_spec.ts +++ b/packages/compiler-cli/src/ngtsc/reflection/test/ts_host_spec.ts @@ -9,7 +9,7 @@ import * as ts from 'typescript'; import {absoluteFrom, getSourceFileOrError} from '../../file_system'; import {runInEachFileSystem} from '../../file_system/testing'; import {getDeclaration, makeProgram} from '../../testing'; -import {ClassMember, ClassMemberKind, CtorParameter} from '../src/host'; +import {ClassMember, ClassMemberKind, CtorParameter, TypeValueReferenceKind} from '../src/host'; import {TypeScriptReflectionHost} from '../src/typescript'; import {isNamedClassDeclaration} from '../src/util'; @@ -178,7 +178,7 @@ runInEachFileSystem(() => { const args = host.getConstructorParameters(clazz)!; expect(args.length).toBe(1); const param = args[0].typeValueReference; - if (param === null || !param.local) { + if (param === null || param.kind !== TypeValueReferenceKind.LOCAL) { return fail('Expected local parameter'); } expect(param).not.toBeNull(); @@ -548,17 +548,20 @@ runInEachFileSystem(() => { if (type === undefined) { expect(param.typeValueReference).toBeNull(); } else { - if (param.typeValueReference === null) { + if (param.typeValueReference.kind === TypeValueReferenceKind.UNAVAILABLE) { return fail(`Expected parameter ${name} to have a typeValueReference`); } - if (param.typeValueReference.local && typeof type === 'string') { + if (param.typeValueReference.kind === TypeValueReferenceKind.LOCAL && + typeof type === 'string') { expect(argExpressionToString(param.typeValueReference.expression)).toEqual(type); - } else if (!param.typeValueReference.local && typeof type !== 'string') { + } else if ( + param.typeValueReference.kind === TypeValueReferenceKind.IMPORTED && + typeof type !== 'string') { expect(param.typeValueReference.moduleName).toEqual(type.moduleName); expect(param.typeValueReference.importedName).toEqual(type.name); } else { return fail(`Mismatch between typeValueReference and expected type: ${param.name} / ${ - param.typeValueReference.local}`); + param.typeValueReference.kind}`); } } if (decorator !== undefined) { diff --git a/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts b/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts index 5ae96b6e5c..58575c1a4e 100644 --- a/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts +++ b/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts @@ -2079,7 +2079,13 @@ runInEachFileSystem(os => { const errors = env.driveDiagnostics(); expect(errors.length).toBe(1); - expect(errors[0].messageText).toContain('No suitable injection token for parameter'); + expect(ts.flattenDiagnosticMessageText(errors[0].messageText, '\n')) + .toBe( + `No suitable injection token for parameter 'notInjectable' of class 'Test'.\n` + + ` Consider using the @Inject decorator to specify an injection token.`); + expect(errors[0].relatedInformation!.length).toBe(1); + expect(errors[0].relatedInformation![0].messageText) + .toBe('This type is not supported as injection token.'); }); it('should give a compile-time error if an invalid @Injectable is used with an argument', @@ -2096,9 +2102,139 @@ runInEachFileSystem(os => { const errors = env.driveDiagnostics(); expect(errors.length).toBe(1); - expect(errors[0].messageText).toContain('No suitable injection token for parameter'); + expect(ts.flattenDiagnosticMessageText(errors[0].messageText, '\n')) + .toBe( + `No suitable injection token for parameter 'notInjectable' of class 'Test'.\n` + + ` Consider using the @Inject decorator to specify an injection token.`); + expect(errors[0].relatedInformation!.length).toBe(1); + expect(errors[0].relatedInformation![0].messageText) + .toBe('This type is not supported as injection token.'); }); + it('should report an error when using a type-only import as injection token', () => { + env.tsconfig({strictInjectionParameters: true}); + env.write(`types.ts`, ` + export class TypeOnly {} + `); + env.write(`test.ts`, ` + import {Injectable} from '@angular/core'; + import type {TypeOnly} from './types'; + + @Injectable() + export class MyService { + constructor(param: TypeOnly) {} + } + `); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(1); + expect(ts.flattenDiagnosticMessageText(diags[0].messageText, '\n')) + .toBe( + `No suitable injection token for parameter 'param' of class 'MyService'.\n` + + ` Consider changing the type-only import to a regular import, ` + + `or use the @Inject decorator to specify an injection token.`); + expect(diags[0].relatedInformation!.length).toBe(2); + expect(diags[0].relatedInformation![0].messageText) + .toBe( + 'This type is imported using a type-only import, ' + + 'which prevents it from being usable as an injection token.'); + expect(diags[0].relatedInformation![1].messageText) + .toBe('The type-only import occurs here.'); + }); + + it('should report an error when using a primitive type as injection token', () => { + env.tsconfig({strictInjectionParameters: true}); + env.write(`test.ts`, ` + import {Injectable} from '@angular/core'; + + @Injectable() + export class MyService { + constructor(param: string) {} + } + `); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(1); + expect(ts.flattenDiagnosticMessageText(diags[0].messageText, '\n')) + .toBe( + `No suitable injection token for parameter 'param' of class 'MyService'.\n` + + ` Consider using the @Inject decorator to specify an injection token.`); + expect(diags[0].relatedInformation!.length).toBe(1); + expect(diags[0].relatedInformation![0].messageText) + .toBe('This type is not supported as injection token.'); + }); + + it('should report an error when using a union type as injection token', () => { + env.tsconfig({strictInjectionParameters: true}); + env.write(`test.ts`, ` + import {Injectable} from '@angular/core'; + + export class ClassA {} + export class ClassB {} + + @Injectable() + export class MyService { + constructor(param: ClassA|ClassB) {} + } + `); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(1); + expect(ts.flattenDiagnosticMessageText(diags[0].messageText, '\n')) + .toBe( + `No suitable injection token for parameter 'param' of class 'MyService'.\n` + + ` Consider using the @Inject decorator to specify an injection token.`); + expect(diags[0].relatedInformation!.length).toBe(1); + expect(diags[0].relatedInformation![0].messageText) + .toBe('This type is not supported as injection token.'); + }); + + it('should report an error when using an interface as injection token', () => { + env.tsconfig({strictInjectionParameters: true}); + env.write(`test.ts`, ` + import {Injectable} from '@angular/core'; + + export interface Interface {} + + @Injectable() + export class MyService { + constructor(param: Interface) {} + } + `); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(1); + expect(ts.flattenDiagnosticMessageText(diags[0].messageText, '\n')) + .toBe( + `No suitable injection token for parameter 'param' of class 'MyService'.\n` + + ` Consider using the @Inject decorator to specify an injection token.`); + expect(diags[0].relatedInformation!.length).toBe(2); + expect(diags[0].relatedInformation![0].messageText) + .toBe('This type does not have a value, so it cannot be used as injection token.'); + expect(diags[0].relatedInformation![1].messageText).toBe('The type is declared here.'); + }); + + it('should report an error when no type is present', () => { + env.tsconfig({strictInjectionParameters: true, noImplicitAny: false}); + env.write(`test.ts`, ` + import {Injectable} from '@angular/core'; + + @Injectable() + export class MyService { + constructor(param) {} + } + `); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(1); + expect(ts.flattenDiagnosticMessageText(diags[0].messageText, '\n')) + .toBe( + `No suitable injection token for parameter 'param' of class 'MyService'.\n` + + ` Consider adding a type to the parameter or ` + + `use the @Inject decorator to specify an injection token.`); + expect(diags[0].relatedInformation).toBeUndefined(); + }); + it('should not give a compile-time error if an invalid @Injectable is used with useValue', () => { env.tsconfig({strictInjectionParameters: true}); @@ -2240,7 +2376,8 @@ runInEachFileSystem(os => { const errors = env.driveDiagnostics(); expect(errors.length).toBe(1); - expect(errors[0].messageText).toContain('No suitable injection token for parameter'); + expect(ts.flattenDiagnosticMessageText(errors[0].messageText, '\n')) + .toContain('No suitable injection token for parameter'); }); }); @@ -4194,9 +4331,16 @@ runInEachFileSystem(os => { const diags = env.driveDiagnostics(); expect(diags.length).toBe(1); - expect(diags[0].messageText) + expect(ts.flattenDiagnosticMessageText(diags[0].messageText, '\n')) .toBe( - `No suitable injection token for parameter 'foo' of class 'MyService'.\nFound Foo`); + `No suitable injection token for parameter 'foo' of class 'MyService'.\n` + + ` Consider using the @Inject decorator to specify an injection token.`); + expect(diags[0].relatedInformation!.length).toBe(2); + expect(diags[0].relatedInformation![0].messageText) + .toBe( + 'This type corresponds with a namespace, which cannot be used as injection token.'); + expect(diags[0].relatedInformation![1].messageText) + .toBe('The namespace import occurs here.'); }); }); @@ -4225,6 +4369,43 @@ runInEachFileSystem(os => { expect(jsContents).toMatch(setClassMetadataRegExp('type: undefined')); }); + it('should use `undefined` in setClassMetadata if types originate from type-only imports', + () => { + env.write(`types.ts`, ` + export default class {} + export class TypeOnly {} + `); + env.write(`test.ts`, ` + import {Component, Inject, Injectable} from '@angular/core'; + import type DefaultImport from './types'; + import type {TypeOnly} from './types'; + import type * as types from './types'; + + @Component({ + selector: 'some-comp', + template: '...', + }) + export class SomeComp { + constructor( + @Inject('token') namedImport: TypeOnly, + @Inject('token') defaultImport: DefaultImport, + @Inject('token') namespacedImport: types.TypeOnly, + ) {} + } + `); + + env.driveMain(); + const jsContents = trim(env.getContents('test.js')); + // Module specifier for type-only import should not be emitted + expect(jsContents).not.toContain('./types'); + // Default type-only import should not be emitted + expect(jsContents).not.toContain('DefaultImport'); + // Named type-only import should not be emitted + expect(jsContents).not.toContain('TypeOnly'); + // The parameter type in class metadata should be undefined + expect(jsContents).toMatch(setClassMetadataRegExp('type: undefined')); + }); + it('should not throw in case whitespaces and HTML comments are present inside ', () => { env.write('test.ts', ` From 80b67e02b7d3f6f31b0042681dfd5a365cd628b3 Mon Sep 17 00:00:00 2001 From: JoostK Date: Sat, 4 Jul 2020 00:16:18 +0200 Subject: [PATCH 014/629] fix(compiler-cli): infer quote expressions as any type in type checker (#37917) "Quote expressions" are expressions that start with an identifier followed by a comma, allowing arbitrary syntax to follow. These kinds of expressions would throw a an error in the template type checker, which would make them hard to track down. As quote expressions are not generally used at all, the error would typically occur for URLs that would inadvertently occur in a binding: ```html ``` This commit lets such bindings be inferred as the `any` type. Fixes #36568 Resolves FW-2051 PR Close #37917 --- packages/compiler-cli/src/ngtsc/typecheck/src/expression.ts | 4 ++-- .../src/ngtsc/typecheck/test/type_check_block_spec.ts | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/expression.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/expression.ts index e068cecb87..81fb098be2 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/expression.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/expression.ts @@ -234,8 +234,8 @@ class AstTranslator implements AstVisitor { return node; } - visitQuote(ast: Quote): never { - throw new Error('Method not implemented.'); + visitQuote(ast: Quote): ts.Expression { + return NULL_AS_ANY; } visitSafeMethodCall(ast: SafeMethodCall): ts.Expression { diff --git a/packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts b/packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts index e9586441c1..febe9c9e98 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts @@ -42,6 +42,11 @@ describe('type check blocks', () => { .toContain('(((ctx).a) ? ((ctx).b) : (((ctx).c) ? ((ctx).d) : ((ctx).e)))'); }); + it('should handle quote expressions as any type', () => { + const TEMPLATE = ``; + expect(tcb(TEMPLATE)).toContain('null as any'); + }); + it('should handle attribute values for directive inputs', () => { const TEMPLATE = `
`; const DIRECTIVES: TestDeclaration[] = [{ From fa0104017a3f0f2d9d3e9bf0180290f589b137e4 Mon Sep 17 00:00:00 2001 From: JoostK Date: Thu, 23 Jul 2020 00:19:38 +0200 Subject: [PATCH 015/629] refactor(compiler-cli): only use type constructors for directives with generic types (#38249) Prior to this change, the template type checker would always use a type-constructor to instantiate a directive. This type-constructor call serves two purposes: 1. Infer any generic types for the directive instance from the inputs that are passed in. 2. Type check the inputs that are passed into the directive's inputs. The first purpose is only relevant when the directive actually has any generic types and using a type-constructor for these cases inhibits a type-check performance penalty, as a type-constructor's signature is quite complex and needs to be generated for each directive. This commit refactors the generated type-check blocks to only generate a type-constructor call for directives that have generic types. Type checking of inputs is achieved by generating individual statements for all inputs, using assignments into the directive's fields. Even if a type-constructor is used for type-inference of generic types will the input checking also be achieved using the individual assignment statements. This is done to support the rework of the language service, which will start to extract symbol information from the type-check blocks. As a future optimization, it may be possible to reduce the number of inputs passed into a type-constructor to only those inputs that contribute the the type-inference of the generics. As this is not a necessity at the moment this is left as follow-up work. Closes #38185 PR Close #38249 --- .../src/ngtsc/annotations/src/component.ts | 8 +- .../src/ngtsc/annotations/src/directive.ts | 10 +- .../compiler-cli/src/ngtsc/metadata/index.ts | 2 +- .../src/ngtsc/metadata/src/api.ts | 44 ++- .../src/ngtsc/metadata/src/dts.ts | 7 +- .../src/ngtsc/metadata/src/inheritance.ts | 10 + .../src/ngtsc/metadata/src/util.ts | 54 ++- .../src/ngtsc/scope/test/local_spec.ts | 3 + .../src/ngtsc/typecheck/api/api.ts | 7 +- .../src/ngtsc/typecheck/src/context.ts | 4 +- .../src/ngtsc/typecheck/src/ts_util.ts | 15 + .../ngtsc/typecheck/src/type_check_block.ts | 349 +++++++++++++----- .../ngtsc/typecheck/src/type_constructor.ts | 5 +- .../ngtsc/typecheck/test/diagnostics_spec.ts | 3 +- .../src/ngtsc/typecheck/test/test_utils.ts | 17 +- .../typecheck/test/type_check_block_spec.ts | 337 +++++++++++++++-- .../ngtsc/typecheck/test/type_checker_spec.ts | 8 +- .../test/ngtsc/template_typecheck_spec.ts | 250 ++++++++++++- 18 files changed, 952 insertions(+), 181 deletions(-) diff --git a/packages/compiler-cli/src/ngtsc/annotations/src/component.ts b/packages/compiler-cli/src/ngtsc/annotations/src/component.ts index c2b5559f6a..5ea6aec532 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/src/component.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/src/component.ts @@ -15,7 +15,7 @@ import {absoluteFrom, relative} from '../../file_system'; import {DefaultImportRecorder, ModuleResolver, Reference, ReferenceEmitter} from '../../imports'; import {DependencyTracker} from '../../incremental/api'; import {IndexingContext} from '../../indexer'; -import {DirectiveMeta, extractDirectiveGuards, InjectableClassRegistry, MetadataReader, MetadataRegistry} from '../../metadata'; +import {DirectiveMeta, DirectiveTypeCheckMeta, extractDirectiveTypeCheckMeta, InjectableClassRegistry, MetadataReader, MetadataRegistry} from '../../metadata'; import {flattenInheritedDirectiveMetadata} from '../../metadata/src/inheritance'; import {EnumValue, PartialEvaluator} from '../../partial_evaluator'; import {ClassDeclaration, Decorator, ReflectionHost, reflectObjectLiteral} from '../../reflection'; @@ -51,7 +51,7 @@ export interface ComponentAnalysisData { */ meta: Omit; baseClass: Reference|'dynamic'|null; - guards: ReturnType; + typeCheckMeta: DirectiveTypeCheckMeta; template: ParsedTemplateWithSource; metadataStmt: Statement|null; @@ -327,7 +327,7 @@ export class ComponentDecoratorHandler implements i18nUseExternalIds: this.i18nUseExternalIds, relativeContextFilePath, }, - guards: extractDirectiveGuards(node, this.reflector), + typeCheckMeta: extractDirectiveTypeCheckMeta(node, metadata.inputs, this.reflector), metadataStmt: generateSetClassMetadataCall( node, this.reflector, this.defaultImportRecorder, this.isCore, this.annotateForClosureCompiler), @@ -356,7 +356,7 @@ export class ComponentDecoratorHandler implements queries: analysis.meta.queries.map(query => query.propertyName), isComponent: true, baseClass: analysis.baseClass, - ...analysis.guards, + ...analysis.typeCheckMeta, }); this.injectableRegistry.registerInjectable(node); diff --git a/packages/compiler-cli/src/ngtsc/annotations/src/directive.ts b/packages/compiler-cli/src/ngtsc/annotations/src/directive.ts index 04c6f3f2c4..6bdd3398a7 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/src/directive.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/src/directive.ts @@ -11,8 +11,8 @@ import * as ts from 'typescript'; import {ErrorCode, FatalDiagnosticError} from '../../diagnostics'; import {DefaultImportRecorder, Reference} from '../../imports'; -import {InjectableClassRegistry, MetadataReader, MetadataRegistry} from '../../metadata'; -import {extractDirectiveGuards} from '../../metadata/src/util'; +import {DirectiveTypeCheckMeta, InjectableClassRegistry, MetadataReader, MetadataRegistry} from '../../metadata'; +import {extractDirectiveTypeCheckMeta} from '../../metadata/src/util'; import {DynamicValue, EnumValue, PartialEvaluator} from '../../partial_evaluator'; import {ClassDeclaration, ClassMember, ClassMemberKind, Decorator, filterToMembersWithDecorator, ReflectionHost, reflectObjectLiteral} from '../../reflection'; import {LocalModuleScopeRegistry} from '../../scope'; @@ -35,7 +35,7 @@ const LIFECYCLE_HOOKS = new Set([ export interface DirectiveHandlerData { baseClass: Reference|'dynamic'|null; - guards: ReturnType; + typeCheckMeta: DirectiveTypeCheckMeta; meta: R3DirectiveMetadata; metadataStmt: Statement|null; providersRequiringFactory: Set>|null; @@ -102,7 +102,7 @@ export class DirectiveDecoratorHandler implements node, this.reflector, this.defaultImportRecorder, this.isCore, this.annotateForClosureCompiler), baseClass: readBaseClass(node, this.reflector, this.evaluator), - guards: extractDirectiveGuards(node, this.reflector), + typeCheckMeta: extractDirectiveTypeCheckMeta(node, analysis.inputs, this.reflector), providersRequiringFactory } }; @@ -122,7 +122,7 @@ export class DirectiveDecoratorHandler implements queries: analysis.meta.queries.map(query => query.propertyName), isComponent: false, baseClass: analysis.baseClass, - ...analysis.guards, + ...analysis.typeCheckMeta, }); this.injectableRegistry.registerInjectable(node); diff --git a/packages/compiler-cli/src/ngtsc/metadata/index.ts b/packages/compiler-cli/src/ngtsc/metadata/index.ts index 9061d03146..ee3f910b1b 100644 --- a/packages/compiler-cli/src/ngtsc/metadata/index.ts +++ b/packages/compiler-cli/src/ngtsc/metadata/index.ts @@ -9,4 +9,4 @@ export * from './src/api'; export {DtsMetadataReader} from './src/dts'; export {CompoundMetadataRegistry, LocalMetadataRegistry, InjectableClassRegistry} from './src/registry'; -export {extractDirectiveGuards, CompoundMetadataReader} from './src/util'; +export {extractDirectiveTypeCheckMeta, CompoundMetadataReader} from './src/util'; diff --git a/packages/compiler-cli/src/ngtsc/metadata/src/api.ts b/packages/compiler-cli/src/ngtsc/metadata/src/api.ts index 45fac785ac..a9d58de7c7 100644 --- a/packages/compiler-cli/src/ngtsc/metadata/src/api.ts +++ b/packages/compiler-cli/src/ngtsc/metadata/src/api.ts @@ -32,19 +32,55 @@ export interface NgModuleMeta { rawDeclarations: ts.Expression|null; } +/** + * Typing metadata collected for a directive within an NgModule's scope. + */ +export interface DirectiveTypeCheckMeta { + /** + * List of static `ngTemplateGuard_xx` members found on the Directive's class. + * @see `TemplateGuardMeta` + */ + ngTemplateGuards: TemplateGuardMeta[]; + + /** + * Whether the Directive's class has a static ngTemplateContextGuard function. + */ + hasNgTemplateContextGuard: boolean; + + /** + * The set of input fields which have a corresponding static `ngAcceptInputType_` on the + * Directive's class. This allows inputs to accept a wider range of types and coerce the input to + * a narrower type with a getter/setter. See https://angular.io/guide/template-typecheck. + */ + coercedInputFields: Set; + + /** + * The set of input fields which map to `readonly`, `private`, or `protected` members in the + * Directive's class. + */ + restrictedInputFields: Set; + + /** + * The set of input fields which do not have corresponding members in the Directive's class. + */ + undeclaredInputFields: Set; + + /** + * Whether the Directive's class is generic, i.e. `class MyDir {...}`. + */ + isGeneric: boolean; +} + /** * Metadata collected for a directive within an NgModule's scope. */ -export interface DirectiveMeta extends T2DirectiveMeta { +export interface DirectiveMeta extends T2DirectiveMeta, DirectiveTypeCheckMeta { ref: Reference; /** * Unparsed selector of the directive, or null if the directive does not have a selector. */ selector: string|null; queries: string[]; - ngTemplateGuards: TemplateGuardMeta[]; - hasNgTemplateContextGuard: boolean; - coercedInputFields: Set; /** * A `Reference` to the base class for the directive, if one was detected. diff --git a/packages/compiler-cli/src/ngtsc/metadata/src/dts.ts b/packages/compiler-cli/src/ngtsc/metadata/src/dts.ts index c8f2c8b2b1..faf588ab67 100644 --- a/packages/compiler-cli/src/ngtsc/metadata/src/dts.ts +++ b/packages/compiler-cli/src/ngtsc/metadata/src/dts.ts @@ -12,7 +12,7 @@ import {Reference} from '../../imports'; import {ClassDeclaration, isNamedClassDeclaration, ReflectionHost} from '../../reflection'; import {DirectiveMeta, MetadataReader, NgModuleMeta, PipeMeta} from './api'; -import {extractDirectiveGuards, extractReferencesFromType, readStringArrayType, readStringMapType, readStringType} from './util'; +import {extractDirectiveTypeCheckMeta, extractReferencesFromType, readStringArrayType, readStringMapType, readStringType} from './util'; /** * A `MetadataReader` that can read metadata from `.d.ts` files, which have static Ivy properties @@ -76,16 +76,17 @@ export class DtsMetadataReader implements MetadataReader { return null; } + const inputs = readStringMapType(def.type.typeArguments[3]); return { ref, name: clazz.name.text, isComponent: def.name === 'ɵcmp', selector: readStringType(def.type.typeArguments[1]), exportAs: readStringArrayType(def.type.typeArguments[2]), - inputs: readStringMapType(def.type.typeArguments[3]), + inputs, outputs: readStringMapType(def.type.typeArguments[4]), queries: readStringArrayType(def.type.typeArguments[5]), - ...extractDirectiveGuards(clazz, this.reflector), + ...extractDirectiveTypeCheckMeta(clazz, inputs, this.reflector), baseClass: readBaseClass(clazz, this.checker, this.reflector), }; } diff --git a/packages/compiler-cli/src/ngtsc/metadata/src/inheritance.ts b/packages/compiler-cli/src/ngtsc/metadata/src/inheritance.ts index 119a7a0e39..ba775783e1 100644 --- a/packages/compiler-cli/src/ngtsc/metadata/src/inheritance.ts +++ b/packages/compiler-cli/src/ngtsc/metadata/src/inheritance.ts @@ -28,6 +28,8 @@ export function flattenInheritedDirectiveMetadata( let inputs: {[key: string]: string|[string, string]} = {}; let outputs: {[key: string]: string} = {}; let coercedInputFields = new Set(); + let undeclaredInputFields = new Set(); + let restrictedInputFields = new Set(); let isDynamic = false; const addMetadata = (meta: DirectiveMeta): void => { @@ -48,6 +50,12 @@ export function flattenInheritedDirectiveMetadata( for (const coercedInputField of meta.coercedInputFields) { coercedInputFields.add(coercedInputField); } + for (const undeclaredInputField of meta.undeclaredInputFields) { + undeclaredInputFields.add(undeclaredInputField); + } + for (const restrictedInputField of meta.restrictedInputFields) { + restrictedInputFields.add(restrictedInputField); + } }; addMetadata(topMeta); @@ -57,6 +65,8 @@ export function flattenInheritedDirectiveMetadata( inputs, outputs, coercedInputFields, + undeclaredInputFields, + restrictedInputFields, baseClass: isDynamic ? 'dynamic' : null, }; } diff --git a/packages/compiler-cli/src/ngtsc/metadata/src/util.ts b/packages/compiler-cli/src/ngtsc/metadata/src/util.ts index 457c8cab2c..4cf0e5eabd 100644 --- a/packages/compiler-cli/src/ngtsc/metadata/src/util.ts +++ b/packages/compiler-cli/src/ngtsc/metadata/src/util.ts @@ -12,7 +12,7 @@ import {Reference} from '../../imports'; import {ClassDeclaration, ClassMember, ClassMemberKind, isNamedClassDeclaration, ReflectionHost, reflectTypeEntityToDeclaration} from '../../reflection'; import {nodeDebugInfo} from '../../util/src/typescript'; -import {DirectiveMeta, MetadataReader, NgModuleMeta, PipeMeta, TemplateGuardMeta} from './api'; +import {DirectiveMeta, DirectiveTypeCheckMeta, MetadataReader, NgModuleMeta, PipeMeta, TemplateGuardMeta} from './api'; export function extractReferencesFromType( checker: ts.TypeChecker, def: ts.TypeNode, ngModuleImportedFrom: string|null, @@ -78,13 +78,16 @@ export function readStringArrayType(type: ts.TypeNode): string[] { return res; } - -export function extractDirectiveGuards(node: ClassDeclaration, reflector: ReflectionHost): { - ngTemplateGuards: TemplateGuardMeta[], - hasNgTemplateContextGuard: boolean, - coercedInputFields: Set, -} { - const staticMembers = reflector.getMembersOfClass(node).filter(member => member.isStatic); +/** + * Inspects the class' members and extracts the metadata that is used when type-checking templates + * that use the directive. This metadata does not contain information from a base class, if any, + * making this metadata invariant to changes of inherited classes. + */ +export function extractDirectiveTypeCheckMeta( + node: ClassDeclaration, inputs: {[fieldName: string]: string|[string, string]}, + reflector: ReflectionHost): DirectiveTypeCheckMeta { + const members = reflector.getMembersOfClass(node); + const staticMembers = members.filter(member => member.isStatic); const ngTemplateGuards = staticMembers.map(extractTemplateGuard) .filter((guard): guard is TemplateGuardMeta => guard !== null); const hasNgTemplateContextGuard = staticMembers.some( @@ -93,7 +96,40 @@ export function extractDirectiveGuards(node: ClassDeclaration, reflector: Reflec const coercedInputFields = new Set(staticMembers.map(extractCoercedInput) .filter((inputName): inputName is string => inputName !== null)); - return {hasNgTemplateContextGuard, ngTemplateGuards, coercedInputFields}; + + const restrictedInputFields = new Set(); + const undeclaredInputFields = new Set(); + + for (const fieldName of Object.keys(inputs)) { + const field = members.find(member => member.name === fieldName); + if (field === undefined || field.node === null) { + undeclaredInputFields.add(fieldName); + } else if (isRestricted(field.node)) { + restrictedInputFields.add(fieldName); + } + } + + const arity = reflector.getGenericArityOfClass(node); + + return { + hasNgTemplateContextGuard, + ngTemplateGuards, + coercedInputFields, + restrictedInputFields, + undeclaredInputFields, + isGeneric: arity !== null && arity > 0, + }; +} + +function isRestricted(node: ts.Node): boolean { + if (node.modifiers === undefined) { + return false; + } + + return node.modifiers.some( + modifier => modifier.kind === ts.SyntaxKind.PrivateKeyword || + modifier.kind === ts.SyntaxKind.ProtectedKeyword || + modifier.kind === ts.SyntaxKind.ReadonlyKeyword); } function extractTemplateGuard(member: ClassMember): TemplateGuardMeta|null { diff --git a/packages/compiler-cli/src/ngtsc/scope/test/local_spec.ts b/packages/compiler-cli/src/ngtsc/scope/test/local_spec.ts index 7e98f45aa9..60302988a1 100644 --- a/packages/compiler-cli/src/ngtsc/scope/test/local_spec.ts +++ b/packages/compiler-cli/src/ngtsc/scope/test/local_spec.ts @@ -243,6 +243,9 @@ function fakeDirective(ref: Reference): DirectiveMeta { hasNgTemplateContextGuard: false, ngTemplateGuards: [], coercedInputFields: new Set(), + restrictedInputFields: new Set(), + undeclaredInputFields: new Set(), + isGeneric: false, baseClass: null, }; } diff --git a/packages/compiler-cli/src/ngtsc/typecheck/api/api.ts b/packages/compiler-cli/src/ngtsc/typecheck/api/api.ts index b7db3f0f6d..f0e241144a 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/api/api.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/api/api.ts @@ -11,7 +11,7 @@ import * as ts from 'typescript'; import {AbsoluteFsPath} from '../../file_system'; import {Reference} from '../../imports'; -import {TemplateGuardMeta} from '../../metadata'; +import {DirectiveTypeCheckMeta} from '../../metadata'; import {ClassDeclaration} from '../../reflection'; @@ -19,12 +19,9 @@ import {ClassDeclaration} from '../../reflection'; * Extension of `DirectiveMeta` that includes additional information required to type-check the * usage of a particular directive. */ -export interface TypeCheckableDirectiveMeta extends DirectiveMeta { +export interface TypeCheckableDirectiveMeta extends DirectiveMeta, DirectiveTypeCheckMeta { ref: Reference; queries: string[]; - ngTemplateGuards: TemplateGuardMeta[]; - coercedInputFields: Set; - hasNgTemplateContextGuard: boolean; } export type TemplateId = string&{__brand: 'TemplateId'}; diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/context.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/context.ts index 9bd6897fde..748ee71d74 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/context.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/context.ts @@ -199,12 +199,12 @@ export class TypeCheckContextImpl implements TypeCheckContext { for (const dir of boundTarget.getUsedDirectives()) { const dirRef = dir.ref as Reference>; const dirNode = dirRef.node; - if (requiresInlineTypeCtor(dirNode, this.reflector)) { + + if (dir.isGeneric && requiresInlineTypeCtor(dirNode, this.reflector)) { if (this.inlining === InliningMode.Error) { missingInlines.push(dirNode); continue; } - // Add a type constructor operation for the directive. this.addInlineTypeCtor(fileData, dirNode.getSourceFile(), dirRef, { fnName: 'ngTypeCtor', diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/ts_util.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/ts_util.ts index 7c58444851..96ff8178a4 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/ts_util.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/ts_util.ts @@ -86,6 +86,21 @@ export function tsDeclareVariable(id: ts.Identifier, type: ts.TypeNode): ts.Vari /* declarationList */[decl]); } +/** + * Creates a `ts.TypeQueryNode` for a coerced input. + * + * For example: `typeof MatInput.ngAcceptInputType_value`, where MatInput is `typeName` and `value` + * is the `coercedInputName`. + * + * @param typeName The `EntityName` of the Directive where the static coerced input is defined. + * @param coercedInputName The field name of the coerced input. + */ +export function tsCreateTypeQueryForCoercedInput( + typeName: ts.EntityName, coercedInputName: string): ts.TypeQueryNode { + return ts.createTypeQueryNode( + ts.createQualifiedName(typeName, `ngAcceptInputType_${coercedInputName}`)); +} + /** * Create a `ts.VariableStatement` that initializes a variable with a given expression. * diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts index 014ff17e02..d2d56de68c 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts @@ -19,7 +19,7 @@ import {Environment} from './environment'; import {astToTypescript, NULL_AS_ANY} from './expression'; import {OutOfBandDiagnosticRecorder} from './oob'; import {ExpressionSemanticVisitor} from './template_semantics'; -import {checkIfClassIsExported, checkIfGenericTypesAreUnbound, tsCallMethod, tsCastToAny, tsCreateElement, tsCreateVariable, tsDeclareVariable} from './ts_util'; +import {checkIfClassIsExported, checkIfGenericTypesAreUnbound, tsCallMethod, tsCastToAny, tsCreateElement, tsCreateTypeQueryForCoercedInput, tsCreateVariable, tsDeclareVariable} from './ts_util'; @@ -197,6 +197,7 @@ class TcbTemplateBodyOp extends TcbOp { constructor(private tcb: Context, private scope: Scope, private template: TmplAstTemplate) { super(); } + execute(): null { // An `if` will be constructed, within which the template's children will be type checked. The // `if` is used for two reasons: it creates a new syntactic scope, isolating variables declared @@ -308,13 +309,15 @@ class TcbTextInterpolationOp extends TcbOp { } /** - * A `TcbOp` which constructs an instance of a directive with types inferred from its inputs, which - * also checks the bindings to the directive in the process. + * A `TcbOp` which constructs an instance of a directive _without_ setting any of its inputs. Inputs + * are later set in the `TcbDirectiveInputsOp`. Type checking was found to be faster when done in + * this way as opposed to `TcbDirectiveCtorOp` which is only necessary when the directive is + * generic. * * Executing this operation returns a reference to the directive instance variable with its inferred * type. */ -class TcbDirectiveOp extends TcbOp { +class TcbDirectiveTypeOp extends TcbOp { constructor( private tcb: Context, private scope: Scope, private node: TmplAstTemplate|TmplAstElement, private dir: TypeCheckableDirectiveMeta) { @@ -323,37 +326,190 @@ class TcbDirectiveOp extends TcbOp { execute(): ts.Identifier { const id = this.tcb.allocateId(); - // Process the directive and construct expressions for each of its bindings. - const inputs = tcbGetDirectiveInputs(this.node, this.dir, this.tcb, this.scope); + + const type = this.tcb.env.referenceType(this.dir.ref); + this.scope.addStatement(tsDeclareVariable(id, type)); + return id; + } +} + +/** + * A `TcbOp` which constructs an instance of a directive with types inferred from its inputs. The + * inputs themselves are not checked here; checking of inputs is achieved in `TcbDirectiveInputsOp`. + * Any errors reported in this statement are ignored, as the type constructor call is only present + * for type-inference. + * + * When a Directive is generic, it is required that the TCB generates the instance using this method + * in order to infer the type information correctly. + * + * Executing this operation returns a reference to the directive instance variable with its inferred + * type. + */ +class TcbDirectiveCtorOp extends TcbOp { + constructor( + private tcb: Context, private scope: Scope, private node: TmplAstTemplate|TmplAstElement, + private dir: TypeCheckableDirectiveMeta) { + super(); + } + + execute(): ts.Identifier { + const id = this.tcb.allocateId(); + + const genericInputs = new Map(); + + const inputs = getBoundInputs(this.dir, this.node, this.tcb); + for (const input of inputs) { + for (const fieldName of input.fieldNames) { + // Skip the field if an attribute has already been bound to it; we can't have a duplicate + // key in the type constructor call. + if (genericInputs.has(fieldName)) { + continue; + } + + const expression = translateInput(input.attribute, this.tcb, this.scope); + genericInputs.set(fieldName, { + type: 'binding', + field: fieldName, + expression, + sourceSpan: input.attribute.sourceSpan + }); + } + } + + // Add unset directive inputs for each of the remaining unset fields. + for (const fieldName of Object.keys(this.dir.inputs)) { + if (!genericInputs.has(fieldName)) { + genericInputs.set(fieldName, {type: 'unset', field: fieldName}); + } + } // Call the type constructor of the directive to infer a type, and assign the directive // instance. - const typeCtor = tcbCallTypeCtor(this.dir, this.tcb, inputs); - addParseSpanInfo(typeCtor, this.node.sourceSpan); + const typeCtor = tcbCallTypeCtor(this.dir, this.tcb, Array.from(genericInputs.values())); + ignoreDiagnostics(typeCtor); this.scope.addStatement(tsCreateVariable(id, typeCtor)); return id; } circularFallback(): TcbOp { - return new TcbDirectiveCircularFallbackOp(this.tcb, this.scope, this.node, this.dir); + return new TcbDirectiveCtorCircularFallbackOp(this.tcb, this.scope, this.node, this.dir); + } +} + +/** + * A `TcbOp` which generates code to check input bindings on an element that correspond with the + * members of a directive. + * + * Executing this operation returns nothing. + */ +class TcbDirectiveInputsOp extends TcbOp { + constructor( + private tcb: Context, private scope: Scope, private node: TmplAstTemplate|TmplAstElement, + private dir: TypeCheckableDirectiveMeta) { + super(); + } + + execute(): null { + const dirId = this.scope.resolve(this.node, this.dir); + + // TODO(joost): report duplicate properties + + const inputs = getBoundInputs(this.dir, this.node, this.tcb); + for (const input of inputs) { + // For bound inputs, the property is assigned the binding expression. + let expr = translateInput(input.attribute, this.tcb, this.scope); + if (!this.tcb.env.config.checkTypeOfInputBindings) { + // If checking the type of bindings is disabled, cast the resulting expression to 'any' + // before the assignment. + expr = tsCastToAny(expr); + } else if (!this.tcb.env.config.strictNullInputBindings) { + // If strict null checks are disabled, erase `null` and `undefined` from the type by + // wrapping the expression in a non-null assertion. + expr = ts.createNonNullExpression(expr); + } + + let assignment: ts.Expression = wrapForDiagnostics(expr); + + for (const fieldName of input.fieldNames) { + let target: ts.LeftHandSideExpression; + if (this.dir.coercedInputFields.has(fieldName)) { + // The input has a coercion declaration which should be used instead of assigning the + // expression into the input field directly. To achieve this, a variable is declared + // with a type of `typeof Directive.ngAcceptInputType_fieldName` which is then used as + // target of the assignment. + const dirTypeRef = this.tcb.env.referenceType(this.dir.ref); + if (!ts.isTypeReferenceNode(dirTypeRef)) { + throw new Error( + `Expected TypeReferenceNode from reference to ${this.dir.ref.debugName}`); + } + + const id = this.tcb.allocateId(); + const type = tsCreateTypeQueryForCoercedInput(dirTypeRef.typeName, fieldName); + this.scope.addStatement(tsDeclareVariable(id, type)); + + target = id; + } else if (this.dir.undeclaredInputFields.has(fieldName)) { + // If no coercion declaration is present nor is the field declared (i.e. the input is + // declared in a `@Directive` or `@Component` decorator's `inputs` property) there is no + // assignment target available, so this field is skipped. + continue; + } else if (this.dir.restrictedInputFields.has(fieldName)) { + // To ignore errors, assign to temp variable with type of the field + const id = this.tcb.allocateId(); + const dirTypeRef = this.tcb.env.referenceType(this.dir.ref); + if (!ts.isTypeReferenceNode(dirTypeRef)) { + throw new Error( + `Expected TypeReferenceNode from reference to ${this.dir.ref.debugName}`); + } + const type = ts.createIndexedAccessTypeNode( + ts.createTypeQueryNode(dirId as ts.Identifier), + ts.createLiteralTypeNode(ts.createStringLiteral(fieldName))); + const temp = tsCreateVariable(id, ts.createNonNullExpression(ts.createNull()), type); + addParseSpanInfo(temp, input.attribute.sourceSpan); + this.scope.addStatement(temp); + target = id; + + // TODO: To get errors assign directly to the fields on the instance, using dot access + // when possible + + } else { + // Otherwise, a declaration exists in which case the `dir["fieldName"]` syntax is used + // as assignment target. An element access is used instead of a property access to + // support input names that are not valid JavaScript identifiers. Additionally, using + // element access syntax does not produce + // TS2341 "Property $prop is private and only accessible within class $class." nor + // TS2445 "Property $prop is protected and only accessible within class $class and its + // subclasses." + target = ts.createElementAccess(dirId, ts.createStringLiteral(fieldName)); + } + + // Finally the assignment is extended by assigning it into the target expression. + assignment = ts.createBinary(target, ts.SyntaxKind.EqualsToken, assignment); + } + + addParseSpanInfo(assignment, input.attribute.sourceSpan); + this.scope.addStatement(ts.createExpressionStatement(assignment)); + } + + return null; } } /** * A `TcbOp` which is used to generate a fallback expression if the inference of a directive type - * via `TcbDirectiveOp` requires a reference to its own type. This can happen using a template + * via `TcbDirectiveCtorOp` requires a reference to its own type. This can happen using a template * reference: * * ```html * * ``` * - * In this case, `TcbDirectiveCircularFallbackOp` will add a second inference of the directive type - * to the type-check block, this time calling the directive's type constructor without any input - * expressions. This infers the widest possible supertype for the directive, which is used to + * In this case, `TcbDirectiveCtorCircularFallbackOp` will add a second inference of the directive + * type to the type-check block, this time calling the directive's type constructor without any + * input expressions. This infers the widest possible supertype for the directive, which is used to * resolve any recursive references required to infer the real type. */ -class TcbDirectiveCircularFallbackOp extends TcbOp { +class TcbDirectiveCtorCircularFallbackOp extends TcbOp { constructor( private tcb: Context, private scope: Scope, private node: TmplAstTemplate|TmplAstElement, private dir: TypeCheckableDirectiveMeta) { @@ -694,8 +850,8 @@ class Scope { */ private elementOpMap = new Map(); /** - * A map of maps which tracks the index of `TcbDirectiveOp`s in the `opQueue` for each directive - * on a `TmplAstElement` or `TmplAstTemplate` node. + * A map of maps which tracks the index of `TcbDirectiveCtorOp`s in the `opQueue` for each + * directive on a `TmplAstElement` or `TmplAstTemplate` node. */ private directiveOpMap = new Map>(); @@ -957,8 +1113,12 @@ class Scope { const dirMap = new Map(); for (const dir of directives) { - const dirIndex = this.opQueue.push(new TcbDirectiveOp(this.tcb, this, node, dir)) - 1; + const directiveOp = dir.isGeneric ? new TcbDirectiveCtorOp(this.tcb, this, node, dir) : + new TcbDirectiveTypeOp(this.tcb, this, node, dir); + const dirIndex = this.opQueue.push(directiveOp) - 1; dirMap.set(dir, dirIndex); + + this.opQueue.push(new TcbDirectiveInputsOp(this.tcb, this, node, dir)); } this.directiveOpMap.set(node, dirMap); @@ -1016,6 +1176,11 @@ class Scope { } } +interface TcbBoundInput { + attribute: TmplAstBoundAttribute|TmplAstTextAttribute; + fieldNames: string[]; +} + /** * Create the `ctx` parameter to the top-level TCB function. * @@ -1269,53 +1434,13 @@ function tcbCallTypeCtor( /* argumentsArray */[ts.createObjectLiteral(members)]); } -type TcbDirectiveInput = { - type: 'binding'; field: string; expression: ts.Expression; sourceSpan: ParseSourceSpan; -}|{ - type: 'unset'; - field: string; -}; +function getBoundInputs( + directive: TypeCheckableDirectiveMeta, node: TmplAstTemplate|TmplAstElement, + tcb: Context): TcbBoundInput[] { + const boundInputs: TcbBoundInput[] = []; -function tcbGetDirectiveInputs( - el: TmplAstElement|TmplAstTemplate, dir: TypeCheckableDirectiveMeta, tcb: Context, - scope: Scope): TcbDirectiveInput[] { - // Only the first binding to a property is written. - // TODO(alxhub): produce an error for duplicate bindings to the same property, independently of - // this logic. - const directiveInputs = new Map(); - // `dir.inputs` is an object map of field names on the directive class to property names. - // This is backwards from what's needed to match bindings - a map of properties to field names - // is desired. Invert `dir.inputs` into `propMatch` to create this map. - const propMatch = new Map(); - const inputs = dir.inputs; - Object.keys(inputs).forEach(key => { - Array.isArray(inputs[key]) ? propMatch.set(inputs[key][0], key) : - propMatch.set(inputs[key] as string, key); - }); - - el.inputs.forEach(processAttribute); - el.attributes.forEach(processAttribute); - if (el instanceof TmplAstTemplate) { - el.templateAttrs.forEach(processAttribute); - } - - // Add unset directive inputs for each of the remaining unset fields. - // Note: it's actually important here that `propMatch.values()` isn't used, as there can be - // multiple fields which share the same property name and only one of them will be listed as a - // value in `propMatch`. - for (const field of Object.keys(inputs)) { - if (!directiveInputs.has(field)) { - directiveInputs.set(field, {type: 'unset', field}); - } - } - - return Array.from(directiveInputs.values()); - - /** - * Add a binding expression to the map for each input/template attribute of the directive that has - * a matching binding. - */ - function processAttribute(attr: TmplAstBoundAttribute|TmplAstTextAttribute): void { + const propertyToFieldNames = invertInputs(directive.inputs); + const processAttribute = (attr: TmplAstBoundAttribute|TmplAstTextAttribute) => { // Skip non-property bindings. if (attr instanceof TmplAstBoundAttribute && attr.type !== BindingType.Property) { return; @@ -1327,34 +1452,92 @@ function tcbGetDirectiveInputs( } // Skip the attribute if the directive does not have an input for it. - if (!propMatch.has(attr.name)) { + if (!propertyToFieldNames.has(attr.name)) { return; } - const field = propMatch.get(attr.name)!; + const fieldNames = propertyToFieldNames.get(attr.name)!; + boundInputs.push({attribute: attr, fieldNames}); + }; - // Skip the attribute if a previous binding also wrote to it. - if (directiveInputs.has(field)) { - return; - } + node.inputs.forEach(processAttribute); + node.attributes.forEach(processAttribute); + if (node instanceof TmplAstTemplate) { + node.templateAttrs.forEach(processAttribute); + } - let expr: ts.Expression; - if (attr instanceof TmplAstBoundAttribute) { - // Produce an expression representing the value of the binding. - expr = tcbExpression(attr.value, tcb, scope); - } else { - // For regular attributes with a static string value, use the represented string literal. - expr = ts.createStringLiteral(attr.value); - } + return boundInputs; +} - directiveInputs.set(field, { - type: 'binding', - field: field, - expression: expr, - sourceSpan: attr.sourceSpan, - }); +/** + * Translates the given attribute binding to a `ts.Expression`. + */ +function translateInput( + attr: TmplAstBoundAttribute|TmplAstTextAttribute, tcb: Context, scope: Scope): ts.Expression { + if (attr instanceof TmplAstBoundAttribute) { + // Produce an expression representing the value of the binding. + return tcbExpression(attr.value, tcb, scope); + } else { + // For regular attributes with a static string value, use the represented string literal. + return ts.createStringLiteral(attr.value); } } +/** + * Inverts the input-mapping from field-to-property name into property-to-field name, to be able + * to match a property in a template with the corresponding field on a directive. + */ +function invertInputs(inputs: {[fieldName: string]: string|[string, string]}): + Map { + const propertyToFieldNames = new Map(); + for (const fieldName of Object.keys(inputs)) { + const propertyNames = inputs[fieldName]; + const propertyName = Array.isArray(propertyNames) ? propertyNames[0] : propertyNames; + + if (propertyToFieldNames.has(propertyName)) { + propertyToFieldNames.get(propertyName)!.push(fieldName); + } else { + propertyToFieldNames.set(propertyName, [fieldName]); + } + } + return propertyToFieldNames; +} + +/** + * An input binding that corresponds with a field of a directive. + */ +interface TcbDirectiveBoundInput { + type: 'binding'; + + /** + * The name of a field on the directive that is set. + */ + field: string; + + /** + * The `ts.Expression` corresponding with the input binding expression. + */ + expression: ts.Expression; + + /** + * The source span of the full attribute binding. + */ + sourceSpan: ParseSourceSpan; +} + +/** + * Indicates that a certain field of a directive does not have a corresponding input binding. + */ +interface TcbDirectiveUnsetInput { + type: 'unset'; + + /** + * The name of a field on the directive for which no input binding is present. + */ + field: string; +} + +type TcbDirectiveInput = TcbDirectiveBoundInput|TcbDirectiveUnsetInput; + const EVENT_PARAMETER = '$event'; const enum EventParamType { diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/type_constructor.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/type_constructor.ts index f80fd0fc69..8cb2114ddf 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/type_constructor.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/type_constructor.ts @@ -11,6 +11,7 @@ import * as ts from 'typescript'; import {ClassDeclaration, ReflectionHost} from '../../reflection'; import {TypeCtorMetadata} from '../api'; +import {tsCreateTypeQueryForCoercedInput} from './ts_util'; import {TypeParameterEmitter} from './type_parameter_emitter'; export function generateTypeCtorDeclarationFn( @@ -150,9 +151,7 @@ function constructTypeCtorParameter( /* modifiers */ undefined, /* name */ key, /* questionToken */ undefined, - /* type */ - ts.createTypeQueryNode( - ts.createQualifiedName(rawType.typeName, `ngAcceptInputType_${key}`)), + /* type */ tsCreateTypeQueryForCoercedInput(rawType.typeName, key), /* initializer */ undefined)); } } diff --git a/packages/compiler-cli/src/ngtsc/typecheck/test/diagnostics_spec.ts b/packages/compiler-cli/src/ngtsc/typecheck/test/diagnostics_spec.ts index 081296f637..2c278a3d68 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/test/diagnostics_spec.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/test/diagnostics_spec.ts @@ -227,7 +227,8 @@ runInEachFileSystem(() => { name: 'GuardDir', selector: '[guard]', inputs: {'guard': 'guard'}, - ngTemplateGuards: [{inputName: 'guard', type: 'binding'}] + ngTemplateGuards: [{inputName: 'guard', type: 'binding'}], + undeclaredInputFields: ['guard'], }]); expect(messages).toEqual([ diff --git a/packages/compiler-cli/src/ngtsc/typecheck/test/test_utils.ts b/packages/compiler-cli/src/ngtsc/typecheck/test/test_utils.ts index 99043a6428..f1e285c25a 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/test/test_utils.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/test/test_utils.ts @@ -120,8 +120,9 @@ export function ngForDeclaration(): TestDeclaration { file: absoluteFrom('/ngfor.d.ts'), selector: '[ngForOf]', name: 'NgForOf', - inputs: {ngForOf: 'ngForOf'}, + inputs: {ngForOf: 'ngForOf', ngForTrackBy: 'ngForTrackBy', ngForTemplate: 'ngForTemplate'}, hasNgTemplateContextGuard: true, + isGeneric: true, }; } @@ -175,11 +176,12 @@ export const ALL_ENABLED_CONFIG: TypeCheckingConfig = { // Remove 'ref' from TypeCheckableDirectiveMeta and add a 'selector' instead. export type TestDirective = Partial>>&{ - selector: string, - name: string, - file?: AbsoluteFsPath, type: 'directive', - coercedInputFields?: string[], + Exclude< + keyof TypeCheckableDirectiveMeta, + 'ref'|'coercedInputFields'|'restrictedInputFields'|'undeclaredInputFields'>>>&{ + selector: string, name: string, file?: AbsoluteFsPath, type: 'directive', + coercedInputFields?: string[], restrictedInputFields?: string[], + undeclaredInputFields?: string[], isGeneric?: boolean; }; export type TestPipe = { name: string, @@ -417,6 +419,9 @@ function prepareDeclarations( isComponent: decl.isComponent || false, ngTemplateGuards: decl.ngTemplateGuards || [], coercedInputFields: new Set(decl.coercedInputFields || []), + restrictedInputFields: new Set(decl.restrictedInputFields || []), + undeclaredInputFields: new Set(decl.undeclaredInputFields || []), + isGeneric: decl.isGeneric ?? false, outputs: decl.outputs || {}, queries: decl.queries || [], }; diff --git a/packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts b/packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts index febe9c9e98..d4a6b14a55 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts @@ -55,7 +55,7 @@ describe('type check blocks', () => { selector: '[dir]', inputs: {inputA: 'inputA'}, }]; - expect(tcb(TEMPLATE, DIRECTIVES)).toContain('"inputA": ("value")'); + expect(tcb(TEMPLATE, DIRECTIVES)).toContain('_t2: DirA = (null!); _t2["inputA"] = ("value");'); }); it('should handle multiple bindings to the same property', () => { @@ -67,8 +67,8 @@ describe('type check blocks', () => { inputs: {inputA: 'inputA'}, }]; const block = tcb(TEMPLATE, DIRECTIVES); - expect(block).toContain('"inputA": (1)'); - expect(block).not.toContain('"inputA": (2)'); + expect(block).toContain('_t2["inputA"] = (1);'); + expect(block).toContain('_t2["inputA"] = (2);'); }); it('should handle empty bindings', () => { @@ -79,7 +79,7 @@ describe('type check blocks', () => { selector: '[dir-a]', inputs: {inputA: 'inputA'}, }]; - expect(tcb(TEMPLATE, DIRECTIVES)).toContain('"inputA": (undefined)'); + expect(tcb(TEMPLATE, DIRECTIVES)).toContain('_t2["inputA"] = (undefined);'); }); it('should handle bindings without value', () => { @@ -90,7 +90,7 @@ describe('type check blocks', () => { selector: '[dir-a]', inputs: {inputA: 'inputA'}, }]; - expect(tcb(TEMPLATE, DIRECTIVES)).toContain('"inputA": (undefined)'); + expect(tcb(TEMPLATE, DIRECTIVES)).toContain('_t2["inputA"] = (undefined);'); }); it('should handle implicit vars on ng-template', () => { @@ -109,20 +109,148 @@ describe('type check blocks', () => { expect(tcb(TEMPLATE)).toContain('var _t2 = _t1.$implicit;'); }); - it('should handle missing property bindings', () => { - const TEMPLATE = `
`; - const DIRECTIVES: TestDeclaration[] = [{ - type: 'directive', - name: 'Dir', - selector: '[dir]', - inputs: { - fieldA: 'inputA', - fieldB: 'inputB', - }, - }]; - expect(tcb(TEMPLATE, DIRECTIVES)) - .toContain( - 'var _t2 = Dir.ngTypeCtor({ "fieldA": (((ctx).foo)), "fieldB": (null as any) });'); + describe('type constructors', () => { + it('should handle missing property bindings', () => { + const TEMPLATE = `
`; + const DIRECTIVES: TestDeclaration[] = [{ + type: 'directive', + name: 'Dir', + selector: '[dir]', + inputs: { + fieldA: 'inputA', + fieldB: 'inputB', + }, + isGeneric: true, + }]; + expect(tcb(TEMPLATE, DIRECTIVES)) + .toContain( + 'var _t2 = Dir.ngTypeCtor({ "fieldA": (((ctx).foo)), "fieldB": (null as any) });'); + }); + + it('should handle multiple bindings to the same property', () => { + const TEMPLATE = `
`; + const DIRECTIVES: TestDeclaration[] = [{ + type: 'directive', + name: 'Dir', + selector: '[dir]', + inputs: { + fieldA: 'inputA', + }, + isGeneric: true, + }]; + const block = tcb(TEMPLATE, DIRECTIVES); + expect(block).toContain('"fieldA": (1)'); + expect(block).not.toContain('"fieldA": (2)'); + }); + + + it('should only apply property bindings to directives', () => { + const TEMPLATE = ` +
+ `; + const DIRECTIVES: TestDeclaration[] = [{ + type: 'directive', + name: 'Dir', + selector: '[dir]', + inputs: {'color': 'color', 'strong': 'strong', 'enabled': 'enabled'}, + isGeneric: true, + }]; + const block = tcb(TEMPLATE, DIRECTIVES); + expect(block).toContain( + 'var _t2 = Dir.ngTypeCtor({ "color": (null as any), "strong": (null as any), "enabled": (null as any) });'); + expect(block).toContain('"blue"; false; true;'); + }); + + it('should generate a circular directive reference correctly', () => { + const TEMPLATE = ` +
+ `; + const DIRECTIVES: TestDirective[] = [{ + type: 'directive', + name: 'Dir', + selector: '[dir]', + exportAs: ['dir'], + inputs: {input: 'input'}, + isGeneric: true, + }]; + expect(tcb(TEMPLATE, DIRECTIVES)) + .toContain( + 'var _t3 = Dir.ngTypeCtor((null!)); ' + + 'var _t2 = Dir.ngTypeCtor({ "input": (_t3) });'); + }); + + it('should generate circular references between two directives correctly', () => { + const TEMPLATE = ` +
A
+
B
+`; + const DIRECTIVES: TestDirective[] = [ + { + type: 'directive', + name: 'DirA', + selector: '[dir-a]', + exportAs: ['dirA'], + inputs: {inputA: 'inputA'}, + isGeneric: true, + }, + { + type: 'directive', + name: 'DirB', + selector: '[dir-b]', + exportAs: ['dirB'], + inputs: {inputB: 'inputB'}, + isGeneric: true, + } + ]; + expect(tcb(TEMPLATE, DIRECTIVES)) + .toContain( + 'var _t4 = DirA.ngTypeCtor((null!)); ' + + 'var _t3 = DirB.ngTypeCtor({ "inputB": (_t4) }); ' + + 'var _t2 = DirA.ngTypeCtor({ "inputA": (_t3) });'); + }); + + it('should handle empty bindings', () => { + const TEMPLATE = `
`; + const DIRECTIVES: TestDeclaration[] = [{ + type: 'directive', + name: 'DirA', + selector: '[dir-a]', + inputs: {inputA: 'inputA'}, + isGeneric: true, + }]; + expect(tcb(TEMPLATE, DIRECTIVES)).toContain('"inputA": (undefined)'); + }); + + it('should handle bindings without value', () => { + const TEMPLATE = `
`; + const DIRECTIVES: TestDeclaration[] = [{ + type: 'directive', + name: 'DirA', + selector: '[dir-a]', + inputs: {inputA: 'inputA'}, + isGeneric: true, + }]; + expect(tcb(TEMPLATE, DIRECTIVES)).toContain('"inputA": (undefined)'); + }); + + it('should use coercion types if declared', () => { + const TEMPLATE = `
`; + const DIRECTIVES: TestDeclaration[] = [{ + type: 'directive', + name: 'Dir', + selector: '[dir]', + inputs: { + fieldA: 'inputA', + }, + isGeneric: true, + coercedInputFields: ['fieldA'], + }]; + expect(tcb(TEMPLATE, DIRECTIVES)) + .toContain( + 'var _t2 = Dir.ngTypeCtor({ "fieldA": (((ctx).foo)) }); ' + + 'var _t3: typeof Dir.ngAcceptInputType_fieldA = (null!); ' + + '_t3 = (((ctx).foo));'); + }); }); it('should generate a forward element reference correctly', () => { @@ -147,7 +275,7 @@ describe('type check blocks', () => { }]; expect(tcb(TEMPLATE, DIRECTIVES)) .toContain( - 'var _t1 = Dir.ngTypeCtor({}); "" + ((_t1).value); var _t2 = document.createElement("div");'); + 'var _t1: Dir = (null!); "" + ((_t1).value); var _t2 = document.createElement("div");'); }); it('should handle style and class bindings specially', () => { @@ -173,8 +301,10 @@ describe('type check blocks', () => { inputs: {'color': 'color', 'strong': 'strong', 'enabled': 'enabled'}, }]; const block = tcb(TEMPLATE, DIRECTIVES); - expect(block).toContain( - 'var _t2 = Dir.ngTypeCtor({ "color": (null as any), "strong": (null as any), "enabled": (null as any) });'); + expect(block).toContain('var _t2: Dir = (null!);'); + expect(block).not.toContain('"color"'); + expect(block).not.toContain('"strong"'); + expect(block).not.toContain('"enabled"'); expect(block).toContain('"blue"; false; true;'); }); @@ -191,8 +321,8 @@ describe('type check blocks', () => { }]; expect(tcb(TEMPLATE, DIRECTIVES)) .toContain( - 'var _t3 = Dir.ngTypeCtor((null!)); ' + - 'var _t2 = Dir.ngTypeCtor({ "input": (_t3) });'); + 'var _t2: Dir = (null!); ' + + '_t2["input"] = (_t2);'); }); it('should generate circular references between two directives correctly', () => { @@ -218,9 +348,139 @@ describe('type check blocks', () => { ]; expect(tcb(TEMPLATE, DIRECTIVES)) .toContain( - 'var _t4 = DirA.ngTypeCtor((null!)); ' + - 'var _t3 = DirB.ngTypeCtor({ "inputA": (_t4) }); ' + - 'var _t2 = DirA.ngTypeCtor({ "inputA": (_t3) });'); + 'var _t2: DirA = (null!); ' + + 'var _t3: DirB = (null!); ' + + '_t2["inputA"] = (_t3); ' + + 'var _t4 = document.createElement("div"); ' + + '_t3["inputA"] = (_t2);'); + }); + + it('should handle undeclared properties', () => { + const TEMPLATE = `
`; + const DIRECTIVES: TestDeclaration[] = [{ + type: 'directive', + name: 'Dir', + selector: '[dir]', + inputs: { + fieldA: 'inputA', + }, + undeclaredInputFields: ['fieldA'] + }]; + expect(tcb(TEMPLATE, DIRECTIVES)) + .toContain( + 'var _t2: Dir = (null!); ' + + '(((ctx).foo)); '); + }); + + it('should handle restricted properties', () => { + const TEMPLATE = `
`; + const DIRECTIVES: TestDeclaration[] = [{ + type: 'directive', + name: 'Dir', + selector: '[dir]', + inputs: { + fieldA: 'inputA', + }, + restrictedInputFields: ['fieldA'] + }]; + expect(tcb(TEMPLATE, DIRECTIVES)) + .toContain( + 'var _t2: Dir = (null!); ' + + 'var _t3: typeof _t2["fieldA"] = (null!); ' + + '_t3 = (((ctx).foo)); '); + }); + + it('should handle a single property bound to multiple fields', () => { + const TEMPLATE = `
`; + const DIRECTIVES: TestDeclaration[] = [{ + type: 'directive', + name: 'Dir', + selector: '[dir]', + inputs: { + field1: 'inputA', + field2: 'inputA', + }, + }]; + expect(tcb(TEMPLATE, DIRECTIVES)) + .toContain( + 'var _t2: Dir = (null!); ' + + '_t2["field2"] = _t2["field1"] = (((ctx).foo));'); + }); + + it('should handle a single property bound to multiple fields, where one of them is coerced', + () => { + const TEMPLATE = `
`; + const DIRECTIVES: TestDeclaration[] = [{ + type: 'directive', + name: 'Dir', + selector: '[dir]', + inputs: { + field1: 'inputA', + field2: 'inputA', + }, + coercedInputFields: ['field1'], + }]; + expect(tcb(TEMPLATE, DIRECTIVES)) + .toContain( + 'var _t2: Dir = (null!); ' + + 'var _t3: typeof Dir.ngAcceptInputType_field1 = (null!); ' + + '_t2["field2"] = _t3 = (((ctx).foo));'); + }); + + it('should handle a single property bound to multiple fields, where one of them is undeclared', + () => { + const TEMPLATE = `
`; + const DIRECTIVES: TestDeclaration[] = [{ + type: 'directive', + name: 'Dir', + selector: '[dir]', + inputs: { + field1: 'inputA', + field2: 'inputA', + }, + undeclaredInputFields: ['field1'], + }]; + expect(tcb(TEMPLATE, DIRECTIVES)) + .toContain( + 'var _t2: Dir = (null!); ' + + '_t2["field2"] = (((ctx).foo));'); + }); + + it('should use coercion types if declared', () => { + const TEMPLATE = `
`; + const DIRECTIVES: TestDeclaration[] = [{ + type: 'directive', + name: 'Dir', + selector: '[dir]', + inputs: { + fieldA: 'inputA', + }, + coercedInputFields: ['fieldA'], + }]; + expect(tcb(TEMPLATE, DIRECTIVES)) + .toContain( + 'var _t2: Dir = (null!); ' + + 'var _t3: typeof Dir.ngAcceptInputType_fieldA = (null!); ' + + '_t3 = (((ctx).foo));'); + }); + + it('should use coercion types if declared, even when backing field is not declared', () => { + const TEMPLATE = `
`; + const DIRECTIVES: TestDeclaration[] = [{ + type: 'directive', + name: 'Dir', + selector: '[dir]', + inputs: { + fieldA: 'inputA', + }, + coercedInputFields: ['fieldA'], + undeclaredInputFields: ['fieldA'], + }]; + expect(tcb(TEMPLATE, DIRECTIVES)) + .toContain( + 'var _t2: Dir = (null!); ' + + 'var _t3: typeof Dir.ngAcceptInputType_fieldA = (null!); ' + + '_t3 = (((ctx).foo));'); }); it('should handle $any casts', () => { @@ -379,14 +639,14 @@ describe('type check blocks', () => { it('should include null and undefined when enabled', () => { const block = tcb(TEMPLATE, DIRECTIVES); - expect(block).toContain('Dir.ngTypeCtor({ "dirInput": (((ctx).a)) })'); + expect(block).toContain('_t2["dirInput"] = (((ctx).a));'); expect(block).toContain('((ctx).b);'); }); it('should use the non-null assertion operator when disabled', () => { const DISABLED_CONFIG: TypeCheckingConfig = {...BASE_CONFIG, strictNullInputBindings: false}; const block = tcb(TEMPLATE, DIRECTIVES, DISABLED_CONFIG); - expect(block).toContain('Dir.ngTypeCtor({ "dirInput": (((ctx).a)!) })'); + expect(block).toContain('_t2["dirInput"] = (((ctx).a)!);'); expect(block).toContain('((ctx).b)!;'); }); }); @@ -395,7 +655,7 @@ describe('type check blocks', () => { it('should check types of bindings when enabled', () => { const TEMPLATE = `
`; const block = tcb(TEMPLATE, DIRECTIVES); - expect(block).toContain('Dir.ngTypeCtor({ "dirInput": (((ctx).a)) })'); + expect(block).toContain('_t2["dirInput"] = (((ctx).a));'); expect(block).toContain('((ctx).b);'); }); @@ -404,7 +664,7 @@ describe('type check blocks', () => { const DISABLED_CONFIG: TypeCheckingConfig = {...BASE_CONFIG, checkTypeOfInputBindings: false}; const block = tcb(TEMPLATE, DIRECTIVES, DISABLED_CONFIG); - expect(block).toContain('Dir.ngTypeCtor({ "dirInput": ((((ctx).a) as any)) })'); + expect(block).toContain('_t2["dirInput"] = ((((ctx).a) as any));'); expect(block).toContain('(((ctx).b) as any);'); }); @@ -413,8 +673,7 @@ describe('type check blocks', () => { const DISABLED_CONFIG: TypeCheckingConfig = {...BASE_CONFIG, checkTypeOfInputBindings: false}; const block = tcb(TEMPLATE, DIRECTIVES, DISABLED_CONFIG); - expect(block).toContain( - 'Dir.ngTypeCtor({ "dirInput": ((((((ctx).a)) === (((ctx).b))) as any)) })'); + expect(block).toContain('_t2["dirInput"] = ((((((ctx).a)) === (((ctx).b))) as any));'); }); }); @@ -534,17 +793,17 @@ describe('type check blocks', () => { it('should assign string value to the input when enabled', () => { const block = tcb(TEMPLATE, DIRECTIVES); - expect(block).toContain('"disabled": ("")'); - expect(block).toContain('"cols": ("3")'); - expect(block).toContain('"rows": (2)'); + expect(block).toContain('_t2["disabled"] = ("");'); + expect(block).toContain('_t2["cols"] = ("3");'); + expect(block).toContain('_t2["rows"] = (2);'); }); it('should use any for attributes but still check bound attributes when disabled', () => { const DISABLED_CONFIG: TypeCheckingConfig = {...BASE_CONFIG, checkTypeOfAttributes: false}; const block = tcb(TEMPLATE, DIRECTIVES, DISABLED_CONFIG); - expect(block).toContain('"disabled": (null as any)'); - expect(block).toContain('"cols": (null as any)'); - expect(block).toContain('"rows": (2)'); + expect(block).not.toContain('"disabled"'); + expect(block).not.toContain('"cols"'); + expect(block).toContain('_t2["rows"] = (2);'); }); }); diff --git a/packages/compiler-cli/src/ngtsc/typecheck/test/type_checker_spec.ts b/packages/compiler-cli/src/ngtsc/typecheck/test/type_checker_spec.ts index 432a1b8dd0..0a075efbf3 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/test/type_checker_spec.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/test/type_checker_spec.ts @@ -86,6 +86,7 @@ runInEachFileSystem(os => { selector: '[dir]', file: dirFile, type: 'directive', + isGeneric: true, }; const {program, templateTypeChecker, programStrategy} = setup([ { @@ -104,7 +105,7 @@ runInEachFileSystem(os => { // A non-exported interface used as a type bound for a generic directive causes // an inline type constructor to be required. interface NotExported {} - export class TestDir {}`, + export abstract class TestDir {}`, templates: {}, }, ]); @@ -161,7 +162,7 @@ runInEachFileSystem(os => { const {program, templateTypeChecker} = setup( [{ fileName, - source: `class Cmp {} // not exported, so requires inline`, + source: `abstract class Cmp {} // not exported, so requires inline`, templates: {'Cmp': '
'} }], {inlining: false}); @@ -188,6 +189,7 @@ runInEachFileSystem(os => { selector: '[dir]', file: dirFile, type: 'directive', + isGeneric: true, }] }, { @@ -196,7 +198,7 @@ runInEachFileSystem(os => { // A non-exported interface used as a type bound for a generic directive causes // an inline type constructor to be required. interface NotExported {} - export class TestDir {}`, + export abstract class TestDir {}`, templates: {}, } ], diff --git a/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts b/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts index 7c9ec4dbf4..5646850c22 100644 --- a/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts +++ b/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts @@ -136,11 +136,42 @@ export declare class AnimationEvent { const diags = env.driveDiagnostics(); expect(diags.length).toBe(1); - expect(diags[0].messageText).toEqual(`Type 'string' is not assignable to type 'number'.`); + expect(diags[0].messageText).toEqual(`Type '"2"' is not assignable to type 'number'.`); // The reported error code should be in the TS error space, not a -99 "NG" code. expect(diags[0].code).toBeGreaterThan(0); }); + it('should produce diagnostics when mapping to multiple fields and bound types are incorrect', + () => { + env.tsconfig( + {fullTemplateTypeCheck: true, strictInputTypes: true, strictAttributeTypes: true}); + env.write('test.ts', ` + import {Component, Directive, NgModule, Input} from '@angular/core'; + + @Component({ + selector: 'test', + template: '
', + }) + class TestCmp {} + + @Directive({selector: '[dir]'}) + class TestDir { + @Input('foo') foo1: number; + @Input('foo') foo2: number; + } + + @NgModule({ + declarations: [TestCmp, TestDir], + }) + class Module {} + `); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(2); + expect(diags[0].messageText).toEqual(`Type '"2"' is not assignable to type 'number'.`); + expect(diags[1].messageText).toEqual(`Type '"2"' is not assignable to type 'number'.`); + }); + it('should support inputs and outputs with names that are not JavaScript identifiers', () => { env.tsconfig( {fullTemplateTypeCheck: true, strictInputTypes: true, strictOutputEventTypes: true}); @@ -173,7 +204,7 @@ export declare class AnimationEvent { const diags = env.driveDiagnostics(); expect(diags.length).toBe(2); - expect(diags[0].messageText).toEqual(`Type 'number' is not assignable to type 'string'.`); + expect(diags[0].messageText).toEqual(`Type '2' is not assignable to type 'string'.`); expect(diags[1].messageText) .toEqual(`Argument of type 'string' is not assignable to parameter of type 'number'.`); }); @@ -349,7 +380,7 @@ export declare class AnimationEvent { const diags = env.driveDiagnostics(); expect(diags.length).toBe(2); - expect(diags[0].messageText).toEqual(`Type 'number' is not assignable to type 'string'.`); + expect(diags[0].messageText).toEqual(`Type '1' is not assignable to type 'string'.`); expect(diags[1].messageText) .toEqual(`Property 'invalid' does not exist on type 'TestCmp'.`); }); @@ -359,7 +390,7 @@ export declare class AnimationEvent { const diags = env.driveDiagnostics(); expect(diags.length).toBe(2); - expect(diags[0].messageText).toEqual(`Type 'number' is not assignable to type 'string'.`); + expect(diags[0].messageText).toEqual(`Type '1' is not assignable to type 'string'.`); expect(diags[1].messageText) .toEqual(`Property 'invalid' does not exist on type 'TestCmp'.`); }); @@ -685,8 +716,8 @@ export declare class AnimationEvent { const diags = env.driveDiagnostics(); expect(diags.length).toBe(2); - expect(diags[0].messageText).toEqual(`Type 'string' is not assignable to type 'boolean'.`); - expect(diags[1].messageText).toEqual(`Type 'string' is not assignable to type 'number'.`); + expect(diags[0].messageText).toEqual(`Type '""' is not assignable to type 'boolean'.`); + expect(diags[1].messageText).toEqual(`Type '"3"' is not assignable to type 'number'.`); }); it('should produce an error for text attributes when overall strictness is enabled', () => { @@ -694,8 +725,8 @@ export declare class AnimationEvent { const diags = env.driveDiagnostics(); expect(diags.length).toBe(2); - expect(diags[0].messageText).toEqual(`Type 'string' is not assignable to type 'boolean'.`); - expect(diags[1].messageText).toEqual(`Type 'string' is not assignable to type 'number'.`); + expect(diags[0].messageText).toEqual(`Type '""' is not assignable to type 'boolean'.`); + expect(diags[1].messageText).toEqual(`Type '"3"' is not assignable to type 'number'.`); }); it('should not produce an error for text attributes when not enabled', () => { @@ -1212,9 +1243,9 @@ export declare class AnimationEvent { expect(diags.length).toBe(3); expect(diags[0].messageText).toBe(`Type 'true' is not assignable to type 'number'.`); expect(getSourceCodeForDiagnostic(diags[0])).toEqual('[fromAbstract]="true"'); - expect(diags[1].messageText).toBe(`Type 'number' is not assignable to type 'string'.`); + expect(diags[1].messageText).toBe(`Type '3' is not assignable to type 'string'.`); expect(getSourceCodeForDiagnostic(diags[1])).toEqual('[fromBase]="3"'); - expect(diags[2].messageText).toBe(`Type 'number' is not assignable to type 'boolean'.`); + expect(diags[2].messageText).toBe(`Type '4' is not assignable to type 'boolean'.`); expect(getSourceCodeForDiagnostic(diags[2])).toEqual('[fromChild]="4"'); }); @@ -1269,9 +1300,9 @@ export declare class AnimationEvent { expect(diags.length).toBe(3); expect(diags[0].messageText).toBe(`Type 'true' is not assignable to type 'number'.`); expect(getSourceCodeForDiagnostic(diags[0])).toEqual('[fromAbstract]="true"'); - expect(diags[1].messageText).toBe(`Type 'number' is not assignable to type 'string'.`); + expect(diags[1].messageText).toBe(`Type '3' is not assignable to type 'string'.`); expect(getSourceCodeForDiagnostic(diags[1])).toEqual('[fromBase]="3"'); - expect(diags[2].messageText).toBe(`Type 'number' is not assignable to type 'boolean'.`); + expect(diags[2].messageText).toBe(`Type '4' is not assignable to type 'boolean'.`); expect(getSourceCodeForDiagnostic(diags[2])).toEqual('[fromChild]="4"'); }); @@ -1476,7 +1507,7 @@ export declare class AnimationEvent { it('should give an error if the binding expression type is not accepted by the coercion function', () => { env.write('test.ts', ` - import {Component, NgModule} from '@angular/core'; + import {Component, NgModule, Input, Directive} from '@angular/core'; import {MatInputModule} from '@angular/material'; @Component({ @@ -1533,6 +1564,199 @@ export declare class AnimationEvent { }); }); + describe('restricted inputs', () => { + const directiveDeclaration = ` + @Directive({selector: '[dir]'}) + export class TestDir { + @Input() + protected protectedField!: string; + @Input() + private privateField!: string; + @Input() + readonly readonlyField!: string; + } + `; + + describe('with strict inputs', () => { + beforeEach(() => { + env.tsconfig({fullTemplateTypeCheck: true, strictInputTypes: true}); + }); + + it('should not produce diagnostics for correct inputs which assign to readonly, private, or protected fields', + () => { + env.write('test.ts', ` + import {Component, NgModule, Input, Directive} from '@angular/core'; + + @Component({ + selector: 'blah', + template: '
', + }) + export class FooCmp { + value = "value"; + } + + ${directiveDeclaration} + + @NgModule({ + declarations: [FooCmp, TestDir], + }) + export class FooModule {} + `); + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(0); + }); + + it('should not produce diagnostics for correct inputs which assign to readonly, private, or protected fields inherited from a base class', + () => { + env.write('test.ts', ` + import {Component, NgModule, Input, Directive} from '@angular/core'; + + @Component({ + selector: 'blah', + template: '
', + }) + export class FooCmp { + value = "value"; + } + + ${directiveDeclaration} + + @Directive({selector: '[child-dir]'}) + export class ChildDir extends TestDir { + } + + @NgModule({ + declarations: [FooCmp, ChildDir], + }) + export class FooModule {} + `); + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(0); + }); + + it('should produce diagnostics when assigning incorrect type to readonly, private, or protected fields', + () => { + env.write('test.ts', ` + import {Component, NgModule, Input, Directive} from '@angular/core'; + + @Component({ + selector: 'blah', + template: '
', + }) + export class FooCmp { + value = 1; + } + + ${directiveDeclaration} + + @NgModule({ + declarations: [FooCmp, TestDir], + }) + export class FooModule {} + `); + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(3); + expect(diags[0].messageText) + .toEqual(`Type 'number' is not assignable to type 'string'.`); + expect(diags[1].messageText) + .toEqual(`Type 'number' is not assignable to type 'string'.`); + expect(diags[2].messageText) + .toEqual(`Type 'number' is not assignable to type 'string'.`); + }); + }); + }); + + it('should not produce diagnostics for undeclared inputs', () => { + env.tsconfig({fullTemplateTypeCheck: true, strictInputTypes: true}); + env.write('test.ts', ` + import {Component, NgModule, Input, Directive} from '@angular/core'; + + @Component({ + selector: 'blah', + template: '
', + }) + export class FooCmp { + value = "value"; + } + + @Directive({ + selector: '[dir]', + inputs: ['undeclared'], + }) + export class TestDir { + } + + @NgModule({ + declarations: [FooCmp, TestDir], + }) + export class FooModule {} + `); + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(0); + }); + + it('should produce diagnostics for invalid expressions when assigned into an undeclared input', + () => { + env.tsconfig({fullTemplateTypeCheck: true, strictInputTypes: true}); + env.write('test.ts', ` + import {Component, NgModule, Input, Directive} from '@angular/core'; + + @Component({ + selector: 'blah', + template: '
', + }) + export class FooCmp { + } + + @Directive({ + selector: '[dir]', + inputs: ['undeclared'], + }) + export class TestDir { + } + + @NgModule({ + declarations: [FooCmp, TestDir], + }) + export class FooModule {} + `); + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(1); + expect(diags[0].messageText).toBe(`Property 'value' does not exist on type 'FooCmp'.`); + }); + + it('should not produce diagnostics for undeclared inputs inherited from a base class', () => { + env.tsconfig({fullTemplateTypeCheck: true, strictInputTypes: true}); + env.write('test.ts', ` + import {Component, NgModule, Input, Directive} from '@angular/core'; + + @Component({ + selector: 'blah', + template: '
', + }) + export class FooCmp { + value = "value"; + } + + @Directive({ + inputs: ['undeclaredBase'], + }) + export class BaseDir { + } + + @Directive({selector: '[dir]'}) + export class TestDir extends BaseDir { + } + + @NgModule({ + declarations: [FooCmp, TestDir], + }) + export class FooModule {} + `); + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(0); + }); + describe('legacy schema checking with the DOM schema', () => { beforeEach(() => { env.tsconfig({ivyTemplateTypeCheck: true, fullTemplateTypeCheck: false}); From 71138f6004394557896acdfda82013ed7b865a95 Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Tue, 28 Jul 2020 14:51:14 -0700 Subject: [PATCH 016/629] feat(compiler-cli): Add compiler option to report errors when assigning to restricted input fields (#38249) The compiler does not currently report errors when there's an `@Input()` for a `private`, `protected`, or `readonly` directive/component class member. This change adds an option to enable reporting errors when a template attempts to bind to one of these restricted input fields. PR Close #38249 --- aio/content/guide/template-typecheck.md | 1 + .../compiler-cli/compiler_options.d.ts | 1 + .../src/ngtsc/core/api/src/public_options.ts | 12 ++ .../src/ngtsc/core/src/compiler.ts | 6 + .../src/ngtsc/metadata/src/api.ts | 7 ++ .../src/ngtsc/metadata/src/inheritance.ts | 11 +- .../src/ngtsc/metadata/src/util.ts | 9 +- .../src/ngtsc/scope/test/local_spec.ts | 1 + .../src/ngtsc/typecheck/api/api.ts | 8 ++ .../ngtsc/typecheck/src/type_check_block.ts | 30 +++-- .../src/ngtsc/typecheck/test/test_utils.ts | 8 +- .../typecheck/test/type_check_block_spec.ts | 103 ++++++++++++++---- .../test/ngtsc/template_typecheck_spec.ts | 98 ++++++++++++++--- 13 files changed, 236 insertions(+), 59 deletions(-) diff --git a/aio/content/guide/template-typecheck.md b/aio/content/guide/template-typecheck.md index 5ef3229329..d5612bb76a 100644 --- a/aio/content/guide/template-typecheck.md +++ b/aio/content/guide/template-typecheck.md @@ -114,6 +114,7 @@ In case of a false positive like these, there are a few options: |Strictness flag|Effect| |-|-| |`strictInputTypes`|Whether the assignability of a binding expression to the `@Input()` field is checked. Also affects the inference of directive generic types. | +|`strictInputAccessModifiers`|Whether access modifiers such as `private`/`protected`/`readonly` are honored when assigning a binding expression to an `@Input()`. If disabled, the access modifiers of the `@Input` are ignored; only the type is checked.| |`strictNullInputTypes`|Whether `strictNullChecks` is honored when checking `@Input()` bindings (per `strictInputTypes`). Turning this off can be useful when using a library that was not built with `strictNullChecks` in mind.| |`strictAttributeTypes`|Whether to check `@Input()` bindings that are made using text attributes (for example, `` vs ``). |`strictSafeNavigationTypes`|Whether the return type of safe navigation operations (for example, `user?.name`) will be correctly inferred based on the type of `user`). If disabled, `user?.name` will be of type `any`. diff --git a/goldens/public-api/compiler-cli/compiler_options.d.ts b/goldens/public-api/compiler-cli/compiler_options.d.ts index 92723d3050..2c462d513a 100644 --- a/goldens/public-api/compiler-cli/compiler_options.d.ts +++ b/goldens/public-api/compiler-cli/compiler_options.d.ts @@ -35,6 +35,7 @@ export interface StrictTemplateOptions { strictContextGenerics?: boolean; strictDomEventTypes?: boolean; strictDomLocalRefTypes?: boolean; + strictInputAccessModifiers?: boolean; strictInputTypes?: boolean; strictLiteralTypes?: boolean; strictNullInputTypes?: boolean; diff --git a/packages/compiler-cli/src/ngtsc/core/api/src/public_options.ts b/packages/compiler-cli/src/ngtsc/core/api/src/public_options.ts index bb682ce21f..98b41f8f07 100644 --- a/packages/compiler-cli/src/ngtsc/core/api/src/public_options.ts +++ b/packages/compiler-cli/src/ngtsc/core/api/src/public_options.ts @@ -147,6 +147,18 @@ export interface StrictTemplateOptions { */ strictInputTypes?: boolean; + /** + * Whether to check if the input binding attempts to assign to a restricted field (readonly, + * private, or protected) on the directive/component. + * + * Defaults to `false`, even if "fullTemplateTypeCheck", "strictTemplates" and/or + * "strictInputTypes" is set. Note that if `strictInputTypes` is not set, or set to `false`, this + * flag has no effect. + * + * Tracking issue for enabling this by default: https://github.com/angular/angular/issues/38400 + */ + strictInputAccessModifiers?: boolean; + /** * Whether to use strict null types for input bindings for directives. * diff --git a/packages/compiler-cli/src/ngtsc/core/src/compiler.ts b/packages/compiler-cli/src/ngtsc/core/src/compiler.ts index 6e7cd3ff04..2aed139c01 100644 --- a/packages/compiler-cli/src/ngtsc/core/src/compiler.ts +++ b/packages/compiler-cli/src/ngtsc/core/src/compiler.ts @@ -415,6 +415,7 @@ export class NgCompiler { checkQueries: false, checkTemplateBodies: true, checkTypeOfInputBindings: strictTemplates, + honorAccessModifiersForInputBindings: false, strictNullInputBindings: strictTemplates, checkTypeOfAttributes: strictTemplates, // Even in full template type-checking mode, DOM binding checks are not quite ready yet. @@ -442,6 +443,7 @@ export class NgCompiler { checkTemplateBodies: false, checkTypeOfInputBindings: false, strictNullInputBindings: false, + honorAccessModifiersForInputBindings: false, checkTypeOfAttributes: false, checkTypeOfDomBindings: false, checkTypeOfOutputEvents: false, @@ -462,6 +464,10 @@ export class NgCompiler { typeCheckingConfig.checkTypeOfInputBindings = this.options.strictInputTypes; typeCheckingConfig.applyTemplateContextGuards = this.options.strictInputTypes; } + if (this.options.strictInputAccessModifiers !== undefined) { + typeCheckingConfig.honorAccessModifiersForInputBindings = + this.options.strictInputAccessModifiers; + } if (this.options.strictNullInputTypes !== undefined) { typeCheckingConfig.strictNullInputBindings = this.options.strictNullInputTypes; } diff --git a/packages/compiler-cli/src/ngtsc/metadata/src/api.ts b/packages/compiler-cli/src/ngtsc/metadata/src/api.ts index a9d58de7c7..8009207f50 100644 --- a/packages/compiler-cli/src/ngtsc/metadata/src/api.ts +++ b/packages/compiler-cli/src/ngtsc/metadata/src/api.ts @@ -60,6 +60,13 @@ export interface DirectiveTypeCheckMeta { */ restrictedInputFields: Set; + /** + * The set of input fields which are declared as string literal members in the Directive's class. + * We need to track these separately because these fields may not be valid JS identifiers so + * we cannot use them with property access expressions when assigning inputs. + */ + stringLiteralInputFields: Set; + /** * The set of input fields which do not have corresponding members in the Directive's class. */ diff --git a/packages/compiler-cli/src/ngtsc/metadata/src/inheritance.ts b/packages/compiler-cli/src/ngtsc/metadata/src/inheritance.ts index ba775783e1..455d103d7d 100644 --- a/packages/compiler-cli/src/ngtsc/metadata/src/inheritance.ts +++ b/packages/compiler-cli/src/ngtsc/metadata/src/inheritance.ts @@ -27,9 +27,10 @@ export function flattenInheritedDirectiveMetadata( let inputs: {[key: string]: string|[string, string]} = {}; let outputs: {[key: string]: string} = {}; - let coercedInputFields = new Set(); - let undeclaredInputFields = new Set(); - let restrictedInputFields = new Set(); + const coercedInputFields = new Set(); + const undeclaredInputFields = new Set(); + const restrictedInputFields = new Set(); + const stringLiteralInputFields = new Set(); let isDynamic = false; const addMetadata = (meta: DirectiveMeta): void => { @@ -56,6 +57,9 @@ export function flattenInheritedDirectiveMetadata( for (const restrictedInputField of meta.restrictedInputFields) { restrictedInputFields.add(restrictedInputField); } + for (const field of meta.stringLiteralInputFields) { + stringLiteralInputFields.add(field); + } }; addMetadata(topMeta); @@ -67,6 +71,7 @@ export function flattenInheritedDirectiveMetadata( coercedInputFields, undeclaredInputFields, restrictedInputFields, + stringLiteralInputFields, baseClass: isDynamic ? 'dynamic' : null, }; } diff --git a/packages/compiler-cli/src/ngtsc/metadata/src/util.ts b/packages/compiler-cli/src/ngtsc/metadata/src/util.ts index 4cf0e5eabd..dbb7e77e94 100644 --- a/packages/compiler-cli/src/ngtsc/metadata/src/util.ts +++ b/packages/compiler-cli/src/ngtsc/metadata/src/util.ts @@ -98,15 +98,21 @@ export function extractDirectiveTypeCheckMeta( .filter((inputName): inputName is string => inputName !== null)); const restrictedInputFields = new Set(); + const stringLiteralInputFields = new Set(); const undeclaredInputFields = new Set(); for (const fieldName of Object.keys(inputs)) { const field = members.find(member => member.name === fieldName); if (field === undefined || field.node === null) { undeclaredInputFields.add(fieldName); - } else if (isRestricted(field.node)) { + continue; + } + if (isRestricted(field.node)) { restrictedInputFields.add(fieldName); } + if (field.nameNode !== null && ts.isStringLiteral(field.nameNode)) { + stringLiteralInputFields.add(fieldName); + } } const arity = reflector.getGenericArityOfClass(node); @@ -116,6 +122,7 @@ export function extractDirectiveTypeCheckMeta( ngTemplateGuards, coercedInputFields, restrictedInputFields, + stringLiteralInputFields, undeclaredInputFields, isGeneric: arity !== null && arity > 0, }; diff --git a/packages/compiler-cli/src/ngtsc/scope/test/local_spec.ts b/packages/compiler-cli/src/ngtsc/scope/test/local_spec.ts index 60302988a1..f8b12d5fc0 100644 --- a/packages/compiler-cli/src/ngtsc/scope/test/local_spec.ts +++ b/packages/compiler-cli/src/ngtsc/scope/test/local_spec.ts @@ -244,6 +244,7 @@ function fakeDirective(ref: Reference): DirectiveMeta { ngTemplateGuards: [], coercedInputFields: new Set(), restrictedInputFields: new Set(), + stringLiteralInputFields: new Set(), undeclaredInputFields: new Set(), isGeneric: false, baseClass: null, diff --git a/packages/compiler-cli/src/ngtsc/typecheck/api/api.ts b/packages/compiler-cli/src/ngtsc/typecheck/api/api.ts index f0e241144a..658260fbf1 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/api/api.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/api/api.ts @@ -90,6 +90,14 @@ export interface TypeCheckingConfig { */ checkTypeOfInputBindings: boolean; + /** + * Whether to honor the access modifiers on input bindings for the component/directive. + * + * If a template binding attempts to assign to an input that is private/protected/readonly, + * this will produce errors when enabled but will not when disabled. + */ + honorAccessModifiersForInputBindings: boolean; + /** * Whether to use strict null types for input bindings for directives. * diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts index d2d56de68c..fa46e7f1db 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts @@ -453,8 +453,13 @@ class TcbDirectiveInputsOp extends TcbOp { // declared in a `@Directive` or `@Component` decorator's `inputs` property) there is no // assignment target available, so this field is skipped. continue; - } else if (this.dir.restrictedInputFields.has(fieldName)) { - // To ignore errors, assign to temp variable with type of the field + } else if ( + !this.tcb.env.config.honorAccessModifiersForInputBindings && + this.dir.restrictedInputFields.has(fieldName)) { + // If strict checking of access modifiers is disabled and the field is restricted + // (i.e. private/protected/readonly), generate an assignment into a temporary variable + // that has the type of the field. This achieves type-checking but circumvents the access + // modifiers. const id = this.tcb.allocateId(); const dirTypeRef = this.tcb.env.referenceType(this.dir.ref); if (!ts.isTypeReferenceNode(dirTypeRef)) { @@ -464,23 +469,16 @@ class TcbDirectiveInputsOp extends TcbOp { const type = ts.createIndexedAccessTypeNode( ts.createTypeQueryNode(dirId as ts.Identifier), ts.createLiteralTypeNode(ts.createStringLiteral(fieldName))); - const temp = tsCreateVariable(id, ts.createNonNullExpression(ts.createNull()), type); - addParseSpanInfo(temp, input.attribute.sourceSpan); + const temp = tsDeclareVariable(id, type); this.scope.addStatement(temp); target = id; - - // TODO: To get errors assign directly to the fields on the instance, using dot access - // when possible - } else { - // Otherwise, a declaration exists in which case the `dir["fieldName"]` syntax is used - // as assignment target. An element access is used instead of a property access to - // support input names that are not valid JavaScript identifiers. Additionally, using - // element access syntax does not produce - // TS2341 "Property $prop is private and only accessible within class $class." nor - // TS2445 "Property $prop is protected and only accessible within class $class and its - // subclasses." - target = ts.createElementAccess(dirId, ts.createStringLiteral(fieldName)); + // To get errors assign directly to the fields on the instance, using property access + // when possible. String literal fields may not be valid JS identifiers so we use + // literal element access instead for those cases. + target = this.dir.stringLiteralInputFields.has(fieldName) ? + ts.createElementAccess(dirId, ts.createStringLiteral(fieldName)) : + ts.createPropertyAccess(dirId, ts.createIdentifier(fieldName)); } // Finally the assignment is extended by assigning it into the target expression. diff --git a/packages/compiler-cli/src/ngtsc/typecheck/test/test_utils.ts b/packages/compiler-cli/src/ngtsc/typecheck/test/test_utils.ts index f1e285c25a..37f66200f1 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/test/test_utils.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/test/test_utils.ts @@ -157,6 +157,7 @@ export const ALL_ENABLED_CONFIG: TypeCheckingConfig = { checkQueries: false, checkTemplateBodies: true, checkTypeOfInputBindings: true, + honorAccessModifiersForInputBindings: true, strictNullInputBindings: true, checkTypeOfAttributes: true, // Feature is still in development. @@ -178,10 +179,11 @@ export type TestDirective = Partial>>&{ + 'ref'|'coercedInputFields'|'restrictedInputFields'|'stringLiteralInputFields'| + 'undeclaredInputFields'>>>&{ selector: string, name: string, file?: AbsoluteFsPath, type: 'directive', coercedInputFields?: string[], restrictedInputFields?: string[], - undeclaredInputFields?: string[], isGeneric?: boolean; + stringLiteralInputFields?: string[], undeclaredInputFields?: string[], isGeneric?: boolean; }; export type TestPipe = { name: string, @@ -212,6 +214,7 @@ export function tcb( applyTemplateContextGuards: true, checkQueries: false, checkTypeOfInputBindings: true, + honorAccessModifiersForInputBindings: false, strictNullInputBindings: true, checkTypeOfAttributes: true, checkTypeOfDomBindings: false, @@ -420,6 +423,7 @@ function prepareDeclarations( ngTemplateGuards: decl.ngTemplateGuards || [], coercedInputFields: new Set(decl.coercedInputFields || []), restrictedInputFields: new Set(decl.restrictedInputFields || []), + stringLiteralInputFields: new Set(decl.stringLiteralInputFields || []), undeclaredInputFields: new Set(decl.undeclaredInputFields || []), isGeneric: decl.isGeneric ?? false, outputs: decl.outputs || {}, diff --git a/packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts b/packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts index d4a6b14a55..8a11d6d87c 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts @@ -55,7 +55,7 @@ describe('type check blocks', () => { selector: '[dir]', inputs: {inputA: 'inputA'}, }]; - expect(tcb(TEMPLATE, DIRECTIVES)).toContain('_t2: DirA = (null!); _t2["inputA"] = ("value");'); + expect(tcb(TEMPLATE, DIRECTIVES)).toContain('_t2: DirA = (null!); _t2.inputA = ("value");'); }); it('should handle multiple bindings to the same property', () => { @@ -67,8 +67,8 @@ describe('type check blocks', () => { inputs: {inputA: 'inputA'}, }]; const block = tcb(TEMPLATE, DIRECTIVES); - expect(block).toContain('_t2["inputA"] = (1);'); - expect(block).toContain('_t2["inputA"] = (2);'); + expect(block).toContain('_t2.inputA = (1);'); + expect(block).toContain('_t2.inputA = (2);'); }); it('should handle empty bindings', () => { @@ -79,7 +79,7 @@ describe('type check blocks', () => { selector: '[dir-a]', inputs: {inputA: 'inputA'}, }]; - expect(tcb(TEMPLATE, DIRECTIVES)).toContain('_t2["inputA"] = (undefined);'); + expect(tcb(TEMPLATE, DIRECTIVES)).toContain('_t2.inputA = (undefined);'); }); it('should handle bindings without value', () => { @@ -90,7 +90,7 @@ describe('type check blocks', () => { selector: '[dir-a]', inputs: {inputA: 'inputA'}, }]; - expect(tcb(TEMPLATE, DIRECTIVES)).toContain('_t2["inputA"] = (undefined);'); + expect(tcb(TEMPLATE, DIRECTIVES)).toContain('_t2.inputA = (undefined);'); }); it('should handle implicit vars on ng-template', () => { @@ -322,7 +322,7 @@ describe('type check blocks', () => { expect(tcb(TEMPLATE, DIRECTIVES)) .toContain( 'var _t2: Dir = (null!); ' + - '_t2["input"] = (_t2);'); + '_t2.input = (_t2);'); }); it('should generate circular references between two directives correctly', () => { @@ -350,9 +350,9 @@ describe('type check blocks', () => { .toContain( 'var _t2: DirA = (null!); ' + 'var _t3: DirB = (null!); ' + - '_t2["inputA"] = (_t3); ' + + '_t2.inputA = (_t3); ' + 'var _t4 = document.createElement("div"); ' + - '_t3["inputA"] = (_t2);'); + '_t3.inputA = (_t2);'); }); it('should handle undeclared properties', () => { @@ -372,7 +372,7 @@ describe('type check blocks', () => { '(((ctx).foo)); '); }); - it('should handle restricted properties', () => { + it('should assign restricted properties to temp variables by default', () => { const TEMPLATE = `
`; const DIRECTIVES: TestDeclaration[] = [{ type: 'directive', @@ -390,6 +390,24 @@ describe('type check blocks', () => { '_t3 = (((ctx).foo)); '); }); + it('should assign properties via element access for field names that are not JS identifiers', + () => { + const TEMPLATE = `
`; + const DIRECTIVES: TestDeclaration[] = [{ + type: 'directive', + name: 'Dir', + selector: '[dir]', + inputs: { + 'some-input.xs': 'inputA', + }, + stringLiteralInputFields: ['some-input.xs'], + }]; + const block = tcb(TEMPLATE, DIRECTIVES); + expect(block).toContain( + 'var _t2: Dir = (null!); ' + + '_t2["some-input.xs"] = (((ctx).foo)); '); + }); + it('should handle a single property bound to multiple fields', () => { const TEMPLATE = `
`; const DIRECTIVES: TestDeclaration[] = [{ @@ -404,7 +422,7 @@ describe('type check blocks', () => { expect(tcb(TEMPLATE, DIRECTIVES)) .toContain( 'var _t2: Dir = (null!); ' + - '_t2["field2"] = _t2["field1"] = (((ctx).foo));'); + '_t2.field2 = _t2.field1 = (((ctx).foo));'); }); it('should handle a single property bound to multiple fields, where one of them is coerced', @@ -424,7 +442,7 @@ describe('type check blocks', () => { .toContain( 'var _t2: Dir = (null!); ' + 'var _t3: typeof Dir.ngAcceptInputType_field1 = (null!); ' + - '_t2["field2"] = _t3 = (((ctx).foo));'); + '_t2.field2 = _t3 = (((ctx).foo));'); }); it('should handle a single property bound to multiple fields, where one of them is undeclared', @@ -443,7 +461,7 @@ describe('type check blocks', () => { expect(tcb(TEMPLATE, DIRECTIVES)) .toContain( 'var _t2: Dir = (null!); ' + - '_t2["field2"] = (((ctx).foo));'); + '_t2.field2 = (((ctx).foo));'); }); it('should use coercion types if declared', () => { @@ -590,6 +608,7 @@ describe('type check blocks', () => { checkQueries: false, checkTemplateBodies: true, checkTypeOfInputBindings: true, + honorAccessModifiersForInputBindings: false, strictNullInputBindings: true, checkTypeOfAttributes: true, checkTypeOfDomBindings: false, @@ -639,14 +658,14 @@ describe('type check blocks', () => { it('should include null and undefined when enabled', () => { const block = tcb(TEMPLATE, DIRECTIVES); - expect(block).toContain('_t2["dirInput"] = (((ctx).a));'); + expect(block).toContain('_t2.dirInput = (((ctx).a));'); expect(block).toContain('((ctx).b);'); }); it('should use the non-null assertion operator when disabled', () => { const DISABLED_CONFIG: TypeCheckingConfig = {...BASE_CONFIG, strictNullInputBindings: false}; const block = tcb(TEMPLATE, DIRECTIVES, DISABLED_CONFIG); - expect(block).toContain('_t2["dirInput"] = (((ctx).a)!);'); + expect(block).toContain('_t2.dirInput = (((ctx).a)!);'); expect(block).toContain('((ctx).b)!;'); }); }); @@ -655,7 +674,7 @@ describe('type check blocks', () => { it('should check types of bindings when enabled', () => { const TEMPLATE = `
`; const block = tcb(TEMPLATE, DIRECTIVES); - expect(block).toContain('_t2["dirInput"] = (((ctx).a));'); + expect(block).toContain('_t2.dirInput = (((ctx).a));'); expect(block).toContain('((ctx).b);'); }); @@ -664,7 +683,7 @@ describe('type check blocks', () => { const DISABLED_CONFIG: TypeCheckingConfig = {...BASE_CONFIG, checkTypeOfInputBindings: false}; const block = tcb(TEMPLATE, DIRECTIVES, DISABLED_CONFIG); - expect(block).toContain('_t2["dirInput"] = ((((ctx).a) as any));'); + expect(block).toContain('_t2.dirInput = ((((ctx).a) as any));'); expect(block).toContain('(((ctx).b) as any);'); }); @@ -673,7 +692,7 @@ describe('type check blocks', () => { const DISABLED_CONFIG: TypeCheckingConfig = {...BASE_CONFIG, checkTypeOfInputBindings: false}; const block = tcb(TEMPLATE, DIRECTIVES, DISABLED_CONFIG); - expect(block).toContain('_t2["dirInput"] = ((((((ctx).a)) === (((ctx).b))) as any));'); + expect(block).toContain('_t2.dirInput = ((((((ctx).a)) === (((ctx).b))) as any));'); }); }); @@ -793,9 +812,9 @@ describe('type check blocks', () => { it('should assign string value to the input when enabled', () => { const block = tcb(TEMPLATE, DIRECTIVES); - expect(block).toContain('_t2["disabled"] = ("");'); - expect(block).toContain('_t2["cols"] = ("3");'); - expect(block).toContain('_t2["rows"] = (2);'); + expect(block).toContain('_t2.disabled = ("");'); + expect(block).toContain('_t2.cols = ("3");'); + expect(block).toContain('_t2.rows = (2);'); }); it('should use any for attributes but still check bound attributes when disabled', () => { @@ -803,7 +822,7 @@ describe('type check blocks', () => { const block = tcb(TEMPLATE, DIRECTIVES, DISABLED_CONFIG); expect(block).not.toContain('"disabled"'); expect(block).not.toContain('"cols"'); - expect(block).toContain('_t2["rows"] = (2);'); + expect(block).toContain('_t2.rows = (2);'); }); }); @@ -873,5 +892,47 @@ describe('type check blocks', () => { expect(block).toContain('function Test_TCB(ctx: Test)'); }); }); + + describe('config.checkAccessModifiersForInputBindings', () => { + const TEMPLATE = `
`; + + it('should assign restricted properties via element access for field names that are not JS identifiers', + () => { + const DIRECTIVES: TestDeclaration[] = [{ + type: 'directive', + name: 'Dir', + selector: '[dir]', + inputs: { + 'some-input.xs': 'inputA', + }, + restrictedInputFields: ['some-input.xs'], + stringLiteralInputFields: ['some-input.xs'], + }]; + const enableChecks: + TypeCheckingConfig = {...BASE_CONFIG, honorAccessModifiersForInputBindings: true}; + const block = tcb(TEMPLATE, DIRECTIVES, enableChecks); + expect(block).toContain( + 'var _t2: Dir = (null!); ' + + '_t2["some-input.xs"] = (((ctx).foo)); '); + }); + + it('should assign restricted properties via property access', () => { + const DIRECTIVES: TestDeclaration[] = [{ + type: 'directive', + name: 'Dir', + selector: '[dir]', + inputs: { + fieldA: 'inputA', + }, + restrictedInputFields: ['fieldA'] + }]; + const enableChecks: + TypeCheckingConfig = {...BASE_CONFIG, honorAccessModifiersForInputBindings: true}; + const block = tcb(TEMPLATE, DIRECTIVES, enableChecks); + expect(block).toContain( + 'var _t2: Dir = (null!); ' + + '_t2.fieldA = (((ctx).foo)); '); + }); + }); }); }); diff --git a/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts b/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts index 5646850c22..dc8dd1af02 100644 --- a/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts +++ b/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts @@ -1577,14 +1577,7 @@ export declare class AnimationEvent { } `; - describe('with strict inputs', () => { - beforeEach(() => { - env.tsconfig({fullTemplateTypeCheck: true, strictInputTypes: true}); - }); - - it('should not produce diagnostics for correct inputs which assign to readonly, private, or protected fields', - () => { - env.write('test.ts', ` + const correctTypeInputsToRestrictedFields = ` import {Component, NgModule, Input, Directive} from '@angular/core'; @Component({ @@ -1601,14 +1594,9 @@ export declare class AnimationEvent { declarations: [FooCmp, TestDir], }) export class FooModule {} - `); - const diags = env.driveDiagnostics(); - expect(diags.length).toBe(0); - }); + `; - it('should not produce diagnostics for correct inputs which assign to readonly, private, or protected fields inherited from a base class', - () => { - env.write('test.ts', ` + const correctInputsToRestrictedFieldsFromBaseClass = ` import {Component, NgModule, Input, Directive} from '@angular/core'; @Component({ @@ -1629,7 +1617,85 @@ export declare class AnimationEvent { declarations: [FooCmp, ChildDir], }) export class FooModule {} - `); + `; + describe('with strictInputAccessModifiers', () => { + beforeEach(() => { + env.tsconfig({ + fullTemplateTypeCheck: true, + strictInputTypes: true, + strictInputAccessModifiers: true + }); + }); + + it('should produce diagnostics for inputs which assign to readonly, private, and protected fields', + () => { + env.write('test.ts', correctTypeInputsToRestrictedFields); + expectIllegalAssignmentErrors(env.driveDiagnostics()); + }); + + it('should produce diagnostics for inputs which assign to readonly, private, and protected fields inherited from a base class', + () => { + env.write('test.ts', correctInputsToRestrictedFieldsFromBaseClass); + expectIllegalAssignmentErrors(env.driveDiagnostics()); + }); + + function expectIllegalAssignmentErrors(diags: ReadonlyArray) { + expect(diags.length).toBe(3); + const actualMessages = diags.map(d => d.messageText).sort(); + const expectedMessages = [ + `Property 'protectedField' is protected and only accessible within class 'TestDir' and its subclasses.`, + `Property 'privateField' is private and only accessible within class 'TestDir'.`, + `Cannot assign to 'readonlyField' because it is a read-only property.`, + ].sort(); + expect(actualMessages).toEqual(expectedMessages); + } + + it('should report invalid type assignment when field name is not a valid JS identifier', + () => { + env.write('test.ts', ` + import {Component, NgModule, Input, Directive} from '@angular/core'; + + @Component({ + selector: 'blah', + template: '
', + }) + export class FooCmp { + value = 5; + } + + @Directive({selector: '[dir]'}) + export class TestDir { + @Input() + private 'private-input.xs'!: string; + } + + @NgModule({ + declarations: [FooCmp, TestDir], + }) + export class FooModule {} + `); + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(1); + expect(diags[0].messageText) + .toEqual(`Type 'number' is not assignable to type 'string'.`); + }); + }); + + describe('with strict inputs', () => { + beforeEach(() => { + env.tsconfig({fullTemplateTypeCheck: true, strictInputTypes: true}); + }); + + it('should not produce diagnostics for correct inputs which assign to readonly, private, or protected fields', + () => { + env.write('test.ts', correctTypeInputsToRestrictedFields); + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(0); + }); + + it('should not produce diagnostics for correct inputs which assign to readonly, private, or protected fields inherited from a base class', + () => { + env.write('test.ts', correctInputsToRestrictedFieldsFromBaseClass); const diags = env.driveDiagnostics(); expect(diags.length).toBe(0); }); From e2e5f83869bd0513b034639b71f75c35cf8d4bb4 Mon Sep 17 00:00:00 2001 From: Andrew Kushnir Date: Tue, 11 Aug 2020 10:34:59 -0700 Subject: [PATCH 017/629] ci: update payload size limits for Closure tests (#38411) Currently the Closure-related tests are not tree-shaking the dev-mode-only content, thus payload size checks are failing even if dev-mode-only content is added. The https://github.com/angular/angular/commit/2e9fdbde9eb26fea17e3e68e272dc1c2cc9f4fa3 commit added some logic to JIT compiler, which is likely triggered the payload size increase. This commit updates the payload size limits for Closure-related test to get master and patch branches back to the "green" state. PR Close #38411 --- goldens/size-tracking/integration-payloads.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/goldens/size-tracking/integration-payloads.json b/goldens/size-tracking/integration-payloads.json index 5e0e6dd3ee..2d5ce83c09 100644 --- a/goldens/size-tracking/integration-payloads.json +++ b/goldens/size-tracking/integration-payloads.json @@ -62,7 +62,7 @@ "bundle": "TODO(i): we should define ngDevMode to false in Closure, but --define only works in the global scope.", "bundle": "TODO(i): (FW-2164) TS 3.9 new class shape seems to have broken Closure in big ways. The size went from 169991 to 252338", "bundle": "TODO(i): after removal of tsickle from ngc-wrapped / ng_package, we had to switch to SIMPLE optimizations which increased the size from 252338 to 1198917, see PR#37221 and PR#37317 for more info", - "bundle": 1214317 + "bundle": 1214857 } } } From a6292faa97556fb85974e6066e947d4317c62ad5 Mon Sep 17 00:00:00 2001 From: Alan Agius Date: Mon, 10 Aug 2020 13:48:41 +0200 Subject: [PATCH 018/629] docs: remove solution style tsconfig (#38394) Following the issues highlighted in https://docs.google.com/document/d/1eB6cGCG_2ircfS5GzpDC9dBgikeYYcMxghVH5sDESHw/edit?usp=sharing and discussions held with the TypeScript team. Together with the TypeScript team it was decided that the best course of action is to rollback this feature. In future, it is not excluded that solution style tsconfigs are re-introduced. See: https://github.com/angular/angular-cli/pull/18478 PR Close #38394 --- aio/content/guide/angular-compiler-options.md | 2 +- aio/content/guide/file-structure.md | 5 +- aio/content/guide/ivy-compatibility.md | 4 +- .../migration-solution-style-tsconfig.md | 55 ------------------- ...date-module-and-target-compiler-options.md | 2 +- aio/content/guide/typescript-configuration.md | 31 +---------- aio/content/guide/universal.md | 3 +- aio/content/guide/updating-to-version-10.md | 1 - aio/content/navigation.json | 5 -- 9 files changed, 10 insertions(+), 98 deletions(-) delete mode 100644 aio/content/guide/migration-solution-style-tsconfig.md diff --git a/aio/content/guide/angular-compiler-options.md b/aio/content/guide/angular-compiler-options.md index 90101d8eff..390147cc1d 100644 --- a/aio/content/guide/angular-compiler-options.md +++ b/aio/content/guide/angular-compiler-options.md @@ -31,7 +31,7 @@ For example: ```json { - "extends": "../tsconfig.base.json", + "extends": "../tsconfig.json", "compilerOptions": { "experimentalDecorators": true, ... diff --git a/aio/content/guide/file-structure.md b/aio/content/guide/file-structure.md index 5b63edcb8e..b78324814f 100644 --- a/aio/content/guide/file-structure.md +++ b/aio/content/guide/file-structure.md @@ -40,8 +40,7 @@ The top level of the workspace contains workspace-wide configuration files, conf | `package-lock.json` | Provides version information for all packages installed into `node_modules` by the npm client. See [npm documentation](https://docs.npmjs.com/files/package-lock.json) for details. If you use the yarn client, this file will be [yarn.lock](https://yarnpkg.com/lang/en/docs/yarn-lock/) instead. | | `src/` | Source files for the root-level application project. | | `node_modules/` | Provides [npm packages](guide/npm-packages) to the entire workspace. Workspace-wide `node_modules` dependencies are visible to all projects. | -| `tsconfig.json` | The `tsconfig.json` file is a ["Solution Style"](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-9.html#support-for-solution-style-tsconfigjson-files) TypeScript configuration file. Code editors and TypeScript’s language server use this file to improve development experience. Compilers do not use this file. | -| `tsconfig.base.json` | The base [TypeScript](https://www.typescriptlang.org/) configuration for projects in the workspace. All other configuration files inherit from this base file. For more information, see the [Configuration inheritance with extends](https://www.typescriptlang.org/docs/handbook/tsconfig-json.html#configuration-inheritance-with-extends) section of the TypeScript documentation.| +| `tsconfig.json` | The base [TypeScript](https://www.typescriptlang.org/) configuration for projects in the workspace. All other configuration files inherit from this base file. For more information, see the [Configuration inheritance with extends](https://www.typescriptlang.org/docs/handbook/tsconfig-json.html#configuration-inheritance-with-extends) section of the TypeScript documentation.| | `tslint.json` | Default [TSLint](https://palantir.github.io/tslint/) configuration for projects in the workspace. | @@ -103,7 +102,7 @@ Angular components, templates, and styles go here. The application-specific configuration files for the root application reside at the workspace root level. For a multi-project workspace, project-specific configuration files are in the project root, under `projects/project-name/`. -Project-specific [TypeScript](https://www.typescriptlang.org/) configuration files inherit from the workspace-wide `tsconfig.base.json`, and project-specific [TSLint](https://palantir.github.io/tslint/) configuration files inherit from the workspace-wide `tslint.json`. +Project-specific [TypeScript](https://www.typescriptlang.org/) configuration files inherit from the workspace-wide `tsconfig.json`, and project-specific [TSLint](https://palantir.github.io/tslint/) configuration files inherit from the workspace-wide `tslint.json`. | APPLICATION-SPECIFIC CONFIG FILES | PURPOSE | | :--------------------- | :------------------------------------------| diff --git a/aio/content/guide/ivy-compatibility.md b/aio/content/guide/ivy-compatibility.md index ee4632316d..025410c67f 100644 --- a/aio/content/guide/ivy-compatibility.md +++ b/aio/content/guide/ivy-compatibility.md @@ -11,11 +11,11 @@ That said, some applications will likely need to apply some manual updates. In version 10, [a few deprecated APIs have been removed](guide/updating-to-version-10#removals) and there are a [few breaking changes](guide/updating-to-version-10#breaking-changes) unrelated to Ivy. If you're seeing errors after updating to version 9, you'll first want to rule those changes out. -To do so, temporarily [turn off Ivy](guide/ivy#opting-out-of-angular-ivy) in your `tsconfig.base.json` and re-start your app. +To do so, temporarily [turn off Ivy](guide/ivy#opting-out-of-angular-ivy) in your `tsconfig.json` and re-start your app. If you're still seeing the errors, they are not specific to Ivy. In this case, you may want to consult the [general version 10 guide](guide/updating-to-version-10). If you've opted into any of the new, stricter type-checking settings, you may also want to check out the [template type-checking guide](guide/template-typecheck). -If the errors are gone, switch back to Ivy by removing the changes to the `tsconfig.base.json` and review the list of expected changes below. +If the errors are gone, switch back to Ivy by removing the changes to the `tsconfig.json` and review the list of expected changes below. {@a payload-size-debugging} ### Payload size debugging diff --git a/aio/content/guide/migration-solution-style-tsconfig.md b/aio/content/guide/migration-solution-style-tsconfig.md deleted file mode 100644 index 211ff5f986..0000000000 --- a/aio/content/guide/migration-solution-style-tsconfig.md +++ /dev/null @@ -1,55 +0,0 @@ -# Solution-style `tsconfig.json` migration - -## What does this migration do? - -This migration adds support to existing projects for TypeScript's new ["solution-style" tsconfig feature](https://devblogs.microsoft.com/typescript/announcing-typescript-3-9/#solution-style-tsconfig). - -Support is added by making two changes: - -1. Renaming the workspace-level `tsconfig.json` to `tsconfig.base.json`. -All project [TypeScript configuration files](guide/typescript-configuration) will extend from this base which contains the common options used throughout the workspace. - -2. Adding the solution `tsconfig.json` file at the root of the workspace. -This `tsconfig.json` file will only contain references to project-level TypeScript configuration files and is only used by editors/IDEs. - -As an example, the solution `tsconfig.json` for a new project is as follows: -```json -// This is a "Solution Style" tsconfig.json file, and is used by editors and TypeScript’s language server to improve development experience. -// It is not intended to be used to perform a compilation. -{ - "files": [], - "references": [ - { - "path": "./tsconfig.app.json" - }, - { - "path": "./tsconfig.spec.json" - }, - { - "path": "./e2e/tsconfig.json" - } - ] -} -``` - -## Why is this migration necessary? - -Solution-style `tsconfig.json` files provide an improved editing experience and fix several long-standing defects when editing files in an IDE. -IDEs that leverage the TypeScript language service (for example, [Visual Studio Code](https://code.visualstudio.com)), will only use TypeScript configuration files that are named `tsconfig.json`. -In complex projects, there may be more than one compilation unit and each of these units may have different settings and options. - -With the Angular CLI, a project will have application code that will target a browser. -It will also have unit tests that should not be included within the built application and that also need additional type information present (`jasmine` in this case). -Both parts of the project also share some but not all of the code within the project. -As a result, two separate TypeScript configuration files (`tsconfig.app.json` and `tsconfig.spec.json`) are needed to ensure that each part of the application is configured properly and that the right types are used for each part. -Also if web workers are used within a project, an additional tsconfig (`tsconfig.worker.json`) is needed. -Web workers use similar but incompatible types to the main browser application. -This requires the additional configuration file to ensure that the web worker files use the appropriate types and will build successfully. - -While the Angular build system knows about all of these TypeScript configuration files, an IDE using TypeScript's language service does not. -Because of this, an IDE will not be able to properly analyze the code from each part of the project and may generate false errors or make suggestions that are incorrect for certain files. -By leveraging the new solution-style tsconfig, the IDE can now be aware of the configuration of each part of a project. -This allows each file to be treated appropriately based on its tsconfig. -IDE features such as error/warning reporting and auto-suggestion will operate more effectively as well. - -The TypeScript 3.9 release [blog post](https://devblogs.microsoft.com/typescript/announcing-typescript-3-9/#solution-style-tsconfig) also contains some additional information regarding this new feature. diff --git a/aio/content/guide/migration-update-module-and-target-compiler-options.md b/aio/content/guide/migration-update-module-and-target-compiler-options.md index 259f85d93a..e5a2cd588b 100644 --- a/aio/content/guide/migration-update-module-and-target-compiler-options.md +++ b/aio/content/guide/migration-update-module-and-target-compiler-options.md @@ -9,7 +9,7 @@ This process helps ensure that intentional changes to the options are kept in pl TypeScript Configuration File(s) | Changed Property | Existing Value | New Value ------------- | ------------- | ------------- | ------------- | ------------- -`/tsconfig.base.json` | `"module"` | `"esnext"` | `"es2020"` +`/tsconfig.json` | `"module"` | `"esnext"` | `"es2020"` Used in `browser` builder options (`ng build` for applications) | `"module"` | `"esnext"` | `"es2020"` Used in `ng-packgr` builder options (`ng build` for libraries) | `"module"` | `"esnext"` | `"es2020"` Used in `karma` builder options (`ng test` for applications) | `"module"` | `"esnext"` | `"es2020"` diff --git a/aio/content/guide/typescript-configuration.md b/aio/content/guide/typescript-configuration.md index 3561008b20..1d058045c3 100644 --- a/aio/content/guide/typescript-configuration.md +++ b/aio/content/guide/typescript-configuration.md @@ -18,32 +18,7 @@ that are important to Angular developers, including details about the following ## Configuration files A given Angular workspace contains several TypeScript configuration files. -At the root level, there are two main TypeScript configuration files: a `tsconfig.json` file and a `tsconfig.base.json` file. - -The `tsconfig.json` file is a ["Solution Style"](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-9.html#support-for-solution-style-tsconfigjson-files) TypeScript configuration file. -Code editors and TypeScript’s language server use this file to improve development experience. -Compilers do not use this file. - -The `tsconfig.json` file contains a list of paths to the other TypeScript configuration files used in the workspace. - - -{ - "files": [], - "references": [ - { - "path": "./tsconfig.app.json" - }, - { - "path": "./tsconfig.spec.json" - }, - { - "path": "./projects/my-lib/tsconfig.lib.json" - } - ] -} - - -The `tsconfig.base.json` file specifies the base TypeScript and Angular compiler options that all projects in the workspace inherit. +At the root `tsconfig.json` file specifies the base TypeScript and Angular compiler options that all projects in the workspace inherit. The TypeScript and Angular have a wide range of options which can be used to configure type-checking features and generated output. For more information, see the [Configuration inheritance with extends](https://www.typescriptlang.org/docs/handbook/tsconfig-json.html#configuration-inheritance-with-extends) section of the TypeScript documentation. @@ -55,9 +30,9 @@ For details about configuration inheritance, see the [Configuration inheritance -The initial `tsconfig.base.json` for an Angular workspace typically looks like the following example. +The initial `tsconfig.json` for an Angular workspace typically looks like the following example. - + { "compileOnSave": false, "compilerOptions": { diff --git a/aio/content/guide/universal.md b/aio/content/guide/universal.md index d9f1bea9df..a96a9788df 100644 --- a/aio/content/guide/universal.md +++ b/aio/content/guide/universal.md @@ -48,8 +48,7 @@ src/ app/ ... application code app.server.module.ts * server-side application module server.ts * express web server -tsconfig.json TypeScript solution style configuration -tsconfig.base.json TypeScript base configuration +tsconfig.json TypeScript base configuration tsconfig.app.json TypeScript browser application configuration tsconfig.server.json TypeScript server application configuration tsconfig.spec.json TypeScript tests configuration diff --git a/aio/content/guide/updating-to-version-10.md b/aio/content/guide/updating-to-version-10.md index 24abe1fa40..b7e56c6c4e 100644 --- a/aio/content/guide/updating-to-version-10.md +++ b/aio/content/guide/updating-to-version-10.md @@ -77,6 +77,5 @@ Read about the migrations the CLI handles for you automatically: * [Migrating missing `@Directive()`/`@Component()` decorators](guide/migration-undecorated-classes) * [Migrating `ModuleWithProviders`](guide/migration-module-with-providers) -* [Solution-style `tsconfig.json` migration](guide/migration-solution-style-tsconfig) * [`tslib` direct dependency migration](guide/migration-update-libraries-tslib) * [Update `module` and `target` compiler options migration](guide/migration-update-module-and-target-compiler-options) diff --git a/aio/content/navigation.json b/aio/content/navigation.json index 2f3e850f45..07007677ce 100644 --- a/aio/content/navigation.json +++ b/aio/content/navigation.json @@ -858,11 +858,6 @@ "title": "Missing @Injectable() Decorators", "tooltip": "Migration to add missing @Injectable() decorators and incomplete provider definitions." }, - { - "url": "guide/migration-solution-style-tsconfig", - "title": "Solution-style `tsconfig.json`", - "tooltip": "Migration to create a solution-style `tsconfig.json`." - }, { "url": "guide/migration-update-libraries-tslib", "title": "`tslib` direct dependency", From 28534d83eeaf721c117af6be6a7f6da20ab352e5 Mon Sep 17 00:00:00 2001 From: Joey Perrott Date: Tue, 11 Aug 2020 16:08:35 -0700 Subject: [PATCH 019/629] feat(dev-infra): Add support for formatting all staged files (#38402) Adds an ng-dev formatter option to format all of the staged files. This will can be used to format only the staged files during the pre-commit hook. PR Close #38402 --- dev-infra/format/cli.ts | 8 +++++++- dev-infra/utils/repo-files.ts | 12 ++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/dev-infra/format/cli.ts b/dev-infra/format/cli.ts index 68ddff86e9..8c635575ab 100644 --- a/dev-infra/format/cli.ts +++ b/dev-infra/format/cli.ts @@ -7,7 +7,7 @@ */ import * as yargs from 'yargs'; -import {allChangedFilesSince, allFiles} from '../utils/repo-files'; +import {allChangedFilesSince, allFiles, allStagedFiles} from '../utils/repo-files'; import {checkFiles, formatFiles} from './format'; @@ -34,6 +34,12 @@ export function buildFormatParser(localYargs: yargs.Argv) { const executionCmd = check ? checkFiles : formatFiles; executionCmd(allChangedFilesSince(sha)); }) + .command( + 'staged', 'Run the formatter on all staged files', {}, + ({check}) => { + const executionCmd = check ? checkFiles : formatFiles; + executionCmd(allStagedFiles()); + }) .command('files ', 'Run the formatter on provided files', {}, ({check, files}) => { const executionCmd = check ? checkFiles : formatFiles; executionCmd(files); diff --git a/dev-infra/utils/repo-files.ts b/dev-infra/utils/repo-files.ts index 7cfbc09ec4..b509e90fc0 100644 --- a/dev-infra/utils/repo-files.ts +++ b/dev-infra/utils/repo-files.ts @@ -27,6 +27,18 @@ export function allChangedFilesSince(sha = 'HEAD') { return Array.from(new Set([...diffFiles, ...untrackedFiles])); } +/** + * A list of all staged files which have been modified. + * + * Only added, created and modified files are listed as others (deleted, renamed, etc) aren't + * changed or available as content to act upon. + */ +export function allStagedFiles() { + return gitOutputAsArray(`git diff --staged --name-only --diff-filter=ACM`); +} + + + export function allFiles() { return gitOutputAsArray(`git ls-files`); } From a2e069fdda3bd1c54783d8868ce86e10ae12eea1 Mon Sep 17 00:00:00 2001 From: Joey Perrott Date: Tue, 11 Aug 2020 16:09:00 -0700 Subject: [PATCH 020/629] build: run formatting automatically on pre-commit hook (#38402) Runs the `ng-dev format changed` command whenever the `git commit` command is run. As all changes which are checked by CI will require this check passing, this change can prevent needless roundtrips to correct lint/formatting errors. This automatic formatting can be bypassed with the `--no-verify` flag on the `git commit` command. PR Close #38402 --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 350be9de2d..ee23d0a23c 100644 --- a/package.json +++ b/package.json @@ -209,6 +209,7 @@ "cldr-data-coverage": "full", "husky": { "hooks": { + "pre-commit": "yarn -s ng-dev format staged", "commit-msg": "yarn -s ng-dev commit-message pre-commit-validate --file-env-variable HUSKY_GIT_PARAMS" } } From 4d1741856995254eea73c7e8678aa50849a28e9a Mon Sep 17 00:00:00 2001 From: Vlad GURDIGA Date: Tue, 4 Aug 2020 10:35:00 +0300 Subject: [PATCH 021/629] docs: delete one superfluous sentence (#38339) PR Close #38339 --- aio/content/tutorial/toh-pt4.md | 1 - 1 file changed, 1 deletion(-) diff --git a/aio/content/tutorial/toh-pt4.md b/aio/content/tutorial/toh-pt4.md index 6c7bb8cbfe..0906563d5b 100644 --- a/aio/content/tutorial/toh-pt4.md +++ b/aio/content/tutorial/toh-pt4.md @@ -385,7 +385,6 @@ next section on [Routing](tutorial/toh-pt5). path="toh-pt4/src/app/heroes/heroes.component.ts"> -The browser refreshes and the page displays the list of heroes. Refresh the browser to see the list of heroes, and scroll to the bottom to see the messages from the HeroService. Each time you click a hero, a new message appears to record the selection. Use the "clear" button to clear the message history. From dcf7baf3d1a0089bb33ad59930583085c0398280 Mon Sep 17 00:00:00 2001 From: Andrew Kushnir Date: Wed, 12 Aug 2020 09:49:28 -0700 Subject: [PATCH 022/629] docs: release notes for the v10.0.9 release --- CHANGELOG.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a466e36b1..e2f52c307c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,25 @@ + +## 10.0.9 (2020-08-12) + + +### Bug Fixes + +* **common:** ensure scrollRestoration is writable ([#30630](https://github.com/angular/angular/issues/30630)) ([#38357](https://github.com/angular/angular/issues/38357)) ([58f4b3a](https://github.com/angular/angular/commit/58f4b3a)), closes [#30629](https://github.com/angular/angular/issues/30629) +* **compiler:** evaluate safe navigation expressions in correct binding order ([#37911](https://github.com/angular/angular/issues/37911)) ([f5b9d87](https://github.com/angular/angular/commit/f5b9d87)), closes [#37194](https://github.com/angular/angular/issues/37194) +* **compiler-cli:** avoid creating value expressions for symbols from type-only imports ([#38415](https://github.com/angular/angular/issues/38415)) ([ca2b4bc](https://github.com/angular/angular/commit/ca2b4bc)), closes [#37912](https://github.com/angular/angular/issues/37912) +* **compiler-cli:** infer quote expressions as any type in type checker ([#37917](https://github.com/angular/angular/issues/37917)) ([5b87c67](https://github.com/angular/angular/commit/5b87c67)), closes [#36568](https://github.com/angular/angular/issues/36568) +* **compiler-cli:** mark eager `NgModuleFactory` construction as not side effectful ([#38320](https://github.com/angular/angular/issues/38320)) ([016a41b](https://github.com/angular/angular/commit/016a41b)), closes [#38147](https://github.com/angular/angular/issues/38147) +* **compiler-cli:** match wrapHost parameter types within plugin interface ([#38004](https://github.com/angular/angular/issues/38004)) ([df01a82](https://github.com/angular/angular/commit/df01a82)) +* **compiler-cli:** preserve quotes in class member names ([#38387](https://github.com/angular/angular/issues/38387)) ([c9acb7b](https://github.com/angular/angular/commit/c9acb7b)), closes [#38311](https://github.com/angular/angular/issues/38311) +* **core:** prevent NgModule scope being overwritten in JIT compiler ([#37795](https://github.com/angular/angular/issues/37795)) ([3acebdc](https://github.com/angular/angular/commit/3acebdc)), closes [#37105](https://github.com/angular/angular/issues/37105) +* **core:** queries not matching string injection tokens ([#38321](https://github.com/angular/angular/issues/38321)) ([32109dc](https://github.com/angular/angular/commit/32109dc)), closes [#38313](https://github.com/angular/angular/issues/38313) [#38315](https://github.com/angular/angular/issues/38315) +* **core:** Store the currently selected ICU in `LView` ([#38345](https://github.com/angular/angular/issues/38345)) ([ee5123f](https://github.com/angular/angular/commit/ee5123f)) +* **platform-server:** remove styles added by ServerStylesHost on destruction ([#38367](https://github.com/angular/angular/issues/38367)) ([7f11149](https://github.com/angular/angular/commit/7f11149)) +* **router:** prevent calling unsubscribe on undefined subscription in RouterPreloader ([#38344](https://github.com/angular/angular/issues/38344)) ([4151314](https://github.com/angular/angular/commit/4151314)) +* **service-worker:** fix the chrome debugger syntax highlighter ([#38332](https://github.com/angular/angular/issues/38332)) ([f5d5bac](https://github.com/angular/angular/commit/f5d5bac)) + + + # 10.1.0-next.4 (2020-08-04) From d6d7caa2a8e8cf26f324ee909a5050625e85683e Mon Sep 17 00:00:00 2001 From: Andrew Kushnir Date: Wed, 12 Aug 2020 09:57:20 -0700 Subject: [PATCH 023/629] release: cut the v10.1.0-next.5 release --- CHANGELOG.md | 19 +++++++++++++++++++ package.json | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e2f52c307c..96d4a2aadb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,22 @@ + +# 10.1.0-next.5 (2020-08-12) + + +### Bug Fixes + +* **compiler-cli:** avoid creating value expressions for symbols from type-only imports ([#37912](https://github.com/angular/angular/issues/37912)) ([18098d3](https://github.com/angular/angular/commit/18098d3)), closes [#37900](https://github.com/angular/angular/issues/37900) +* **compiler-cli:** type-check inputs that include undefined when there's coercion members ([#38273](https://github.com/angular/angular/issues/38273)) ([7525f3a](https://github.com/angular/angular/commit/7525f3a)) +* **router:** defer loading of wildcard module until needed ([#38348](https://github.com/angular/angular/issues/38348)) ([8f708b5](https://github.com/angular/angular/commit/8f708b5)), closes [#25494](https://github.com/angular/angular/issues/25494) +* **router:** restore 'history.state' object for navigations coming from Angular router ([#28108](https://github.com/angular/angular/issues/28108)) ([#28176](https://github.com/angular/angular/issues/28176)) ([df76a20](https://github.com/angular/angular/commit/df76a20)) + + +### Features + +* **compiler-cli:** Add compiler option to report errors when assigning to restricted input fields ([#38249](https://github.com/angular/angular/issues/38249)) ([71138f6](https://github.com/angular/angular/commit/71138f6)) +* **router:** better warning message when a router outlet has not been instantiated ([#30246](https://github.com/angular/angular/issues/30246)) ([1609815](https://github.com/angular/angular/commit/1609815)) + + + ## 10.0.9 (2020-08-12) diff --git a/package.json b/package.json index ee23d0a23c..ca078c921d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "angular-srcs", - "version": "10.1.0-next.4", + "version": "10.1.0-next.5", "private": true, "description": "Angular - a web framework for modern web apps", "homepage": "https://github.com/angular/angular", From 823dd5b3412bf97ea75cf9aac156ab747f201a8d Mon Sep 17 00:00:00 2001 From: Alan Agius Date: Wed, 12 Aug 2020 10:44:45 +0200 Subject: [PATCH 024/629] docs: update web-worker CLI commands to bash style (#38421) With this change we update the CLI generate commands to be in bash style. PR Close #38421 --- aio/content/guide/web-worker.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/aio/content/guide/web-worker.md b/aio/content/guide/web-worker.md index cdf2dd8a8e..8151625e98 100644 --- a/aio/content/guide/web-worker.md +++ b/aio/content/guide/web-worker.md @@ -14,12 +14,16 @@ The CLI does not support running Angular itself in a web worker. To add a web worker to an existing project, use the Angular CLI `ng generate` command. -`ng generate web-worker` *location* +```bash +ng generate web-worker +``` You can add a web worker anywhere in your application. For example, to add a web worker to the root component, `src/app/app.component.ts`, run the following command. -`ng generate web-worker app` +```bash +ng generate web-worker app +``` The command performs the following actions. From aa3520eb7d7bba15b65df04800037c4149c9f45d Mon Sep 17 00:00:00 2001 From: Joey Perrott Date: Tue, 11 Aug 2020 15:29:11 -0700 Subject: [PATCH 025/629] refactor(dev-infra): use promptConfirm util in ng-dev's formatter (#38419) Use the promptConfirm util instead of manually creating a confirm prompt with inquirer. PR Close #38419 --- dev-infra/format/BUILD.bazel | 2 -- dev-infra/format/format.ts | 10 ++-------- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/dev-infra/format/BUILD.bazel b/dev-infra/format/BUILD.bazel index 2b340e6aeb..73f836bc5f 100644 --- a/dev-infra/format/BUILD.bazel +++ b/dev-infra/format/BUILD.bazel @@ -10,12 +10,10 @@ ts_library( deps = [ "//dev-infra/utils", "@npm//@types/cli-progress", - "@npm//@types/inquirer", "@npm//@types/node", "@npm//@types/shelljs", "@npm//@types/yargs", "@npm//cli-progress", - "@npm//inquirer", "@npm//multimatch", "@npm//shelljs", "@npm//yargs", diff --git a/dev-infra/format/format.ts b/dev-infra/format/format.ts index b7c55fbf9c..e3e79f6a40 100644 --- a/dev-infra/format/format.ts +++ b/dev-infra/format/format.ts @@ -6,9 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {prompt} from 'inquirer'; - -import {error, info} from '../utils/console'; +import {error, info, promptConfirm} from '../utils/console'; import {runFormatterInParallel} from './run-commands-parallel'; @@ -57,11 +55,7 @@ export async function checkFiles(files: string[]) { // If the command is run in a non-CI environment, prompt to format the files immediately. let runFormatter = false; if (!process.env['CI']) { - runFormatter = (await prompt({ - type: 'confirm', - name: 'runFormatter', - message: 'Format the files now?', - })).runFormatter; + runFormatter = await promptConfirm('Format the files now?', true); } if (runFormatter) { From 5f2e475abfaacfe8989c7dbb7ae92a683ba8631e Mon Sep 17 00:00:00 2001 From: Sergey Falinsky Date: Fri, 31 Jul 2020 01:30:57 +0300 Subject: [PATCH 026/629] docs: remove unused Input decorator (#38306) In the part "5. Add In-app Navigation" of the tutorial it was already removed PR Close #38306 --- .../toh-pt6/src/app/hero-detail/hero-detail.component.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/aio/content/examples/toh-pt6/src/app/hero-detail/hero-detail.component.ts b/aio/content/examples/toh-pt6/src/app/hero-detail/hero-detail.component.ts index 6bcb8f014d..8c3eab261a 100644 --- a/aio/content/examples/toh-pt6/src/app/hero-detail/hero-detail.component.ts +++ b/aio/content/examples/toh-pt6/src/app/hero-detail/hero-detail.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit, Input } from '@angular/core'; +import { Component, OnInit } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { Location } from '@angular/common'; @@ -11,7 +11,7 @@ import { HeroService } from '../hero.service'; styleUrls: [ './hero-detail.component.css' ] }) export class HeroDetailComponent implements OnInit { - @Input() hero: Hero; + hero: Hero; constructor( private route: ActivatedRoute, From 8366effeec8e74c1a11d2a6e1a48d42e849e4653 Mon Sep 17 00:00:00 2001 From: Joey Perrott Date: Wed, 12 Aug 2020 09:40:37 -0700 Subject: [PATCH 027/629] refactor(dev-infra): extract the commit message parsing function into its own file (#38429) Extracts the commit message parsing function into its own file. PR Close #38429 --- dev-infra/commit-message/BUILD.bazel | 11 ++- dev-infra/commit-message/parse.spec.ts | 85 ++++++++++++++++++++++ dev-infra/commit-message/parse.ts | 73 +++++++++++++++++++ dev-infra/commit-message/validate-range.ts | 3 +- dev-infra/commit-message/validate.ts | 47 +----------- dev-infra/pr/merge/strategies/api-merge.ts | 2 +- 6 files changed, 170 insertions(+), 51 deletions(-) create mode 100644 dev-infra/commit-message/parse.spec.ts create mode 100644 dev-infra/commit-message/parse.ts diff --git a/dev-infra/commit-message/BUILD.bazel b/dev-infra/commit-message/BUILD.bazel index 2c6d3be371..333c62ba99 100644 --- a/dev-infra/commit-message/BUILD.bazel +++ b/dev-infra/commit-message/BUILD.bazel @@ -6,6 +6,7 @@ ts_library( srcs = [ "cli.ts", "config.ts", + "parse.ts", "validate.ts", "validate-file.ts", "validate-range.ts", @@ -23,9 +24,12 @@ ts_library( ) ts_library( - name = "validate-test", + name = "test_lib", testonly = True, - srcs = ["validate.spec.ts"], + srcs = [ + "parse.spec.ts", + "validate.spec.ts", + ], deps = [ ":commit-message", "//dev-infra/utils", @@ -40,7 +44,6 @@ jasmine_node_test( name = "test", bootstrap = ["//tools/testing:node_no_angular_es5"], deps = [ - ":commit-message", - ":validate-test", + "test_lib", ], ) diff --git a/dev-infra/commit-message/parse.spec.ts b/dev-infra/commit-message/parse.spec.ts new file mode 100644 index 0000000000..71f4d6875b --- /dev/null +++ b/dev-infra/commit-message/parse.spec.ts @@ -0,0 +1,85 @@ +/** + * @license + * Copyright Google LLC 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 {parseCommitMessage, ParsedCommitMessage} from './parse'; + + +const commitValues = { + prefix: '', + type: 'fix', + scope: 'changed-area', + summary: 'This is a short summary of the change', + body: 'This is a longer description of the change Closes #1', +}; + +function buildCommitMessage(params = {}) { + const {prefix, type, scope, summary, body} = {...commitValues, ...params}; + return `${prefix}${type}${scope ? '(' + scope + ')' : ''}: ${summary}\n\n${body}`; +} + + +describe('commit message parsing:', () => { + it('parses the scope', () => { + const message = buildCommitMessage(); + expect(parseCommitMessage(message).scope).toBe(commitValues.scope); + }); + + it('parses the type', () => { + const message = buildCommitMessage(); + expect(parseCommitMessage(message).type).toBe(commitValues.type); + }); + + it('parses the header', () => { + const message = buildCommitMessage(); + expect(parseCommitMessage(message).header) + .toBe(`${commitValues.type}(${commitValues.scope}): ${commitValues.summary}`); + }); + + it('parses the body', () => { + const message = buildCommitMessage(); + expect(parseCommitMessage(message).body).toBe(commitValues.body); + }); + + it('parses the body without Github linking', () => { + const body = 'This has linking\nCloses #1'; + const message = buildCommitMessage({body}); + expect(parseCommitMessage(message).bodyWithoutLinking).toBe('This has linking\n'); + }); + + it('parses the subject', () => { + const message = buildCommitMessage(); + expect(parseCommitMessage(message).subject).toBe(commitValues.summary); + }); + + it('identifies if a commit is a fixup', () => { + const message1 = buildCommitMessage(); + expect(parseCommitMessage(message1).isFixup).toBe(false); + + const message2 = buildCommitMessage({prefix: 'fixup! '}); + expect(parseCommitMessage(message2).isFixup).toBe(true); + }); + + it('identifies if a commit is a revert', () => { + const message1 = buildCommitMessage(); + expect(parseCommitMessage(message1).isRevert).toBe(false); + + const message2 = buildCommitMessage({prefix: 'revert: '}); + expect(parseCommitMessage(message2).isRevert).toBe(true); + + const message3 = buildCommitMessage({prefix: 'revert '}); + expect(parseCommitMessage(message3).isRevert).toBe(true); + }); + + it('identifies if a commit is a squash', () => { + const message1 = buildCommitMessage(); + expect(parseCommitMessage(message1).isSquash).toBe(false); + + const message2 = buildCommitMessage({prefix: 'squash! '}); + expect(parseCommitMessage(message2).isSquash).toBe(true); + }); +}); diff --git a/dev-infra/commit-message/parse.ts b/dev-infra/commit-message/parse.ts new file mode 100644 index 0000000000..f8acd0fb60 --- /dev/null +++ b/dev-infra/commit-message/parse.ts @@ -0,0 +1,73 @@ +/** + * @license + * Copyright Google LLC 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 + */ + +/** A parsed commit message. */ +export interface ParsedCommitMessage { + header: string; + body: string; + bodyWithoutLinking: string; + type: string; + scope: string; + subject: string; + isFixup: boolean; + isSquash: boolean; + isRevert: boolean; +} + +/** Regex determining if a commit is a fixup. */ +const FIXUP_PREFIX_RE = /^fixup! /i; +/** Regex finding all github keyword links. */ +const GITHUB_LINKING_RE = /((closed?s?)|(fix(es)?(ed)?)|(resolved?s?))\s\#(\d+)/ig; +/** Regex determining if a commit is a squash. */ +const SQUASH_PREFIX_RE = /^squash! /i; +/** Regex determining if a commit is a revert. */ +const REVERT_PREFIX_RE = /^revert:? /i; +/** Regex determining the scope of a commit if provided. */ +const TYPE_SCOPE_RE = /^(\w+)(?:\(([^)]+)\))?\:\s(.+)$/; +/** Regex determining the entire header line of the commit. */ +const COMMIT_HEADER_RE = /^(.*)/i; +/** Regex determining the body of the commit. */ +const COMMIT_BODY_RE = /^.*\n\n([\s\S]*)$/; + +/** Parse a full commit message into its composite parts. */ +export function parseCommitMessage(commitMsg: string): ParsedCommitMessage { + let header = ''; + let body = ''; + let bodyWithoutLinking = ''; + let type = ''; + let scope = ''; + let subject = ''; + + if (COMMIT_HEADER_RE.test(commitMsg)) { + header = COMMIT_HEADER_RE.exec(commitMsg)![1] + .replace(FIXUP_PREFIX_RE, '') + .replace(SQUASH_PREFIX_RE, ''); + } + if (COMMIT_BODY_RE.test(commitMsg)) { + body = COMMIT_BODY_RE.exec(commitMsg)![1]; + bodyWithoutLinking = body.replace(GITHUB_LINKING_RE, ''); + } + + if (TYPE_SCOPE_RE.test(header)) { + const parsedCommitHeader = TYPE_SCOPE_RE.exec(header)!; + type = parsedCommitHeader[1]; + scope = parsedCommitHeader[2]; + subject = parsedCommitHeader[3]; + } + return { + header, + body, + bodyWithoutLinking, + type, + scope, + subject, + isFixup: FIXUP_PREFIX_RE.test(commitMsg), + isSquash: SQUASH_PREFIX_RE.test(commitMsg), + isRevert: REVERT_PREFIX_RE.test(commitMsg), + }; +} diff --git a/dev-infra/commit-message/validate-range.ts b/dev-infra/commit-message/validate-range.ts index 485fa06b2f..2b29cf8382 100644 --- a/dev-infra/commit-message/validate-range.ts +++ b/dev-infra/commit-message/validate-range.ts @@ -8,7 +8,8 @@ import {info} from '../utils/console'; import {exec} from '../utils/shelljs'; -import {parseCommitMessage, validateCommitMessage, ValidateCommitMessageOptions} from './validate'; +import {parseCommitMessage} from './parse'; +import {validateCommitMessage, ValidateCommitMessageOptions} from './validate'; // Whether the provided commit is a fixup commit. const isNonFixup = (m: string) => !parseCommitMessage(m).isFixup; diff --git a/dev-infra/commit-message/validate.ts b/dev-infra/commit-message/validate.ts index 7108a63cac..fdc2f006c4 100644 --- a/dev-infra/commit-message/validate.ts +++ b/dev-infra/commit-message/validate.ts @@ -8,6 +8,7 @@ import {error} from '../utils/console'; import {getCommitMessageConfig} from './config'; +import {parseCommitMessage} from './parse'; /** Options for commit message validation. */ export interface ValidateCommitMessageOptions { @@ -15,53 +16,9 @@ export interface ValidateCommitMessageOptions { nonFixupCommitHeaders?: string[]; } -const FIXUP_PREFIX_RE = /^fixup! /i; -const GITHUB_LINKING_RE = /((closed?s?)|(fix(es)?(ed)?)|(resolved?s?))\s\#(\d+)/ig; -const SQUASH_PREFIX_RE = /^squash! /i; -const REVERT_PREFIX_RE = /^revert:? /i; -const TYPE_SCOPE_RE = /^(\w+)(?:\(([^)]+)\))?\:\s(.+)$/; -const COMMIT_HEADER_RE = /^(.*)/i; -const COMMIT_BODY_RE = /^.*\n\n([\s\S]*)$/; +/** Regex matching a URL for an entire commit body line. */ const COMMIT_BODY_URL_LINE_RE = /^https?:\/\/.*$/; -/** Parse a full commit message into its composite parts. */ -export function parseCommitMessage(commitMsg: string) { - let header = ''; - let body = ''; - let bodyWithoutLinking = ''; - let type = ''; - let scope = ''; - let subject = ''; - - if (COMMIT_HEADER_RE.test(commitMsg)) { - header = COMMIT_HEADER_RE.exec(commitMsg)![1] - .replace(FIXUP_PREFIX_RE, '') - .replace(SQUASH_PREFIX_RE, ''); - } - if (COMMIT_BODY_RE.test(commitMsg)) { - body = COMMIT_BODY_RE.exec(commitMsg)![1]; - bodyWithoutLinking = body.replace(GITHUB_LINKING_RE, ''); - } - - if (TYPE_SCOPE_RE.test(header)) { - const parsedCommitHeader = TYPE_SCOPE_RE.exec(header)!; - type = parsedCommitHeader[1]; - scope = parsedCommitHeader[2]; - subject = parsedCommitHeader[3]; - } - return { - header, - body, - bodyWithoutLinking, - type, - scope, - subject, - isFixup: FIXUP_PREFIX_RE.test(commitMsg), - isSquash: SQUASH_PREFIX_RE.test(commitMsg), - isRevert: REVERT_PREFIX_RE.test(commitMsg), - }; -} - /** Validate a commit message against using the local repo's config. */ export function validateCommitMessage( commitMsg: string, options: ValidateCommitMessageOptions = {}) { diff --git a/dev-infra/pr/merge/strategies/api-merge.ts b/dev-infra/pr/merge/strategies/api-merge.ts index f52b768e00..42afdd1c6f 100644 --- a/dev-infra/pr/merge/strategies/api-merge.ts +++ b/dev-infra/pr/merge/strategies/api-merge.ts @@ -9,7 +9,7 @@ import {PullsListCommitsResponse, PullsMergeParams} from '@octokit/rest'; import {prompt} from 'inquirer'; -import {parseCommitMessage} from '../../../commit-message/validate'; +import {parseCommitMessage} from '../../../commit-message/parse'; import {GitClient} from '../../../utils/git'; import {GithubApiMergeMethod} from '../config'; import {PullRequestFailure} from '../failures'; From f4ced74e3a617267051ce423e58101457bc1e99a Mon Sep 17 00:00:00 2001 From: Joey Perrott Date: Thu, 30 Jul 2020 14:01:03 -0700 Subject: [PATCH 028/629] feat(dev-infra): save invalid commit message attempts to be restored on next commit attempt (#38304) When a commit message fails validation, rather than throwing out the commit message entirely the commit message is saved into a draft file and restored on the next commit attempt. PR Close #38304 --- dev-infra/commit-message/BUILD.bazel | 2 + dev-infra/commit-message/cli.ts | 23 +++++++++ .../commit-message/commit-message-draft.ts | 30 ++++++++++++ .../commit-message/restore-commit-message.ts | 48 +++++++++++++++++++ dev-infra/commit-message/validate-file.ts | 5 ++ package.json | 3 +- 6 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 dev-infra/commit-message/commit-message-draft.ts create mode 100644 dev-infra/commit-message/restore-commit-message.ts diff --git a/dev-infra/commit-message/BUILD.bazel b/dev-infra/commit-message/BUILD.bazel index 333c62ba99..c8e7898a4b 100644 --- a/dev-infra/commit-message/BUILD.bazel +++ b/dev-infra/commit-message/BUILD.bazel @@ -5,8 +5,10 @@ ts_library( name = "commit-message", srcs = [ "cli.ts", + "commit-message-draft.ts", "config.ts", "parse.ts", + "restore-commit-message.ts", "validate.ts", "validate-file.ts", "validate-range.ts", diff --git a/dev-infra/commit-message/cli.ts b/dev-infra/commit-message/cli.ts index f7b2cc8936..ebdde827e4 100644 --- a/dev-infra/commit-message/cli.ts +++ b/dev-infra/commit-message/cli.ts @@ -9,6 +9,7 @@ import * as yargs from 'yargs'; import {info} from '../utils/console'; +import {restoreCommitMessage} from './restore-commit-message'; import {validateFile} from './validate-file'; import {validateCommitRange} from './validate-range'; @@ -16,6 +17,28 @@ import {validateCommitRange} from './validate-range'; export function buildCommitMessageParser(localYargs: yargs.Argv) { return localYargs.help() .strict() + .command( + 'restore-commit-message-draft', false, { + 'file-env-variable': { + type: 'string', + conflicts: ['file'], + required: true, + description: + 'The key for the environment variable which holds the arguments for the ' + + 'prepare-commit-msg hook as described here: ' + + 'https://git-scm.com/docs/githooks#_prepare_commit_msg', + coerce: arg => { + const [file, source] = (process.env[arg] || '').split(' '); + if (!file) { + throw new Error(`Provided environment variable "${arg}" was not found.`); + } + return [file, source]; + }, + } + }, + args => { + restoreCommitMessage(args.fileEnvVariable[0], args.fileEnvVariable[1]); + }) .command( 'pre-commit-validate', 'Validate the most recent commit message', { 'file': { diff --git a/dev-infra/commit-message/commit-message-draft.ts b/dev-infra/commit-message/commit-message-draft.ts new file mode 100644 index 0000000000..86a5655fd0 --- /dev/null +++ b/dev-infra/commit-message/commit-message-draft.ts @@ -0,0 +1,30 @@ +/** + * @license + * Copyright Google LLC 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 {existsSync, readFileSync, unlinkSync, writeFileSync} from 'fs'; + +/** Load the commit message draft from the file system if it exists. */ +export function loadCommitMessageDraft(basePath: string) { + const commitMessageDraftPath = `${basePath}.ngDevSave`; + if (existsSync(commitMessageDraftPath)) { + return readFileSync(commitMessageDraftPath).toString(); + } + return ''; +} + +/** Remove the commit message draft from the file system. */ +export function deleteCommitMessageDraft(basePath: string) { + const commitMessageDraftPath = `${basePath}.ngDevSave`; + if (existsSync(commitMessageDraftPath)) { + unlinkSync(commitMessageDraftPath); + } +} + +/** Save the commit message draft to the file system for later retrieval. */ +export function saveCommitMessageDraft(basePath: string, commitMessage: string) { + writeFileSync(`${basePath}.ngDevSave`, commitMessage); +} diff --git a/dev-infra/commit-message/restore-commit-message.ts b/dev-infra/commit-message/restore-commit-message.ts new file mode 100644 index 0000000000..bcbf302b98 --- /dev/null +++ b/dev-infra/commit-message/restore-commit-message.ts @@ -0,0 +1,48 @@ +/** + * @license + * Copyright Google LLC 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 {info} from 'console'; +import {writeFileSync} from 'fs'; + +import {loadCommitMessageDraft} from './commit-message-draft'; + +/** + * Restore the commit message draft to the git to be used as the default commit message. + * + * The source provided may be one of the sources described in + * https://git-scm.com/docs/githooks#_prepare_commit_msg + */ +export function restoreCommitMessage( + filePath: string, source?: 'message'|'template'|'squash'|'commit') { + if (!!source) { + info('Skipping commit message restoration attempt'); + if (source === 'message') { + info('A commit message was already provided via the command with a -m or -F flag'); + } + if (source === 'template') { + info('A commit message was already provided via the -t flag or config.template setting'); + } + if (source === 'squash') { + info('A commit message was already provided as a merge action or via .git/MERGE_MSG'); + } + if (source === 'commit') { + info('A commit message was already provided through a revision specified via --fixup, -c,'); + info('-C or --amend flag'); + } + process.exit(0); + } + /** A draft of a commit message. */ + const commitMessage = loadCommitMessageDraft(filePath); + + // If the commit message draft has content, restore it into the provided filepath. + if (commitMessage) { + writeFileSync(filePath, commitMessage); + } + // Exit the process + process.exit(0); +} diff --git a/dev-infra/commit-message/validate-file.ts b/dev-infra/commit-message/validate-file.ts index f4cd5ed830..50b8b72afc 100644 --- a/dev-infra/commit-message/validate-file.ts +++ b/dev-infra/commit-message/validate-file.ts @@ -11,6 +11,7 @@ import {resolve} from 'path'; import {getRepoBaseDir} from '../utils/config'; import {info} from '../utils/console'; +import {deleteCommitMessageDraft, saveCommitMessageDraft} from './commit-message-draft'; import {validateCommitMessage} from './validate'; /** Validate commit message at the provided file path. */ @@ -18,8 +19,12 @@ export function validateFile(filePath: string) { const commitMessage = readFileSync(resolve(getRepoBaseDir(), filePath), 'utf8'); if (validateCommitMessage(commitMessage)) { info('√ Valid commit message'); + deleteCommitMessageDraft(filePath); return; } + // On all invalid commit messages, the commit message should be saved as a draft to be + // restored on the next commit attempt. + saveCommitMessageDraft(filePath, commitMessage); // If the validation did not return true, exit as a failure. process.exit(1); } diff --git a/package.json b/package.json index ca078c921d..f57e005bbe 100644 --- a/package.json +++ b/package.json @@ -210,7 +210,8 @@ "husky": { "hooks": { "pre-commit": "yarn -s ng-dev format staged", - "commit-msg": "yarn -s ng-dev commit-message pre-commit-validate --file-env-variable HUSKY_GIT_PARAMS" + "commit-msg": "yarn -s ng-dev commit-message pre-commit-validate --file-env-variable HUSKY_GIT_PARAMS", + "prepare-commit-msg": "yarn -s ng-dev commit-message restore-commit-message-draft --file-env-variable HUSKY_GIT_PARAMS" } } } From 773f7908c0ba2a23707773d8d157313476b4b921 Mon Sep 17 00:00:00 2001 From: Paul Gschwendtner Date: Thu, 13 Aug 2020 14:23:15 +0200 Subject: [PATCH 029/629] feat(dev-infra): update to latest benchpress version (#38440) We recently updated the benchpress package to have a more loose Angular core peer dependency, and less other unused dependencies. We should make sure to use that in the dev-infra package so that peer dependencies can be satisified in consumer projects, and so that less unused dependencies are brought into projects. PR Close #38440 --- dev-infra/tmpl-package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-infra/tmpl-package.json b/dev-infra/tmpl-package.json index 0b3048a665..746926eb4b 100644 --- a/dev-infra/tmpl-package.json +++ b/dev-infra/tmpl-package.json @@ -9,7 +9,7 @@ "ts-circular-deps": "./ts-circular-dependencies/index.js" }, "dependencies": { - "@angular/benchpress": "0.2.0", + "@angular/benchpress": "0.2.1", "@octokit/graphql": "", "@octokit/types": "", "brotli": "", From 9f7a37b4e9922cc8cf96b1998693afbac301bb5f Mon Sep 17 00:00:00 2001 From: Joey Perrott Date: Wed, 12 Aug 2020 09:36:59 -0700 Subject: [PATCH 030/629] feat(dev-infra): migrate to unified commit message types in commit message linting (#38430) Previously commit message types were provided as part of the ng-dev config in the repository using the ng-dev toolset. This change removes this configuration expectation and instead predefines the valid types for commit messages. Additionally, with this new unified set of types requirements around providing a scope have been put in place. Scopes are either required, optional or forbidden for a given commit type. PR Close #38430 --- dev-infra/commit-message/config.ts | 44 ++++++++++++++++++++++- dev-infra/commit-message/validate.spec.ts | 31 +++++++--------- dev-infra/commit-message/validate.ts | 24 +++++++++++-- 3 files changed, 76 insertions(+), 23 deletions(-) diff --git a/dev-infra/commit-message/config.ts b/dev-infra/commit-message/config.ts index 2d9739cecf..9183e0c9ba 100644 --- a/dev-infra/commit-message/config.ts +++ b/dev-infra/commit-message/config.ts @@ -12,7 +12,6 @@ export interface CommitMessageConfig { maxLineLength: number; minBodyLength: number; minBodyLengthTypeExcludes?: string[]; - types: string[]; scopes: string[]; } @@ -30,3 +29,46 @@ export function getCommitMessageConfig() { assertNoErrors(errors); return config as Required; } + +/** Scope requirement level to be set for each commit type. */ +export enum ScopeRequirement { + Required, + Optional, + Forbidden, +} + +/** A commit type */ +export interface CommitType { + scope: ScopeRequirement; +} + +/** The valid commit types for Angular commit messages. */ +export const COMMIT_TYPES: {[key: string]: CommitType} = { + build: { + scope: ScopeRequirement.Forbidden, + }, + ci: { + scope: ScopeRequirement.Forbidden, + }, + docs: { + scope: ScopeRequirement.Optional, + }, + feat: { + scope: ScopeRequirement.Required, + }, + fix: { + scope: ScopeRequirement.Required, + }, + perf: { + scope: ScopeRequirement.Required, + }, + refactor: { + scope: ScopeRequirement.Required, + }, + release: { + scope: ScopeRequirement.Forbidden, + }, + test: { + scope: ScopeRequirement.Required, + }, +}; diff --git a/dev-infra/commit-message/validate.spec.ts b/dev-infra/commit-message/validate.spec.ts index edc0bab802..238a7909a9 100644 --- a/dev-infra/commit-message/validate.spec.ts +++ b/dev-infra/commit-message/validate.spec.ts @@ -18,13 +18,6 @@ const config: {commitMessage: CommitMessageConfig} = { commitMessage: { maxLineLength: 120, minBodyLength: 0, - types: [ - 'feat', - 'fix', - 'refactor', - 'release', - 'style', - ], scopes: [ 'common', 'compiler', @@ -33,7 +26,7 @@ const config: {commitMessage: CommitMessageConfig} = { ] } }; -const TYPES = config.commitMessage.types.join(', '); +const TYPES = Object.keys(validateConfig.COMMIT_TYPES).join(', '); const SCOPES = config.commitMessage.scopes.join(', '); const INVALID = false; const VALID = true; @@ -47,7 +40,8 @@ describe('validate-commit-message.js', () => { lastError = ''; spyOn(console, 'error').and.callFake((msg: string) => lastError = msg); - spyOn(validateConfig, 'getCommitMessageConfig').and.returnValue(config); + spyOn(validateConfig, 'getCommitMessageConfig') + .and.returnValue(config as ReturnType); }); describe('validateMessage()', () => { @@ -55,16 +49,16 @@ describe('validate-commit-message.js', () => { expect(validateCommitMessage('feat(packaging): something')).toBe(VALID); expect(lastError).toBe(''); - expect(validateCommitMessage('release(packaging): something')).toBe(VALID); + expect(validateCommitMessage('fix(packaging): something')).toBe(VALID); expect(lastError).toBe(''); - expect(validateCommitMessage('fixup! release(packaging): something')).toBe(VALID); + expect(validateCommitMessage('fixup! fix(packaging): something')).toBe(VALID); expect(lastError).toBe(''); - expect(validateCommitMessage('squash! release(packaging): something')).toBe(VALID); + expect(validateCommitMessage('squash! fix(packaging): something')).toBe(VALID); expect(lastError).toBe(''); - expect(validateCommitMessage('Revert: "release(packaging): something"')).toBe(VALID); + expect(validateCommitMessage('Revert: "fix(packaging): something"')).toBe(VALID); expect(lastError).toBe(''); }); @@ -110,8 +104,8 @@ describe('validate-commit-message.js', () => { expect(validateCommitMessage('feat(bah): something')).toBe(INVALID); expect(lastError).toContain(errorMessageFor('bah', 'feat(bah): something')); - expect(validateCommitMessage('style(webworker): something')).toBe(INVALID); - expect(lastError).toContain(errorMessageFor('webworker', 'style(webworker): something')); + expect(validateCommitMessage('fix(webworker): something')).toBe(INVALID); + expect(lastError).toContain(errorMessageFor('webworker', 'fix(webworker): something')); expect(validateCommitMessage('refactor(security): something')).toBe(INVALID); expect(lastError).toContain(errorMessageFor('security', 'refactor(security): something')); @@ -119,12 +113,12 @@ describe('validate-commit-message.js', () => { expect(validateCommitMessage('refactor(docs): something')).toBe(INVALID); expect(lastError).toContain(errorMessageFor('docs', 'refactor(docs): something')); - expect(validateCommitMessage('release(angular): something')).toBe(INVALID); - expect(lastError).toContain(errorMessageFor('angular', 'release(angular): something')); + expect(validateCommitMessage('feat(angular): something')).toBe(INVALID); + expect(lastError).toContain(errorMessageFor('angular', 'feat(angular): something')); }); it('should allow empty scope', () => { - expect(validateCommitMessage('fix: blablabla')).toBe(VALID); + expect(validateCommitMessage('build: blablabla')).toBe(VALID); expect(lastError).toBe(''); }); @@ -243,7 +237,6 @@ describe('validate-commit-message.js', () => { maxLineLength: 120, minBodyLength: 30, minBodyLengthTypeExcludes: ['docs'], - types: ['fix', 'docs'], scopes: ['core'] } }; diff --git a/dev-infra/commit-message/validate.ts b/dev-infra/commit-message/validate.ts index fdc2f006c4..1c10ada900 100644 --- a/dev-infra/commit-message/validate.ts +++ b/dev-infra/commit-message/validate.ts @@ -7,7 +7,7 @@ */ import {error} from '../utils/console'; -import {getCommitMessageConfig} from './config'; +import {COMMIT_TYPES, getCommitMessageConfig, ScopeRequirement} from './config'; import {parseCommitMessage} from './parse'; /** Options for commit message validation. */ @@ -86,8 +86,26 @@ export function validateCommitMessage( return false; } - if (!config.types.includes(commit.type)) { - printError(`'${commit.type}' is not an allowed type.\n => TYPES: ${config.types.join(', ')}`); + + + if (COMMIT_TYPES[commit.type] === undefined) { + printError(`'${commit.type}' is not an allowed type.\n => TYPES: ${ + Object.keys(COMMIT_TYPES).join(', ')}`); + return false; + } + + /** The scope requirement level for the provided type of the commit message. */ + const scopeRequirementForType = COMMIT_TYPES[commit.type].scope; + + if (scopeRequirementForType === ScopeRequirement.Forbidden && commit.scope) { + printError(`Scopes are forbidden for commits with type '${commit.type}', but a scope of '${ + commit.scope}' was provided.`); + return false; + } + + if (scopeRequirementForType === ScopeRequirement.Required && !commit.scope) { + printError( + `Scopes are required for commits with type '${commit.type}', but no scope was provided.`); return false; } From 8763d8201c6a95f029fcd2d27d04ab60cd088334 Mon Sep 17 00:00:00 2001 From: Joey Perrott Date: Thu, 13 Aug 2020 07:43:41 -0700 Subject: [PATCH 031/629] build: update ng-dev config file for new commit message configuration (#38430) Removes the commit message types from the config as they are now staticly defined in the dev-infra code. PR Close #38430 --- .ng-dev/commit-message.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/.ng-dev/commit-message.ts b/.ng-dev/commit-message.ts index 5e22155091..2e18e9eaad 100644 --- a/.ng-dev/commit-message.ts +++ b/.ng-dev/commit-message.ts @@ -7,18 +7,6 @@ export const commitMessage: CommitMessageConfig = { maxLineLength: 120, minBodyLength: 20, minBodyLengthTypeExcludes: ['docs'], - types: [ - 'build', - 'ci', - 'docs', - 'feat', - 'fix', - 'perf', - 'refactor', - 'release', - 'style', - 'test', - ], scopes: [ 'animations', 'bazel', From aa847cb014529cfed916e99486d928408657d1bb Mon Sep 17 00:00:00 2001 From: Paul Gschwendtner Date: Thu, 13 Aug 2020 10:09:43 +0200 Subject: [PATCH 032/629] build: run browsers tests on chromium locally (#38435) Previously we added a browser target for `firefox` into the dev-infra package. It looks like as part of this change, we accidentally switched the local web testing target to `firefox`. Web tests are not commonly run locally as we use Domino and NodeJS tests for primary development. Sometimes though we intend to run tests in a browser. This would currently work with Firefox but not on Windows (as Firefox is a noop there in Bazel). This commit switches the primary browser back to `chromium`. Also Firefox has been added as a second browser to web testing targets. This allows us to reduce browsers in the legacy Saucelabs job. i.e. not running Chrome and Firefox there. This should increase stability and speed up the legacy job (+ reduced rate limit for Saucelabs). PR Close #38435 --- browser-providers.conf.js | 6 ++++-- tools/defaults.bzl | 5 ++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/browser-providers.conf.js b/browser-providers.conf.js index a377f4fac6..74921df5e2 100644 --- a/browser-providers.conf.js +++ b/browser-providers.conf.js @@ -12,8 +12,10 @@ // If a category becomes empty (e.g. BS and required), then the corresponding job must be commented // out in the CI configuration. var CIconfiguration = { - 'Chrome': {unitTest: {target: 'SL', required: true}, e2e: {target: null, required: true}}, - 'Firefox': {unitTest: {target: 'SL', required: true}, e2e: {target: null, required: true}}, + // Chrome and Firefox run as part of the Bazel browser tests, so we do not run them as + // part of the legacy Saucelabs tests. + 'Chrome': {unitTest: {target: null, required: false}, e2e: {target: null, required: true}}, + 'Firefox': {unitTest: {target: null, required: false}, e2e: {target: null, required: true}}, // Set ESR as a not required browser as it fails for Ivy acceptance tests. 'FirefoxESR': {unitTest: {target: 'SL', required: false}, e2e: {target: null, required: true}}, // Disabled because using the "beta" channel of Chrome can cause non-deterministic CI results. diff --git a/tools/defaults.bzl b/tools/defaults.bzl index 0fbfb1296c..b99e371f4f 100644 --- a/tools/defaults.bzl +++ b/tools/defaults.bzl @@ -245,7 +245,10 @@ def karma_web_test_suite(name, **kwargs): runtime_deps = runtime_deps, bootstrap = bootstrap, deps = deps, - browsers = ["//dev-infra/browsers/firefox:firefox"], + browsers = [ + "//dev-infra/browsers/chromium:chromium", + "//dev-infra/browsers/firefox:firefox", + ], data = data, tags = tags, **kwargs From a80f654af9d323e05f932069f1d7d63b162688b8 Mon Sep 17 00:00:00 2001 From: crisbeto Date: Thu, 13 Aug 2020 08:02:59 +0200 Subject: [PATCH 033/629] fix(core): error if CSS custom property in host binding has number in name (#38432) Fixes an error if a CSS custom property, used inside a host binding, has a number in its name. The error is thrown because the styling parser only expects characters from A to Z,dashes, underscores and a handful of other characters. Fixes #37292. PR Close #38432 --- .../src/render3/styling/styling_parser.ts | 3 +- packages/core/src/util/char_code.ts | 2 + packages/core/test/acceptance/styling_spec.ts | 39 +++++++++++++++---- 3 files changed, 35 insertions(+), 9 deletions(-) diff --git a/packages/core/src/render3/styling/styling_parser.ts b/packages/core/src/render3/styling/styling_parser.ts index de80259bc1..8407476743 100644 --- a/packages/core/src/render3/styling/styling_parser.ts +++ b/packages/core/src/render3/styling/styling_parser.ts @@ -210,7 +210,8 @@ export function consumeStyleKey(text: string, startIndex: number, endIndex: numb let ch: number; while (startIndex < endIndex && ((ch = text.charCodeAt(startIndex)) === CharCode.DASH || ch === CharCode.UNDERSCORE || - ((ch & CharCode.UPPER_CASE) >= CharCode.A && (ch & CharCode.UPPER_CASE) <= CharCode.Z))) { + ((ch & CharCode.UPPER_CASE) >= CharCode.A && (ch & CharCode.UPPER_CASE) <= CharCode.Z) || + (ch >= CharCode.ZERO && ch <= CharCode.NINE))) { startIndex++; } return startIndex; diff --git a/packages/core/src/util/char_code.ts b/packages/core/src/util/char_code.ts index 7815358d74..6680ebacad 100644 --- a/packages/core/src/util/char_code.ts +++ b/packages/core/src/util/char_code.ts @@ -22,6 +22,8 @@ export const enum CharCode { SEMI_COLON = 59, // ";" BACK_SLASH = 92, // "\\" AT_SIGN = 64, // "@" + ZERO = 48, // "0" + NINE = 57, // "9" A = 65, // "A" U = 85, // "U" R = 82, // "R" diff --git a/packages/core/test/acceptance/styling_spec.ts b/packages/core/test/acceptance/styling_spec.ts index 8431c9b79e..bea42b397c 100644 --- a/packages/core/test/acceptance/styling_spec.ts +++ b/packages/core/test/acceptance/styling_spec.ts @@ -214,28 +214,51 @@ describe('styling', () => { }); }); - describe('css variables', () => { - onlyInIvy('css variables').it('should support css variables', () => { + onlyInIvy('CSS variables are only supported in Ivy').describe('css variables', () => { + const supportsCssVariables = typeof getComputedStyle !== 'undefined' && + typeof CSS !== 'undefined' && typeof CSS.supports !== 'undefined' && + CSS.supports('color', 'var(--fake-var)'); + + it('should support css variables', () => { // This test only works in browsers which support CSS variables. - if (!(typeof getComputedStyle !== 'undefined' && typeof CSS !== 'undefined' && - typeof CSS.supports !== 'undefined' && CSS.supports('color', 'var(--fake-var)'))) + if (!supportsCssVariables) { return; + } + @Component({ template: ` -
- CONTENT -
` +
+ CONTENT +
+ ` }) class Cmp { } TestBed.configureTestingModule({declarations: [Cmp]}); const fixture = TestBed.createComponent(Cmp); - // document.body.appendChild(fixture.nativeElement); fixture.detectChanges(); const span = fixture.nativeElement.querySelector('span') as HTMLElement; expect(getComputedStyle(span).getPropertyValue('width')).toEqual('100px'); }); + + it('should support css variables with numbers in their name inside a host binding', () => { + // This test only works in browsers which support CSS variables. + if (!supportsCssVariables) { + return; + } + + @Component({template: `

Hello

`}) + class Cmp { + @HostBinding('style') style = '--my-1337-var: 100px;'; + } + TestBed.configureTestingModule({declarations: [Cmp]}); + const fixture = TestBed.createComponent(Cmp); + fixture.detectChanges(); + + const header = fixture.nativeElement.querySelector('h1') as HTMLElement; + expect(getComputedStyle(header).getPropertyValue('width')).toEqual('100px'); + }); }); modifiedInIvy('shadow bindings include static portion') From b769771d60cdc6df18e750439bb3c10da278a4e7 Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Thu, 13 Aug 2020 09:27:15 -0700 Subject: [PATCH 034/629] refactor(router): Add annotations to correct Router documentation (#38448) The `@HostListener` functions and lifecycle hooks aren't intended to be public API but do need to appear in the `.d.ts` files or type checking will break. Adding the nodoc annotation will correctly hide this function on the docs site. Again, note that `@internal` cannot be used because the result would be that the functions then do not appear in the `.d.ts` files. This would break lifecycle hooks because the class would be seen as not implementing the interface correctly. This would also break `HostListener` because the compiled templates would attempt to call the `onClick` functions, but those would also not appear in the `d.ts` and would produce errors like "Property 'onClick' does not exist on type 'RouterLinkWithHref'". PR Close #38448 --- packages/router/src/directives/router_link.ts | 4 ++++ packages/router/src/directives/router_link_active.ts | 4 +++- packages/router/src/directives/router_outlet.ts | 2 ++ packages/router/src/router.ts | 2 +- packages/router/src/router_preloader.ts | 1 + packages/router/src/router_scroller.ts | 1 + 6 files changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/router/src/directives/router_link.ts b/packages/router/src/directives/router_link.ts index 7b041980f3..58be39a05a 100644 --- a/packages/router/src/directives/router_link.ts +++ b/packages/router/src/directives/router_link.ts @@ -202,6 +202,7 @@ export class RouterLink { this.preserve = value; } + /** @nodoc */ @HostListener('click') onClick(): boolean { const extras = { @@ -334,13 +335,16 @@ export class RouterLinkWithHref implements OnChanges, OnDestroy { this.preserve = value; } + /** @nodoc */ ngOnChanges(changes: {}): any { this.updateTargetUrlAndHref(); } + /** @nodoc */ ngOnDestroy(): any { this.subscription.unsubscribe(); } + /** @nodoc */ @HostListener('click', ['$event.button', '$event.ctrlKey', '$event.metaKey', '$event.shiftKey']) onClick(button: number, ctrlKey: boolean, metaKey: boolean, shiftKey: boolean): boolean { if (button !== 0 || ctrlKey || metaKey || shiftKey) { diff --git a/packages/router/src/directives/router_link_active.ts b/packages/router/src/directives/router_link_active.ts index 8c56ca12b4..209546d424 100644 --- a/packages/router/src/directives/router_link_active.ts +++ b/packages/router/src/directives/router_link_active.ts @@ -102,7 +102,7 @@ export class RouterLinkActive implements OnChanges, OnDestroy, AfterContentInit }); } - + /** @nodoc */ ngAfterContentInit(): void { this.links.changes.subscribe(_ => this.update()); this.linksWithHrefs.changes.subscribe(_ => this.update()); @@ -115,9 +115,11 @@ export class RouterLinkActive implements OnChanges, OnDestroy, AfterContentInit this.classes = classes.filter(c => !!c); } + /** @nodoc */ ngOnChanges(changes: SimpleChanges): void { this.update(); } + /** @nodoc */ ngOnDestroy(): void { this.subscription.unsubscribe(); } diff --git a/packages/router/src/directives/router_outlet.ts b/packages/router/src/directives/router_outlet.ts index 904e5e1f76..6e7425a4f5 100644 --- a/packages/router/src/directives/router_outlet.ts +++ b/packages/router/src/directives/router_outlet.ts @@ -76,10 +76,12 @@ export class RouterOutlet implements OnDestroy, OnInit { parentContexts.onChildOutletCreated(this.name, this); } + /** @nodoc */ ngOnDestroy(): void { this.parentContexts.onChildOutletDestroyed(this.name); } + /** @nodoc */ ngOnInit(): void { if (!this.activated) { // If the outlet was not instantiated at the time the route got activated we need to populate diff --git a/packages/router/src/router.ts b/packages/router/src/router.ts index a4e1dae9fa..5d05c48305 100644 --- a/packages/router/src/router.ts +++ b/packages/router/src/router.ts @@ -1010,7 +1010,7 @@ export class Router { this.lastSuccessfulId = -1; } - /** @docsNotRequired */ + /** @nodoc */ ngOnDestroy(): void { this.dispose(); } diff --git a/packages/router/src/router_preloader.ts b/packages/router/src/router_preloader.ts index 6b6da836bb..2da507f9cf 100644 --- a/packages/router/src/router_preloader.ts +++ b/packages/router/src/router_preloader.ts @@ -97,6 +97,7 @@ export class RouterPreloader implements OnDestroy { return this.processRoutes(ngModule, this.router.config); } + /** @nodoc */ ngOnDestroy(): void { if (this.subscription) { this.subscription.unsubscribe(); diff --git a/packages/router/src/router_scroller.ts b/packages/router/src/router_scroller.ts index 9d0c6e8d58..e1569083b2 100644 --- a/packages/router/src/router_scroller.ts +++ b/packages/router/src/router_scroller.ts @@ -87,6 +87,7 @@ export class RouterScroller implements OnDestroy { routerEvent, this.lastSource === 'popstate' ? this.store[this.restoredId] : null, anchor)); } + /** @nodoc */ ngOnDestroy() { if (this.routerEventsSubscription) { this.routerEventsSubscription.unsubscribe(); From 945751e2e87f0adc7ce5248509681f56ffefaa54 Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Thu, 13 Aug 2020 11:03:31 -0700 Subject: [PATCH 035/629] ci: disable closure size tracking test (#38449) We should define ngDevMode to false in Closure, but --define only works in the global scope. With ngDevMode not being set to false, this size tracking test provides little value but a lot of headache to continue updating the size. PR Close #38449 --- integration/BUILD.bazel | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/integration/BUILD.bazel b/integration/BUILD.bazel index b352583c40..3e43c6ef34 100644 --- a/integration/BUILD.bazel +++ b/integration/BUILD.bazel @@ -53,7 +53,10 @@ INTEGRATION_TESTS = { }, "dynamic-compiler": {"tags": ["no-ivy-aot"]}, "hello_world__closure": { - "commands": "payload_size_tracking", + # TODO: Re-enable the payload_size_tracking command: + # We should define ngDevMode to false in Closure, but --define only works in the global scope. + # With ngDevMode not being set to false, this size tracking test provides little value but a lot of + # headache to continue updating the size. "tags": ["no-ivy-aot"], }, "hello_world__systemjs_umd": { From 175c79d1d831b6c1278914250da016bde184b735 Mon Sep 17 00:00:00 2001 From: Sonu Kapoor Date: Mon, 10 Aug 2020 19:02:47 -0400 Subject: [PATCH 036/629] test(docs-infra): remove deprecated `ReflectiveInjector` (#38408) This commit replaces the old and slow `ReflectiveInjector` that was deprecated in v5 with the new `Injector`. Note: This change was only done in the spec files inside the `aio` folder. While changing this, it was not possible to directly use `Injector.get` to get the correct typing for the mocked classes. For example: ```typescript locationService = injector.get(LocationService); ``` Fails with: > Argument of type 'typeof LocationService' is not assignable to parameter of type 'Type | InjectionToken | AbstractType'. Type 'typeof LocationService' is not assignable to type 'Type'. Property 'searchResult' is missing in type 'LocationService' but required in type 'TestLocationService'. Therefore, it was necessary to first convert to `unknown` and then to `TestLocationService`. ```typescript locationService = injector.get(LocationService) as unknown as TestLocationService; ``` PR Close #38408 --- .../contributor-list.component.spec.ts | 20 ++++++----- .../resource/resource-list.component.spec.ts | 20 ++++++----- aio/src/app/search/search.service.spec.ts | 15 ++++---- aio/src/app/shared/deployment.service.spec.ts | 12 +++---- aio/src/app/shared/ga.service.spec.ts | 11 ++++-- aio/src/app/shared/location.service.spec.ts | 34 ++++++++++--------- aio/src/app/shared/logger.service.spec.ts | 10 +++--- .../shared/reporting-error-handler.spec.ts | 13 +++---- aio/src/app/shared/scroll-spy.service.spec.ts | 8 ++--- aio/src/app/shared/scroll.service.spec.ts | 34 +++++++++++-------- aio/src/app/shared/toc.service.spec.ts | 19 ++++++----- .../app/sw-updates/sw-updates.service.spec.ts | 20 +++++------ 12 files changed, 118 insertions(+), 98 deletions(-) diff --git a/aio/src/app/custom-elements/contributor/contributor-list.component.spec.ts b/aio/src/app/custom-elements/contributor/contributor-list.component.spec.ts index 48d9bd2080..72f110ce29 100644 --- a/aio/src/app/custom-elements/contributor/contributor-list.component.spec.ts +++ b/aio/src/app/custom-elements/contributor/contributor-list.component.spec.ts @@ -1,4 +1,4 @@ -import { ReflectiveInjector } from '@angular/core'; +import { Injector } from '@angular/core'; import { of } from 'rxjs'; @@ -12,20 +12,22 @@ import { LocationService } from 'app/shared/location.service'; describe('ContributorListComponent', () => { let component: ContributorListComponent; - let injector: ReflectiveInjector; + let injector: Injector; let contributorService: TestContributorService; let locationService: TestLocationService; let contributorGroups: ContributorGroup[]; beforeEach(() => { - injector = ReflectiveInjector.resolveAndCreate([ - ContributorListComponent, - {provide: ContributorService, useClass: TestContributorService }, - {provide: LocationService, useClass: TestLocationService } - ]); + injector = Injector.create({ + providers: [ + {provide: ContributorListComponent, deps: [ContributorService, LocationService] }, + {provide: ContributorService, useClass: TestContributorService, deps: [] }, + {provide: LocationService, useClass: TestLocationService, deps: [] } + ] + }); - locationService = injector.get(LocationService); - contributorService = injector.get(ContributorService); + locationService = injector.get(LocationService) as unknown as TestLocationService; + contributorService = injector.get(ContributorService) as unknown as TestContributorService; contributorGroups = contributorService.testContributors; }); diff --git a/aio/src/app/custom-elements/resource/resource-list.component.spec.ts b/aio/src/app/custom-elements/resource/resource-list.component.spec.ts index 60a9374252..cfc2bbf554 100644 --- a/aio/src/app/custom-elements/resource/resource-list.component.spec.ts +++ b/aio/src/app/custom-elements/resource/resource-list.component.spec.ts @@ -1,4 +1,4 @@ -import { ReflectiveInjector } from '@angular/core'; +import { Injector } from '@angular/core'; import { of } from 'rxjs'; @@ -12,20 +12,22 @@ import { Category } from './resource.model'; describe('ResourceListComponent', () => { let component: ResourceListComponent; - let injector: ReflectiveInjector; + let injector: Injector; let resourceService: TestResourceService; let locationService: TestLocationService; let categories: Category[]; beforeEach(() => { - injector = ReflectiveInjector.resolveAndCreate([ - ResourceListComponent, - {provide: ResourceService, useClass: TestResourceService }, - {provide: LocationService, useClass: TestLocationService } - ]); + injector = Injector.create({ + providers: [ + {provide: ResourceListComponent, deps: [ResourceService, LocationService] }, + {provide: ResourceService, useClass: TestResourceService, deps: [] }, + {provide: LocationService, useClass: TestLocationService, deps: [] } + ] + }); - locationService = injector.get(LocationService); - resourceService = injector.get(ResourceService); + locationService = injector.get(LocationService) as unknown as TestLocationService; + resourceService = injector.get(ResourceService) as unknown as TestResourceService; categories = resourceService.testCategories; }); diff --git a/aio/src/app/search/search.service.spec.ts b/aio/src/app/search/search.service.spec.ts index 4fad506ac0..906236f161 100644 --- a/aio/src/app/search/search.service.spec.ts +++ b/aio/src/app/search/search.service.spec.ts @@ -1,4 +1,4 @@ -import { ReflectiveInjector, NgZone } from '@angular/core'; +import { Injector, NgZone } from '@angular/core'; import { fakeAsync, tick } from '@angular/core/testing'; import { of } from 'rxjs'; import { SearchService } from './search.service'; @@ -6,7 +6,7 @@ import { WebWorkerClient } from 'app/shared/web-worker'; describe('SearchService', () => { - let injector: ReflectiveInjector; + let injector: Injector; let service: SearchService; let sendMessageSpy: jasmine.Spy; let mockWorker: WebWorkerClient; @@ -16,10 +16,13 @@ describe('SearchService', () => { mockWorker = { sendMessage: sendMessageSpy } as any; spyOn(WebWorkerClient, 'create').and.returnValue(mockWorker); - injector = ReflectiveInjector.resolveAndCreate([ - SearchService, - { provide: NgZone, useFactory: () => new NgZone({ enableLongStackTrace: false }) } - ]); + injector = Injector.create({ + providers: [ + { provide: SearchService, deps: [NgZone]}, + { provide: NgZone, useFactory: () => new NgZone({ enableLongStackTrace: false }), deps: [] } + ] + }); + service = injector.get(SearchService); }); diff --git a/aio/src/app/shared/deployment.service.spec.ts b/aio/src/app/shared/deployment.service.spec.ts index 34df4ec92e..5c19ee2016 100644 --- a/aio/src/app/shared/deployment.service.spec.ts +++ b/aio/src/app/shared/deployment.service.spec.ts @@ -1,4 +1,4 @@ -import { ReflectiveInjector } from '@angular/core'; +import { Injector } from '@angular/core'; import { environment } from 'environments/environment'; import { LocationService } from 'app/shared/location.service'; import { MockLocationService } from 'testing/location.service'; @@ -15,7 +15,7 @@ describe('Deployment service', () => { it('should get the mode from the `mode` query parameter if available', () => { const injector = getInjector(); - const locationService: MockLocationService = injector.get(LocationService); + const locationService = injector.get(LocationService) as unknown as MockLocationService; locationService.search.and.returnValue({ mode: 'bar' }); const deployment = injector.get(Deployment); @@ -25,8 +25,8 @@ describe('Deployment service', () => { }); function getInjector() { - return ReflectiveInjector.resolveAndCreate([ - Deployment, - { provide: LocationService, useFactory: () => new MockLocationService('') } - ]); + return Injector.create({providers: [ + { provide: Deployment, deps: [LocationService] }, + { provide: LocationService, useFactory: () => new MockLocationService(''), deps: [] } + ]}); } diff --git a/aio/src/app/shared/ga.service.spec.ts b/aio/src/app/shared/ga.service.spec.ts index ffd95fbc45..507b23e713 100644 --- a/aio/src/app/shared/ga.service.spec.ts +++ b/aio/src/app/shared/ga.service.spec.ts @@ -1,18 +1,23 @@ -import { ReflectiveInjector } from '@angular/core'; +import { Injector } from '@angular/core'; import { GaService } from 'app/shared/ga.service'; import { WindowToken } from 'app/shared/window'; describe('GaService', () => { let gaService: GaService; - let injector: ReflectiveInjector; + let injector: Injector; let gaSpy: jasmine.Spy; let mockWindow: any; beforeEach(() => { gaSpy = jasmine.createSpy('ga'); mockWindow = { ga: gaSpy }; - injector = ReflectiveInjector.resolveAndCreate([GaService, { provide: WindowToken, useFactory: () => mockWindow }]); + injector = Injector.create({ + providers: [ + { provide: GaService, deps: [WindowToken] }, + { provide: WindowToken, useFactory: () => mockWindow, deps: [] } + ]}); + gaService = injector.get(GaService); }); diff --git a/aio/src/app/shared/location.service.spec.ts b/aio/src/app/shared/location.service.spec.ts index a098d9bf31..9661758493 100644 --- a/aio/src/app/shared/location.service.spec.ts +++ b/aio/src/app/shared/location.service.spec.ts @@ -1,4 +1,4 @@ -import { ReflectiveInjector } from '@angular/core'; +import { Injector } from '@angular/core'; import { Location, LocationStrategy, PlatformLocation } from '@angular/common'; import { MockLocationStrategy } from '@angular/common/testing'; import { Subject } from 'rxjs'; @@ -9,26 +9,28 @@ import { LocationService } from './location.service'; import { ScrollService } from './scroll.service'; describe('LocationService', () => { - let injector: ReflectiveInjector; + let injector: Injector; let location: MockLocationStrategy; let service: LocationService; let swUpdates: MockSwUpdatesService; let scrollService: MockScrollService; beforeEach(() => { - injector = ReflectiveInjector.resolveAndCreate([ - LocationService, - Location, - { provide: GaService, useClass: TestGaService }, - { provide: LocationStrategy, useClass: MockLocationStrategy }, - { provide: PlatformLocation, useClass: MockPlatformLocation }, - { provide: SwUpdatesService, useClass: MockSwUpdatesService }, - { provide: ScrollService, useClass: MockScrollService } - ]); + injector = Injector.create({ + providers: [ + { provide: LocationService, deps: [GaService, Location, ScrollService, PlatformLocation, SwUpdatesService] }, + { provide: Location, deps: [LocationStrategy, PlatformLocation] }, + { provide: GaService, useClass: TestGaService, deps: [] }, + { provide: LocationStrategy, useClass: MockLocationStrategy, deps: [] }, + { provide: PlatformLocation, useClass: MockPlatformLocation, deps: [] }, + { provide: SwUpdatesService, useClass: MockSwUpdatesService, deps: [] }, + { provide: ScrollService, useClass: MockScrollService, deps: [] } + ] + }); - location = injector.get(LocationStrategy); - service = injector.get(LocationService); - swUpdates = injector.get(SwUpdatesService); + location = injector.get(LocationStrategy) as unknown as MockLocationStrategy; + service = injector.get(LocationService); + swUpdates = injector.get(SwUpdatesService) as unknown as MockSwUpdatesService; scrollService = injector.get(ScrollService); }); @@ -380,7 +382,7 @@ describe('LocationService', () => { let platformLocation: MockPlatformLocation; beforeEach(() => { - platformLocation = injector.get(PlatformLocation); + platformLocation = injector.get(PlatformLocation) as unknown as MockPlatformLocation; }); it('should call replaceState on PlatformLocation', () => { @@ -577,7 +579,7 @@ describe('LocationService', () => { let gaLocationChanged: jasmine.Spy; beforeEach(() => { - const gaService = injector.get(GaService); + const gaService = injector.get(GaService) as unknown as TestGaService; gaLocationChanged = gaService.locationChanged; // execute currentPath observable so that gaLocationChanged is called service.currentPath.subscribe(); diff --git a/aio/src/app/shared/logger.service.spec.ts b/aio/src/app/shared/logger.service.spec.ts index d676cc4e37..2dedb6e228 100644 --- a/aio/src/app/shared/logger.service.spec.ts +++ b/aio/src/app/shared/logger.service.spec.ts @@ -1,4 +1,4 @@ -import { ErrorHandler, ReflectiveInjector } from '@angular/core'; +import { ErrorHandler, Injector } from '@angular/core'; import { Logger } from './logger.service'; describe('logger service', () => { @@ -10,10 +10,10 @@ describe('logger service', () => { beforeEach(() => { logSpy = spyOn(console, 'log'); warnSpy = spyOn(console, 'warn'); - const injector = ReflectiveInjector.resolveAndCreate([ - Logger, - { provide: ErrorHandler, useClass: MockErrorHandler } - ]); + const injector = Injector.create({providers: [ + { provide: Logger, deps: [ErrorHandler] }, + { provide: ErrorHandler, useClass: MockErrorHandler, deps: [] } + ]}); logger = injector.get(Logger); errorHandler = injector.get(ErrorHandler); }); diff --git a/aio/src/app/shared/reporting-error-handler.spec.ts b/aio/src/app/shared/reporting-error-handler.spec.ts index a7389824d4..39fb790e30 100644 --- a/aio/src/app/shared/reporting-error-handler.spec.ts +++ b/aio/src/app/shared/reporting-error-handler.spec.ts @@ -1,4 +1,4 @@ -import { ErrorHandler, ReflectiveInjector } from '@angular/core'; +import { ErrorHandler, Injector } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { WindowToken } from 'app/shared/window'; import { AppModule } from 'app/app.module'; @@ -14,11 +14,12 @@ describe('ReportingErrorHandler service', () => { onerrorSpy = jasmine.createSpy('onerror'); superHandler = spyOn(ErrorHandler.prototype, 'handleError'); - const injector = ReflectiveInjector.resolveAndCreate([ - { provide: ErrorHandler, useClass: ReportingErrorHandler }, - { provide: WindowToken, useFactory: () => ({ onerror: onerrorSpy }) } - ]); - handler = injector.get(ErrorHandler); + const injector = Injector.create({providers: [ + { provide: ErrorHandler, useClass: ReportingErrorHandler, deps: [WindowToken] }, + { provide: WindowToken, useFactory: () => ({ onerror: onerrorSpy }), deps: [] } + ]}); + + handler = injector.get(ErrorHandler) as unknown as ReportingErrorHandler; }); it('should be registered on the AppModule', () => { diff --git a/aio/src/app/shared/scroll-spy.service.spec.ts b/aio/src/app/shared/scroll-spy.service.spec.ts index 4563683c72..9184839fa5 100644 --- a/aio/src/app/shared/scroll-spy.service.spec.ts +++ b/aio/src/app/shared/scroll-spy.service.spec.ts @@ -1,4 +1,4 @@ -import { Injector, ReflectiveInjector } from '@angular/core'; +import { Injector } from '@angular/core'; import { fakeAsync, tick } from '@angular/core/testing'; import { DOCUMENT } from '@angular/common'; @@ -151,11 +151,11 @@ describe('ScrollSpyService', () => { let scrollSpyService: ScrollSpyService; beforeEach(() => { - injector = ReflectiveInjector.resolveAndCreate([ + injector = Injector.create({providers: [ { provide: DOCUMENT, useValue: { body: {} } }, { provide: ScrollService, useValue: { topOffset: 50 } }, - ScrollSpyService - ]); + { provide: ScrollSpyService, deps: [DOCUMENT, ScrollService] } + ]}); scrollSpyService = injector.get(ScrollSpyService); }); diff --git a/aio/src/app/shared/scroll.service.spec.ts b/aio/src/app/shared/scroll.service.spec.ts index bd3ffd71f1..51cbfb490c 100644 --- a/aio/src/app/shared/scroll.service.spec.ts +++ b/aio/src/app/shared/scroll.service.spec.ts @@ -1,7 +1,7 @@ import {Location, LocationStrategy, PlatformLocation, ViewportScroller} from '@angular/common'; import {DOCUMENT} from '@angular/common'; import {MockLocationStrategy, SpyLocation} from '@angular/common/testing'; -import {ReflectiveInjector} from '@angular/core'; +import {Injector} from '@angular/core'; import {fakeAsync, tick} from '@angular/core/testing'; import {ScrollService, topMargin} from './scroll.service'; @@ -15,7 +15,7 @@ describe('ScrollService', () => { }; const topOfPageElem = {} as Element; - let injector: ReflectiveInjector; + let injector: Injector; let document: MockDocument; let platformLocation: MockPlatformLocation; let scrollService: ScrollService; @@ -41,21 +41,25 @@ describe('ScrollService', () => { jasmine.createSpyObj('viewportScroller', ['getScrollPosition', 'scrollToPosition']); beforeEach(() => { - injector = ReflectiveInjector.resolveAndCreate([ - { - provide: ScrollService, - useFactory: createScrollService, - deps: [DOCUMENT, PlatformLocation, ViewportScroller, Location], - }, - {provide: Location, useClass: SpyLocation}, {provide: DOCUMENT, useClass: MockDocument}, - {provide: PlatformLocation, useClass: MockPlatformLocation}, - {provide: ViewportScroller, useValue: viewportScrollerStub}, - {provide: LocationStrategy, useClass: MockLocationStrategy} - ]); + injector = Injector.create( { + providers: [ + { + provide: ScrollService, + useFactory: createScrollService, + deps: [DOCUMENT, PlatformLocation, ViewportScroller, Location], + }, + {provide: Location, useClass: SpyLocation, deps: [] }, + {provide: DOCUMENT, useClass: MockDocument, deps: []}, + {provide: PlatformLocation, useClass: MockPlatformLocation, deps: []}, + {provide: ViewportScroller, useValue: viewportScrollerStub}, + {provide: LocationStrategy, useClass: MockLocationStrategy, deps: []} + ] + }); + platformLocation = injector.get(PlatformLocation); - document = injector.get(DOCUMENT); + document = injector.get(DOCUMENT) as unknown as MockDocument; scrollService = injector.get(ScrollService); - location = injector.get(Location); + location = injector.get(Location) as unknown as SpyLocation; spyOn(window, 'scrollBy'); }); diff --git a/aio/src/app/shared/toc.service.spec.ts b/aio/src/app/shared/toc.service.spec.ts index ffe765e820..be5cd36fe5 100644 --- a/aio/src/app/shared/toc.service.spec.ts +++ b/aio/src/app/shared/toc.service.spec.ts @@ -1,5 +1,5 @@ import { DOCUMENT } from '@angular/common'; -import { ReflectiveInjector } from '@angular/core'; +import { Injector } from '@angular/core'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; import { Subject } from 'rxjs'; @@ -7,7 +7,7 @@ import { ScrollItem, ScrollSpyInfo, ScrollSpyService } from 'app/shared/scroll-s import { TocItem, TocService } from './toc.service'; describe('TocService', () => { - let injector: ReflectiveInjector; + let injector: Injector; let scrollSpyService: MockScrollSpyService; let tocService: TocService; let lastTocList: TocItem[]; @@ -21,13 +21,14 @@ describe('TocService', () => { } beforeEach(() => { - injector = ReflectiveInjector.resolveAndCreate([ - { provide: DomSanitizer, useClass: TestDomSanitizer }, + injector = Injector.create({providers: [ + { provide: DomSanitizer, useClass: TestDomSanitizer, deps: [] }, { provide: DOCUMENT, useValue: document }, - { provide: ScrollSpyService, useClass: MockScrollSpyService }, - TocService, - ]); - scrollSpyService = injector.get(ScrollSpyService); + { provide: ScrollSpyService, useClass: MockScrollSpyService, deps: [] }, + { provide: TocService, deps: [DOCUMENT, DomSanitizer, ScrollSpyService] }, + ]}); + + scrollSpyService = injector.get(ScrollSpyService) as unknown as MockScrollSpyService; tocService = injector.get(TocService); tocService.tocList.subscribe(tocList => lastTocList = tocList); }); @@ -330,7 +331,7 @@ describe('TocService', () => { }); it('should have bypassed HTML sanitizing of heading\'s innerHTML ', () => { - const domSanitizer: TestDomSanitizer = injector.get(DomSanitizer); + const domSanitizer: TestDomSanitizer = injector.get(DomSanitizer) as unknown as TestDomSanitizer; expect(domSanitizer.bypassSecurityTrustHtml) .toHaveBeenCalledWith('Setup to develop locally.'); }); diff --git a/aio/src/app/sw-updates/sw-updates.service.spec.ts b/aio/src/app/sw-updates/sw-updates.service.spec.ts index e99330d663..fa35b19931 100644 --- a/aio/src/app/sw-updates/sw-updates.service.spec.ts +++ b/aio/src/app/sw-updates/sw-updates.service.spec.ts @@ -1,4 +1,4 @@ -import { ApplicationRef, ReflectiveInjector } from '@angular/core'; +import { ApplicationRef, Injector } from '@angular/core'; import { discardPeriodicTasks, fakeAsync, tick } from '@angular/core/testing'; import { SwUpdate } from '@angular/service-worker'; import { Subject } from 'rxjs'; @@ -8,7 +8,7 @@ import { SwUpdatesService } from './sw-updates.service'; describe('SwUpdatesService', () => { - let injector: ReflectiveInjector; + let injector: Injector; let appRef: MockApplicationRef; let service: SwUpdatesService; let swu: MockSwUpdate; @@ -21,16 +21,16 @@ describe('SwUpdatesService', () => { // run `setup()`/`tearDown()` in `beforeEach()`/`afterEach()` blocks. We use the `run()` helper // to call them inside each test's zone. const setup = (isSwUpdateEnabled: boolean) => { - injector = ReflectiveInjector.resolveAndCreate([ - { provide: ApplicationRef, useClass: MockApplicationRef }, - { provide: Logger, useClass: MockLogger }, - { provide: SwUpdate, useFactory: () => new MockSwUpdate(isSwUpdateEnabled) }, - SwUpdatesService - ]); + injector = Injector.create({providers: [ + { provide: ApplicationRef, useClass: MockApplicationRef, deps: [] }, + { provide: Logger, useClass: MockLogger, deps: [] }, + { provide: SwUpdate, useFactory: () => new MockSwUpdate(isSwUpdateEnabled), deps: [] }, + { provide: SwUpdatesService, deps: [ApplicationRef, Logger, SwUpdate] } + ]}); - appRef = injector.get(ApplicationRef); + appRef = injector.get(ApplicationRef) as unknown as MockApplicationRef; service = injector.get(SwUpdatesService); - swu = injector.get(SwUpdate); + swu = injector.get(SwUpdate) as unknown as MockSwUpdate; checkInterval = (service as any).checkInterval; }; const tearDown = () => service.ngOnDestroy(); From f42e6ce9175eb1b9b373c8ef787519a5ccde203b Mon Sep 17 00:00:00 2001 From: JoostK Date: Tue, 11 Aug 2020 22:29:34 +0200 Subject: [PATCH 037/629] perf(compiler-cli): only generate type-check code for referenced DOM elements (#38418) The template type-checker would generate a statement with a call expression for all DOM elements in a template of the form: ``` const _t1 = document.createElement("div"); ``` Profiling has shown that this is a particularly expensive call to perform type inference on, as TypeScript needs to perform signature selection of `Document.createElement` and resolve the exact type from the `HTMLElementTagNameMap`. However, it can be observed that the statement by itself does not contribute anything to the type-checking result if `_t1` is not actually used anywhere, which is only rarely the case---it requires that the element is referenced by its name from somewhere else in the template. Consequently, the type-checker can skip generating this statement altogether for most DOM elements. The effect of this optimization is significant in several phases: 1. Less type-check code to generate 2. Less type-check code to emit and parse again 3. No expensive type inference to perform for the call expression The effect on phase 3 is the most significant here, as type-checking is not currently incremental in the sense that only phases 1 and 2 can be reused from a prior compilation. The actual type-checking of all templates in phase 3 needs to be repeated on each incremental compilation, so any performance gains we achieve here are very beneficial. PR Close #38418 --- .../ngtsc/typecheck/src/type_check_block.ts | 82 +++++++++- .../typecheck/test/span_comments_spec.ts | 2 +- .../typecheck/test/type_check_block_spec.ts | 140 +++++++++--------- .../ngtsc/typecheck/test/type_checker_spec.ts | 28 ++-- 4 files changed, 166 insertions(+), 86 deletions(-) diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts index fa46e7f1db..ab21246052 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts @@ -99,6 +99,13 @@ export function generateTypeCheckBlock( * `ts.Expression` which can be used to reference the operation's result. */ abstract class TcbOp { + /** + * Set to true if this operation can be considered optional. Optional operations are only executed + * when depended upon by other operations, otherwise they are disregarded. This allows for less + * code to generate, parse and type-check, overall positively contributing to performance. + */ + abstract readonly optional: boolean; + abstract execute(): ts.Expression|null; /** @@ -125,6 +132,13 @@ class TcbElementOp extends TcbOp { super(); } + get optional() { + // The statement generated by this operation is only used for type-inference of the DOM + // element's type and won't report diagnostics by itself, so the operation is marked as optional + // to avoid generating statements for DOM elements that are never referenced. + return true; + } + execute(): ts.Identifier { const id = this.tcb.allocateId(); // Add the declaration of the element using document.createElement. @@ -148,6 +162,10 @@ class TcbVariableOp extends TcbOp { super(); } + get optional() { + return false; + } + execute(): ts.Identifier { // Look for a context variable for the template. const ctx = this.scope.resolve(this.template); @@ -176,6 +194,10 @@ class TcbTemplateContextOp extends TcbOp { super(); } + get optional() { + return false; + } + execute(): ts.Identifier { // Allocate a template ctx variable and declare it with an 'any' type. The type of this variable // may be narrowed as a result of template guard conditions. @@ -198,6 +220,10 @@ class TcbTemplateBodyOp extends TcbOp { super(); } + get optional() { + return false; + } + execute(): null { // An `if` will be constructed, within which the template's children will be type checked. The // `if` is used for two reasons: it creates a new syntactic scope, isolating variables declared @@ -301,6 +327,10 @@ class TcbTextInterpolationOp extends TcbOp { super(); } + get optional() { + return false; + } + execute(): null { const expr = tcbExpression(this.binding.value, this.tcb, this.scope); this.scope.addStatement(ts.createExpressionStatement(expr)); @@ -324,6 +354,10 @@ class TcbDirectiveTypeOp extends TcbOp { super(); } + get optional() { + return false; + } + execute(): ts.Identifier { const id = this.tcb.allocateId(); @@ -352,6 +386,10 @@ class TcbDirectiveCtorOp extends TcbOp { super(); } + get optional() { + return false; + } + execute(): ts.Identifier { const id = this.tcb.allocateId(); @@ -409,6 +447,10 @@ class TcbDirectiveInputsOp extends TcbOp { super(); } + get optional() { + return false; + } + execute(): null { const dirId = this.scope.resolve(this.node, this.dir); @@ -514,6 +556,10 @@ class TcbDirectiveCtorCircularFallbackOp extends TcbOp { super(); } + get optional() { + return false; + } + execute(): ts.Identifier { const id = this.tcb.allocateId(); const typeCtor = this.tcb.env.typeCtorFor(this.dir); @@ -541,6 +587,10 @@ class TcbDomSchemaCheckerOp extends TcbOp { super(); } + get optional() { + return false; + } + execute(): ts.Expression|null { if (this.checkElement) { this.tcb.domSchemaChecker.checkElement(this.tcb.id, this.element, this.tcb.schemas); @@ -597,10 +647,14 @@ class TcbUnclaimedInputsOp extends TcbOp { super(); } + get optional() { + return false; + } + execute(): null { // `this.inputs` contains only those bindings not matched by any directive. These bindings go to // the element itself. - const elId = this.scope.resolve(this.element); + let elId: ts.Expression|null = null; // TODO(alxhub): this could be more efficient. for (const binding of this.element.inputs) { @@ -622,6 +676,9 @@ class TcbUnclaimedInputsOp extends TcbOp { if (this.tcb.env.config.checkTypeOfDomBindings && binding.type === BindingType.Property) { if (binding.name !== 'style' && binding.name !== 'class') { + if (elId === null) { + elId = this.scope.resolve(this.element); + } // A direct binding to a property. const propertyName = ATTR_TO_PROP[binding.name] || binding.name; const prop = ts.createElementAccess(elId, ts.createStringLiteral(propertyName)); @@ -656,6 +713,10 @@ class TcbDirectiveOutputsOp extends TcbOp { super(); } + get optional() { + return false; + } + execute(): null { const dirId = this.scope.resolve(this.node, this.dir); @@ -723,8 +784,12 @@ class TcbUnclaimedOutputsOp extends TcbOp { super(); } + get optional() { + return false; + } + execute(): null { - const elId = this.scope.resolve(this.element); + let elId: ts.Expression|null = null; // TODO(alxhub): this could be more efficient. for (const output of this.element.outputs) { @@ -749,6 +814,9 @@ class TcbUnclaimedOutputsOp extends TcbOp { // base `Event` type. const handler = tcbCreateEventHandler(output, this.tcb, this.scope, EventParamType.Infer); + if (elId === null) { + elId = this.scope.resolve(this.element); + } const call = ts.createCall( /* expression */ ts.createPropertyAccess(elId, 'addEventListener'), /* typeArguments */ undefined, @@ -965,7 +1033,7 @@ class Scope { */ render(): ts.Statement[] { for (let i = 0; i < this.opQueue.length; i++) { - this.executeOp(i); + this.executeOp(i, /* skipOptional */ true); } return this.statements; } @@ -1031,7 +1099,7 @@ class Scope { * Like `executeOp`, but assert that the operation actually returned `ts.Expression`. */ private resolveOp(opIndex: number): ts.Expression { - const res = this.executeOp(opIndex); + const res = this.executeOp(opIndex, /* skipOptional */ false); if (res === null) { throw new Error(`Error resolving operation, got null`); } @@ -1045,12 +1113,16 @@ class Scope { * and also protects against a circular dependency from the operation to itself by temporarily * setting the operation's result to a special expression. */ - private executeOp(opIndex: number): ts.Expression|null { + private executeOp(opIndex: number, skipOptional: boolean): ts.Expression|null { const op = this.opQueue[opIndex]; if (!(op instanceof TcbOp)) { return op; } + if (skipOptional && op.optional) { + return null; + } + // Set the result of the operation in the queue to its circular fallback. If executing this // operation results in a circular dependency, this will prevent an infinite loop and allow for // the resolution of such cycles. diff --git a/packages/compiler-cli/src/ngtsc/typecheck/test/span_comments_spec.ts b/packages/compiler-cli/src/ngtsc/typecheck/test/span_comments_spec.ts index b6c5322fbf..c15b1bc608 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/test/span_comments_spec.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/test/span_comments_spec.ts @@ -158,7 +158,7 @@ describe('type check blocks diagnostics', () => { }]; const TEMPLATE = `{{ a || a }}`; expect(tcbWithSpans(TEMPLATE, DIRECTIVES)) - .toContain('((_t2 /*23,24*/) || (_t2 /*28,29*/) /*23,29*/);'); + .toContain('((_t1 /*23,24*/) || (_t1 /*28,29*/) /*23,29*/);'); }); }); }); diff --git a/packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts b/packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts index 8a11d6d87c..27cb1ac6e3 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts @@ -55,7 +55,7 @@ describe('type check blocks', () => { selector: '[dir]', inputs: {inputA: 'inputA'}, }]; - expect(tcb(TEMPLATE, DIRECTIVES)).toContain('_t2: DirA = (null!); _t2.inputA = ("value");'); + expect(tcb(TEMPLATE, DIRECTIVES)).toContain('_t1: DirA = (null!); _t1.inputA = ("value");'); }); it('should handle multiple bindings to the same property', () => { @@ -67,8 +67,8 @@ describe('type check blocks', () => { inputs: {inputA: 'inputA'}, }]; const block = tcb(TEMPLATE, DIRECTIVES); - expect(block).toContain('_t2.inputA = (1);'); - expect(block).toContain('_t2.inputA = (2);'); + expect(block).toContain('_t1.inputA = (1);'); + expect(block).toContain('_t1.inputA = (2);'); }); it('should handle empty bindings', () => { @@ -79,7 +79,7 @@ describe('type check blocks', () => { selector: '[dir-a]', inputs: {inputA: 'inputA'}, }]; - expect(tcb(TEMPLATE, DIRECTIVES)).toContain('_t2.inputA = (undefined);'); + expect(tcb(TEMPLATE, DIRECTIVES)).toContain('_t1.inputA = (undefined);'); }); it('should handle bindings without value', () => { @@ -90,7 +90,7 @@ describe('type check blocks', () => { selector: '[dir-a]', inputs: {inputA: 'inputA'}, }]; - expect(tcb(TEMPLATE, DIRECTIVES)).toContain('_t2.inputA = (undefined);'); + expect(tcb(TEMPLATE, DIRECTIVES)).toContain('_t1.inputA = (undefined);'); }); it('should handle implicit vars on ng-template', () => { @@ -124,7 +124,7 @@ describe('type check blocks', () => { }]; expect(tcb(TEMPLATE, DIRECTIVES)) .toContain( - 'var _t2 = Dir.ngTypeCtor({ "fieldA": (((ctx).foo)), "fieldB": (null as any) });'); + 'var _t1 = Dir.ngTypeCtor({ "fieldA": (((ctx).foo)), "fieldB": (null as any) });'); }); it('should handle multiple bindings to the same property', () => { @@ -157,7 +157,7 @@ describe('type check blocks', () => { }]; const block = tcb(TEMPLATE, DIRECTIVES); expect(block).toContain( - 'var _t2 = Dir.ngTypeCtor({ "color": (null as any), "strong": (null as any), "enabled": (null as any) });'); + 'var _t1 = Dir.ngTypeCtor({ "color": (null as any), "strong": (null as any), "enabled": (null as any) });'); expect(block).toContain('"blue"; false; true;'); }); @@ -175,8 +175,8 @@ describe('type check blocks', () => { }]; expect(tcb(TEMPLATE, DIRECTIVES)) .toContain( - 'var _t3 = Dir.ngTypeCtor((null!)); ' + - 'var _t2 = Dir.ngTypeCtor({ "input": (_t3) });'); + 'var _t2 = Dir.ngTypeCtor((null!)); ' + + 'var _t1 = Dir.ngTypeCtor({ "input": (_t2) });'); }); it('should generate circular references between two directives correctly', () => { @@ -204,9 +204,9 @@ describe('type check blocks', () => { ]; expect(tcb(TEMPLATE, DIRECTIVES)) .toContain( - 'var _t4 = DirA.ngTypeCtor((null!)); ' + - 'var _t3 = DirB.ngTypeCtor({ "inputB": (_t4) }); ' + - 'var _t2 = DirA.ngTypeCtor({ "inputA": (_t3) });'); + 'var _t3 = DirA.ngTypeCtor((null!)); ' + + 'var _t2 = DirB.ngTypeCtor({ "inputB": (_t3) }); ' + + 'var _t1 = DirA.ngTypeCtor({ "inputA": (_t2) });'); }); it('should handle empty bindings', () => { @@ -247,12 +247,23 @@ describe('type check blocks', () => { }]; expect(tcb(TEMPLATE, DIRECTIVES)) .toContain( - 'var _t2 = Dir.ngTypeCtor({ "fieldA": (((ctx).foo)) }); ' + - 'var _t3: typeof Dir.ngAcceptInputType_fieldA = (null!); ' + - '_t3 = (((ctx).foo));'); + 'var _t1 = Dir.ngTypeCtor({ "fieldA": (((ctx).foo)) }); ' + + 'var _t2: typeof Dir.ngAcceptInputType_fieldA = (null!); ' + + '_t2 = (((ctx).foo));'); }); }); + it('should only generate code for DOM elements that are actually referenced', () => { + const TEMPLATE = ` +
+ + `; + const block = tcb(TEMPLATE); + expect(block).not.toContain('"div"'); + expect(block).toContain('var _t1 = document.createElement("button");'); + expect(block).toContain('(ctx).handle(_t1);'); + }); + it('should generate a forward element reference correctly', () => { const TEMPLATE = ` {{ i.value }} @@ -273,9 +284,7 @@ describe('type check blocks', () => { selector: '[dir]', exportAs: ['dir'], }]; - expect(tcb(TEMPLATE, DIRECTIVES)) - .toContain( - 'var _t1: Dir = (null!); "" + ((_t1).value); var _t2 = document.createElement("div");'); + expect(tcb(TEMPLATE, DIRECTIVES)).toContain('var _t1: Dir = (null!); "" + ((_t1).value);'); }); it('should handle style and class bindings specially', () => { @@ -301,7 +310,7 @@ describe('type check blocks', () => { inputs: {'color': 'color', 'strong': 'strong', 'enabled': 'enabled'}, }]; const block = tcb(TEMPLATE, DIRECTIVES); - expect(block).toContain('var _t2: Dir = (null!);'); + expect(block).toContain('var _t1: Dir = (null!);'); expect(block).not.toContain('"color"'); expect(block).not.toContain('"strong"'); expect(block).not.toContain('"enabled"'); @@ -321,8 +330,8 @@ describe('type check blocks', () => { }]; expect(tcb(TEMPLATE, DIRECTIVES)) .toContain( - 'var _t2: Dir = (null!); ' + - '_t2.input = (_t2);'); + 'var _t1: Dir = (null!); ' + + '_t1.input = (_t1);'); }); it('should generate circular references between two directives correctly', () => { @@ -348,11 +357,10 @@ describe('type check blocks', () => { ]; expect(tcb(TEMPLATE, DIRECTIVES)) .toContain( - 'var _t2: DirA = (null!); ' + - 'var _t3: DirB = (null!); ' + - '_t2.inputA = (_t3); ' + - 'var _t4 = document.createElement("div"); ' + - '_t3.inputA = (_t2);'); + 'var _t1: DirA = (null!); ' + + 'var _t2: DirB = (null!); ' + + '_t1.inputA = (_t2); ' + + '_t2.inputA = (_t1);'); }); it('should handle undeclared properties', () => { @@ -368,7 +376,7 @@ describe('type check blocks', () => { }]; expect(tcb(TEMPLATE, DIRECTIVES)) .toContain( - 'var _t2: Dir = (null!); ' + + 'var _t1: Dir = (null!); ' + '(((ctx).foo)); '); }); @@ -385,9 +393,9 @@ describe('type check blocks', () => { }]; expect(tcb(TEMPLATE, DIRECTIVES)) .toContain( - 'var _t2: Dir = (null!); ' + - 'var _t3: typeof _t2["fieldA"] = (null!); ' + - '_t3 = (((ctx).foo)); '); + 'var _t1: Dir = (null!); ' + + 'var _t2: typeof _t1["fieldA"] = (null!); ' + + '_t2 = (((ctx).foo)); '); }); it('should assign properties via element access for field names that are not JS identifiers', @@ -404,8 +412,8 @@ describe('type check blocks', () => { }]; const block = tcb(TEMPLATE, DIRECTIVES); expect(block).toContain( - 'var _t2: Dir = (null!); ' + - '_t2["some-input.xs"] = (((ctx).foo)); '); + 'var _t1: Dir = (null!); ' + + '_t1["some-input.xs"] = (((ctx).foo)); '); }); it('should handle a single property bound to multiple fields', () => { @@ -421,8 +429,8 @@ describe('type check blocks', () => { }]; expect(tcb(TEMPLATE, DIRECTIVES)) .toContain( - 'var _t2: Dir = (null!); ' + - '_t2.field2 = _t2.field1 = (((ctx).foo));'); + 'var _t1: Dir = (null!); ' + + '_t1.field2 = _t1.field1 = (((ctx).foo));'); }); it('should handle a single property bound to multiple fields, where one of them is coerced', @@ -440,9 +448,9 @@ describe('type check blocks', () => { }]; expect(tcb(TEMPLATE, DIRECTIVES)) .toContain( - 'var _t2: Dir = (null!); ' + - 'var _t3: typeof Dir.ngAcceptInputType_field1 = (null!); ' + - '_t2.field2 = _t3 = (((ctx).foo));'); + 'var _t1: Dir = (null!); ' + + 'var _t2: typeof Dir.ngAcceptInputType_field1 = (null!); ' + + '_t1.field2 = _t2 = (((ctx).foo));'); }); it('should handle a single property bound to multiple fields, where one of them is undeclared', @@ -460,8 +468,8 @@ describe('type check blocks', () => { }]; expect(tcb(TEMPLATE, DIRECTIVES)) .toContain( - 'var _t2: Dir = (null!); ' + - '_t2.field2 = (((ctx).foo));'); + 'var _t1: Dir = (null!); ' + + '_t1.field2 = (((ctx).foo));'); }); it('should use coercion types if declared', () => { @@ -477,9 +485,9 @@ describe('type check blocks', () => { }]; expect(tcb(TEMPLATE, DIRECTIVES)) .toContain( - 'var _t2: Dir = (null!); ' + - 'var _t3: typeof Dir.ngAcceptInputType_fieldA = (null!); ' + - '_t3 = (((ctx).foo));'); + 'var _t1: Dir = (null!); ' + + 'var _t2: typeof Dir.ngAcceptInputType_fieldA = (null!); ' + + '_t2 = (((ctx).foo));'); }); it('should use coercion types if declared, even when backing field is not declared', () => { @@ -496,9 +504,9 @@ describe('type check blocks', () => { }]; expect(tcb(TEMPLATE, DIRECTIVES)) .toContain( - 'var _t2: Dir = (null!); ' + - 'var _t3: typeof Dir.ngAcceptInputType_fieldA = (null!); ' + - '_t3 = (((ctx).foo));'); + 'var _t1: Dir = (null!); ' + + 'var _t2: typeof Dir.ngAcceptInputType_fieldA = (null!); ' + + '_t2 = (((ctx).foo));'); }); it('should handle $any casts', () => { @@ -561,7 +569,7 @@ describe('type check blocks', () => { const TEMPLATE = `
`; const block = tcb(TEMPLATE, DIRECTIVES); expect(block).toContain( - '_outputHelper(_t2["outputField"]).subscribe(function ($event): any { (ctx).foo($event); });'); + '_outputHelper(_t1["outputField"]).subscribe(function ($event): any { (ctx).foo($event); });'); }); it('should emit a listener function with AnimationEvent for animation events', () => { @@ -658,14 +666,14 @@ describe('type check blocks', () => { it('should include null and undefined when enabled', () => { const block = tcb(TEMPLATE, DIRECTIVES); - expect(block).toContain('_t2.dirInput = (((ctx).a));'); + expect(block).toContain('_t1.dirInput = (((ctx).a));'); expect(block).toContain('((ctx).b);'); }); it('should use the non-null assertion operator when disabled', () => { const DISABLED_CONFIG: TypeCheckingConfig = {...BASE_CONFIG, strictNullInputBindings: false}; const block = tcb(TEMPLATE, DIRECTIVES, DISABLED_CONFIG); - expect(block).toContain('_t2.dirInput = (((ctx).a)!);'); + expect(block).toContain('_t1.dirInput = (((ctx).a)!);'); expect(block).toContain('((ctx).b)!;'); }); }); @@ -674,7 +682,7 @@ describe('type check blocks', () => { it('should check types of bindings when enabled', () => { const TEMPLATE = `
`; const block = tcb(TEMPLATE, DIRECTIVES); - expect(block).toContain('_t2.dirInput = (((ctx).a));'); + expect(block).toContain('_t1.dirInput = (((ctx).a));'); expect(block).toContain('((ctx).b);'); }); @@ -683,7 +691,7 @@ describe('type check blocks', () => { const DISABLED_CONFIG: TypeCheckingConfig = {...BASE_CONFIG, checkTypeOfInputBindings: false}; const block = tcb(TEMPLATE, DIRECTIVES, DISABLED_CONFIG); - expect(block).toContain('_t2.dirInput = ((((ctx).a) as any));'); + expect(block).toContain('_t1.dirInput = ((((ctx).a) as any));'); expect(block).toContain('(((ctx).b) as any);'); }); @@ -692,7 +700,7 @@ describe('type check blocks', () => { const DISABLED_CONFIG: TypeCheckingConfig = {...BASE_CONFIG, checkTypeOfInputBindings: false}; const block = tcb(TEMPLATE, DIRECTIVES, DISABLED_CONFIG); - expect(block).toContain('_t2.dirInput = ((((((ctx).a)) === (((ctx).b))) as any));'); + expect(block).toContain('_t1.dirInput = ((((((ctx).a)) === (((ctx).b))) as any));'); }); }); @@ -702,9 +710,9 @@ describe('type check blocks', () => { it('should check types of directive outputs when enabled', () => { const block = tcb(TEMPLATE, DIRECTIVES); expect(block).toContain( - '_outputHelper(_t2["outputField"]).subscribe(function ($event): any { (ctx).foo($event); });'); + '_outputHelper(_t1["outputField"]).subscribe(function ($event): any { (ctx).foo($event); });'); expect(block).toContain( - '_t1.addEventListener("nonDirOutput", function ($event): any { (ctx).foo($event); });'); + '_t2.addEventListener("nonDirOutput", function ($event): any { (ctx).foo($event); });'); }); it('should not check types of directive outputs when disabled', () => { const DISABLED_CONFIG: @@ -713,7 +721,7 @@ describe('type check blocks', () => { expect(block).toContain('function ($event: any): any { (ctx).foo($event); }'); // Note that DOM events are still checked, that is controlled by `checkTypeOfDomEvents` expect(block).toContain( - '_t1.addEventListener("nonDirOutput", function ($event): any { (ctx).foo($event); });'); + '_t2.addEventListener("nonDirOutput", function ($event): any { (ctx).foo($event); });'); }); }); @@ -739,9 +747,9 @@ describe('type check blocks', () => { it('should check types of DOM events when enabled', () => { const block = tcb(TEMPLATE, DIRECTIVES); expect(block).toContain( - '_outputHelper(_t2["outputField"]).subscribe(function ($event): any { (ctx).foo($event); });'); + '_outputHelper(_t1["outputField"]).subscribe(function ($event): any { (ctx).foo($event); });'); expect(block).toContain( - '_t1.addEventListener("nonDirOutput", function ($event): any { (ctx).foo($event); });'); + '_t2.addEventListener("nonDirOutput", function ($event): any { (ctx).foo($event); });'); }); it('should not check types of DOM events when disabled', () => { const DISABLED_CONFIG: TypeCheckingConfig = {...BASE_CONFIG, checkTypeOfDomEvents: false}; @@ -749,7 +757,7 @@ describe('type check blocks', () => { // Note that directive outputs are still checked, that is controlled by // `checkTypeOfOutputEvents` expect(block).toContain( - '_outputHelper(_t2["outputField"]).subscribe(function ($event): any { (ctx).foo($event); });'); + '_outputHelper(_t1["outputField"]).subscribe(function ($event): any { (ctx).foo($event); });'); expect(block).toContain('function ($event: any): any { (ctx).foo($event); }'); }); }); @@ -785,7 +793,7 @@ describe('type check blocks', () => { it('should trace references to a directive when enabled', () => { const block = tcb(TEMPLATE, DIRECTIVES); - expect(block).toContain('(_t2).value'); + expect(block).toContain('(_t1).value'); }); it('should trace references to an when enabled', () => { @@ -812,9 +820,9 @@ describe('type check blocks', () => { it('should assign string value to the input when enabled', () => { const block = tcb(TEMPLATE, DIRECTIVES); - expect(block).toContain('_t2.disabled = ("");'); - expect(block).toContain('_t2.cols = ("3");'); - expect(block).toContain('_t2.rows = (2);'); + expect(block).toContain('_t1.disabled = ("");'); + expect(block).toContain('_t1.cols = ("3");'); + expect(block).toContain('_t1.rows = (2);'); }); it('should use any for attributes but still check bound attributes when disabled', () => { @@ -822,7 +830,7 @@ describe('type check blocks', () => { const block = tcb(TEMPLATE, DIRECTIVES, DISABLED_CONFIG); expect(block).not.toContain('"disabled"'); expect(block).not.toContain('"cols"'); - expect(block).toContain('_t2.rows = (2);'); + expect(block).toContain('_t1.rows = (2);'); }); }); @@ -912,8 +920,8 @@ describe('type check blocks', () => { TypeCheckingConfig = {...BASE_CONFIG, honorAccessModifiersForInputBindings: true}; const block = tcb(TEMPLATE, DIRECTIVES, enableChecks); expect(block).toContain( - 'var _t2: Dir = (null!); ' + - '_t2["some-input.xs"] = (((ctx).foo)); '); + 'var _t1: Dir = (null!); ' + + '_t1["some-input.xs"] = (((ctx).foo)); '); }); it('should assign restricted properties via property access', () => { @@ -930,8 +938,8 @@ describe('type check blocks', () => { TypeCheckingConfig = {...BASE_CONFIG, honorAccessModifiersForInputBindings: true}; const block = tcb(TEMPLATE, DIRECTIVES, enableChecks); expect(block).toContain( - 'var _t2: Dir = (null!); ' + - '_t2.fieldA = (((ctx).foo)); '); + 'var _t1: Dir = (null!); ' + + '_t1.fieldA = (((ctx).foo)); '); }); }); }); diff --git a/packages/compiler-cli/src/ngtsc/typecheck/test/type_checker_spec.ts b/packages/compiler-cli/src/ngtsc/typecheck/test/type_checker_spec.ts index 0a075efbf3..e2a14bce68 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/test/type_checker_spec.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/test/type_checker_spec.ts @@ -66,7 +66,7 @@ runInEachFileSystem(os => { const file1 = absoluteFrom('/file1.ts'); const file2 = absoluteFrom('/file2.ts'); const {program, templateTypeChecker, programStrategy} = setup([ - {fileName: file1, templates: {'Cmp1': '
'}}, + {fileName: file1, templates: {'Cmp1': '
{{value}}
'}}, {fileName: file2, templates: {'Cmp2': ''}} ]); @@ -74,7 +74,7 @@ runInEachFileSystem(os => { const block = templateTypeChecker.getTypeCheckBlock(cmp1); expect(block).not.toBeNull(); expect(block!.getText()).toMatch(/: i[0-9]\.Cmp1/); - expect(block!.getText()).toContain(`document.createElement("div")`); + expect(block!.getText()).toContain(`value`); }); it('should clear old inlines when necessary', () => { @@ -223,43 +223,43 @@ runInEachFileSystem(os => { const fileName = absoluteFrom('/main.ts'); const {program, templateTypeChecker} = setup([{ fileName, - templates: {'Cmp': '
'}, + templates: {'Cmp': '
{{original}}
'}, }]); const sf = getSourceFileOrError(program, fileName); const cmp = getClass(sf, 'Cmp'); const tcbReal = templateTypeChecker.getTypeCheckBlock(cmp)!; - expect(tcbReal.getText()).toContain('div'); + expect(tcbReal.getText()).toContain('original'); - templateTypeChecker.overrideComponentTemplate(cmp, ''); + templateTypeChecker.overrideComponentTemplate(cmp, '
{{override}}
'); const tcbOverridden = templateTypeChecker.getTypeCheckBlock(cmp); expect(tcbOverridden).not.toBeNull(); - expect(tcbOverridden!.getText()).not.toContain('div'); - expect(tcbOverridden!.getText()).toContain('span'); + expect(tcbOverridden!.getText()).not.toContain('original'); + expect(tcbOverridden!.getText()).toContain('override'); }); it('should clear overrides on request', () => { const fileName = absoluteFrom('/main.ts'); const {program, templateTypeChecker} = setup([{ fileName, - templates: {'Cmp': '
'}, + templates: {'Cmp': '
{{original}}
'}, }]); const sf = getSourceFileOrError(program, fileName); const cmp = getClass(sf, 'Cmp'); - templateTypeChecker.overrideComponentTemplate(cmp, ''); + templateTypeChecker.overrideComponentTemplate(cmp, '
{{override}}
'); const tcbOverridden = templateTypeChecker.getTypeCheckBlock(cmp)!; - expect(tcbOverridden.getText()).not.toContain('div'); - expect(tcbOverridden.getText()).toContain('span'); + expect(tcbOverridden.getText()).not.toContain('original'); + expect(tcbOverridden.getText()).toContain('override'); templateTypeChecker.resetOverrides(); - // The template should be back to the original, which has
and not . + // The template should be back to the original. const tcbReal = templateTypeChecker.getTypeCheckBlock(cmp)!; - expect(tcbReal.getText()).toContain('div'); - expect(tcbReal.getText()).not.toContain('span'); + expect(tcbReal.getText()).toContain('original'); + expect(tcbReal.getText()).not.toContain('override'); }); it('should override a template and make use of previously unused directives', () => { From fb8f4b4d721e98a3b0f8163af6411dbab35f775e Mon Sep 17 00:00:00 2001 From: JoostK Date: Tue, 11 Aug 2020 23:09:05 +0200 Subject: [PATCH 038/629] perf(compiler-cli): only generate directive declarations when used (#38418) The template type-checker would always generate a directive declaration even if its type was never used. For example, directives without any input nor output bindings nor exportAs references don't need the directive to be declared, as its type would never be used. This commit makes the `TcbOp`s that are responsible for declaring a directive as optional, such that they are only executed when requested from another operation. PR Close #38418 --- .../ngtsc/typecheck/src/type_check_block.ts | 25 +++- .../typecheck/test/type_check_block_spec.ts | 111 +++++++++++++----- .../ngtsc/typecheck/test/type_checker_spec.ts | 6 +- 3 files changed, 105 insertions(+), 37 deletions(-) diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts index ab21246052..5a5535057c 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts @@ -355,7 +355,10 @@ class TcbDirectiveTypeOp extends TcbOp { } get optional() { - return false; + // The statement generated by this operation is only used to declare the directive's type and + // won't report diagnostics by itself, so the operation is marked as optional to avoid + // generating declarations for directives that don't have any inputs/outputs. + return true; } execute(): ts.Identifier { @@ -387,7 +390,9 @@ class TcbDirectiveCtorOp extends TcbOp { } get optional() { - return false; + // The statement generated by this operation is only used to infer the directive's type and + // won't report diagnostics by itself, so the operation is marked as optional. + return true; } execute(): ts.Identifier { @@ -452,7 +457,7 @@ class TcbDirectiveInputsOp extends TcbOp { } execute(): null { - const dirId = this.scope.resolve(this.node, this.dir); + let dirId: ts.Expression|null = null; // TODO(joost): report duplicate properties @@ -502,6 +507,10 @@ class TcbDirectiveInputsOp extends TcbOp { // (i.e. private/protected/readonly), generate an assignment into a temporary variable // that has the type of the field. This achieves type-checking but circumvents the access // modifiers. + if (dirId === null) { + dirId = this.scope.resolve(this.node, this.dir); + } + const id = this.tcb.allocateId(); const dirTypeRef = this.tcb.env.referenceType(this.dir.ref); if (!ts.isTypeReferenceNode(dirTypeRef)) { @@ -515,6 +524,10 @@ class TcbDirectiveInputsOp extends TcbOp { this.scope.addStatement(temp); target = id; } else { + if (dirId === null) { + dirId = this.scope.resolve(this.node, this.dir); + } + // To get errors assign directly to the fields on the instance, using property access // when possible. String literal fields may not be valid JS identifiers so we use // literal element access instead for those cases. @@ -718,7 +731,8 @@ class TcbDirectiveOutputsOp extends TcbOp { } execute(): null { - const dirId = this.scope.resolve(this.node, this.dir); + let dirId: ts.Expression|null = null; + // `dir.outputs` is an object map of field names on the directive class to event names. // This is backwards from what's needed to match event handlers - a map of event names to field @@ -748,6 +762,9 @@ class TcbDirectiveOutputsOp extends TcbOp { // that has a `subscribe` method that properly carries the `T` into the handler function. const handler = tcbCreateEventHandler(output, this.tcb, this.scope, EventParamType.Infer); + if (dirId === null) { + dirId = this.scope.resolve(this.node, this.dir); + } const outputField = ts.createElementAccess(dirId, ts.createStringLiteral(field)); const outputHelper = ts.createCall(this.tcb.env.declareOutputHelper(), undefined, [outputField]); diff --git a/packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts b/packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts index 27cb1ac6e3..ab7c6e73aa 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts @@ -156,8 +156,7 @@ describe('type check blocks', () => { isGeneric: true, }]; const block = tcb(TEMPLATE, DIRECTIVES); - expect(block).toContain( - 'var _t1 = Dir.ngTypeCtor({ "color": (null as any), "strong": (null as any), "enabled": (null as any) });'); + expect(block).not.toContain('Dir.ngTypeCtor'); expect(block).toContain('"blue"; false; true;'); }); @@ -204,9 +203,9 @@ describe('type check blocks', () => { ]; expect(tcb(TEMPLATE, DIRECTIVES)) .toContain( - 'var _t3 = DirA.ngTypeCtor((null!)); ' + - 'var _t2 = DirB.ngTypeCtor({ "inputB": (_t3) }); ' + - 'var _t1 = DirA.ngTypeCtor({ "inputA": (_t2) });'); + 'var _t3 = DirB.ngTypeCtor((null!)); ' + + 'var _t2 = DirA.ngTypeCtor({ "inputA": (_t3) }); ' + + 'var _t1 = DirB.ngTypeCtor({ "inputB": (_t2) });'); }); it('should handle empty bindings', () => { @@ -247,9 +246,8 @@ describe('type check blocks', () => { }]; expect(tcb(TEMPLATE, DIRECTIVES)) .toContain( - 'var _t1 = Dir.ngTypeCtor({ "fieldA": (((ctx).foo)) }); ' + - 'var _t2: typeof Dir.ngAcceptInputType_fieldA = (null!); ' + - '_t2 = (((ctx).foo));'); + 'var _t1: typeof Dir.ngAcceptInputType_fieldA = (null!); ' + + '_t1 = (((ctx).foo));'); }); }); @@ -264,6 +262,58 @@ describe('type check blocks', () => { expect(block).toContain('(ctx).handle(_t1);'); }); + it('should only generate directive declarations that have bindings or are referenced', () => { + const TEMPLATE = ` +
{{ref.a}}
+ `; + const DIRECTIVES: TestDeclaration[] = [ + { + type: 'directive', + name: 'HasInput', + selector: '[hasInput]', + inputs: {input: 'input'}, + }, + { + type: 'directive', + name: 'HasOutput', + selector: '[hasOutput]', + outputs: {output: 'output'}, + }, + { + type: 'directive', + name: 'HasReference', + selector: '[hasReference]', + exportAs: ['ref'], + }, + { + type: 'directive', + name: 'NoReference', + selector: '[noReference]', + exportAs: ['no-ref'], + }, + { + type: 'directive', + name: 'NoBindings', + selector: '[noBindings]', + inputs: {unset: 'unset'}, + }, + ]; + const block = tcb(TEMPLATE, DIRECTIVES); + expect(block).toContain('var _t1: HasInput = (null!)'); + expect(block).toContain('_t1.input = (((ctx).value));'); + expect(block).toContain('var _t2: HasOutput = (null!)'); + expect(block).toContain('_t2["output"]'); + expect(block).toContain('var _t3: HasReference = (null!)'); + expect(block).toContain('(_t3).a'); + expect(block).not.toContain('NoBindings'); + expect(block).not.toContain('NoReference'); + }); + it('should generate a forward element reference correctly', () => { const TEMPLATE = ` {{ i.value }} @@ -310,7 +360,7 @@ describe('type check blocks', () => { inputs: {'color': 'color', 'strong': 'strong', 'enabled': 'enabled'}, }]; const block = tcb(TEMPLATE, DIRECTIVES); - expect(block).toContain('var _t1: Dir = (null!);'); + expect(block).not.toContain('var _t1: Dir = (null!);'); expect(block).not.toContain('"color"'); expect(block).not.toContain('"strong"'); expect(block).not.toContain('"enabled"'); @@ -357,10 +407,10 @@ describe('type check blocks', () => { ]; expect(tcb(TEMPLATE, DIRECTIVES)) .toContain( - 'var _t1: DirA = (null!); ' + - 'var _t2: DirB = (null!); ' + - '_t1.inputA = (_t2); ' + - '_t2.inputA = (_t1);'); + 'var _t1: DirB = (null!); ' + + 'var _t2: DirA = (null!); ' + + '_t2.inputA = (_t1); ' + + '_t1.inputA = (_t2);'); }); it('should handle undeclared properties', () => { @@ -374,10 +424,9 @@ describe('type check blocks', () => { }, undeclaredInputFields: ['fieldA'] }]; - expect(tcb(TEMPLATE, DIRECTIVES)) - .toContain( - 'var _t1: Dir = (null!); ' + - '(((ctx).foo)); '); + const block = tcb(TEMPLATE, DIRECTIVES); + expect(block).not.toContain('var _t1: Dir = (null!);'); + expect(block).toContain('(((ctx).foo)); '); }); it('should assign restricted properties to temp variables by default', () => { @@ -448,9 +497,9 @@ describe('type check blocks', () => { }]; expect(tcb(TEMPLATE, DIRECTIVES)) .toContain( - 'var _t1: Dir = (null!); ' + - 'var _t2: typeof Dir.ngAcceptInputType_field1 = (null!); ' + - '_t1.field2 = _t2 = (((ctx).foo));'); + 'var _t1: typeof Dir.ngAcceptInputType_field1 = (null!); ' + + 'var _t2: Dir = (null!); ' + + '_t2.field2 = _t1 = (((ctx).foo));'); }); it('should handle a single property bound to multiple fields, where one of them is undeclared', @@ -483,11 +532,11 @@ describe('type check blocks', () => { }, coercedInputFields: ['fieldA'], }]; - expect(tcb(TEMPLATE, DIRECTIVES)) - .toContain( - 'var _t1: Dir = (null!); ' + - 'var _t2: typeof Dir.ngAcceptInputType_fieldA = (null!); ' + - '_t2 = (((ctx).foo));'); + const block = tcb(TEMPLATE, DIRECTIVES); + expect(block).not.toContain('var _t1: Dir = (null!);'); + expect(block).toContain( + 'var _t1: typeof Dir.ngAcceptInputType_fieldA = (null!); ' + + '_t1 = (((ctx).foo));'); }); it('should use coercion types if declared, even when backing field is not declared', () => { @@ -502,11 +551,11 @@ describe('type check blocks', () => { coercedInputFields: ['fieldA'], undeclaredInputFields: ['fieldA'], }]; - expect(tcb(TEMPLATE, DIRECTIVES)) - .toContain( - 'var _t1: Dir = (null!); ' + - 'var _t2: typeof Dir.ngAcceptInputType_fieldA = (null!); ' + - '_t2 = (((ctx).foo));'); + const block = tcb(TEMPLATE, DIRECTIVES); + expect(block).not.toContain('var _t1: Dir = (null!);'); + expect(block).toContain( + 'var _t1: typeof Dir.ngAcceptInputType_fieldA = (null!); ' + + '_t1 = (((ctx).foo));'); }); it('should handle $any casts', () => { @@ -721,7 +770,7 @@ describe('type check blocks', () => { expect(block).toContain('function ($event: any): any { (ctx).foo($event); }'); // Note that DOM events are still checked, that is controlled by `checkTypeOfDomEvents` expect(block).toContain( - '_t2.addEventListener("nonDirOutput", function ($event): any { (ctx).foo($event); });'); + '_t1.addEventListener("nonDirOutput", function ($event): any { (ctx).foo($event); });'); }); }); diff --git a/packages/compiler-cli/src/ngtsc/typecheck/test/type_checker_spec.ts b/packages/compiler-cli/src/ngtsc/typecheck/test/type_checker_spec.ts index e2a14bce68..c6e148b961 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/test/type_checker_spec.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/test/type_checker_spec.ts @@ -276,11 +276,13 @@ runInEachFileSystem(os => { selector: '[dir]', file: dirFile, type: 'directive', + inputs: {'input': 'input'}, + isGeneric: true, }] }, { fileName: dirFile, - source: `export class TestDir {}`, + source: `export class TestDir {}`, templates: {}, } ], @@ -294,7 +296,7 @@ runInEachFileSystem(os => { const tcbReal = templateTypeChecker.getTypeCheckBlock(cmp)!; expect(tcbReal.getSourceFile().text).not.toContain('TestDir'); - templateTypeChecker.overrideComponentTemplate(cmp, '
'); + templateTypeChecker.overrideComponentTemplate(cmp, '
'); const tcbOverridden = templateTypeChecker.getTypeCheckBlock(cmp); expect(tcbOverridden).not.toBeNull(); From 1388c1761f1a474de852c50f56e056e48e821bb0 Mon Sep 17 00:00:00 2001 From: JoostK Date: Tue, 11 Aug 2020 23:26:57 +0200 Subject: [PATCH 039/629] perf(compiler-cli): don't emit template guards when child scope is empty (#38418) For a template that contains for example `` there's no need to render the `NgIf` guard expression, as the child scope does not have any type-checking statements, so any narrowing effect of the guard is not applicable. This seems like a minor improvement, however it reduces the number of flow-node antecedents that TypeScript needs to keep into account for such cases, resulting in an overall reduction of type-checking time. PR Close #38418 --- .../ngtsc/typecheck/src/type_check_block.ts | 15 +++++++++++-- .../typecheck/test/type_check_block_spec.ts | 22 ++++++++++++++++--- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts index 5a5535057c..5576556a71 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts @@ -304,8 +304,19 @@ class TcbTemplateBodyOp extends TcbOp { // children, as well as tracks bindings within the template. const tmplScope = Scope.forNodes(this.tcb, this.scope, this.template, guard); - // Render the template's `Scope` into a block. - let tmplBlock: ts.Statement = ts.createBlock(tmplScope.render()); + // Render the template's `Scope` into its statements. + const statements = tmplScope.render(); + if (statements.length === 0) { + // As an optimization, don't generate the scope's block if it has no statements. This is + // beneficial for templates that contain for example ``, in which + // case there's no need to render the `NgIf` guard expression. This seems like a minor + // improvement, however it reduces the number of flow-node antecedents that TypeScript needs + // to keep into account for such cases, resulting in an overall reduction of + // type-checking time. + return null; + } + + let tmplBlock: ts.Statement = ts.createBlock(statements); if (guard !== null) { // The scope has a guard that needs to be applied, so wrap the template block into an `if` // statement containing the guard expression. diff --git a/packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts b/packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts index ab7c6e73aa..9dafff8bde 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts @@ -585,7 +585,7 @@ describe('type check blocks', () => { type: 'invocation', }] }]; - const TEMPLATE = `
`; + const TEMPLATE = `
{{person.name}}
`; const block = tcb(TEMPLATE, DIRECTIVES); expect(block).toContain('if (NgIf.ngTemplateGuard_ngIf(_t1, ((ctx).person)))'); }); @@ -601,10 +601,26 @@ describe('type check blocks', () => { type: 'binding', }] }]; - const TEMPLATE = `
`; + const TEMPLATE = `
{{person.name}}
`; const block = tcb(TEMPLATE, DIRECTIVES); expect(block).toContain('if ((((ctx).person)) !== (null))'); }); + + it('should not emit guards when the child scope is empty', () => { + const DIRECTIVES: TestDeclaration[] = [{ + type: 'directive', + name: 'NgIf', + selector: '[ngIf]', + inputs: {'ngIf': 'ngIf'}, + ngTemplateGuards: [{ + inputName: 'ngIf', + type: 'invocation', + }] + }]; + const TEMPLATE = `
static
`; + const block = tcb(TEMPLATE, DIRECTIVES); + expect(block).not.toContain('NgIf.ngTemplateGuard_ngIf'); + }); }); describe('outputs', () => { @@ -681,7 +697,7 @@ describe('type check blocks', () => { }; describe('config.applyTemplateContextGuards', () => { - const TEMPLATE = `
`; + const TEMPLATE = `
{{ value }}
`; const GUARD_APPLIED = 'if (Dir.ngTemplateContextGuard('; it('should apply template context guards when enabled', () => { From d5f819ebc121a054aa968fd4b06131271626b0c2 Mon Sep 17 00:00:00 2001 From: Ahn Date: Thu, 13 Aug 2020 15:24:03 +0200 Subject: [PATCH 040/629] style(compiler-cli): remove unused constant (#38441) Remove unused constant allDiagnostics PR Close #38441 --- packages/compiler-cli/src/main.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/compiler-cli/src/main.ts b/packages/compiler-cli/src/main.ts index 0c8eb8ddb8..88facc5587 100644 --- a/packages/compiler-cli/src/main.ts +++ b/packages/compiler-cli/src/main.ts @@ -172,7 +172,6 @@ export function readCommandLineAndConfiguration( emitFlags: api.EmitFlags.Default }; } - const allDiagnostics: Diagnostics = []; const config = readConfiguration(project, cmdConfig.options); const options = {...config.options, ...existingOptions}; if (options.locale) { From b071495f9216324783a8c4daca077acf9850292e Mon Sep 17 00:00:00 2001 From: waterplea Date: Thu, 13 Aug 2020 22:53:46 +0300 Subject: [PATCH 041/629] fix(core): fix multiple nested views removal from ViewContainerRef (#38317) When removal of one view causes removal of another one from the same ViewContainerRef it triggers an error with views length calculation. This commit fixes this bug by removing a view from the list of available views before invoking actual view removal (which might be recursive and relies on the length of the list of available views). Fixes #38201. PR Close #38317 --- .../core/src/render3/node_manipulation.ts | 11 ---- .../src/render3/view_engine_compatibility.ts | 18 ++++-- .../acceptance/view_container_ref_spec.ts | 58 ++++++++++++++++++- 3 files changed, 71 insertions(+), 16 deletions(-) diff --git a/packages/core/src/render3/node_manipulation.ts b/packages/core/src/render3/node_manipulation.ts index c7ce0d2542..1e0dba6d78 100644 --- a/packages/core/src/render3/node_manipulation.ts +++ b/packages/core/src/render3/node_manipulation.ts @@ -349,17 +349,6 @@ export function detachView(lContainer: LContainer, removeIndex: number): LView|u return viewToDetach; } -/** - * Removes a view from a container, i.e. detaches it and then destroys the underlying LView. - * - * @param lContainer The container from which to remove a view - * @param removeIndex The index of the view to remove - */ -export function removeView(lContainer: LContainer, removeIndex: number) { - const detachedView = detachView(lContainer, removeIndex); - detachedView && destroyLView(detachedView[TVIEW], detachedView); -} - /** * A standalone function which destroys an LView, * conducting clean up (e.g. removing listeners, calling onDestroys). diff --git a/packages/core/src/render3/view_engine_compatibility.ts b/packages/core/src/render3/view_engine_compatibility.ts index 42e69cea5a..e1958ebf43 100644 --- a/packages/core/src/render3/view_engine_compatibility.ts +++ b/packages/core/src/render3/view_engine_compatibility.ts @@ -22,12 +22,12 @@ import {assertLContainer} from './assert'; import {getParentInjectorLocation, NodeInjector} from './di'; import {addToViewTree, createLContainer, createLView, renderView} from './instructions/shared'; import {CONTAINER_HEADER_OFFSET, LContainer, VIEW_REFS} from './interfaces/container'; -import {TContainerNode, TDirectiveHostNode, TElementContainerNode, TElementNode, TNode, TNodeType, TViewNode} from './interfaces/node'; +import {TContainerNode, TDirectiveHostNode, TElementContainerNode, TElementNode, TNode, TNodeType} from './interfaces/node'; import {isProceduralRenderer, RComment, RElement} from './interfaces/renderer'; import {isComponentHost, isLContainer, isLView, isRootView} from './interfaces/type_checks'; import {DECLARATION_COMPONENT_VIEW, DECLARATION_LCONTAINER, LView, LViewFlags, PARENT, QUERIES, RENDERER, T_HOST, TVIEW, TView} from './interfaces/view'; import {assertNodeOfPossibleTypes} from './node_assert'; -import {addRemoveViewFromContainer, appendChild, detachView, getBeforeNodeForView, insertView, nativeInsertBefore, nativeNextSibling, nativeParentNode, removeView} from './node_manipulation'; +import {addRemoveViewFromContainer, appendChild, destroyLView, detachView, getBeforeNodeForView, insertView, nativeInsertBefore, nativeNextSibling, nativeParentNode} from './node_manipulation'; import {getParentInjectorTNode} from './node_util'; import {getLView, getPreviousOrParentTNode} from './state'; import {getParentInjectorView, hasParentInjector} from './util/injector_utils'; @@ -304,8 +304,18 @@ export function createContainerRef( remove(index?: number): void { this.allocateContainerIfNeeded(); const adjustedIdx = this._adjustIndex(index, -1); - removeView(this._lContainer, adjustedIdx); - removeFromArray(this._lContainer[VIEW_REFS]!, adjustedIdx); + const detachedView = detachView(this._lContainer, adjustedIdx); + + if (detachedView) { + // Before destroying the view, remove it from the container's array of `ViewRef`s. + // This ensures the view container length is updated before calling + // `destroyLView`, which could recursively call view container methods that + // rely on an accurate container length. + // (e.g. a method on this view container being called by a child directive's OnDestroy + // lifecycle hook) + removeFromArray(this._lContainer[VIEW_REFS]!, adjustedIdx); + destroyLView(detachedView[TVIEW], detachedView); + } } detach(index?: number): viewEngine_ViewRef|null { diff --git a/packages/core/test/acceptance/view_container_ref_spec.ts b/packages/core/test/acceptance/view_container_ref_spec.ts index 002028a106..bc97b91aa2 100644 --- a/packages/core/test/acceptance/view_container_ref_spec.ts +++ b/packages/core/test/acceptance/view_container_ref_spec.ts @@ -8,7 +8,7 @@ import {CommonModule, DOCUMENT} from '@angular/common'; import {computeMsgId} from '@angular/compiler'; -import {Compiler, Component, ComponentFactoryResolver, Directive, DoCheck, ElementRef, EmbeddedViewRef, ErrorHandler, Injector, NgModule, NO_ERRORS_SCHEMA, OnInit, Pipe, PipeTransform, QueryList, RendererFactory2, RendererType2, Sanitizer, TemplateRef, ViewChild, ViewChildren, ViewContainerRef, ɵsetDocument} from '@angular/core'; +import {Compiler, Component, ComponentFactoryResolver, Directive, DoCheck, ElementRef, EmbeddedViewRef, ErrorHandler, Injector, NgModule, NO_ERRORS_SCHEMA, OnDestroy, OnInit, Pipe, PipeTransform, QueryList, RendererFactory2, RendererType2, Sanitizer, TemplateRef, ViewChild, ViewChildren, ViewContainerRef, ɵsetDocument} from '@angular/core'; import {Input} from '@angular/core/src/metadata'; import {ngDevModeResetPerfCounters} from '@angular/core/src/util/ng_dev_mode'; import {TestBed, TestComponentRenderer} from '@angular/core/testing'; @@ -936,6 +936,62 @@ describe('ViewContainerRef', () => { }); }); + describe('dependant views', () => { + it('should not throw when view removes another view upon removal', () => { + @Component({ + template: ` +
I host a template
+ +
I host a child template
+
+ + I am child template + + ` + }) + class AppComponent { + visible = true; + + constructor(private readonly vcr: ViewContainerRef) {} + + add(template: TemplateRef): EmbeddedViewRef { + return this.vcr.createEmbeddedView(template); + } + + remove(viewRef: EmbeddedViewRef) { + this.vcr.remove(this.vcr.indexOf(viewRef)); + } + } + + @Directive({selector: '[template]'}) + class TemplateDirective implements OnInit, OnDestroy { + @Input() template !: TemplateRef; + ref!: EmbeddedViewRef; + + constructor(private readonly host: AppComponent) {} + + ngOnInit() { + this.ref = this.host.add(this.template); + this.ref.detectChanges(); + } + + ngOnDestroy() { + this.host.remove(this.ref); + } + } + + TestBed.configureTestingModule({ + imports: [CommonModule], + declarations: [AppComponent, TemplateDirective], + }); + + const fixture = TestBed.createComponent(AppComponent); + fixture.detectChanges(); + fixture.componentRef.instance.visible = false; + fixture.detectChanges(); + }); + }); + describe('createEmbeddedView (incl. insert)', () => { it('should work on elements', () => { @Component({ From ca798804b244a380875abc1da5f0cff99164f539 Mon Sep 17 00:00:00 2001 From: Anas Barghoud Date: Mon, 15 Jul 2019 23:00:01 +0200 Subject: [PATCH 042/629] fix(router): export DefaultRouteReuseStrategy to Router public_api (#31575) export DefaultRouteStrategy class that was used internally and exposed, and add documentation for each one of methods PR Close #31575 --- goldens/public-api/router/router.d.ts | 8 +++++ packages/router/src/index.ts | 2 +- packages/router/src/route_reuse_strategy.ts | 38 +++++++++++++++++++-- 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/goldens/public-api/router/router.d.ts b/goldens/public-api/router/router.d.ts index 41395ae9bb..8e88ac7b7c 100644 --- a/goldens/public-api/router/router.d.ts +++ b/goldens/public-api/router/router.d.ts @@ -51,6 +51,14 @@ export declare class ActivationStart { toString(): string; } +export declare abstract class BaseRouteReuseStrategy implements RouteReuseStrategy { + retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle | null; + shouldAttach(route: ActivatedRouteSnapshot): boolean; + shouldDetach(route: ActivatedRouteSnapshot): boolean; + shouldReuseRoute(future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean; + store(route: ActivatedRouteSnapshot, detachedTree: DetachedRouteHandle): void; +} + export declare interface CanActivate { canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable | Promise | boolean | UrlTree; } diff --git a/packages/router/src/index.ts b/packages/router/src/index.ts index ffd02e06c8..ea41cbd7ac 100644 --- a/packages/router/src/index.ts +++ b/packages/router/src/index.ts @@ -13,7 +13,7 @@ export {RouterLinkActive} from './directives/router_link_active'; export {RouterOutlet} from './directives/router_outlet'; export {ActivationEnd, ActivationStart, ChildActivationEnd, ChildActivationStart, Event, GuardsCheckEnd, GuardsCheckStart, NavigationCancel, NavigationEnd, NavigationError, NavigationStart, ResolveEnd, ResolveStart, RouteConfigLoadEnd, RouteConfigLoadStart, RouterEvent, RoutesRecognized, Scroll} from './events'; export {CanActivate, CanActivateChild, CanDeactivate, CanLoad, Resolve} from './interfaces'; -export {DetachedRouteHandle, RouteReuseStrategy} from './route_reuse_strategy'; +export {BaseRouteReuseStrategy, DetachedRouteHandle, RouteReuseStrategy} from './route_reuse_strategy'; export {Navigation, NavigationExtras, Router} from './router'; export {ROUTES} from './router_config_loader'; export {ExtraOptions, InitialNavigation, provideRoutes, ROUTER_CONFIGURATION, ROUTER_INITIALIZER, RouterModule} from './router_module'; diff --git a/packages/router/src/route_reuse_strategy.ts b/packages/router/src/route_reuse_strategy.ts index 0d2a0241ac..8af9f8d9d7 100644 --- a/packages/router/src/route_reuse_strategy.ts +++ b/packages/router/src/route_reuse_strategy.ts @@ -60,20 +60,54 @@ export abstract class RouteReuseStrategy { } /** - * Does not detach any subtrees. Reuses routes as long as their route config is the same. + * @description + * + * This base route reuse strategy only reuses routes when the matched router configs are + * identical. This prevents components from being destroyed and recreated + * when just the fragment or query parameters change + * (that is, the existing component is _reused_). + * + * This strategy does not store any routes for later reuse. + * + * Angular uses this strategy by default. + * + * + * It can be used as a base class for custom route reuse strategies, i.e. you can create your own + * class that extends the `BaseRouteReuseStrategy` one. + * @publicApi */ -export class DefaultRouteReuseStrategy implements RouteReuseStrategy { +export abstract class BaseRouteReuseStrategy implements RouteReuseStrategy { + /** + * Whether the given route should detach for later reuse. + * Always returns false for `BaseRouteReuseStrategy`. + * */ shouldDetach(route: ActivatedRouteSnapshot): boolean { return false; } + + /** + * A no-op; the route is never stored since this strategy never detaches routes for later re-use. + */ store(route: ActivatedRouteSnapshot, detachedTree: DetachedRouteHandle): void {} + + /** Returns `false`, meaning the route (and its subtree) is never reattached */ shouldAttach(route: ActivatedRouteSnapshot): boolean { return false; } + + /** Returns `null` because this strategy does not store routes for later re-use. */ retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle|null { return null; } + + /** + * Determines if a route should be reused. + * This strategy returns `true` when the future route config and current route config are + * identical. + */ shouldReuseRoute(future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean { return future.routeConfig === curr.routeConfig; } } + +export class DefaultRouteReuseStrategy extends BaseRouteReuseStrategy {} From 71079ce47e974df4139fb7f93cca5cc4da237a9d Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Fri, 14 Aug 2020 10:32:55 -0700 Subject: [PATCH 043/629] fix(common): Allow scrolling when browser supports scrollTo (#38468) This commit fixes a regression from "fix(common): ensure scrollRestoration is writable (#30630)" that caused scrolling to not happen at all in browsers that do not support scroll restoration. The issue was that `supportScrollRestoration` was updated to return `false` if a browser did not have a writable `scrollRestoration`. However, the previous behavior was that the function would return `true` if `window.scrollTo` was defined. Every scrolling function in the `ViewportScroller` used `supportScrollRestoration` and, with the update in bb88c9fa3daac80086efbda951d81c159e3840f4, no scrolling would be performed if a browser did not have writable `scrollRestoration` but _did_ have `window.scrollTo`. Note, that this failure was detected in the saucelabs tests. IE does not support scroll restoration so IE tests were failing. PR Close #38468 --- packages/common/src/viewport_scroller.ts | 14 +++++++++++--- packages/common/test/viewport_scroller_spec.ts | 15 ++++++++++++--- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/packages/common/src/viewport_scroller.ts b/packages/common/src/viewport_scroller.ts index 355b90ecc7..a7c9093426 100644 --- a/packages/common/src/viewport_scroller.ts +++ b/packages/common/src/viewport_scroller.ts @@ -88,7 +88,7 @@ export class BrowserViewportScroller implements ViewportScroller { * @returns The position in screen coordinates. */ getScrollPosition(): [number, number] { - if (this.supportScrollRestoration()) { + if (this.supportsScrolling()) { return [this.window.scrollX, this.window.scrollY]; } else { return [0, 0]; @@ -100,7 +100,7 @@ export class BrowserViewportScroller implements ViewportScroller { * @param position The new position in screen coordinates. */ scrollToPosition(position: [number, number]): void { - if (this.supportScrollRestoration()) { + if (this.supportsScrolling()) { this.window.scrollTo(position[0], position[1]); } } @@ -110,7 +110,7 @@ export class BrowserViewportScroller implements ViewportScroller { * @param anchor The ID of the anchor element. */ scrollToAnchor(anchor: string): void { - if (this.supportScrollRestoration()) { + if (this.supportsScrolling()) { const elSelected = this.document.getElementById(anchor) || this.document.getElementsByName(anchor)[0]; if (elSelected) { @@ -163,6 +163,14 @@ export class BrowserViewportScroller implements ViewportScroller { return false; } } + + private supportsScrolling(): boolean { + try { + return !!this.window.scrollTo; + } catch { + return false; + } + } } function getScrollRestorationProperty(obj: any): PropertyDescriptor|undefined { diff --git a/packages/common/test/viewport_scroller_spec.ts b/packages/common/test/viewport_scroller_spec.ts index 510a24bad5..006702e985 100644 --- a/packages/common/test/viewport_scroller_spec.ts +++ b/packages/common/test/viewport_scroller_spec.ts @@ -15,21 +15,30 @@ describe('BrowserViewportScroller', () => { let windowSpy: any; beforeEach(() => { - windowSpy = jasmine.createSpyObj('window', ['history']); - windowSpy.scrollTo = 1; + windowSpy = jasmine.createSpyObj('window', ['history', 'scrollTo']); windowSpy.history.scrollRestoration = 'auto'; documentSpy = jasmine.createSpyObj('document', ['getElementById', 'getElementsByName']); scroller = new BrowserViewportScroller(documentSpy, windowSpy, null!); }); describe('setHistoryScrollRestoration', () => { - it('should not crash when scrollRestoration is not writable', () => { + function createNonWritableScrollRestoration() { Object.defineProperty(windowSpy.history, 'scrollRestoration', { value: 'auto', configurable: true, }); + } + + it('should not crash when scrollRestoration is not writable', () => { + createNonWritableScrollRestoration(); expect(() => scroller.setHistoryScrollRestoration('manual')).not.toThrow(); }); + + it('should still allow scrolling if scrollRestoration is not writable', () => { + createNonWritableScrollRestoration(); + scroller.scrollToPosition([10, 10]); + expect(windowSpy.scrollTo as jasmine.Spy).toHaveBeenCalledWith(10, 10); + }); }); describe('scrollToAnchor', () => { From 5f90b64328f597b4748878c4e4d46a2b34096f84 Mon Sep 17 00:00:00 2001 From: Andrew Kushnir Date: Fri, 14 Aug 2020 17:54:21 -0700 Subject: [PATCH 044/629] refactor(compiler): i18n compiler tests refactoring (#38404) This commit refactors i18n compiler tests to avoid code duplication and simplify further maintenance and updates. PR Close #38404 --- .../compliance/r3_view_compiler_i18n_spec.ts | 1993 ++++++----------- 1 file changed, 700 insertions(+), 1293 deletions(-) diff --git a/packages/compiler-cli/test/compliance/r3_view_compiler_i18n_spec.ts b/packages/compiler-cli/test/compliance/r3_view_compiler_i18n_spec.ts index 533046c8ef..9ccb7a2cbe 100644 --- a/packages/compiler-cli/test/compliance/r3_view_compiler_i18n_spec.ts +++ b/packages/compiler-cli/test/compliance/r3_view_compiler_i18n_spec.ts @@ -182,6 +182,104 @@ const verify = (input: string, output: string, extra: any = {}): void => { } }; +// Describes a simple key-value object. +type KVList = { + [key: string]: string +}; + +// Describes placeholder type used in tests. Note: the type is an array (not an object), since it's +// important to preserve the order of placeholders (so that we can compare it with generated +// output). +type Placeholder = string[]; + +// Unique message id index that is needed to avoid different i18n vars with the same name to appear +// in the i18n block while generating an output string (used to verify compiler-generated code). +let msgIndex = 0; + +// Wraps a string into quotes is needed. +// Note: if a string starts with `$` is a special case in tests when ICU reference +// is used as a placeholder value, this we should not wrap it in quotes. +const quotedValue = (value: string) => value.startsWith('$') ? value : `"${value}"`; + +// Generates a string that represents expected Closure metadata output. +const i18nMsgClosureMeta = (meta?: KVList): string => { + if (!meta || !(meta.desc || meta.meaning)) return ''; + return ` + /** + ${meta.desc ? '* @desc ' + meta.desc : ''} + ${meta.meaning ? '* @meaning ' + meta.meaning : ''} + */ + `; +}; + +// Converts a set of placeholders to a string (as it's expected from compiler). +const i18nPlaceholdersToString = (placeholders: Placeholder[]): string => { + if (placeholders.length === 0) return ''; + const result = placeholders.map(([key, value]) => `"${key}": ${quotedValue(value)}`); + return `, { ${result.join(',')} }`; +}; + +// Generates a string that represents expected $localize metadata output. +const i18nMsgLocalizeMeta = (meta?: KVList): string => { + if (!meta) return ''; + let localizeMeta = ''; + if (meta.meaning) localizeMeta += `${meta.meaning}|`; + if (meta.desc) localizeMeta += meta.desc; + if (meta.id) localizeMeta += `@@${meta.id}`; + return `:${localizeMeta}:`; +}; + +// Transforms a message in a Closure format to a $localize version. +const i18nMsgInsertLocalizePlaceholders = + (message: string, placeholders: Placeholder[]): string => { + if (placeholders.length > 0) { + message = message.replace(/{\$(.*?)}/g, function(_, name) { + const value = placeholders.find(([k, _]) => k === name)![1]; + // e.g. startDivTag -> START_DIV_TAG + const key = name.replace(/[A-Z]/g, (ch: string) => '_' + ch).toUpperCase(); + return '$' + String.raw`{${quotedValue(value)}}:${key}:`; + }); + } + return message; + }; + +// Generates a string that represents expected i18n block content for simple message. +const i18nMsg = (message: string, placeholders: Placeholder[] = [], meta?: KVList) => { + const varName = `$I18N_${msgIndex++}$`; + const closurePlaceholders = i18nPlaceholdersToString(placeholders); + const locMessageWithPlaceholders = i18nMsgInsertLocalizePlaceholders(message, placeholders); + return String.raw` + var ${varName}; + if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { + ${i18nMsgClosureMeta(meta)} + const $MSG_EXTERNAL_${msgIndex}$ = goog.getMsg("${message}"${closurePlaceholders}); + ${varName} = $MSG_EXTERNAL_${msgIndex}$; + } + else { + ${varName} = $localize \`${i18nMsgLocalizeMeta(meta)}${locMessageWithPlaceholders}\`; + }`; +}; + +// Generates a string that represents expected i18n block content for a message that requires +// post-processing (thus includes `ɵɵi18nPostprocess` in generated code). +const i18nMsgWithPostprocess = + (message: string, placeholders: Placeholder[] = [], meta?: KVList, + postprocessPlaceholders?: Placeholder[]) => { + const varName = `$I18N_${msgIndex}$`; + const ppPaceholders = + postprocessPlaceholders ? i18nPlaceholdersToString(postprocessPlaceholders) : ''; + return String.raw` + ${i18nMsg(message, placeholders, meta)} + ${varName} = $r3$.ɵɵi18nPostprocess($${varName}$${ppPaceholders}); + `; + }; + +// Generates a string that represents expected i18n block content for an ICU. +const i18nIcuMsg = + (message: string, placeholders: string[][] = []) => { + return i18nMsgWithPostprocess(message, [], undefined, placeholders); + } + describe('i18n support in the template compiler', () => { describe('element attributes', () => { it('should add the meaning and description as JsDoc comments and metadata blocks', () => { @@ -196,109 +294,66 @@ describe('i18n support in the template compiler', () => {
Content H
`; - const output = String.raw` - var $I18N_0$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - /** - * @desc descA - * @meaning meaningA - */ - const $MSG_EXTERNAL_idA$$APP_SPEC_TS_1$ = goog.getMsg("Content A"); - $I18N_0$ = $MSG_EXTERNAL_idA$$APP_SPEC_TS_1$; - } - else { - $I18N_0$ = $localize \`:meaningA|descA@@idA:Content A\`; - } - var $I18N_3$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - /** - * @desc descB - * @meaning meaningB - */ - const $MSG_EXTERNAL_idB$$APP_SPEC_TS_4$ = goog.getMsg("Title B"); - $I18N_3$ = $MSG_EXTERNAL_idB$$APP_SPEC_TS_4$; - } - else { - $I18N_3$ = $localize \`:meaningB|descB@@idB:Title B\`; - } - const $_c5$ = ["title", $I18N_3$]; - var $I18N_7$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - /** - * @meaning meaningC - */ - const $MSG_EXTERNAL_6435899732746131543$$APP_SPEC_TS_8$ = goog.getMsg("Title C"); - $I18N_7$ = $MSG_EXTERNAL_6435899732746131543$$APP_SPEC_TS_8$; - } - else { - $I18N_7$ = $localize \`:meaningC|:Title C\`; - } - const $_c9$ = ["title", $I18N_7$]; - var $I18N_11$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - /** - * @desc descD - * @meaning meaningD - */ - const $MSG_EXTERNAL_5200291527729162531$$APP_SPEC_TS_12$ = goog.getMsg("Title D"); - $I18N_11$ = $MSG_EXTERNAL_5200291527729162531$$APP_SPEC_TS_12$; - } - else { - $I18N_11$ = $localize \`:meaningD|descD:Title D\`; - } - const $_c13$ = ["title", $I18N_11$]; - var $I18N_15$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - /** - * @desc meaningE - */ - const $MSG_EXTERNAL_idE$$APP_SPEC_TS_16$ = goog.getMsg("Title E"); - $I18N_15$ = $MSG_EXTERNAL_idE$$APP_SPEC_TS_16$; - } - else { - $I18N_15$ = $localize \`:meaningE@@idE:Title E\`; - } - const $_c17$ = ["title", $I18N_15$]; - var $I18N_19$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_idF$$APP_SPEC_TS_20$ = goog.getMsg("Title F"); - $I18N_19$ = $MSG_EXTERNAL_idF$$APP_SPEC_TS_20$; - } - else { - $I18N_19$ = $localize \`:@@idF:Title F\`; - } - const $_c21$ = ["title", $I18N_19$]; + const i18n_0 = i18nMsg('Content A', [], {id: 'idA', meaning: 'meaningA', desc: 'descA'}); + const i18n_1 = i18nMsg('Title B', [], {id: 'idB', meaning: 'meaningB', desc: 'descB'}); + const i18n_2 = i18nMsg('Title C', [], {meaning: 'meaningC'}); + const i18n_3 = i18nMsg('Title D', [], {meaning: 'meaningD', desc: 'descD'}); + const i18n_4 = i18nMsg('Title E', [], {id: 'idE', desc: 'meaningE'}); + const i18n_5 = i18nMsg('Title F', [], {id: 'idF'}); + + // Keeping this block as a raw string, since it checks escaping of special chars. + const i18n_6 = String.raw` var $I18N_23$; if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - /** - * @desc [BACKUP_$` + + /** + * @desc [BACKUP_$` + String.raw`{MESSAGE}_ID:idH]` + '`' + String.raw`desc - */ - const $MSG_EXTERNAL_idG$$APP_SPEC_TS_24$ = goog.getMsg("Title G"); - $I18N_23$ = $MSG_EXTERNAL_idG$$APP_SPEC_TS_24$; + */ + const $MSG_EXTERNAL_idG$$APP_SPEC_TS_24$ = goog.getMsg("Title G"); + $I18N_23$ = $MSG_EXTERNAL_idG$$APP_SPEC_TS_24$; } else { $I18N_23$ = $localize \`:[BACKUP_$\{MESSAGE}_ID\:idH]\\\`desc@@idG:Title G\`; } - const $_c25$ = ["title", $I18N_23$]; - var $I18N_20$; + `; + + // Keeping this block as a raw string, since it checks escaping of special chars. + const i18n_7 = String.raw` + var $i18n_7$; if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - /** - * @desc Some text \' [BACKUP_MESSAGE_ID: xxx] - */ - const $MSG_EXTERNAL_idG$$APP_SPEC_TS_21$ = goog.getMsg("Content H"); - $I18N_20$ = $MSG_EXTERNAL_idG$$APP_SPEC_TS_21$; + /** + * @desc Some text \' [BACKUP_MESSAGE_ID: xxx] + */ + const $MSG_EXTERNAL_idG$$APP_SPEC_TS_21$ = goog.getMsg("Content H"); + $i18n_7$ = $MSG_EXTERNAL_idG$$APP_SPEC_TS_21$; } else { - $I18N_20$ = $localize \`:Some text \\' [BACKUP_MESSAGE_ID\: xxx]:Content H\`; + $i18n_7$ = $localize \`:Some text \\' [BACKUP_MESSAGE_ID\: xxx]:Content H\`; } + `; + + const output = String.raw` + ${i18n_0} + ${i18n_1} + const $_c5$ = ["title", $i18n_1$]; + ${i18n_2} + const $_c9$ = ["title", $i18n_2$]; + ${i18n_3} + const $_c13$ = ["title", $i18n_3$]; + ${i18n_4} + const $_c17$ = ["title", $i18n_4$]; + ${i18n_5} + const $_c21$ = ["title", $i18n_5$]; + ${i18n_6} + const $_c25$ = ["title", $i18n_6$]; + ${i18n_7} … consts: [[${AttributeMarker.I18n}, "title"]], template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); - $r3$.ɵɵi18n(1, $I18N_0$); + $r3$.ɵɵi18n(1, $i18n_0$); $r3$.ɵɵelementEnd(); $r3$.ɵɵelementStart(2, "div", 0); $r3$.ɵɵi18nAttributes(3, $_c5$); @@ -325,7 +380,7 @@ describe('i18n support in the template compiler', () => { $r3$.ɵɵtext(19, "Content G"); $r3$.ɵɵelementEnd(); $r3$.ɵɵelementStart(20, "div"); - $r3$.ɵɵi18n(21, $I18N_20$); + $r3$.ɵɵi18n(21, $i18n_7$); $r3$.ɵɵelementEnd(); } } @@ -339,16 +394,10 @@ describe('i18n support in the template compiler', () => { `; + const i18n_0 = i18nMsg('Hello'); const output = String.raw` - var $I18N_0$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_6616505470450179563$$APP_SPEC_TS_1$ = goog.getMsg("Hello"); - $I18N_0$ = $MSG_EXTERNAL_6616505470450179563$$APP_SPEC_TS_1$; - } - else { - $I18N_0$ = $localize \`Hello\`; - } - const $_c2$ = ["title", $I18N_0$]; + ${i18n_0} + const $_c2$ = ["title", $i18n_0$]; … consts: [[${AttributeMarker.I18n}, "title"]], template: function MyComponent_Template(rf, ctx) { @@ -367,16 +416,10 @@ describe('i18n support in the template compiler', () => { Test `; + const i18n_0 = i18nMsg('Hello'); const output = String.raw` - var $I18N_0$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_6616505470450179563$$APP_SPEC_TS_1$ = goog.getMsg("Hello"); - $I18N_0$ = $MSG_EXTERNAL_6616505470450179563$$APP_SPEC_TS_1$; - } - else { - $I18N_0$ = $localize \`Hello\`; - } - const $_c2$ = ["title", $I18N_0$]; + ${i18n_0} + const $_c2$ = ["title", $i18n_0$]; function MyComponent_0_ng_template_0_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵtext(0, "Test"); @@ -408,19 +451,11 @@ describe('i18n support in the template compiler', () => { `; + const i18n_0 = + i18nMsg('Hello {$interpolation}', [['interpolation', String.raw`\uFFFD0\uFFFD`]]); const output = String.raw` - var $I18N_0$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_3771704108176831903$$APP_SPEC_TS_1$ = goog.getMsg("Hello {$interpolation}", { - "interpolation": "\uFFFD0\uFFFD" - }); - $I18N_0$ = $MSG_EXTERNAL_3771704108176831903$$APP_SPEC_TS_1$; - } - else { - $I18N_0$ = $localize \`Hello $` + - String.raw`{"\uFFFD0\uFFFD"}:INTERPOLATION:\`; - } - const $_c2$ = ["title", $I18N_0$]; + ${i18n_0} + const $_c2$ = ["title", $i18n_0$]; … consts: [[${AttributeMarker.Bindings}, "title"]], template: function MyComponent_Template(rf, ctx) { @@ -443,19 +478,11 @@ describe('i18n support in the template compiler', () => { `; + const i18n_0 = + i18nMsg('Hello {$interpolation}', [['interpolation', String.raw`\uFFFD0\uFFFD`]]); const output = String.raw` - var $I18N_0$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_3771704108176831903$$APP_SPEC_TS__1$ = goog.getMsg("Hello {$interpolation}", { - "interpolation": "\uFFFD0\uFFFD" - }); - $I18N_0$ = $MSG_EXTERNAL_3771704108176831903$$APP_SPEC_TS__1$; - } - else { - $I18N_0$ = $localize \`Hello $` + - String.raw`{"\uFFFD0\uFFFD"}:INTERPOLATION:\`; - } - const $_c2$ = ["title", $I18N_0$]; + ${i18n_0} + const $_c2$ = ["title", $i18n_0$]; … function MyComponent_0_Template(rf, ctx) { if (rf & 1) { @@ -530,20 +557,10 @@ describe('i18n support in the template compiler', () => {
`; + const i18n_0 = i18nMsg('introduction', [], {meaning: 'm', desc: 'd'}); const output = String.raw` - var $I18N_1$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - /** - * @desc d - * @meaning m - */ - const $MSG_EXTERNAL_8809028065680254561$$APP_SPEC_TS_1$ = goog.getMsg("introduction"); - $I18N_1$ = $MSG_EXTERNAL_8809028065680254561$$APP_SPEC_TS_1$; - } - else { - $I18N_1$ = $localize \`:m|d:introduction\`; - } - const $_c1$ = ["title", $I18N_1$]; + ${i18n_0} + const $_c1$ = ["title", $i18n_0$]; … consts: [["id", "static", ${AttributeMarker.I18n}, "title"]], template: function MyComponent_Template(rf, ctx) { @@ -571,89 +588,45 @@ describe('i18n support in the template compiler', () => { >
`; + const i18n_0 = i18nMsg('static text'); + const i18n_1 = i18nMsg( + 'intro {$interpolation}', [['interpolation', String.raw`\uFFFD0\uFFFD`]], + {meaning: 'm', desc: 'd'}); + const i18n_2 = i18nMsg( + '{$interpolation}', [['interpolation', String.raw`\uFFFD0\uFFFD`]], + {meaning: 'm1', desc: 'd1'}); + const i18n_3 = i18nMsg( + '{$interpolation} and {$interpolation_1} and again {$interpolation_2}', + [ + ['interpolation', String.raw`\uFFFD0\uFFFD`], + ['interpolation_1', String.raw`\uFFFD1\uFFFD`], + ['interpolation_2', String.raw`\uFFFD2\uFFFD`] + ], + {meaning: 'm2', desc: 'd2'}); + const i18n_4 = i18nMsg('{$interpolation}', [['interpolation', String.raw`\uFFFD0\uFFFD`]]); + const output = String.raw` - var $I18N_1$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_5526535577705876535$$APP_SPEC_TS_1$ = goog.getMsg("static text"); - $I18N_1$ = $MSG_EXTERNAL_5526535577705876535$$APP_SPEC_TS_1$; - } - else { - $I18N_1$ = $localize \`static text\`; - } - var $I18N_2$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - /** - * @desc d - * @meaning m - */ - const $MSG_EXTERNAL_8977039798304050198$$APP_SPEC_TS_2$ = goog.getMsg("intro {$interpolation}", { - "interpolation": "\uFFFD0\uFFFD" - }); - $I18N_2$ = $MSG_EXTERNAL_8977039798304050198$$APP_SPEC_TS_2$; - } - else { - $I18N_2$ = $localize \`:m|d:intro $` + - String.raw`{"\uFFFD0\uFFFD"}:INTERPOLATION:\`; - } - var $I18N_3$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - /** - * @desc d1 - * @meaning m1 - */ - const $MSG_EXTERNAL_7432761130955693041$$APP_SPEC_TS_3$ = goog.getMsg("{$interpolation}", { - "interpolation": "\uFFFD0\uFFFD" - }); - $I18N_3$ = $MSG_EXTERNAL_7432761130955693041$$APP_SPEC_TS_3$; - } - else { - $I18N_3$ = $localize \`:m1|d1:$` + - String.raw`{"\uFFFD0\uFFFD"}:INTERPOLATION:\`; - } + ${i18n_0} + ${i18n_1} + ${i18n_2} const $_c1$ = [ - "aria-roledescription", $I18N_1$, - "title", $I18N_2$, - "aria-label", $I18N_3$ + "aria-roledescription", $i18n_0$, + "title", $i18n_1$, + "aria-label", $i18n_2$ ]; - var $I18N_6$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - /** - * @desc d2 - * @meaning m2 - */ - const $MSG_EXTERNAL_7566208596013750546$$APP_SPEC_TS_6$ = goog.getMsg("{$interpolation} and {$interpolation_1} and again {$interpolation_2}", { - "interpolation": "\uFFFD0\uFFFD", "interpolation_1": "\uFFFD1\uFFFD", "interpolation_2": "\uFFFD2\uFFFD" - }); - $I18N_6$ = $MSG_EXTERNAL_7566208596013750546$$APP_SPEC_TS_6$; - } - else { - $I18N_6$ = $localize \`:m2|d2:$` + - String.raw`{"\uFFFD0\uFFFD"}:INTERPOLATION: and $` + - String.raw`{"\uFFFD1\uFFFD"}:INTERPOLATION_1: and again $` + - String.raw`{"\uFFFD2\uFFFD"}:INTERPOLATION_2:\`; - } - var $I18N_7$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_6639222533406278123$$APP_SPEC_TS_7$ = goog.getMsg("{$interpolation}", { - "interpolation": "\uFFFD0\uFFFD" - }); - $I18N_7$ = $MSG_EXTERNAL_6639222533406278123$$APP_SPEC_TS_7$; - } - else { - $I18N_7$ = $localize \`$` + - String.raw`{"\uFFFD0\uFFFD"}:INTERPOLATION:\`; - } + ${i18n_3} + ${i18n_4} const $_c3$ = [ - "title", $I18N_6$, - "aria-roledescription", $I18N_7$ + "title", $i18n_3$, + "aria-roledescription", $i18n_4$ ]; … decls: 5, vars: 8, consts: [["id", "dynamic-1", ${ - AttributeMarker - .I18n}, "aria-roledescription", "title", "aria-label"], ["id", "dynamic-2", ${ - AttributeMarker.I18n}, "title", "aria-roledescription"]], + AttributeMarker + .I18n}, "aria-roledescription", "title", "aria-label"], ["id", "dynamic-2", ${ + AttributeMarker.I18n}, "title", "aria-roledescription"]], template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div", 0); @@ -682,23 +655,12 @@ describe('i18n support in the template compiler', () => {
`; + const i18n_0 = i18nMsg( + 'intro {$interpolation}', [['interpolation', String.raw`\uFFFD0\uFFFD`]], + {meaning: 'm', desc: 'd'}); const output = String.raw` - var $I18N_1$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - /** - * @desc d - * @meaning m - */ - const $MSG_EXTERNAL_8977039798304050198$ = goog.getMsg("intro {$interpolation}", { - "interpolation": "\uFFFD0\uFFFD" - }); - $I18N_1$ = $MSG_EXTERNAL_8977039798304050198$; - } - else { - $I18N_1$ = $localize \`:m|d:intro $` + - String.raw`{"\uFFFD0\uFFFD"}:INTERPOLATION:\`; - } - const $_c3$ = ["title", $I18N_1$]; + ${i18n_0} + const $_c3$ = ["title", $i18n_0$]; … consts: [[${AttributeMarker.I18n}, "title"]], template: function MyComponent_Template(rf, ctx) { @@ -724,23 +686,13 @@ describe('i18n support in the template compiler', () => { `; + const i18n_0 = i18nMsg( + 'different scope {$interpolation}', [['interpolation', String.raw`\uFFFD0\uFFFD`]], + {meaning: 'm', desc: 'd'}); + const output = String.raw` - var $I18N_1$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - /** - * @desc d - * @meaning m - */ - const $MSG_EXTERNAL_8538466649243975456$$APP_SPEC_TS__1$ = goog.getMsg("different scope {$interpolation}", { - "interpolation": "\uFFFD0\uFFFD" - }); - $I18N_1$ = $MSG_EXTERNAL_8538466649243975456$$APP_SPEC_TS__1$; - } - else { - $I18N_1$ = $localize \`:m|d:different scope $` + - String.raw`{"\uFFFD0\uFFFD"}:INTERPOLATION:\`; - } - const $_c2$ = ["title", $I18N_1$]; + ${i18n_0} + const $_c2$ = ["title", $i18n_0$]; function MyComponent_div_0_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); @@ -761,7 +713,7 @@ describe('i18n support in the template compiler', () => { decls: 1, vars: 1, consts: [[${AttributeMarker.Template}, "ngFor", "ngForOf"], [${ - AttributeMarker.I18n}, "title"]], + AttributeMarker.I18n}, "title"]], template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵtemplate(0, MyComponent_div_0_Template, 4, 3, "div", 0); @@ -780,19 +732,12 @@ describe('i18n support in the template compiler', () => {
`; + const i18n_0 = + i18nMsg('{$interpolation} title', [['interpolation', String.raw`\uFFFD0\uFFFD`]]); + const output = String.raw` - var $I18N_1$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_3462388422673575127$$APP_SPEC_TS_2$ = goog.getMsg("{$interpolation} title", { - "interpolation": "\uFFFD0\uFFFD" - }); - $I18N_1$ = $MSG_EXTERNAL_3462388422673575127$$APP_SPEC_TS_2$; - } - else { - $I18N_1$ = $localize \`$` + - String.raw`{"\uFFFD0\uFFFD"}:INTERPOLATION: title\`; - } - const $_c3$ = ["title", $I18N_1$]; + ${i18n_0} + const $_c3$ = ["title", $i18n_0$]; … decls: 2, vars: 1, @@ -827,81 +772,37 @@ describe('i18n support in the template compiler', () => { > `; + const i18n_0 = i18nMsg('static text'); + const i18n_1 = i18nMsg( + 'intro {$interpolation}', [['interpolation', String.raw`\uFFFD0\uFFFD`]], + {meaning: 'm', desc: 'd'}); + const i18n_2 = i18nMsg( + '{$interpolation}', [['interpolation', String.raw`\uFFFD0\uFFFD`]], + {meaning: 'm1', desc: 'd1'}); + const i18n_3 = i18nMsg( + '{$interpolation} and {$interpolation_1} and again {$interpolation_2}', + [ + ['interpolation', String.raw`\uFFFD0\uFFFD`], + ['interpolation_1', String.raw`\uFFFD1\uFFFD`], + ['interpolation_2', String.raw`\uFFFD2\uFFFD`] + ], + {meaning: 'm2', desc: 'd2'}); + const i18n_4 = i18nMsg('{$interpolation}', [['interpolation', String.raw`\uFFFD0\uFFFD`]]); + const output = String.raw` - var $I18N_1$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_5526535577705876535$$APP_SPEC_TS_1$ = goog.getMsg("static text"); - $I18N_1$ = $MSG_EXTERNAL_5526535577705876535$$APP_SPEC_TS_1$; - } - else { - $I18N_1$ = $localize \`static text\`; - } - var $I18N_2$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - /** - * @desc d - * @meaning m - */ - const $MSG_EXTERNAL_8977039798304050198$$APP_SPEC_TS_2$ = goog.getMsg("intro {$interpolation}", { - "interpolation": "\uFFFD0\uFFFD" - }); - $I18N_2$ = $MSG_EXTERNAL_8977039798304050198$$APP_SPEC_TS_2$; - } - else { - $I18N_2$ = $localize \`:m|d:intro $` + - String.raw`{"\uFFFD0\uFFFD"}:INTERPOLATION:\`; - } - var $I18N_3$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - /** - * @desc d1 - * @meaning m1 - */ - const $MSG_EXTERNAL_7432761130955693041$$APP_SPEC_TS_3$ = goog.getMsg("{$interpolation}", { - "interpolation": "\uFFFD0\uFFFD" - }); - $I18N_3$ = $MSG_EXTERNAL_7432761130955693041$$APP_SPEC_TS_3$; - } - else { - $I18N_3$ = $localize \`:m1|d1:$` + - String.raw`{"\uFFFD0\uFFFD"}:INTERPOLATION:\`; - } + ${i18n_0} + ${i18n_1} + ${i18n_2} const $_c1$ = [ - "aria-roledescription", $I18N_1$, - "title", $I18N_2$, - "aria-label", $I18N_3$ + "aria-roledescription", $i18n_0$, + "title", $i18n_1$, + "aria-label", $i18n_2$ ]; - var $I18N_6$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - /** - * @desc d2 - * @meaning m2 - */ - const $MSG_EXTERNAL_7566208596013750546$$APP_SPEC_TS_6$ = goog.getMsg("{$interpolation} and {$interpolation_1} and again {$interpolation_2}", { - "interpolation": "\uFFFD0\uFFFD", "interpolation_1": "\uFFFD1\uFFFD", "interpolation_2": "\uFFFD2\uFFFD" - }); - $I18N_6$ = $MSG_EXTERNAL_7566208596013750546$$APP_SPEC_TS_6$; - } - else { - $I18N_6$ = $localize \`:m2|d2:$` + - String.raw`{"\uFFFD0\uFFFD"}:INTERPOLATION: and $` + - String.raw`{"\uFFFD1\uFFFD"}:INTERPOLATION_1: and again $` + - String.raw`{"\uFFFD2\uFFFD"}:INTERPOLATION_2:\`; - } - var $I18N_7$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_6639222533406278123$$APP_SPEC_TS_7$ = goog.getMsg("{$interpolation}", { - "interpolation": "\uFFFD0\uFFFD" - }); - $I18N_7$ = $MSG_EXTERNAL_6639222533406278123$$APP_SPEC_TS_7$; - } - else { - $I18N_7$ = $localize \`$` + - String.raw`{"\uFFFD0\uFFFD"}:INTERPOLATION:\`; - } + ${i18n_3} + ${i18n_4} const $_c3$ = [ - "title", $I18N_6$, - "aria-roledescription", $I18N_7$ + "title", $i18n_3$, + "aria-roledescription", $i18n_4$ ]; … decls: 5, @@ -940,23 +841,13 @@ describe('i18n support in the template compiler', () => { `; + const i18n_0 = i18nMsg( + 'different scope {$interpolation}', [['interpolation', String.raw`\uFFFD0\uFFFD`]], + {meaning: 'm', desc: 'd'}); + const output = String.raw` - var $I18N_2$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - /** - * @desc d - * @meaning m - */ - const $MSG_EXTERNAL_8538466649243975456$$APP_SPEC_TS__3$ = goog.getMsg("different scope {$interpolation}", { - "interpolation": "\uFFFD0\uFFFD" - }); - $I18N_2$ = $MSG_EXTERNAL_8538466649243975456$$APP_SPEC_TS__3$; - } - else { - $I18N_2$ = $localize \`:m|d:different scope $` + - String.raw`{"\uFFFD0\uFFFD"}:INTERPOLATION:\`; - } - const $_c4$ = ["title", $I18N_2$]; + ${i18n_0} + const $_c4$ = ["title", $i18n_0$]; function MyComponent_div_0_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); @@ -977,7 +868,7 @@ describe('i18n support in the template compiler', () => { decls: 1, vars: 1, consts: [[${AttributeMarker.Template}, "ngFor", "ngForOf"], [${ - AttributeMarker.I18n}, "title"]], + AttributeMarker.I18n}, "title"]], template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵtemplate(0, MyComponent_div_0_Template, 4, 3, "div", 0); @@ -996,35 +887,20 @@ describe('i18n support in the template compiler', () => {
Some content
`; + const i18n_0 = i18nMsg('Element title', [], {meaning: 'm', desc: 'd'}); + const i18n_1 = i18nMsg('Some content'); + const output = String.raw` - var $I18N_0$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - /** - * @desc d - * @meaning m - */ - const $MSG_EXTERNAL_7727043314656808423$$APP_SPEC_TS_0$ = goog.getMsg("Element title"); - $I18N_0$ = $MSG_EXTERNAL_7727043314656808423$$APP_SPEC_TS_0$; - } - else { - $I18N_0$ = $localize \`:m|d:Element title\`; - } - const $_c1$ = ["title", $I18N_0$]; - var $I18N_2$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_4969674997806975147$$APP_SPEC_TS_2$ = goog.getMsg("Some content"); - $I18N_2$ = $MSG_EXTERNAL_4969674997806975147$$APP_SPEC_TS_2$; - } - else { - $I18N_2$ = $localize \`Some content\`; - } + ${i18n_0} + const $_c1$ = ["title", $i18n_0$]; + ${i18n_1} … consts: [[${AttributeMarker.I18n}, "title"]], template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div", 0); $r3$.ɵɵi18nAttributes(1, $_c1$); - $r3$.ɵɵi18n(2, $I18N_2$); + $r3$.ɵɵi18n(2, $i18n_1$); $r3$.ɵɵelementEnd(); } } @@ -1039,6 +915,7 @@ describe('i18n support in the template compiler', () => { `; + // Keeping raw content (avoiding `i18nMsg`) to illustrate message id sanitization. const output = String.raw` var $I18N_0$; if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { @@ -1096,20 +973,8 @@ describe('i18n support in the template compiler', () => { }); it('should ignore HTML comments within translated text', () => { - const input = ` -
Some text
- `; - - const output = String.raw` - var $I18N_0$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_APP_SPEC_TS_1$ = goog.getMsg("Some text"); - $I18N_0$ = $MSG_APP_SPEC_TS_1$; - } - else { - $I18N_0$ = $localize \`Some text\`; - } - `; + const input = `
Some text
`; + const output = i18nMsg('Some text'); verify(input, output); }); @@ -1118,6 +983,7 @@ describe('i18n support in the template compiler', () => {
Some text 'with single quotes', "with double quotes", \`with backticks\` and without quotes.
`; + // Keeping raw content (avoiding `i18nMsg`) to illustrate quotes escaping. const output = String.raw` var $I18N_0$; if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { @@ -1135,6 +1001,7 @@ describe('i18n support in the template compiler', () => { it('should handle interpolations wrapped in backticks', () => { const input = '
`{{ count }}`
'; + // Keeping raw content (avoiding `i18nMsg`) to illustrate backticks escaping. const output = String.raw` var $I18N_0$; if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { @@ -1158,48 +1025,31 @@ describe('i18n support in the template compiler', () => {
My i18n block #3
`; + const i18n_0 = i18nMsg('My i18n block #1'); + const i18n_1 = i18nMsg('My i18n block #2'); + const i18n_2 = i18nMsg('My i18n block #3'); + const output = String.raw` - var $I18N_0$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_4890179241114413722$$APP_SPEC_TS_0$ = goog.getMsg("My i18n block #1"); - $I18N_0$ = $MSG_EXTERNAL_4890179241114413722$$APP_SPEC_TS_0$; - } - else { - $I18N_0$ = $localize \`My i18n block #1\`; - } - var $I18N_1$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_2413150872298537152$$APP_SPEC_TS_1$ = goog.getMsg("My i18n block #2"); - $I18N_1$ = $MSG_EXTERNAL_2413150872298537152$$APP_SPEC_TS_1$; - } - else { - $I18N_1$ = $localize \`My i18n block #2\`; - } - var $I18N_2$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_5023003143537152794$$APP_SPEC_TS_2$ = goog.getMsg("My i18n block #3"); - $I18N_2$ = $MSG_EXTERNAL_5023003143537152794$$APP_SPEC_TS_2$; - } - else { - $I18N_2$ = $localize \`My i18n block #3\`; - } + ${i18n_0} + ${i18n_1} + ${i18n_2} … template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); - $r3$.ɵɵi18n(1, $I18N_0$); + $r3$.ɵɵi18n(1, $i18n_0$); $r3$.ɵɵelementEnd(); $r3$.ɵɵelementStart(2, "div"); $r3$.ɵɵtext(3, "My non-i18n block #1"); $r3$.ɵɵelementEnd(); $r3$.ɵɵelementStart(4, "div"); - $r3$.ɵɵi18n(5, $I18N_1$); + $r3$.ɵɵi18n(5, $i18n_1$); $r3$.ɵɵelementEnd(); $r3$.ɵɵelementStart(6, "div"); $r3$.ɵɵtext(7, "My non-i18n block #2"); $r3$.ɵɵelementEnd(); $r3$.ɵɵelementStart(8, "div"); - $r3$.ɵɵi18n(9, $I18N_2$); + $r3$.ɵɵi18n(9, $i18n_2$); $r3$.ɵɵelementEnd(); } } @@ -1216,6 +1066,8 @@ describe('i18n support in the template compiler', () => { `; + // Keeping raw content (avoiding `i18nMsg`) to illustrate how named interpolations are + // generated. const output = String.raw` var $I18N_0$; if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { @@ -1255,23 +1107,15 @@ describe('i18n support in the template compiler', () => {
{% valueA %}
`; + const i18n_0 = i18nMsg('{$interpolation}', [['interpolation', String.raw`\uFFFD0\uFFFD`]]); + const output = String.raw` - var $I18N_0$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_6749967533321674787$$APP_SPEC_TS_0$ = goog.getMsg("{$interpolation}", { - "interpolation": "\uFFFD0\uFFFD" - }); - $I18N_0$ = $MSG_EXTERNAL_6749967533321674787$$APP_SPEC_TS_0$; - } - else { - $I18N_0$ = $localize \`$` + - String.raw`{"\uFFFD0\uFFFD"}:INTERPOLATION:\`; - } + ${i18n_0} … template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); - $r3$.ɵɵi18n(1, $I18N_0$); + $r3$.ɵɵi18n(1, $i18n_0$); $r3$.ɵɵelementEnd(); } if (rf & 2) { @@ -1293,34 +1137,28 @@ describe('i18n support in the template compiler', () => { `; + const i18n_0 = i18nMsg(' {$interpolation} {$interpolation_1} {$interpolation_2} ', [ + ['interpolation', String.raw`\uFFFD0\uFFFD`], + ['interpolation_1', String.raw`\uFFFD1\uFFFD`], + ['interpolation_2', String.raw`\uFFFD2\uFFFD`] + ]); + const output = String.raw` - var $I18N_0$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_APP_SPEC_TS_1$$APP_SPEC_TS_1$ = goog.getMsg(" {$interpolation} {$interpolation_1} {$interpolation_2} ", { - "interpolation": "\uFFFD0\uFFFD", - "interpolation_1": "\uFFFD1\uFFFD", - "interpolation_2": "\uFFFD2\uFFFD" - }); - $I18N_0$ = $MSG_APP_SPEC_TS_1$$APP_SPEC_TS_1$; - } - else { - $I18N_0$ = $localize \` $` + - String.raw`{"\uFFFD0\uFFFD"}:INTERPOLATION: $` + - String.raw`{"\uFFFD1\uFFFD"}:INTERPOLATION_1: $` + - String.raw`{"\uFFFD2\uFFFD"}:INTERPOLATION_2: \`; - } + ${i18n_0} … template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); - $r3$.ɵɵi18n(1, $I18N_0$); + $r3$.ɵɵi18n(1, $i18n_0$); $r3$.ɵɵpipe(2, "async"); $r3$.ɵɵelementEnd(); } if (rf & 2) { var $tmp_2_0$ = null; $r3$.ɵɵadvance(2); - $r3$.ɵɵi18nExp($r3$.ɵɵpipeBind1(2, 3, ctx.valueA))(ctx.valueA == null ? null : ctx.valueA.a == null ? null : ctx.valueA.a.b)(($tmp_2_0$ = ctx.valueA.getRawValue()) == null ? null : $tmp_2_0$.getTitle()); + $r3$.ɵɵi18nExp($r3$.ɵɵpipeBind1(2, 3, ctx.valueA)) + (ctx.valueA == null ? null : ctx.valueA.a == null ? null : ctx.valueA.a.b) + (($tmp_2_0$ = ctx.valueA.getRawValue()) == null ? null : $tmp_2_0$.getTitle()); $r3$.ɵɵi18nApply(1); } } @@ -1335,54 +1173,31 @@ describe('i18n support in the template compiler', () => {
My i18n block #{{ three + four + five }}
`; + const i18n_0 = i18nMsg( + 'My i18n block #{$interpolation}', [['interpolation', String.raw`\uFFFD0\uFFFD`]]); + const i18n_1 = i18nMsg( + 'My i18n block #{$interpolation}', [['interpolation', String.raw`\uFFFD0\uFFFD`]]); + const i18n_2 = i18nMsg( + 'My i18n block #{$interpolation}', [['interpolation', String.raw`\uFFFD0\uFFFD`]]); + const output = String.raw` - var $I18N_0$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_572579892698764378$$APP_SPEC_TS_0$ = goog.getMsg("My i18n block #{$interpolation}", { - "interpolation": "\uFFFD0\uFFFD" - }); - $I18N_0$ = $MSG_EXTERNAL_572579892698764378$$APP_SPEC_TS_0$; - } - else { - $I18N_0$ = $localize \`My i18n block #$` + - String.raw`{"\uFFFD0\uFFFD"}:INTERPOLATION:\`; - } - var $I18N_1$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_609623417156596326$$APP_SPEC_TS_1$ = goog.getMsg("My i18n block #{$interpolation}", { - "interpolation": "\uFFFD0\uFFFD" - }); - $I18N_1$ = $MSG_EXTERNAL_609623417156596326$$APP_SPEC_TS_1$; - } - else { - $I18N_1$ = $localize \`My i18n block #$` + - String.raw`{"\uFFFD0\uFFFD"}:INTERPOLATION:\`; - } - var $I18N_2$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_3998119318957372120$$APP_SPEC_TS_2$ = goog.getMsg("My i18n block #{$interpolation}", { - "interpolation": "\uFFFD0\uFFFD" - }); - $I18N_2$ = $MSG_EXTERNAL_3998119318957372120$$APP_SPEC_TS_2$; - } - else { - $I18N_2$ = $localize \`My i18n block #$` + - String.raw`{"\uFFFD0\uFFFD"}:INTERPOLATION:\`; - } + ${i18n_0} + ${i18n_1} + ${i18n_2} … decls: 7, vars: 5, template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); - $r3$.ɵɵi18n(1, $I18N_0$); + $r3$.ɵɵi18n(1, $i18n_0$); $r3$.ɵɵelementEnd(); $r3$.ɵɵelementStart(2, "div"); - $r3$.ɵɵi18n(3, $I18N_1$); + $r3$.ɵɵi18n(3, $i18n_1$); $r3$.ɵɵpipe(4, "uppercase"); $r3$.ɵɵelementEnd(); $r3$.ɵɵelementStart(5, "div"); - $r3$.ɵɵi18n(6, $I18N_2$); + $r3$.ɵɵi18n(6, $i18n_2$); $r3$.ɵɵelementEnd(); } if (rf & 2) { @@ -1420,58 +1235,39 @@ describe('i18n support in the template compiler', () => { `; + const i18n_0 = i18nMsg( + ' My i18n block #{$interpolation} {$startTagSpan}Plain text in nested element{$closeTagSpan}', + [ + ['interpolation', String.raw`\uFFFD0\uFFFD`], + ['startTagSpan', String.raw`\uFFFD#2\uFFFD`], + ['closeTagSpan', String.raw`\uFFFD/#2\uFFFD`] + ]); + const i18n_1 = i18nMsgWithPostprocess( + ' My i18n block #{$interpolation} {$startTagDiv}{$startTagDiv}{$startTagSpan} More bindings in more nested element: {$interpolation_1} {$closeTagSpan}{$closeTagDiv}{$closeTagDiv}', + [ + ['interpolation', String.raw`\uFFFD0\uFFFD`], + ['startTagDiv', String.raw`[\uFFFD#6\uFFFD|\uFFFD#7\uFFFD]`], + ['startTagSpan', String.raw`\uFFFD#8\uFFFD`], + ['interpolation_1', String.raw`\uFFFD1\uFFFD`], + ['closeTagSpan', String.raw`\uFFFD/#8\uFFFD`], + ['closeTagDiv', String.raw`[\uFFFD/#7\uFFFD|\uFFFD/#6\uFFFD]`] + ]); + const output = String.raw` - var $I18N_0$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_7905233330103651696$$APP_SPEC_TS_0$ = goog.getMsg(" My i18n block #{$interpolation} {$startTagSpan}Plain text in nested element{$closeTagSpan}", { - "interpolation": "\uFFFD0\uFFFD", - "startTagSpan": "\uFFFD#2\uFFFD", - "closeTagSpan": "\uFFFD/#2\uFFFD" - }); - $I18N_0$ = $MSG_EXTERNAL_7905233330103651696$$APP_SPEC_TS_0$; - } - else { - $I18N_0$ = $localize \` My i18n block #$` + - String.raw`{"\uFFFD0\uFFFD"}:INTERPOLATION: $` + - String.raw`{"\uFFFD#2\uFFFD"}:START_TAG_SPAN:Plain text in nested element$` + - String.raw`{"\uFFFD/#2\uFFFD"}:CLOSE_TAG_SPAN:\`; - } - var $I18N_1$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_5788821996131681377$$APP_SPEC_TS_1$ = goog.getMsg(" My i18n block #{$interpolation} {$startTagDiv}{$startTagDiv}{$startTagSpan} More bindings in more nested element: {$interpolation_1} {$closeTagSpan}{$closeTagDiv}{$closeTagDiv}", { - "interpolation": "\uFFFD0\uFFFD", - "startTagDiv": "[\uFFFD#6\uFFFD|\uFFFD#7\uFFFD]", - "startTagSpan": "\uFFFD#8\uFFFD", - "interpolation_1": "\uFFFD1\uFFFD", - "closeTagSpan": "\uFFFD/#8\uFFFD", - "closeTagDiv": "[\uFFFD/#7\uFFFD|\uFFFD/#6\uFFFD]" - }); - $I18N_1$ = $MSG_EXTERNAL_5788821996131681377$$APP_SPEC_TS_1$; - } - else { - $I18N_1$ = $localize \` My i18n block #$` + - String.raw`{"\uFFFD0\uFFFD"}:INTERPOLATION: $` + - String.raw`{"[\uFFFD#6\uFFFD|\uFFFD#7\uFFFD]"}:START_TAG_DIV:$` + - String.raw`{"[\uFFFD#6\uFFFD|\uFFFD#7\uFFFD]"}:START_TAG_DIV:$` + String.raw - `{"\uFFFD#8\uFFFD"}:START_TAG_SPAN: More bindings in more nested element: $` + - String.raw`{"\uFFFD1\uFFFD"}:INTERPOLATION_1: $` + - String.raw`{"\uFFFD/#8\uFFFD"}:CLOSE_TAG_SPAN:$` + - String.raw`{"[\uFFFD/#7\uFFFD|\uFFFD/#6\uFFFD]"}:CLOSE_TAG_DIV:$` + - String.raw`{"[\uFFFD/#7\uFFFD|\uFFFD/#6\uFFFD]"}:CLOSE_TAG_DIV:\`; - } - $I18N_1$ = $r3$.ɵɵi18nPostprocess($I18N_1$); + ${i18n_0} + ${i18n_1} … decls: 9, vars: 5, template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); - $r3$.ɵɵi18nStart(1, $I18N_0$); + $r3$.ɵɵi18nStart(1, $i18n_0$); $r3$.ɵɵelement(2, "span"); $r3$.ɵɵi18nEnd(); $r3$.ɵɵelementEnd(); $r3$.ɵɵelementStart(3, "div"); - $r3$.ɵɵi18nStart(4, $I18N_1$); + $r3$.ɵɵi18nStart(4, $i18n_1$); $r3$.ɵɵpipe(5, "uppercase"); $r3$.ɵɵelementStart(6, "div"); $r3$.ɵɵelementStart(7, "div"); @@ -1511,63 +1307,33 @@ describe('i18n support in the template compiler', () => { `; + const i18n_0 = i18nMsg('Span title {$interpolation} and {$interpolation_1}', [ + ['interpolation', String.raw`\uFFFD0\uFFFD`], ['interpolation_1', String.raw`\uFFFD1\uFFFD`] + ]); + const i18n_1 = i18nMsg( + ' My i18n block #1 with value: {$interpolation} {$startTagSpan} Plain text in nested element (block #1) {$closeTagSpan}', + [ + ['interpolation', String.raw`\uFFFD0\uFFFD`], + ['startTagSpan', String.raw`\uFFFD#2\uFFFD`], + ['closeTagSpan', String.raw`\uFFFD/#2\uFFFD`] + ]); + const i18n_2 = + i18nMsg('Span title {$interpolation}', [['interpolation', String.raw`\uFFFD0\uFFFD`]]); + const i18n_3 = i18nMsg( + ' My i18n block #2 with value {$interpolation} {$startTagSpan} Plain text in nested element (block #2) {$closeTagSpan}', + [ + ['interpolation', String.raw`\uFFFD0\uFFFD`], + ['startTagSpan', String.raw`\uFFFD#7\uFFFD`], + ['closeTagSpan', String.raw`\uFFFD/#7\uFFFD`] + ]); + const output = String.raw` - var $I18N_2$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_4782264005467235841$$APP_SPEC_TS_3$ = goog.getMsg("Span title {$interpolation} and {$interpolation_1}", { - "interpolation": "\uFFFD0\uFFFD", - "interpolation_1": "\uFFFD1\uFFFD" - }); - $I18N_2$ = $MSG_EXTERNAL_4782264005467235841$$APP_SPEC_TS_3$; - } - else { - $I18N_2$ = $localize \`Span title $` + - String.raw`{"\uFFFD0\uFFFD"}:INTERPOLATION: and $` + - String.raw`{"\uFFFD1\uFFFD"}:INTERPOLATION_1:\`; - } - const $_c4$ = ["title", $I18N_2$]; - var $I18N_0$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_4446430594603971069$$APP_SPEC_TS_5$ = goog.getMsg(" My i18n block #1 with value: {$interpolation} {$startTagSpan} Plain text in nested element (block #1) {$closeTagSpan}", { - "interpolation": "\uFFFD0\uFFFD", - "startTagSpan": "\uFFFD#2\uFFFD", - "closeTagSpan": "\uFFFD/#2\uFFFD" - }); - $I18N_0$ = $MSG_EXTERNAL_4446430594603971069$$APP_SPEC_TS_5$; - } - else { - $I18N_0$ = $localize \` My i18n block #1 with value: $` + - String.raw`{"\uFFFD0\uFFFD"}:INTERPOLATION: $` + String.raw - `{"\uFFFD#2\uFFFD"}:START_TAG_SPAN: Plain text in nested element (block #1) $` + - String.raw`{"\uFFFD/#2\uFFFD"}:CLOSE_TAG_SPAN:\`; - } - var $I18N_7$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_2719594642740200058$$APP_SPEC_TS_8$ = goog.getMsg("Span title {$interpolation}", { - "interpolation": "\uFFFD0\uFFFD" - }); - $I18N_7$ = $MSG_EXTERNAL_2719594642740200058$$APP_SPEC_TS_8$; - } - else { - $I18N_7$ = $localize \`Span title $` + - String.raw`{"\uFFFD0\uFFFD"}:INTERPOLATION:\`; - } - const $_c9$ = ["title", $I18N_7$]; - var $I18N_6$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_2778714953278357902$$APP_SPEC_TS_10$ = goog.getMsg(" My i18n block #2 with value {$interpolation} {$startTagSpan} Plain text in nested element (block #2) {$closeTagSpan}", { - "interpolation": "\uFFFD0\uFFFD", - "startTagSpan": "\uFFFD#7\uFFFD", - "closeTagSpan": "\uFFFD/#7\uFFFD" - }); - $I18N_6$ = $MSG_EXTERNAL_2778714953278357902$$APP_SPEC_TS_10$; - } - else { - $I18N_6$ = $localize \` My i18n block #2 with value $` + - String.raw`{"\uFFFD0\uFFFD"}:INTERPOLATION: $` + String.raw - `{"\uFFFD#7\uFFFD"}:START_TAG_SPAN: Plain text in nested element (block #2) $` + - String.raw`{"\uFFFD/#7\uFFFD"}:CLOSE_TAG_SPAN:\`; - } + ${i18n_0} + const $_c4$ = ["title", $i18n_0$]; + ${i18n_1} + ${i18n_2} + const $_c9$ = ["title", $i18n_2$]; + ${i18n_3} … decls: 9, vars: 7, @@ -1575,14 +1341,14 @@ describe('i18n support in the template compiler', () => { template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); - $r3$.ɵɵi18nStart(1, $I18N_0$); + $r3$.ɵɵi18nStart(1, $i18n_1$); $r3$.ɵɵelementStart(2, "span", 0); $r3$.ɵɵi18nAttributes(3, $_c4$); $r3$.ɵɵelementEnd(); $r3$.ɵɵi18nEnd(); $r3$.ɵɵelementEnd(); $r3$.ɵɵelementStart(4, "div"); - $r3$.ɵɵi18nStart(5, $I18N_6$); + $r3$.ɵɵi18nStart(5, $i18n_3$); $r3$.ɵɵpipe(6, "uppercase"); $r3$.ɵɵelementStart(7, "span", 0); $r3$.ɵɵi18nAttributes(8, $_c9$); @@ -1625,30 +1391,23 @@ describe('i18n support in the template compiler', () => { `; + const i18n_0 = i18nMsg( + ' Some other content {$interpolation} {$startTagDiv} More nested levels with bindings {$interpolation_1} {$closeTagDiv}', + [ + ['interpolation', String.raw`\uFFFD0\uFFFD`], + ['startTagDiv', String.raw`\uFFFD#3\uFFFD`], + ['interpolation_1', String.raw`\uFFFD1\uFFFD`], + ['closeTagDiv', String.raw`\uFFFD/#3\uFFFD`] + ]); + const output = String.raw` - var $I18N_1$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_7679414751795588050$$APP_SPEC_TS__1$ = goog.getMsg(" Some other content {$interpolation} {$startTagDiv} More nested levels with bindings {$interpolation_1} {$closeTagDiv}", { - "interpolation": "\uFFFD0\uFFFD", - "startTagDiv": "\uFFFD#3\uFFFD", - "interpolation_1": "\uFFFD1\uFFFD", - "closeTagDiv": "\uFFFD/#3\uFFFD" - }); - $I18N_1$ = $MSG_EXTERNAL_7679414751795588050$$APP_SPEC_TS__1$; - } - else { - $I18N_1$ = $localize \` Some other content $` + - String.raw`{"\uFFFD0\uFFFD"}:INTERPOLATION: $` + - String.raw`{"\uFFFD#3\uFFFD"}:START_TAG_DIV: More nested levels with bindings $` + - String.raw`{"\uFFFD1\uFFFD"}:INTERPOLATION_1: $` + - String.raw`{"\uFFFD/#3\uFFFD"}:CLOSE_TAG_DIV:\`; - } + ${i18n_0} … function MyComponent_div_2_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); $r3$.ɵɵelementStart(1, "div"); - $r3$.ɵɵi18nStart(2, $I18N_1$); + $r3$.ɵɵi18nStart(2, $i18n_0$); $r3$.ɵɵelement(3, "div"); $r3$.ɵɵpipe(4, "uppercase"); $r3$.ɵɵi18nEnd(); @@ -1690,24 +1449,17 @@ describe('i18n support in the template compiler', () => { `; + const i18n_0 = + i18nMsg('App logo #{$interpolation}', [['interpolation', String.raw`\uFFFD0\uFFFD`]]); + const output = String.raw` function MyComponent_img_1_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelement(0, "img", 0); } } - var $I18N_2$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_2367729185105559721$$APP_SPEC_TS__2$ = goog.getMsg("App logo #{$interpolation}", { - "interpolation": "\uFFFD0\uFFFD" - }); - $I18N_2$ = $MSG_EXTERNAL_2367729185105559721$$APP_SPEC_TS__2$; - } - else { - $I18N_2$ = $localize \`App logo #$` + - String.raw`{"\uFFFD0\uFFFD"}:INTERPOLATION:\`; - } - const $_c4$ = ["title", $I18N_2$]; + ${i18n_0} + const $_c4$ = ["title", $i18n_0$]; function MyComponent_img_2_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "img", 3); @@ -1724,10 +1476,10 @@ describe('i18n support in the template compiler', () => { decls: 3, vars: 2, consts: [["src", "logo.png"], ["src", "logo.png", ${ - AttributeMarker.Template}, "ngIf"], ["src", "logo.png", ${ - AttributeMarker.Bindings}, "title", ${ - AttributeMarker.Template}, "ngIf"], ["src", "logo.png", ${ - AttributeMarker.I18n}, "title"]], + AttributeMarker.Template}, "ngIf"], ["src", "logo.png", ${ + AttributeMarker.Bindings}, "title", ${ + AttributeMarker.Template}, "ngIf"], ["src", "logo.png", ${ + AttributeMarker.I18n}, "title"]], template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelement(0, "img", 0); @@ -1771,6 +1523,26 @@ describe('i18n support in the template compiler', () => { `; + const i18n_0 = i18nMsgWithPostprocess( + ' Some content {$startTagDiv_2} Some other content {$interpolation} {$startTagDiv} More nested levels with bindings {$interpolation_1} {$startTagDiv_1} Content inside sub-template {$interpolation_2} {$startTagDiv} Bottom level element {$interpolation_3} {$closeTagDiv}{$closeTagDiv}{$closeTagDiv}{$closeTagDiv}{$startTagDiv_3} Some other content {$interpolation_4} {$startTagDiv} More nested levels with bindings {$interpolation_5} {$closeTagDiv}{$closeTagDiv}', + [ + ['startTagDiv_2', String.raw`\uFFFD*2:1\uFFFD\uFFFD#1:1\uFFFD`], + [ + 'closeTagDiv', + String + .raw`[\uFFFD/#2:2\uFFFD|\uFFFD/#1:2\uFFFD\uFFFD/*4:2\uFFFD|\uFFFD/#2:1\uFFFD|\uFFFD/#1:1\uFFFD\uFFFD/*2:1\uFFFD|\uFFFD/#2:3\uFFFD|\uFFFD/#1:3\uFFFD\uFFFD/*3:3\uFFFD]` + ], + ['startTagDiv_3', String.raw`\uFFFD*3:3\uFFFD\uFFFD#1:3\uFFFD`], + ['interpolation', String.raw`\uFFFD0:1\uFFFD`], + ['startTagDiv', String.raw`[\uFFFD#2:1\uFFFD|\uFFFD#2:2\uFFFD|\uFFFD#2:3\uFFFD]`], + ['interpolation_1', String.raw`\uFFFD1:1\uFFFD`], + ['startTagDiv_1', String.raw`\uFFFD*4:2\uFFFD\uFFFD#1:2\uFFFD`], + ['interpolation_2', String.raw`\uFFFD0:2\uFFFD`], + ['interpolation_3', String.raw`\uFFFD1:2\uFFFD`], + ['interpolation_4', String.raw`\uFFFD0:3\uFFFD`], + ['interpolation_5', String.raw`\uFFFD1:3\uFFFD`] + ]); + const output = String.raw` function MyComponent_div_2_div_4_Template(rf, ctx) { if (rf & 1) { @@ -1806,51 +1578,7 @@ describe('i18n support in the template compiler', () => { $r3$.ɵɵi18nApply(0); } } - var $I18N_0$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_1221890473527419724$$APP_SPEC_TS_0$ = goog.getMsg(" Some content {$startTagDiv_2} Some other content {$interpolation} {$startTagDiv} More nested levels with bindings {$interpolation_1} {$startTagDiv_1} Content inside sub-template {$interpolation_2} {$startTagDiv} Bottom level element {$interpolation_3} {$closeTagDiv}{$closeTagDiv}{$closeTagDiv}{$closeTagDiv}{$startTagDiv_3} Some other content {$interpolation_4} {$startTagDiv} More nested levels with bindings {$interpolation_5} {$closeTagDiv}{$closeTagDiv}", { - "startTagDiv_2": "\uFFFD*2:1\uFFFD\uFFFD#1:1\uFFFD", - "closeTagDiv": "[\uFFFD/#2:2\uFFFD|\uFFFD/#1:2\uFFFD\uFFFD/*4:2\uFFFD|\uFFFD/#2:1\uFFFD|\uFFFD/#1:1\uFFFD\uFFFD/*2:1\uFFFD|\uFFFD/#2:3\uFFFD|\uFFFD/#1:3\uFFFD\uFFFD/*3:3\uFFFD]", - "startTagDiv_3": "\uFFFD*3:3\uFFFD\uFFFD#1:3\uFFFD", - "interpolation": "\uFFFD0:1\uFFFD", - "startTagDiv": "[\uFFFD#2:1\uFFFD|\uFFFD#2:2\uFFFD|\uFFFD#2:3\uFFFD]", - "interpolation_1": "\uFFFD1:1\uFFFD", - "startTagDiv_1": "\uFFFD*4:2\uFFFD\uFFFD#1:2\uFFFD", - "interpolation_2": "\uFFFD0:2\uFFFD", - "interpolation_3": "\uFFFD1:2\uFFFD", - "interpolation_4": "\uFFFD0:3\uFFFD", - "interpolation_5": "\uFFFD1:3\uFFFD" - }); - $I18N_0$ = $MSG_EXTERNAL_1221890473527419724$$APP_SPEC_TS_0$; - } - else { - $I18N_0$ = $localize \` Some content $` + - String.raw - `{"\uFFFD*2:1\uFFFD\uFFFD#1:1\uFFFD"}:START_TAG_DIV_2: Some other content $` + - String.raw`{"\uFFFD0:1\uFFFD"}:INTERPOLATION: $` + String.raw - `{"[\uFFFD#2:1\uFFFD|\uFFFD#2:2\uFFFD|\uFFFD#2:3\uFFFD]"}:START_TAG_DIV: More nested levels with bindings $` + - String.raw`{"\uFFFD1:1\uFFFD"}:INTERPOLATION_1: $` + String.raw - `{"\uFFFD*4:2\uFFFD\uFFFD#1:2\uFFFD"}:START_TAG_DIV_1: Content inside sub-template $` + - String.raw`{"\uFFFD0:2\uFFFD"}:INTERPOLATION_2: $` + String.raw - `{"[\uFFFD#2:1\uFFFD|\uFFFD#2:2\uFFFD|\uFFFD#2:3\uFFFD]"}:START_TAG_DIV: Bottom level element $` + - String.raw`{"\uFFFD1:2\uFFFD"}:INTERPOLATION_3: $` + String.raw - `{"[\uFFFD/#2:2\uFFFD|\uFFFD/#1:2\uFFFD\uFFFD/*4:2\uFFFD|\uFFFD/#2:1\uFFFD|\uFFFD/#1:1\uFFFD\uFFFD/*2:1\uFFFD|\uFFFD/#2:3\uFFFD|\uFFFD/#1:3\uFFFD\uFFFD/*3:3\uFFFD]"}:CLOSE_TAG_DIV:$` + - String.raw - `{"[\uFFFD/#2:2\uFFFD|\uFFFD/#1:2\uFFFD\uFFFD/*4:2\uFFFD|\uFFFD/#2:1\uFFFD|\uFFFD/#1:1\uFFFD\uFFFD/*2:1\uFFFD|\uFFFD/#2:3\uFFFD|\uFFFD/#1:3\uFFFD\uFFFD/*3:3\uFFFD]"}:CLOSE_TAG_DIV:$` + - String.raw - `{"[\uFFFD/#2:2\uFFFD|\uFFFD/#1:2\uFFFD\uFFFD/*4:2\uFFFD|\uFFFD/#2:1\uFFFD|\uFFFD/#1:1\uFFFD\uFFFD/*2:1\uFFFD|\uFFFD/#2:3\uFFFD|\uFFFD/#1:3\uFFFD\uFFFD/*3:3\uFFFD]"}:CLOSE_TAG_DIV:$` + - String.raw - `{"[\uFFFD/#2:2\uFFFD|\uFFFD/#1:2\uFFFD\uFFFD/*4:2\uFFFD|\uFFFD/#2:1\uFFFD|\uFFFD/#1:1\uFFFD\uFFFD/*2:1\uFFFD|\uFFFD/#2:3\uFFFD|\uFFFD/#1:3\uFFFD\uFFFD/*3:3\uFFFD]"}:CLOSE_TAG_DIV:$` + - String.raw - `{"\uFFFD*3:3\uFFFD\uFFFD#1:3\uFFFD"}:START_TAG_DIV_3: Some other content $` + - String.raw`{"\uFFFD0:3\uFFFD"}:INTERPOLATION_4: $` + String.raw - `{"[\uFFFD#2:1\uFFFD|\uFFFD#2:2\uFFFD|\uFFFD#2:3\uFFFD]"}:START_TAG_DIV: More nested levels with bindings $` + - String.raw`{"\uFFFD1:3\uFFFD"}:INTERPOLATION_5: $` + String.raw - `{"[\uFFFD/#2:2\uFFFD|\uFFFD/#1:2\uFFFD\uFFFD/*4:2\uFFFD|\uFFFD/#2:1\uFFFD|\uFFFD/#1:1\uFFFD\uFFFD/*2:1\uFFFD|\uFFFD/#2:3\uFFFD|\uFFFD/#1:3\uFFFD\uFFFD/*3:3\uFFFD]"}:CLOSE_TAG_DIV:$` + - String.raw - `{"[\uFFFD/#2:2\uFFFD|\uFFFD/#1:2\uFFFD\uFFFD/*4:2\uFFFD|\uFFFD/#2:1\uFFFD|\uFFFD/#1:1\uFFFD\uFFFD/*2:1\uFFFD|\uFFFD/#2:3\uFFFD|\uFFFD/#1:3\uFFFD\uFFFD/*3:3\uFFFD]"}:CLOSE_TAG_DIV:\`; - } - $I18N_0$ = $r3$.ɵɵi18nPostprocess($I18N_0$); + ${i18n_0} function MyComponent_div_3_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵi18nStart(0, $I18N_0$, 3); @@ -1897,27 +1625,18 @@ describe('i18n support in the template compiler', () => {
Some other content {{ valueA }}
`; + const i18n_0 = i18nMsg('Some other content {$startTagSpan}{$interpolation}{$closeTagSpan}', [ + ['startTagSpan', String.raw`\uFFFD#2\uFFFD`], ['interpolation', String.raw`\uFFFD0\uFFFD`], + ['closeTagSpan', String.raw`\uFFFD/#2\uFFFD`] + ]); + const output = String.raw` - var $I18N_1$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_119975189388320493$$APP_SPEC_TS__1$ = goog.getMsg("Some other content {$startTagSpan}{$interpolation}{$closeTagSpan}", { - "startTagSpan": "\uFFFD#2\uFFFD", - "interpolation": "\uFFFD0\uFFFD", - "closeTagSpan": "\uFFFD/#2\uFFFD" - }); - $I18N_1$ = $MSG_EXTERNAL_119975189388320493$$APP_SPEC_TS__1$; - } - else { - $I18N_1$ = $localize \`Some other content $` + - String.raw`{"\uFFFD#2\uFFFD"}:START_TAG_SPAN:$` + - String.raw`{"\uFFFD0\uFFFD"}:INTERPOLATION:$` + - String.raw`{"\uFFFD/#2\uFFFD"}:CLOSE_TAG_SPAN:\`; - } + ${i18n_0} … function MyComponent_div_0_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); - $r3$.ɵɵi18nStart(1, $I18N_1$); + $r3$.ɵɵi18nStart(1, $i18n_0$); $r3$.ɵɵelement(2, "span"); $r3$.ɵɵi18nEnd(); $r3$.ɵɵelementEnd(); @@ -1951,22 +1670,17 @@ describe('i18n support in the template compiler', () => {
Hello
`; + const i18n_0 = i18nMsg('Hello'); + const output = String.raw` - var $I18N_1$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_APP_SPEC_TS_2$ = goog.getMsg("Hello"); - $I18N_1$ = $MSG_APP_SPEC_TS_2$; - } - else { - $I18N_1$ = $localize \`Hello\`; - } + ${i18n_0} … consts: [[${AttributeMarker.Bindings}, "click"]], template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div", 0); $r3$.ɵɵlistener("click", function MyComponent_Template_div_click_0_listener() { return ctx.onClick(); }); - $r3$.ɵɵi18n(1, $I18N_1$); + $r3$.ɵɵi18n(1, $i18n_0$); $r3$.ɵɵelementEnd(); } } @@ -1982,20 +1696,15 @@ describe('i18n support in the template compiler', () => {
My i18n block #1
`; + const i18n_0 = i18nMsg('My i18n block #1'); + const output = String.raw` - var $I18N_0$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_4890179241114413722$$APP_SPEC_TS_0$ = goog.getMsg("My i18n block #1"); - $I18N_0$ = $MSG_EXTERNAL_4890179241114413722$$APP_SPEC_TS_0$; - } - else { - $I18N_0$ = $localize \`My i18n block #1\`; - } + ${i18n_0} … template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); - $r3$.ɵɵi18n(1, $I18N_0$); + $r3$.ɵɵi18n(1, $i18n_0$); $r3$.ɵɵelementEnd(); } } @@ -2009,18 +1718,12 @@ describe('i18n support in the template compiler', () => {
{age, select, 10 {ten} 20 {twenty} other {other}}
`; + const i18n_0 = i18nIcuMsg( + '{VAR_SELECT, select, 10 {ten} 20 {twenty} other {other}}', + [['VAR_SELECT', String.raw`\uFFFD0\uFFFD`]]); + const output = String.raw` - var $I18N_0$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_8806993169187953163$$APP_SPEC_TS_0$ = goog.getMsg("{VAR_SELECT, select, 10 {ten} 20 {twenty} other {other}}"); - $I18N_0$ = $MSG_EXTERNAL_8806993169187953163$$APP_SPEC_TS_0$; - } - else { - $I18N_0$ = $localize \`{VAR_SELECT, select, 10 {ten} 20 {twenty} other {other}}\`; - } - $I18N_0$ = $r3$.ɵɵi18nPostprocess($I18N_0$, { - "VAR_SELECT": "\uFFFD0\uFFFD" - }); + ${i18n_0} … decls: 2, vars: 1, @@ -2047,26 +1750,15 @@ describe('i18n support in the template compiler', () => { My i18n block #2 `; + const i18n_0 = i18nMsg('My i18n block #2'); + const i18n_1 = i18nMsg('My i18n block #1'); + const output = String.raw` - var $I18N_0$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_2413150872298537152$$APP_SPEC_TS_0$ = goog.getMsg("My i18n block #2"); - $I18N_0$ = $MSG_EXTERNAL_2413150872298537152$$APP_SPEC_TS_0$; - } - else { - $I18N_0$ = $localize \`My i18n block #2\`; - } - var $I18N_1$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_4890179241114413722$$APP_SPEC_TS__1$ = goog.getMsg("My i18n block #1"); - $I18N_1$ = $MSG_EXTERNAL_4890179241114413722$$APP_SPEC_TS__1$; - } - else { - $I18N_1$ = $localize \`My i18n block #1\`; - } + ${i18n_0} + ${i18n_1} function MyComponent_ng_template_0_Template(rf, ctx) { if (rf & 1) { - $r3$.ɵɵi18n(0, $I18N_1$); + $r3$.ɵɵi18n(0, $i18n_1$); } } … @@ -2074,7 +1766,7 @@ describe('i18n support in the template compiler', () => { if (rf & 1) { $r3$.ɵɵtemplate(0, MyComponent_ng_template_0_Template, 1, 0, "ng-template"); $r3$.ɵɵelementContainerStart(1); - $r3$.ɵɵi18n(2, $I18N_0$); + $r3$.ɵɵi18n(2, $i18n_0$); $r3$.ɵɵelementContainerEnd(); } } @@ -2089,23 +1781,12 @@ describe('i18n support in the template compiler', () => { Text #2 `; + const i18n_0 = i18nMsg('Text #1'); + const i18n_1 = i18nMsg('Text #2'); + const output = String.raw` - var $I18N_1$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_5295701706185791735$$APP_SPEC_TS_1$ = goog.getMsg("Text #1"); - $I18N_1$ = $MSG_EXTERNAL_5295701706185791735$$APP_SPEC_TS_1$; - } - else { - $I18N_1$ = $localize \`Text #1\`; - } - var $I18N_3$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_4722270221386399294$$APP_SPEC_TS_3$ = goog.getMsg("Text #2"); - $I18N_3$ = $MSG_EXTERNAL_4722270221386399294$$APP_SPEC_TS_3$; - } - else { - $I18N_3$ = $localize \`Text #2\`; - } + ${i18n_0} + ${i18n_1} … decls: 4, vars: 0, @@ -2114,10 +1795,10 @@ describe('i18n support in the template compiler', () => { template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "span", 0); - $r3$.ɵɵi18n(1, $I18N_1$); + $r3$.ɵɵi18n(1, $i18n_0$); $r3$.ɵɵelementEnd(); $r3$.ɵɵelementStart(2, "span", 1); - $r3$.ɵɵi18n(3, $I18N_3$); + $r3$.ɵɵi18n(3, $i18n_1$); $r3$.ɵɵelementEnd(); } } @@ -2133,25 +1814,18 @@ describe('i18n support in the template compiler', () => { Some content: {{ valueA | uppercase }} `; + const i18n_0 = + i18nMsg('Some content: {$interpolation}', [['interpolation', String.raw`\uFFFD0\uFFFD`]]); + const output = String.raw` - var $I18N_0$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_355394464191978948$$APP_SPEC_TS_0$ = goog.getMsg("Some content: {$interpolation}", { - "interpolation": "\uFFFD0\uFFFD" - }); - $I18N_0$ = $MSG_EXTERNAL_355394464191978948$$APP_SPEC_TS_0$; - } - else { - $I18N_0$ = $localize \`Some content: $` + - String.raw`{"\uFFFD0\uFFFD"}:INTERPOLATION:\`; - } + ${i18n_0} … decls: 3, vars: 3, template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementContainerStart(0); - $r3$.ɵɵi18n(1, $I18N_0$); + $r3$.ɵɵi18n(1, $i18n_0$); $r3$.ɵɵpipe(2, "uppercase"); $r3$.ɵɵelementContainerEnd(); } @@ -2171,21 +1845,14 @@ describe('i18n support in the template compiler', () => { Some content: {{ valueA | uppercase }} `; + const i18n_0 = + i18nMsg('Some content: {$interpolation}', [['interpolation', String.raw`\uFFFD0\uFFFD`]]); + const output = String.raw` - var $I18N_0$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_355394464191978948$$APP_SPEC_TS__0$ = goog.getMsg("Some content: {$interpolation}", { - "interpolation": "\uFFFD0\uFFFD" - }); - $I18N_0$ = $MSG_EXTERNAL_355394464191978948$$APP_SPEC_TS__0$; - } - else { - $I18N_0$ = $localize \`Some content: $` + - String.raw`{"\uFFFD0\uFFFD"}:INTERPOLATION:\`; - } + ${i18n_0} function MyComponent_ng_template_0_Template(rf, ctx) { if (rf & 1) { - $r3$.ɵɵi18n(0, $I18N_0$); + $r3$.ɵɵi18n(0, $i18n_0$); $r3$.ɵɵpipe(1, "uppercase"); } if (rf & 2) { const $ctx_r0$ = $r3$.ɵɵnextContext(); @@ -2215,28 +1882,19 @@ describe('i18n support in the template compiler', () => { `; + const i18n_0 = i18nMsg( + '{$startTagNgTemplate}Template content: {$interpolation}{$closeTagNgTemplate}{$startTagNgContainer}Container content: {$interpolation_1}{$closeTagNgContainer}', + [ + ['startTagNgTemplate', String.raw`\uFFFD*2:1\uFFFD`], + ['closeTagNgTemplate', String.raw`\uFFFD/*2:1\uFFFD`], + ['startTagNgContainer', String.raw`\uFFFD#3\uFFFD`], + ['interpolation_1', String.raw`\uFFFD0\uFFFD`], + ['closeTagNgContainer', String.raw`\uFFFD/#3\uFFFD`], + ['interpolation', String.raw`\uFFFD0:1\uFFFD`] + ]); + const output = String.raw` - var $I18N_0$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_702706566400598764$$APP_SPEC_TS_0$ = goog.getMsg("{$startTagNgTemplate}Template content: {$interpolation}{$closeTagNgTemplate}{$startTagNgContainer}Container content: {$interpolation_1}{$closeTagNgContainer}", { - "startTagNgTemplate": "\uFFFD*2:1\uFFFD", - "closeTagNgTemplate": "\uFFFD/*2:1\uFFFD", - "startTagNgContainer": "\uFFFD#3\uFFFD", - "interpolation_1": "\uFFFD0\uFFFD", - "closeTagNgContainer": "\uFFFD/#3\uFFFD", - "interpolation": "\uFFFD0:1\uFFFD" - }); - $I18N_0$ = $MSG_EXTERNAL_702706566400598764$$APP_SPEC_TS_0$; - } - else { - $I18N_0$ = $localize \`$` + - String.raw`{"\uFFFD*2:1\uFFFD"}:START_TAG_NG_TEMPLATE:Template content: $` + - String.raw`{"\uFFFD0:1\uFFFD"}:INTERPOLATION:$` + - String.raw`{"\uFFFD/*2:1\uFFFD"}:CLOSE_TAG_NG_TEMPLATE:$` + - String.raw`{"\uFFFD#3\uFFFD"}:START_TAG_NG_CONTAINER:Container content: $` + - String.raw`{"\uFFFD0\uFFFD"}:INTERPOLATION_1:$` + - String.raw`{"\uFFFD/#3\uFFFD"}:CLOSE_TAG_NG_CONTAINER:\`; - } + ${i18n_0} function MyComponent_ng_template_2_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵi18n(0, $I18N_0$, 1); @@ -2255,7 +1913,7 @@ describe('i18n support in the template compiler', () => { template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); - $r3$.ɵɵi18nStart(1, $I18N_0$); + $r3$.ɵɵi18nStart(1, $i18n_0$); $r3$.ɵɵtemplate(2, MyComponent_ng_template_2_Template, 2, 3, "ng-template"); $r3$.ɵɵelementContainer(3); $r3$.ɵɵpipe(4, "uppercase"); @@ -2279,32 +1937,19 @@ describe('i18n support in the template compiler', () => { {age, select, 10 {ten} 20 {twenty} other {other}} `; + const i18n_0 = i18nIcuMsg( + '{VAR_SELECT, select, 10 {ten} 20 {twenty} other {other}}', + [['VAR_SELECT', String.raw`\uFFFD0\uFFFD`]]); + const i18n_1 = i18nIcuMsg( + '{VAR_SELECT, select, male {male} female {female} other {other}}', + [['VAR_SELECT', String.raw`\uFFFD0\uFFFD`]]); + const output = String.raw` - var $I18N_0$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_8806993169187953163$$APP_SPEC_TS_0$ = goog.getMsg("{VAR_SELECT, select, 10 {ten} 20 {twenty} other {other}}"); - $I18N_0$ = $MSG_EXTERNAL_8806993169187953163$$APP_SPEC_TS_0$; - } - else { - $I18N_0$ = $localize \`{VAR_SELECT, select, 10 {ten} 20 {twenty} other {other}}\`; - } - $I18N_0$ = $r3$.ɵɵi18nPostprocess($I18N_0$, { - "VAR_SELECT": "\uFFFD0\uFFFD" - }); - var $I18N_1$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_7842238767399919809$$APP_SPEC_TS__1$ = goog.getMsg("{VAR_SELECT, select, male {male} female {female} other {other}}"); - $I18N_1$ = $MSG_EXTERNAL_7842238767399919809$$APP_SPEC_TS__1$; - } - else { - $I18N_1$ = $localize \`{VAR_SELECT, select, male {male} female {female} other {other}}\`; - } - $I18N_1$ = $r3$.ɵɵi18nPostprocess($I18N_1$, { - "VAR_SELECT": "\uFFFD0\uFFFD" - }); + ${i18n_0} + ${i18n_1} function MyComponent_ng_template_0_Template(rf, ctx) { if (rf & 1) { - $r3$.ɵɵi18n(0, $I18N_1$); + $r3$.ɵɵi18n(0, $i18n_1$); } if (rf & 2) { const $ctx_r0$ = $r3$.ɵɵnextContext(); @@ -2319,7 +1964,7 @@ describe('i18n support in the template compiler', () => { if (rf & 1) { $r3$.ɵɵtemplate(0, MyComponent_ng_template_0_Template, 1, 1, "ng-template"); $r3$.ɵɵelementContainerStart(1); - $r3$.ɵɵi18n(2, $I18N_0$); + $r3$.ɵɵi18n(2, $i18n_0$); $r3$.ɵɵelementContainerEnd(); } if (rf & 2) { @@ -2348,10 +1993,25 @@ describe('i18n support in the template compiler', () => { `; + const i18n_0 = i18nMsgWithPostprocess( + '{$startTagNgTemplate} Template A: {$interpolation} {$startTagNgTemplate} Template B: {$interpolation_1} {$startTagNgTemplate} Template C: {$interpolation_2} {$closeTagNgTemplate}{$closeTagNgTemplate}{$closeTagNgTemplate}', + [ + [ + 'startTagNgTemplate', String.raw`[\uFFFD*2:1\uFFFD|\uFFFD*2:2\uFFFD|\uFFFD*1:3\uFFFD]` + ], + [ + 'closeTagNgTemplate', + String.raw`[\uFFFD/*1:3\uFFFD|\uFFFD/*2:2\uFFFD|\uFFFD/*2:1\uFFFD]` + ], + ['interpolation', String.raw`\uFFFD0:1\uFFFD`], + ['interpolation_1', String.raw`\uFFFD0:2\uFFFD`], + ['interpolation_2', String.raw`\uFFFD0:3\uFFFD`] + ]); + const output = String.raw` function MyComponent_ng_template_2_ng_template_2_ng_template_1_Template(rf, ctx) { if (rf & 1) { - $r3$.ɵɵi18n(0, $I18N_0$, 3); + $r3$.ɵɵi18n(0, $i18n_0$, 3); } if (rf & 2) { const $ctx_r2$ = $r3$.ɵɵnextContext(3); @@ -2361,7 +2021,7 @@ describe('i18n support in the template compiler', () => { } function MyComponent_ng_template_2_ng_template_2_Template(rf, ctx) { if (rf & 1) { - $r3$.ɵɵi18nStart(0, $I18N_0$, 2); + $r3$.ɵɵi18nStart(0, $i18n_0$, 2); $r3$.ɵɵtemplate(1, MyComponent_ng_template_2_ng_template_2_ng_template_1_Template, 1, 1, "ng-template"); $r3$.ɵɵi18nEnd(); } @@ -2372,36 +2032,10 @@ describe('i18n support in the template compiler', () => { $r3$.ɵɵi18nApply(0); } } - var $I18N_0$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_2051477021417799640$$APP_SPEC_TS_0$ = goog.getMsg("{$startTagNgTemplate} Template A: {$interpolation} {$startTagNgTemplate} Template B: {$interpolation_1} {$startTagNgTemplate} Template C: {$interpolation_2} {$closeTagNgTemplate}{$closeTagNgTemplate}{$closeTagNgTemplate}", { - "startTagNgTemplate": "[\uFFFD*2:1\uFFFD|\uFFFD*2:2\uFFFD|\uFFFD*1:3\uFFFD]", - "closeTagNgTemplate": "[\uFFFD/*1:3\uFFFD|\uFFFD/*2:2\uFFFD|\uFFFD/*2:1\uFFFD]", - "interpolation": "\uFFFD0:1\uFFFD", - "interpolation_1": "\uFFFD0:2\uFFFD", - "interpolation_2": "\uFFFD0:3\uFFFD" - }); - $I18N_0$ = $MSG_EXTERNAL_2051477021417799640$$APP_SPEC_TS_0$; - } - else { - $I18N_0$ = $localize \`$` + - String.raw - `{"[\uFFFD*2:1\uFFFD|\uFFFD*2:2\uFFFD|\uFFFD*1:3\uFFFD]"}:START_TAG_NG_TEMPLATE: Template A: $` + - String.raw`{"\uFFFD0:1\uFFFD"}:INTERPOLATION: $` + String.raw - `{"[\uFFFD*2:1\uFFFD|\uFFFD*2:2\uFFFD|\uFFFD*1:3\uFFFD]"}:START_TAG_NG_TEMPLATE: Template B: $` + - String.raw`{"\uFFFD0:2\uFFFD"}:INTERPOLATION_1: $` + String.raw - `{"[\uFFFD*2:1\uFFFD|\uFFFD*2:2\uFFFD|\uFFFD*1:3\uFFFD]"}:START_TAG_NG_TEMPLATE: Template C: $` + - String.raw`{"\uFFFD0:3\uFFFD"}:INTERPOLATION_2: $` + String.raw - `{"[\uFFFD/*1:3\uFFFD|\uFFFD/*2:2\uFFFD|\uFFFD/*2:1\uFFFD]"}:CLOSE_TAG_NG_TEMPLATE:$` + - String.raw - `{"[\uFFFD/*1:3\uFFFD|\uFFFD/*2:2\uFFFD|\uFFFD/*2:1\uFFFD]"}:CLOSE_TAG_NG_TEMPLATE:$` + - String.raw - `{"[\uFFFD/*1:3\uFFFD|\uFFFD/*2:2\uFFFD|\uFFFD/*2:1\uFFFD]"}:CLOSE_TAG_NG_TEMPLATE:\`; - } - $I18N_0$ = $r3$.ɵɵi18nPostprocess($I18N_0$); + ${i18n_0} function MyComponent_ng_template_2_Template(rf, ctx) { if (rf & 1) { - $r3$.ɵɵi18nStart(0, $I18N_0$, 1); + $r3$.ɵɵi18nStart(0, $i18n_0$, 1); $r3$.ɵɵpipe(1, "uppercase"); $r3$.ɵɵtemplate(2, MyComponent_ng_template_2_ng_template_2_Template, 2, 1, "ng-template"); $r3$.ɵɵi18nEnd(); @@ -2419,7 +2053,7 @@ describe('i18n support in the template compiler', () => { template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); - $r3$.ɵɵi18nStart(1, $I18N_0$); + $r3$.ɵɵi18nStart(1, $i18n_0$); $r3$.ɵɵtemplate(2, MyComponent_ng_template_2_Template, 3, 3, "ng-template"); $r3$.ɵɵi18nEnd(); $r3$.ɵɵelementEnd(); @@ -2436,29 +2070,16 @@ describe('i18n support in the template compiler', () => { {age, select, 10 {ten} 20 {twenty} other {other}} `; + const i18n_0 = i18nIcuMsg( + '{VAR_SELECT, select, male {male} female {female} other {other}}', + [['VAR_SELECT', String.raw`\uFFFD0\uFFFD`]]); + const i18n_1 = i18nIcuMsg( + '{VAR_SELECT, select, 10 {ten} 20 {twenty} other {other}}', + [['VAR_SELECT', String.raw`\uFFFD0\uFFFD`]]); + const output = String.raw` - var $I18N_0$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_7842238767399919809$$APP_SPEC_TS_0$ = goog.getMsg("{VAR_SELECT, select, male {male} female {female} other {other}}"); - $I18N_0$ = $MSG_EXTERNAL_7842238767399919809$$APP_SPEC_TS_0$; - } - else { - $I18N_0$ = $localize \`{VAR_SELECT, select, male {male} female {female} other {other}}\`; - } - $I18N_0$ = $r3$.ɵɵi18nPostprocess($I18N_0$, { - "VAR_SELECT": "\uFFFD0\uFFFD" - }); - var $I18N_1$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_8806993169187953163$$APP_SPEC_TS__1$ = goog.getMsg("{VAR_SELECT, select, 10 {ten} 20 {twenty} other {other}}"); - $I18N_1$ = $MSG_EXTERNAL_8806993169187953163$$APP_SPEC_TS__1$; - } - else { - $I18N_1$ = $localize \`{VAR_SELECT, select, 10 {ten} 20 {twenty} other {other}}\`; - } - $I18N_1$ = $r3$.ɵɵi18nPostprocess($I18N_1$, { - "VAR_SELECT": "\uFFFD0\uFFFD" - }); + ${i18n_0} + ${i18n_1} function MyComponent_ng_template_2_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵi18n(0, $I18N_1$); @@ -2475,7 +2096,7 @@ describe('i18n support in the template compiler', () => { template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementContainerStart(0); - $r3$.ɵɵi18n(1, $I18N_0$); + $r3$.ɵɵi18n(1, $i18n_0$); $r3$.ɵɵelementContainerEnd(); $r3$.ɵɵtemplate(2, MyComponent_ng_template_2_Template, 1, 1, "ng-template"); } @@ -2500,32 +2121,17 @@ describe('i18n support in the template compiler', () => {
`; + const i18n_0 = i18nMsg( + '{$tagImg} is my logo #1 ', [['tagImg', String.raw`\uFFFD#2\uFFFD\uFFFD/#2\uFFFD`]]); + const i18n_1 = i18nMsg( + '{$tagImg} is my logo #2 ', [['tagImg', String.raw`\uFFFD#1\uFFFD\uFFFD/#1\uFFFD`]]); + const output = String.raw` - var $I18N_0$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_4891196282781544695$$APP_SPEC_TS_0$ = goog.getMsg("{$tagImg} is my logo #1 ", { - "tagImg": "\uFFFD#2\uFFFD\uFFFD/#2\uFFFD" - }); - $I18N_0$ = $MSG_EXTERNAL_4891196282781544695$$APP_SPEC_TS_0$; - } - else { - $I18N_0$ = $localize \`$` + - String.raw`{"\uFFFD#2\uFFFD\uFFFD/#2\uFFFD"}:TAG_IMG: is my logo #1 \`; - } - var $I18N_2$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_461986953980355147$$APP_SPEC_TS__2$ = goog.getMsg("{$tagImg} is my logo #2 ", { - "tagImg": "\uFFFD#1\uFFFD\uFFFD/#1\uFFFD" - }); - $I18N_2$ = $MSG_EXTERNAL_461986953980355147$$APP_SPEC_TS__2$; - } - else { - $I18N_2$ = $localize \`$` + - String.raw`{"\uFFFD#1\uFFFD\uFFFD/#1\uFFFD"}:TAG_IMG: is my logo #2 \`; - } + ${i18n_0} + ${i18n_1} function MyComponent_ng_template_3_Template(rf, ctx) { if (rf & 1) { - $r3$.ɵɵi18nStart(0, $I18N_2$); + $r3$.ɵɵi18nStart(0, $i18n_1$); $r3$.ɵɵelement(1, "img", 0); $r3$.ɵɵi18nEnd(); } @@ -2535,7 +2141,7 @@ describe('i18n support in the template compiler', () => { template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementContainerStart(0); - $r3$.ɵɵi18nStart(1, $I18N_0$); + $r3$.ɵɵi18nStart(1, $i18n_0$); $r3$.ɵɵelement(2, "img", 0); $r3$.ɵɵi18nEnd(); $r3$.ɵɵelementContainerEnd(); @@ -2557,23 +2163,11 @@ describe('i18n support in the template compiler', () => { `; - const output = String.raw` - var $I18N_0$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_8537814667662432133$$APP_SPEC_TS__0$ = goog.getMsg(" Root content {$startTagNgContainer} Nested content {$closeTagNgContainer}", { - "startTagNgContainer": "\uFFFD*1:1\uFFFD\uFFFD#1:1\uFFFD", - "closeTagNgContainer": "\uFFFD/#1:1\uFFFD\uFFFD/*1:1\uFFFD" - }); - $I18N_0$ = $MSG_EXTERNAL_8537814667662432133$$APP_SPEC_TS__0$; - } - else { - $I18N_0$ = $localize \` Root content $` + - String.raw - `{"\uFFFD*1:1\uFFFD\uFFFD#1:1\uFFFD"}:START_TAG_NG_CONTAINER: Nested content $` + - String.raw`{"\uFFFD/#1:1\uFFFD\uFFFD/*1:1\uFFFD"}:CLOSE_TAG_NG_CONTAINER:\`; - } - … - `; + const output = + i18nMsg(' Root content {$startTagNgContainer} Nested content {$closeTagNgContainer}', [ + ['startTagNgContainer', String.raw`\uFFFD*1:1\uFFFD\uFFFD#1:1\uFFFD`], + ['closeTagNgContainer', String.raw`\uFFFD/#1:1\uFFFD\uFFFD/*1:1\uFFFD`] + ]); verify(input, output); }); @@ -2586,25 +2180,10 @@ describe('i18n support in the template compiler', () => { // TODO(FW-635): currently we generate unique consts for each i18n block even though it // might contain the same content. This should be optimized by translation statements caching, - // that can be implemented in the future within FW-635. + // that can be implemented in the future. const output = String.raw` - var $I18N_0$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_6563391987554512024$$APP_SPEC_TS_0$ = goog.getMsg("Test"); - $I18N_0$ = $MSG_EXTERNAL_6563391987554512024$$APP_SPEC_TS_0$; - } - else { - $I18N_0$ = $localize \`Test\`; - } - var $I18N_1$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_6563391987554512024$$APP_SPEC_TS_1$ = goog.getMsg("Test"); - $I18N_1$ = $MSG_EXTERNAL_6563391987554512024$$APP_SPEC_TS_1$; - } - else { - $I18N_1$ = $localize \`Test\`; - } - … + ${i18nMsg('Test')} + ${i18nMsg('Test')} `; verify(input, output); @@ -2617,24 +2196,20 @@ describe('i18n support in the template compiler', () => { `; + const i18n_0 = i18nMsg(' Hello {$startTagNgContainer}there{$closeTagNgContainer}', [ + ['startTagNgContainer', String.raw`\uFFFD#2\uFFFD`], + ['closeTagNgContainer', String.raw`\uFFFD/#2\uFFFD`] + ]); + const output = String.raw` - var $I18N_0$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_APP_SPEC_TS_1$ = goog.getMsg(" Hello {$startTagNgContainer}there{$closeTagNgContainer}", { "startTagNgContainer": "\uFFFD#2\uFFFD", "closeTagNgContainer": "\uFFFD/#2\uFFFD" }); - $I18N_0$ = $MSG_APP_SPEC_TS_1$; - } - else { - $I18N_0$ = $localize \` Hello $` + - String.raw`{"\uFFFD#2\uFFFD"}:START_TAG_NG_CONTAINER:there$` + - String.raw`{"\uFFFD/#2\uFFFD"}:CLOSE_TAG_NG_CONTAINER:\`; - } + ${i18n_0} … decls: 3, vars: 0, template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); - $r3$.ɵɵi18nStart(1, I18N_0); + $r3$.ɵɵi18nStart(1, $i18n_0$); $r3$.ɵɵelementContainer(2); $r3$.ɵɵi18nEnd(); $r3$.ɵɵelementEnd(); @@ -2653,19 +2228,17 @@ describe('i18n support in the template compiler', () => { `; + const i18n_0 = i18nMsg( + ' Hello {$startTagNgContainer}there {$startTagStrong}!{$closeTagStrong}{$closeTagNgContainer}', + [ + ['startTagNgContainer', String.raw`\uFFFD#2\uFFFD`], + ['startTagStrong', String.raw`\uFFFD#3\uFFFD`], + ['closeTagStrong', String.raw`\uFFFD/#3\uFFFD`], + ['closeTagNgContainer', String.raw`\uFFFD/#2\uFFFD`] + ]); + const output = String.raw` - var $I18N_0$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_APP_SPEC_TS_1$ = goog.getMsg(" Hello {$startTagNgContainer}there {$startTagStrong}!{$closeTagStrong}{$closeTagNgContainer}", { "startTagNgContainer": "\uFFFD#2\uFFFD", "startTagStrong": "\uFFFD#3\uFFFD", "closeTagStrong": "\uFFFD/#3\uFFFD", "closeTagNgContainer": "\uFFFD/#2\uFFFD" }); - $I18N_0$ = $MSG_APP_SPEC_TS_1$; - } - else { - $I18N_0$ = $localize \` Hello $` + - String.raw`{"\uFFFD#2\uFFFD"}:START_TAG_NG_CONTAINER:there $` + - String.raw`{"\uFFFD#3\uFFFD"}:START_TAG_STRONG:!$` + - String.raw`{"\uFFFD/#3\uFFFD"}:CLOSE_TAG_STRONG:$` + - String.raw`{"\uFFFD/#2\uFFFD"}:CLOSE_TAG_NG_CONTAINER:\`; - } + ${i18n_0} … decls: 4, vars: 0, @@ -2685,25 +2258,22 @@ describe('i18n support in the template compiler', () => { verify(input, output); }); - // Note: applying structural directives to is typically user error, but it is - // technically allowed, so we need to support it. + // Note: applying structural directives to is typically user error, + // but it is technically allowed, so we need to support it. it('should handle structural directives', () => { const input = ` Content A Content B `; + const i18n_0 = i18nMsg('Content A'); + const i18n_1 = i18nMsg('Content B'); + const output = String.raw` - var $I18N_1$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_3308216566145348998$$APP_SPEC_TS___2$ = goog.getMsg("Content A"); - $I18N_1$ = $MSG_EXTERNAL_3308216566145348998$$APP_SPEC_TS___2$; - } else { - $I18N_1$ = $localize \`Content A\`; - } + ${i18n_0} function MyComponent_0_ng_template_0_Template(rf, ctx) { if (rf & 1) { - $r3$.ɵɵi18n(0, $I18N_1$); + $r3$.ɵɵi18n(0, $i18n_0$); } } function MyComponent_0_Template(rf, ctx) { @@ -2711,17 +2281,11 @@ describe('i18n support in the template compiler', () => { $r3$.ɵɵtemplate(0, MyComponent_0_ng_template_0_Template, 1, 0, "ng-template"); } } - var $I18N_3$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_8349021389088127654$$APP_SPEC_TS__4$ = goog.getMsg("Content B"); - $I18N_3$ = $MSG_EXTERNAL_8349021389088127654$$APP_SPEC_TS__4$; - } else { - $I18N_3$ = $localize \`Content B\`; - } + ${i18n_1} function MyComponent_ng_container_1_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementContainerStart(0); - $r3$.ɵɵi18n(1, $I18N_3$); + $r3$.ɵɵi18n(1, $i18n_1$); $r3$.ɵɵelementContainerEnd(); } } @@ -2754,6 +2318,8 @@ describe('i18n support in the template compiler', () => { `; + // Keeping raw content (avoiding `i18nMsg`) to illustrate message layout + // in case of whitespace preserving mode. const output = String.raw` var $I18N_0$; if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { @@ -2795,18 +2361,12 @@ describe('i18n support in the template compiler', () => {
{gender, select, male {male} female {female} other {other}}
`; + const i18n_0 = i18nIcuMsg( + '{VAR_SELECT, select, male {male} female {female} other {other}}', + [['VAR_SELECT', String.raw`\uFFFD0\uFFFD`]]); + const output = String.raw` - var $I18N_0$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_7842238767399919809$$APP_SPEC_TS_0$ = goog.getMsg("{VAR_SELECT, select, male {male} female {female} other {other}}"); - $I18N_0$ = $MSG_EXTERNAL_7842238767399919809$$APP_SPEC_TS_0$; - } - else { - $I18N_0$ = $localize \`{VAR_SELECT, select, male {male} female {female} other {other}}\`; - } - $I18N_0$ = $r3$.ɵɵi18nPostprocess($I18N_0$, { - "VAR_SELECT": "\uFFFD0\uFFFD" - }); + ${i18n_0} … decls: 2, vars: 1, @@ -2854,24 +2414,18 @@ describe('i18n support in the template compiler', () => { {age, select, 10 {ten} 20 {twenty} other {other}} `; + const i18n_0 = i18nIcuMsg( + '{VAR_SELECT, select, 10 {ten} 20 {twenty} other {other}}', + [['VAR_SELECT', String.raw`\uFFFD0\uFFFD`]]); + const output = String.raw` - var $I18N_0$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_8806993169187953163$$APP_SPEC_TS_0$ = goog.getMsg("{VAR_SELECT, select, 10 {ten} 20 {twenty} other {other}}"); - $I18N_0$ = $MSG_EXTERNAL_8806993169187953163$$APP_SPEC_TS_0$; - } - else { - $I18N_0$ = $localize \`{VAR_SELECT, select, 10 {ten} 20 {twenty} other {other}}\`; - } - $I18N_0$ = $r3$.ɵɵi18nPostprocess($I18N_0$, { - "VAR_SELECT": "\uFFFD0\uFFFD" - }); + ${i18n_0} … decls: 1, vars: 1, template: function MyComponent_Template(rf, ctx) { if (rf & 1) { - $r3$.ɵɵi18n(0, $I18N_0$); + $r3$.ɵɵi18n(0, $i18n_0$); } if (rf & 2) { $r3$.ɵɵi18nExp(ctx.age); @@ -2894,34 +2448,25 @@ describe('i18n support in the template compiler', () => { `; + const i18n_0 = i18nIcuMsg( + '{VAR_SELECT, select, male {male} female {female} other {other}}', + [['VAR_SELECT', String.raw`\uFFFD0\uFFFD`]]); + const i18n_1 = i18nIcuMsg( + '{VAR_SELECT, select, 10 {ten} 20 {twenty} other {other}}', + [['VAR_SELECT', String.raw`\uFFFD0\uFFFD`]]); + const i18n_2 = i18nIcuMsg( + '{VAR_SELECT, select, 0 {no emails} 1 {one email} other {{INTERPOLATION} emails}}', [ + ['VAR_SELECT', String.raw`\uFFFD0\uFFFD`], ['INTERPOLATION', String.raw`\uFFFD1\uFFFD`] + ]); + const output = String.raw` - var $I18N_0$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_7842238767399919809$$APP_SPEC_TS_0$ = goog.getMsg("{VAR_SELECT, select, male {male} female {female} other {other}}"); - $I18N_0$ = $MSG_EXTERNAL_7842238767399919809$$APP_SPEC_TS_0$; - } - else { - $I18N_0$ = $localize \`{VAR_SELECT, select, male {male} female {female} other {other}}\`; - } - $I18N_0$ = $r3$.ɵɵi18nPostprocess($I18N_0$, { - "VAR_SELECT": "\uFFFD0\uFFFD" - }); - var $I18N_3$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_8806993169187953163$$APP_SPEC_TS__3$ = goog.getMsg("{VAR_SELECT, select, 10 {ten} 20 {twenty} other {other}}"); - $I18N_3$ = $MSG_EXTERNAL_8806993169187953163$$APP_SPEC_TS__3$; - } - else { - $I18N_3$ = $localize \`{VAR_SELECT, select, 10 {ten} 20 {twenty} other {other}}\`; - } - $I18N_3$ = $r3$.ɵɵi18nPostprocess($I18N_3$, { - "VAR_SELECT": "\uFFFD0\uFFFD" - }); + ${i18n_0} + ${i18n_1} function MyComponent_div_2_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div", 2); $r3$.ɵɵtext(1, " "); - $r3$.ɵɵi18n(2, $I18N_3$); + $r3$.ɵɵi18n(2, $i18n_1$); $r3$.ɵɵtext(3, " "); $r3$.ɵɵelementEnd(); } @@ -2932,23 +2477,12 @@ describe('i18n support in the template compiler', () => { $r3$.ɵɵi18nApply(2); } } - var $I18N_5$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_1922743304863699161$$APP_SPEC_TS__5$ = goog.getMsg("{VAR_SELECT, select, 0 {no emails} 1 {one email} other {{INTERPOLATION} emails}}"); - $I18N_5$ = $MSG_EXTERNAL_1922743304863699161$$APP_SPEC_TS__5$; - } - else { - $I18N_5$ = $localize \`{VAR_SELECT, select, 0 {no emails} 1 {one email} other {{INTERPOLATION} emails}}\`; - } - $I18N_5$ = $r3$.ɵɵi18nPostprocess($I18N_5$, { - "VAR_SELECT": "\uFFFD0\uFFFD", - "INTERPOLATION": "\uFFFD1\uFFFD" - }); + ${i18n_2} function MyComponent_div_3_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div", 3); $r3$.ɵɵtext(1, " You have "); - $r3$.ɵɵi18n(2, $I18N_5$); + $r3$.ɵɵi18n(2, $i18n_2$); $r3$.ɵɵtext(3, ". "); $r3$.ɵɵelementEnd(); } @@ -2968,7 +2502,7 @@ describe('i18n support in the template compiler', () => { template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); - $r3$.ɵɵi18n(1, $I18N_0$); + $r3$.ɵɵi18n(1, $i18n_0$); $r3$.ɵɵelementEnd(); $r3$.ɵɵtemplate(2, MyComponent_div_2_Template, 4, 1, "div", 0); $r3$.ɵɵtemplate(3, MyComponent_div_3_Template, 4, 2, "div", 1); @@ -2993,24 +2527,18 @@ describe('i18n support in the template compiler', () => {
{age, select, 10 {ten} 20 {twenty} other {{% other %}}}
`; + const i18n_0 = + i18nIcuMsg('{VAR_SELECT, select, 10 {ten} 20 {twenty} other {{INTERPOLATION}}}', [ + ['VAR_SELECT', String.raw`\uFFFD0\uFFFD`], ['INTERPOLATION', String.raw`\uFFFD1\uFFFD`] + ]); + const output = String.raw` - var $I18N_0$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_2949673783721159566$$APP_SPEC_TS_0$ = goog.getMsg("{VAR_SELECT, select, 10 {ten} 20 {twenty} other {{INTERPOLATION}}}"); - $I18N_0$ = $MSG_EXTERNAL_2949673783721159566$$APP_SPEC_TS_0$; - } - else { - $I18N_0$ = $localize \`{VAR_SELECT, select, 10 {ten} 20 {twenty} other {{INTERPOLATION}}}\`; - } - $I18N_0$ = $r3$.ɵɵi18nPostprocess($I18N_0$, { - "VAR_SELECT": "\uFFFD0\uFFFD", - "INTERPOLATION": "\uFFFD1\uFFFD" - }); + ${i18n_0} … template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); - $r3$.ɵɵi18n(1, $I18N_0$); + $r3$.ɵɵi18n(1, $i18n_0$); $r3$.ɵɵelementEnd(); } if (rf & 2) { @@ -3033,47 +2561,33 @@ describe('i18n support in the template compiler', () => { `; + const i18n_0 = i18nIcuMsg( + '{VAR_SELECT, select, male {male - {START_BOLD_TEXT}male{CLOSE_BOLD_TEXT}} female {female {START_BOLD_TEXT}female{CLOSE_BOLD_TEXT}} other {{START_TAG_DIV}{START_ITALIC_TEXT}other{CLOSE_ITALIC_TEXT}{CLOSE_TAG_DIV}}}', + [ + ['VAR_SELECT', String.raw`\uFFFD0\uFFFD`], + ['START_BOLD_TEXT', ''], + ['CLOSE_BOLD_TEXT', ''], + ['START_ITALIC_TEXT', ''], + ['CLOSE_ITALIC_TEXT', ''], + ['START_TAG_DIV', '
'], + ['CLOSE_TAG_DIV', '
'], + ]); + + const i18n_1 = i18nMsg( + ' {$icu} {$startBoldText}Other content{$closeBoldText}{$startTagDiv}{$startItalicText}Another content{$closeItalicText}{$closeTagDiv}', + [ + ['startBoldText', String.raw`\uFFFD#2\uFFFD`], + ['closeBoldText', String.raw`\uFFFD/#2\uFFFD`], + ['startTagDiv', String.raw`\uFFFD#3\uFFFD`], + ['startItalicText', String.raw`\uFFFD#4\uFFFD`], + ['closeItalicText', String.raw`\uFFFD/#4\uFFFD`], + ['closeTagDiv', String.raw`\uFFFD/#3\uFFFD`], + ['icu', '$I18N_0$'], + ]); + const output = String.raw` - var $I18N_1$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_2417296354340576868$$APP_SPEC_TS_1$ = goog.getMsg("{VAR_SELECT, select, male {male - {START_BOLD_TEXT}male{CLOSE_BOLD_TEXT}} female {female {START_BOLD_TEXT}female{CLOSE_BOLD_TEXT}} other {{START_TAG_DIV}{START_ITALIC_TEXT}other{CLOSE_ITALIC_TEXT}{CLOSE_TAG_DIV}}}"); - $I18N_1$ = $MSG_EXTERNAL_2417296354340576868$$APP_SPEC_TS_1$; - } - else { - $I18N_1$ = $localize \`{VAR_SELECT, select, male {male - {START_BOLD_TEXT}male{CLOSE_BOLD_TEXT}} female {female {START_BOLD_TEXT}female{CLOSE_BOLD_TEXT}} other {{START_TAG_DIV}{START_ITALIC_TEXT}other{CLOSE_ITALIC_TEXT}{CLOSE_TAG_DIV}}}\`; - } - $I18N_1$ = $r3$.ɵɵi18nPostprocess($I18N_1$, { - "VAR_SELECT": "\uFFFD0\uFFFD", - "START_BOLD_TEXT": "", - "CLOSE_BOLD_TEXT": "", - "START_ITALIC_TEXT": "", - "CLOSE_ITALIC_TEXT": "", - "START_TAG_DIV": "
", - "CLOSE_TAG_DIV": "
" - }); - var $I18N_0$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_5791551881115084301$$APP_SPEC_TS_0$ = goog.getMsg(" {$icu} {$startBoldText}Other content{$closeBoldText}{$startTagDiv}{$startItalicText}Another content{$closeItalicText}{$closeTagDiv}", { - "startBoldText": "\uFFFD#2\uFFFD", - "closeBoldText": "\uFFFD/#2\uFFFD", - "startTagDiv": "\uFFFD#3\uFFFD", - "startItalicText": "\uFFFD#4\uFFFD", - "closeItalicText": "\uFFFD/#4\uFFFD", - "closeTagDiv": "\uFFFD/#3\uFFFD", - "icu": $I18N_1$ - }); - $I18N_0$ = $MSG_EXTERNAL_5791551881115084301$$APP_SPEC_TS_0$; - } - else { - $I18N_0$ = $localize \` $` + - String.raw`{$I18N_1$}:ICU: $` + - String.raw`{"\uFFFD#2\uFFFD"}:START_BOLD_TEXT:Other content$` + - String.raw`{"\uFFFD/#2\uFFFD"}:CLOSE_BOLD_TEXT:$` + - String.raw`{"\uFFFD#3\uFFFD"}:START_TAG_DIV:$` + - String.raw`{"\uFFFD#4\uFFFD"}:START_ITALIC_TEXT:Another content$` + - String.raw`{"\uFFFD/#4\uFFFD"}:CLOSE_ITALIC_TEXT:$` + - String.raw`{"\uFFFD/#3\uFFFD"}:CLOSE_TAG_DIV:\`; - } + ${i18n_0} + ${i18n_1} … decls: 5, vars: 1, @@ -3081,7 +2595,7 @@ describe('i18n support in the template compiler', () => { template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); - $r3$.ɵɵi18nStart(1, $I18N_0$); + $r3$.ɵɵi18nStart(1, $i18n_1$); $r3$.ɵɵelement(2, "b"); $r3$.ɵɵelementStart(3, "div", 0); $r3$.ɵɵelement(4, "i"); @@ -3105,26 +2619,22 @@ describe('i18n support in the template compiler', () => {
{gender, select, male {male of age: {{ ageA + ageB + ageC }}} female {female} other {other}}
`; + const i18n_0 = i18nIcuMsg( + '{VAR_SELECT, select, male {male of age: {INTERPOLATION}} female {female} other {other}}', + [ + ['VAR_SELECT', String.raw`\uFFFD0\uFFFD`], + ['INTERPOLATION', String.raw`\uFFFD1\uFFFD`], + ]); + const output = String.raw` - var $I18N_0$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_6879461626778511059$$APP_SPEC_TS_0$ = goog.getMsg("{VAR_SELECT, select, male {male of age: {INTERPOLATION}} female {female} other {other}}"); - $I18N_0$ = $MSG_EXTERNAL_6879461626778511059$$APP_SPEC_TS_0$; - } - else { - $I18N_0$ = $localize \`{VAR_SELECT, select, male {male of age: {INTERPOLATION}} female {female} other {other}}\`; - } - $I18N_0$ = $r3$.ɵɵi18nPostprocess($I18N_0$, { - "VAR_SELECT": "\uFFFD0\uFFFD", - "INTERPOLATION": "\uFFFD1\uFFFD" - }); + ${i18n_0} … decls: 2, vars: 2, template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); - $r3$.ɵɵi18n(1, $I18N_0$); + $r3$.ɵɵi18n(1, $i18n_0$); $r3$.ɵɵelementEnd(); } if (rf & 2) { @@ -3146,48 +2656,28 @@ describe('i18n support in the template compiler', () => { `; + const i18n_0 = i18nIcuMsg( + '{VAR_SELECT, select, male {male} female {female} other {other}}', + [['VAR_SELECT', String.raw`\uFFFD0\uFFFD`]]); + const i18n_1 = i18nIcuMsg( + '{VAR_SELECT, select, 10 {ten} 20 {twenty} 30 {thirty} other {other}}', + [['VAR_SELECT', String.raw`\uFFFD1\uFFFD`]]); + const i18n_2 = i18nMsg(' {$icu} {$icu_1} ', [ + ['icu', '$i18n_0$'], + ['icu_1', '$i18n_1$'], + ]); + const output = String.raw` - var $I18N_1$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_7842238767399919809$$APP_SPEC_TS_1$ = goog.getMsg("{VAR_SELECT, select, male {male} female {female} other {other}}"); - $I18N_1$ = $MSG_EXTERNAL_7842238767399919809$$APP_SPEC_TS_1$; - } - else { - $I18N_1$ = $localize \`{VAR_SELECT, select, male {male} female {female} other {other}}\`; - } - $I18N_1$ = $r3$.ɵɵi18nPostprocess($I18N_1$, { - "VAR_SELECT": "\uFFFD0\uFFFD" - }); - var $I18N_2$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_7068143081688428291$$APP_SPEC_TS_2$ = goog.getMsg("{VAR_SELECT, select, 10 {ten} 20 {twenty} 30 {thirty} other {other}}"); - $I18N_2$ = $MSG_EXTERNAL_7068143081688428291$$APP_SPEC_TS_2$; - } - else { - $I18N_2$ = $localize \`{VAR_SELECT, select, 10 {ten} 20 {twenty} 30 {thirty} other {other}}\`; - } - $I18N_2$ = $r3$.ɵɵi18nPostprocess($I18N_2$, { - "VAR_SELECT": "\uFFFD1\uFFFD" - }); - var $I18N_0$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_2967249209167308918$$APP_SPEC_TS_0$ = goog.getMsg(" {$icu} {$icu_1} ", { - "icu": $I18N_1$, - "icu_1": $I18N_2$ - }); - $I18N_0$ = $MSG_EXTERNAL_2967249209167308918$$APP_SPEC_TS_0$; - } - else { - $I18N_0$ = $localize \` $` + - String.raw`{$I18N_1$}:ICU: $` + String.raw`{$I18N_2$}:ICU_1: \`; - } + ${i18n_0} + ${i18n_1} + ${i18n_2} … decls: 2, vars: 2, template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); - $r3$.ɵɵi18n(1, $I18N_0$); + $r3$.ɵɵi18n(1, $i18n_2$); $r3$.ɵɵelementEnd(); } if (rf & 2) { @@ -3306,9 +2796,9 @@ describe('i18n support in the template compiler', () => { } `; - // TODO(akushnir): this use-case is currently supported with + // TODO(FW-635): this use-case is currently supported with // file-based prefix for translation const names. Translation statements - // caching is required to support this use-case (FW-635) with id-based consts. + // caching is required to support this use-case with id-based consts. verify(input, output, {skipIdBasedCheck: true}); }); @@ -3323,34 +2813,21 @@ describe('i18n support in the template compiler', () => { `; + const i18n_0 = i18nIcuMsg( + '{VAR_SELECT_1, select, male {male of age: {VAR_SELECT, select, 10 {ten} 20 {twenty} 30 {thirty} other {other}}} female {female} other {other}}', + [['VAR_SELECT', String.raw`\uFFFD0\uFFFD`], ['VAR_SELECT_1', String.raw`\uFFFD1\uFFFD`]]); + const i18n_1 = i18nMsg(' {$icu} ', [['icu', '$i18n_0$']]); + const output = String.raw` - var $I18N_1$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_343563413083115114$$APP_SPEC_TS_0$ = goog.getMsg("{VAR_SELECT_1, select, male {male of age: {VAR_SELECT, select, 10 {ten} 20 {twenty} 30 {thirty} other {other}}} female {female} other {other}}"); - $I18N_1$ = $MSG_EXTERNAL_343563413083115114$$APP_SPEC_TS_0$; - } - else { - $I18N_1$ = $localize \`{VAR_SELECT_1, select, male {male of age: {VAR_SELECT, select, 10 {ten} 20 {twenty} 30 {thirty} other {other}}} female {female} other {other}}\`; - } - $I18N_1$ = $r3$.ɵɵi18nPostprocess($I18N_1$, { - "VAR_SELECT": "\uFFFD0\uFFFD", - "VAR_SELECT_1": "\uFFFD1\uFFFD" - }); - var $I18N_0$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_3052001905251380936$$APP_SPEC_TS_3$ = goog.getMsg(" {$icu} ", { "icu": $I18N_1$ }); - $I18N_0$ = $MSG_EXTERNAL_3052001905251380936$$APP_SPEC_TS_3$; - } - else { - $I18N_0$ = $localize \` $` + - String.raw`{$I18N_1$}:ICU: \`; - } … + ${i18n_0} + ${i18n_1} + … decls: 2, vars: 2, template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); - $r3$.ɵɵi18n(1, $I18N_0$); + $r3$.ɵɵi18n(1, $i18n_1$); $r3$.ɵɵelementEnd(); } if (rf & 2) { @@ -3379,27 +2856,23 @@ describe('i18n support in the template compiler', () => { } `; + const i18n_0 = i18nIcuMsg( + '{VAR_PLURAL, plural, =0 {zero} =2 {{INTERPOLATION} {VAR_SELECT, select, cat {cats} dog {dogs} other {animals}} !} other {other - {INTERPOLATION}}}', + [ + ['VAR_SELECT', String.raw`\uFFFD0\uFFFD`], + ['VAR_PLURAL', String.raw`\uFFFD1\uFFFD`], + ['INTERPOLATION', String.raw`\uFFFD2\uFFFD`], + ]); + const output = String.raw` - var $I18N_0$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_6870293071705078389$$APP_SPEC_TS_1$ = goog.getMsg("{VAR_PLURAL, plural, =0 {zero} =2 {{INTERPOLATION} {VAR_SELECT, select, cat {cats} dog {dogs} other {animals}} !} other {other - {INTERPOLATION}}}"); - $I18N_0$ = $MSG_EXTERNAL_6870293071705078389$$APP_SPEC_TS_1$; - } - else { - $I18N_0$ = $localize \`{VAR_PLURAL, plural, =0 {zero} =2 {{INTERPOLATION} {VAR_SELECT, select, cat {cats} dog {dogs} other {animals}} !} other {other - {INTERPOLATION}}}\`; - } - $I18N_0$ = $r3$.ɵɵi18nPostprocess($I18N_0$, { - "VAR_SELECT": "\uFFFD0\uFFFD", - "VAR_PLURAL": "\uFFFD1\uFFFD", - "INTERPOLATION": "\uFFFD2\uFFFD" - }); + ${i18n_0} … decls: 2, vars: 3, template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); - $r3$.ɵɵi18n(1, $I18N_0$); + $r3$.ɵɵi18n(1, $i18n_0$); $r3$.ɵɵelementEnd(); } if (rf & 2) { @@ -3423,49 +2896,27 @@ describe('i18n support in the template compiler', () => { `; + const i18n_0 = i18nIcuMsg( + '{VAR_SELECT, select, male {male} female {female} other {other}}', + [['VAR_SELECT', String.raw`\uFFFD0\uFFFD`]]); + const i18n_1 = i18nIcuMsg( + '{VAR_SELECT, select, 10 {ten} 20 {twenty} 30 {thirty} other {other}}', + [['VAR_SELECT', String.raw`\uFFFD0:1\uFFFD`]]); + const i18n_2 = i18nMsg(' {$icu} {$startTagSpan} {$icu_1} {$closeTagSpan}', [ + ['startTagSpan', String.raw`\uFFFD*2:1\uFFFD\uFFFD#1:1\uFFFD`], + ['closeTagSpan', String.raw`\uFFFD/#1:1\uFFFD\uFFFD/*2:1\uFFFD`], + ['icu', '$i18n_0$'], + ['icu_1', '$i18n_1$'], + ]); + const output = String.raw` - var $I18N_1$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_7842238767399919809$$APP_SPEC_TS_1$ = goog.getMsg("{VAR_SELECT, select, male {male} female {female} other {other}}"); - $I18N_1$ = $MSG_EXTERNAL_7842238767399919809$$APP_SPEC_TS_1$; - } - else { - $I18N_1$ = $localize \`{VAR_SELECT, select, male {male} female {female} other {other}}\`; - } - $I18N_1$ = $r3$.ɵɵi18nPostprocess($I18N_1$, { - "VAR_SELECT": "\uFFFD0\uFFFD" - }); - var $I18N_3$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_7068143081688428291$$APP_SPEC_TS__3$ = goog.getMsg("{VAR_SELECT, select, 10 {ten} 20 {twenty} 30 {thirty} other {other}}"); - $I18N_3$ = $MSG_EXTERNAL_7068143081688428291$$APP_SPEC_TS__3$; - } - else { - $I18N_3$ = $localize \`{VAR_SELECT, select, 10 {ten} 20 {twenty} 30 {thirty} other {other}}\`; - } - $I18N_3$ = $r3$.ɵɵi18nPostprocess($I18N_3$, { - "VAR_SELECT": "\uFFFD0:1\uFFFD" - }); - var $I18N_0$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_1194472282609532229$$APP_SPEC_TS_0$ = goog.getMsg(" {$icu} {$startTagSpan} {$icu_1} {$closeTagSpan}", { - "startTagSpan": "\uFFFD*2:1\uFFFD\uFFFD#1:1\uFFFD", - "closeTagSpan": "\uFFFD/#1:1\uFFFD\uFFFD/*2:1\uFFFD", - "icu": $I18N_1$, - "icu_1": $I18N_3$ - }); - $I18N_0$ = $MSG_EXTERNAL_1194472282609532229$$APP_SPEC_TS_0$; - } - else { - $I18N_0$ = $localize \` $` + - String.raw`{$I18N_1$}:ICU: $` + - String.raw`{"\uFFFD*2:1\uFFFD\uFFFD#1:1\uFFFD"}:START_TAG_SPAN: $` + - String.raw`{$I18N_3$}:ICU_1: $` + - String.raw`{"\uFFFD/#1:1\uFFFD\uFFFD/*2:1\uFFFD"}:CLOSE_TAG_SPAN:\`; - } + ${i18n_0} + ${i18n_1} + ${i18n_2} + … function MyComponent_span_2_Template(rf, ctx) { if (rf & 1) { - $r3$.ɵɵi18nStart(0, $I18N_0$, 1); + $r3$.ɵɵi18nStart(0, $i18n_2$, 1); $r3$.ɵɵelement(1, "span"); $r3$.ɵɵi18nEnd(); } @@ -3483,7 +2934,7 @@ describe('i18n support in the template compiler', () => { template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); - $r3$.ɵɵi18nStart(1, $I18N_0$); + $r3$.ɵɵi18nStart(1, $i18n_2$); $r3$.ɵɵtemplate(2, MyComponent_span_2_Template, 2, 1, "span", 0); $r3$.ɵɵi18nEnd(); $r3$.ɵɵelementEnd(); @@ -3510,52 +2961,32 @@ describe('i18n support in the template compiler', () => { `; + const i18n_0 = i18nIcuMsg( + '{VAR_SELECT, select, male {male {INTERPOLATION}} female {female {INTERPOLATION_1}} other {other}}', + [ + ['VAR_SELECT', String.raw`\uFFFD0\uFFFD`], + ['INTERPOLATION', String.raw`\uFFFD1\uFFFD`], + ['INTERPOLATION_1', String.raw`\uFFFD2\uFFFD`], + ]); + const i18n_1 = i18nIcuMsg( + '{VAR_SELECT, select, 10 {ten} 20 {twenty} 30 {thirty} other {other: {INTERPOLATION}}}', [ + ['VAR_SELECT', String.raw`\uFFFD0:1\uFFFD`], + ['INTERPOLATION', String.raw`\uFFFD1:1\uFFFD`], + ]); + const i18n_2 = i18nMsg(' {$icu} {$startTagSpan} {$icu_1} {$closeTagSpan}', [ + ['startTagSpan', String.raw`\uFFFD*2:1\uFFFD\uFFFD#1:1\uFFFD`], + ['closeTagSpan', String.raw`\uFFFD/#1:1\uFFFD\uFFFD/*2:1\uFFFD`], + ['icu', '$i18n_0$'], + ['icu_1', '$i18n_1$'], + ]); + const output = String.raw` - var $I18N_1$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_7825031864601787094$$APP_SPEC_TS_1$ = goog.getMsg("{VAR_SELECT, select, male {male {INTERPOLATION}} female {female {INTERPOLATION_1}} other {other}}"); - $I18N_1$ = $MSG_EXTERNAL_7825031864601787094$$APP_SPEC_TS_1$; - } - else { - $I18N_1$ = $localize \`{VAR_SELECT, select, male {male {INTERPOLATION}} female {female {INTERPOLATION_1}} other {other}}\`; - } - $I18N_1$ = $r3$.ɵɵi18nPostprocess($I18N_1$, { - "VAR_SELECT": "\uFFFD0\uFFFD", - "INTERPOLATION": "\uFFFD1\uFFFD", - "INTERPOLATION_1": "\uFFFD2\uFFFD" - }); - var $I18N_3$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_2310343208266678305$$APP_SPEC_TS__3$ = goog.getMsg("{VAR_SELECT, select, 10 {ten} 20 {twenty} 30 {thirty} other {other: {INTERPOLATION}}}"); - $I18N_3$ = $MSG_EXTERNAL_2310343208266678305$$APP_SPEC_TS__3$; - } - else { - $I18N_4$ = $localize \`{VAR_SELECT, select, 10 {ten} 20 {twenty} 30 {thirty} other {other: {INTERPOLATION}}}\`; - } - $I18N_3$ = $r3$.ɵɵi18nPostprocess($I18N_3$, { - "VAR_SELECT": "\uFFFD0:1\uFFFD", - "INTERPOLATION": "\uFFFD1:1\uFFFD" - }); - var $I18N_0$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_7186042105600518133$$APP_SPEC_TS_0$ = goog.getMsg(" {$icu} {$startTagSpan} {$icu_1} {$closeTagSpan}", { - "startTagSpan": "\uFFFD*2:1\uFFFD\uFFFD#1:1\uFFFD", - "closeTagSpan": "\uFFFD/#1:1\uFFFD\uFFFD/*2:1\uFFFD", - "icu": $I18N_1$, - "icu_1": $I18N_3$ - }); - $I18N_0$ = $MSG_EXTERNAL_7186042105600518133$$APP_SPEC_TS_0$; - } - else { - $I18N_0$ = $localize \` $` + - String.raw`{I18N_1}:ICU: $` + - String.raw`{"\uFFFD*2:1\uFFFD\uFFFD#1:1\uFFFD"}:START_TAG_SPAN: $` + - String.raw`{I18N_3}:ICU_1: $` + - String.raw`{"\uFFFD/#1:1\uFFFD\uFFFD/*2:1\uFFFD"}:CLOSE_TAG_SPAN:\`; - } + ${i18n_0} + ${i18n_1} + ${i18n_2} function MyComponent_span_2_Template(rf, ctx) { if (rf & 1) { - $r3$.ɵɵi18nStart(0, $I18N_0$, 1); + $r3$.ɵɵi18nStart(0, $i18n_2$, 1); $r3$.ɵɵelement(1, "span"); $r3$.ɵɵi18nEnd(); } @@ -3573,7 +3004,7 @@ describe('i18n support in the template compiler', () => { template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); - $r3$.ɵɵi18nStart(1, $I18N_0$); + $r3$.ɵɵi18nStart(1, $i18n_2$); $r3$.ɵɵtemplate(2, MyComponent_span_2_Template, 2, 2, "span", 0); $r3$.ɵɵi18nEnd(); $r3$.ɵɵelementEnd(); @@ -3601,28 +3032,24 @@ describe('i18n support in the template compiler', () => { } `; + const i18n_0 = i18nIcuMsg( + '{VAR_SELECT, select, male {male {PH_A}} female {female {PH_B}} other {other {PH_WITH_SPACES}}}', + [ + ['VAR_SELECT', String.raw`\uFFFD0\uFFFD`], + ['PH_A', String.raw`\uFFFD1\uFFFD`], + ['PH_B', String.raw`\uFFFD2\uFFFD`], + ['PH_WITH_SPACES', String.raw`\uFFFD3\uFFFD`], + ]); + const output = String.raw` - var $I18N_0$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_6318060397235942326$$APP_SPEC_TS_0$ = goog.getMsg("{VAR_SELECT, select, male {male {PH_A}} female {female {PH_B}} other {other {PH_WITH_SPACES}}}"); - $I18N_0$ = $MSG_EXTERNAL_6318060397235942326$$APP_SPEC_TS_0$; - } - else { - $I18N_0$ = $localize \`{VAR_SELECT, select, male {male {PH_A}} female {female {PH_B}} other {other {PH_WITH_SPACES}}}\`; - } - $I18N_0$ = $r3$.ɵɵi18nPostprocess($I18N_0$, { - "VAR_SELECT": "\uFFFD0\uFFFD", - "PH_A": "\uFFFD1\uFFFD", - "PH_B": "\uFFFD2\uFFFD", - "PH_WITH_SPACES": "\uFFFD3\uFFFD" - }); + ${i18n_0} … decls: 2, vars: 4, template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); - $r3$.ɵɵi18n(1, $I18N_0$); + $r3$.ɵɵi18n(1, $i18n_0$); $r3$.ɵɵelementEnd(); } if (rf & 2) { @@ -3641,21 +3068,10 @@ describe('i18n support in the template compiler', () => {
{count, select, 1 {one} other {more than one}}
`; - const output = String.raw` - var $I18N_0$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - /** - * @desc descA - * @meaning meaningA - */ - const $MSG_EXTERNAL_idA$$APP_SPEC_TS_1$ = goog.getMsg("{VAR_SELECT, select, 1 {one} other {more than one}}"); - $I18N_0$ = $MSG_EXTERNAL_idA$$APP_SPEC_TS_1$; - } - else { - $I18N_0$ = $localize \`:meaningA|descA@@idA:{VAR_SELECT, select, 1 {one} other {more than one}}\`; - } - $I18N_0$ = i0.ɵɵi18nPostprocess($I18N_0$, { "VAR_SELECT": "\uFFFD0\uFFFD" }); - `; + const output = i18nMsgWithPostprocess( + '{VAR_SELECT, select, 1 {one} other {more than one}}', [], + {meaning: 'meaningA', desc: 'descA', id: 'idA'}, + [['VAR_SELECT', String.raw`\uFFFD0\uFFFD`]]); verify(input, output); }); @@ -3669,25 +3085,16 @@ describe('i18n support in the template compiler', () => { `; + const i18n_0 = i18nIcuMsg( + '{VAR_SELECT , select , 1 {one} other {more than one}}', + [['VAR_SELECT', String.raw`\uFFFD0\uFFFD`]]); + const i18n_1 = i18nIcuMsg( + '{VAR_PLURAL , plural , =1 {one} other {more than one}}', + [['VAR_PLURAL', String.raw`\uFFFD1\uFFFD`]]); + const output = String.raw` - var $I18N_1$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_199763560911211963$$APP_SPEC_TS_2$ = goog.getMsg("{VAR_SELECT , select , 1 {one} other {more than one}}"); - $I18N_1$ = $MSG_EXTERNAL_199763560911211963$$APP_SPEC_TS_2$; - } - else { - $I18N_1$ = $localize \`{VAR_SELECT , select , 1 {one} other {more than one}}\`; - } - $I18N_1$ = i0.ɵɵi18nPostprocess($I18N_1$, { "VAR_SELECT": "\uFFFD0\uFFFD" }); - var $I18N_3$; - if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { - const $MSG_EXTERNAL_3383986062053865025$$APP_SPEC_TS_4$ = goog.getMsg("{VAR_PLURAL , plural , =1 {one} other {more than one}}"); - $I18N_3$ = $MSG_EXTERNAL_3383986062053865025$$APP_SPEC_TS_4$; - } - else { - $I18N_3$ = $localize \`{VAR_PLURAL , plural , =1 {one} other {more than one}}\`; - } - $I18N_3$ = i0.ɵɵi18nPostprocess($I18N_3$, { "VAR_PLURAL": "\uFFFD1\uFFFD" }); + ${i18n_0} + ${i18n_1} `; verify(input, output); From cb05c0102fc951899745e16a923966d41f9384a7 Mon Sep 17 00:00:00 2001 From: Andrew Kushnir Date: Mon, 10 Aug 2020 17:25:51 -0700 Subject: [PATCH 045/629] fix(core): move generated i18n statements to the `consts` field of ComponentDef (#38404) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit updates the code to move generated i18n statements into the `consts` field of ComponentDef to avoid invoking `$localize` function before component initialization (to better support runtime translations) and also avoid problems with lazy-loading when i18n defs may not be present in a chunk where it's referenced. Prior to this change the i18n statements were generated at the top leve: ``` var I18N_0; if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { var MSG_X = goog.getMsg(“…”); I18N_0 = MSG_X; } else { I18N_0 = $localize('...'); } defineComponent({ // ... template: function App_Template(rf, ctx) { i0.ɵɵi18n(2, I18N_0); } }); ``` This commit updates the logic to generate the following code instead: ``` defineComponent({ // ... consts: function() { var I18N_0; if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { var MSG_X = goog.getMsg(“…”); I18N_0 = MSG_X; } else { I18N_0 = $localize('...'); } return [ I18N_0 ]; }, template: function App_Template(rf, ctx) { i0.ɵɵi18n(2, 0); } }); ``` Also note that i18n template instructions now refer to the `consts` array using an index (similar to other template instructions). PR Close #38404 --- .../compliance/r3_view_compiler_i18n_spec.ts | 960 +++++++++++------- .../compiler/src/render3/view/compiler.ts | 17 +- .../compiler/src/render3/view/i18n/util.ts | 12 +- .../compiler/src/render3/view/template.ts | 52 +- packages/core/src/render3/definition.ts | 4 +- .../core/src/render3/instructions/i18n.ts | 17 +- .../core/src/render3/instructions/shared.ts | 5 +- .../core/src/render3/interfaces/definition.ts | 4 +- packages/core/src/render3/interfaces/node.ts | 15 + packages/core/test/render3/i18n_spec.ts | 114 +-- 10 files changed, 751 insertions(+), 449 deletions(-) diff --git a/packages/compiler-cli/test/compliance/r3_view_compiler_i18n_spec.ts b/packages/compiler-cli/test/compliance/r3_view_compiler_i18n_spec.ts index 9ccb7a2cbe..a3ed0a3a28 100644 --- a/packages/compiler-cli/test/compliance/r3_view_compiler_i18n_spec.ts +++ b/packages/compiler-cli/test/compliance/r3_view_compiler_i18n_spec.ts @@ -182,15 +182,17 @@ const verify = (input: string, output: string, extra: any = {}): void => { } }; -// Describes a simple key-value object. -type KVList = { - [key: string]: string -}; +// Describes message metadata object. +interface Meta { + desc?: string; + meaning?: string; + id?: string; +} // Describes placeholder type used in tests. Note: the type is an array (not an object), since it's // important to preserve the order of placeholders (so that we can compare it with generated // output). -type Placeholder = string[]; +type Placeholder = [string, string]; // Unique message id index that is needed to avoid different i18n vars with the same name to appear // in the i18n block while generating an output string (used to verify compiler-generated code). @@ -202,7 +204,7 @@ let msgIndex = 0; const quotedValue = (value: string) => value.startsWith('$') ? value : `"${value}"`; // Generates a string that represents expected Closure metadata output. -const i18nMsgClosureMeta = (meta?: KVList): string => { +const i18nMsgClosureMeta = (meta?: Meta): string => { if (!meta || !(meta.desc || meta.meaning)) return ''; return ` /** @@ -220,7 +222,7 @@ const i18nPlaceholdersToString = (placeholders: Placeholder[]): string => { }; // Generates a string that represents expected $localize metadata output. -const i18nMsgLocalizeMeta = (meta?: KVList): string => { +const i18nMsgLocalizeMeta = (meta?: Meta): string => { if (!meta) return ''; let localizeMeta = ''; if (meta.meaning) localizeMeta += `${meta.meaning}|`; @@ -244,7 +246,7 @@ const i18nMsgInsertLocalizePlaceholders = }; // Generates a string that represents expected i18n block content for simple message. -const i18nMsg = (message: string, placeholders: Placeholder[] = [], meta?: KVList) => { +const i18nMsg = (message: string, placeholders: Placeholder[] = [], meta?: Meta) => { const varName = `$I18N_${msgIndex++}$`; const closurePlaceholders = i18nPlaceholdersToString(placeholders); const locMessageWithPlaceholders = i18nMsgInsertLocalizePlaceholders(message, placeholders); @@ -263,7 +265,7 @@ const i18nMsg = (message: string, placeholders: Placeholder[] = [], meta?: KVLis // Generates a string that represents expected i18n block content for a message that requires // post-processing (thus includes `ɵɵi18nPostprocess` in generated code). const i18nMsgWithPostprocess = - (message: string, placeholders: Placeholder[] = [], meta?: KVList, + (message: string, placeholders: Placeholder[] = [], meta?: Meta, postprocessPlaceholders?: Placeholder[]) => { const varName = `$I18N_${msgIndex}$`; const ppPaceholders = @@ -275,10 +277,9 @@ const i18nMsgWithPostprocess = }; // Generates a string that represents expected i18n block content for an ICU. -const i18nIcuMsg = - (message: string, placeholders: string[][] = []) => { - return i18nMsgWithPostprocess(message, [], undefined, placeholders); - } +const i18nIcuMsg = (message: string, placeholders: Placeholder[] = []) => { + return i18nMsgWithPostprocess(message, [], undefined, placeholders); +}; describe('i18n support in the template compiler', () => { describe('element attributes', () => { @@ -303,7 +304,7 @@ describe('i18n support in the template compiler', () => { // Keeping this block as a raw string, since it checks escaping of special chars. const i18n_6 = String.raw` - var $I18N_23$; + var $i18n_23$; if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { /** * @desc [BACKUP_$` + @@ -311,10 +312,10 @@ describe('i18n support in the template compiler', () => { '`' + String.raw`desc */ const $MSG_EXTERNAL_idG$$APP_SPEC_TS_24$ = goog.getMsg("Title G"); - $I18N_23$ = $MSG_EXTERNAL_idG$$APP_SPEC_TS_24$; + $i18n_23$ = $MSG_EXTERNAL_idG$$APP_SPEC_TS_24$; } else { - $I18N_23$ = $localize \`:[BACKUP_$\{MESSAGE}_ID\:idH]\\\`desc@@idG:Title G\`; + $i18n_23$ = $localize \`:[BACKUP_$\{MESSAGE}_ID\:idH]\\\`desc@@idG:Title G\`; } `; @@ -334,53 +335,58 @@ describe('i18n support in the template compiler', () => { `; const output = String.raw` - ${i18n_0} - ${i18n_1} - const $_c5$ = ["title", $i18n_1$]; - ${i18n_2} - const $_c9$ = ["title", $i18n_2$]; - ${i18n_3} - const $_c13$ = ["title", $i18n_3$]; - ${i18n_4} - const $_c17$ = ["title", $i18n_4$]; - ${i18n_5} - const $_c21$ = ["title", $i18n_5$]; - ${i18n_6} - const $_c25$ = ["title", $i18n_6$]; - ${i18n_7} - … - consts: [[${AttributeMarker.I18n}, "title"]], + consts: function () { + ${i18n_0} + ${i18n_1} + ${i18n_2} + ${i18n_3} + ${i18n_4} + ${i18n_5} + ${i18n_6} + ${i18n_7} + return [ + $i18n_0$, + [${AttributeMarker.I18n}, "title"], + ["title", $i18n_1$], + ["title", $i18n_2$], + ["title", $i18n_3$], + ["title", $i18n_4$], + ["title", $i18n_5$], + ["title", $i18n_6$], + $i18n_7$ + ]; + }, template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); - $r3$.ɵɵi18n(1, $i18n_0$); + $r3$.ɵɵi18n(1, 0); $r3$.ɵɵelementEnd(); - $r3$.ɵɵelementStart(2, "div", 0); - $r3$.ɵɵi18nAttributes(3, $_c5$); + $r3$.ɵɵelementStart(2, "div", 1); + $r3$.ɵɵi18nAttributes(3, 2); $r3$.ɵɵtext(4, "Content B"); $r3$.ɵɵelementEnd(); - $r3$.ɵɵelementStart(5, "div", 0); - $r3$.ɵɵi18nAttributes(6, $_c9$); + $r3$.ɵɵelementStart(5, "div", 1); + $r3$.ɵɵi18nAttributes(6, 3); $r3$.ɵɵtext(7, "Content C"); $r3$.ɵɵelementEnd(); - $r3$.ɵɵelementStart(8, "div", 0); - $r3$.ɵɵi18nAttributes(9, $_c13$); + $r3$.ɵɵelementStart(8, "div", 1); + $r3$.ɵɵi18nAttributes(9, 4); $r3$.ɵɵtext(10, "Content D"); $r3$.ɵɵelementEnd(); - $r3$.ɵɵelementStart(11, "div", 0); - $r3$.ɵɵi18nAttributes(12, $_c17$); + $r3$.ɵɵelementStart(11, "div", 1); + $r3$.ɵɵi18nAttributes(12, 5); $r3$.ɵɵtext(13, "Content E"); $r3$.ɵɵelementEnd(); - $r3$.ɵɵelementStart(14, "div", 0); - $r3$.ɵɵi18nAttributes(15, $_c21$); + $r3$.ɵɵelementStart(14, "div", 1); + $r3$.ɵɵi18nAttributes(15, 6); $r3$.ɵɵtext(16, "Content F"); $r3$.ɵɵelementEnd(); - $r3$.ɵɵelementStart(17, "div", 0); - $r3$.ɵɵi18nAttributes(18, $_c25$); + $r3$.ɵɵelementStart(17, "div", 1); + $r3$.ɵɵi18nAttributes(18, 7); $r3$.ɵɵtext(19, "Content G"); $r3$.ɵɵelementEnd(); $r3$.ɵɵelementStart(20, "div"); - $r3$.ɵɵi18n(21, $i18n_7$); + $r3$.ɵɵi18n(21, 8); $r3$.ɵɵelementEnd(); } } @@ -396,14 +402,17 @@ describe('i18n support in the template compiler', () => { const i18n_0 = i18nMsg('Hello'); const output = String.raw` - ${i18n_0} - const $_c2$ = ["title", $i18n_0$]; - … - consts: [[${AttributeMarker.I18n}, "title"]], + consts: function () { + ${i18n_0} + return [ + [${AttributeMarker.I18n}, "title"], + ["title", $i18n_0$] + ]; + }, template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵtemplate(0, MyComponent_ng_template_0_Template, 0, 0, "ng-template", 0); - $r3$.ɵɵi18nAttributes(1, $_c2$); + $r3$.ɵɵi18nAttributes(1, 1); } } `; @@ -417,9 +426,8 @@ describe('i18n support in the template compiler', () => { `; const i18n_0 = i18nMsg('Hello'); + const output = String.raw` - ${i18n_0} - const $_c2$ = ["title", $i18n_0$]; function MyComponent_0_ng_template_0_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵtext(0, "Test"); @@ -428,11 +436,18 @@ describe('i18n support in the template compiler', () => { function MyComponent_0_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵtemplate(0, MyComponent_0_ng_template_0_Template, 1, 0, "ng-template", 1); - $r3$.ɵɵi18nAttributes(1, $_c2$); + $r3$.ɵɵi18nAttributes(1, 2); } } … - consts: [[${AttributeMarker.Template}, "ngIf"], [${AttributeMarker.I18n}, "title"]], + consts: function() { + ${i18n_0} + return [ + [${AttributeMarker.Template}, "ngIf"], + [${AttributeMarker.I18n}, "title"], + ["title", $i18n_0$] + ]; + }, template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵtemplate(0, MyComponent_0_Template, 2, 0, undefined, 0); @@ -454,14 +469,17 @@ describe('i18n support in the template compiler', () => { const i18n_0 = i18nMsg('Hello {$interpolation}', [['interpolation', String.raw`\uFFFD0\uFFFD`]]); const output = String.raw` - ${i18n_0} - const $_c2$ = ["title", $i18n_0$]; - … - consts: [[${AttributeMarker.Bindings}, "title"]], + consts: function() { + ${i18n_0} + return [ + [${AttributeMarker.Bindings}, "title"], + ["title", $i18n_0$] + ]; + }, template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵtemplate(0, MyComponent_ng_template_0_Template, 0, 0, "ng-template", 0); - $r3$.ɵɵi18nAttributes(1, $_c2$); + $r3$.ɵɵi18nAttributes(1, 1); } if (rf & 2) { $r3$.ɵɵi18nExp(ctx.name); @@ -481,13 +499,10 @@ describe('i18n support in the template compiler', () => { const i18n_0 = i18nMsg('Hello {$interpolation}', [['interpolation', String.raw`\uFFFD0\uFFFD`]]); const output = String.raw` - ${i18n_0} - const $_c2$ = ["title", $i18n_0$]; - … function MyComponent_0_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵtemplate(0, MyComponent_0_ng_template_0_Template, 0, 0, "ng-template", 1); - $r3$.ɵɵi18nAttributes(1, $_c2$); + $r3$.ɵɵi18nAttributes(1, 2); } if (rf & 2) { const $ctx_r2$ = $r3$.ɵɵnextContext(); @@ -496,7 +511,14 @@ describe('i18n support in the template compiler', () => { } } … - consts: [[${AttributeMarker.Template}, "ngIf"], [${AttributeMarker.Bindings}, "title"]], + consts: function() { + ${i18n_0} + return [ + [${AttributeMarker.Template}, "ngIf"], + [${AttributeMarker.Bindings}, "title"], + ["title", $i18n_0$] + ]; + }, template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵtemplate(0, MyComponent_0_Template, 2, 1, undefined, 0); @@ -536,7 +558,6 @@ describe('i18n support in the template compiler', () => { `; const output = ` - … consts: [[3, "title"]], template: function MyComponent_Template(rf, ctx) { if (rf & 1) { @@ -558,15 +579,19 @@ describe('i18n support in the template compiler', () => { `; const i18n_0 = i18nMsg('introduction', [], {meaning: 'm', desc: 'd'}); + const output = String.raw` - ${i18n_0} - const $_c1$ = ["title", $i18n_0$]; - … - consts: [["id", "static", ${AttributeMarker.I18n}, "title"]], + consts: function() { + ${i18n_0} + return [ + ["id", "static", ${AttributeMarker.I18n}, "title"], + ["title", $i18n_0$] + ]; + }, template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div", 0); - $r3$.ɵɵi18nAttributes(1, $_c1$); + $r3$.ɵɵi18nAttributes(1, 1); $r3$.ɵɵelementEnd(); } } @@ -606,35 +631,30 @@ describe('i18n support in the template compiler', () => { const i18n_4 = i18nMsg('{$interpolation}', [['interpolation', String.raw`\uFFFD0\uFFFD`]]); const output = String.raw` - ${i18n_0} - ${i18n_1} - ${i18n_2} - const $_c1$ = [ - "aria-roledescription", $i18n_0$, - "title", $i18n_1$, - "aria-label", $i18n_2$ - ]; - ${i18n_3} - ${i18n_4} - const $_c3$ = [ - "title", $i18n_3$, - "aria-roledescription", $i18n_4$ - ]; - … decls: 5, vars: 8, - consts: [["id", "dynamic-1", ${ - AttributeMarker - .I18n}, "aria-roledescription", "title", "aria-label"], ["id", "dynamic-2", ${ - AttributeMarker.I18n}, "title", "aria-roledescription"]], + consts: function() { + ${i18n_0} + ${i18n_1} + ${i18n_2} + ${i18n_3} + ${i18n_4} + return [ + ["id", "dynamic-1", ${AttributeMarker.I18n}, "aria-roledescription", + "title", "aria-label"], + ["aria-roledescription", $i18n_0$, "title", $i18n_1$, "aria-label", $i18n_2$], + ["id", "dynamic-2", ${AttributeMarker.I18n}, "title", "aria-roledescription"], + ["title", $i18n_3$, "aria-roledescription", $i18n_4$] + ]; + }, template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div", 0); $r3$.ɵɵpipe(1, "uppercase"); - $r3$.ɵɵi18nAttributes(2, $_c1$); + $r3$.ɵɵi18nAttributes(2, 1); $r3$.ɵɵelementEnd(); - $r3$.ɵɵelementStart(3, "div", 1); - $r3$.ɵɵi18nAttributes(4, $_c3$); + $r3$.ɵɵelementStart(3, "div", 2); + $r3$.ɵɵi18nAttributes(4, 3); $r3$.ɵɵelementEnd(); } if (rf & 2) { @@ -658,16 +678,20 @@ describe('i18n support in the template compiler', () => { const i18n_0 = i18nMsg( 'intro {$interpolation}', [['interpolation', String.raw`\uFFFD0\uFFFD`]], {meaning: 'm', desc: 'd'}); + const output = String.raw` - ${i18n_0} - const $_c3$ = ["title", $i18n_0$]; - … - consts: [[${AttributeMarker.I18n}, "title"]], + consts: function() { + ${i18n_0} + return [ + [${AttributeMarker.I18n}, "title"], + ["title", $i18n_0$] + ]; + }, template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div", 0); $r3$.ɵɵpipe(1, "uppercase"); - $r3$.ɵɵi18nAttributes(2, $_c3$); + $r3$.ɵɵi18nAttributes(2, 1); $r3$.ɵɵelementEnd(); } if (rf & 2) { @@ -691,14 +715,12 @@ describe('i18n support in the template compiler', () => { {meaning: 'm', desc: 'd'}); const output = String.raw` - ${i18n_0} - const $_c2$ = ["title", $i18n_0$]; function MyComponent_div_0_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); $r3$.ɵɵelementStart(1, "div", 1); $r3$.ɵɵpipe(2, "uppercase"); - $r3$.ɵɵi18nAttributes(3, $_c2$); + $r3$.ɵɵi18nAttributes(3, 2); $r3$.ɵɵelementEnd(); $r3$.ɵɵelementEnd(); } @@ -712,8 +734,14 @@ describe('i18n support in the template compiler', () => { … decls: 1, vars: 1, - consts: [[${AttributeMarker.Template}, "ngFor", "ngForOf"], [${ - AttributeMarker.I18n}, "title"]], + consts: function() { + ${i18n_0} + return [ + [${AttributeMarker.Template}, "ngFor", "ngForOf"], + [${AttributeMarker.I18n}, "title"], + ["title", $i18n_0$] + ]; + }, template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵtemplate(0, MyComponent_div_0_Template, 4, 3, "div", 0); @@ -736,16 +764,19 @@ describe('i18n support in the template compiler', () => { i18nMsg('{$interpolation} title', [['interpolation', String.raw`\uFFFD0\uFFFD`]]); const output = String.raw` - ${i18n_0} - const $_c3$ = ["title", $i18n_0$]; - … decls: 2, vars: 1, - consts: [[${AttributeMarker.I18n}, "title"]], + consts: function() { + ${i18n_0} + return [ + [${AttributeMarker.I18n}, "title"], + ["title", $i18n_0$] + ]; + }, template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div", 0); - $r3$.ɵɵi18nAttributes(1, $_c3$); + $r3$.ɵɵi18nAttributes(1, 1); $r3$.ɵɵelementEnd(); } if (rf & 2) { @@ -790,35 +821,30 @@ describe('i18n support in the template compiler', () => { const i18n_4 = i18nMsg('{$interpolation}', [['interpolation', String.raw`\uFFFD0\uFFFD`]]); const output = String.raw` - ${i18n_0} - ${i18n_1} - ${i18n_2} - const $_c1$ = [ - "aria-roledescription", $i18n_0$, - "title", $i18n_1$, - "aria-label", $i18n_2$ - ]; - ${i18n_3} - ${i18n_4} - const $_c3$ = [ - "title", $i18n_3$, - "aria-roledescription", $i18n_4$ - ]; - … decls: 5, vars: 8, - consts: [[ - "id", "dynamic-1", - ${AttributeMarker.I18n}, "aria-roledescription", "title", "aria-label" - ], ["id", "dynamic-2", ${AttributeMarker.I18n}, "title", "aria-roledescription"]], + consts: function() { + ${i18n_0} + ${i18n_1} + ${i18n_2} + ${i18n_3} + ${i18n_4} + return [ + ["id", "dynamic-1", ${AttributeMarker.I18n}, "aria-roledescription", + "title", "aria-label"], + ["aria-roledescription", $i18n_0$, "title", $i18n_1$, "aria-label", $i18n_2$], + ["id", "dynamic-2", ${AttributeMarker.I18n}, "title", "aria-roledescription"], + ["title", $i18n_3$, "aria-roledescription", $i18n_4$] + ]; + }, template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div", 0); $r3$.ɵɵpipe(1, "uppercase"); - $r3$.ɵɵi18nAttributes(2, $_c1$); + $r3$.ɵɵi18nAttributes(2, 1); $r3$.ɵɵelementEnd(); - $r3$.ɵɵelementStart(3, "div", 1); - $r3$.ɵɵi18nAttributes(4, $_c3$); + $r3$.ɵɵelementStart(3, "div", 2); + $r3$.ɵɵi18nAttributes(4, 3); $r3$.ɵɵelementEnd(); } if (rf & 2) { @@ -846,14 +872,12 @@ describe('i18n support in the template compiler', () => { {meaning: 'm', desc: 'd'}); const output = String.raw` - ${i18n_0} - const $_c4$ = ["title", $i18n_0$]; function MyComponent_div_0_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); $r3$.ɵɵelementStart(1, "div", 1); $r3$.ɵɵpipe(2, "uppercase"); - $r3$.ɵɵi18nAttributes(3, $_c4$); + $r3$.ɵɵi18nAttributes(3, 2); $r3$.ɵɵelementEnd(); $r3$.ɵɵelementEnd(); } @@ -867,8 +891,14 @@ describe('i18n support in the template compiler', () => { … decls: 1, vars: 1, - consts: [[${AttributeMarker.Template}, "ngFor", "ngForOf"], [${ - AttributeMarker.I18n}, "title"]], + consts: function() { + ${i18n_0} + return [ + [${AttributeMarker.Template}, "ngFor", "ngForOf"], + [${AttributeMarker.I18n}, "title"], + ["title", $i18n_0$] + ]; + }, template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵtemplate(0, MyComponent_div_0_Template, 4, 3, "div", 0); @@ -891,16 +921,20 @@ describe('i18n support in the template compiler', () => { const i18n_1 = i18nMsg('Some content'); const output = String.raw` - ${i18n_0} - const $_c1$ = ["title", $i18n_0$]; - ${i18n_1} - … - consts: [[${AttributeMarker.I18n}, "title"]], + consts: function() { + ${i18n_0} + ${i18n_1} + return [ + [${AttributeMarker.I18n}, "title"], + ["title", $i18n_0$], + $i18n_1$ + ]; + }, template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div", 0); - $r3$.ɵɵi18nAttributes(1, $_c1$); - $r3$.ɵɵi18n(2, $i18n_1$); + $r3$.ɵɵi18nAttributes(1, 1); + $r3$.ɵɵi18n(2, 2); $r3$.ɵɵelementEnd(); } } @@ -925,7 +959,7 @@ describe('i18n support in the template compiler', () => { else { $I18N_0$ = $localize \`:@@ID.WITH.INVALID.CHARS:Element title\`; } - const $_c1$ = ["title", $I18N_0$]; + … var $I18N_2$; if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { const $MSG_EXTERNAL_ID_WITH_INVALID_CHARS_2$$APP_SPEC_TS_4$ = goog.getMsg(" Some content "); @@ -934,7 +968,6 @@ describe('i18n support in the template compiler', () => { else { $I18N_2$ = $localize \`:@@ID.WITH.INVALID.CHARS.2: Some content \`; } - … `; const exceptions = { @@ -1030,26 +1063,32 @@ describe('i18n support in the template compiler', () => { const i18n_2 = i18nMsg('My i18n block #3'); const output = String.raw` - ${i18n_0} - ${i18n_1} - ${i18n_2} - … + consts: function() { + ${i18n_0} + ${i18n_1} + ${i18n_2} + return [ + $i18n_0$, + $i18n_1$, + $i18n_2$ + ]; + }, template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); - $r3$.ɵɵi18n(1, $i18n_0$); + $r3$.ɵɵi18n(1, 0); $r3$.ɵɵelementEnd(); $r3$.ɵɵelementStart(2, "div"); $r3$.ɵɵtext(3, "My non-i18n block #1"); $r3$.ɵɵelementEnd(); $r3$.ɵɵelementStart(4, "div"); - $r3$.ɵɵi18n(5, $i18n_1$); + $r3$.ɵɵi18n(5, 1); $r3$.ɵɵelementEnd(); $r3$.ɵɵelementStart(6, "div"); $r3$.ɵɵtext(7, "My non-i18n block #2"); $r3$.ɵɵelementEnd(); $r3$.ɵɵelementStart(8, "div"); - $r3$.ɵɵi18n(9, $i18n_2$); + $r3$.ɵɵi18n(9, 2); $r3$.ɵɵelementEnd(); } } @@ -1068,7 +1107,7 @@ describe('i18n support in the template compiler', () => { // Keeping raw content (avoiding `i18nMsg`) to illustrate how named interpolations are // generated. - const output = String.raw` + const i18n_0 = String.raw` var $I18N_0$; if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { const $MSG_EXTERNAL_7597881511811528589$$APP_SPEC_TS_0$ = goog.getMsg(" Named interpolation: {$phA} Named interpolation with spaces: {$phB} ", { @@ -1082,13 +1121,21 @@ describe('i18n support in the template compiler', () => { String.raw`{"\uFFFD0\uFFFD"}:PH_A: Named interpolation with spaces: $` + String.raw`{"\uFFFD1\uFFFD"}:PH_B: \`; } - … + `; + + const output = String.raw` decls: 2, vars: 2, + consts: function() { + ${i18n_0} + return [ + $i18n_0$ + ]; + }, template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); - $r3$.ɵɵi18n(1, $I18N_0$); + $r3$.ɵɵi18n(1, 0); $r3$.ɵɵelementEnd(); } if (rf & 2) { @@ -1110,12 +1157,16 @@ describe('i18n support in the template compiler', () => { const i18n_0 = i18nMsg('{$interpolation}', [['interpolation', String.raw`\uFFFD0\uFFFD`]]); const output = String.raw` - ${i18n_0} - … + consts: function() { + ${i18n_0} + return [ + $i18n_0$ + ]; + }, template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); - $r3$.ɵɵi18n(1, $i18n_0$); + $r3$.ɵɵi18n(1, 0); $r3$.ɵɵelementEnd(); } if (rf & 2) { @@ -1144,12 +1195,16 @@ describe('i18n support in the template compiler', () => { ]); const output = String.raw` - ${i18n_0} - … + consts: function() { + ${i18n_0} + return [ + $i18n_0$ + ]; + }, template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); - $r3$.ɵɵi18n(1, $i18n_0$); + $r3$.ɵɵi18n(1, 0); $r3$.ɵɵpipe(2, "async"); $r3$.ɵɵelementEnd(); } @@ -1181,23 +1236,29 @@ describe('i18n support in the template compiler', () => { 'My i18n block #{$interpolation}', [['interpolation', String.raw`\uFFFD0\uFFFD`]]); const output = String.raw` - ${i18n_0} - ${i18n_1} - ${i18n_2} - … decls: 7, vars: 5, + consts: function() { + ${i18n_0} + ${i18n_1} + ${i18n_2} + return [ + $i18n_0$, + $i18n_1$, + $i18n_2$ + ]; + }, template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); - $r3$.ɵɵi18n(1, $i18n_0$); + $r3$.ɵɵi18n(1, 0); $r3$.ɵɵelementEnd(); $r3$.ɵɵelementStart(2, "div"); - $r3$.ɵɵi18n(3, $i18n_1$); + $r3$.ɵɵi18n(3, 1); $r3$.ɵɵpipe(4, "uppercase"); $r3$.ɵɵelementEnd(); $r3$.ɵɵelementStart(5, "div"); - $r3$.ɵɵi18n(6, $i18n_2$); + $r3$.ɵɵi18n(6, 2); $r3$.ɵɵelementEnd(); } if (rf & 2) { @@ -1254,20 +1315,25 @@ describe('i18n support in the template compiler', () => { ]); const output = String.raw` - ${i18n_0} - ${i18n_1} - … decls: 9, vars: 5, + consts: function() { + ${i18n_0} + ${i18n_1} + return [ + $i18n_0$, + $i18n_1$ + ]; + }, template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); - $r3$.ɵɵi18nStart(1, $i18n_0$); + $r3$.ɵɵi18nStart(1, 0); $r3$.ɵɵelement(2, "span"); $r3$.ɵɵi18nEnd(); $r3$.ɵɵelementEnd(); $r3$.ɵɵelementStart(3, "div"); - $r3$.ɵɵi18nStart(4, $i18n_1$); + $r3$.ɵɵi18nStart(4, 1); $r3$.ɵɵpipe(5, "uppercase"); $r3$.ɵɵelementStart(6, "div"); $r3$.ɵɵelementStart(7, "div"); @@ -1328,30 +1394,35 @@ describe('i18n support in the template compiler', () => { ]); const output = String.raw` - ${i18n_0} - const $_c4$ = ["title", $i18n_0$]; - ${i18n_1} - ${i18n_2} - const $_c9$ = ["title", $i18n_2$]; - ${i18n_3} - … decls: 9, vars: 7, - consts: [[${AttributeMarker.I18n}, "title"]], + consts: function() { + ${i18n_0} + ${i18n_1} + ${i18n_2} + ${i18n_3} + return [ + $i18n_0$, + [${AttributeMarker.I18n}, "title"], + ["title", $i18n_1$], + $i18n_2$, + ["title", $i18n_3$] + ]; + }, template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); - $r3$.ɵɵi18nStart(1, $i18n_1$); - $r3$.ɵɵelementStart(2, "span", 0); - $r3$.ɵɵi18nAttributes(3, $_c4$); + $r3$.ɵɵi18nStart(1, 0); + $r3$.ɵɵelementStart(2, "span", 1); + $r3$.ɵɵi18nAttributes(3, 2); $r3$.ɵɵelementEnd(); $r3$.ɵɵi18nEnd(); $r3$.ɵɵelementEnd(); $r3$.ɵɵelementStart(4, "div"); - $r3$.ɵɵi18nStart(5, $i18n_3$); + $r3$.ɵɵi18nStart(5, 3); $r3$.ɵɵpipe(6, "uppercase"); - $r3$.ɵɵelementStart(7, "span", 0); - $r3$.ɵɵi18nAttributes(8, $_c9$); + $r3$.ɵɵelementStart(7, "span", 1); + $r3$.ɵɵi18nAttributes(8, 4); $r3$.ɵɵelementEnd(); $r3$.ɵɵi18nEnd(); $r3$.ɵɵelementEnd(); @@ -1401,13 +1472,11 @@ describe('i18n support in the template compiler', () => { ]); const output = String.raw` - ${i18n_0} - … function MyComponent_div_2_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); $r3$.ɵɵelementStart(1, "div"); - $r3$.ɵɵi18nStart(2, $i18n_0$); + $r3$.ɵɵi18nStart(2, 1); $r3$.ɵɵelement(3, "div"); $r3$.ɵɵpipe(4, "uppercase"); $r3$.ɵɵi18nEnd(); @@ -1424,7 +1493,13 @@ describe('i18n support in the template compiler', () => { … decls: 3, vars: 1, - consts: [[${AttributeMarker.Template}, "ngIf"]], + consts: function() { + ${i18n_0} + return [ + [${AttributeMarker.Template}, "ngIf"], + $i18n_0$ + ]; + }, template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); @@ -1458,12 +1533,11 @@ describe('i18n support in the template compiler', () => { $r3$.ɵɵelement(0, "img", 0); } } - ${i18n_0} - const $_c4$ = ["title", $i18n_0$]; + … function MyComponent_img_2_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "img", 3); - $r3$.ɵɵi18nAttributes(1, $_c4$); + $r3$.ɵɵi18nAttributes(1, 4); $r3$.ɵɵelementEnd(); } if (rf & 2) { @@ -1475,11 +1549,17 @@ describe('i18n support in the template compiler', () => { … decls: 3, vars: 2, - consts: [["src", "logo.png"], ["src", "logo.png", ${ - AttributeMarker.Template}, "ngIf"], ["src", "logo.png", ${ - AttributeMarker.Bindings}, "title", ${ - AttributeMarker.Template}, "ngIf"], ["src", "logo.png", ${ - AttributeMarker.I18n}, "title"]], + consts: function() { + ${i18n_0} + return [ + ["src", "logo.png"], + ["src", "logo.png", ${AttributeMarker.Template}, "ngIf"], + ["src", "logo.png", ${AttributeMarker.Bindings}, "title", + ${AttributeMarker.Template}, "ngIf"], + ["src", "logo.png", ${AttributeMarker.I18n}, "title"], + ["title", $i18n_0$] + ]; + }, template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelement(0, "img", 0); @@ -1546,7 +1626,7 @@ describe('i18n support in the template compiler', () => { const output = String.raw` function MyComponent_div_2_div_4_Template(rf, ctx) { if (rf & 1) { - $r3$.ɵɵi18nStart(0, $I18N_0$, 2); + $r3$.ɵɵi18nStart(0, 0, 2); $r3$.ɵɵelementStart(1, "div"); $r3$.ɵɵelement(2, "div"); $r3$.ɵɵelementEnd(); @@ -1561,11 +1641,11 @@ describe('i18n support in the template compiler', () => { } function MyComponent_div_2_Template(rf, ctx) { if (rf & 1) { - $r3$.ɵɵi18nStart(0, $I18N_0$, 1); + $r3$.ɵɵi18nStart(0, 0, 1); $r3$.ɵɵelementStart(1, "div"); $r3$.ɵɵelementStart(2, "div"); $r3$.ɵɵpipe(3, "uppercase"); - $r3$.ɵɵtemplate(4, MyComponent_div_2_div_4_Template, 3, 2, "div", 0); + $r3$.ɵɵtemplate(4, MyComponent_div_2_div_4_Template, 3, 2, "div", 1); $r3$.ɵɵelementEnd(); $r3$.ɵɵelementEnd(); $r3$.ɵɵi18nEnd(); @@ -1578,10 +1658,10 @@ describe('i18n support in the template compiler', () => { $r3$.ɵɵi18nApply(0); } } - ${i18n_0} + … function MyComponent_div_3_Template(rf, ctx) { if (rf & 1) { - $r3$.ɵɵi18nStart(0, $I18N_0$, 3); + $r3$.ɵɵi18nStart(0, 0, 3); $r3$.ɵɵelementStart(1, "div"); $r3$.ɵɵelement(2, "div"); $r3$.ɵɵpipe(3, "uppercase"); @@ -1598,13 +1678,19 @@ describe('i18n support in the template compiler', () => { … decls: 4, vars: 2, - consts: [[${AttributeMarker.Template}, "ngIf"]], + consts: function() { + ${i18n_0} + return [ + $i18n_0$, + [${AttributeMarker.Template}, "ngIf"] + ]; + }, template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); - $r3$.ɵɵi18nStart(1, $I18N_0$); - $r3$.ɵɵtemplate(2, MyComponent_div_2_Template, 5, 5, "div", 0); - $r3$.ɵɵtemplate(3, MyComponent_div_3_Template, 4, 4, "div", 0); + $r3$.ɵɵi18nStart(1, 0); + $r3$.ɵɵtemplate(2, MyComponent_div_2_Template, 5, 5, "div", 1); + $r3$.ɵɵtemplate(3, MyComponent_div_3_Template, 4, 4, "div", 1); $r3$.ɵɵi18nEnd(); $r3$.ɵɵelementEnd(); } @@ -1631,12 +1717,10 @@ describe('i18n support in the template compiler', () => { ]); const output = String.raw` - ${i18n_0} - … function MyComponent_div_0_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); - $r3$.ɵɵi18nStart(1, $i18n_0$); + $r3$.ɵɵi18nStart(1, 1); $r3$.ɵɵelement(2, "span"); $r3$.ɵɵi18nEnd(); $r3$.ɵɵelementEnd(); @@ -1651,7 +1735,13 @@ describe('i18n support in the template compiler', () => { … decls: 1, vars: 1, - consts: [[${AttributeMarker.Template}, "ngIf"]], + consts: function() { + ${i18n_0} + return [ + [${AttributeMarker.Template}, "ngIf"], + $i18n_0$ + ]; + }, template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵtemplate(0, MyComponent_div_0_Template, 3, 1, "div", 0); @@ -1673,14 +1763,18 @@ describe('i18n support in the template compiler', () => { const i18n_0 = i18nMsg('Hello'); const output = String.raw` - ${i18n_0} - … - consts: [[${AttributeMarker.Bindings}, "click"]], + consts: function() { + ${i18n_0} + return [ + [${AttributeMarker.Bindings}, "click"], + $i18n_0$ + ]; + }, template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div", 0); $r3$.ɵɵlistener("click", function MyComponent_Template_div_click_0_listener() { return ctx.onClick(); }); - $r3$.ɵɵi18n(1, $i18n_0$); + $r3$.ɵɵi18n(1, 1); $r3$.ɵɵelementEnd(); } } @@ -1699,12 +1793,16 @@ describe('i18n support in the template compiler', () => { const i18n_0 = i18nMsg('My i18n block #1'); const output = String.raw` - ${i18n_0} - … + consts: function() { + ${i18n_0} + return [ + $i18n_0$ + ]; + }, template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); - $r3$.ɵɵi18n(1, $i18n_0$); + $r3$.ɵɵi18n(1, 0); $r3$.ɵɵelementEnd(); } } @@ -1723,14 +1821,18 @@ describe('i18n support in the template compiler', () => { [['VAR_SELECT', String.raw`\uFFFD0\uFFFD`]]); const output = String.raw` - ${i18n_0} - … decls: 2, vars: 1, + consts: function() { + ${i18n_0} + return [ + $i18n_0$ + ]; + }, template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); - $r3$.ɵɵi18n(1, $I18N_0$); + $r3$.ɵɵi18n(1, 0); $r3$.ɵɵelementEnd(); } if (rf & 2) { @@ -1754,19 +1856,25 @@ describe('i18n support in the template compiler', () => { const i18n_1 = i18nMsg('My i18n block #1'); const output = String.raw` - ${i18n_0} - ${i18n_1} function MyComponent_ng_template_0_Template(rf, ctx) { if (rf & 1) { - $r3$.ɵɵi18n(0, $i18n_1$); + $r3$.ɵɵi18n(0, 1); } } … + consts: function() { + ${i18n_0} + ${i18n_1} + return [ + $i18n_0$, + $i18n_1$ + ]; + }, template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵtemplate(0, MyComponent_ng_template_0_Template, 1, 0, "ng-template"); $r3$.ɵɵelementContainerStart(1); - $r3$.ɵɵi18n(2, $i18n_0$); + $r3$.ɵɵi18n(2, 0); $r3$.ɵɵelementContainerEnd(); } } @@ -1785,20 +1893,25 @@ describe('i18n support in the template compiler', () => { const i18n_1 = i18nMsg('Text #2'); const output = String.raw` - ${i18n_0} - ${i18n_1} - … decls: 4, vars: 0, - consts: [[${AttributeMarker.Classes}, "myClass"], [${ - AttributeMarker.Styles}, "padding", "10px"]], + consts: function() { + ${i18n_0} + ${i18n_1} + return [ + [${AttributeMarker.Classes}, "myClass"], + $i18n_0$, + [${AttributeMarker.Styles}, "padding", "10px"], + $i18n_1$ + ]; + }, template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "span", 0); - $r3$.ɵɵi18n(1, $i18n_0$); + $r3$.ɵɵi18n(1, 1); $r3$.ɵɵelementEnd(); - $r3$.ɵɵelementStart(2, "span", 1); - $r3$.ɵɵi18n(3, $i18n_1$); + $r3$.ɵɵelementStart(2, "span", 2); + $r3$.ɵɵi18n(3, 3); $r3$.ɵɵelementEnd(); } } @@ -1818,14 +1931,18 @@ describe('i18n support in the template compiler', () => { i18nMsg('Some content: {$interpolation}', [['interpolation', String.raw`\uFFFD0\uFFFD`]]); const output = String.raw` - ${i18n_0} - … decls: 3, vars: 3, + consts: function() { + ${i18n_0} + return [ + $i18n_0$ + ]; + }, template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementContainerStart(0); - $r3$.ɵɵi18n(1, $i18n_0$); + $r3$.ɵɵi18n(1, 0); $r3$.ɵɵpipe(2, "uppercase"); $r3$.ɵɵelementContainerEnd(); } @@ -1849,10 +1966,9 @@ describe('i18n support in the template compiler', () => { i18nMsg('Some content: {$interpolation}', [['interpolation', String.raw`\uFFFD0\uFFFD`]]); const output = String.raw` - ${i18n_0} function MyComponent_ng_template_0_Template(rf, ctx) { if (rf & 1) { - $r3$.ɵɵi18n(0, $i18n_0$); + $r3$.ɵɵi18n(0, 0); $r3$.ɵɵpipe(1, "uppercase"); } if (rf & 2) { const $ctx_r0$ = $r3$.ɵɵnextContext(); @@ -1864,6 +1980,12 @@ describe('i18n support in the template compiler', () => { … decls: 1, vars: 0, + consts: function() { + ${i18n_0} + return [ + $i18n_0$ + ]; + }, template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵtemplate(0, MyComponent_ng_template_0_Template, 2, 3, "ng-template"); @@ -1894,10 +2016,9 @@ describe('i18n support in the template compiler', () => { ]); const output = String.raw` - ${i18n_0} function MyComponent_ng_template_2_Template(rf, ctx) { if (rf & 1) { - $r3$.ɵɵi18n(0, $I18N_0$, 1); + $r3$.ɵɵi18n(0, 0, 1); $r3$.ɵɵpipe(1, "uppercase"); } if (rf & 2) { @@ -1910,10 +2031,16 @@ describe('i18n support in the template compiler', () => { … decls: 5, vars: 3, + consts: function() { + ${i18n_0} + return [ + $i18n_0$ + ]; + }, template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); - $r3$.ɵɵi18nStart(1, $i18n_0$); + $r3$.ɵɵi18nStart(1, 0); $r3$.ɵɵtemplate(2, MyComponent_ng_template_2_Template, 2, 3, "ng-template"); $r3$.ɵɵelementContainer(3); $r3$.ɵɵpipe(4, "uppercase"); @@ -1945,11 +2072,9 @@ describe('i18n support in the template compiler', () => { [['VAR_SELECT', String.raw`\uFFFD0\uFFFD`]]); const output = String.raw` - ${i18n_0} - ${i18n_1} function MyComponent_ng_template_0_Template(rf, ctx) { if (rf & 1) { - $r3$.ɵɵi18n(0, $i18n_1$); + $r3$.ɵɵi18n(0, 1); } if (rf & 2) { const $ctx_r0$ = $r3$.ɵɵnextContext(); @@ -1960,11 +2085,19 @@ describe('i18n support in the template compiler', () => { … decls: 3, vars: 1, + consts: function() { + ${i18n_0} + ${i18n_1} + return [ + $i18n_0$, + $i18n_1$ + ]; + }, template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵtemplate(0, MyComponent_ng_template_0_Template, 1, 1, "ng-template"); $r3$.ɵɵelementContainerStart(1); - $r3$.ɵɵi18n(2, $i18n_0$); + $r3$.ɵɵi18n(2, 0); $r3$.ɵɵelementContainerEnd(); } if (rf & 2) { @@ -2011,7 +2144,7 @@ describe('i18n support in the template compiler', () => { const output = String.raw` function MyComponent_ng_template_2_ng_template_2_ng_template_1_Template(rf, ctx) { if (rf & 1) { - $r3$.ɵɵi18n(0, $i18n_0$, 3); + $r3$.ɵɵi18n(0, 0, 3); } if (rf & 2) { const $ctx_r2$ = $r3$.ɵɵnextContext(3); @@ -2021,7 +2154,7 @@ describe('i18n support in the template compiler', () => { } function MyComponent_ng_template_2_ng_template_2_Template(rf, ctx) { if (rf & 1) { - $r3$.ɵɵi18nStart(0, $i18n_0$, 2); + $r3$.ɵɵi18nStart(0, 0, 2); $r3$.ɵɵtemplate(1, MyComponent_ng_template_2_ng_template_2_ng_template_1_Template, 1, 1, "ng-template"); $r3$.ɵɵi18nEnd(); } @@ -2032,10 +2165,10 @@ describe('i18n support in the template compiler', () => { $r3$.ɵɵi18nApply(0); } } - ${i18n_0} + … function MyComponent_ng_template_2_Template(rf, ctx) { if (rf & 1) { - $r3$.ɵɵi18nStart(0, $i18n_0$, 1); + $r3$.ɵɵi18nStart(0, 0, 1); $r3$.ɵɵpipe(1, "uppercase"); $r3$.ɵɵtemplate(2, MyComponent_ng_template_2_ng_template_2_Template, 2, 1, "ng-template"); $r3$.ɵɵi18nEnd(); @@ -2050,10 +2183,16 @@ describe('i18n support in the template compiler', () => { … decls: 3, vars: 0, + consts: function() { + ${i18n_0} + return [ + $i18n_0$ + ]; + }, template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); - $r3$.ɵɵi18nStart(1, $i18n_0$); + $r3$.ɵɵi18nStart(1, 0); $r3$.ɵɵtemplate(2, MyComponent_ng_template_2_Template, 3, 3, "ng-template"); $r3$.ɵɵi18nEnd(); $r3$.ɵɵelementEnd(); @@ -2078,11 +2217,9 @@ describe('i18n support in the template compiler', () => { [['VAR_SELECT', String.raw`\uFFFD0\uFFFD`]]); const output = String.raw` - ${i18n_0} - ${i18n_1} function MyComponent_ng_template_2_Template(rf, ctx) { if (rf & 1) { - $r3$.ɵɵi18n(0, $I18N_1$); + $r3$.ɵɵi18n(0, 1); } if (rf & 2) { const $ctx_r0$ = $r3$.ɵɵnextContext(); @@ -2093,10 +2230,18 @@ describe('i18n support in the template compiler', () => { … decls: 3, vars: 1, + consts: function() { + ${i18n_0} + ${i18n_1} + return [ + $i18n_0$, + $i18n_1$ + ]; + }, template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementContainerStart(0); - $r3$.ɵɵi18n(1, $i18n_0$); + $r3$.ɵɵi18n(1, 0); $r3$.ɵɵelementContainerEnd(); $r3$.ɵɵtemplate(2, MyComponent_ng_template_2_Template, 1, 1, "ng-template"); } @@ -2127,22 +2272,28 @@ describe('i18n support in the template compiler', () => { '{$tagImg} is my logo #2 ', [['tagImg', String.raw`\uFFFD#1\uFFFD\uFFFD/#1\uFFFD`]]); const output = String.raw` - ${i18n_0} - ${i18n_1} function MyComponent_ng_template_3_Template(rf, ctx) { if (rf & 1) { - $r3$.ɵɵi18nStart(0, $i18n_1$); - $r3$.ɵɵelement(1, "img", 0); + $r3$.ɵɵi18nStart(0, 2); + $r3$.ɵɵelement(1, "img", 1); $r3$.ɵɵi18nEnd(); } } … - consts: [["src", "logo.png", "title", "Logo"]], + consts: function() { + ${i18n_0} + ${i18n_1} + return [ + $i18n_0$, + ["src", "logo.png", "title", "Logo"], + $i18n_1$ + ]; + }, template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementContainerStart(0); - $r3$.ɵɵi18nStart(1, $i18n_0$); - $r3$.ɵɵelement(2, "img", 0); + $r3$.ɵɵi18nStart(1, 0); + $r3$.ɵɵelement(2, "img", 1); $r3$.ɵɵi18nEnd(); $r3$.ɵɵelementContainerEnd(); $r3$.ɵɵtemplate(3, MyComponent_ng_template_3_Template, 2, 0, "ng-template"); @@ -2202,14 +2353,18 @@ describe('i18n support in the template compiler', () => { ]); const output = String.raw` - ${i18n_0} - … decls: 3, vars: 0, + consts: function() { + ${i18n_0} + return [ + $i18n_0$ + ]; + }, template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); - $r3$.ɵɵi18nStart(1, $i18n_0$); + $r3$.ɵɵi18nStart(1, 0); $r3$.ɵɵelementContainer(2); $r3$.ɵɵi18nEnd(); $r3$.ɵɵelementEnd(); @@ -2238,14 +2393,18 @@ describe('i18n support in the template compiler', () => { ]); const output = String.raw` - ${i18n_0} - … decls: 4, vars: 0, + consts: function() { + ${i18n_0} + return [ + $i18n_0$ + ]; + }, template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); - $r3$.ɵɵi18nStart(1, I18N_0); + $r3$.ɵɵi18nStart(1, 0); $r3$.ɵɵelementContainerStart(2); $r3$.ɵɵelement(3, "strong"); $r3$.ɵɵelementContainerEnd(); @@ -2270,10 +2429,9 @@ describe('i18n support in the template compiler', () => { const i18n_1 = i18nMsg('Content B'); const output = String.raw` - ${i18n_0} function MyComponent_0_ng_template_0_Template(rf, ctx) { if (rf & 1) { - $r3$.ɵɵi18n(0, $i18n_0$); + $r3$.ɵɵi18n(0, 1); } } function MyComponent_0_Template(rf, ctx) { @@ -2281,18 +2439,26 @@ describe('i18n support in the template compiler', () => { $r3$.ɵɵtemplate(0, MyComponent_0_ng_template_0_Template, 1, 0, "ng-template"); } } - ${i18n_1} + … function MyComponent_ng_container_1_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementContainerStart(0); - $r3$.ɵɵi18n(1, $i18n_1$); + $r3$.ɵɵi18n(1, 2); $r3$.ɵɵelementContainerEnd(); } } … decls: 2, vars: 2, - consts: [[4, "ngIf"]], + consts: function() { + ${i18n_0} + ${i18n_1} + return [ + [${AttributeMarker.Template}, "ngIf"], + $i18n_0$, + $i18n_1$ + ]; + }, template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵtemplate(0, MyComponent_0_Template, 1, 0, undefined, 0); @@ -2320,7 +2486,7 @@ describe('i18n support in the template compiler', () => { // Keeping raw content (avoiding `i18nMsg`) to illustrate message layout // in case of whitespace preserving mode. - const output = String.raw` + const i18n_0 = String.raw` var $I18N_0$; if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { const $MSG_EXTERNAL_963542717423364282$$APP_SPEC_TS_0$ = goog.getMsg("\n Some text\n {$startTagSpan}Text inside span{$closeTagSpan}\n ", { @@ -2337,12 +2503,20 @@ describe('i18n support in the template compiler', () => { String.raw`{"\uFFFD/#3\uFFFD"}:CLOSE_TAG_SPAN: \`; } - … + `; + + const output = String.raw` + consts: function() { + ${i18n_0} + return [ + $i18n_0$ + ]; + }, template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵtext(0, "\n "); $r3$.ɵɵelementStart(1, "div"); - $r3$.ɵɵi18nStart(2, $I18N_0$); + $r3$.ɵɵi18nStart(2, 0); $r3$.ɵɵelement(3, "span"); $r3$.ɵɵi18nEnd(); $r3$.ɵɵelementEnd(); @@ -2366,14 +2540,18 @@ describe('i18n support in the template compiler', () => { [['VAR_SELECT', String.raw`\uFFFD0\uFFFD`]]); const output = String.raw` - ${i18n_0} - … decls: 2, vars: 1, + consts: function() { + ${i18n_0} + return [ + $i18n_0$ + ]; + }, template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); - $r3$.ɵɵi18n(1, $I18N_0$); + $r3$.ɵɵi18n(1, 0); $r3$.ɵɵelementEnd(); } if (rf & 2) { @@ -2419,13 +2597,17 @@ describe('i18n support in the template compiler', () => { [['VAR_SELECT', String.raw`\uFFFD0\uFFFD`]]); const output = String.raw` - ${i18n_0} - … decls: 1, vars: 1, + consts: function() { + ${i18n_0} + return [ + $i18n_0$ + ]; + }, template: function MyComponent_Template(rf, ctx) { if (rf & 1) { - $r3$.ɵɵi18n(0, $i18n_0$); + $r3$.ɵɵi18n(0, 0); } if (rf & 2) { $r3$.ɵɵi18nExp(ctx.age); @@ -2460,13 +2642,11 @@ describe('i18n support in the template compiler', () => { ]); const output = String.raw` - ${i18n_0} - ${i18n_1} function MyComponent_div_2_Template(rf, ctx) { if (rf & 1) { - $r3$.ɵɵelementStart(0, "div", 2); + $r3$.ɵɵelementStart(0, "div", 3); $r3$.ɵɵtext(1, " "); - $r3$.ɵɵi18n(2, $i18n_1$); + $r3$.ɵɵi18n(2, 4); $r3$.ɵɵtext(3, " "); $r3$.ɵɵelementEnd(); } @@ -2477,12 +2657,12 @@ describe('i18n support in the template compiler', () => { $r3$.ɵɵi18nApply(2); } } - ${i18n_2} + … function MyComponent_div_3_Template(rf, ctx) { if (rf & 1) { - $r3$.ɵɵelementStart(0, "div", 3); + $r3$.ɵɵelementStart(0, "div", 5); $r3$.ɵɵtext(1, " You have "); - $r3$.ɵɵi18n(2, $i18n_2$); + $r3$.ɵɵi18n(2, 6); $r3$.ɵɵtext(3, ". "); $r3$.ɵɵelementEnd(); } @@ -2496,16 +2676,27 @@ describe('i18n support in the template compiler', () => { … decls: 4, vars: 3, - consts: [["title", "icu only", ${ - AttributeMarker.Template}, "ngIf"], ["title", "icu and text", ${ - AttributeMarker.Template}, "ngIf"], ["title", "icu only"], ["title", "icu and text"]], + consts: function() { + ${i18n_0} + ${i18n_1} + ${i18n_2} + return [ + $i18n_0$, + ["title", "icu only", ${AttributeMarker.Template}, "ngIf"], + ["title", "icu and text", ${AttributeMarker.Template}, "ngIf"], + ["title", "icu only"], + $i18n_1$, + ["title", "icu and text"], + $i18n_2$ + ]; + }, template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); - $r3$.ɵɵi18n(1, $i18n_0$); + $r3$.ɵɵi18n(1, 0); $r3$.ɵɵelementEnd(); - $r3$.ɵɵtemplate(2, MyComponent_div_2_Template, 4, 1, "div", 0); - $r3$.ɵɵtemplate(3, MyComponent_div_3_Template, 4, 2, "div", 1); + $r3$.ɵɵtemplate(2, MyComponent_div_2_Template, 4, 1, "div", 1); + $r3$.ɵɵtemplate(3, MyComponent_div_3_Template, 4, 2, "div", 2); } if (rf & 2) { $r3$.ɵɵadvance(1); @@ -2533,12 +2724,16 @@ describe('i18n support in the template compiler', () => { ]); const output = String.raw` - ${i18n_0} - … + consts: function() { + ${i18n_0} + return [ + $i18n_0$ + ]; + }, template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); - $r3$.ɵɵi18n(1, $i18n_0$); + $r3$.ɵɵi18n(1, 0); $r3$.ɵɵelementEnd(); } if (rf & 2) { @@ -2586,18 +2781,22 @@ describe('i18n support in the template compiler', () => { ]); const output = String.raw` - ${i18n_0} - ${i18n_1} - … decls: 5, vars: 1, - consts: [[1, "other"]], + consts: function() { + ${i18n_0} + ${i18n_1} + return [ + $i18n_1$, + [${AttributeMarker.Classes}, "other"] + ]; + }, template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); - $r3$.ɵɵi18nStart(1, $i18n_1$); + $r3$.ɵɵi18nStart(1, 0); $r3$.ɵɵelement(2, "b"); - $r3$.ɵɵelementStart(3, "div", 0); + $r3$.ɵɵelementStart(3, "div", 1); $r3$.ɵɵelement(4, "i"); $r3$.ɵɵelementEnd(); $r3$.ɵɵi18nEnd(); @@ -2627,14 +2826,18 @@ describe('i18n support in the template compiler', () => { ]); const output = String.raw` - ${i18n_0} - … decls: 2, vars: 2, + consts: function() { + ${i18n_0} + return [ + $i18n_0$ + ]; + }, template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); - $r3$.ɵɵi18n(1, $i18n_0$); + $r3$.ɵɵi18n(1, 0); $r3$.ɵɵelementEnd(); } if (rf & 2) { @@ -2668,16 +2871,20 @@ describe('i18n support in the template compiler', () => { ]); const output = String.raw` - ${i18n_0} - ${i18n_1} - ${i18n_2} - … decls: 2, vars: 2, + consts: function() { + ${i18n_0} + ${i18n_1} + ${i18n_2} + return [ + $i18n_2$ + ]; + }, template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); - $r3$.ɵɵi18n(1, $i18n_2$); + $r3$.ɵɵi18n(1, 0); $r3$.ɵɵelementEnd(); } if (rf & 2) { @@ -2704,7 +2911,9 @@ describe('i18n support in the template compiler', () => { `; - const output = String.raw` + // Keeping raw content here to illustrate the difference in placeholders generated for + // goog.getMsg and $localize calls (see last i18n block). + const i18n_0 = String.raw` var $I18N_1$; if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { const $MSG_APP_SPEC_TS_1$ = goog.getMsg("{VAR_SELECT, select, male {male} female {female} other {other}}"); @@ -2761,9 +2970,12 @@ describe('i18n support in the template compiler', () => { $I18N_0$ = $r3$.ɵɵi18nPostprocess($I18N_0$, { "ICU": [$I18N_1$, $I18N_2$, $I18N_4$] }); + `; + + const output = String.raw` function MyComponent_div_3_Template(rf, ctx) { if (rf & 1) { - $r3$.ɵɵi18nStart(0, $I18N_0$, 1); + $r3$.ɵɵi18nStart(0, 0, 1); $r3$.ɵɵelement(1, "div"); $r3$.ɵɵi18nEnd(); } @@ -2777,13 +2989,19 @@ describe('i18n support in the template compiler', () => { … decls: 4, vars: 3, - consts: [[${AttributeMarker.Template}, "ngIf"]], + consts: function() { + ${i18n_0} + return [ + $i18n_0$, + [${AttributeMarker.Template}, "ngIf"] + ]; + }, template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); - $r3$.ɵɵi18nStart(1, $I18N_0$); + $r3$.ɵɵi18nStart(1, 0); $r3$.ɵɵelement(2, "div"); - $r3$.ɵɵtemplate(3, MyComponent_div_3_Template, 2, 1, "div", 0); + $r3$.ɵɵtemplate(3, MyComponent_div_3_Template, 2, 1, "div", 1); $r3$.ɵɵi18nEnd(); $r3$.ɵɵelementEnd(); } @@ -2819,15 +3037,19 @@ describe('i18n support in the template compiler', () => { const i18n_1 = i18nMsg(' {$icu} ', [['icu', '$i18n_0$']]); const output = String.raw` - ${i18n_0} - ${i18n_1} - … decls: 2, vars: 2, + consts: function() { + ${i18n_0} + ${i18n_1} + return [ + $i18n_1$ + ]; + }, template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); - $r3$.ɵɵi18n(1, $i18n_1$); + $r3$.ɵɵi18n(1, 0); $r3$.ɵɵelementEnd(); } if (rf & 2) { @@ -2865,14 +3087,18 @@ describe('i18n support in the template compiler', () => { ]); const output = String.raw` - ${i18n_0} - … decls: 2, vars: 3, + consts: function() { + ${i18n_0} + return [ + $i18n_0$ + ]; + }, template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); - $r3$.ɵɵi18n(1, $i18n_0$); + $r3$.ɵɵi18n(1, 0); $r3$.ɵɵelementEnd(); } if (rf & 2) { @@ -2910,13 +3136,9 @@ describe('i18n support in the template compiler', () => { ]); const output = String.raw` - ${i18n_0} - ${i18n_1} - ${i18n_2} - … function MyComponent_span_2_Template(rf, ctx) { if (rf & 1) { - $r3$.ɵɵi18nStart(0, $i18n_2$, 1); + $r3$.ɵɵi18nStart(0, 0, 1); $r3$.ɵɵelement(1, "span"); $r3$.ɵɵi18nEnd(); } @@ -2930,12 +3152,20 @@ describe('i18n support in the template compiler', () => { … decls: 3, vars: 2, - consts: [[${AttributeMarker.Template}, "ngIf"]], + consts: function() { + ${i18n_0} + ${i18n_1} + ${i18n_2} + return [ + $i18n_2$, + [${AttributeMarker.Template}, "ngIf"] + ]; + }, template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); - $r3$.ɵɵi18nStart(1, $i18n_2$); - $r3$.ɵɵtemplate(2, MyComponent_span_2_Template, 2, 1, "span", 0); + $r3$.ɵɵi18nStart(1, 0); + $r3$.ɵɵtemplate(2, MyComponent_span_2_Template, 2, 1, "span", 1); $r3$.ɵɵi18nEnd(); $r3$.ɵɵelementEnd(); } @@ -2981,12 +3211,9 @@ describe('i18n support in the template compiler', () => { ]); const output = String.raw` - ${i18n_0} - ${i18n_1} - ${i18n_2} function MyComponent_span_2_Template(rf, ctx) { if (rf & 1) { - $r3$.ɵɵi18nStart(0, $i18n_2$, 1); + $r3$.ɵɵi18nStart(0, 0, 1); $r3$.ɵɵelement(1, "span"); $r3$.ɵɵi18nEnd(); } @@ -3000,12 +3227,20 @@ describe('i18n support in the template compiler', () => { … decls: 3, vars: 4, - consts: [[${AttributeMarker.Template}, "ngIf"]], + consts: function() { + ${i18n_0} + ${i18n_1} + ${i18n_2} + return [ + $i18n_2$, + [${AttributeMarker.Template}, "ngIf"] + ]; + }, template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); - $r3$.ɵɵi18nStart(1, $i18n_2$); - $r3$.ɵɵtemplate(2, MyComponent_span_2_Template, 2, 2, "span", 0); + $r3$.ɵɵi18nStart(1, 0); + $r3$.ɵɵtemplate(2, MyComponent_span_2_Template, 2, 2, "span", 1); $r3$.ɵɵi18nEnd(); $r3$.ɵɵelementEnd(); } @@ -3042,14 +3277,18 @@ describe('i18n support in the template compiler', () => { ]); const output = String.raw` - ${i18n_0} - … decls: 2, vars: 4, + consts: function() { + ${i18n_0} + return [ + $i18n_0$ + ]; + }, template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); - $r3$.ɵɵi18n(1, $i18n_0$); + $r3$.ɵɵi18n(1, 0); $r3$.ɵɵelementEnd(); } if (rf & 2) { @@ -3283,7 +3522,7 @@ $` + String.raw`{$I18N_4$}:ICU:\`; `; - const output = String.raw` + const i18n_0 = String.raw` var $I18N_0$; if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { const $MSG_EXTERNAL_7128002169381370313$$APP_SPEC_TS_1$ = goog.getMsg("{$startTagXhtmlDiv} Count: {$startTagXhtmlSpan}5{$closeTagXhtmlSpan}{$closeTagXhtmlDiv}", { @@ -3301,15 +3540,26 @@ $` + String.raw`{$I18N_4$}:ICU:\`; String.raw`{"\uFFFD/#4\uFFFD"}:CLOSE_TAG__XHTML_SPAN:$` + String.raw`{"\uFFFD/#3\uFFFD"}:CLOSE_TAG__XHTML_DIV:\`; } + `; + + const output = String.raw` … - function MyComponent_Template(rf, ctx) { + consts: function() { + ${i18n_0} + return [ + ["xmlns", "http://www.w3.org/2000/svg"], + $i18n_0$, + ["xmlns", "http://www.w3.org/1999/xhtml"] + ]; + }, + template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵnamespaceSVG(); $r3$.ɵɵelementStart(0, "svg", 0); $r3$.ɵɵelementStart(1, "foreignObject"); - $r3$.ɵɵi18nStart(2, $I18N_0$); + $r3$.ɵɵi18nStart(2, 1); $r3$.ɵɵnamespaceHTML(); - $r3$.ɵɵelementStart(3, "div", 1); + $r3$.ɵɵelementStart(3, "div", 2); $r3$.ɵɵelement(4, "span"); $r3$.ɵɵelementEnd(); $r3$.ɵɵi18nEnd(); @@ -3333,7 +3583,7 @@ $` + String.raw`{$I18N_4$}:ICU:\`; `; - const output = String.raw` + const i18n_0 = String.raw` var $I18N_0$; if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) { const $MSG_EXTERNAL_7428861019045796010$$APP_SPEC_TS_1$ = goog.getMsg(" Count: {$startTagXhtmlSpan}5{$closeTagXhtmlSpan}", { @@ -3347,15 +3597,25 @@ $` + String.raw`{$I18N_4$}:ICU:\`; String.raw`{"\uFFFD#4\uFFFD"}:START_TAG__XHTML_SPAN:5$` + String.raw`{"\uFFFD/#4\uFFFD"}:CLOSE_TAG__XHTML_SPAN:\`; } - … - function MyComponent_Template(rf, ctx) { + `; + + const output = String.raw` + consts: function() { + ${i18n_0} + return [ + ["xmlns", "http://www.w3.org/2000/svg"], + ["xmlns", "http://www.w3.org/1999/xhtml"], + $i18n_0$ + ]; + }, + template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵnamespaceSVG(); $r3$.ɵɵelementStart(0, "svg", 0); $r3$.ɵɵelementStart(1, "foreignObject"); $r3$.ɵɵnamespaceHTML(); $r3$.ɵɵelementStart(2, "div", 1); - $r3$.ɵɵi18nStart(3, $I18N_0$); + $r3$.ɵɵi18nStart(3, 2); $r3$.ɵɵelement(4, "span"); $r3$.ɵɵi18nEnd(); $r3$.ɵɵelementEnd(); @@ -3365,7 +3625,7 @@ $` + String.raw`{$I18N_4$}:ICU:\`; } `; - verify(input, output, {verbose: true}); + verify(input, output); }); }); }); diff --git a/packages/compiler/src/render3/view/compiler.ts b/packages/compiler/src/render3/view/compiler.ts index 2aaeddc1fe..9844830d0a 100644 --- a/packages/compiler/src/render3/view/compiler.ts +++ b/packages/compiler/src/render3/view/compiler.ts @@ -196,10 +196,19 @@ export function compileComponentFromMetadata( // e.g. `vars: 2` definitionMap.set('vars', o.literal(templateBuilder.getVarCount())); - // e.g. `consts: [['one', 'two'], ['three', 'four']] - const consts = templateBuilder.getConsts(); - if (consts.length > 0) { - definitionMap.set('consts', o.literalArr(consts)); + // Generate `consts` section of ComponentDef: + // - either as an array: + // `consts: [['one', 'two'], ['three', 'four']]` + // - or as a factory function in case additional statements are present (to support i18n): + // `consts: function() { var i18n_0; if (ngI18nClosureMode) {...} else {...} return [i18n_0]; }` + const {constExpressions, prepareStatements} = templateBuilder.getConsts(); + if (constExpressions.length > 0) { + let constsExpr: o.LiteralArrayExpr|o.FunctionExpr = o.literalArr(constExpressions); + // Prepare statements are present - turn `consts` into a function. + if (prepareStatements.length > 0) { + constsExpr = o.fn([], [...prepareStatements, new o.ReturnStatement(constsExpr)]); + } + definitionMap.set('consts', constsExpr); } definitionMap.set('template', templateFunctionExpression); diff --git a/packages/compiler/src/render3/view/i18n/util.ts b/packages/compiler/src/render3/view/i18n/util.ts index f867dcc0c9..da71db4b67 100644 --- a/packages/compiler/src/render3/view/i18n/util.ts +++ b/packages/compiler/src/render3/view/i18n/util.ts @@ -12,10 +12,14 @@ import * as o from '../../../output/output_ast'; import * as t from '../../r3_ast'; /* Closure variables holding messages must be named `MSG_[A-Z0-9]+` */ -const CLOSURE_TRANSLATION_PREFIX = 'MSG_'; +const CLOSURE_TRANSLATION_VAR_PREFIX = 'MSG_'; -/* Prefix for non-`goog.getMsg` i18n-related vars */ -export const TRANSLATION_PREFIX = 'I18N_'; +/** + * Prefix for non-`goog.getMsg` i18n-related vars. + * Note: the prefix uses lowercase characters intentionally due to a Closure behavior that + * considers variables like `I18N_0` as constants and throws an error when their value changes. + */ +export const TRANSLATION_VAR_PREFIX = 'i18n_'; /** Name of the i18n attributes **/ export const I18N_ATTR = 'i18n'; @@ -166,7 +170,7 @@ export function formatI18nPlaceholderName(name: string, useCamelCase: boolean = * @returns Complete translation const prefix */ export function getTranslationConstPrefix(extra: string): string { - return `${CLOSURE_TRANSLATION_PREFIX}${extra}`.toUpperCase(); + return `${CLOSURE_TRANSLATION_VAR_PREFIX}${extra}`.toUpperCase(); } /** diff --git a/packages/compiler/src/render3/view/template.ts b/packages/compiler/src/render3/view/template.ts index 4cb1601915..818d0c5119 100644 --- a/packages/compiler/src/render3/view/template.ts +++ b/packages/compiler/src/render3/view/template.ts @@ -36,7 +36,7 @@ import {I18nContext} from './i18n/context'; import {createGoogleGetMsgStatements} from './i18n/get_msg_utils'; import {createLocalizeStatements} from './i18n/localize_utils'; import {I18nMetaVisitor} from './i18n/meta'; -import {assembleBoundTextPlaceholders, assembleI18nBoundString, declareI18nVariable, getTranslationConstPrefix, hasI18nMeta, I18N_ICU_MAPPING_PREFIX, i18nFormatPlaceholderNames, icuFromI18nMessage, isI18nRootNode, isSingleI18nIcu, placeholdersToParams, TRANSLATION_PREFIX, wrapI18nPlaceholder} from './i18n/util'; +import {assembleBoundTextPlaceholders, assembleI18nBoundString, declareI18nVariable, getTranslationConstPrefix, hasI18nMeta, I18N_ICU_MAPPING_PREFIX, i18nFormatPlaceholderNames, icuFromI18nMessage, isI18nRootNode, isSingleI18nIcu, placeholdersToParams, TRANSLATION_VAR_PREFIX, wrapI18nPlaceholder} from './i18n/util'; import {StylingBuilder, StylingInstruction} from './styling_builder'; import {asLiteral, chainedInstruction, CONTEXT_NAME, getAttrsForDirectiveMatching, getInterpolationArgsLength, IMPLICIT_REFERENCE, invalid, NON_BINDABLE_ATTR, REFERENCE_PREFIX, RENDER_FLAGS, trimTrailingNulls, unsupported} from './util'; @@ -103,6 +103,18 @@ export function prepareEventListenerParameters( return params; } +// Collects information needed to generate `consts` field of the ComponentDef. +// When a constant requires some pre-processing, the `prepareStatements` section +// contains corresponding statements. +export interface ComponentDefConsts { + prepareStatements: o.Statement[]; + constExpressions: o.Expression[]; +} + +function createComponentDefConsts(): ComponentDefConsts { + return {prepareStatements: [], constExpressions: []}; +} + export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver { private _dataIndex = 0; private _bindingContext = 0; @@ -171,7 +183,8 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver private directiveMatcher: SelectorMatcher|null, private directives: Set, private pipeTypeByName: Map, private pipes: Set, private _namespace: o.ExternalReference, relativeContextFilePath: string, - private i18nUseExternalIds: boolean, private _constants: o.Expression[] = []) { + private i18nUseExternalIds: boolean, + private _constants: ComponentDefConsts = createComponentDefConsts()) { this._bindingScope = parentBindingScope.nestedScope(level); // Turn the relative context file path into an identifier by replacing non-alphanumeric @@ -307,12 +320,12 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver private i18nTranslate( message: i18n.Message, params: {[name: string]: o.Expression} = {}, ref?: o.ReadVarExpr, transformFn?: (raw: o.ReadVarExpr) => o.Expression): o.ReadVarExpr { - const _ref = ref || o.variable(this.constantPool.uniqueName(TRANSLATION_PREFIX)); + const _ref = ref || this.i18nGenerateMainBlockVar(); // Closure Compiler requires const names to start with `MSG_` but disallows any other const to // start with `MSG_`. We define a variable starting with `MSG_` just for the `goog.getMsg` call const closureVar = this.i18nGenerateClosureVar(message.id); const statements = getTranslationDeclStmts(message, _ref, closureVar, params, transformFn); - this.constantPool.statements.push(...statements); + this._constants.prepareStatements.push(...statements); return _ref; } @@ -364,6 +377,12 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver return bound; } + // Generates top level vars for i18n blocks (i.e. `i18n_N`). + private i18nGenerateMainBlockVar(): o.ReadVarExpr { + return o.variable(this.constantPool.uniqueName(TRANSLATION_VAR_PREFIX)); + } + + // Generates vars with Closure-specific names for i18n blocks (i.e. `MSG_XXX`). private i18nGenerateClosureVar(messageId: string): o.ReadVarExpr { let name: string; const suffix = this.fileBasedI18nSuffix.toUpperCase(); @@ -426,16 +445,13 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver private i18nStart(span: ParseSourceSpan|null = null, meta: i18n.I18nMeta, selfClosing?: boolean): void { const index = this.allocateDataSlot(); - if (this.i18nContext) { - this.i18n = this.i18nContext.forkChildContext(index, this.templateIndex!, meta); - } else { - const ref = o.variable(this.constantPool.uniqueName(TRANSLATION_PREFIX)); - this.i18n = new I18nContext(index, ref, 0, this.templateIndex, meta); - } + this.i18n = this.i18nContext ? + this.i18nContext.forkChildContext(index, this.templateIndex!, meta) : + new I18nContext(index, this.i18nGenerateMainBlockVar(), 0, this.templateIndex, meta); // generate i18nStart instruction const {id, ref} = this.i18n; - const params: o.Expression[] = [o.literal(index), ref]; + const params: o.Expression[] = [o.literal(index), this.addToConsts(ref)]; if (id > 0) { // do not push 3rd argument (sub-block id) // into i18nStart call for top level i18n context @@ -507,8 +523,8 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver } if (i18nAttrArgs.length > 0) { const index: o.Expression = o.literal(this.allocateDataSlot()); - const args = this.constantPool.getConstLiteral(o.literalArr(i18nAttrArgs), true); - this.creationInstruction(sourceSpan, R3.i18nAttributes, [index, args]); + const constIndex = this.addToConsts(o.literalArr(i18nAttrArgs)); + this.creationInstruction(sourceSpan, R3.i18nAttributes, [index, constIndex]); if (hasBindings) { this.updateInstruction(sourceSpan, R3.i18nApply, [index]); } @@ -1028,7 +1044,7 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver return this._pureFunctionSlots; } - getConsts() { + getConsts(): ComponentDefConsts { return this._constants; } @@ -1352,14 +1368,16 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver return o.TYPED_NULL_EXPR; } + const consts = this._constants.constExpressions; + // Try to reuse a literal that's already in the array, if possible. - for (let i = 0; i < this._constants.length; i++) { - if (this._constants[i].isEquivalent(expression)) { + for (let i = 0; i < consts.length; i++) { + if (consts[i].isEquivalent(expression)) { return o.literal(i); } } - return o.literal(this._constants.push(expression) - 1); + return o.literal(consts.push(expression) - 1); } private addAttrsToConsts(attrs: o.Expression[]): o.LiteralExpr { diff --git a/packages/core/src/render3/definition.ts b/packages/core/src/render3/definition.ts index 74fa4237b6..5b528ded1c 100644 --- a/packages/core/src/render3/definition.ts +++ b/packages/core/src/render3/definition.ts @@ -18,7 +18,7 @@ import {stringify} from '../util/stringify'; import {EMPTY_ARRAY, EMPTY_OBJ} from './empty'; import {NG_COMP_DEF, NG_DIR_DEF, NG_FACTORY_DEF, NG_LOC_ID_DEF, NG_MOD_DEF, NG_PIPE_DEF} from './fields'; import {ComponentDef, ComponentDefFeature, ComponentTemplate, ComponentType, ContentQueriesFunction, DirectiveDef, DirectiveDefFeature, DirectiveTypesOrFactory, FactoryFn, HostBindingsFunction, PipeDef, PipeType, PipeTypesOrFactory, ViewQueriesFunction} from './interfaces/definition'; -import {AttributeMarker, TAttributes, TConstants} from './interfaces/node'; +import {AttributeMarker, TAttributes, TConstantsOrFactory} from './interfaces/node'; import {CssSelectorList, SelectorFlags} from './interfaces/projection'; import {NgModuleType} from './ng_module_ref'; @@ -220,7 +220,7 @@ export function ɵɵdefineComponent(componentDefinition: { * Constants for the nodes in the component's view. * Includes attribute arrays, local definition arrays etc. */ - consts?: TConstants; + consts?: TConstantsOrFactory; /** * An array of `ngContent[selector]` values that were found in the template. diff --git a/packages/core/src/render3/instructions/i18n.ts b/packages/core/src/render3/instructions/i18n.ts index bcc865e8d2..afaa3209c4 100644 --- a/packages/core/src/render3/instructions/i18n.ts +++ b/packages/core/src/render3/instructions/i18n.ts @@ -15,6 +15,7 @@ import {i18nAttributesFirstPass, i18nStartFirstPass} from '../i18n/i18n_parse'; import {i18nPostprocess} from '../i18n/i18n_postprocess'; import {HEADER_OFFSET} from '../interfaces/view'; import {getLView, getTView, nextBindingIndex} from '../state'; +import {getConstant} from '../util/view_utils'; import {setDelayProjection} from './all'; @@ -42,14 +43,15 @@ import {setDelayProjection} from './all'; * `template` instruction index. A `block` that matches the sub-template in which it was declared. * * @param index A unique index of the translation in the static block. - * @param message The translation message. + * @param messageIndex An index of the translation message from the `def.consts` array. * @param subTemplateIndex Optional sub-template index in the `message`. * * @codeGenApi */ -export function ɵɵi18nStart(index: number, message: string, subTemplateIndex?: number): void { +export function ɵɵi18nStart(index: number, messageIndex: number, subTemplateIndex?: number): void { const tView = getTView(); ngDevMode && assertDefined(tView, `tView should be defined`); + const message = getConstant(tView.consts, messageIndex)!; pushI18nIndex(index); // We need to delay projections until `i18nEnd` setDelayProjection(true); @@ -96,13 +98,13 @@ export function ɵɵi18nEnd(): void { * `template` instruction index. A `block` that matches the sub-template in which it was declared. * * @param index A unique index of the translation in the static block. - * @param message The translation message. + * @param messageIndex An index of the translation message from the `def.consts` array. * @param subTemplateIndex Optional sub-template index in the `message`. * * @codeGenApi */ -export function ɵɵi18n(index: number, message: string, subTemplateIndex?: number): void { - ɵɵi18nStart(index, message, subTemplateIndex); +export function ɵɵi18n(index: number, messageIndex: number, subTemplateIndex?: number): void { + ɵɵi18nStart(index, messageIndex, subTemplateIndex); ɵɵi18nEnd(); } @@ -114,11 +116,12 @@ export function ɵɵi18n(index: number, message: string, subTemplateIndex?: numb * * @codeGenApi */ -export function ɵɵi18nAttributes(index: number, values: string[]): void { +export function ɵɵi18nAttributes(index: number, attrsIndex: number): void { const lView = getLView(); const tView = getTView(); ngDevMode && assertDefined(tView, `tView should be defined`); - i18nAttributesFirstPass(lView, tView, index, values); + const attrs = getConstant(tView.consts, attrsIndex)!; + i18nAttributesFirstPass(lView, tView, index, attrs); } diff --git a/packages/core/src/render3/instructions/shared.ts b/packages/core/src/render3/instructions/shared.ts index c707856b0f..4addb1e83b 100644 --- a/packages/core/src/render3/instructions/shared.ts +++ b/packages/core/src/render3/instructions/shared.ts @@ -26,7 +26,7 @@ import {executeCheckHooks, executeInitAndCheckHooks, incrementInitPhaseFlags} fr import {CONTAINER_HEADER_OFFSET, HAS_TRANSPLANTED_VIEWS, LContainer, MOVED_VIEWS} from '../interfaces/container'; import {ComponentDef, ComponentTemplate, DirectiveDef, DirectiveDefListOrFactory, PipeDefListOrFactory, RenderFlags, ViewQueriesFunction} from '../interfaces/definition'; import {INJECTOR_BLOOM_PARENT_SIZE, NodeInjectorFactory} from '../interfaces/injector'; -import {AttributeMarker, InitialInputData, InitialInputs, LocalRefExtractor, PropertyAliases, PropertyAliasValue, TAttributes, TConstants, TContainerNode, TDirectiveHostNode, TElementContainerNode, TElementNode, TIcuContainerNode, TNode, TNodeFlags, TNodeProviderIndexes, TNodeType, TProjectionNode, TViewNode} from '../interfaces/node'; +import {AttributeMarker, InitialInputData, InitialInputs, LocalRefExtractor, PropertyAliases, PropertyAliasValue, TAttributes, TConstantsOrFactory, TContainerNode, TDirectiveHostNode, TElementContainerNode, TElementNode, TIcuContainerNode, TNode, TNodeFlags, TNodeProviderIndexes, TNodeType, TProjectionNode, TViewNode} from '../interfaces/node'; import {isProceduralRenderer, RComment, RElement, Renderer3, RendererFactory3, RNode, RText} from '../interfaces/renderer'; import {SanitizerFn} from '../interfaces/sanitization'; import {isComponentDef, isComponentHost, isContentQueryHost, isLContainer, isRootView} from '../interfaces/type_checks'; @@ -650,7 +650,7 @@ export function createTView( type: TViewType, viewIndex: number, templateFn: ComponentTemplate|null, decls: number, vars: number, directives: DirectiveDefListOrFactory|null, pipes: PipeDefListOrFactory|null, viewQuery: ViewQueriesFunction|null, schemas: SchemaMetadata[]|null, - consts: TConstants|null): TView { + constsOrFactory: TConstantsOrFactory|null): TView { ngDevMode && ngDevMode.tView++; const bindingStartIndex = HEADER_OFFSET + decls; // This length does not yet contain host bindings from child directives because at this point, @@ -658,6 +658,7 @@ export function createTView( // that has a host binding, we will update the blueprint with that def's hostVars count. const initialViewLength = bindingStartIndex + vars; const blueprint = createViewBlueprint(bindingStartIndex, initialViewLength); + const consts = typeof constsOrFactory === 'function' ? constsOrFactory() : constsOrFactory; const tView = blueprint[TVIEW as any] = ngDevMode ? new TViewConstructor( type, diff --git a/packages/core/src/render3/interfaces/definition.ts b/packages/core/src/render3/interfaces/definition.ts index a48cb77732..c8de2c50e4 100644 --- a/packages/core/src/render3/interfaces/definition.ts +++ b/packages/core/src/render3/interfaces/definition.ts @@ -10,7 +10,7 @@ import {SchemaMetadata, ViewEncapsulation} from '../../core'; import {ProcessProvidersFunction} from '../../di/interface/provider'; import {Type} from '../../interface/type'; -import {TAttributes, TConstants} from './node'; +import {TAttributes, TConstantsOrFactory} from './node'; import {CssSelectorList} from './projection'; import {TView} from './view'; @@ -299,7 +299,7 @@ export interface ComponentDef extends DirectiveDef { readonly template: ComponentTemplate; /** Constants associated with the component's view. */ - readonly consts: TConstants|null; + readonly consts: TConstantsOrFactory|null; /** * An array of `ngContent[selector]` values that were found in the template. diff --git a/packages/core/src/render3/interfaces/node.ts b/packages/core/src/render3/interfaces/node.ts index 462105d2f4..22d9744786 100644 --- a/packages/core/src/render3/interfaces/node.ts +++ b/packages/core/src/render3/interfaces/node.ts @@ -255,9 +255,24 @@ export type TAttributes = (string|AttributeMarker|CssSelector)[]; * Constants that are associated with a view. Includes: * - Attribute arrays. * - Local definition arrays. + * - Translated messages (i18n). */ export type TConstants = (TAttributes|string)[]; +/** + * Factory function that returns an array of consts. Consts can be represented as a function in case + * any additional statements are required to define consts in the list. An example is i18n where + * additional i18n calls are generated, which should be executed when consts are requested for the + * first time. + */ +export type TConstantsFactory = () => TConstants; + +/** + * TConstants type that describes how the `consts` field is generated on ComponentDef: it can be + * either an array or a factory function that returns that array. + */ +export type TConstantsOrFactory = TConstants|TConstantsFactory; + /** * Binding data (flyweight) for a particular node that is shared between all templates * of a specific type. diff --git a/packages/core/test/render3/i18n_spec.ts b/packages/core/test/render3/i18n_spec.ts index 249a18ffc7..238a3a0d49 100644 --- a/packages/core/test/render3/i18n_spec.ts +++ b/packages/core/test/render3/i18n_spec.ts @@ -12,6 +12,7 @@ import {getTranslationForTemplate} from '@angular/core/src/render3/i18n/i18n_par import {noop} from '../../../compiler/src/render3/view/util'; import {setDelayProjection, ɵɵelementEnd, ɵɵelementStart} from '../../src/render3/instructions/all'; import {I18nUpdateOpCodes, TI18n, TIcu} from '../../src/render3/interfaces/i18n'; +import {TConstants} from '../../src/render3/interfaces/node'; import {HEADER_OFFSET, LView, TVIEW} from '../../src/render3/interfaces/view'; import {getNativeByIndex} from '../../src/render3/util/view_utils'; @@ -57,26 +58,29 @@ describe('Runtime i18n', () => { }); function prepareFixture( - createTemplate: () => void, updateTemplate: (() => void)|null, nbConsts = 0, - nbVars = 0): TemplateFixture { - return new TemplateFixture(createTemplate, updateTemplate || noop, nbConsts, nbVars); + createTemplate: () => void, updateTemplate: (() => void)|null, nbConsts = 0, nbVars = 0, + consts: TConstants = []): TemplateFixture { + return new TemplateFixture( + createTemplate, updateTemplate || noop, nbConsts, nbVars, null, null, null, undefined, + consts); } function getOpCodes( - createTemplate: () => void, updateTemplate: (() => void)|null, nbConsts: number, - index: number): TI18n|I18nUpdateOpCodes { - const fixture = prepareFixture(createTemplate, updateTemplate, nbConsts); + messageOrAtrs: string|string[], createTemplate: () => void, updateTemplate: (() => void)|null, + nbConsts: number, index: number): TI18n|I18nUpdateOpCodes { + const fixture = + prepareFixture(createTemplate, updateTemplate, nbConsts, undefined, [messageOrAtrs]); const tView = fixture.hostView[TVIEW]; return tView.data[index + HEADER_OFFSET] as TI18n; } describe('i18nStart', () => { it('for text', () => { - const MSG_DIV = `simple text`; + const message = 'simple text'; const nbConsts = 1; const index = 0; - const opCodes = getOpCodes(() => { - ɵɵi18nStart(index, MSG_DIV); + const opCodes = getOpCodes(message, () => { + ɵɵi18nStart(index, 0); }, null, nbConsts, index) as TI18n; expect(opCodes).toEqual({ @@ -91,13 +95,13 @@ describe('Runtime i18n', () => { }); it('for elements', () => { - const MSG_DIV = `Hello �#2�world�/#2� and �#3�universe�/#3�!`; + const message = `Hello �#2�world�/#2� and �#3�universe�/#3�!`; // Template: `
Hello
world
and universe!` // 3 consts for the 2 divs and 1 span + 1 const for `i18nStart` = 4 consts const nbConsts = 4; const index = 1; - const opCodes = getOpCodes(() => { - ɵɵi18nStart(index, MSG_DIV); + const opCodes = getOpCodes(message, () => { + ɵɵi18nStart(index, 0); }, null, nbConsts, index); expect(opCodes).toEqual({ @@ -124,11 +128,11 @@ describe('Runtime i18n', () => { }); it('for simple bindings', () => { - const MSG_DIV = `Hello �0�!`; + const message = `Hello �0�!`; const nbConsts = 2; const index = 1; - const opCodes = getOpCodes(() => { - ɵɵi18nStart(index, MSG_DIV); + const opCodes = getOpCodes(message, () => { + ɵɵi18nStart(index, 0); }, null, nbConsts, index); expect((opCodes as any).update.debug).toEqual([ @@ -148,11 +152,11 @@ describe('Runtime i18n', () => { }); it('for multiple bindings', () => { - const MSG_DIV = `Hello �0� and �1�, again �0�!`; + const message = `Hello �0� and �1�, again �0�!`; const nbConsts = 2; const index = 1; - const opCodes = getOpCodes(() => { - ɵɵi18nStart(index, MSG_DIV); + const opCodes = getOpCodes(message, () => { + ɵɵi18nStart(index, 0); }, null, nbConsts, index); expect(opCodes).toEqual({ @@ -176,17 +180,15 @@ describe('Runtime i18n', () => { // // ! //
- const MSG_DIV = + const message = `�0� is rendered as: �*2:1��#1:1�before�*2:2��#1:2�middle�/#1:2��/*2:2�after�/#1:1��/*2:1�!`; /**** Root template ****/ // �0� is rendered as: �*2:1��/*2:1�! let nbConsts = 3; let index = 1; - const firstTextNode = 3; - const rootTemplate = 2; - let opCodes = getOpCodes(() => { - ɵɵi18nStart(index, MSG_DIV); + let opCodes = getOpCodes(message, () => { + ɵɵi18nStart(index, 0); }, null, nbConsts, index); expect(opCodes).toEqual({ @@ -207,10 +209,8 @@ describe('Runtime i18n', () => { // �#1:1�before�*2:2�middle�/*2:2�after�/#1:1� nbConsts = 3; index = 0; - const spanElement = 1; - const bElementSubTemplate = 2; - opCodes = getOpCodes(() => { - ɵɵi18nStart(index, MSG_DIV, 1); + opCodes = getOpCodes(message, () => { + ɵɵi18nStart(index, 0, 1); }, null, nbConsts, index); expect(opCodes).toEqual({ @@ -233,9 +233,8 @@ describe('Runtime i18n', () => { // middle nbConsts = 2; index = 0; - const bElement = 1; - opCodes = getOpCodes(() => { - ɵɵi18nStart(index, MSG_DIV, 2); + opCodes = getOpCodes(message, () => { + ɵɵi18nStart(index, 0, 2); }, null, nbConsts, index); expect(opCodes).toEqual({ @@ -252,15 +251,15 @@ describe('Runtime i18n', () => { }); it('for ICU expressions', () => { - const MSG_DIV = `{�0�, plural, + const message = `{�0�, plural, =0 {no emails!} =1 {one email} other {�0� emails} }`; const nbConsts = 1; const index = 0; - const opCodes = getOpCodes(() => { - ɵɵi18nStart(index, MSG_DIV); + const opCodes = getOpCodes(message, () => { + ɵɵi18nStart(index, 0); }, null, nbConsts, index) as TI18n; expect(opCodes).toEqual({ @@ -337,7 +336,7 @@ describe('Runtime i18n', () => { }); it('for nested ICU expressions', () => { - const MSG_DIV = `{�0�, plural, + const message = `{�0�, plural, =0 {zero} other {�0� {�1�, select, cat {cats} @@ -347,16 +346,9 @@ describe('Runtime i18n', () => { }`; const nbConsts = 1; const index = 0; - const opCodes = getOpCodes(() => { - ɵɵi18nStart(index, MSG_DIV); + const opCodes = getOpCodes(message, () => { + ɵɵi18nStart(index, 0); }, null, nbConsts, index); - const icuCommentNodeIndex = index + 1; - const firstTextNodeIndex = index + 2; - const nestedIcuCommentNodeIndex = index + 3; - const lastTextNodeIndex = index + 4; - const nestedTextNodeIndex = index + 5; - const tIcuIndex = 1; - const nestedTIcuIndex = 0; expect(opCodes).toEqual({ vars: 9, @@ -443,31 +435,31 @@ describe('Runtime i18n', () => { describe(`i18nAttribute`, () => { it('for text', () => { - const MSG_title = `Hello world!`; - const MSG_div_attr = ['title', MSG_title]; + const message = `Hello world!`; + const attrs = ['title', message]; const nbConsts = 2; const index = 1; const fixture = prepareFixture(() => { ɵɵelementStart(0, 'div'); - ɵɵi18nAttributes(index, MSG_div_attr); + ɵɵi18nAttributes(index, 0); ɵɵelementEnd(); - }, null, nbConsts, index); + }, null, nbConsts, index, [attrs]); const tView = fixture.hostView[TVIEW]; const opCodes = tView.data[index + HEADER_OFFSET] as I18nUpdateOpCodes; expect(opCodes).toEqual([]); expect( (getNativeByIndex(0, fixture.hostView as LView) as any as Element).getAttribute('title')) - .toEqual(MSG_title); + .toEqual(message); }); it('for simple bindings', () => { - const MSG_title = `Hello �0�!`; - const MSG_div_attr = ['title', MSG_title]; + const message = `Hello �0�!`; + const attrs = ['title', message]; const nbConsts = 2; const index = 1; - const opCodes = getOpCodes(() => { - ɵɵi18nAttributes(index, MSG_div_attr); + const opCodes = getOpCodes(attrs, () => { + ɵɵi18nAttributes(index, 0); }, null, nbConsts, index); expect(opCodes).toEqual(debugMatch([ @@ -476,12 +468,12 @@ describe('Runtime i18n', () => { }); it('for multiple bindings', () => { - const MSG_title = `Hello �0� and �1�, again �0�!`; - const MSG_div_attr = ['title', MSG_title]; + const message = `Hello �0� and �1�, again �0�!`; + const attrs = ['title', message]; const nbConsts = 2; const index = 1; - const opCodes = getOpCodes(() => { - ɵɵi18nAttributes(index, MSG_div_attr); + const opCodes = getOpCodes(attrs, () => { + ɵɵi18nAttributes(index, 0); }, null, nbConsts, index); expect(opCodes).toEqual(debugMatch([ @@ -490,12 +482,12 @@ describe('Runtime i18n', () => { }); it('for multiple attributes', () => { - const MSG_title = `Hello �0�!`; - const MSG_div_attr = ['title', MSG_title, 'aria-label', MSG_title]; + const message = `Hello �0�!`; + const attrs = ['title', message, 'aria-label', message]; const nbConsts = 2; const index = 1; - const opCodes = getOpCodes(() => { - ɵɵi18nAttributes(index, MSG_div_attr); + const opCodes = getOpCodes(attrs, () => { + ɵɵi18nAttributes(index, 0); }, null, nbConsts, index); expect(opCodes).toEqual(debugMatch([ @@ -643,4 +635,4 @@ describe('Runtime i18n', () => { .toThrowError(); }); }); -}); +}); \ No newline at end of file From be96510ce9feb2a870e5cba212ef865e89c19d30 Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Fri, 14 Aug 2020 12:20:32 +0100 Subject: [PATCH 046/629] test(compiler): add additional i18n serialization tests (#38484) The addiational tests check that ICUs containing interpolations are serialized correctly. PR Close #38484 --- .../compiler/test/render3/view/i18n_spec.ts | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/packages/compiler/test/render3/view/i18n_spec.ts b/packages/compiler/test/render3/view/i18n_spec.ts index f809a2523c..328cb7b7f3 100644 --- a/packages/compiler/test/render3/view/i18n_spec.ts +++ b/packages/compiler/test/render3/view/i18n_spec.ts @@ -415,7 +415,7 @@ describe('serializeI18nMessageForLocalize', () => { }); - it('should serialize ICU with nested HTML for `$localize()`', () => { + it('should serialize ICU with embedded HTML for `$localize()`', () => { expect(serialize('{age, plural, 10 {ten} other {
other
}}')).toEqual({ messageParts: [ '{VAR_PLURAL, plural, 10 {{START_BOLD_TEXT}ten{CLOSE_BOLD_TEXT}} other {{START_TAG_DIV}other{CLOSE_TAG_DIV}}}' @@ -424,6 +424,15 @@ describe('serializeI18nMessageForLocalize', () => { }); }); + it('should serialize ICU with embedded interpolation for `$localize()`', () => { + expect(serialize('{age, plural, 10 {ten} other {{{age}} years old}}')).toEqual({ + messageParts: [ + '{VAR_PLURAL, plural, 10 {{START_BOLD_TEXT}ten{CLOSE_BOLD_TEXT}} other {{INTERPOLATION} years old}}' + ], + placeHolders: [] + }); + }); + it('should serialize ICU with nested HTML containing further ICUs for `$localize()`', () => { expect( serialize( @@ -433,6 +442,18 @@ describe('serializeI18nMessageForLocalize', () => { placeHolders: ['ICU', 'START_TAG_DIV', 'ICU', 'CLOSE_TAG_DIV'] }); }); + + it('should serialize nested ICUs with embedded interpolation for `$localize()`', () => { + expect( + serialize( + '{age, plural, 10 {ten {size, select, 1 {{{ varOne }}} 2 {{{ varTwo }}} other {2+}}} other {other}}')) + .toEqual({ + messageParts: [ + '{VAR_PLURAL, plural, 10 {ten {VAR_SELECT, select, 1 {{INTERPOLATION}} 2 {{INTERPOLATION_1}} other {2+}}} other {other}}' + ], + placeHolders: [] + }); + }); }); describe('serializeIcuNode', () => { @@ -447,7 +468,7 @@ describe('serializeIcuNode', () => { .toEqual('{VAR_PLURAL, plural, 10 {ten} other {other}}'); }); - it('should serialize a next ICU', () => { + it('should serialize a nested ICU', () => { expect(serialize( '{age, plural, 10 {ten {size, select, 1 {one} 2 {two} other {2+}}} other {other}}')) .toEqual( @@ -459,4 +480,9 @@ describe('serializeIcuNode', () => { .toEqual( '{VAR_PLURAL, plural, 10 {{START_BOLD_TEXT}ten{CLOSE_BOLD_TEXT}} other {{START_TAG_DIV}other{CLOSE_TAG_DIV}}}'); }); + + it('should serialize an ICU with embedded interpolations', () => { + expect(serialize('{age, select, 10 {ten} other {{{age}} years old}}')) + .toEqual('{VAR_SELECT, select, 10 {ten} other {{INTERPOLATION} years old}}'); + }); }); From 81c3e809aaec5fc7563ad9ee28d3e13c7c13dc40 Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Sun, 16 Aug 2020 12:43:41 +0100 Subject: [PATCH 047/629] fix(localize): render ICU placeholders in extracted translation files (#38484) Previously placeholders were only rendered for dynamic interpolation expressons in `$localize` tagged strings. But there are also potentially dynamic values in ICU expressions too, so we need to render these as placeholders when extracting i18n messages into translation files. PR Close #38484 --- .../extract/translation_files/icu_parsing.ts | 216 ++++++++++++++++++ .../xliff1_translation_serializer.ts | 20 +- .../xliff2_translation_serializer.ts | 49 ++-- .../xmb_translation_serializer.ts | 20 +- .../test/extract/integration/main_spec.ts | 4 +- .../translation_files/icu_parsing_spec.ts | 76 ++++++ .../json_translation_serializer_spec.ts | 18 +- .../xliff1_translation_serializer_spec.ts | 20 +- .../xliff2_translation_serializer_spec.ts | 32 ++- .../xmb_translation_serializer_spec.ts | 14 ++ 10 files changed, 437 insertions(+), 32 deletions(-) create mode 100644 packages/localize/src/tools/src/extract/translation_files/icu_parsing.ts create mode 100644 packages/localize/src/tools/test/extract/translation_files/icu_parsing_spec.ts diff --git a/packages/localize/src/tools/src/extract/translation_files/icu_parsing.ts b/packages/localize/src/tools/src/extract/translation_files/icu_parsing.ts new file mode 100644 index 0000000000..b2e00f85f5 --- /dev/null +++ b/packages/localize/src/tools/src/extract/translation_files/icu_parsing.ts @@ -0,0 +1,216 @@ +/** + * @license + * Copyright Google LLC 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 + */ + +/** + * Split the given `text` into an array of "static strings" and ICU "placeholder names". + * + * This is required because ICU expressions in `$localize` tagged messages may contain "dynamic" + * piece (e.g. interpolations or element markers). These markers need to be translated to + * placeholders in extracted translation files. So we must parse ICUs to identify them and separate + * them out so that the translation serializers can render them appropriately. + * + * An example of an ICU with interpolations: + * + * ``` + * {VAR_PLURAL, plural, one {{INTERPOLATION}} other {{INTERPOLATION_1} post}} + * ``` + * + * In this ICU, `INTERPOLATION` and `INTERPOLATION_1` are actually placeholders that will be + * replaced with dynamic content at runtime. + * + * Such placeholders are identifiable as text wrapped in curly braces, within an ICU case + * expression. + * + * To complicate matters, it is possible for ICUs to be nested indefinitely within each other. In + * such cases, the nested ICU expression appears enclosed in a set of curly braces in the same way + * as a placeholder. The nested ICU expressions can be differentiated from placeholders as they + * contain a comma `,`, which separates the ICU value from the ICU type. + * + * Furthermore, nested ICUs can have placeholders of their own, which need to be extracted. + * + * An example of a nested ICU containing its own placeholders: + * + * ``` + * {VAR_SELECT_1, select, + * invoice {Invoice for {INTERPOLATION}} + * payment {{VAR_SELECT, select, + * processor {Payment gateway} + * other {{INTERPOLATION_1}} + * }} + * ``` + * + * @param text Text to be broken. + * @returns an array of strings, where + * - even values are static strings (e.g. 0, 2, 4, etc) + * - odd values are placeholder names (e.g. 1, 3, 5, etc) + */ +export function extractIcuPlaceholders(text: string): string[] { + const state = new StateStack(); + const pieces = new IcuPieces(); + const braces = /[{}]/g; + + let lastPos = 0; + let match: RegExpMatchArray|null; + while (match = braces.exec(text)) { + if (match[0] == '{') { + state.enterBlock(); + } else { + // We must have hit a `}` + state.leaveBlock(); + } + + if (state.getCurrent() === 'placeholder') { + const name = tryParsePlaceholder(text, braces.lastIndex); + if (name) { + // We found a placeholder so store it in the pieces; + // store the current static text (minus the opening curly brace); + // skip the closing brace and leave the placeholder block. + pieces.addText(text.substring(lastPos, braces.lastIndex - 1)); + pieces.addPlaceholder(name); + braces.lastIndex += name.length + 1; + state.leaveBlock(); + } else { + // This is not a placeholder, so it must be a nested ICU; + // store the current static text (including the opening curly brace). + pieces.addText(text.substring(lastPos, braces.lastIndex)); + state.nestedIcu(); + } + } else { + pieces.addText(text.substring(lastPos, braces.lastIndex)); + } + lastPos = braces.lastIndex; + } + + // Capture the last piece of text after the ICUs (if any). + pieces.addText(text.substring(lastPos)); + return pieces.toArray(); +} + +/** + * A helper class to store the pieces ("static text" or "placeholder name") in an ICU. + */ +class IcuPieces { + private pieces: string[] = ['']; + + /** + * Add the given `text` to the current "static text" piece. + * + * Sequential calls to `addText()` will append to the current text piece. + */ + addText(text: string): void { + this.pieces[this.pieces.length - 1] += text; + } + + /** + * Add the given placeholder `name` to the stored pieces. + */ + addPlaceholder(name: string): void { + this.pieces.push(name); + this.pieces.push(''); + } + + /** + * Return the stored pieces as an array of strings. + * + * Even values are static strings (e.g. 0, 2, 4, etc) + * Odd values are placeholder names (e.g. 1, 3, 5, etc) + */ + toArray(): string[] { + return this.pieces; + } +} + +/** + * A helper class to track the current state of parsing the strings for ICU placeholders. + * + * State changes happen when we enter or leave a curly brace block. + * Since ICUs can be nested the state is stored as a stack. + */ +class StateStack { + private stack: ParserState[] = []; + + /** + * Update the state upon entering a block. + * + * The new state is computed from the current state and added to the stack. + */ + enterBlock(): void { + const current = this.getCurrent(); + switch (current) { + case 'icu': + this.stack.push('case'); + break; + case 'case': + this.stack.push('placeholder'); + break; + case 'placeholder': + this.stack.push('case'); + break; + default: + this.stack.push('icu'); + break; + } + } + + /** + * Update the state upon leaving a block. + * + * The previous state is popped off the stack. + */ + leaveBlock(): ParserState { + return this.stack.pop(); + } + + /** + * Update the state upon arriving at a nested ICU. + * + * In this case, the current state of "placeholder" is incorrect, so this is popped off and the + * correct "icu" state is stored. + */ + nestedIcu(): void { + const current = this.stack.pop(); + assert(current === 'placeholder', 'A nested ICU must replace a placeholder but got ' + current); + this.stack.push('icu'); + } + + /** + * Get the current (most recent) state from the stack. + */ + getCurrent() { + return this.stack[this.stack.length - 1]; + } +} +type ParserState = 'icu'|'case'|'placeholder'|undefined; + +/** + * Attempt to parse a simple placeholder name from a curly braced block. + * + * If the block contains a comma `,` then it cannot be a placeholder - and is probably a nest ICU + * instead. + * + * @param text the whole string that is being parsed. + * @param start the index of the character in the `text` string where this placeholder may start. + * @returns the placeholder name or `null` if it is not a placeholder. + */ +function tryParsePlaceholder(text: string, start: number): string|null { + for (let i = start; i < text.length; i++) { + if (text[i] === ',') { + break; + } + if (text[i] === '}') { + return text.substring(start, i); + } + } + return null; +} + +function assert(test: boolean, message: string): void { + if (!test) { + throw new Error('Assertion failure: ' + message); + } +} diff --git a/packages/localize/src/tools/src/extract/translation_files/xliff1_translation_serializer.ts b/packages/localize/src/tools/src/extract/translation_files/xliff1_translation_serializer.ts index 8c1afb69f2..cd0b13ba5d 100644 --- a/packages/localize/src/tools/src/extract/translation_files/xliff1_translation_serializer.ts +++ b/packages/localize/src/tools/src/extract/translation_files/xliff1_translation_serializer.ts @@ -8,6 +8,7 @@ import {AbsoluteFsPath, relative} from '@angular/compiler-cli/src/ngtsc/file_system'; import {ɵParsedMessage, ɵSourceLocation} from '@angular/localize'; +import {extractIcuPlaceholders} from './icu_parsing'; import {TranslationSerializer} from './translation_serializer'; import {XmlFile} from './xml_file'; @@ -63,11 +64,22 @@ export class Xliff1TranslationSerializer implements TranslationSerializer { } private serializeMessage(xml: XmlFile, message: ɵParsedMessage): void { - xml.text(message.messageParts[0]); - for (let i = 1; i < message.messageParts.length; i++) { - xml.startTag('x', {id: message.placeholderNames[i - 1]}, {selfClosing: true}); - xml.text(message.messageParts[i]); + const length = message.messageParts.length - 1; + for (let i = 0; i < length; i++) { + this.serializeTextPart(xml, message.messageParts[i]); + xml.startTag('x', {id: message.placeholderNames[i]}, {selfClosing: true}); } + this.serializeTextPart(xml, message.messageParts[length]); + } + + private serializeTextPart(xml: XmlFile, text: string): void { + const pieces = extractIcuPlaceholders(text); + const length = pieces.length - 1; + for (let i = 0; i < length; i += 2) { + xml.text(pieces[i]); + xml.startTag('x', {id: pieces[i + 1]}, {selfClosing: true}); + } + xml.text(pieces[length]); } private serializeNote(xml: XmlFile, name: string, value: string): void { diff --git a/packages/localize/src/tools/src/extract/translation_files/xliff2_translation_serializer.ts b/packages/localize/src/tools/src/extract/translation_files/xliff2_translation_serializer.ts index 31f2a42eeb..d3aa29d5fc 100644 --- a/packages/localize/src/tools/src/extract/translation_files/xliff2_translation_serializer.ts +++ b/packages/localize/src/tools/src/extract/translation_files/xliff2_translation_serializer.ts @@ -8,6 +8,7 @@ import {AbsoluteFsPath, relative} from '@angular/compiler-cli/src/ngtsc/file_system'; import {ɵParsedMessage} from '@angular/localize'; +import {extractIcuPlaceholders} from './icu_parsing'; import {TranslationSerializer} from './translation_serializer'; import {XmlFile} from './xml_file'; @@ -22,6 +23,7 @@ const MAX_LEGACY_XLIFF_2_MESSAGE_LENGTH = 20; * @see Xliff2TranslationParser */ export class Xliff2TranslationSerializer implements TranslationSerializer { + private currentPlaceholderId = 0; constructor( private sourceLocale: string, private basePath: AbsoluteFsPath, private useLegacyIds: boolean) {} @@ -74,21 +76,38 @@ export class Xliff2TranslationSerializer implements TranslationSerializer { } private serializeMessage(xml: XmlFile, message: ɵParsedMessage): void { - xml.text(message.messageParts[0]); - for (let i = 1; i < message.messageParts.length; i++) { - const placeholderName = message.placeholderNames[i - 1]; - if (placeholderName.startsWith('START_')) { - xml.startTag('pc', { - id: `${i}`, - equivStart: placeholderName, - equivEnd: placeholderName.replace(/^START/, 'CLOSE') - }); - } else if (placeholderName.startsWith('CLOSE_')) { - xml.endTag('pc'); - } else { - xml.startTag('ph', {id: `${i}`, equiv: placeholderName}, {selfClosing: true}); - } - xml.text(message.messageParts[i]); + this.currentPlaceholderId = 0; + const length = message.messageParts.length - 1; + for (let i = 0; i < length; i++) { + this.serializeTextPart(xml, message.messageParts[i]); + this.serializePlaceholder(xml, message.placeholderNames[i]); + } + this.serializeTextPart(xml, message.messageParts[length]); + } + + private serializeTextPart(xml: XmlFile, text: string): void { + const pieces = extractIcuPlaceholders(text); + const length = pieces.length - 1; + for (let i = 0; i < length; i += 2) { + xml.text(pieces[i]); + this.serializePlaceholder(xml, pieces[i + 1]); + } + xml.text(pieces[length]); + } + + private serializePlaceholder(xml: XmlFile, placeholderName: string): void { + if (placeholderName.startsWith('START_')) { + xml.startTag('pc', { + id: `${this.currentPlaceholderId++}`, + equivStart: placeholderName, + equivEnd: placeholderName.replace(/^START/, 'CLOSE') + }); + } else if (placeholderName.startsWith('CLOSE_')) { + xml.endTag('pc'); + } else { + xml.startTag( + 'ph', {id: `${this.currentPlaceholderId++}`, equiv: placeholderName}, + {selfClosing: true}); } } diff --git a/packages/localize/src/tools/src/extract/translation_files/xmb_translation_serializer.ts b/packages/localize/src/tools/src/extract/translation_files/xmb_translation_serializer.ts index ffb4383409..574173ed56 100644 --- a/packages/localize/src/tools/src/extract/translation_files/xmb_translation_serializer.ts +++ b/packages/localize/src/tools/src/extract/translation_files/xmb_translation_serializer.ts @@ -8,6 +8,7 @@ import {AbsoluteFsPath, relative} from '@angular/compiler-cli/src/ngtsc/file_system'; import {ɵParsedMessage, ɵSourceLocation} from '@angular/localize'; +import {extractIcuPlaceholders} from './icu_parsing'; import {TranslationSerializer} from './translation_serializer'; import {XmlFile} from './xml_file'; @@ -77,11 +78,22 @@ export class XmbTranslationSerializer implements TranslationSerializer { } private serializeMessage(xml: XmlFile, message: ɵParsedMessage): void { - xml.text(message.messageParts[0]); - for (let i = 1; i < message.messageParts.length; i++) { - xml.startTag('ph', {name: message.placeholderNames[i - 1]}, {selfClosing: true}); - xml.text(message.messageParts[i]); + const length = message.messageParts.length - 1; + for (let i = 0; i < length; i++) { + this.serializeTextPart(xml, message.messageParts[i]); + xml.startTag('ph', {name: message.placeholderNames[i]}, {selfClosing: true}); } + this.serializeTextPart(xml, message.messageParts[length]); + } + + private serializeTextPart(xml: XmlFile, text: string): void { + const pieces = extractIcuPlaceholders(text); + const length = pieces.length - 1; + for (let i = 0; i < length; i += 2) { + xml.text(pieces[i]); + xml.startTag('ph', {name: pieces[i + 1]}, {selfClosing: true}); + } + xml.text(pieces[length]); } /** diff --git a/packages/localize/src/tools/test/extract/integration/main_spec.ts b/packages/localize/src/tools/test/extract/integration/main_spec.ts index 7e5fd37fc6..ae2d27dd16 100644 --- a/packages/localize/src/tools/test/extract/integration/main_spec.ts +++ b/packages/localize/src/tools/test/extract/integration/main_spec.ts @@ -175,12 +175,12 @@ runInEachFileSystem(() => { ` `, ` `, ` `, - ` Hello, !`, + ` Hello, !`, ` `, ` `, ` `, ` `, - ` tryme`, + ` tryme`, ` `, ` `, ` `, diff --git a/packages/localize/src/tools/test/extract/translation_files/icu_parsing_spec.ts b/packages/localize/src/tools/test/extract/translation_files/icu_parsing_spec.ts new file mode 100644 index 0000000000..092cdf6bc5 --- /dev/null +++ b/packages/localize/src/tools/test/extract/translation_files/icu_parsing_spec.ts @@ -0,0 +1,76 @@ +/** + * @license + * Copyright Google LLC 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 {extractIcuPlaceholders} from '../../../src/extract/translation_files/icu_parsing'; + +describe('extractIcuPlaceholders()', () => { + it('should return a single string if there is no ICU', () => { + expect(extractIcuPlaceholders('')).toEqual(['']); + expect(extractIcuPlaceholders('some text')).toEqual(['some text']); + expect(extractIcuPlaceholders('some } text')).toEqual(['some } text']); + expect(extractIcuPlaceholders('this is {not an ICU}')).toEqual(['this is {not an ICU}']); + }); + + it('should return a single string if there are no ICU placeholders', () => { + expect(extractIcuPlaceholders('{VAR_PLURAL, plural, one {SOME} few {FEW} other {OTHER}}')) + .toEqual(['{VAR_PLURAL, plural, one {SOME} few {FEW} other {OTHER}}']); + expect(extractIcuPlaceholders('{VAR_SELECT, select, male {HE} female {SHE} other {XE}}')) + .toEqual(['{VAR_SELECT, select, male {HE} female {SHE} other {XE}}']); + }); + + it('should split out simple interpolation placeholders', () => { + expect( + extractIcuPlaceholders( + '{VAR_PLURAL, plural, one {{INTERPOLATION}} few {pre {INTERPOLATION_1}} other {{INTERPOLATION_2} post}}')) + .toEqual([ + '{VAR_PLURAL, plural, one {', + 'INTERPOLATION', + '} few {pre ', + 'INTERPOLATION_1', + '} other {', + 'INTERPOLATION_2', + ' post}}', + ]); + }); + + it('should split out element placeholders', () => { + expect( + extractIcuPlaceholders( + '{VAR_PLURAL, plural, one {{START_BOLD_TEXT}something bold{CLOSE_BOLD_TEXT}} other {pre {START_TAG_SPAN}middle{CLOSE_TAG_SPAN} post}}')) + .toEqual([ + '{VAR_PLURAL, plural, one {', + 'START_BOLD_TEXT', + 'something bold', + 'CLOSE_BOLD_TEXT', + '} other {pre ', + 'START_TAG_SPAN', + 'middle', + 'CLOSE_TAG_SPAN', + ' post}}', + ]); + }); + + it('should handle nested ICUs', () => { + expect(extractIcuPlaceholders([ + '{VAR_SELECT_1, select,', + ' invoice {Invoice for {INTERPOLATION}}', + ' payment {{VAR_SELECT, select,', + ' processor {Payment gateway}', + ' other {{INTERPOLATION_1}}', + ' }}', + '}', + ].join('\n'))) + .toEqual([ + '{VAR_SELECT_1, select,\n invoice {Invoice for ', + 'INTERPOLATION', + '}\n payment {{VAR_SELECT, select,\n processor {Payment gateway}\n other {', + 'INTERPOLATION_1', + '}\n }}\n}', + ]); + }); +}); \ No newline at end of file diff --git a/packages/localize/src/tools/test/extract/translation_files/json_translation_serializer_spec.ts b/packages/localize/src/tools/test/extract/translation_files/json_translation_serializer_spec.ts index bcfa7bd36e..bd4bb50e88 100644 --- a/packages/localize/src/tools/test/extract/translation_files/json_translation_serializer_spec.ts +++ b/packages/localize/src/tools/test/extract/translation_files/json_translation_serializer_spec.ts @@ -22,6 +22,19 @@ describe('JsonTranslationSerializer', () => { mockMessage('13579', ['', 'b', ''], ['START_BOLD_TEXT', 'CLOSE_BOLD_TEXT'], {}), mockMessage('24680', ['a'], [], {meaning: 'meaning', description: 'and description'}), mockMessage('80808', ['multi\nlines'], [], {}), + mockMessage('90000', [''], ['double-quotes-"'], {}), + mockMessage( + '100000', + [ + 'pre-ICU {VAR_SELECT, select, a {a} b {{INTERPOLATION}} c {pre {INTERPOLATION_1} post}} post-ICU' + ], + [], {}), + mockMessage( + '100001', + [ + '{VAR_PLURAL, plural, one {{START_BOLD_TEXT}something bold{CLOSE_BOLD_TEXT}} other {pre {START_TAG_SPAN}middle{CLOSE_TAG_SPAN} post}}' + ], + [], {}), ]; const serializer = new SimpleJsonTranslationSerializer('xx'); const output = serializer.serialize(messages); @@ -33,7 +46,10 @@ describe('JsonTranslationSerializer', () => { ` "13579": "{$START_BOLD_TEXT}b{$CLOSE_BOLD_TEXT}",`, ` "24680": "a",`, ` "67890": "a{$START_TAG_SPAN}{$CLOSE_TAG_SPAN}c",`, - ` "80808": "multi\\nlines"`, + ` "80808": "multi\\nlines",`, + ` "90000": "",`, + ` "100000": "pre-ICU {VAR_SELECT, select, a {a} b {{INTERPOLATION}} c {pre {INTERPOLATION_1} post}} post-ICU",`, + ` "100001": "{VAR_PLURAL, plural, one {{START_BOLD_TEXT}something bold{CLOSE_BOLD_TEXT}} other {pre {START_TAG_SPAN}middle{CLOSE_TAG_SPAN} post}}"`, ` }`, `}`, ].join('\n')); diff --git a/packages/localize/src/tools/test/extract/translation_files/xliff1_translation_serializer_spec.ts b/packages/localize/src/tools/test/extract/translation_files/xliff1_translation_serializer_spec.ts index 2b89404fe2..a0e832ffb0 100644 --- a/packages/localize/src/tools/test/extract/translation_files/xliff1_translation_serializer_spec.ts +++ b/packages/localize/src/tools/test/extract/translation_files/xliff1_translation_serializer_spec.ts @@ -34,7 +34,19 @@ runInEachFileSystem(() => { mockMessage('13579', ['', 'b', ''], ['START_BOLD_TEXT', 'CLOSE_BOLD_TEXT'], {}), mockMessage('24680', ['a'], [], {meaning: 'meaning', description: 'and description'}), mockMessage('80808', ['multi\nlines'], [], {}), - mockMessage('90000', [''], ['double-quotes-"'], {}) + mockMessage('90000', [''], ['double-quotes-"'], {}), + mockMessage( + '100000', + [ + 'pre-ICU {VAR_SELECT, select, a {a} b {{INTERPOLATION}} c {pre {INTERPOLATION_1} post}} post-ICU' + ], + [], {}), + mockMessage( + '100001', + [ + '{VAR_PLURAL, plural, one {{START_BOLD_TEXT}something bold{CLOSE_BOLD_TEXT}} other {pre {START_TAG_SPAN}middle{CLOSE_TAG_SPAN} post}}' + ], + [], {}), ]; const serializer = new Xliff1TranslationSerializer('xx', absoluteFrom('/project'), useLegacyIds); @@ -73,6 +85,12 @@ runInEachFileSystem(() => { ` `, ` <escapeme>`, ` `, + ` `, + ` pre-ICU {VAR_SELECT, select, a {a} b {} c {pre post}} post-ICU`, + ` `, + ` `, + ` {VAR_PLURAL, plural, one {something bold} other {pre middle post}}`, + ` `, ` `, ` `, `\n`, diff --git a/packages/localize/src/tools/test/extract/translation_files/xliff2_translation_serializer_spec.ts b/packages/localize/src/tools/test/extract/translation_files/xliff2_translation_serializer_spec.ts index dbbf0fe84d..a634d2a42c 100644 --- a/packages/localize/src/tools/test/extract/translation_files/xliff2_translation_serializer_spec.ts +++ b/packages/localize/src/tools/test/extract/translation_files/xliff2_translation_serializer_spec.ts @@ -40,7 +40,19 @@ runInEachFileSystem(() => { mockMessage('13579', ['', 'b', ''], ['START_BOLD_TEXT', 'CLOSE_BOLD_TEXT'], {}), mockMessage('24680', ['a'], [], {meaning: 'meaning', description: 'and description'}), mockMessage('80808', ['multi\nlines'], [], {}), - mockMessage('90000', [''], ['double-quotes-"'], {}) + mockMessage('90000', [''], ['double-quotes-"'], {}), + mockMessage( + '100000', + [ + 'pre-ICU {VAR_SELECT, select, a {a} b {{INTERPOLATION}} c {pre {INTERPOLATION_1} post}} post-ICU' + ], + [], {}), + mockMessage( + '100001', + [ + '{VAR_PLURAL, plural, one {{START_BOLD_TEXT}something bold{CLOSE_BOLD_TEXT}} other {pre {START_TAG_SPAN}middle{CLOSE_TAG_SPAN} post}}' + ], + [], {}), ]; const serializer = new Xliff2TranslationSerializer('xx', absoluteFrom('/project'), useLegacyIds); @@ -55,7 +67,7 @@ runInEachFileSystem(() => { ` some meaning`, ` `, ` `, - ` abc`, + ` abc`, ` `, ` `, ` `, @@ -64,12 +76,12 @@ runInEachFileSystem(() => { ` some description`, ` `, ` `, - ` ac`, + ` ac`, ` `, ` `, ` `, ` `, - ` b`, + ` b`, ` `, ` `, ` `, @@ -89,7 +101,17 @@ runInEachFileSystem(() => { ` `, ` `, ` `, - ` <escapeme>`, + ` <escapeme>`, + ` `, + ` `, + ` `, + ` `, + ` pre-ICU {VAR_SELECT, select, a {a} b {} c {pre post}} post-ICU`, + ` `, + ` `, + ` `, + ` `, + ` {VAR_PLURAL, plural, one {something bold} other {pre middle post}}`, ` `, ` `, ` `, diff --git a/packages/localize/src/tools/test/extract/translation_files/xmb_translation_serializer_spec.ts b/packages/localize/src/tools/test/extract/translation_files/xmb_translation_serializer_spec.ts index 45a1608334..b7ce9f41b6 100644 --- a/packages/localize/src/tools/test/extract/translation_files/xmb_translation_serializer_spec.ts +++ b/packages/localize/src/tools/test/extract/translation_files/xmb_translation_serializer_spec.ts @@ -30,6 +30,18 @@ runInEachFileSystem(() => { mockMessage('24680', ['a'], [], {meaning: 'meaning', description: 'and description'}), mockMessage('80808', ['multi\nlines'], [], {}), mockMessage('90000', [''], ['double-quotes-"'], {}), + mockMessage( + '100000', + [ + 'pre-ICU {VAR_SELECT, select, a {a} b {{INTERPOLATION}} c {pre {INTERPOLATION_1} post}} post-ICU' + ], + [], {}), + mockMessage( + '100001', + [ + '{VAR_PLURAL, plural, one {{START_BOLD_TEXT}something bold{CLOSE_BOLD_TEXT}} other {pre {START_TAG_SPAN}middle{CLOSE_TAG_SPAN} post}}' + ], + [], {}), ]; const serializer = new XmbTranslationSerializer(absoluteFrom('/project'), useLegacyIds); const output = serializer.serialize(messages); @@ -44,6 +56,8 @@ runInEachFileSystem(() => { ` a`, ` multi`, `lines`, ` <escapeme>`, + ` pre-ICU {VAR_SELECT, select, a {a} b {} c {pre post}} post-ICU`, + ` {VAR_PLURAL, plural, one {something bold} other {pre middle post}}`, `\n` ].join('\n')); }); From ca07da4563ada2fe30ecc50c7fa49834d569776e Mon Sep 17 00:00:00 2001 From: Paul Gschwendtner Date: Fri, 14 Aug 2020 10:23:53 +0200 Subject: [PATCH 048/629] fix(core): detect DI parameters in JIT mode for downleveled ES2015 classes (#38463) In the Angular Package Format, we always shipped UMD bundles and previously even ES5 module output. With V10, we removed the ES5 module output but kept the UMD ES5 output. For this, we were able to remove our second TypeScript transpilation. Instead we started only building ES2015 output and then downleveled it to ES5 UMD for the NPM packages. This worked as expected but unveiled an issue in the `@angular/core` reflection capabilities. In JIT mode, Angular determines constructor parameters (for DI) using the `ReflectionCapabilities`. The reflection capabilities basically read runtime metadata of classes to determine the DI parameters. Such metadata can be either stored in static class properties like `ctorParameters` or within TypeScript's `design:params`. If Angular comes across a class that does not have any parameter metadata, it tries to detect if the given class is actually delegating to an inherited class. It does this naively in JIT by checking if the stringified class (function in ES5) matches a certain pattern. e.g. ```js function MatTable() { var _this = _super.apply(this, arguments) || this; ``` These patterns are reluctant to changes of the class output. If a class is not recognized properly, the DI parameters will be assumed empty and the class is **incorrectly** constructed without arguments. This actually happened as part of v10 now. Since we downlevel ES2015 to ES5 (instead of previously compiling sources directly to ES5), the class output changed slightly so that Angular no longer detects it. e.g. ```js var _this = _super.apply(this, __spread(arguments)) || this; ``` This happens because the ES2015 output will receive an auto-generated constructor if the class defines class properties. This constructor is then already containing an explicit `super` call. ```js export class MatTable extends CdkTable { constructor() { super(...arguments); this.disabled = true; } } ``` If we then downlevel this file to ES5 with `--downlevelIteration`, TypeScript adjusts the `super` call so that the spread operator is no longer used (not supported in ES5). The resulting super call is different to the super call that would have been emitted if we would directly transpile to ES5. Ultimately, Angular no longer detects such classes as having an delegate constructor -> and DI breaks. We fix this by expanding the rather naive RegExp patterns used for the reflection capabilities so that downleveled pass-through/delegate constructors are properly detected. There is a risk of a false-positive as we cannot detect whether `__spread` is actually the TypeScript spread helper, but given the reflection patterns already make lots of assumptions (e.g. that `super` is actually the superclass, we should be fine making this assumption too. The false-positive would not result in a broken app, but rather in unnecessary providers being injected (as a noop). Fixes #38453 PR Close #38463 --- .circleci/config.yml | 12 +++++ karma-js.conf.js | 3 ++ .../src/reflection/reflection_capabilities.ts | 48 +++++++++++++++---- packages/core/test/BUILD.bazel | 22 +++++++++ .../reflection/es2015_inheritance_fixture.ts | 22 +++++++++ .../core/test/reflection/reflector_spec.ts | 10 ++++ 6 files changed, 109 insertions(+), 8 deletions(-) create mode 100644 packages/core/test/reflection/es2015_inheritance_fixture.ts diff --git a/.circleci/config.yml b/.circleci/config.yml index 2dc3023c41..7da0ae3536 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -656,6 +656,18 @@ jobs: - run: yarn tsc -p packages - run: yarn tsc -p modules - run: yarn bazel build //packages/zone.js:npm_package + # Build test fixtures for a test that rely on Bazel-generated fixtures. Note that disabling + # specific tests which are reliant on such generated fixtures is not an option as SystemJS + # in the Saucelabs legacy job always fetches referenced files, even if the imports would be + # guarded by an check to skip in the Saucelabs legacy job. We should be good running such + # test in all supported browsers on Saucelabs anyway until this job can be removed. + - run: + name: Preparing Bazel-generated fixtures required in legacy tests + command: | + yarn bazel build //packages/core/test:downleveled_es5_fixture + # Needed for the ES5 downlevel reflector test in `packages/core/test/reflection`. + cp dist/bin/packages/core/test/reflection/es5_downleveled_inheritance_fixture.js \ + dist/all/@angular/core/test/reflection/es5_downleveled_inheritance_fixture.js - run: # Waiting on ready ensures that we don't run tests too early without Saucelabs not being ready. name: Waiting for Saucelabs tunnel to connect diff --git a/karma-js.conf.js b/karma-js.conf.js index bdab6ea463..6f97a611f8 100644 --- a/karma-js.conf.js +++ b/karma-js.conf.js @@ -37,6 +37,9 @@ module.exports = function(config) { 'node_modules/core-js/client/core.js', 'node_modules/jasmine-ajax/lib/mock-ajax.js', + + // Dependencies built by Bazel. See `config.yml` for steps running before + // the legacy Saucelabs tests run. 'dist/bin/packages/zone.js/npm_package/bundles/zone.umd.js', 'dist/bin/packages/zone.js/npm_package/bundles/zone-testing.umd.js', 'dist/bin/packages/zone.js/npm_package/bundles/task-tracking.umd.js', diff --git a/packages/core/src/reflection/reflection_capabilities.ts b/packages/core/src/reflection/reflection_capabilities.ts index cd5a155c40..b4880c4188 100644 --- a/packages/core/src/reflection/reflection_capabilities.ts +++ b/packages/core/src/reflection/reflection_capabilities.ts @@ -17,14 +17,45 @@ import {GetterFn, MethodFn, SetterFn} from './types'; -/** - * Attention: These regex has to hold even if the code is minified! +/* + * ######################### + * Attention: These Regular expressions have to hold even if the code is minified! + * ########################## */ -export const DELEGATE_CTOR = /^function\s+\S+\(\)\s*{[\s\S]+\.apply\(this,\s*arguments\)/; -export const INHERITED_CLASS = /^class\s+[A-Za-z\d$_]*\s*extends\s+[^{]+{/; -export const INHERITED_CLASS_WITH_CTOR = + +/** + * Regular expression that detects pass-through constructors for ES5 output. This Regex + * intends to capture the common delegation pattern emitted by TypeScript and Babel. Also + * it intends to capture the pattern where existing constructors have been downleveled from + * ES2015 to ES5 using TypeScript w/ downlevel iteration. e.g. + * + * * ``` + * function MyClass() { + * var _this = _super.apply(this, arguments) || this; + * ``` + * + * ``` + * function MyClass() { + * var _this = _super.apply(this, __spread(arguments)) || this; + * ``` + * + * More details can be found in: https://github.com/angular/angular/issues/38453. + */ +export const ES5_DELEGATE_CTOR = + /^function\s+\S+\(\)\s*{[\s\S]+\.apply\(this,\s*(arguments|[^()]+\(arguments\))\)/; +/** Regular expression that detects ES2015 classes which extend from other classes. */ +export const ES2015_INHERITED_CLASS = /^class\s+[A-Za-z\d$_]*\s*extends\s+[^{]+{/; +/** + * Regular expression that detects ES2015 classes which extend from other classes and + * have an explicit constructor defined. + */ +export const ES2015_INHERITED_CLASS_WITH_CTOR = /^class\s+[A-Za-z\d$_]*\s*extends\s+[^{]+{[\s\S]*constructor\s*\(/; -export const INHERITED_CLASS_WITH_DELEGATE_CTOR = +/** + * Regular expression that detects ES2015 classes which extend from other classes + * and inherit a constructor. + */ +export const ES2015_INHERITED_CLASS_WITH_DELEGATE_CTOR = /^class\s+[A-Za-z\d$_]*\s*extends\s+[^{]+{[\s\S]*constructor\s*\(\)\s*{\s*super\(\.\.\.arguments\)/; /** @@ -36,8 +67,9 @@ export const INHERITED_CLASS_WITH_DELEGATE_CTOR = * an initialized instance property. */ export function isDelegateCtor(typeStr: string): boolean { - return DELEGATE_CTOR.test(typeStr) || INHERITED_CLASS_WITH_DELEGATE_CTOR.test(typeStr) || - (INHERITED_CLASS.test(typeStr) && !INHERITED_CLASS_WITH_CTOR.test(typeStr)); + return ES5_DELEGATE_CTOR.test(typeStr) || + ES2015_INHERITED_CLASS_WITH_DELEGATE_CTOR.test(typeStr) || + (ES2015_INHERITED_CLASS.test(typeStr) && !ES2015_INHERITED_CLASS_WITH_CTOR.test(typeStr)); } export class ReflectionCapabilities implements PlatformReflectionCapabilities { diff --git a/packages/core/test/BUILD.bazel b/packages/core/test/BUILD.bazel index d5fc991815..8827492936 100644 --- a/packages/core/test/BUILD.bazel +++ b/packages/core/test/BUILD.bazel @@ -15,6 +15,23 @@ circular_dependency_test( deps = ["//packages/core/testing"], ) +genrule( + name = "downleveled_es5_fixture", + srcs = ["reflection/es2015_inheritance_fixture.ts"], + outs = ["reflection/es5_downleveled_inheritance_fixture.js"], + cmd = """ + es2015_out_dir="/tmp/downleveled_es5_fixture/" + es2015_out_file="$$es2015_out_dir/es2015_inheritance_fixture.js" + + # Build the ES2015 output and then downlevel it to ES5. + $(execpath @npm//typescript/bin:tsc) $< --outDir $$es2015_out_dir --target ES2015 \ + --types --module umd + $(execpath @npm//typescript/bin:tsc) --outFile $@ $$es2015_out_file --allowJs \ + --types --target ES5 + """, + tools = ["@npm//typescript/bin:tsc"], +) + ts_library( name = "test_lib", testonly = True, @@ -22,6 +39,7 @@ ts_library( ["**/*.ts"], exclude = [ "**/*_node_only_spec.ts", + "reflection/es2015_inheritance_fixture.ts", ], ), # Visible to //:saucelabs_unit_tests_poc target @@ -73,6 +91,9 @@ ts_library( jasmine_node_test( name = "test", bootstrap = ["//tools/testing:node_es5"], + data = [ + ":downleveled_es5_fixture", + ], shard_count = 4, deps = [ ":test_lib", @@ -87,6 +108,7 @@ jasmine_node_test( karma_web_test_suite( name = "test_web", + runtime_deps = [":downleveled_es5_fixture"], deps = [ ":test_lib", ], diff --git a/packages/core/test/reflection/es2015_inheritance_fixture.ts b/packages/core/test/reflection/es2015_inheritance_fixture.ts new file mode 100644 index 0000000000..d9ba165ace --- /dev/null +++ b/packages/core/test/reflection/es2015_inheritance_fixture.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright Google LLC 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 + */ + +// AMD module name is required so that this file can be loaded in the Karma tests. +/// + +class Parent {} + +export class ChildNoCtor extends Parent {} +export class ChildWithCtor extends Parent { + constructor() { + super(); + } +} +export class ChildNoCtorPrivateProps extends Parent { + x = 10; +} diff --git a/packages/core/test/reflection/reflector_spec.ts b/packages/core/test/reflection/reflector_spec.ts index 43b732b315..87123b7087 100644 --- a/packages/core/test/reflection/reflector_spec.ts +++ b/packages/core/test/reflection/reflector_spec.ts @@ -201,6 +201,16 @@ class TestObj { expect(isDelegateCtor(ChildWithCtor.toString())).toBe(false); }); + // See: https://github.com/angular/angular/issues/38453 + it('should support ES2015 downleveled classes', () => { + const {ChildNoCtor, ChildNoCtorPrivateProps, ChildWithCtor} = + require('./es5_downleveled_inheritance_fixture'); + + expect(isDelegateCtor(ChildNoCtor.toString())).toBe(true); + expect(isDelegateCtor(ChildNoCtorPrivateProps.toString())).toBe(true); + expect(isDelegateCtor(ChildWithCtor.toString())).toBe(false); + }); + it('should support ES2015 classes when minified', () => { // These classes are ES2015 in minified form const ChildNoCtorMinified = 'class ChildNoCtor extends Parent{}'; From 3b9c802deefcc53ab2b759ac8650449bed75aa9a Mon Sep 17 00:00:00 2001 From: Paul Gschwendtner Date: Fri, 14 Aug 2020 20:43:59 +0200 Subject: [PATCH 049/629] fix(ngcc): detect synthesized delegate constructors for downleveled ES2015 classes (#38463) Similarly to the change we landed in the `@angular/core` reflection capabilities, we need to make sure that ngcc can detect pass-through delegate constructors for classes using downleveled ES2015 output. More details can be found in the preceding commit, and in the issue outlining the problem: #38453. Fixes #38453. PR Close #38463 --- .../compiler-cli/ngcc/src/host/esm5_host.ts | 341 +++++++++++------- .../ngcc/test/host/commonjs_host_spec.ts | 204 +++++++++++ .../ngcc/test/host/esm5_host_spec.ts | 224 ++++++++++-- .../ngcc/test/host/umd_host_spec.ts | 225 ++++++++++++ .../src/reflection/reflection_capabilities.ts | 2 +- 5 files changed, 834 insertions(+), 162 deletions(-) diff --git a/packages/compiler-cli/ngcc/src/host/esm5_host.ts b/packages/compiler-cli/ngcc/src/host/esm5_host.ts index fbaf423b7c..5464fbcff4 100644 --- a/packages/compiler-cli/ngcc/src/host/esm5_host.ts +++ b/packages/compiler-cli/ngcc/src/host/esm5_host.ts @@ -8,7 +8,7 @@ import * as ts from 'typescript'; -import {ClassDeclaration, ClassMember, ClassMemberKind, Declaration, Decorator, FunctionDefinition, Parameter, reflectObjectLiteral} from '../../../src/ngtsc/reflection'; +import {ClassDeclaration, ClassMember, ClassMemberKind, Declaration, Decorator, FunctionDefinition, KnownDeclaration, Parameter, reflectObjectLiteral} from '../../../src/ngtsc/reflection'; import {getTsHelperFnFromDeclaration, getTsHelperFnFromIdentifier, hasNameIdentifier} from '../utils'; import {Esm2015ReflectionHost, getClassDeclarationFromInnerDeclaration, getPropertyValueFromSymbol, isAssignmentStatement, ParamInfo} from './esm2015_host'; @@ -219,7 +219,7 @@ export class Esm5ReflectionHost extends Esm2015ReflectionHost { return Array.from(constructor.parameters); } - if (isSynthesizedConstructor(constructor)) { + if (this.isSynthesizedConstructor(constructor)) { return null; } @@ -352,6 +352,219 @@ export class Esm5ReflectionHost extends Esm2015ReflectionHost { const classDeclarationParent = classSymbol.implementation.valueDeclaration.parent; return ts.isBlock(classDeclarationParent) ? Array.from(classDeclarationParent.statements) : []; } + + ///////////// Host Private Helpers ///////////// + + /** + * A constructor function may have been "synthesized" by TypeScript during JavaScript emit, + * in the case no user-defined constructor exists and e.g. property initializers are used. + * Those initializers need to be emitted into a constructor in JavaScript, so the TypeScript + * compiler generates a synthetic constructor. + * + * We need to identify such constructors as ngcc needs to be able to tell if a class did + * originally have a constructor in the TypeScript source. For ES5, we can not tell an + * empty constructor apart from a synthesized constructor, but fortunately that does not + * matter for the code generated by ngtsc. + * + * When a class has a superclass however, a synthesized constructor must not be considered + * as a user-defined constructor as that prevents a base factory call from being created by + * ngtsc, resulting in a factory function that does not inject the dependencies of the + * superclass. Hence, we identify a default synthesized super call in the constructor body, + * according to the structure that TypeScript's ES2015 to ES5 transformer generates in + * https://github.com/Microsoft/TypeScript/blob/v3.2.2/src/compiler/transformers/es2015.ts#L1082-L1098 + * + * Additionally, we handle synthetic delegate constructors that are emitted when TypeScript + * downlevel's ES2015 synthetically generated to ES5. These vary slightly from the default + * structure mentioned above because the ES2015 output uses a spread operator, for delegating + * to the parent constructor, that is preserved through a TypeScript helper in ES5. e.g. + * + * ``` + * return _super.apply(this, tslib.__spread(arguments)) || this; + * ``` + * + * Such constructs can be still considered as synthetic delegate constructors as they are + * the product of a common TypeScript to ES5 synthetic constructor, just being downleveled + * to ES5 using `tsc`. See: https://github.com/angular/angular/issues/38453. + * + * + * @param constructor a constructor function to test + * @returns true if the constructor appears to have been synthesized + */ + private isSynthesizedConstructor(constructor: ts.FunctionDeclaration): boolean { + if (!constructor.body) return false; + + const firstStatement = constructor.body.statements[0]; + if (!firstStatement) return false; + + return this.isSynthesizedSuperThisAssignment(firstStatement) || + this.isSynthesizedSuperReturnStatement(firstStatement); + } + + /** + * Identifies synthesized super calls which pass-through function arguments directly and are + * being assigned to a common `_this` variable. The following patterns we intend to match: + * + * 1. Delegate call emitted by TypeScript when it emits ES5 directly. + * ``` + * var _this = _super !== null && _super.apply(this, arguments) || this; + * ``` + * + * 2. Delegate call emitted by TypeScript when it downlevel's ES2015 to ES5. + * ``` + * var _this = _super.apply(this, tslib.__spread(arguments)) || this; + * ``` + * + * + * @param statement a statement that may be a synthesized super call + * @returns true if the statement looks like a synthesized super call + */ + private isSynthesizedSuperThisAssignment(statement: ts.Statement): boolean { + if (!ts.isVariableStatement(statement)) return false; + + const variableDeclarations = statement.declarationList.declarations; + if (variableDeclarations.length !== 1) return false; + + const variableDeclaration = variableDeclarations[0]; + if (!ts.isIdentifier(variableDeclaration.name) || + !variableDeclaration.name.text.startsWith('_this')) + return false; + + const initializer = variableDeclaration.initializer; + if (!initializer) return false; + + return this.isSynthesizedDefaultSuperCall(initializer); + } + /** + * Identifies synthesized super calls which pass-through function arguments directly and + * are being returned. The following patterns correspond to synthetic super return calls: + * + * 1. Delegate call emitted by TypeScript when it emits ES5 directly. + * ``` + * return _super !== null && _super.apply(this, arguments) || this; + * ``` + * + * 2. Delegate call emitted by TypeScript when it downlevel's ES2015 to ES5. + * ``` + * return _super.apply(this, tslib.__spread(arguments)) || this; + * ``` + * + * @param statement a statement that may be a synthesized super call + * @returns true if the statement looks like a synthesized super call + */ + private isSynthesizedSuperReturnStatement(statement: ts.Statement): boolean { + if (!ts.isReturnStatement(statement)) return false; + + const expression = statement.expression; + if (!expression) return false; + + return this.isSynthesizedDefaultSuperCall(expression); + } + + /** + * Identifies synthesized super calls which pass-through function arguments directly. The + * synthetic delegate super call match the following patterns we intend to match: + * + * 1. Delegate call emitted by TypeScript when it emits ES5 directly. + * ``` + * _super !== null && _super.apply(this, arguments) || this; + * ``` + * + * 2. Delegate call emitted by TypeScript when it downlevel's ES2015 to ES5. + * ``` + * _super.apply(this, tslib.__spread(arguments)) || this; + * ``` + * + * @param expression an expression that may represent a default super call + * @returns true if the expression corresponds with the above form + */ + private isSynthesizedDefaultSuperCall(expression: ts.Expression): boolean { + if (!isBinaryExpr(expression, ts.SyntaxKind.BarBarToken)) return false; + if (expression.right.kind !== ts.SyntaxKind.ThisKeyword) return false; + + const left = expression.left; + if (isBinaryExpr(left, ts.SyntaxKind.AmpersandAmpersandToken)) { + return isSuperNotNull(left.left) && this.isSuperApplyCall(left.right); + } else { + return this.isSuperApplyCall(left); + } + } + + /** + * Tests whether the expression corresponds to a `super` call passing through + * function arguments without any modification. e.g. + * + * ``` + * _super !== null && _super.apply(this, arguments) || this; + * ``` + * + * This structure is generated by TypeScript when transforming ES2015 to ES5, see + * https://github.com/Microsoft/TypeScript/blob/v3.2.2/src/compiler/transformers/es2015.ts#L1148-L1163 + * + * Additionally, we also handle cases where `arguments` are wrapped by a TypeScript spread helper. + * This can happen if ES2015 class output contain auto-generated constructors due to class + * members. The ES2015 output will be using `super(...arguments)` to delegate to the superclass, + * but once downleveled to ES5, the spread operator will be persisted through a TypeScript spread + * helper. For example: + * + * ``` + * _super.apply(this, __spread(arguments)) || this; + * ``` + * + * More details can be found in: https://github.com/angular/angular/issues/38453. + * + * @param expression an expression that may represent a default super call + * @returns true if the expression corresponds with the above form + */ + private isSuperApplyCall(expression: ts.Expression): boolean { + if (!ts.isCallExpression(expression) || expression.arguments.length !== 2) return false; + + const targetFn = expression.expression; + if (!ts.isPropertyAccessExpression(targetFn)) return false; + if (!isSuperIdentifier(targetFn.expression)) return false; + if (targetFn.name.text !== 'apply') return false; + + const thisArgument = expression.arguments[0]; + if (thisArgument.kind !== ts.SyntaxKind.ThisKeyword) return false; + + const argumentsExpr = expression.arguments[1]; + + // If the super is directly invoked with `arguments`, return `true`. This represents the + // common TypeScript output where the delegate constructor super call matches the following + // pattern: `super.apply(this, arguments)`. + if (isArgumentsIdentifier(argumentsExpr)) { + return true; + } + + // The other scenario we intend to detect: The `arguments` variable might be wrapped with the + // TypeScript spread helper (either through tslib or inlined). This can happen if an explicit + // delegate constructor uses `super(...arguments)` in ES2015 and is downleveled to ES5 using + // `--downlevelIteration`. The output in such cases would not directly pass the function + // `arguments` to the `super` call, but wrap it in a TS spread helper. The output would match + // the following pattern: `super.apply(this, tslib.__spread(arguments))`. We check for such + // constructs below, but perform the detection of the call expression definition as last as + // that is the most expensive operation here. + if (!ts.isCallExpression(argumentsExpr) || argumentsExpr.arguments.length !== 1 || + !isArgumentsIdentifier(argumentsExpr.arguments[0])) { + return false; + } + + const argumentsCallExpr = argumentsExpr.expression; + let argumentsCallDeclaration: Declaration|null = null; + + // The `__spread` helper could be globally available, or accessed through a namespaced + // import. Hence we support a property access here as long as it resolves to the actual + // known TypeScript spread helper. + if (ts.isIdentifier(argumentsCallExpr)) { + argumentsCallDeclaration = this.getDeclarationOfIdentifier(argumentsCallExpr); + } else if ( + ts.isPropertyAccessExpression(argumentsCallExpr) && + ts.isIdentifier(argumentsCallExpr.name)) { + argumentsCallDeclaration = this.getDeclarationOfIdentifier(argumentsCallExpr.name); + } + + return argumentsCallDeclaration !== null && + argumentsCallDeclaration.known === KnownDeclaration.TsHelperSpread; + } } ///////////// Internal Helpers ///////////// @@ -422,103 +635,8 @@ function reflectArrayElement(element: ts.Expression) { return ts.isObjectLiteralExpression(element) ? reflectObjectLiteral(element) : null; } -/** - * A constructor function may have been "synthesized" by TypeScript during JavaScript emit, - * in the case no user-defined constructor exists and e.g. property initializers are used. - * Those initializers need to be emitted into a constructor in JavaScript, so the TypeScript - * compiler generates a synthetic constructor. - * - * We need to identify such constructors as ngcc needs to be able to tell if a class did - * originally have a constructor in the TypeScript source. For ES5, we can not tell an - * empty constructor apart from a synthesized constructor, but fortunately that does not - * matter for the code generated by ngtsc. - * - * When a class has a superclass however, a synthesized constructor must not be considered - * as a user-defined constructor as that prevents a base factory call from being created by - * ngtsc, resulting in a factory function that does not inject the dependencies of the - * superclass. Hence, we identify a default synthesized super call in the constructor body, - * according to the structure that TypeScript's ES2015 to ES5 transformer generates in - * https://github.com/Microsoft/TypeScript/blob/v3.2.2/src/compiler/transformers/es2015.ts#L1082-L1098 - * - * @param constructor a constructor function to test - * @returns true if the constructor appears to have been synthesized - */ -function isSynthesizedConstructor(constructor: ts.FunctionDeclaration): boolean { - if (!constructor.body) return false; - - const firstStatement = constructor.body.statements[0]; - if (!firstStatement) return false; - - return isSynthesizedSuperThisAssignment(firstStatement) || - isSynthesizedSuperReturnStatement(firstStatement); -} - -/** - * Identifies a synthesized super call of the form: - * - * ``` - * var _this = _super !== null && _super.apply(this, arguments) || this; - * ``` - * - * @param statement a statement that may be a synthesized super call - * @returns true if the statement looks like a synthesized super call - */ -function isSynthesizedSuperThisAssignment(statement: ts.Statement): boolean { - if (!ts.isVariableStatement(statement)) return false; - - const variableDeclarations = statement.declarationList.declarations; - if (variableDeclarations.length !== 1) return false; - - const variableDeclaration = variableDeclarations[0]; - if (!ts.isIdentifier(variableDeclaration.name) || - !variableDeclaration.name.text.startsWith('_this')) - return false; - - const initializer = variableDeclaration.initializer; - if (!initializer) return false; - - return isSynthesizedDefaultSuperCall(initializer); -} -/** - * Identifies a synthesized super call of the form: - * - * ``` - * return _super !== null && _super.apply(this, arguments) || this; - * ``` - * - * @param statement a statement that may be a synthesized super call - * @returns true if the statement looks like a synthesized super call - */ -function isSynthesizedSuperReturnStatement(statement: ts.Statement): boolean { - if (!ts.isReturnStatement(statement)) return false; - - const expression = statement.expression; - if (!expression) return false; - - return isSynthesizedDefaultSuperCall(expression); -} - -/** - * Tests whether the expression is of the form: - * - * ``` - * _super !== null && _super.apply(this, arguments) || this; - * ``` - * - * This structure is generated by TypeScript when transforming ES2015 to ES5, see - * https://github.com/Microsoft/TypeScript/blob/v3.2.2/src/compiler/transformers/es2015.ts#L1148-L1163 - * - * @param expression an expression that may represent a default super call - * @returns true if the expression corresponds with the above form - */ -function isSynthesizedDefaultSuperCall(expression: ts.Expression): boolean { - if (!isBinaryExpr(expression, ts.SyntaxKind.BarBarToken)) return false; - if (expression.right.kind !== ts.SyntaxKind.ThisKeyword) return false; - - const left = expression.left; - if (!isBinaryExpr(left, ts.SyntaxKind.AmpersandAmpersandToken)) return false; - - return isSuperNotNull(left.left) && isSuperApplyCall(left.right); +function isArgumentsIdentifier(expression: ts.Expression): boolean { + return ts.isIdentifier(expression) && expression.text === 'arguments'; } function isSuperNotNull(expression: ts.Expression): boolean { @@ -526,31 +644,6 @@ function isSuperNotNull(expression: ts.Expression): boolean { isSuperIdentifier(expression.left); } -/** - * Tests whether the expression is of the form - * - * ``` - * _super.apply(this, arguments) - * ``` - * - * @param expression an expression that may represent a default super call - * @returns true if the expression corresponds with the above form - */ -function isSuperApplyCall(expression: ts.Expression): boolean { - if (!ts.isCallExpression(expression) || expression.arguments.length !== 2) return false; - - const targetFn = expression.expression; - if (!ts.isPropertyAccessExpression(targetFn)) return false; - if (!isSuperIdentifier(targetFn.expression)) return false; - if (targetFn.name.text !== 'apply') return false; - - const thisArgument = expression.arguments[0]; - if (thisArgument.kind !== ts.SyntaxKind.ThisKeyword) return false; - - const argumentsArgument = expression.arguments[1]; - return ts.isIdentifier(argumentsArgument) && argumentsArgument.text === 'arguments'; -} - function isBinaryExpr( expression: ts.Expression, operator: ts.BinaryOperator): expression is ts.BinaryExpression { return ts.isBinaryExpression(expression) && expression.operatorToken.kind === operator; diff --git a/packages/compiler-cli/ngcc/test/host/commonjs_host_spec.ts b/packages/compiler-cli/ngcc/test/host/commonjs_host_spec.ts index 922b88f777..d2e1fcf02b 100644 --- a/packages/compiler-cli/ngcc/test/host/commonjs_host_spec.ts +++ b/packages/compiler-cli/ngcc/test/host/commonjs_host_spec.ts @@ -1456,6 +1456,210 @@ exports.MissingClass2 = MissingClass2; expect(decorators[0].args).toEqual([]); }); }); + + function getConstructorParameters( + constructor: string, mode?: 'inlined'|'inlined_with_suffix'|'imported') { + let fileHeader = ''; + + switch (mode) { + case 'imported': + fileHeader = `const tslib = require('tslib');`; + break; + case 'inlined': + fileHeader = + `var __spread = (this && this.__spread) || function (...args) { /* ... */ }`; + break; + case 'inlined_with_suffix': + fileHeader = + `var __spread$1 = (this && this.__spread$1) || function (...args) { /* ... */ }`; + break; + } + const file = { + name: _('/synthesized_constructors.js'), + contents: ` + ${fileHeader} + + var TestClass = /** @class */ (function (_super) { + __extends(TestClass, _super); + ${constructor} + return TestClass; + }(null)); + + exports.TestClass = TestClass;`, + }; + + loadTestFiles([file]); + const bundle = makeTestBundleProgram(file.name); + const host = + createHost(bundle, new CommonJsReflectionHost(new MockLogger(), false, bundle)); + const classNode = + getDeclaration(bundle.program, file.name, 'TestClass', isNamedVariableDeclaration); + return host.getConstructorParameters(classNode); + } + + describe('TS -> ES5: synthesized constructors', () => { + it('recognizes _this assignment from super call', () => { + const parameters = getConstructorParameters(` + function TestClass() { + var _this = _super !== null && _super.apply(this, arguments) || this; + _this.synthesizedProperty = null; + return _this; + } + `); + + expect(parameters).toBeNull(); + }); + + it('recognizes super call as return statement', () => { + const parameters = getConstructorParameters(` + function TestClass() { + return _super !== null && _super.apply(this, arguments) || this; + } + `); + + expect(parameters).toBeNull(); + }); + + it('handles the case where a unique name was generated for _super or _this', () => { + const parameters = getConstructorParameters(` + function TestClass() { + var _this_1 = _super_1 !== null && _super_1.apply(this, arguments) || this; + _this_1._this = null; + _this_1._super = null; + return _this_1; + } + `); + + expect(parameters).toBeNull(); + }); + + it('does not consider constructors with parameters as synthesized', () => { + const parameters = getConstructorParameters(` + function TestClass(arg) { + return _super !== null && _super.apply(this, arguments) || this; + } + `); + + expect(parameters!.length).toBe(1); + }); + + it('does not consider manual super calls as synthesized', () => { + const parameters = getConstructorParameters(` + function TestClass() { + return _super.call(this) || this; + } + `); + + expect(parameters!.length).toBe(0); + }); + + it('does not consider empty constructors as synthesized', () => { + const parameters = getConstructorParameters(`function TestClass() {}`); + expect(parameters!.length).toBe(0); + }); + }); + + // See: https://github.com/angular/angular/issues/38453. + describe('ES2015 -> ES5: synthesized constructors through TSC downleveling', () => { + it('recognizes delegate super call using inline spread helper', () => { + const parameters = getConstructorParameters( + ` + function TestClass() { + return _super.apply(this, __spread(arguments)) || this; + }`, + 'inlined'); + + expect(parameters).toBeNull(); + }); + + it('recognizes delegate super call using inline spread helper with suffix', () => { + const parameters = getConstructorParameters( + ` + function TestClass() { + return _super.apply(this, __spread$1(arguments)) || this; + }`, + 'inlined_with_suffix'); + + expect(parameters).toBeNull(); + }); + + it('recognizes delegate super call using imported spread helper', () => { + const parameters = getConstructorParameters( + ` + function TestClass() { + return _super.apply(this, tslib.__spread(arguments)) || this; + }`, + 'imported'); + + expect(parameters).toBeNull(); + }); + + describe('with class member assignment', () => { + it('recognizes delegate super call using inline spread helper', () => { + const parameters = getConstructorParameters( + ` + function TestClass() { + var _this = _super.apply(this, __spread(arguments)) || this; + _this.synthesizedProperty = null; + return _this; + }`, + 'inlined'); + + expect(parameters).toBeNull(); + }); + + it('recognizes delegate super call using inline spread helper with suffix', () => { + const parameters = getConstructorParameters( + ` + function TestClass() { + var _this = _super.apply(this, __spread$1(arguments)) || this; + _this.synthesizedProperty = null; + return _this; + }`, + 'inlined_with_suffix'); + + expect(parameters).toBeNull(); + }); + + it('recognizes delegate super call using imported spread helper', () => { + const parameters = getConstructorParameters( + ` + function TestClass() { + var _this = _super.apply(this, tslib.__spread(arguments)) || this; + _this.synthesizedProperty = null; + return _this; + }`, + 'imported'); + + expect(parameters).toBeNull(); + }); + }); + + it('handles the case where a unique name was generated for _super or _this', () => { + const parameters = getConstructorParameters( + ` + function TestClass() { + var _this_1 = _super_1.apply(this, __spread(arguments)) || this; + _this_1._this = null; + _this_1._super = null; + return _this_1; + }`, + 'inlined'); + + expect(parameters).toBeNull(); + }); + + it('does not consider constructors with parameters as synthesized', () => { + const parameters = getConstructorParameters( + ` + function TestClass(arg) { + return _super.apply(this, __spread(arguments)) || this; + }`, + 'inlined'); + + expect(parameters!.length).toBe(1); + }); + }); }); describe('getDefinitionOfFunction()', () => { diff --git a/packages/compiler-cli/ngcc/test/host/esm5_host_spec.ts b/packages/compiler-cli/ngcc/test/host/esm5_host_spec.ts index 6e219658c5..0fb7428196 100644 --- a/packages/compiler-cli/ngcc/test/host/esm5_host_spec.ts +++ b/packages/compiler-cli/ngcc/test/host/esm5_host_spec.ts @@ -1417,86 +1417,236 @@ runInEachFileSystem(() => { }); }); - describe('synthesized constructors', () => { - function getConstructorParameters(constructor: string) { - const file = { - name: _('/synthesized_constructors.js'), - contents: ` + function getConstructorParameters( + constructor: string, + mode?: 'inlined'|'inlined_with_suffix'|'imported'|'imported_namespace') { + let fileHeader = ''; + + switch (mode) { + case 'imported': + fileHeader = `import {__spread} from 'tslib';`; + break; + case 'imported_namespace': + fileHeader = `import * as tslib from 'tslib';`; + break; + case 'inlined': + fileHeader = + `var __spread = (this && this.__spread) || function (...args) { /* ... */ }`; + break; + case 'inlined_with_suffix': + fileHeader = + `var __spread$1 = (this && this.__spread$1) || function (...args) { /* ... */ }`; + break; + } + + const file = { + name: _('/synthesized_constructors.js'), + contents: ` + ${fileHeader} var TestClass = /** @class */ (function (_super) { __extends(TestClass, _super); ${constructor} return TestClass; }(null)); `, - }; + }; - loadTestFiles([file]); - const bundle = makeTestBundleProgram(file.name); - const host = createHost(bundle, new Esm5ReflectionHost(new MockLogger(), false, bundle)); - const classNode = - getDeclaration(bundle.program, file.name, 'TestClass', isNamedVariableDeclaration); - return host.getConstructorParameters(classNode); - } + loadTestFiles([file]); + const bundle = makeTestBundleProgram(file.name); + const host = createHost(bundle, new Esm5ReflectionHost(new MockLogger(), false, bundle)); + const classNode = + getDeclaration(bundle.program, file.name, 'TestClass', isNamedVariableDeclaration); + return host.getConstructorParameters(classNode); + } + describe('TS -> ES5: synthesized constructors', () => { it('recognizes _this assignment from super call', () => { const parameters = getConstructorParameters(` - function TestClass() { - var _this = _super !== null && _super.apply(this, arguments) || this; - _this.synthesizedProperty = null; - return _this; - }`); + function TestClass() { + var _this = _super !== null && _super.apply(this, arguments) || this; + _this.synthesizedProperty = null; + return _this; + } + `); expect(parameters).toBeNull(); }); it('recognizes super call as return statement', () => { const parameters = getConstructorParameters(` - function TestClass() { - return _super !== null && _super.apply(this, arguments) || this; - }`); + function TestClass() { + return _super !== null && _super.apply(this, arguments) || this; + } + `); expect(parameters).toBeNull(); }); it('handles the case where a unique name was generated for _super or _this', () => { const parameters = getConstructorParameters(` - function TestClass() { - var _this_1 = _super_1 !== null && _super_1.apply(this, arguments) || this; - _this_1._this = null; - _this_1._super = null; - return _this_1; - }`); + function TestClass() { + var _this_1 = _super_1 !== null && _super_1.apply(this, arguments) || this; + _this_1._this = null; + _this_1._super = null; + return _this_1; + } + `); expect(parameters).toBeNull(); }); it('does not consider constructors with parameters as synthesized', () => { const parameters = getConstructorParameters(` - function TestClass(arg) { - return _super !== null && _super.apply(this, arguments) || this; - }`); + function TestClass(arg) { + return _super !== null && _super.apply(this, arguments) || this; + } + `); expect(parameters!.length).toBe(1); }); it('does not consider manual super calls as synthesized', () => { const parameters = getConstructorParameters(` - function TestClass() { - return _super.call(this) || this; - }`); + function TestClass() { + return _super.call(this) || this; + } + `); expect(parameters!.length).toBe(0); }); it('does not consider empty constructors as synthesized', () => { - const parameters = getConstructorParameters(` - function TestClass() { - }`); - + const parameters = getConstructorParameters(`function TestClass() {}`); expect(parameters!.length).toBe(0); }); }); + // See: https://github.com/angular/angular/issues/38453. + describe('ES2015 -> ES5: synthesized constructors through TSC downleveling', () => { + it('recognizes delegate super call using inline spread helper', () => { + const parameters = getConstructorParameters( + ` + function TestClass() { + return _super.apply(this, __spread(arguments)) || this; + }`, + 'inlined'); + + expect(parameters).toBeNull(); + }); + + it('recognizes delegate super call using inline spread helper with suffix', () => { + const parameters = getConstructorParameters( + ` + function TestClass() { + return _super.apply(this, __spread$1(arguments)) || this; + }`, + 'inlined_with_suffix'); + + expect(parameters).toBeNull(); + }); + + it('recognizes delegate super call using imported spread helper', () => { + const parameters = getConstructorParameters( + ` + function TestClass() { + return _super.apply(this, __spread(arguments)) || this; + }`, + 'imported'); + + expect(parameters).toBeNull(); + }); + + it('recognizes delegate super call using namespace imported spread helper', () => { + const parameters = getConstructorParameters( + ` + function TestClass() { + return _super.apply(this, tslib.__spread(arguments)) || this; + }`, + 'imported_namespace'); + + expect(parameters).toBeNull(); + }); + + describe('with class member assignment', () => { + it('recognizes delegate super call using inline spread helper', () => { + const parameters = getConstructorParameters( + ` + function TestClass() { + var _this = _super.apply(this, __spread(arguments)) || this; + _this.synthesizedProperty = null; + return _this; + }`, + 'inlined'); + + expect(parameters).toBeNull(); + }); + + it('recognizes delegate super call using inline spread helper with suffix', () => { + const parameters = getConstructorParameters( + ` + function TestClass() { + var _this = _super.apply(this, __spread$1(arguments)) || this; + _this.synthesizedProperty = null; + return _this; + }`, + 'inlined_with_suffix'); + + expect(parameters).toBeNull(); + }); + + it('recognizes delegate super call using imported spread helper', () => { + const parameters = getConstructorParameters( + ` + function TestClass() { + var _this = _super.apply(this, __spread(arguments)) || this; + _this.synthesizedProperty = null; + return _this; + }`, + 'imported'); + + expect(parameters).toBeNull(); + }); + + it('recognizes delegate super call using namespace imported spread helper', () => { + const parameters = getConstructorParameters( + ` + function TestClass() { + var _this = _super.apply(this, tslib.__spread(arguments)) || this; + _this.synthesizedProperty = null; + return _this; + }`, + 'imported_namespace'); + + expect(parameters).toBeNull(); + }); + }); + + it('handles the case where a unique name was generated for _super or _this', () => { + const parameters = getConstructorParameters( + ` + function TestClass() { + var _this_1 = _super_1.apply(this, __spread(arguments)) || this; + _this_1._this = null; + _this_1._super = null; + return _this_1; + }`, + 'inlined'); + + expect(parameters).toBeNull(); + }); + + it('does not consider constructors with parameters as synthesized', () => { + const parameters = getConstructorParameters( + ` + function TestClass(arg) { + return _super.apply(this, __spread(arguments)) || this; + }`, + 'inlined'); + + expect(parameters!.length).toBe(1); + }); + }); + describe('(returned parameters `decorators.args`)', () => { it('should be an empty array if param decorator has no `args` property', () => { loadTestFiles([INVALID_CTOR_DECORATOR_ARGS_FILE]); diff --git a/packages/compiler-cli/ngcc/test/host/umd_host_spec.ts b/packages/compiler-cli/ngcc/test/host/umd_host_spec.ts index 01a10eb4bb..a25b09fb5a 100644 --- a/packages/compiler-cli/ngcc/test/host/umd_host_spec.ts +++ b/packages/compiler-cli/ngcc/test/host/umd_host_spec.ts @@ -1564,6 +1564,231 @@ runInEachFileSystem(() => { expect(decorators[0].args).toEqual([]); }); }); + + function getConstructorParameters( + constructor: string, mode: 'inlined'|'inlined_with_suffix'|'imported' = 'imported') { + let fileHeaderWithUmd = ''; + + switch (mode) { + case 'imported': + fileHeaderWithUmd = ` + (function (global, factory) { + typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('tslib'))) : + typeof define === 'function' && define.amd ? define('test', ['exports', 'tslib'], factory) : + (factory(global.test, global.tslib)); + }(this, (function (exports, tslib) { 'use strict'; + `; + break; + case 'inlined': + fileHeaderWithUmd = ` + (function (global, factory) { + typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports)) : + typeof define === 'function' && define.amd ? define('test', ['exports'], factory) : + (factory(global.test)); + }(this, (function (exports) { 'use strict'; + + var __spread = (this && this.__spread) || function (...args) { /* ... */ } + `; + break; + case 'inlined_with_suffix': + fileHeaderWithUmd = ` + (function (global, factory) { + typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports)) : + typeof define === 'function' && define.amd ? define('test', ['exports'], factory) : + (factory(global.test)); + }(this, (function (exports) { 'use strict'; + + var __spread$1 = (this && this.__spread$1) || function (...args) { /* ... */ } + `; + break; + } + + const file = { + name: _('/synthesized_constructors.js'), + contents: ` + ${fileHeaderWithUmd} + var TestClass = /** @class */ (function (_super) { + __extends(TestClass, _super); + ${constructor} + return TestClass; + }(null)); + + exports.TestClass = TestClass; + }))); + `, + }; + + loadTestFiles([file]); + const bundle = makeTestBundleProgram(file.name); + const host = createHost(bundle, new UmdReflectionHost(new MockLogger(), false, bundle)); + const classNode = + getDeclaration(bundle.program, file.name, 'TestClass', isNamedVariableDeclaration); + return host.getConstructorParameters(classNode); + } + + describe('TS -> ES5: synthesized constructors', () => { + it('recognizes _this assignment from super call', () => { + const parameters = getConstructorParameters(` + function TestClass() { + var _this = _super !== null && _super.apply(this, arguments) || this; + _this.synthesizedProperty = null; + return _this; + } + `); + + expect(parameters).toBeNull(); + }); + + it('recognizes super call as return statement', () => { + const parameters = getConstructorParameters(` + function TestClass() { + return _super !== null && _super.apply(this, arguments) || this; + } + `); + + expect(parameters).toBeNull(); + }); + + it('handles the case where a unique name was generated for _super or _this', () => { + const parameters = getConstructorParameters(` + function TestClass() { + var _this_1 = _super_1 !== null && _super_1.apply(this, arguments) || this; + _this_1._this = null; + _this_1._super = null; + return _this_1; + } + `); + + expect(parameters).toBeNull(); + }); + + it('does not consider constructors with parameters as synthesized', () => { + const parameters = getConstructorParameters(` + function TestClass(arg) { + return _super !== null && _super.apply(this, arguments) || this; + } + `); + + expect(parameters!.length).toBe(1); + }); + + it('does not consider manual super calls as synthesized', () => { + const parameters = getConstructorParameters(` + function TestClass() { + return _super.call(this) || this; + } + `); + + expect(parameters!.length).toBe(0); + }); + + it('does not consider empty constructors as synthesized', () => { + const parameters = getConstructorParameters(`function TestClass() {}`); + expect(parameters!.length).toBe(0); + }); + }); + + // See: https://github.com/angular/angular/issues/38453. + describe('ES2015 -> ES5: synthesized constructors through TSC downleveling', () => { + it('recognizes delegate super call using inline spread helper', () => { + const parameters = getConstructorParameters( + ` + function TestClass() { + return _super.apply(this, __spread(arguments)) || this; + }`, + 'inlined'); + + expect(parameters).toBeNull(); + }); + + it('recognizes delegate super call using inline spread helper with suffix', () => { + const parameters = getConstructorParameters( + ` + function TestClass() { + return _super.apply(this, __spread$1(arguments)) || this; + }`, + 'inlined_with_suffix'); + + expect(parameters).toBeNull(); + }); + + it('recognizes delegate super call using imported spread helper', () => { + const parameters = getConstructorParameters( + ` + function TestClass() { + return _super.apply(this, tslib_1.__spread(arguments)) || this; + }`, + 'imported'); + + expect(parameters).toBeNull(); + }); + + describe('with class member assignment', () => { + it('recognizes delegate super call using inline spread helper', () => { + const parameters = getConstructorParameters( + ` + function TestClass() { + var _this = _super.apply(this, __spread(arguments)) || this; + _this.synthesizedProperty = null; + return _this; + }`, + 'inlined'); + + expect(parameters).toBeNull(); + }); + + it('recognizes delegate super call using inline spread helper with suffix', () => { + const parameters = getConstructorParameters( + ` + function TestClass() { + var _this = _super.apply(this, __spread$1(arguments)) || this; + _this.synthesizedProperty = null; + return _this; + }`, + 'inlined_with_suffix'); + + expect(parameters).toBeNull(); + }); + + it('recognizes delegate super call using imported spread helper', () => { + const parameters = getConstructorParameters( + ` + function TestClass() { + var _this = _super.apply(this, tslib_1.__spread(arguments)) || this; + _this.synthesizedProperty = null; + return _this; + }`, + 'imported'); + + expect(parameters).toBeNull(); + }); + }); + + it('handles the case where a unique name was generated for _super or _this', () => { + const parameters = getConstructorParameters( + ` + function TestClass() { + var _this_1 = _super_1.apply(this, __spread(arguments)) || this; + _this_1._this = null; + _this_1._super = null; + return _this_1; + }`, + 'inlined'); + + expect(parameters).toBeNull(); + }); + + it('does not consider constructors with parameters as synthesized', () => { + const parameters = getConstructorParameters( + ` + function TestClass(arg) { + return _super.apply(this, __spread(arguments)) || this; + }`, + 'inlined'); + + expect(parameters!.length).toBe(1); + }); + }); }); describe('getDefinitionOfFunction()', () => { diff --git a/packages/core/src/reflection/reflection_capabilities.ts b/packages/core/src/reflection/reflection_capabilities.ts index b4880c4188..75575ebb13 100644 --- a/packages/core/src/reflection/reflection_capabilities.ts +++ b/packages/core/src/reflection/reflection_capabilities.ts @@ -29,7 +29,7 @@ import {GetterFn, MethodFn, SetterFn} from './types'; * it intends to capture the pattern where existing constructors have been downleveled from * ES2015 to ES5 using TypeScript w/ downlevel iteration. e.g. * - * * ``` + * ``` * function MyClass() { * var _this = _super.apply(this, arguments) || this; * ``` From cfe424e875f77bf7708feeea9a2347790d14aaf2 Mon Sep 17 00:00:00 2001 From: Keen Yee Liau Date: Thu, 30 Jul 2020 21:26:02 -0700 Subject: [PATCH 050/629] refactor(language-service): [Ivy] remove temporary compiler (#38310) Now that Ivy compiler has a proper `TemplateTypeChecker` interface (see https://github.com/angular/angular/pull/38105) we no longer need to keep the temporary compiler implementation. The temporary compiler was created to enable testing infrastructure to be developed for the Ivy language service. This commit removes the whole `ivy/compiler` directory and moves two functions `createTypeCheckingProgramStrategy` and `getOrCreateTypeCheckScriptInfo` to the `LanguageService` class. Also re-enable the Ivy LS test since it's no longer blocking development. PR Close #38310 --- packages/language-service/ivy/BUILD.bazel | 8 +- .../language-service/ivy/compiler/BUILD.bazel | 17 --- .../language-service/ivy/compiler/README.md | 2 - .../language-service/ivy/compiler/compiler.ts | 124 ----------------- .../ivy/compiler/compiler_host.ts | 103 --------------- .../language-service/ivy/language_service.ts | 125 ++++++++++++++++-- .../language-service/ivy/test/BUILD.bazel | 1 - 7 files changed, 119 insertions(+), 261 deletions(-) delete mode 100644 packages/language-service/ivy/compiler/BUILD.bazel delete mode 100644 packages/language-service/ivy/compiler/README.md delete mode 100644 packages/language-service/ivy/compiler/compiler.ts delete mode 100644 packages/language-service/ivy/compiler/compiler_host.ts diff --git a/packages/language-service/ivy/BUILD.bazel b/packages/language-service/ivy/BUILD.bazel index f71c4b7141..3519eedd13 100644 --- a/packages/language-service/ivy/BUILD.bazel +++ b/packages/language-service/ivy/BUILD.bazel @@ -7,7 +7,13 @@ ts_library( srcs = glob(["*.ts"]), deps = [ "//packages/compiler-cli", - "//packages/language-service/ivy/compiler", + "//packages/compiler-cli/src/ngtsc/core", + "//packages/compiler-cli/src/ngtsc/core:api", + "//packages/compiler-cli/src/ngtsc/file_system", + "//packages/compiler-cli/src/ngtsc/incremental", + "//packages/compiler-cli/src/ngtsc/shims", + "//packages/compiler-cli/src/ngtsc/typecheck", + "//packages/compiler-cli/src/ngtsc/typecheck/api", "@npm//typescript", ], ) diff --git a/packages/language-service/ivy/compiler/BUILD.bazel b/packages/language-service/ivy/compiler/BUILD.bazel deleted file mode 100644 index 22c25e3ba1..0000000000 --- a/packages/language-service/ivy/compiler/BUILD.bazel +++ /dev/null @@ -1,17 +0,0 @@ -load("//tools:defaults.bzl", "ts_library") - -package(default_visibility = ["//packages/language-service/ivy:__pkg__"]) - -ts_library( - name = "compiler", - srcs = glob(["*.ts"]), - deps = [ - "//packages/compiler-cli", - "//packages/compiler-cli/src/ngtsc/core", - "//packages/compiler-cli/src/ngtsc/file_system", - "//packages/compiler-cli/src/ngtsc/incremental", - "//packages/compiler-cli/src/ngtsc/typecheck", - "//packages/compiler-cli/src/ngtsc/typecheck/api", - "@npm//typescript", - ], -) diff --git a/packages/language-service/ivy/compiler/README.md b/packages/language-service/ivy/compiler/README.md deleted file mode 100644 index 2945bc7dac..0000000000 --- a/packages/language-service/ivy/compiler/README.md +++ /dev/null @@ -1,2 +0,0 @@ -All files in this directory are temporary. This is created to simulate the final -form of the Ivy compiler that supports language service. diff --git a/packages/language-service/ivy/compiler/compiler.ts b/packages/language-service/ivy/compiler/compiler.ts deleted file mode 100644 index 9454666ffb..0000000000 --- a/packages/language-service/ivy/compiler/compiler.ts +++ /dev/null @@ -1,124 +0,0 @@ - -/** - * @license - * Copyright Google LLC 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 {CompilerOptions} from '@angular/compiler-cli'; -import {NgCompiler, NgCompilerHost} from '@angular/compiler-cli/src/ngtsc/core'; -import {absoluteFromSourceFile, AbsoluteFsPath} from '@angular/compiler-cli/src/ngtsc/file_system'; -import {PatchedProgramIncrementalBuildStrategy} from '@angular/compiler-cli/src/ngtsc/incremental'; -import {TypeCheckShimGenerator} from '@angular/compiler-cli/src/ngtsc/typecheck'; -import {TypeCheckingProgramStrategy, UpdateMode} from '@angular/compiler-cli/src/ngtsc/typecheck/api'; -import * as ts from 'typescript/lib/tsserverlibrary'; - -import {makeCompilerHostFromProject} from './compiler_host'; - -interface AnalysisResult { - compiler: NgCompiler; - program: ts.Program; -} - -export class Compiler { - private tsCompilerHost: ts.CompilerHost; - private lastKnownProgram: ts.Program|null = null; - private readonly strategy: TypeCheckingProgramStrategy; - - constructor(private readonly project: ts.server.Project, private options: CompilerOptions) { - this.tsCompilerHost = makeCompilerHostFromProject(project); - this.strategy = createTypeCheckingProgramStrategy(project); - // Do not retrieve the program in constructor because project is still in - // the process of loading, and not all data members have been initialized. - } - - setCompilerOptions(options: CompilerOptions) { - this.options = options; - } - - analyze(): AnalysisResult|undefined { - const inputFiles = this.project.getRootFiles(); - const ngCompilerHost = - NgCompilerHost.wrap(this.tsCompilerHost, inputFiles, this.options, this.lastKnownProgram); - const program = this.strategy.getProgram(); - const compiler = new NgCompiler( - ngCompilerHost, this.options, program, this.strategy, - new PatchedProgramIncrementalBuildStrategy(), this.lastKnownProgram); - try { - // This is the only way to force the compiler to update the typecheck file - // in the program. We have to do try-catch because the compiler immediately - // throws if it fails to parse any template in the entire program! - const d = compiler.getDiagnostics(); - if (d.length) { - // There could be global compilation errors. It's useful to print them - // out in development. - console.error(d.map(d => ts.flattenDiagnosticMessageText(d.messageText, '\n'))); - } - } catch (e) { - console.error('Failed to analyze program', e.message); - return; - } - this.lastKnownProgram = compiler.getNextProgram(); - return { - compiler, - program: this.lastKnownProgram, - }; - } -} - -function createTypeCheckingProgramStrategy(project: ts.server.Project): - TypeCheckingProgramStrategy { - return { - supportsInlineOperations: false, - shimPathForComponent(component: ts.ClassDeclaration): AbsoluteFsPath { - return TypeCheckShimGenerator.shimFor(absoluteFromSourceFile(component.getSourceFile())); - }, - getProgram(): ts.Program { - const program = project.getLanguageService().getProgram(); - if (!program) { - throw new Error('Language service does not have a program!'); - } - return program; - }, - updateFiles(contents: Map, updateMode: UpdateMode) { - if (updateMode !== UpdateMode.Complete) { - throw new Error(`Incremental update mode is currently not supported`); - } - for (const [fileName, newText] of contents) { - const scriptInfo = getOrCreateTypeCheckScriptInfo(project, fileName); - const snapshot = scriptInfo.getSnapshot(); - const length = snapshot.getLength(); - scriptInfo.editContent(0, length, newText); - } - }, - }; -} - -function getOrCreateTypeCheckScriptInfo( - project: ts.server.Project, tcf: string): ts.server.ScriptInfo { - // First check if there is already a ScriptInfo for the tcf - const {projectService} = project; - let scriptInfo = projectService.getScriptInfo(tcf); - if (!scriptInfo) { - // ScriptInfo needs to be opened by client to be able to set its user-defined - // content. We must also provide file content, otherwise the service will - // attempt to fetch the content from disk and fail. - scriptInfo = projectService.getOrCreateScriptInfoForNormalizedPath( - ts.server.toNormalizedPath(tcf), - true, // openedByClient - '', // fileContent - ts.ScriptKind.TS, // scriptKind - ); - if (!scriptInfo) { - throw new Error(`Failed to create script info for ${tcf}`); - } - } - // Add ScriptInfo to project if it's missing. A ScriptInfo needs to be part of - // the project so that it becomes part of the program. - if (!project.containsScriptInfo(scriptInfo)) { - project.addRoot(scriptInfo); - } - return scriptInfo; -} diff --git a/packages/language-service/ivy/compiler/compiler_host.ts b/packages/language-service/ivy/compiler/compiler_host.ts deleted file mode 100644 index f417ed2541..0000000000 --- a/packages/language-service/ivy/compiler/compiler_host.ts +++ /dev/null @@ -1,103 +0,0 @@ -/** - * @license - * Copyright Google LLC 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 * as ts from 'typescript/lib/tsserverlibrary'; - -export function makeCompilerHostFromProject(project: ts.server.Project): ts.CompilerHost { - const compilerHost: ts.CompilerHost = { - fileExists(fileName: string): boolean { - return project.fileExists(fileName); - }, - readFile(fileName: string): string | - undefined { - return project.readFile(fileName); - }, - directoryExists(directoryName: string): boolean { - return project.directoryExists(directoryName); - }, - getCurrentDirectory(): string { - return project.getCurrentDirectory(); - }, - getDirectories(path: string): string[] { - return project.getDirectories(path); - }, - getSourceFile( - fileName: string, languageVersion: ts.ScriptTarget, onError?: (message: string) => void, - shouldCreateNewSourceFile?: boolean): ts.SourceFile | - undefined { - const path = project.projectService.toPath(fileName); - return project.getSourceFile(path); - }, - getSourceFileByPath( - fileName: string, path: ts.Path, languageVersion: ts.ScriptTarget, - onError?: (message: string) => void, shouldCreateNewSourceFile?: boolean): ts.SourceFile | - undefined { - return project.getSourceFile(path); - }, - getCancellationToken(): ts.CancellationToken { - return { - isCancellationRequested() { - return project.getCancellationToken().isCancellationRequested(); - }, - throwIfCancellationRequested() { - if (this.isCancellationRequested()) { - throw new ts.OperationCanceledException(); - } - }, - }; - }, - getDefaultLibFileName(options: ts.CompilerOptions): string { - return project.getDefaultLibFileName(); - }, - writeFile( - fileName: string, data: string, writeByteOrderMark: boolean, - onError?: (message: string) => void, sourceFiles?: readonly ts.SourceFile[]) { - return project.writeFile(fileName, data); - }, - getCanonicalFileName(fileName: string): string { - return project.projectService.toCanonicalFileName(fileName); - }, - useCaseSensitiveFileNames(): boolean { - return project.useCaseSensitiveFileNames(); - }, - getNewLine(): string { - return project.getNewLine(); - }, - readDirectory( - rootDir: string, extensions: readonly string[], excludes: readonly string[]|undefined, - includes: readonly string[], depth?: number): string[] { - return project.readDirectory(rootDir, extensions, excludes, includes, depth); - }, - resolveModuleNames( - moduleNames: string[], containingFile: string, reusedNames: string[]|undefined, - redirectedReference: ts.ResolvedProjectReference|undefined, options: ts.CompilerOptions): - (ts.ResolvedModule | undefined)[] { - return project.resolveModuleNames( - moduleNames, containingFile, reusedNames, redirectedReference); - }, - resolveTypeReferenceDirectives( - typeReferenceDirectiveNames: string[], containingFile: string, - redirectedReference: ts.ResolvedProjectReference|undefined, options: ts.CompilerOptions): - (ts.ResolvedTypeReferenceDirective | undefined)[] { - return project.resolveTypeReferenceDirectives( - typeReferenceDirectiveNames, containingFile, redirectedReference); - }, - }; - - if (project.trace) { - compilerHost.trace = function trace(s: string) { - project.trace!(s); - }; - } - if (project.realpath) { - compilerHost.realpath = function realpath(path: string): string { - return project.realpath!(path); - }; - } - return compilerHost; -} diff --git a/packages/language-service/ivy/language_service.ts b/packages/language-service/ivy/language_service.ts index b0b6699784..128c1ed1a9 100644 --- a/packages/language-service/ivy/language_service.ts +++ b/packages/language-service/ivy/language_service.ts @@ -7,30 +7,53 @@ */ import {CompilerOptions, createNgCompilerOptions} from '@angular/compiler-cli'; +import {NgCompiler} from '@angular/compiler-cli/src/ngtsc/core'; +import {NgCompilerAdapter} from '@angular/compiler-cli/src/ngtsc/core/api'; +import {absoluteFrom, absoluteFromSourceFile, AbsoluteFsPath} from '@angular/compiler-cli/src/ngtsc/file_system'; +import {PatchedProgramIncrementalBuildStrategy} from '@angular/compiler-cli/src/ngtsc/incremental'; +import {isShim} from '@angular/compiler-cli/src/ngtsc/shims'; +import {TypeCheckShimGenerator} from '@angular/compiler-cli/src/ngtsc/typecheck'; +import {OptimizeFor, TypeCheckingProgramStrategy} from '@angular/compiler-cli/src/ngtsc/typecheck/api'; import * as ts from 'typescript/lib/tsserverlibrary'; -import {Compiler} from './compiler/compiler'; export class LanguageService { private options: CompilerOptions; - private readonly compiler: Compiler; + private lastKnownProgram: ts.Program|null = null; + private readonly strategy: TypeCheckingProgramStrategy; + private readonly adapter: NgCompilerAdapter; constructor(project: ts.server.Project, private readonly tsLS: ts.LanguageService) { this.options = parseNgCompilerOptions(project); + this.strategy = createTypeCheckingProgramStrategy(project); + this.adapter = createNgCompilerAdapter(project); this.watchConfigFile(project); - this.compiler = new Compiler(project, this.options); } getSemanticDiagnostics(fileName: string): ts.Diagnostic[] { - const result = this.compiler.analyze(); - if (!result) { - return []; + const program = this.strategy.getProgram(); + const compiler = this.createCompiler(program); + if (fileName.endsWith('.ts')) { + const sourceFile = program.getSourceFile(fileName); + if (!sourceFile) { + return []; + } + const ttc = compiler.getTemplateTypeChecker(); + const diagnostics = ttc.getDiagnosticsForFile(sourceFile, OptimizeFor.SingleFile); + this.lastKnownProgram = compiler.getNextProgram(); + return diagnostics; } - const {compiler, program} = result; - const sourceFile = program.getSourceFile(fileName); - if (!sourceFile) { - return []; - } - return compiler.getDiagnostics(sourceFile); + throw new Error('Ivy LS currently does not support external template'); + } + + private createCompiler(program: ts.Program): NgCompiler { + return new NgCompiler( + this.adapter, + this.options, + program, + this.strategy, + new PatchedProgramIncrementalBuildStrategy(), + this.lastKnownProgram, + ); } private watchConfigFile(project: ts.server.Project) { @@ -47,7 +70,6 @@ export class LanguageService { project.log(`Config file changed: ${fileName}`); if (eventKind === ts.FileWatcherEventKind.Changed) { this.options = parseNgCompilerOptions(project); - this.compiler.setCompilerOptions(this.options); } }); } @@ -66,3 +88,80 @@ export function parseNgCompilerOptions(project: ts.server.Project): CompilerOpti const basePath = project.getCurrentDirectory(); return createNgCompilerOptions(basePath, config, project.getCompilationSettings()); } + +function createNgCompilerAdapter(project: ts.server.Project): NgCompilerAdapter { + return { + entryPoint: null, // entry point is only needed if code is emitted + constructionDiagnostics: [], + ignoreForEmit: new Set(), + factoryTracker: null, // no .ngfactory shims + unifiedModulesHost: null, // only used in Bazel + rootDirs: project.getCompilationSettings().rootDirs?.map(absoluteFrom) || [], + isShim, + fileExists(fileName: string): boolean { + return project.fileExists(fileName); + }, + readFile(fileName: string): string | + undefined { + return project.readFile(fileName); + }, + getCurrentDirectory(): string { + return project.getCurrentDirectory(); + }, + getCanonicalFileName(fileName: string): string { + return project.projectService.toCanonicalFileName(fileName); + }, + }; +} + +function createTypeCheckingProgramStrategy(project: ts.server.Project): + TypeCheckingProgramStrategy { + return { + supportsInlineOperations: false, + shimPathForComponent(component: ts.ClassDeclaration): AbsoluteFsPath { + return TypeCheckShimGenerator.shimFor(absoluteFromSourceFile(component.getSourceFile())); + }, + getProgram(): ts.Program { + const program = project.getLanguageService().getProgram(); + if (!program) { + throw new Error('Language service does not have a program!'); + } + return program; + }, + updateFiles(contents: Map) { + for (const [fileName, newText] of contents) { + const scriptInfo = getOrCreateTypeCheckScriptInfo(project, fileName); + const snapshot = scriptInfo.getSnapshot(); + const length = snapshot.getLength(); + scriptInfo.editContent(0, length, newText); + } + }, + }; +} + +function getOrCreateTypeCheckScriptInfo( + project: ts.server.Project, tcf: string): ts.server.ScriptInfo { + // First check if there is already a ScriptInfo for the tcf + const {projectService} = project; + let scriptInfo = projectService.getScriptInfo(tcf); + if (!scriptInfo) { + // ScriptInfo needs to be opened by client to be able to set its user-defined + // content. We must also provide file content, otherwise the service will + // attempt to fetch the content from disk and fail. + scriptInfo = projectService.getOrCreateScriptInfoForNormalizedPath( + ts.server.toNormalizedPath(tcf), + true, // openedByClient + '', // fileContent + ts.ScriptKind.TS, // scriptKind + ); + if (!scriptInfo) { + throw new Error(`Failed to create script info for ${tcf}`); + } + } + // Add ScriptInfo to project if it's missing. A ScriptInfo needs to be part of + // the project so that it becomes part of the program. + if (!project.containsScriptInfo(scriptInfo)) { + project.addRoot(scriptInfo); + } + return scriptInfo; +} diff --git a/packages/language-service/ivy/test/BUILD.bazel b/packages/language-service/ivy/test/BUILD.bazel index 941062a1ef..7d539e9e6b 100644 --- a/packages/language-service/ivy/test/BUILD.bazel +++ b/packages/language-service/ivy/test/BUILD.bazel @@ -25,7 +25,6 @@ jasmine_node_test( ], tags = [ "ivy-only", - "manual", # do not run this on CI since compiler APIs are not yet stable ], deps = [ ":test_lib", From e0e5c9f195460c7626c30248e7a79de9a2ebe377 Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Wed, 5 Aug 2020 10:34:12 -0700 Subject: [PATCH 051/629] fix(router): ensure routerLinkActive updates when associated routerLinks change (#38349) This commit introduces a new subscription in the `routerLinkActive` directive which triggers an update when any of its associated routerLinks have changes. `RouterLinkActive` not only needs to know when links are added or removed, but it also needs to know about if a link it already knows about changes in some way. Quick note that `from...mergeAll` is used instead of just a simple `merge` (or `scheduled...mergeAll`) to avoid introducing new rxjs operators in order to keep bundle size down. Fixes #18469 PR Close #38349 --- goldens/public-api/router/router.d.ts | 5 +- packages/router/src/directives/router_link.ts | 22 +++++++-- .../src/directives/router_link_active.ts | 33 +++++++++---- .../test/regression_integration.spec.ts | 48 +++++++++++++++++++ 4 files changed, 93 insertions(+), 15 deletions(-) diff --git a/goldens/public-api/router/router.d.ts b/goldens/public-api/router/router.d.ts index 8e88ac7b7c..5d911194bd 100644 --- a/goldens/public-api/router/router.d.ts +++ b/goldens/public-api/router/router.d.ts @@ -378,7 +378,7 @@ export declare class RouterEvent { url: string); } -export declare class RouterLink { +export declare class RouterLink implements OnChanges { fragment: string; preserveFragment: boolean; /** @deprecated */ set preserveQueryParams(value: boolean); @@ -394,6 +394,7 @@ export declare class RouterLink { }; get urlTree(): UrlTree; constructor(router: Router, route: ActivatedRoute, tabIndex: string, renderer: Renderer2, el: ElementRef); + ngOnChanges(changes: SimpleChanges): void; onClick(): boolean; } @@ -429,7 +430,7 @@ export declare class RouterLinkWithHref implements OnChanges, OnDestroy { target: string; get urlTree(): UrlTree; constructor(router: Router, route: ActivatedRoute, locationStrategy: LocationStrategy); - ngOnChanges(changes: {}): any; + ngOnChanges(changes: SimpleChanges): any; ngOnDestroy(): any; onClick(button: number, ctrlKey: boolean, metaKey: boolean, shiftKey: boolean): boolean; } diff --git a/packages/router/src/directives/router_link.ts b/packages/router/src/directives/router_link.ts index 58be39a05a..997319252a 100644 --- a/packages/router/src/directives/router_link.ts +++ b/packages/router/src/directives/router_link.ts @@ -7,8 +7,8 @@ */ import {LocationStrategy} from '@angular/common'; -import {Attribute, Directive, ElementRef, HostBinding, HostListener, Input, isDevMode, OnChanges, OnDestroy, Renderer2} from '@angular/core'; -import {Subscription} from 'rxjs'; +import {Attribute, Directive, ElementRef, HostBinding, HostListener, Input, isDevMode, OnChanges, OnDestroy, Renderer2, SimpleChanges} from '@angular/core'; +import {Subject, Subscription} from 'rxjs'; import {QueryParamsHandling} from '../config'; import {Event, NavigationEnd} from '../events'; @@ -115,7 +115,7 @@ import {UrlTree} from '../url_tree'; * @publicApi */ @Directive({selector: ':not(a):not(area)[routerLink]'}) -export class RouterLink { +export class RouterLink implements OnChanges { /** * Passed to {@link Router#createUrlTree Router#createUrlTree} as part of the `NavigationExtras`. * @see {@link NavigationExtras#queryParams NavigationExtras#queryParams} @@ -167,6 +167,9 @@ export class RouterLink { private commands: any[] = []; private preserve!: boolean; + /** @internal */ + onChanges = new Subject(); + constructor( private router: Router, private route: ActivatedRoute, @Attribute('tabindex') tabIndex: string, renderer: Renderer2, el: ElementRef) { @@ -175,6 +178,13 @@ export class RouterLink { } } + /** @nodoc */ + ngOnChanges(changes: SimpleChanges) { + // This is subscribed to by `RouterLinkActive` so that it knows to update when there are changes + // to the RouterLinks it's tracking. + this.onChanges.next(); + } + /** * Commands to pass to {@link Router#createUrlTree Router#createUrlTree}. * - **array**: commands to pass to {@link Router#createUrlTree Router#createUrlTree}. @@ -298,6 +308,9 @@ export class RouterLinkWithHref implements OnChanges, OnDestroy { // TODO(issue/24571): remove '!'. @HostBinding() href!: string; + /** @internal */ + onChanges = new Subject(); + constructor( private router: Router, private route: ActivatedRoute, private locationStrategy: LocationStrategy) { @@ -336,8 +349,9 @@ export class RouterLinkWithHref implements OnChanges, OnDestroy { } /** @nodoc */ - ngOnChanges(changes: {}): any { + ngOnChanges(changes: SimpleChanges): any { this.updateTargetUrlAndHref(); + this.onChanges.next(); } /** @nodoc */ ngOnDestroy(): any { diff --git a/packages/router/src/directives/router_link_active.ts b/packages/router/src/directives/router_link_active.ts index 209546d424..7761d91dbc 100644 --- a/packages/router/src/directives/router_link_active.ts +++ b/packages/router/src/directives/router_link_active.ts @@ -7,7 +7,8 @@ */ import {AfterContentInit, ChangeDetectorRef, ContentChildren, Directive, ElementRef, Input, OnChanges, OnDestroy, Optional, QueryList, Renderer2, SimpleChanges} from '@angular/core'; -import {Subscription} from 'rxjs'; +import {from, of, Subscription} from 'rxjs'; +import {mergeAll} from 'rxjs/operators'; import {Event, NavigationEnd} from '../events'; import {Router} from '../router'; @@ -79,14 +80,13 @@ import {RouterLink, RouterLinkWithHref} from './router_link'; exportAs: 'routerLinkActive', }) export class RouterLinkActive implements OnChanges, OnDestroy, AfterContentInit { - // TODO(issue/24571): remove '!'. @ContentChildren(RouterLink, {descendants: true}) links!: QueryList; - // TODO(issue/24571): remove '!'. @ContentChildren(RouterLinkWithHref, {descendants: true}) linksWithHrefs!: QueryList; private classes: string[] = []; - private subscription: Subscription; + private routerEventsSubscription: Subscription; + private linkInputChangesSubscription?: Subscription; public readonly isActive: boolean = false; @Input() routerLinkActiveOptions: {exact: boolean} = {exact: false}; @@ -95,7 +95,7 @@ export class RouterLinkActive implements OnChanges, OnDestroy, AfterContentInit private router: Router, private element: ElementRef, private renderer: Renderer2, private readonly cdr: ChangeDetectorRef, @Optional() private link?: RouterLink, @Optional() private linkWithHref?: RouterLinkWithHref) { - this.subscription = router.events.subscribe((s: Event) => { + this.routerEventsSubscription = router.events.subscribe((s: Event) => { if (s instanceof NavigationEnd) { this.update(); } @@ -104,9 +104,23 @@ export class RouterLinkActive implements OnChanges, OnDestroy, AfterContentInit /** @nodoc */ ngAfterContentInit(): void { - this.links.changes.subscribe(_ => this.update()); - this.linksWithHrefs.changes.subscribe(_ => this.update()); - this.update(); + // `of(null)` is used to force subscribe body to execute once immediately (like `startWith`). + from([this.links.changes, this.linksWithHrefs.changes, of(null)]) + .pipe(mergeAll()) + .subscribe(_ => { + this.update(); + this.subscribeToEachLinkOnChanges(); + }); + } + + private subscribeToEachLinkOnChanges() { + this.linkInputChangesSubscription?.unsubscribe(); + const allLinkChanges = + [...this.links.toArray(), ...this.linksWithHrefs.toArray(), this.link, this.linkWithHref] + .filter((link): link is RouterLink|RouterLinkWithHref => !!link) + .map(link => link.onChanges); + this.linkInputChangesSubscription = + from(allLinkChanges).pipe(mergeAll()).subscribe(() => this.update()); } @Input() @@ -121,7 +135,8 @@ export class RouterLinkActive implements OnChanges, OnDestroy, AfterContentInit } /** @nodoc */ ngOnDestroy(): void { - this.subscription.unsubscribe(); + this.routerEventsSubscription.unsubscribe(); + this.linkInputChangesSubscription?.unsubscribe(); } private update(): void { diff --git a/packages/router/test/regression_integration.spec.ts b/packages/router/test/regression_integration.spec.ts index 3dbf4eaf68..9dd5b8ba32 100644 --- a/packages/router/test/regression_integration.spec.ts +++ b/packages/router/test/regression_integration.spec.ts @@ -14,6 +14,54 @@ import {RouterTestingModule} from '@angular/router/testing'; describe('Integration', () => { describe('routerLinkActive', () => { + it('should update when the associated routerLinks change - #18469', fakeAsync(() => { + @Component({ + template: ` + {{firstLink}} + + `, + }) + class LinkComponent { + firstLink = 'link-a'; + secondLink = 'link-b'; + + changeLinks(): void { + const temp = this.secondLink; + this.secondLink = this.firstLink; + this.firstLink = temp; + } + } + + @Component({template: 'simple'}) + class SimpleCmp { + } + + TestBed.configureTestingModule({ + imports: [RouterTestingModule.withRoutes( + [{path: 'link-a', component: SimpleCmp}, {path: 'link-b', component: SimpleCmp}])], + declarations: [LinkComponent, SimpleCmp] + }); + + const router: Router = TestBed.inject(Router); + const fixture = createRoot(router, LinkComponent); + const firstLink = fixture.debugElement.query(p => p.nativeElement.id === 'first-link'); + const secondLink = fixture.debugElement.query(p => p.nativeElement.id === 'second-link'); + router.navigateByUrl('/link-a'); + advance(fixture); + + expect(firstLink.nativeElement.classList).toContain('active'); + expect(secondLink.nativeElement.classList).not.toContain('active'); + + fixture.componentInstance.changeLinks(); + fixture.detectChanges(); + advance(fixture); + + expect(firstLink.nativeElement.classList).not.toContain('active'); + expect(secondLink.nativeElement.classList).toContain('active'); + })); + it('should not cause infinite loops in the change detection - #15825', fakeAsync(() => { @Component({selector: 'simple', template: 'simple'}) class SimpleCmp { From fec9dcbeb01f8d3270cb7594c47bb881e50faed3 Mon Sep 17 00:00:00 2001 From: atscott Date: Mon, 17 Aug 2020 13:19:03 -0700 Subject: [PATCH 052/629] docs: release notes for the v10.0.10 release --- CHANGELOG.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96d4a2aadb..71d8fd5e89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,18 @@ + +## 10.0.10 (2020-08-17) + + +### Bug Fixes + +* **common:** Allow scrolling when browser supports scrollTo ([#38468](https://github.com/angular/angular/issues/38468)) ([b32126c](https://github.com/angular/angular/commit/b32126c)), closes [#30630](https://github.com/angular/angular/issues/30630) +* **core:** detect DI parameters in JIT mode for downleveled ES2015 classes ([#38500](https://github.com/angular/angular/issues/38500)) ([863acb6](https://github.com/angular/angular/commit/863acb6)), closes [#38453](https://github.com/angular/angular/issues/38453) +* **core:** error if CSS custom property in host binding has number in name ([#38432](https://github.com/angular/angular/issues/38432)) ([cb83b8a](https://github.com/angular/angular/commit/cb83b8a)), closes [#37292](https://github.com/angular/angular/issues/37292) +* **core:** fix multiple nested views removal from ViewContainerRef ([#38317](https://github.com/angular/angular/issues/38317)) ([d5e09f4](https://github.com/angular/angular/commit/d5e09f4)), closes [#38201](https://github.com/angular/angular/issues/38201) +* **ngcc:** detect synthesized delegate constructors for downleveled ES2015 classes ([#38500](https://github.com/angular/angular/issues/38500)) ([f3dd6c2](https://github.com/angular/angular/commit/f3dd6c2)), closes [#38453](https://github.com/angular/angular/issues/38453) [#38453](https://github.com/angular/angular/issues/38453) +* **router:** ensure routerLinkActive updates when associated routerLinks change ([#38349](https://github.com/angular/angular/issues/38349)) ([989e8a1](https://github.com/angular/angular/commit/989e8a1)), closes [#18469](https://github.com/angular/angular/issues/18469) + + + # 10.1.0-next.5 (2020-08-12) From 64cf087ae562720c39cc89ba6073f070e3be4c3b Mon Sep 17 00:00:00 2001 From: atscott Date: Mon, 17 Aug 2020 13:23:09 -0700 Subject: [PATCH 053/629] release: cut the v10.1.0-next.6 release --- CHANGELOG.md | 23 +++++++++++++++++++++++ package.json | 2 +- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71d8fd5e89..c0f2a64d5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,26 @@ + +# 10.1.0-next.6 (2020-08-17) + + +### Bug Fixes + +* **core:** detect DI parameters in JIT mode for downleveled ES2015 classes ([#38463](https://github.com/angular/angular/issues/38463)) ([ca07da4](https://github.com/angular/angular/commit/ca07da4)), closes [#38453](https://github.com/angular/angular/issues/38453) +* **core:** move generated i18n statements to the `consts` field of ComponentDef ([#38404](https://github.com/angular/angular/issues/38404)) ([cb05c01](https://github.com/angular/angular/commit/cb05c01)) +* **localize:** render ICU placeholders in extracted translation files ([#38484](https://github.com/angular/angular/issues/38484)) ([81c3e80](https://github.com/angular/angular/commit/81c3e80)) +* **ngcc:** detect synthesized delegate constructors for downleveled ES2015 classes ([#38463](https://github.com/angular/angular/issues/38463)) ([3b9c802](https://github.com/angular/angular/commit/3b9c802)), closes [#38453](https://github.com/angular/angular/issues/38453) [#38453](https://github.com/angular/angular/issues/38453) + + +### Performance Improvements + +* **compiler-cli:** don't emit template guards when child scope is empty ([#38418](https://github.com/angular/angular/issues/38418)) ([1388c17](https://github.com/angular/angular/commit/1388c17)) +* **compiler-cli:** only generate directive declarations when used ([#38418](https://github.com/angular/angular/issues/38418)) ([fb8f4b4](https://github.com/angular/angular/commit/fb8f4b4)) +* **compiler-cli:** only generate type-check code for referenced DOM elements ([#38418](https://github.com/angular/angular/issues/38418)) ([f42e6ce](https://github.com/angular/angular/commit/f42e6ce)) + +### Code Refactoring +* **router:** export DefaultRouteReuseStrategy to Router public_api ([#31575](https://github.com/angular/angular/issues/31575)) ([ca79880](https://github.com/angular/angular/commit/ca79880)) + + + ## 10.0.10 (2020-08-17) diff --git a/package.json b/package.json index f57e005bbe..4b7ad01010 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "angular-srcs", - "version": "10.1.0-next.5", + "version": "10.1.0-next.6", "private": true, "description": "Angular - a web framework for modern web apps", "homepage": "https://github.com/angular/angular", From 301513311ebbdba3ad32cb8c0a08be0041653da2 Mon Sep 17 00:00:00 2001 From: Joey Perrott Date: Fri, 14 Aug 2020 12:20:55 -0700 Subject: [PATCH 054/629] refactor(dev-infra): update yargs and typings for yargs (#38470) Updating yargs and typings for the updated yargs module. PR Close #38470 --- dev-infra/commit-message/cli.ts | 16 +++--- dev-infra/format/cli.ts | 17 +++--- dev-infra/pr/discover-new-conflicts/cli.ts | 30 +++++++---- dev-infra/pr/merge/cli.ts | 25 ++++++--- dev-infra/pr/rebase/cli.ts | 25 ++++++--- dev-infra/ts-circular-dependencies/index.ts | 19 ++++--- package.json | 4 +- yarn.lock | 57 ++++++++++++--------- 8 files changed, 116 insertions(+), 77 deletions(-) diff --git a/dev-infra/commit-message/cli.ts b/dev-infra/commit-message/cli.ts index ebdde827e4..90143ccee2 100644 --- a/dev-infra/commit-message/cli.ts +++ b/dev-infra/commit-message/cli.ts @@ -18,14 +18,16 @@ export function buildCommitMessageParser(localYargs: yargs.Argv) { return localYargs.help() .strict() .command( - 'restore-commit-message-draft', false, { - 'file-env-variable': { + 'restore-commit-message-draft', false, + args => { + return args.option('file-env-variable', { type: 'string', + array: true, conflicts: ['file'], required: true, description: - 'The key for the environment variable which holds the arguments for the ' + - 'prepare-commit-msg hook as described here: ' + + 'The key for the environment variable which holds the arguments for the\n' + + 'prepare-commit-msg hook as described here:\n' + 'https://git-scm.com/docs/githooks#_prepare_commit_msg', coerce: arg => { const [file, source] = (process.env[arg] || '').split(' '); @@ -34,10 +36,10 @@ export function buildCommitMessageParser(localYargs: yargs.Argv) { } return [file, source]; }, - } + }); }, args => { - restoreCommitMessage(args.fileEnvVariable[0], args.fileEnvVariable[1]); + restoreCommitMessage(args['file-env-variable'][0], args['file-env-variable'][1] as any); }) .command( 'pre-commit-validate', 'Validate the most recent commit message', { @@ -61,7 +63,7 @@ export function buildCommitMessageParser(localYargs: yargs.Argv) { } }, args => { - const file = args.file || args.fileEnvVariable || '.git/COMMIT_EDITMSG'; + const file = args.file || args['file-env-variable'] || '.git/COMMIT_EDITMSG'; validateFile(file); }) .command( diff --git a/dev-infra/format/cli.ts b/dev-infra/format/cli.ts index 8c635575ab..77dc650369 100644 --- a/dev-infra/format/cli.ts +++ b/dev-infra/format/cli.ts @@ -22,28 +22,31 @@ export function buildFormatParser(localYargs: yargs.Argv) { description: 'Run the formatter to check formatting rather than updating code format' }) .command( - 'all', 'Run the formatter on all files in the repository', {}, + 'all', 'Run the formatter on all files in the repository', args => args, ({check}) => { const executionCmd = check ? checkFiles : formatFiles; executionCmd(allFiles()); }) .command( - 'changed [shaOrRef]', 'Run the formatter on files changed since the provided sha/ref', {}, + 'changed [shaOrRef]', 'Run the formatter on files changed since the provided sha/ref', + args => args.positional('shaOrRef', {type: 'string'}), ({shaOrRef, check}) => { const sha = shaOrRef || 'master'; const executionCmd = check ? checkFiles : formatFiles; executionCmd(allChangedFilesSince(sha)); }) .command( - 'staged', 'Run the formatter on all staged files', {}, + 'staged', 'Run the formatter on all staged files', args => args, ({check}) => { const executionCmd = check ? checkFiles : formatFiles; executionCmd(allStagedFiles()); }) - .command('files ', 'Run the formatter on provided files', {}, ({check, files}) => { - const executionCmd = check ? checkFiles : formatFiles; - executionCmd(files); - }); + .command( + 'files ', 'Run the formatter on provided files', + args => args.positional('files', {array: true, type: 'string'}), ({check, files}) => { + const executionCmd = check ? checkFiles : formatFiles; + executionCmd(files!); + }); } if (require.main === module) { diff --git a/dev-infra/pr/discover-new-conflicts/cli.ts b/dev-infra/pr/discover-new-conflicts/cli.ts index 672afb3c14..27f83b69e9 100644 --- a/dev-infra/pr/discover-new-conflicts/cli.ts +++ b/dev-infra/pr/discover-new-conflicts/cli.ts @@ -12,18 +12,28 @@ import {error} from '../../utils/console'; import {discoverNewConflictsForPr} from './index'; +/** The options available to the discover-new-conflicts command via CLI. */ +export interface DiscoverNewConflictsCommandOptions { + date: number; + 'pr-number': number; +} + /** Builds the discover-new-conflicts pull request command. */ -export function buildDiscoverNewConflictsCommand(yargs: Argv) { - return yargs.option('date', { - description: 'Only consider PRs updated since provided date', - defaultDescription: '30 days ago', - coerce: Date.parse, - default: getThirtyDaysAgoDate, - }); +export function buildDiscoverNewConflictsCommand(yargs: Argv): + Argv { + return yargs + .option('date', { + description: 'Only consider PRs updated since provided date', + defaultDescription: '30 days ago', + coerce: (date) => typeof date === 'number' ? date : Date.parse(date), + default: getThirtyDaysAgoDate(), + }) + .positional('pr-number', {demandOption: true, type: 'number'}); } /** Handles the discover-new-conflicts pull request command. */ -export async function handleDiscoverNewConflictsCommand({prNumber, date}: Arguments) { +export async function handleDiscoverNewConflictsCommand( + {'pr-number': prNumber, date}: Arguments) { // If a provided date is not able to be parsed, yargs provides it as NaN. if (isNaN(date)) { error('Unable to parse the value provided via --date flag'); @@ -33,11 +43,11 @@ export async function handleDiscoverNewConflictsCommand({prNumber, date}: Argume } /** Gets a date object 30 days ago from today. */ -function getThirtyDaysAgoDate(): Date { +function getThirtyDaysAgoDate() { const date = new Date(); // Set the hours, minutes and seconds to 0 to only consider date. date.setHours(0, 0, 0, 0); // Set the date to 30 days in the past. date.setDate(date.getDate() - 30); - return date; + return date.getTime(); } diff --git a/dev-infra/pr/merge/cli.ts b/dev-infra/pr/merge/cli.ts index b1d30223d6..785b9fb2da 100644 --- a/dev-infra/pr/merge/cli.ts +++ b/dev-infra/pr/merge/cli.ts @@ -12,17 +12,26 @@ import {error, red, yellow} from '../../utils/console'; import {GITHUB_TOKEN_GENERATE_URL, mergePullRequest} from './index'; +/** The options available to the merge command via CLI. */ +export interface MergeCommandOptions { + 'github-token'?: string; + 'pr-number': number; +} + /** Builds the options for the merge command. */ -export function buildMergeCommand(yargs: Argv) { - return yargs.help().strict().option('github-token', { - type: 'string', - description: 'Github token. If not set, token is retrieved from the environment variables.' - }); +export function buildMergeCommand(yargs: Argv): Argv { + return yargs.help() + .strict() + .positional('pr-number', {demandOption: true, type: 'number'}) + .option('github-token', { + type: 'string', + description: 'Github token. If not set, token is retrieved from the environment variables.' + }); } /** Handles the merge command. i.e. performs the merge of a specified pull request. */ -export async function handleMergeCommand(args: Arguments) { - const githubToken = args.githubToken || process.env.GITHUB_TOKEN || process.env.TOKEN; +export async function handleMergeCommand(args: Arguments) { + const githubToken = args['github-token'] || process.env.GITHUB_TOKEN || process.env.TOKEN; if (!githubToken) { error(red('No Github token set. Please set the `GITHUB_TOKEN` environment variable.')); error(red('Alternatively, pass the `--github-token` command line flag.')); @@ -30,5 +39,5 @@ export async function handleMergeCommand(args: Arguments) { process.exit(1); } - await mergePullRequest(args.prNumber, githubToken); + await mergePullRequest(args['pr-number'], githubToken); } diff --git a/dev-infra/pr/rebase/cli.ts b/dev-infra/pr/rebase/cli.ts index 03a369d891..d06ace794a 100644 --- a/dev-infra/pr/rebase/cli.ts +++ b/dev-infra/pr/rebase/cli.ts @@ -15,17 +15,26 @@ import {rebasePr} from './index'; /** URL to the Github page where personal access tokens can be generated. */ export const GITHUB_TOKEN_GENERATE_URL = `https://github.com/settings/tokens`; -/** Builds the rebase pull request command. */ -export function buildRebaseCommand(yargs: Argv) { - return yargs.option('github-token', { - type: 'string', - description: 'Github token. If not set, token is retrieved from the environment variables.' - }); +/** The options available to the rebase command via CLI. */ +export interface RebaseCommandOptions { + 'github-token'?: string; + prNumber: number; } +/** Builds the rebase pull request command. */ +export function buildRebaseCommand(yargs: Argv): Argv { + return yargs + .option('github-token', { + type: 'string', + description: 'Github token. If not set, token is retrieved from the environment variables.' + }) + .positional('prNumber', {type: 'number', demandOption: true}); +} + + /** Handles the rebase pull request command. */ -export async function handleRebaseCommand(args: Arguments) { - const githubToken = args.githubToken || process.env.GITHUB_TOKEN || process.env.TOKEN; +export async function handleRebaseCommand(args: Arguments) { + const githubToken = args['github-token'] || process.env.GITHUB_TOKEN || process.env.TOKEN; if (!githubToken) { error('No Github token set. Please set the `GITHUB_TOKEN` environment variable.'); error('Alternatively, pass the `--github-token` command line flag.'); diff --git a/dev-infra/ts-circular-dependencies/index.ts b/dev-infra/ts-circular-dependencies/index.ts index df597bf6e2..fb90015f2f 100644 --- a/dev-infra/ts-circular-dependencies/index.ts +++ b/dev-infra/ts-circular-dependencies/index.ts @@ -30,20 +30,19 @@ export function tsCircularDependenciesBuilder(localYargs: yargs.Argv) { {type: 'string', demandOption: true, description: 'Path to the configuration file.'}) .option('warnings', {type: 'boolean', description: 'Prints all warnings.'}) .command( - 'check', 'Checks if the circular dependencies have changed.', {}, - (argv: yargs.Arguments) => { + 'check', 'Checks if the circular dependencies have changed.', args => args, + argv => { const {config: configArg, warnings} = argv; const configPath = isAbsolute(configArg) ? configArg : resolve(configArg); const config = loadTestConfig(configPath); - process.exit(main(false, config, warnings)); + process.exit(main(false, config, !!warnings)); }) - .command( - 'approve', 'Approves the current circular dependencies.', {}, (argv: yargs.Arguments) => { - const {config: configArg, warnings} = argv; - const configPath = isAbsolute(configArg) ? configArg : resolve(configArg); - const config = loadTestConfig(configPath); - process.exit(main(true, config, warnings)); - }); + .command('approve', 'Approves the current circular dependencies.', args => args, argv => { + const {config: configArg, warnings} = argv; + const configPath = isAbsolute(configArg) ? configArg : resolve(configArg); + const config = loadTestConfig(configPath); + process.exit(main(true, config, !!warnings)); + }); } /** diff --git a/package.json b/package.json index 4b7ad01010..2fcd6424bc 100644 --- a/package.json +++ b/package.json @@ -88,7 +88,7 @@ "@types/shelljs": "^0.8.6", "@types/systemjs": "0.19.32", "@types/yaml": "^1.2.0", - "@types/yargs": "^11.1.1", + "@types/yargs": "^15.0.5", "@webcomponents/custom-elements": "^1.1.0", "angular": "npm:angular@1.7", "angular-1.5": "npm:angular@1.5", @@ -153,7 +153,7 @@ "typescript": "~3.9.5", "xhr2": "0.2.0", "yaml": "^1.7.2", - "yargs": "15.3.0" + "yargs": "^15.4.1" }, "// 2": "devDependencies are not used under Bazel. Many can be removed after test.sh is deleted.", "devDependencies": { diff --git a/yarn.lock b/yarn.lock index c234a1cc6e..54131051c6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2352,10 +2352,17 @@ resolved "https://registry.yarnpkg.com/@types/yaml/-/yaml-1.2.0.tgz#4ed577fc4ebbd6b829b28734e56d10c9e6984e09" integrity sha512-GW8b9qM+ebgW3/zjzPm0I1NxMvLaz/YKT9Ph6tTb+Fkeyzd9yLTvQ6ciQ2MorTRmb/qXmfjMerRpG4LviixaqQ== -"@types/yargs@^11.1.1": - version "11.1.5" - resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-11.1.5.tgz#8d71dfe4848ac5d714b75eca3df9cac75a4f8dac" - integrity sha512-1jmXgoIyzxQSm33lYgEXvegtkhloHbed2I0QGlTN66U2F9/ExqJWSCSmaWC0IB/g1tW+IYSp+tDhcZBYB1ZGog== +"@types/yargs-parser@*": + version "15.0.0" + resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-15.0.0.tgz#cb3f9f741869e20cce330ffbeb9271590483882d" + integrity sha512-FA/BWv8t8ZWJ+gEOnLLd8ygxH/2UFbAvgEonyfN6yWGLKc7zVjbpl2Y4CTjid9h2RfgPP6SEt6uHwEOply00yw== + +"@types/yargs@^15.0.5": + version "15.0.5" + resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-15.0.5.tgz#947e9a6561483bdee9adffc983e91a6902af8b79" + integrity sha512-Dk/IDOPtOgubt/IaevIUbTgV7doaKkoorvOyYM2CMwuDyP89bekI7H4xLIwunNYiK9jhCkmc6pUrJk3cj2AB9w== + dependencies: + "@types/yargs-parser" "*" "@types/yauzl@^2.9.1": version "2.9.1" @@ -16301,10 +16308,10 @@ yargs-parser@^15.0.1: camelcase "^5.0.0" decamelize "^1.2.0" -yargs-parser@^18.1.0: - version "18.1.2" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-18.1.2.tgz#2f482bea2136dbde0861683abea7756d30b504f1" - integrity sha512-hlIPNR3IzC1YuL1c2UwwDKpXlNFBqD1Fswwh1khz5+d8Cq/8yc/Mn0i+rQXduu8hcrFKvO7Eryk+09NecTQAAQ== +yargs-parser@^18.1.2: + version "18.1.3" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-18.1.3.tgz#be68c4975c6b2abf469236b0c870362fab09a7b0" + integrity sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ== dependencies: camelcase "^5.0.0" decamelize "^1.2.0" @@ -16316,23 +16323,6 @@ yargs-parser@^9.0.2: dependencies: camelcase "^4.1.0" -yargs@15.3.0: - version "15.3.0" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-15.3.0.tgz#403af6edc75b3ae04bf66c94202228ba119f0976" - integrity sha512-g/QCnmjgOl1YJjGsnUg2SatC7NUYEiLXJqxNOQU9qSpjzGtGXda9b+OKccr1kLTy8BN9yqEyqfq5lxlwdc13TA== - dependencies: - cliui "^6.0.0" - decamelize "^1.2.0" - find-up "^4.1.0" - get-caller-file "^2.0.1" - require-directory "^2.1.1" - require-main-filename "^2.0.0" - set-blocking "^2.0.0" - string-width "^4.2.0" - which-module "^2.0.0" - y18n "^4.0.0" - yargs-parser "^18.1.0" - yargs@^11.0.0: version "11.1.1" resolved "https://registry.yarnpkg.com/yargs/-/yargs-11.1.1.tgz#5052efe3446a4df5ed669c995886cc0f13702766" @@ -16384,6 +16374,23 @@ yargs@^14.2.3: y18n "^4.0.0" yargs-parser "^15.0.1" +yargs@^15.4.1: + version "15.4.1" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-15.4.1.tgz#0d87a16de01aee9d8bec2bfbf74f67851730f4f8" + integrity sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A== + dependencies: + cliui "^6.0.0" + decamelize "^1.2.0" + find-up "^4.1.0" + get-caller-file "^2.0.1" + require-directory "^2.1.1" + require-main-filename "^2.0.0" + set-blocking "^2.0.0" + string-width "^4.2.0" + which-module "^2.0.0" + y18n "^4.0.0" + yargs-parser "^18.1.2" + yauzl@^2.10.0: version "2.10.0" resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9" From 8373b720f3db0542ce1670a33a70236cb167ce11 Mon Sep 17 00:00:00 2001 From: Joey Perrott Date: Fri, 14 Aug 2020 14:00:01 -0700 Subject: [PATCH 055/629] refactor(localize): update yargs and typings for yargs (#38470) Updating yargs and typings for the updated yargs module. PR Close #38470 --- .../localize/src/tools/src/extract/main.ts | 33 +++++++++++-------- .../localize/src/tools/src/translate/main.ts | 24 +++++++++----- 2 files changed, 36 insertions(+), 21 deletions(-) diff --git a/packages/localize/src/tools/src/extract/main.ts b/packages/localize/src/tools/src/extract/main.ts index e0a41f0528..c764abcb0b 100644 --- a/packages/localize/src/tools/src/extract/main.ts +++ b/packages/localize/src/tools/src/extract/main.ts @@ -30,12 +30,14 @@ if (require.main === module) { alias: 'locale', describe: 'The locale of the source being processed', default: 'en', + type: 'string', }) .option('r', { alias: 'root', default: '.', describe: 'The root path for other paths provided in these options.\n' + - 'This should either be absolute or relative to the current working directory.' + 'This should either be absolute or relative to the current working directory.', + type: 'string', }) .option('s', { alias: 'source', @@ -43,40 +45,45 @@ if (require.main === module) { describe: 'A glob pattern indicating what files to search for translations, e.g. `./dist/**/*.js`.\n' + 'This should be relative to the root path.', + type: 'string', }) .option('f', { alias: 'format', required: true, choices: ['xmb', 'xlf', 'xlif', 'xliff', 'xlf2', 'xlif2', 'xliff2', 'json'], describe: 'The format of the translation file.', + type: 'string', }) .option('o', { alias: 'outputPath', required: true, describe: - 'A path to where the translation file will be written. This should be relative to the root path.' + 'A path to where the translation file will be written. This should be relative to the root path.', + type: 'string', }) .option('loglevel', { describe: 'The lowest severity logging message that should be output.', choices: ['debug', 'info', 'warn', 'error'], + type: 'string', }) .option('useSourceMaps', { type: 'boolean', default: true, describe: - 'Whether to generate source information in the output files by following source-map mappings found in the source files' + 'Whether to generate source information in the output files by following source-map mappings found in the source files', }) .option('useLegacyIds', { type: 'boolean', default: true, describe: - 'Whether to use the legacy id format for messages that were extracted from Angular templates.' + 'Whether to use the legacy id format for messages that were extracted from Angular templates.', }) .option('d', { alias: 'duplicateMessageHandling', describe: 'How to handle messages with the same id but different text.', choices: ['error', 'warning', 'ignore'], default: 'warning', + type: 'string', }) .strict() .help() @@ -85,22 +92,22 @@ if (require.main === module) { const fs = new NodeJSFileSystem(); setFileSystem(fs); - const rootPath = options['root']; - const sourceFilePaths = glob.sync(options['source'], {cwd: rootPath, nodir: true}); - const logLevel = options['loglevel'] as (keyof typeof LogLevel) | undefined; + const rootPath = options.r; + const sourceFilePaths = glob.sync(options.s, {cwd: rootPath, nodir: true}); + const logLevel = options.loglevel as (keyof typeof LogLevel) | undefined; const logger = new ConsoleLogger(logLevel ? LogLevel[logLevel] : LogLevel.warn); - const duplicateMessageHandling: DiagnosticHandlingStrategy = options['d']; + const duplicateMessageHandling = options.d as DiagnosticHandlingStrategy; extractTranslations({ rootPath, sourceFilePaths, - sourceLocale: options['locale'], - format: options['format'], - outputPath: options['outputPath'], + sourceLocale: options.l, + format: options.f, + outputPath: options.o, logger, - useSourceMaps: options['useSourceMaps'], - useLegacyIds: options['useLegacyIds'], + useSourceMaps: options.useSourceMaps, + useLegacyIds: options.useLegacyIds, duplicateMessageHandling, }); } diff --git a/packages/localize/src/tools/src/translate/main.ts b/packages/localize/src/tools/src/translate/main.ts index 8db12dc24f..590d00822f 100644 --- a/packages/localize/src/tools/src/translate/main.ts +++ b/packages/localize/src/tools/src/translate/main.ts @@ -30,18 +30,21 @@ if (require.main === module) { required: true, describe: 'The root path of the files to translate, either absolute or relative to the current working directory. E.g. `dist/en`.', + type: 'string', }) .option('s', { alias: 'source', required: true, describe: 'A glob pattern indicating what files to translate, relative to the `root` path. E.g. `bundles/**/*`.', + type: 'string', }) .option('l', { alias: 'source-locale', describe: 'The source locale of the application. If this is provided then a copy of the application will be created with no translation but just the `$localize` calls stripped out.', + type: 'string', }) .option('t', { @@ -54,6 +57,7 @@ if (require.main === module) { 'If you want to merge multiple translation files for each locale, then provide the list of files in an array.\n' + 'Note that the arrays must be in double quotes if you include any whitespace within the array.\n' + 'E.g. `-t "[src/locale/messages.en.xlf, src/locale/messages-2.en.xlf]" [src/locale/messages.fr.xlf,src/locale/messages-2.fr.xlf]`', + type: 'string', }) .option('target-locales', { @@ -61,6 +65,7 @@ if (require.main === module) { describe: 'A list of target locales for the translation files, which will override any target locale parsed from the translation file.\n' + 'E.g. "-t en fr de".', + type: 'string', }) .option('o', { @@ -68,7 +73,8 @@ if (require.main === module) { required: true, describe: 'A output path pattern to where the translated files will be written.\n' + 'The path must be either absolute or relative to the current working directory.\n' + - 'The marker `{{LOCALE}}` will be replaced with the target locale. E.g. `dist/{{LOCALE}}`.' + 'The marker `{{LOCALE}}` will be replaced with the target locale. E.g. `dist/{{LOCALE}}`.', + type: 'string', }) .option('m', { @@ -76,6 +82,7 @@ if (require.main === module) { describe: 'How to handle missing translations.', choices: ['error', 'warning', 'ignore'], default: 'warning', + type: 'string', }) .option('d', { @@ -83,6 +90,7 @@ if (require.main === module) { describe: 'How to handle duplicate translations.', choices: ['error', 'warning', 'ignore'], default: 'warning', + type: 'string', }) .strict() @@ -92,14 +100,14 @@ if (require.main === module) { const fs = new NodeJSFileSystem(); setFileSystem(fs); - const sourceRootPath = options['r']; - const sourceFilePaths = glob.sync(options['s'], {cwd: sourceRootPath, nodir: true}); - const translationFilePaths: (string|string[])[] = convertArraysFromArgs(options['t']); - const outputPathFn = getOutputPathFn(fs.resolve(options['o'])); + const sourceRootPath = options.r; + const sourceFilePaths = glob.sync(options.s, {cwd: sourceRootPath, nodir: true}); + const translationFilePaths: (string|string[])[] = convertArraysFromArgs(options.t); + const outputPathFn = getOutputPathFn(fs.resolve(options.o)); const diagnostics = new Diagnostics(); - const missingTranslation: DiagnosticHandlingStrategy = options['m']; - const duplicateTranslation: DiagnosticHandlingStrategy = options['d']; - const sourceLocale: string|undefined = options['l']; + const missingTranslation = options.m as DiagnosticHandlingStrategy; + const duplicateTranslation = options.d as DiagnosticHandlingStrategy; + const sourceLocale: string|undefined = options.l; const translationFileLocales: string[] = options['target-locales'] || []; translateFiles({ From e472f5f68804eaa85045779af43d4107385e286e Mon Sep 17 00:00:00 2001 From: Joey Perrott Date: Fri, 14 Aug 2020 15:23:05 -0700 Subject: [PATCH 056/629] refactor(ngcc): update yargs and typings for yargs (#38470) Updating yargs and typings for the updated yargs module. PR Close #38470 --- .../ngcc/src/command_line_options.ts | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/packages/compiler-cli/ngcc/src/command_line_options.ts b/packages/compiler-cli/ngcc/src/command_line_options.ts index 1487a68ec8..12f06df3b7 100644 --- a/packages/compiler-cli/ngcc/src/command_line_options.ts +++ b/packages/compiler-cli/ngcc/src/command_line_options.ts @@ -19,9 +19,10 @@ export function parseCommandLineOptions(args: string[]): NgccOptions { alias: 'source', describe: 'A path (relative to the working directory) of the `node_modules` folder to process.', - default: './node_modules' + default: './node_modules', + type: 'string', }) - .option('f', {alias: 'formats', hidden: true, array: true}) + .option('f', {alias: 'formats', hidden: true, array: true, type: 'string'}) .option('p', { alias: 'properties', array: true, @@ -29,7 +30,8 @@ export function parseCommandLineOptions(args: string[]): NgccOptions { 'An array of names of properties in package.json to compile (e.g. `module` or `main`)\n' + 'Each of these properties should hold the path to a bundle-format.\n' + 'If provided, only the specified properties are considered for processing.\n' + - 'If not provided, all the supported format properties (e.g. fesm2015, fesm5, es2015, esm2015, esm5, main, module) in the package.json are considered.' + 'If not provided, all the supported format properties (e.g. fesm2015, fesm5, es2015, esm2015, esm5, main, module) in the package.json are considered.', + type: 'string', }) .option('t', { alias: 'target', @@ -37,6 +39,7 @@ export function parseCommandLineOptions(args: string[]): NgccOptions { 'A relative path (from the `source` path) to a single entry-point to process (plus its dependencies).\n' + 'If this property is provided then `error-on-failed-entry-point` is forced to true.\n' + 'This option overrides the `--use-program-dependencies` option.', + type: 'string', }) .option('use-program-dependencies', { type: 'boolean', @@ -47,7 +50,7 @@ export function parseCommandLineOptions(args: string[]): NgccOptions { .option('first-only', { describe: 'If specified then only the first matching package.json property will be compiled.', - type: 'boolean' + type: 'boolean', }) .option('create-ivy-entry-points', { describe: @@ -78,6 +81,7 @@ export function parseCommandLineOptions(args: string[]): NgccOptions { alias: 'loglevel', describe: 'The lowest severity logging message that should be output.', choices: ['debug', 'info', 'warn', 'error'], + type: 'string', }) .option('invalidate-entry-point-manifest', { describe: @@ -105,7 +109,7 @@ export function parseCommandLineOptions(args: string[]): NgccOptions { .help() .parse(args); - if (options['f'] && options['f'].length) { + if (options.f?.length) { console.error( 'The formats option (-f/--formats) has been removed. Consider the properties option (-p/--properties) instead.'); process.exit(1); @@ -113,12 +117,12 @@ export function parseCommandLineOptions(args: string[]): NgccOptions { setFileSystem(new NodeJSFileSystem()); - const baseSourcePath = resolve(options['s'] || './node_modules'); - const propertiesToConsider: string[] = options['p']; - const targetEntryPointPath = options['t'] ? options['t'] : undefined; + const baseSourcePath = resolve(options.s || './node_modules'); + const propertiesToConsider = options.p; + const targetEntryPointPath = options.t; const compileAllFormats = !options['first-only']; const createNewEntryPointFormats = options['create-ivy-entry-points']; - const logLevel = options['l'] as keyof typeof LogLevel | undefined; + const logLevel = options.l as keyof typeof LogLevel | undefined; const enableI18nLegacyMessageIdFormat = options['legacy-message-ids']; const invalidateEntryPointManifest = options['invalidate-entry-point-manifest']; const errorOnFailedEntryPoint = options['error-on-failed-entry-point']; @@ -126,7 +130,7 @@ export function parseCommandLineOptions(args: string[]): NgccOptions { // yargs is not so great at mixed string+boolean types, so we have to test tsconfig against a // string "false" to capture the `tsconfig=false` option. // And we have to convert the option to a string to handle `no-tsconfig`, which will be `false`. - const tsConfigPath = `${options['tsconfig']}` === 'false' ? null : options['tsconfig']; + const tsConfigPath = `${options.tsconfig}` === 'false' ? null : options.tsconfig; const logger = logLevel && new ConsoleLogger(LogLevel[logLevel]); @@ -138,7 +142,7 @@ export function parseCommandLineOptions(args: string[]): NgccOptions { createNewEntryPointFormats, logger, enableI18nLegacyMessageIdFormat, - async: options['async'], + async: options.async, invalidateEntryPointManifest, errorOnFailedEntryPoint, tsConfigPath, From 723a9ff095400df1b9041c65342201964c821de9 Mon Sep 17 00:00:00 2001 From: Andrea Balducci Date: Mon, 17 Aug 2020 11:24:09 +0200 Subject: [PATCH 057/629] docs(common): Wrong parameter description on TrackBy (#38495) Track By Function receive the T[index] data, not the node id. TrackByFunction reference description has the same issue. PR Close #38495 --- packages/common/src/directives/ng_for_of.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/common/src/directives/ng_for_of.ts b/packages/common/src/directives/ng_for_of.ts index 700b671026..bae7bd543d 100644 --- a/packages/common/src/directives/ng_for_of.ts +++ b/packages/common/src/directives/ng_for_of.ts @@ -155,7 +155,7 @@ export class NgForOf = NgIterable> implements DoCh * rather than the identity of the object itself. * * The function receives two inputs, - * the iteration index and the node object ID. + * the iteration index and the associated node data. */ @Input() set ngForTrackBy(fn: TrackByFunction) { From bee44b33592da4c8b0d4a7e84964d9805bf96ef5 Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Tue, 18 Aug 2020 07:56:58 -0700 Subject: [PATCH 058/629] Revert "fix(router): ensure routerLinkActive updates when associated routerLinks change (#38349)" (#38511) This reverts commit e0e5c9f195460c7626c30248e7a79de9a2ebe377. Failures in Google tests were detected. PR Close #38511 --- goldens/public-api/router/router.d.ts | 5 +- packages/router/src/directives/router_link.ts | 22 ++------- .../src/directives/router_link_active.ts | 33 ++++--------- .../test/regression_integration.spec.ts | 48 ------------------- 4 files changed, 15 insertions(+), 93 deletions(-) diff --git a/goldens/public-api/router/router.d.ts b/goldens/public-api/router/router.d.ts index 5d911194bd..8e88ac7b7c 100644 --- a/goldens/public-api/router/router.d.ts +++ b/goldens/public-api/router/router.d.ts @@ -378,7 +378,7 @@ export declare class RouterEvent { url: string); } -export declare class RouterLink implements OnChanges { +export declare class RouterLink { fragment: string; preserveFragment: boolean; /** @deprecated */ set preserveQueryParams(value: boolean); @@ -394,7 +394,6 @@ export declare class RouterLink implements OnChanges { }; get urlTree(): UrlTree; constructor(router: Router, route: ActivatedRoute, tabIndex: string, renderer: Renderer2, el: ElementRef); - ngOnChanges(changes: SimpleChanges): void; onClick(): boolean; } @@ -430,7 +429,7 @@ export declare class RouterLinkWithHref implements OnChanges, OnDestroy { target: string; get urlTree(): UrlTree; constructor(router: Router, route: ActivatedRoute, locationStrategy: LocationStrategy); - ngOnChanges(changes: SimpleChanges): any; + ngOnChanges(changes: {}): any; ngOnDestroy(): any; onClick(button: number, ctrlKey: boolean, metaKey: boolean, shiftKey: boolean): boolean; } diff --git a/packages/router/src/directives/router_link.ts b/packages/router/src/directives/router_link.ts index 997319252a..58be39a05a 100644 --- a/packages/router/src/directives/router_link.ts +++ b/packages/router/src/directives/router_link.ts @@ -7,8 +7,8 @@ */ import {LocationStrategy} from '@angular/common'; -import {Attribute, Directive, ElementRef, HostBinding, HostListener, Input, isDevMode, OnChanges, OnDestroy, Renderer2, SimpleChanges} from '@angular/core'; -import {Subject, Subscription} from 'rxjs'; +import {Attribute, Directive, ElementRef, HostBinding, HostListener, Input, isDevMode, OnChanges, OnDestroy, Renderer2} from '@angular/core'; +import {Subscription} from 'rxjs'; import {QueryParamsHandling} from '../config'; import {Event, NavigationEnd} from '../events'; @@ -115,7 +115,7 @@ import {UrlTree} from '../url_tree'; * @publicApi */ @Directive({selector: ':not(a):not(area)[routerLink]'}) -export class RouterLink implements OnChanges { +export class RouterLink { /** * Passed to {@link Router#createUrlTree Router#createUrlTree} as part of the `NavigationExtras`. * @see {@link NavigationExtras#queryParams NavigationExtras#queryParams} @@ -167,9 +167,6 @@ export class RouterLink implements OnChanges { private commands: any[] = []; private preserve!: boolean; - /** @internal */ - onChanges = new Subject(); - constructor( private router: Router, private route: ActivatedRoute, @Attribute('tabindex') tabIndex: string, renderer: Renderer2, el: ElementRef) { @@ -178,13 +175,6 @@ export class RouterLink implements OnChanges { } } - /** @nodoc */ - ngOnChanges(changes: SimpleChanges) { - // This is subscribed to by `RouterLinkActive` so that it knows to update when there are changes - // to the RouterLinks it's tracking. - this.onChanges.next(); - } - /** * Commands to pass to {@link Router#createUrlTree Router#createUrlTree}. * - **array**: commands to pass to {@link Router#createUrlTree Router#createUrlTree}. @@ -308,9 +298,6 @@ export class RouterLinkWithHref implements OnChanges, OnDestroy { // TODO(issue/24571): remove '!'. @HostBinding() href!: string; - /** @internal */ - onChanges = new Subject(); - constructor( private router: Router, private route: ActivatedRoute, private locationStrategy: LocationStrategy) { @@ -349,9 +336,8 @@ export class RouterLinkWithHref implements OnChanges, OnDestroy { } /** @nodoc */ - ngOnChanges(changes: SimpleChanges): any { + ngOnChanges(changes: {}): any { this.updateTargetUrlAndHref(); - this.onChanges.next(); } /** @nodoc */ ngOnDestroy(): any { diff --git a/packages/router/src/directives/router_link_active.ts b/packages/router/src/directives/router_link_active.ts index 7761d91dbc..209546d424 100644 --- a/packages/router/src/directives/router_link_active.ts +++ b/packages/router/src/directives/router_link_active.ts @@ -7,8 +7,7 @@ */ import {AfterContentInit, ChangeDetectorRef, ContentChildren, Directive, ElementRef, Input, OnChanges, OnDestroy, Optional, QueryList, Renderer2, SimpleChanges} from '@angular/core'; -import {from, of, Subscription} from 'rxjs'; -import {mergeAll} from 'rxjs/operators'; +import {Subscription} from 'rxjs'; import {Event, NavigationEnd} from '../events'; import {Router} from '../router'; @@ -80,13 +79,14 @@ import {RouterLink, RouterLinkWithHref} from './router_link'; exportAs: 'routerLinkActive', }) export class RouterLinkActive implements OnChanges, OnDestroy, AfterContentInit { + // TODO(issue/24571): remove '!'. @ContentChildren(RouterLink, {descendants: true}) links!: QueryList; + // TODO(issue/24571): remove '!'. @ContentChildren(RouterLinkWithHref, {descendants: true}) linksWithHrefs!: QueryList; private classes: string[] = []; - private routerEventsSubscription: Subscription; - private linkInputChangesSubscription?: Subscription; + private subscription: Subscription; public readonly isActive: boolean = false; @Input() routerLinkActiveOptions: {exact: boolean} = {exact: false}; @@ -95,7 +95,7 @@ export class RouterLinkActive implements OnChanges, OnDestroy, AfterContentInit private router: Router, private element: ElementRef, private renderer: Renderer2, private readonly cdr: ChangeDetectorRef, @Optional() private link?: RouterLink, @Optional() private linkWithHref?: RouterLinkWithHref) { - this.routerEventsSubscription = router.events.subscribe((s: Event) => { + this.subscription = router.events.subscribe((s: Event) => { if (s instanceof NavigationEnd) { this.update(); } @@ -104,23 +104,9 @@ export class RouterLinkActive implements OnChanges, OnDestroy, AfterContentInit /** @nodoc */ ngAfterContentInit(): void { - // `of(null)` is used to force subscribe body to execute once immediately (like `startWith`). - from([this.links.changes, this.linksWithHrefs.changes, of(null)]) - .pipe(mergeAll()) - .subscribe(_ => { - this.update(); - this.subscribeToEachLinkOnChanges(); - }); - } - - private subscribeToEachLinkOnChanges() { - this.linkInputChangesSubscription?.unsubscribe(); - const allLinkChanges = - [...this.links.toArray(), ...this.linksWithHrefs.toArray(), this.link, this.linkWithHref] - .filter((link): link is RouterLink|RouterLinkWithHref => !!link) - .map(link => link.onChanges); - this.linkInputChangesSubscription = - from(allLinkChanges).pipe(mergeAll()).subscribe(() => this.update()); + this.links.changes.subscribe(_ => this.update()); + this.linksWithHrefs.changes.subscribe(_ => this.update()); + this.update(); } @Input() @@ -135,8 +121,7 @@ export class RouterLinkActive implements OnChanges, OnDestroy, AfterContentInit } /** @nodoc */ ngOnDestroy(): void { - this.routerEventsSubscription.unsubscribe(); - this.linkInputChangesSubscription?.unsubscribe(); + this.subscription.unsubscribe(); } private update(): void { diff --git a/packages/router/test/regression_integration.spec.ts b/packages/router/test/regression_integration.spec.ts index 9dd5b8ba32..3dbf4eaf68 100644 --- a/packages/router/test/regression_integration.spec.ts +++ b/packages/router/test/regression_integration.spec.ts @@ -14,54 +14,6 @@ import {RouterTestingModule} from '@angular/router/testing'; describe('Integration', () => { describe('routerLinkActive', () => { - it('should update when the associated routerLinks change - #18469', fakeAsync(() => { - @Component({ - template: ` - {{firstLink}} - - `, - }) - class LinkComponent { - firstLink = 'link-a'; - secondLink = 'link-b'; - - changeLinks(): void { - const temp = this.secondLink; - this.secondLink = this.firstLink; - this.firstLink = temp; - } - } - - @Component({template: 'simple'}) - class SimpleCmp { - } - - TestBed.configureTestingModule({ - imports: [RouterTestingModule.withRoutes( - [{path: 'link-a', component: SimpleCmp}, {path: 'link-b', component: SimpleCmp}])], - declarations: [LinkComponent, SimpleCmp] - }); - - const router: Router = TestBed.inject(Router); - const fixture = createRoot(router, LinkComponent); - const firstLink = fixture.debugElement.query(p => p.nativeElement.id === 'first-link'); - const secondLink = fixture.debugElement.query(p => p.nativeElement.id === 'second-link'); - router.navigateByUrl('/link-a'); - advance(fixture); - - expect(firstLink.nativeElement.classList).toContain('active'); - expect(secondLink.nativeElement.classList).not.toContain('active'); - - fixture.componentInstance.changeLinks(); - fixture.detectChanges(); - advance(fixture); - - expect(firstLink.nativeElement.classList).not.toContain('active'); - expect(secondLink.nativeElement.classList).toContain('active'); - })); - it('should not cause infinite loops in the change detection - #15825', fakeAsync(() => { @Component({selector: 'simple', template: 'simple'}) class SimpleCmp { From dbfb50e9f4fe48050b2ab59d19ccd2438c14cc97 Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Tue, 18 Aug 2020 07:57:42 -0700 Subject: [PATCH 059/629] fix(router): ensure routerLinkActive updates when associated routerLinks change (#38511) This commit introduces a new subscription in the `routerLinkActive` directive which triggers an update when any of its associated routerLinks have changes. `RouterLinkActive` not only needs to know when links are added or removed, but it also needs to know about if a link it already knows about changes in some way. Quick note that `from...mergeAll` is used instead of just a simple `merge` (or `scheduled...mergeAll`) to avoid introducing new rxjs operators in order to keep bundle size down. Fixes #18469 PR Close #38511 --- goldens/public-api/router/router.d.ts | 5 +- packages/router/src/directives/router_link.ts | 22 +++++++-- .../src/directives/router_link_active.ts | 36 ++++++++++---- packages/router/test/integration.spec.ts | 25 +++++++++- .../test/regression_integration.spec.ts | 48 +++++++++++++++++++ 5 files changed, 120 insertions(+), 16 deletions(-) diff --git a/goldens/public-api/router/router.d.ts b/goldens/public-api/router/router.d.ts index 8e88ac7b7c..5d911194bd 100644 --- a/goldens/public-api/router/router.d.ts +++ b/goldens/public-api/router/router.d.ts @@ -378,7 +378,7 @@ export declare class RouterEvent { url: string); } -export declare class RouterLink { +export declare class RouterLink implements OnChanges { fragment: string; preserveFragment: boolean; /** @deprecated */ set preserveQueryParams(value: boolean); @@ -394,6 +394,7 @@ export declare class RouterLink { }; get urlTree(): UrlTree; constructor(router: Router, route: ActivatedRoute, tabIndex: string, renderer: Renderer2, el: ElementRef); + ngOnChanges(changes: SimpleChanges): void; onClick(): boolean; } @@ -429,7 +430,7 @@ export declare class RouterLinkWithHref implements OnChanges, OnDestroy { target: string; get urlTree(): UrlTree; constructor(router: Router, route: ActivatedRoute, locationStrategy: LocationStrategy); - ngOnChanges(changes: {}): any; + ngOnChanges(changes: SimpleChanges): any; ngOnDestroy(): any; onClick(button: number, ctrlKey: boolean, metaKey: boolean, shiftKey: boolean): boolean; } diff --git a/packages/router/src/directives/router_link.ts b/packages/router/src/directives/router_link.ts index 58be39a05a..8d8a2da5cb 100644 --- a/packages/router/src/directives/router_link.ts +++ b/packages/router/src/directives/router_link.ts @@ -7,8 +7,8 @@ */ import {LocationStrategy} from '@angular/common'; -import {Attribute, Directive, ElementRef, HostBinding, HostListener, Input, isDevMode, OnChanges, OnDestroy, Renderer2} from '@angular/core'; -import {Subscription} from 'rxjs'; +import {Attribute, Directive, ElementRef, HostBinding, HostListener, Input, isDevMode, OnChanges, OnDestroy, Renderer2, SimpleChanges} from '@angular/core'; +import {Subject, Subscription} from 'rxjs'; import {QueryParamsHandling} from '../config'; import {Event, NavigationEnd} from '../events'; @@ -115,7 +115,7 @@ import {UrlTree} from '../url_tree'; * @publicApi */ @Directive({selector: ':not(a):not(area)[routerLink]'}) -export class RouterLink { +export class RouterLink implements OnChanges { /** * Passed to {@link Router#createUrlTree Router#createUrlTree} as part of the `NavigationExtras`. * @see {@link NavigationExtras#queryParams NavigationExtras#queryParams} @@ -167,6 +167,9 @@ export class RouterLink { private commands: any[] = []; private preserve!: boolean; + /** @internal */ + onChanges = new Subject(); + constructor( private router: Router, private route: ActivatedRoute, @Attribute('tabindex') tabIndex: string, renderer: Renderer2, el: ElementRef) { @@ -175,6 +178,13 @@ export class RouterLink { } } + /** @nodoc */ + ngOnChanges(changes: SimpleChanges) { + // This is subscribed to by `RouterLinkActive` so that it knows to update when there are changes + // to the RouterLinks it's tracking. + this.onChanges.next(this); + } + /** * Commands to pass to {@link Router#createUrlTree Router#createUrlTree}. * - **array**: commands to pass to {@link Router#createUrlTree Router#createUrlTree}. @@ -298,6 +308,9 @@ export class RouterLinkWithHref implements OnChanges, OnDestroy { // TODO(issue/24571): remove '!'. @HostBinding() href!: string; + /** @internal */ + onChanges = new Subject(); + constructor( private router: Router, private route: ActivatedRoute, private locationStrategy: LocationStrategy) { @@ -336,8 +349,9 @@ export class RouterLinkWithHref implements OnChanges, OnDestroy { } /** @nodoc */ - ngOnChanges(changes: {}): any { + ngOnChanges(changes: SimpleChanges): any { this.updateTargetUrlAndHref(); + this.onChanges.next(this); } /** @nodoc */ ngOnDestroy(): any { diff --git a/packages/router/src/directives/router_link_active.ts b/packages/router/src/directives/router_link_active.ts index 209546d424..4b1f69b47c 100644 --- a/packages/router/src/directives/router_link_active.ts +++ b/packages/router/src/directives/router_link_active.ts @@ -7,7 +7,8 @@ */ import {AfterContentInit, ChangeDetectorRef, ContentChildren, Directive, ElementRef, Input, OnChanges, OnDestroy, Optional, QueryList, Renderer2, SimpleChanges} from '@angular/core'; -import {Subscription} from 'rxjs'; +import {from, of, Subscription} from 'rxjs'; +import {mergeAll} from 'rxjs/operators'; import {Event, NavigationEnd} from '../events'; import {Router} from '../router'; @@ -79,14 +80,13 @@ import {RouterLink, RouterLinkWithHref} from './router_link'; exportAs: 'routerLinkActive', }) export class RouterLinkActive implements OnChanges, OnDestroy, AfterContentInit { - // TODO(issue/24571): remove '!'. @ContentChildren(RouterLink, {descendants: true}) links!: QueryList; - // TODO(issue/24571): remove '!'. @ContentChildren(RouterLinkWithHref, {descendants: true}) linksWithHrefs!: QueryList; private classes: string[] = []; - private subscription: Subscription; + private routerEventsSubscription: Subscription; + private linkInputChangesSubscription?: Subscription; public readonly isActive: boolean = false; @Input() routerLinkActiveOptions: {exact: boolean} = {exact: false}; @@ -95,7 +95,7 @@ export class RouterLinkActive implements OnChanges, OnDestroy, AfterContentInit private router: Router, private element: ElementRef, private renderer: Renderer2, private readonly cdr: ChangeDetectorRef, @Optional() private link?: RouterLink, @Optional() private linkWithHref?: RouterLinkWithHref) { - this.subscription = router.events.subscribe((s: Event) => { + this.routerEventsSubscription = router.events.subscribe((s: Event) => { if (s instanceof NavigationEnd) { this.update(); } @@ -104,9 +104,26 @@ export class RouterLinkActive implements OnChanges, OnDestroy, AfterContentInit /** @nodoc */ ngAfterContentInit(): void { - this.links.changes.subscribe(_ => this.update()); - this.linksWithHrefs.changes.subscribe(_ => this.update()); - this.update(); + // `of(null)` is used to force subscribe body to execute once immediately (like `startWith`). + from([this.links.changes, this.linksWithHrefs.changes, of(null)]) + .pipe(mergeAll()) + .subscribe(_ => { + this.update(); + this.subscribeToEachLinkOnChanges(); + }); + } + + private subscribeToEachLinkOnChanges() { + this.linkInputChangesSubscription?.unsubscribe(); + const allLinkChanges = + [...this.links.toArray(), ...this.linksWithHrefs.toArray(), this.link, this.linkWithHref] + .filter((link): link is RouterLink|RouterLinkWithHref => !!link) + .map(link => link.onChanges); + this.linkInputChangesSubscription = from(allLinkChanges).pipe(mergeAll()).subscribe(link => { + if (this.isActive !== this.isLinkActive(this.router)(link)) { + this.update(); + } + }); } @Input() @@ -121,7 +138,8 @@ export class RouterLinkActive implements OnChanges, OnDestroy, AfterContentInit } /** @nodoc */ ngOnDestroy(): void { - this.subscription.unsubscribe(); + this.routerEventsSubscription.unsubscribe(); + this.linkInputChangesSubscription?.unsubscribe(); } private update(): void { diff --git a/packages/router/test/integration.spec.ts b/packages/router/test/integration.spec.ts index 47d4edbbce..52a568b6b1 100644 --- a/packages/router/test/integration.spec.ts +++ b/packages/router/test/integration.spec.ts @@ -4344,7 +4344,7 @@ describe('Integration', () => { }))); }); - describe('routerActiveLink', () => { + describe('routerLinkActive', () => { it('should set the class when the link is active (a tag)', fakeAsync(inject([Router, Location], (router: Router, location: Location) => { const fixture = createRoot(router, RootCmp); @@ -4494,6 +4494,29 @@ describe('Integration', () => { advance(fixture); expect(paragraph.textContent).toEqual('false'); })); + + it('should not trigger change detection when active state has not changed', fakeAsync(() => { + @Component({ + template: ``, + }) + class LinkComponent { + link = 'notactive'; + } + + @Component({template: ''}) + class SimpleComponent { + } + + TestBed.configureTestingModule({ + imports: [RouterTestingModule.withRoutes([{path: '', component: SimpleComponent}])], + declarations: [LinkComponent, SimpleComponent] + }); + + const fixture = createRoot(TestBed.inject(Router), LinkComponent); + fixture.componentInstance.link = 'stillnotactive'; + fixture.detectChanges(false /** checkNoChanges */); + expect(TestBed.inject(NgZone).hasPendingMicrotasks).toBe(false); + })); }); describe('lazy loading', () => { diff --git a/packages/router/test/regression_integration.spec.ts b/packages/router/test/regression_integration.spec.ts index 3dbf4eaf68..9dd5b8ba32 100644 --- a/packages/router/test/regression_integration.spec.ts +++ b/packages/router/test/regression_integration.spec.ts @@ -14,6 +14,54 @@ import {RouterTestingModule} from '@angular/router/testing'; describe('Integration', () => { describe('routerLinkActive', () => { + it('should update when the associated routerLinks change - #18469', fakeAsync(() => { + @Component({ + template: ` + {{firstLink}} + + `, + }) + class LinkComponent { + firstLink = 'link-a'; + secondLink = 'link-b'; + + changeLinks(): void { + const temp = this.secondLink; + this.secondLink = this.firstLink; + this.firstLink = temp; + } + } + + @Component({template: 'simple'}) + class SimpleCmp { + } + + TestBed.configureTestingModule({ + imports: [RouterTestingModule.withRoutes( + [{path: 'link-a', component: SimpleCmp}, {path: 'link-b', component: SimpleCmp}])], + declarations: [LinkComponent, SimpleCmp] + }); + + const router: Router = TestBed.inject(Router); + const fixture = createRoot(router, LinkComponent); + const firstLink = fixture.debugElement.query(p => p.nativeElement.id === 'first-link'); + const secondLink = fixture.debugElement.query(p => p.nativeElement.id === 'second-link'); + router.navigateByUrl('/link-a'); + advance(fixture); + + expect(firstLink.nativeElement.classList).toContain('active'); + expect(secondLink.nativeElement.classList).not.toContain('active'); + + fixture.componentInstance.changeLinks(); + fixture.detectChanges(); + advance(fixture); + + expect(firstLink.nativeElement.classList).not.toContain('active'); + expect(secondLink.nativeElement.classList).toContain('active'); + })); + it('should not cause infinite loops in the change detection - #15825', fakeAsync(() => { @Component({selector: 'simple', template: 'simple'}) class SimpleCmp { From aaa1d8e2fe41e7ce55861eaa0ac176b23a061e2d Mon Sep 17 00:00:00 2001 From: JiaLiPassion Date: Sat, 15 Aug 2020 08:47:09 +0900 Subject: [PATCH 060/629] release: cut the zone.js-0.11.0 release (#38473) PR Close #38473 --- packages/zone.js/CHANGELOG.md | 26 ++++++++++++++++++++++++++ packages/zone.js/DEVELOPER.md | 2 +- packages/zone.js/package.json | 6 +++--- 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/packages/zone.js/CHANGELOG.md b/packages/zone.js/CHANGELOG.md index c08fc781df..ad1cc63e00 100644 --- a/packages/zone.js/CHANGELOG.md +++ b/packages/zone.js/CHANGELOG.md @@ -1,3 +1,29 @@ + +# [0.11.0](https://github.com/angular/angular/compare/zone.js-0.10.3...zone.js-0.11.0) (2020-08-14) + + +### Bug Fixes + +* **zone.js:** add issue numbers of `[@types](https://github.com/types)/jasmine` to the test cases ([#34625](https://github.com/angular/angular/issues/34625)) ([41667de](https://github.com/angular/angular/commit/41667de)) +* **zone.js:** clearTimeout/clearInterval should call on object global ([#37858](https://github.com/angular/angular/issues/37858)) ([a71f114](https://github.com/angular/angular/commit/a71f114)), closes [#37333](https://github.com/angular/angular/issues/37333) +* **zone.js:** fix 2 bluebird test cases for each/mapSeries ([#36295](https://github.com/angular/angular/issues/36295)) ([b44f7b5](https://github.com/angular/angular/commit/b44f7b5)) +* **zone.js:** patch nodejs EventEmtter.prototype.off ([#37863](https://github.com/angular/angular/issues/37863)) ([1822cbc](https://github.com/angular/angular/commit/1822cbc)), closes [#35473](https://github.com/angular/angular/issues/35473) +* **zone.js:** remove unused Promise overwritten setter logic ([#36851](https://github.com/angular/angular/issues/36851)) ([31796e8](https://github.com/angular/angular/commit/31796e8)) +* **zone.js:** should not try to patch fetch if it is not writable ([#36311](https://github.com/angular/angular/issues/36311)) ([416c786](https://github.com/angular/angular/commit/416c786)), closes [#36142](https://github.com/angular/angular/issues/36142) +* **zone.js:** UNPATCHED_EVENTS and PASSIVE_EVENTS should be string[] not boolean ([#36258](https://github.com/angular/angular/issues/36258)) ([36e927a](https://github.com/angular/angular/commit/36e927a)) +* **zone.js:** zone patch rxjs should return null _unsubscribe correctly. ([#37091](https://github.com/angular/angular/issues/37091)) ([96aa14d](https://github.com/angular/angular/commit/96aa14d)), closes [#31684](https://github.com/angular/angular/issues/31684) +* **zone.js:** zone.js patch jest should handle done correctly ([#36022](https://github.com/angular/angular/issues/36022)) ([4374931](https://github.com/angular/angular/commit/4374931)) + + +### Features + +* **zone.js:** move all zone optional bundles to plugins folders ([#36540](https://github.com/angular/angular/issues/36540)) ([b199ef6](https://github.com/angular/angular/commit/b199ef6)) +* **zone.js:** move MutationObserver/FileReader to different module ([#31657](https://github.com/angular/angular/issues/31657)) ([253337d](https://github.com/angular/angular/commit/253337d)) +* **zone.js:** patch jasmine.createSpyObj to make properties enumerable to be true ([#34624](https://github.com/angular/angular/issues/34624)) ([c2b4d92](https://github.com/angular/angular/commit/c2b4d92)), closes [#33657](https://github.com/angular/angular/issues/33657) +* **zone.js:** upgrade zone.js to angular package format(APF) ([#36540](https://github.com/angular/angular/issues/36540)) ([583a9d3](https://github.com/angular/angular/commit/583a9d3)), closes [#35157](https://github.com/angular/angular/issues/35157) [/github.com/angular/angular-cli/blob/5376a8b1392ac7bd252782d8474161ce03a4d1cb/packages/schematics/angular/application/files/src/polyfills.ts.template#L55-L58](https://github.com//github.com/angular/angular-cli/blob/5376a8b1392ac7bd252782d8474161ce03a4d1cb/packages/schematics/angular/application/files/src/polyfills.ts.template/issues/L55-L58) + + + ## [0.10.3](https://github.com/angular/angular/compare/zone.js-0.10.2...zone.js-0.10.3) (2020-02-27) diff --git a/packages/zone.js/DEVELOPER.md b/packages/zone.js/DEVELOPER.md index c1a5e5df53..c47f9108b3 100644 --- a/packages/zone.js/DEVELOPER.md +++ b/packages/zone.js/DEVELOPER.md @@ -75,7 +75,7 @@ Releasing `zone.js` is a two step process. #### 1. Creating a PR for release ``` -export PREVIOUS_ZONE_TAG=`git tag -l 'zone.js-0.10.*' | tail -n1` +export PREVIOUS_ZONE_TAG=`git tag -l 'zone.js-0.11.*' | tail -n1` export VERSION=`(cd packages/zone.js; npm version patch --no-git-tag-version)` export VERSION=${VERSION#v} export TAG="zone.js-${VERSION}" diff --git a/packages/zone.js/package.json b/packages/zone.js/package.json index de98a551e0..fdfad9a9c9 100644 --- a/packages/zone.js/package.json +++ b/packages/zone.js/package.json @@ -1,6 +1,6 @@ { "name": "zone.js", - "version": "0.10.3", + "version": "0.11.0", "description": "Zones for JavaScript", "main": "./bundles/zone.umd.js", "module": "./fesm2015/zone.js", @@ -38,8 +38,8 @@ "url": "git://github.com/angular/angular.git", "directory": "packages/zone.js" }, - "publishConfig":{ - "registry":"https://wombat-dressing-room.appspot.com" + "publishConfig": { + "registry": "https://wombat-dressing-room.appspot.com" }, "author": "Brian Ford", "license": "MIT", From 63ba74fe4e7f5ea9cb07b866647488cb76adf066 Mon Sep 17 00:00:00 2001 From: Joey Perrott Date: Fri, 14 Aug 2020 16:49:07 -0700 Subject: [PATCH 061/629] feat(dev-infra): tooling to check out pending PR (#38474) Creates a tool within ng-dev to checkout a pending PR from the upstream repository. This automates an action that many developers on the Angular team need to do periodically in the process of testing and reviewing incoming PRs. Example usage: ng-dev pr checkout PR Close #38474 --- dev-infra/pr/BUILD.bazel | 1 + dev-infra/pr/checkout/BUILD.bazel | 13 ++ dev-infra/pr/checkout/cli.ts | 50 +++++++ dev-infra/pr/cli.ts | 4 +- dev-infra/pr/common/BUILD.bazel | 12 ++ dev-infra/pr/common/checkout-pr.ts | 135 +++++++++++++++++++ dev-infra/pr/discover-new-conflicts/index.ts | 2 +- dev-infra/pr/rebase/index.ts | 2 +- dev-infra/utils/git/github.ts | 8 +- dev-infra/utils/git/index.ts | 19 +++ dev-infra/utils/github.ts | 71 +++------- 11 files changed, 261 insertions(+), 56 deletions(-) create mode 100644 dev-infra/pr/checkout/BUILD.bazel create mode 100644 dev-infra/pr/checkout/cli.ts create mode 100644 dev-infra/pr/common/BUILD.bazel create mode 100644 dev-infra/pr/common/checkout-pr.ts diff --git a/dev-infra/pr/BUILD.bazel b/dev-infra/pr/BUILD.bazel index 023bbd69ac..bd2aaaa512 100644 --- a/dev-infra/pr/BUILD.bazel +++ b/dev-infra/pr/BUILD.bazel @@ -6,6 +6,7 @@ ts_library( module_name = "@angular/dev-infra-private/pr", visibility = ["//dev-infra:__subpackages__"], deps = [ + "//dev-infra/pr/checkout", "//dev-infra/pr/discover-new-conflicts", "//dev-infra/pr/merge", "//dev-infra/pr/rebase", diff --git a/dev-infra/pr/checkout/BUILD.bazel b/dev-infra/pr/checkout/BUILD.bazel new file mode 100644 index 0000000000..b6ce76177d --- /dev/null +++ b/dev-infra/pr/checkout/BUILD.bazel @@ -0,0 +1,13 @@ +load("@npm_bazel_typescript//:index.bzl", "ts_library") + +ts_library( + name = "checkout", + srcs = glob(["*.ts"]), + module_name = "@angular/dev-infra-private/pr/checkout", + visibility = ["//dev-infra:__subpackages__"], + deps = [ + "//dev-infra/pr/common", + "//dev-infra/utils", + "@npm//@types/yargs", + ], +) diff --git a/dev-infra/pr/checkout/cli.ts b/dev-infra/pr/checkout/cli.ts new file mode 100644 index 0000000000..de9d8b59f8 --- /dev/null +++ b/dev-infra/pr/checkout/cli.ts @@ -0,0 +1,50 @@ +/** + * @license + * Copyright Google LLC 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 {Arguments, Argv, CommandModule} from 'yargs'; + +import {error} from '../../utils/console'; +import {checkOutPullRequestLocally} from '../common/checkout-pr'; + +export interface CheckoutOptions { + prNumber: number; + 'github-token'?: string; +} + +/** URL to the Github page where personal access tokens can be generated. */ +export const GITHUB_TOKEN_GENERATE_URL = `https://github.com/settings/tokens`; + +/** Builds the checkout pull request command. */ +function builder(yargs: Argv) { + return yargs.positional('prNumber', {type: 'number', demandOption: true}).option('github-token', { + type: 'string', + description: 'Github token. If not set, token is retrieved from the environment variables.' + }); +} + +/** Handles the checkout pull request command. */ +async function handler({prNumber, 'github-token': token}: Arguments) { + const githubToken = token || process.env.GITHUB_TOKEN || process.env.TOKEN; + if (!githubToken) { + error('No Github token set. Please set the `GITHUB_TOKEN` environment variable.'); + error('Alternatively, pass the `--github-token` command line flag.'); + error(`You can generate a token here: ${GITHUB_TOKEN_GENERATE_URL}`); + process.exitCode = 1; + return; + } + const prCheckoutOptions = {allowIfMaintainerCannotModify: true, branchName: `pr-${prNumber}`}; + await checkOutPullRequestLocally(prNumber, githubToken, prCheckoutOptions); +} + +/** yargs command module for checking out a PR */ +export const CheckoutCommandModule: CommandModule<{}, CheckoutOptions> = { + handler, + builder, + command: 'checkout ', + describe: 'Checkout a PR from the upstream repo', +}; diff --git a/dev-infra/pr/cli.ts b/dev-infra/pr/cli.ts index 0f4d312d44..fb080ed5c2 100644 --- a/dev-infra/pr/cli.ts +++ b/dev-infra/pr/cli.ts @@ -8,6 +8,7 @@ import * as yargs from 'yargs'; +import {CheckoutCommandModule} from './checkout/cli'; import {buildDiscoverNewConflictsCommand, handleDiscoverNewConflictsCommand} from './discover-new-conflicts/cli'; import {buildMergeCommand, handleMergeCommand} from './merge/cli'; import {buildRebaseCommand, handleRebaseCommand} from './rebase/cli'; @@ -24,7 +25,8 @@ export function buildPrParser(localYargs: yargs.Argv) { buildDiscoverNewConflictsCommand, handleDiscoverNewConflictsCommand) .command( 'rebase ', 'Rebase a pending PR and push the rebased commits back to Github', - buildRebaseCommand, handleRebaseCommand); + buildRebaseCommand, handleRebaseCommand) + .command(CheckoutCommandModule); } if (require.main === module) { diff --git a/dev-infra/pr/common/BUILD.bazel b/dev-infra/pr/common/BUILD.bazel new file mode 100644 index 0000000000..a25c3b90ea --- /dev/null +++ b/dev-infra/pr/common/BUILD.bazel @@ -0,0 +1,12 @@ +load("@npm_bazel_typescript//:index.bzl", "ts_library") + +ts_library( + name = "common", + srcs = glob(["*.ts"]), + visibility = ["//dev-infra:__subpackages__"], + deps = [ + "//dev-infra/utils", + "@npm//@types/node", + "@npm//typed-graphqlify", + ], +) diff --git a/dev-infra/pr/common/checkout-pr.ts b/dev-infra/pr/common/checkout-pr.ts new file mode 100644 index 0000000000..5fd8a4a36f --- /dev/null +++ b/dev-infra/pr/common/checkout-pr.ts @@ -0,0 +1,135 @@ +/** + * @license + * Copyright Google LLC 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 {types as graphQLTypes} from 'typed-graphqlify'; +import {URL} from 'url'; + +import {info} from '../../utils/console'; +import {GitClient} from '../../utils/git'; +import {getPr} from '../../utils/github'; + +/* GraphQL schema for the response body for a pending PR. */ +const PR_SCHEMA = { + state: graphQLTypes.string, + maintainerCanModify: graphQLTypes.boolean, + viewerDidAuthor: graphQLTypes.boolean, + headRefOid: graphQLTypes.string, + headRef: { + name: graphQLTypes.string, + repository: { + url: graphQLTypes.string, + nameWithOwner: graphQLTypes.string, + }, + }, + baseRef: { + name: graphQLTypes.string, + repository: { + url: graphQLTypes.string, + nameWithOwner: graphQLTypes.string, + }, + }, +}; + + +export class UnexpectedLocalChangesError extends Error { + constructor(m: string) { + super(m); + Object.setPrototypeOf(this, UnexpectedLocalChangesError.prototype); + } +} + +export class MaintainerModifyAccessError extends Error { + constructor(m: string) { + super(m); + Object.setPrototypeOf(this, MaintainerModifyAccessError.prototype); + } +} + +/** Options for checking out a PR */ +export interface PullRequestCheckoutOptions { + /** Whether the PR should be checked out if the maintainer cannot modify. */ + allowIfMaintainerCannotModify?: boolean; +} + +/** + * Rebase the provided PR onto its merge target branch, and push up the resulting + * commit to the PRs repository. + */ +export async function checkOutPullRequestLocally( + prNumber: number, githubToken: string, opts: PullRequestCheckoutOptions = {}) { + /** Authenticated Git client for git and Github interactions. */ + const git = new GitClient(githubToken); + + // In order to preserve local changes, checkouts cannot occur if local changes are present in the + // git environment. Checked before retrieving the PR to fail fast. + if (git.hasLocalChanges()) { + throw new UnexpectedLocalChangesError('Unable to checkout PR due to uncommitted changes.'); + } + + /** + * The branch or revision originally checked out before this method performed + * any Git operations that may change the working branch. + */ + const previousBranchOrRevision = git.getCurrentBranchOrRevision(); + /* The PR information from Github. */ + const pr = await getPr(PR_SCHEMA, prNumber, git); + /** The branch name of the PR from the repository the PR came from. */ + const headRefName = pr.headRef.name; + /** The full ref for the repository and branch the PR came from. */ + const fullHeadRef = `${pr.headRef.repository.nameWithOwner}:${headRefName}`; + /** The full URL path of the repository the PR came from with github token as authentication. */ + const headRefUrl = addAuthenticationToUrl(pr.headRef.repository.url, githubToken); + // Note: Since we use a detached head for rebasing the PR and therefore do not have + // remote-tracking branches configured, we need to set our expected ref and SHA. This + // allows us to use `--force-with-lease` for the detached head while ensuring that we + // never accidentally override upstream changes that have been pushed in the meanwhile. + // See: + // https://git-scm.com/docs/git-push#Documentation/git-push.txt---force-with-leaseltrefnamegtltexpectgt + /** Flag for a force push with leage back to upstream. */ + const forceWithLeaseFlag = `--force-with-lease=${headRefName}:${pr.headRefOid}`; + + // If the PR does not allow maintainers to modify it, exit as the rebased PR cannot + // be pushed up. + if (!pr.maintainerCanModify && !pr.viewerDidAuthor && !opts.allowIfMaintainerCannotModify) { + throw new MaintainerModifyAccessError('PR is not set to allow maintainers to modify the PR'); + } + + try { + // Fetch the branch at the commit of the PR, and check it out in a detached state. + info(`Checking out PR #${prNumber} from ${fullHeadRef}`); + git.run(['fetch', headRefUrl, headRefName]); + git.run(['checkout', '--detach', 'FETCH_HEAD']); + } catch (e) { + git.checkout(previousBranchOrRevision, true); + throw e; + } + + return { + /** + * Pushes the current local branch to the PR on the upstream repository. + * + * @returns true If the command did not fail causing a GitCommandError to be thrown. + * @throws GitCommandError Thrown when the push back to upstream fails. + */ + pushToUpstream: (): true => { + git.run(['push', headRefUrl, `HEAD:${headRefName}`, forceWithLeaseFlag]); + return true; + }, + /** Restores the state of the local repository to before the PR checkout occured. */ + resetGitState: (): boolean => { + return git.checkout(previousBranchOrRevision, true); + } + }; +} + +/** Adds the provided token as username to the provided url. */ +function addAuthenticationToUrl(urlString: string, token: string) { + const url = new URL(urlString); + url.username = token; + return url.toString(); +} diff --git a/dev-infra/pr/discover-new-conflicts/index.ts b/dev-infra/pr/discover-new-conflicts/index.ts index 790d809438..c59f1a0698 100644 --- a/dev-infra/pr/discover-new-conflicts/index.ts +++ b/dev-infra/pr/discover-new-conflicts/index.ts @@ -72,7 +72,7 @@ export async function discoverNewConflictsForPr( info(`Requesting pending PRs from Github`); /** List of PRs from github currently known as mergable. */ - const allPendingPRs = (await getPendingPrs(PR_SCHEMA, config.github)).map(processPr); + const allPendingPRs = (await getPendingPrs(PR_SCHEMA, git)).map(processPr); /** The PR which is being checked against. */ const requestedPr = allPendingPRs.find(pr => pr.number === newPrNumber); if (requestedPr === undefined) { diff --git a/dev-infra/pr/rebase/index.ts b/dev-infra/pr/rebase/index.ts index 8c0af93324..149de6513a 100644 --- a/dev-infra/pr/rebase/index.ts +++ b/dev-infra/pr/rebase/index.ts @@ -55,7 +55,7 @@ export async function rebasePr( */ const previousBranchOrRevision = git.getCurrentBranchOrRevision(); /* Get the PR information from Github. */ - const pr = await getPr(PR_SCHEMA, prNumber, config.github); + const pr = await getPr(PR_SCHEMA, prNumber, git); const headRefName = pr.headRef.name; const baseRefName = pr.baseRef.name; diff --git a/dev-infra/utils/git/github.ts b/dev-infra/utils/git/github.ts index 85d0614358..cea6979cd7 100644 --- a/dev-infra/utils/git/github.ts +++ b/dev-infra/utils/git/github.ts @@ -26,7 +26,7 @@ export class GithubApiRequestError extends Error { **/ export class GithubClient extends Octokit { /** The Github GraphQL (v4) API. */ - graqhql: GithubGraphqlClient; + graphql: GithubGraphqlClient; /** The current user based on checking against the Github API. */ private _currentUser: string|null = null; @@ -42,7 +42,7 @@ export class GithubClient extends Octokit { }); // Create authenticated graphql client. - this.graqhql = new GithubGraphqlClient(token); + this.graphql = new GithubGraphqlClient(token); } /** Retrieve the login of the current user from Github. */ @@ -51,7 +51,7 @@ export class GithubClient extends Octokit { if (this._currentUser !== null) { return this._currentUser; } - const result = await this.graqhql.query({ + const result = await this.graphql.query({ viewer: { login: types.string, } @@ -80,7 +80,7 @@ class GithubGraphqlClient { // Set the default headers to include authorization with the provided token for all // graphQL calls. if (token) { - this.graqhql.defaults({headers: {authorization: `token ${token}`}}); + this.graqhql = this.graqhql.defaults({headers: {authorization: `token ${token}`}}); } } diff --git a/dev-infra/utils/git/index.ts b/dev-infra/utils/git/index.ts index 030a25c4ca..93364731ad 100644 --- a/dev-infra/utils/git/index.ts +++ b/dev-infra/utils/git/index.ts @@ -150,6 +150,25 @@ export class GitClient { return value.replace(this._githubTokenRegex, ''); } + /** + * Checks out a requested branch or revision, optionally cleaning the state of the repository + * before attempting the checking. Returns a boolean indicating whether the branch or revision + * was cleanly checked out. + */ + checkout(branchOrRevision: string, cleanState: boolean): boolean { + if (cleanState) { + // Abort any outstanding ams. + this.runGraceful(['am', '--abort'], {stdio: 'ignore'}); + // Abort any outstanding cherry-picks. + this.runGraceful(['cherry-pick', '--abort'], {stdio: 'ignore'}); + // Abort any outstanding rebases. + this.runGraceful(['rebase', '--abort'], {stdio: 'ignore'}); + // Clear any changes in the current repo. + this.runGraceful(['reset', '--hard'], {stdio: 'ignore'}); + } + return this.runGraceful(['checkout', branchOrRevision], {stdio: 'ignore'}).status === 0; + } + /** * Assert the GitClient instance is using a token with permissions for the all of the * provided OAuth scopes. diff --git a/dev-infra/utils/github.ts b/dev-infra/utils/github.ts index 04cd93ed97..7e187a24a4 100644 --- a/dev-infra/utils/github.ts +++ b/dev-infra/utils/github.ts @@ -6,29 +6,15 @@ * found in the LICENSE file at https://angular.io/license */ -import {graphql as unauthenticatedGraphql} from '@octokit/graphql'; +import {params, types} from 'typed-graphqlify'; -import {params, query as graphqlQuery, types} from 'typed-graphqlify'; -import {NgDevConfig} from './config'; - -/** The configuration required for github interactions. */ -type GithubConfig = NgDevConfig['github']; - -/** - * Authenticated instance of Github GraphQl API service, relies on a - * personal access token being available in the TOKEN environment variable. - */ -const graphql = unauthenticatedGraphql.defaults({ - headers: { - // TODO(josephperrott): Remove reference to TOKEN environment variable as part of larger - // effort to migrate to expecting tokens via GITHUB_ACCESS_TOKEN environment variables. - authorization: `token ${process.env.TOKEN || process.env.GITHUB_ACCESS_TOKEN}`, - } -}); +import {GitClient} from './git'; /** Get a PR from github */ -export async function getPr( - prSchema: PrSchema, prNumber: number, {owner, name}: GithubConfig) { +export async function getPr(prSchema: PrSchema, prNumber: number, git: GitClient) { + /** The owner and name of the repository */ + const {owner, name} = git.remoteConfig; + /** The GraphQL query object to get a the PR */ const PR_QUERY = params( { $number: 'Int!', // The PR number @@ -41,14 +27,15 @@ export async function getPr( }) }); - const result = - await graphql(graphqlQuery(PR_QUERY), {number: prNumber, owner, name}) as typeof PR_QUERY; + const result = (await git.github.graphql.query(PR_QUERY, {number: prNumber, owner, name})); return result.repository.pullRequest; } /** Get all pending PRs from github */ -export async function getPendingPrs(prSchema: PrSchema, {owner, name}: GithubConfig) { - // The GraphQL query object to get a page of pending PRs +export async function getPendingPrs(prSchema: PrSchema, git: GitClient) { + /** The owner and name of the repository */ + const {owner, name} = git.remoteConfig; + /** The GraphQL query object to get a page of pending PRs */ const PRS_QUERY = params( { $first: 'Int', // How many entries to get with each request @@ -73,36 +60,22 @@ export async function getPendingPrs(prSchema: PrSchema, {owner, name}: }), }) }); - const query = graphqlQuery('members', PRS_QUERY); - - /** - * Gets the query and queryParams for a specific page of entries. - */ - const queryBuilder = (count: number, cursor?: string) => { - return { - query, - params: { - after: cursor || null, - first: count, - owner, - name, - }, - }; - }; - - // The current cursor + /** The current cursor */ let cursor: string|undefined; - // If an additional page of members is expected + /** If an additional page of members is expected */ let hasNextPage = true; - // Array of pending PRs + /** Array of pending PRs */ const prs: Array = []; - // For each page of the response, get the page and add it to the - // list of PRs + // For each page of the response, get the page and add it to the list of PRs while (hasNextPage) { - const {query, params} = queryBuilder(100, cursor); - const results = await graphql(query, params) as typeof PRS_QUERY; - + const params = { + after: cursor || null, + first: 100, + owner, + name, + }; + const results = await git.github.graphql.query(PRS_QUERY, params) as typeof PRS_QUERY; prs.push(...results.repository.pullRequests.nodes); hasNextPage = results.repository.pullRequests.pageInfo.hasNextPage; cursor = results.repository.pullRequests.pageInfo.endCursor; From f77fd5e02a2d432821c0b15ce9777420add7e79a Mon Sep 17 00:00:00 2001 From: Joey Perrott Date: Wed, 29 Jul 2020 14:19:15 -0700 Subject: [PATCH 062/629] feat(dev-infra): create a wizard for building commit messages (#38457) Creates a wizard to walk through creating a commit message in the correct template for commit messages in Angular repositories. PR Close #38457 --- dev-infra/commit-message/BUILD.bazel | 5 ++ dev-infra/commit-message/builder.spec.ts | 46 ++++++++++ dev-infra/commit-message/builder.ts | 70 +++++++++++++++ dev-infra/commit-message/cli.ts | 18 ++++ dev-infra/commit-message/config.ts | 20 +++++ dev-infra/commit-message/wizard.ts | 43 ++++++++++ dev-infra/utils/BUILD.bazel | 1 + dev-infra/utils/console.ts | 49 ++++++++++- .../utils/inquirer-autocomplete-typings.d.ts | 17 ++++ package.json | 7 +- yarn.lock | 85 ++++++++++++++++--- 11 files changed, 344 insertions(+), 17 deletions(-) create mode 100644 dev-infra/commit-message/builder.spec.ts create mode 100644 dev-infra/commit-message/builder.ts create mode 100644 dev-infra/commit-message/wizard.ts create mode 100644 dev-infra/utils/inquirer-autocomplete-typings.d.ts diff --git a/dev-infra/commit-message/BUILD.bazel b/dev-infra/commit-message/BUILD.bazel index c8e7898a4b..18ad90463a 100644 --- a/dev-infra/commit-message/BUILD.bazel +++ b/dev-infra/commit-message/BUILD.bazel @@ -4,6 +4,7 @@ load("@npm_bazel_typescript//:index.bzl", "ts_library") ts_library( name = "commit-message", srcs = [ + "builder.ts", "cli.ts", "commit-message-draft.ts", "config.ts", @@ -12,14 +13,17 @@ ts_library( "validate.ts", "validate-file.ts", "validate-range.ts", + "wizard.ts", ], module_name = "@angular/dev-infra-private/commit-message", visibility = ["//dev-infra:__subpackages__"], deps = [ "//dev-infra/utils", + "@npm//@types/inquirer", "@npm//@types/node", "@npm//@types/shelljs", "@npm//@types/yargs", + "@npm//inquirer", "@npm//shelljs", "@npm//yargs", ], @@ -29,6 +33,7 @@ ts_library( name = "test_lib", testonly = True, srcs = [ + "builder.spec.ts", "parse.spec.ts", "validate.spec.ts", ], diff --git a/dev-infra/commit-message/builder.spec.ts b/dev-infra/commit-message/builder.spec.ts new file mode 100644 index 0000000000..5b45ac4140 --- /dev/null +++ b/dev-infra/commit-message/builder.spec.ts @@ -0,0 +1,46 @@ +/** + * @license + * Copyright Google LLC 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 * as config from '../utils/config'; +import * as console from '../utils/console'; + +import {buildCommitMessage} from './builder'; + + +describe('commit message building:', () => { + beforeEach(() => { + // stub logging calls to prevent noise in test log + spyOn(console, 'info').and.stub(); + // provide a configuration for DevInfra when loaded + spyOn(config, 'getConfig').and.returnValue({ + commitMessage: { + scopes: ['core'], + } + } as any); + }); + + it('creates a commit message with a scope', async () => { + buildPromptResponseSpies('fix', 'core', 'This is a summary'); + + expect(await buildCommitMessage()).toMatch(/^fix\(core\): This is a summary/); + }); + + it('creates a commit message without a scope', async () => { + buildPromptResponseSpies('build', false, 'This is a summary'); + + expect(await buildCommitMessage()).toMatch(/^build: This is a summary/); + }); +}); + + +/** Create spies to return the mocked selections from prompts. */ +function buildPromptResponseSpies(type: string, scope: string|false, summary: string) { + spyOn(console, 'promptAutocomplete') + .and.returnValues(Promise.resolve(type), Promise.resolve(scope)); + spyOn(console, 'promptInput').and.returnValue(Promise.resolve(summary)); +} diff --git a/dev-infra/commit-message/builder.ts b/dev-infra/commit-message/builder.ts new file mode 100644 index 0000000000..f663f3619d --- /dev/null +++ b/dev-infra/commit-message/builder.ts @@ -0,0 +1,70 @@ +/** + * @license + * Copyright Google LLC 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 {ListChoiceOptions} from 'inquirer'; + +import {info, promptAutocomplete, promptInput} from '../utils/console'; + +import {COMMIT_TYPES, CommitType, getCommitMessageConfig, ScopeRequirement} from './config'; + +/** Validate commit message at the provided file path. */ +export async function buildCommitMessage() { + // TODO(josephperrott): Add support for skipping wizard with local untracked config file + // TODO(josephperrott): Add default commit message information/commenting into generated messages + info('Just a few questions to start building the commit message!'); + + /** The commit message type. */ + const type = await promptForCommitMessageType(); + /** The commit message scope. */ + const scope = await promptForCommitMessageScopeForType(type); + /** The commit message summary. */ + const summary = await promptForCommitMessageSummary(); + + return `${type.name}${scope ? '(' + scope + ')' : ''}: ${summary}\n\n`; +} + +/** Prompts in the terminal for the commit message's type. */ +async function promptForCommitMessageType(): Promise { + info('The type of change in the commit. Allows a reader to know the effect of the change,'); + info('whether it brings a new feature, adds additional testing, documents the `project, etc.'); + + /** List of commit type options for the autocomplete prompt. */ + const typeOptions: ListChoiceOptions[] = + Object.values(COMMIT_TYPES).map(({description, name}) => { + return { + name: `${name} - ${description}`, + value: name, + short: name, + }; + }); + /** The key of a commit message type, selected by the user via prompt. */ + const typeName = await promptAutocomplete('Select a type for the commit:', typeOptions); + + return COMMIT_TYPES[typeName]; +} + +/** Prompts in the terminal for the commit message's scope. */ +async function promptForCommitMessageScopeForType(type: CommitType): Promise { + // If the commit type's scope requirement is forbidden, return early. + if (type.scope === ScopeRequirement.Forbidden) { + info(`Skipping scope selection as the '${type.name}' type does not allow scopes`); + return false; + } + /** Commit message configuration */ + const config = getCommitMessageConfig(); + + info('The area of the repository the changes in this commit most affects.'); + return await promptAutocomplete( + 'Select a scope for the commit:', config.commitMessage.scopes, + type.scope === ScopeRequirement.Optional ? '' : ''); +} + +/** Prompts in the terminal for the commit message's summary. */ +async function promptForCommitMessageSummary(): Promise { + info('Provide a short summary of what the changes in the commit do'); + return await promptInput('Provide a short summary of the commit'); +} diff --git a/dev-infra/commit-message/cli.ts b/dev-infra/commit-message/cli.ts index 90143ccee2..e6e97e3a52 100644 --- a/dev-infra/commit-message/cli.ts +++ b/dev-infra/commit-message/cli.ts @@ -12,6 +12,7 @@ import {info} from '../utils/console'; import {restoreCommitMessage} from './restore-commit-message'; import {validateFile} from './validate-file'; import {validateCommitRange} from './validate-range'; +import {runWizard} from './wizard'; /** Build the parser for the commit-message commands. */ export function buildCommitMessageParser(localYargs: yargs.Argv) { @@ -41,6 +42,23 @@ export function buildCommitMessageParser(localYargs: yargs.Argv) { args => { restoreCommitMessage(args['file-env-variable'][0], args['file-env-variable'][1] as any); }) + .command( + 'wizard [source] [commitSha]', '', ((args: any) => { + return args + .positional( + 'filePath', + {description: 'The file path to write the generated commit message into'}) + .positional('source', { + choices: ['message', 'template', 'merge', 'squash', 'commit'], + description: 'The source of the commit message as described here: ' + + 'https://git-scm.com/docs/githooks#_prepare_commit_msg' + }) + .positional( + 'commitSha', {description: 'The commit sha if source is set to `commit`'}); + }), + async (args: any) => { + await runWizard(args); + }) .command( 'pre-commit-validate', 'Validate the most recent commit message', { 'file': { diff --git a/dev-infra/commit-message/config.ts b/dev-infra/commit-message/config.ts index 9183e0c9ba..599f51aafb 100644 --- a/dev-infra/commit-message/config.ts +++ b/dev-infra/commit-message/config.ts @@ -39,36 +39,56 @@ export enum ScopeRequirement { /** A commit type */ export interface CommitType { + description: string; + name: string; scope: ScopeRequirement; } /** The valid commit types for Angular commit messages. */ export const COMMIT_TYPES: {[key: string]: CommitType} = { build: { + name: 'build', + description: 'Changes to local repository build system and tooling', scope: ScopeRequirement.Forbidden, }, ci: { + name: 'ci', + description: 'Changes to CI configuration and CI specific tooling', scope: ScopeRequirement.Forbidden, }, docs: { + name: 'docs', + description: 'Changes which exclusively affects documentation.', scope: ScopeRequirement.Optional, }, feat: { + name: 'feat', + description: 'Creates a new feature', scope: ScopeRequirement.Required, }, fix: { + name: 'fix', + description: 'Fixes a previously discovered failure/bug', scope: ScopeRequirement.Required, }, perf: { + name: 'perf', + description: 'Improves performance without any change in functionality or API', scope: ScopeRequirement.Required, }, refactor: { + name: 'refactor', + description: 'Refactor without any change in functionality or API (includes style changes)', scope: ScopeRequirement.Required, }, release: { + name: 'release', + description: 'A release point in the repository', scope: ScopeRequirement.Forbidden, }, test: { + name: 'test', + description: 'Improvements or corrections made to the project\'s test suite', scope: ScopeRequirement.Required, }, }; diff --git a/dev-infra/commit-message/wizard.ts b/dev-infra/commit-message/wizard.ts new file mode 100644 index 0000000000..fdf7d49496 --- /dev/null +++ b/dev-infra/commit-message/wizard.ts @@ -0,0 +1,43 @@ +/** + * @license + * Copyright Google LLC 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 {writeFileSync} from 'fs'; + +import {info} from '../utils/console'; + +import {buildCommitMessage} from './builder'; + +/** + * The source triggering the git commit message creation. + * As described in: https://git-scm.com/docs/githooks#_prepare_commit_msg + */ +export type PrepareCommitMsgHookSource = 'message'|'template'|'merge'|'squash'|'commit'; + +/** The default commit message used if the wizard does not procude a commit message. */ +const defaultCommitMessage = `(): + +# \n\n`; + +export async function runWizard( + args: {filePath: string, source?: PrepareCommitMsgHookSource, commitSha?: string}) { + // TODO(josephperrott): Add support for skipping wizard with local untracked config file + + if (args.source !== undefined) { + info(`Skipping commit message wizard due because the commit was created via '${ + args.source}' source`); + process.exitCode = 0; + return; + } + + // Set the default commit message to be updated if the user cancels out of the wizard in progress + writeFileSync(args.filePath, defaultCommitMessage); + + /** The generated commit message. */ + const commitMessage = await buildCommitMessage(); + writeFileSync(args.filePath, commitMessage); +} diff --git a/dev-infra/utils/BUILD.bazel b/dev-infra/utils/BUILD.bazel index 248aaddacf..3881ac19b0 100644 --- a/dev-infra/utils/BUILD.bazel +++ b/dev-infra/utils/BUILD.bazel @@ -17,6 +17,7 @@ ts_library( "@npm//@types/shelljs", "@npm//chalk", "@npm//inquirer", + "@npm//inquirer-autocomplete-prompt", "@npm//shelljs", "@npm//tslib", "@npm//typed-graphqlify", diff --git a/dev-infra/utils/console.ts b/dev-infra/utils/console.ts index 63830a5e76..625cf9189a 100644 --- a/dev-infra/utils/console.ts +++ b/dev-infra/utils/console.ts @@ -7,7 +7,8 @@ */ import chalk from 'chalk'; -import {prompt} from 'inquirer'; +import {createPromptModule, ListChoiceOptions, prompt} from 'inquirer'; +import * as inquirerAutocomplete from 'inquirer-autocomplete-prompt'; /** Reexport of chalk colors for convenient access. */ @@ -26,6 +27,52 @@ export async function promptConfirm(message: string, defaultValue = false): Prom .result; } +/** Prompts the user to select an option from a filterable autocomplete list. */ +export async function promptAutocomplete( + message: string, choices: (string|ListChoiceOptions)[]): Promise; +/** + * Prompts the user to select an option from a filterable autocomplete list, with an option to + * choose no value. + */ +export async function promptAutocomplete( + message: string, choices: (string|ListChoiceOptions)[], + noChoiceText?: string): Promise; +export async function promptAutocomplete( + message: string, choices: (string|ListChoiceOptions)[], + noChoiceText?: string): Promise { + // Creates a local prompt module with an autocomplete prompt type. + const prompt = createPromptModule({}).registerPrompt('autocomplete', inquirerAutocomplete); + if (noChoiceText) { + choices = [noChoiceText, ...choices]; + } + // `prompt` must be cast as `any` as the autocomplete typings are not available. + const result = (await (prompt as any)({ + type: 'autocomplete', + name: 'result', + message, + source: (_: any, input: string) => { + if (!input) { + return Promise.resolve(choices); + } + return Promise.resolve(choices.filter(choice => { + if (typeof choice === 'string') { + return choice.includes(input); + } + return choice.name!.includes(input); + })); + } + })).result; + if (result === noChoiceText) { + return false; + } + return result; +} + +/** Prompts the user for one line of input. */ +export async function promptInput(message: string): Promise { + return (await prompt<{result: string}>({type: 'input', name: 'result', message})).result; +} + /** * Supported levels for logging functions. * diff --git a/dev-infra/utils/inquirer-autocomplete-typings.d.ts b/dev-infra/utils/inquirer-autocomplete-typings.d.ts new file mode 100644 index 0000000000..bddf9e2e1a --- /dev/null +++ b/dev-infra/utils/inquirer-autocomplete-typings.d.ts @@ -0,0 +1,17 @@ +/** + * @license + * Copyright Google LLC 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 + */ + +// inquirer-autocomplete-prompt doesn't provide types and no types are made available via +// DefinitelyTyped. +declare module "inquirer-autocomplete-prompt" { + + import {registerPrompt} from 'inquirer'; + + let AutocompletePrompt: Parameters[1]; + export = AutocompletePrompt; +} diff --git a/package.json b/package.json index 2fcd6424bc..d702e27d4e 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,7 @@ "@types/diff": "^3.5.1", "@types/fs-extra": "4.0.2", "@types/hammerjs": "2.0.35", - "@types/inquirer": "^6.5.0", + "@types/inquirer": "^7.3.0", "@types/jasmine": "3.5.10", "@types/jasmine-ajax": "^3.3.1", "@types/jasminewd2": "^2.0.8", @@ -179,8 +179,9 @@ "glob": "7.1.2", "gulp": "3.9.1", "gulp-conventional-changelog": "^2.0.3", - "husky": "^4.2.3", - "inquirer": "^7.1.0", + "husky": "^4.2.5", + "inquirer": "^7.3.3", + "inquirer-autocomplete-prompt": "^1.0.2", "jpm": "1.3.1", "karma-browserstack-launcher": "^1.3.0", "karma-sauce-launcher": "^2.0.2", diff --git a/yarn.lock b/yarn.lock index 54131051c6..62934308c6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2198,10 +2198,10 @@ resolved "https://registry.yarnpkg.com/@types/hammerjs/-/hammerjs-2.0.35.tgz#7b7c950c7d54593e23bffc8d2b4feba9866a7277" integrity sha512-4mUIMSZ2U4UOWq1b+iV7XUTE4w+Kr3x+Zb/Qz5ROO6BTZLw2c8/ftjq0aRgluguLs4KRuBnrOy/s389HVn1/zA== -"@types/inquirer@^6.5.0": - version "6.5.0" - resolved "https://registry.yarnpkg.com/@types/inquirer/-/inquirer-6.5.0.tgz#b83b0bf30b88b8be7246d40e51d32fe9d10e09be" - integrity sha512-rjaYQ9b9y/VFGOpqBEXRavc3jh0a+e6evAbI31tMda8VlPaSy0AZJfXsvmIe3wklc7W6C3zCSfleuMXR7NOyXw== +"@types/inquirer@^7.3.0": + version "7.3.0" + resolved "https://registry.yarnpkg.com/@types/inquirer/-/inquirer-7.3.0.tgz#a1233632ea6249f14eb481dae91138e747b85664" + integrity sha512-wcPs5jTrZYQBzzPlvUEzBcptzO4We2sijSvkBq8oAKRMJoH8PvrmP6QQnxLB5RScNUmRfujxA+ngxD4gk4xe7Q== dependencies: "@types/through" "*" rxjs "^6.4.0" @@ -2761,7 +2761,7 @@ ansi-colors@^3.0.0: resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-3.2.4.tgz#e3a3da4bfbae6c86a9c285625de124a234026fbf" integrity sha512-hHUXGagefjN2iRrID63xckIvotOXOojhQKWIPUZ4mNUZ9nLZW+7FMNoE1lOkEhNWYsx/7ysGIuJYCiMAA9FnrA== -ansi-escapes@^3.1.0, ansi-escapes@^3.2.0: +ansi-escapes@^3.0.0, ansi-escapes@^3.1.0, ansi-escapes@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-3.2.0.tgz#8780b98ff9dbf5638152d1f1fe5c1d7b4442976b" integrity sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ== @@ -4027,6 +4027,14 @@ chalk@^3.0.0: ansi-styles "^4.1.0" supports-color "^7.1.0" +chalk@^4.0.0, chalk@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.0.tgz#4e14870a618d9e2edd97dd8345fd9d9dc315646a" + integrity sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + char-spinner@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/char-spinner/-/char-spinner-1.0.1.tgz#e6ea67bd247e107112983b7ab0479ed362800081" @@ -4271,6 +4279,11 @@ cli-width@^2.0.0: resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-2.2.0.tgz#ff19ede8a9a5e579324147b0c11f0fbcbabed639" integrity sha1-/xnt6Kml5XkyQUewwR8PvLq+1jk= +cli-width@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-3.0.0.tgz#a2f48437a2caa9a22436e794bf071ec9e61cedf6" + integrity sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw== + cliui@^4.0.0: version "4.1.0" resolved "https://registry.yarnpkg.com/cliui/-/cliui-4.1.0.tgz#348422dbe82d800b3022eef4f6ac10bf2e4d1b49" @@ -4477,7 +4490,7 @@ compare-semver@^1.0.0: dependencies: semver "^5.0.1" -compare-versions@^3.5.1: +compare-versions@^3.6.0: version "3.6.0" resolved "https://registry.yarnpkg.com/compare-versions/-/compare-versions-3.6.0.tgz#1a5689913685e5a87637b8d3ffca75514ec41d62" integrity sha512-W6Af2Iw1z4CB7q4uU4hv646dW9GQuBM+YpC0UvUCWSD8w90SJjp+ujJuXaEMtAXBtSqGfMPuFOVn4/+FlaqfBA== @@ -8061,14 +8074,14 @@ humanize-ms@^1.2.1: dependencies: ms "^2.0.0" -husky@^4.2.3: - version "4.2.3" - resolved "https://registry.yarnpkg.com/husky/-/husky-4.2.3.tgz#3b18d2ee5febe99e27f2983500202daffbc3151e" - integrity sha512-VxTsSTRwYveKXN4SaH1/FefRJYCtx+wx04sSVcOpD7N2zjoHxa+cEJ07Qg5NmV3HAK+IRKOyNVpi2YBIVccIfQ== +husky@^4.2.5: + version "4.2.5" + resolved "https://registry.yarnpkg.com/husky/-/husky-4.2.5.tgz#2b4f7622673a71579f901d9885ed448394b5fa36" + integrity sha512-SYZ95AjKcX7goYVZtVZF2i6XiZcHknw50iXvY7b0MiGoj5RwdgRQNEHdb+gPDPCXKlzwrybjFjkL6FOj8uRhZQ== dependencies: - chalk "^3.0.0" + chalk "^4.0.0" ci-info "^2.0.0" - compare-versions "^3.5.1" + compare-versions "^3.6.0" cosmiconfig "^6.0.0" find-versions "^3.2.0" opencollective-postinstall "^2.0.2" @@ -8248,7 +8261,17 @@ ini@1.3.5, ini@^1.3.2, ini@^1.3.4, ini@~1.3.0, ini@~1.3.3: resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927" integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw== -inquirer@7.1.0, inquirer@^7.1.0: +inquirer-autocomplete-prompt@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/inquirer-autocomplete-prompt/-/inquirer-autocomplete-prompt-1.0.2.tgz#3f2548f73dd12f0a541be055ea9c8c7aedeb42bf" + integrity sha512-vNmAhhrOQwPnUm4B9kz1UB7P98rVF1z8txnjp53r40N0PBCuqoRWqjg3Tl0yz0UkDg7rEUtZ2OZpNc7jnOU9Zw== + dependencies: + ansi-escapes "^3.0.0" + chalk "^2.0.0" + figures "^2.0.0" + run-async "^2.3.0" + +inquirer@7.1.0: version "7.1.0" resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-7.1.0.tgz#1298a01859883e17c7264b82870ae1034f92dd29" integrity sha512-5fJMWEmikSYu0nv/flMc475MhGbB7TSPd/2IpFV4I4rMklboCH2rQjYY5kKiYGHqUF9gvaambupcJFFG9dvReg== @@ -8267,6 +8290,25 @@ inquirer@7.1.0, inquirer@^7.1.0: strip-ansi "^6.0.0" through "^2.3.6" +inquirer@^7.3.3: + version "7.3.3" + resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-7.3.3.tgz#04d176b2af04afc157a83fd7c100e98ee0aad003" + integrity sha512-JG3eIAj5V9CwcGvuOmoo6LB9kbAYT8HXffUl6memuszlwDC/qvFAJw49XJ5NROSFNPxp3iQg1GqkFhaY/CR0IA== + dependencies: + ansi-escapes "^4.2.1" + chalk "^4.1.0" + cli-cursor "^3.1.0" + cli-width "^3.0.0" + external-editor "^3.0.3" + figures "^3.0.0" + lodash "^4.17.19" + mute-stream "0.0.8" + run-async "^2.4.0" + rxjs "^6.6.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + through "^2.3.6" + inquirer@~6.3.1: version "6.3.1" resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-6.3.1.tgz#7a413b5e7950811013a3db491c61d1f3b776e8e7" @@ -9889,6 +9931,11 @@ lodash@^4.0.0, lodash@^4.14.0, lodash@^4.16.6, lodash@^4.17.10, lodash@^4.17.11, resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548" integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A== +lodash@^4.17.19: + version "4.17.19" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.19.tgz#e48ddedbe30b3321783c5b4301fbd353bc1e4a4b" + integrity sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ== + lodash@~1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/lodash/-/lodash-1.0.2.tgz#8f57560c83b59fc270bd3d561b690043430e2551" @@ -13380,6 +13427,11 @@ run-async@^2.2.0, run-async@^2.4.0: dependencies: is-promise "^2.1.0" +run-async@^2.3.0: + version "2.4.1" + resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.4.1.tgz#8440eccf99ea3e70bd409d49aab88e10c189a455" + integrity sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ== + run-queue@^1.0.0, run-queue@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/run-queue/-/run-queue-1.0.3.tgz#e848396f057d223f24386924618e25694161ec47" @@ -13401,6 +13453,13 @@ rxjs@6.5.5: dependencies: tslib "^1.9.0" +rxjs@^6.6.0: + version "6.6.2" + resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.6.2.tgz#8096a7ac03f2cc4fe5860ef6e572810d9e01c0d2" + integrity sha512-BHdBMVoWC2sL26w//BCu3YzKT4s2jip/WhwsGEDmeKYBhKDZeYezVUnHatYB7L85v5xs0BAQmg6BEYJEKxBabg== + dependencies: + tslib "^1.9.0" + safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: version "5.1.2" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" From 55fd725e74cc5bbc678c9c0348693dccf48aca69 Mon Sep 17 00:00:00 2001 From: Aristeidis Bampakos Date: Wed, 19 Aug 2020 10:48:52 +0300 Subject: [PATCH 063/629] docs: Fix typo in the inputs and outputs guide (#38524) PR Close #38524 --- aio/content/guide/inputs-outputs.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/aio/content/guide/inputs-outputs.md b/aio/content/guide/inputs-outputs.md index 6841f7b714..c1442c5c57 100644 --- a/aio/content/guide/inputs-outputs.md +++ b/aio/content/guide/inputs-outputs.md @@ -208,7 +208,7 @@ about the event and gives that data to the parent. The child's template has two controls. The first is an HTML `` with a [template reference variable](guide/template-reference-variables) , `#newItem`, where the user types in an item name. Whatever the user types -into the `` gets stored in the `#newItem` variable. +into the `` gets stored in the `value` property of the `#newItem` variable. @@ -218,7 +218,7 @@ an event binding because the part to the left of the equal sign is in parentheses, `(click)`. The `(click)` event is bound to the `addNewItem()` method in the child component class which -takes as its argument whatever the value of `#newItem` is. +takes as its argument whatever the value of `#newItem.value` property is. Now the child component has an `@Output()` for sending data to the parent and a method for raising an event. From 6b662d10c131217047d3ffb097c98204e231a4bc Mon Sep 17 00:00:00 2001 From: JiaLiPassion Date: Wed, 19 Aug 2020 20:09:58 +0900 Subject: [PATCH 064/629] fix(zone.js): zone.js package.json should not include files/directories field (#38528) Close #38526, #38516, #38513 After update to `APF`, the `directories` and `files` options are not compatible, so we need to remove those fileds to make sure everything work as expected. PR Close #38528 --- packages/zone.js/package.json | 9 --------- 1 file changed, 9 deletions(-) diff --git a/packages/zone.js/package.json b/packages/zone.js/package.json index fdfad9a9c9..c93c336514 100644 --- a/packages/zone.js/package.json +++ b/packages/zone.js/package.json @@ -6,18 +6,9 @@ "module": "./fesm2015/zone.js", "es2015": "./fesm2015/zone.js", "fesm2015": "./fesm2015/zone.js", - "files": [ - "./zone.js.d.ts", - "./zone.api.extensions.ts", - "./zone.configurations.api.ts" - ], "dependencies": { "tslib": "^2.0.0" }, - "directories": { - "lib": "lib", - "test": "test" - }, "devDependencies": { "@types/node": "^10.9.4", "domino": "2.1.2", From 0270020ac249010a75ecb58353f98d38ff3ea423 Mon Sep 17 00:00:00 2001 From: atscott Date: Wed, 19 Aug 2020 09:16:16 -0700 Subject: [PATCH 065/629] docs: release notes for the v10.0.11 release --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0f2a64d5b..0d126f6adc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ + +## 10.0.11 (2020-08-19) + + +### Bug Fixes + +* **router:** ensure routerLinkActive updates when associated routerLinks change (resubmit of [#38349](https://github.com/angular/angular/issues/38349)) ([#38511](https://github.com/angular/angular/issues/38511)) ([0af9533](https://github.com/angular/angular/commit/0af9533)), closes [#18469](https://github.com/angular/angular/issues/18469) + + + # 10.1.0-next.6 (2020-08-17) From 9af2de821cf228bb7c98f6a7bdeeb5f9946a812e Mon Sep 17 00:00:00 2001 From: atscott Date: Wed, 19 Aug 2020 09:35:47 -0700 Subject: [PATCH 066/629] release: cut the v10.1.0-next.7 release --- CHANGELOG.md | 6 ++++++ package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d126f6adc..2fd1922511 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ + +# 10.1.0-next.7 (2020-08-19) + +This release contains various API docs improvements. + + ## 10.0.11 (2020-08-19) diff --git a/package.json b/package.json index d702e27d4e..5052f97979 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "angular-srcs", - "version": "10.1.0-next.6", + "version": "10.1.0-next.7", "private": true, "description": "Angular - a web framework for modern web apps", "homepage": "https://github.com/angular/angular", From 9ad69c1503256e7347d41a59fd696f097bd172c1 Mon Sep 17 00:00:00 2001 From: Misko Hevery Date: Wed, 19 Aug 2020 10:06:34 -0700 Subject: [PATCH 067/629] release: cut the zone.js-0.11.1 release (#38537) PR Close #38537 --- packages/zone.js/CHANGELOG.md | 10 ++++++++++ packages/zone.js/DEVELOPER.md | 4 ++++ packages/zone.js/package.json | 2 +- 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/zone.js/CHANGELOG.md b/packages/zone.js/CHANGELOG.md index ad1cc63e00..a8b2612d1e 100644 --- a/packages/zone.js/CHANGELOG.md +++ b/packages/zone.js/CHANGELOG.md @@ -1,3 +1,13 @@ + +## [0.11.1](https://github.com/angular/angular/compare/zone.js-0.11.0...zone.js-0.11.1) (2020-08-19) + + +### Bug Fixes + +* **zone.js:** zone.js package.json should not include files/directories field ([#38528](https://github.com/angular/angular/issues/38528)) ([6b662d1](https://github.com/angular/angular/commit/6b662d1)), closes [#38526](https://github.com/angular/angular/issues/38526) [#38516](https://github.com/angular/angular/issues/38516) [#38513](https://github.com/angular/angular/issues/38513) + + + # [0.11.0](https://github.com/angular/angular/compare/zone.js-0.10.3...zone.js-0.11.0) (2020-08-14) diff --git a/packages/zone.js/DEVELOPER.md b/packages/zone.js/DEVELOPER.md index c47f9108b3..0fa4298484 100644 --- a/packages/zone.js/DEVELOPER.md +++ b/packages/zone.js/DEVELOPER.md @@ -75,6 +75,7 @@ Releasing `zone.js` is a two step process. #### 1. Creating a PR for release ``` +rm -rf node_modules && yarn install export PREVIOUS_ZONE_TAG=`git tag -l 'zone.js-0.11.*' | tail -n1` export VERSION=`(cd packages/zone.js; npm version patch --no-git-tag-version)` export VERSION=${VERSION#v} @@ -107,9 +108,12 @@ Check out the SHA on master which has the changelog commit of the zone.js ``` git fetch upstream +git checkout upstream/master +rm -rf node_modules && yarn install export VERSION=`(node -e "console.log(require('./packages/zone.js/package.json').version)")` export TAG="zone.js-${VERSION}" export SHA=`git log upstream/master --oneline -n 1000 | grep "release: cut the ${TAG} release" | cut -f 1 -d " "` +echo "Releasing '$VERSION' which will be tagged as '$TAG' from SHA '$SHA'." git co ${SHA} npm login --registry https://wombat-dressing-room.appspot.com yarn bazel -- run --config=release -- //packages/zone.js:npm_package.publish --access public --tag latest diff --git a/packages/zone.js/package.json b/packages/zone.js/package.json index c93c336514..46ff790fef 100644 --- a/packages/zone.js/package.json +++ b/packages/zone.js/package.json @@ -1,6 +1,6 @@ { "name": "zone.js", - "version": "0.11.0", + "version": "0.11.1", "description": "Zones for JavaScript", "main": "./bundles/zone.umd.js", "module": "./fesm2015/zone.js", From 7ad32649c0d0004fcc3604c62cf0c1ae159a825b Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Thu, 6 Aug 2020 11:48:37 -0700 Subject: [PATCH 068/629] fix(router): support lazy loading for empty path named outlets (#38379) In general, the router only matches and loads a single Route config tree. However, named outlets with empty paths are a special case where the router can and should actually match two different `Route`s and ensure that the modules are loaded for each match. This change updates the "ApplyRedirects" stage to ensure that named outlets with empty paths finish loading their configs before proceeding to the next stage in the routing pipe. This is necessary because if the named outlet has `loadChildren` but the associated lazy config is not loaded before following stages attempt to match and activate relevant `Route`s, an error will occur. fixes #12842 PR Close #38379 --- .../size-tracking/integration-payloads.json | 2 +- packages/router/src/apply_redirects.ts | 69 ++++++---- packages/router/test/apply_redirects.spec.ts | 127 +++++++++++++++++- 3 files changed, 171 insertions(+), 27 deletions(-) diff --git a/goldens/size-tracking/integration-payloads.json b/goldens/size-tracking/integration-payloads.json index 2d5ce83c09..b6c8981eb9 100644 --- a/goldens/size-tracking/integration-payloads.json +++ b/goldens/size-tracking/integration-payloads.json @@ -39,7 +39,7 @@ "master": { "uncompressed": { "runtime-es2015": 2289, - "main-es2015": 245351, + "main-es2015": 245885, "polyfills-es2015": 36938, "5-es2015": 751 } diff --git a/packages/router/src/apply_redirects.ts b/packages/router/src/apply_redirects.ts index 5eb4050473..21b7b00234 100644 --- a/packages/router/src/apply_redirects.ts +++ b/packages/router/src/apply_redirects.ts @@ -8,7 +8,7 @@ import {Injector, NgModuleRef} from '@angular/core'; import {defer, EmptyError, Observable, Observer, of} from 'rxjs'; -import {catchError, concatAll, first, map, mergeMap, tap} from 'rxjs/operators'; +import {catchError, first, map, mergeMap, switchMap, tap} from 'rxjs/operators'; import {LoadedRouterConfig, Route, Routes} from './config'; import {CanLoadFn} from './interfaces'; @@ -148,28 +148,47 @@ class ApplyRedirects { ngModule: NgModuleRef, segmentGroup: UrlSegmentGroup, routes: Route[], segments: UrlSegment[], outlet: string, allowRedirects: boolean): Observable { - return of(...routes).pipe( - map((r: any) => { - const expanded$ = this.expandSegmentAgainstRoute( - ngModule, segmentGroup, routes, r, segments, outlet, allowRedirects); - return expanded$.pipe(catchError((e: any) => { - if (e instanceof NoMatch) { - // TODO(i): this return type doesn't match the declared Observable - - // talk to Jason - return of(null) as any; - } - throw e; - })); - }), - concatAll(), first((s: any) => !!s), catchError((e: any, _: any) => { - if (e instanceof EmptyError || e.name === 'EmptyError') { - if (this.noLeftoversInUrl(segmentGroup, segments, outlet)) { - return of(new UrlSegmentGroup([], {})); - } - throw new NoMatch(segmentGroup); - } - throw e; - })); + type MatchedSegment = {segment: UrlSegmentGroup, outlet: string}; + // This logic takes each route and switches to a new observable that depends on the result of + // the previous route expansion. In this way, we compose a list of results where each one can + // depend on and look at the previous to determine how to proceed with expansion of the + // current route. + return routes + .reduce( + (accumulatedResults: Observable>, r: Route) => { + return accumulatedResults.pipe(switchMap(resultsThusFar => { + // If we already matched a previous `Route` with the same outlet as the current, + // we should not process the current one. + if (resultsThusFar.some(result => result && result.outlet === getOutlet(r))) { + return of(resultsThusFar); + } + const expanded$ = this.expandSegmentAgainstRoute( + ngModule, segmentGroup, routes, r, segments, outlet, allowRedirects); + return expanded$.pipe( + map((segment) => resultsThusFar.concat({segment, outlet: getOutlet(r)})), + catchError((e: any) => { + if (e instanceof NoMatch) { + return of(resultsThusFar); + } + throw e; + })); + })); + }, + of([] as MatchedSegment[])) + .pipe( + // Find the matched segment whose outlet matches the one we're looking for. + map(results => results.find(s => s.outlet === outlet)?.segment), + first((s): s is UrlSegmentGroup => s !== undefined), + catchError((e: any, _: any) => { + if (e instanceof EmptyError || e.name === 'EmptyError') { + if (this.noLeftoversInUrl(segmentGroup, segments, outlet)) { + return of(new UrlSegmentGroup([], {})); + } + throw new NoMatch(segmentGroup); + } + throw e; + }), + ); } private noLeftoversInUrl(segmentGroup: UrlSegmentGroup, segments: UrlSegment[], outlet: string): @@ -180,7 +199,9 @@ class ApplyRedirects { private expandSegmentAgainstRoute( ngModule: NgModuleRef, segmentGroup: UrlSegmentGroup, routes: Route[], route: Route, paths: UrlSegment[], outlet: string, allowRedirects: boolean): Observable { - if (getOutlet(route) !== outlet) { + // Empty string segments are special because multiple outlets can match a single path, i.e. + // `[{path: '', component: B}, {path: '', loadChildren: () => {}, outlet: "about"}]` + if (getOutlet(route) !== outlet && route.path !== '') { return noMatch(segmentGroup); } diff --git a/packages/router/test/apply_redirects.spec.ts b/packages/router/test/apply_redirects.spec.ts index d564727a88..812b7b83bb 100644 --- a/packages/router/test/apply_redirects.spec.ts +++ b/packages/router/test/apply_redirects.spec.ts @@ -7,9 +7,9 @@ */ import {NgModuleRef} from '@angular/core'; -import {TestBed} from '@angular/core/testing'; +import {fakeAsync, TestBed, tick} from '@angular/core/testing'; import {Observable, of} from 'rxjs'; -import {delay} from 'rxjs/operators'; +import {delay, tap} from 'rxjs/operators'; import {applyRedirects} from '../src/apply_redirects'; import {LoadedRouterConfig, Route, Routes} from '../src/config'; @@ -482,6 +482,89 @@ describe('applyRedirects', () => { expect((config[0] as any)._loadedConfig).toBe(loadedConfig); }); }); + + it('should load all matching configurations of empty path, including an auxiliary outlets', + fakeAsync(() => { + const loadedConfig = + new LoadedRouterConfig([{path: '', component: ComponentA}], testModule); + let loadCalls = 0; + let loaded: string[] = []; + const loader = { + load: (injector: any, p: Route) => { + loadCalls++; + return of(loadedConfig) + .pipe( + delay(100 * loadCalls), + tap(() => loaded.push(p.loadChildren! as string)), + ); + } + }; + + const config: Routes = + [{path: '', loadChildren: 'root'}, {path: '', loadChildren: 'aux', outlet: 'popup'}]; + + applyRedirects(testModule.injector, loader, serializer, tree(''), config).subscribe(); + expect(loadCalls).toBe(1); + tick(100); + expect(loaded).toEqual(['root']); + tick(200); + expect(loadCalls).toBe(2); + expect(loaded).toEqual(['root', 'aux']); + })); + + it('loads only the first match when two Routes with the same outlet have the same path', () => { + const loadedConfig = new LoadedRouterConfig([{path: '', component: ComponentA}], testModule); + let loadCalls = 0; + let loaded: string[] = []; + const loader = { + load: (injector: any, p: Route) => { + loadCalls++; + return of(loadedConfig) + .pipe( + tap(() => loaded.push(p.loadChildren! as string)), + ); + } + }; + + const config: Routes = + [{path: 'a', loadChildren: 'first'}, {path: 'a', loadChildren: 'second'}]; + + applyRedirects(testModule.injector, loader, serializer, tree('a'), config).subscribe(); + expect(loadCalls).toBe(1); + expect(loaded).toEqual(['first']); + }); + + it('should load the configuration of empty root path if the entry is an aux outlet', + fakeAsync(() => { + const loadedConfig = + new LoadedRouterConfig([{path: '', component: ComponentA}], testModule); + let loaded: string[] = []; + const rootDelay = 100; + const auxDelay = 1; + const loader = { + load: (injector: any, p: Route) => { + const delayMs = p.loadChildren! as string === 'aux' ? auxDelay : rootDelay; + return of(loadedConfig) + .pipe( + delay(delayMs), + tap(() => loaded.push(p.loadChildren! as string)), + ); + } + }; + + const config: Routes = [ + // Define aux route first so it matches before the primary outlet + {path: 'modal', loadChildren: 'aux', outlet: 'popup'}, + {path: '', loadChildren: 'root'}, + ]; + + applyRedirects(testModule.injector, loader, serializer, tree('(popup:modal)'), config) + .subscribe(); + tick(auxDelay); + expect(loaded).toEqual(['aux']); + tick(rootDelay); + expect(loaded).toEqual(['aux', 'root']); + })); }); describe('empty paths', () => { @@ -754,6 +837,46 @@ describe('applyRedirects', () => { }); }); + describe('multiple matches with empty path named outlets', () => { + it('should work with redirects when other outlet comes before the one being activated', () => { + applyRedirects( + testModule.injector, null!, serializer, tree(''), + [ + { + path: '', + children: [ + {path: '', component: ComponentA, outlet: 'aux'}, + {path: '', redirectTo: 'b', pathMatch: 'full'}, + {path: 'b', component: ComponentB}, + ], + }, + ]) + .subscribe( + (tree: UrlTree) => { + expect(tree.toString()).toEqual('/b'); + }, + () => { + fail('should not be reached'); + }); + }); + + it('should work when entry point is named outlet', () => { + applyRedirects( + testModule.injector, null!, serializer, tree('(popup:modal)'), + [ + {path: '', component: ComponentA}, + {path: 'modal', component: ComponentB, outlet: 'popup'}, + ]) + .subscribe( + (tree: UrlTree) => { + expect(tree.toString()).toEqual('/(popup:modal)'); + }, + (e) => { + fail('should not be reached' + e.message); + }); + }); + }); + describe('redirecting to named outlets', () => { it('should work when using absolute redirects', () => { checkRedirect( From 1ec609946fa5d3ad444a90b47dd5531e21c8e652 Mon Sep 17 00:00:00 2001 From: Aristeidis Bampakos Date: Wed, 19 Aug 2020 09:30:07 +0300 Subject: [PATCH 069/629] docs: Typos fixes in the binding syntax guide (#38519) PR Close #38519 --- aio/content/guide/binding-syntax.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/aio/content/guide/binding-syntax.md b/aio/content/guide/binding-syntax.md index 0bd41cf77d..d5f4747cdf 100644 --- a/aio/content/guide/binding-syntax.md +++ b/aio/content/guide/binding-syntax.md @@ -154,7 +154,7 @@ Attributes can be changed by `setAttribute()`, which re-initializes correspondin For more information, see the [MDN Interfaces documentation](https://developer.mozilla.org/en-US/docs/Web/API#Interfaces) which has API docs for all the standard DOM elements and their properties. -Comparing the [`` attributes](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/td) attributes to the [`` properties](https://developer.mozilla.org/en-US/docs/Web/API/HTMLTableCellElement) provides a helpful example for differentiation. +Comparing the [`` attributes](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/td) to the [`` properties](https://developer.mozilla.org/en-US/docs/Web/API/HTMLTableCellElement) provides a helpful example for differentiation. In particular, you can navigate from the attributes page to the properties via "DOM interface" link, and navigate the inheritance hierarchy up to `HTMLTableCellElement`. @@ -195,7 +195,7 @@ To control the state of the button, set the `disabled` *property*,
-Though you could technically set the `[attr.disabled]` attribute binding, the values are different in that the property binding requires to a boolean value, while its corresponding attribute binding relies on whether the value is `null` or not. Consider the following: +Though you could technically set the `[attr.disabled]` attribute binding, the values are different in that the property binding requires to be a boolean value, while its corresponding attribute binding relies on whether the value is `null` or not. Consider the following: ```html From 0b54c0c6b42276369848bce323b03c9da1244f92 Mon Sep 17 00:00:00 2001 From: Alex Rickabaugh Date: Wed, 5 Aug 2020 16:13:12 -0700 Subject: [PATCH 070/629] refactor(compiler-cli): add getTemplateOfComponent to TemplateTypeChecker (#38355) This commit adds a `getTemplateOfComponent` method to the `TemplateTypeChecker` API, which retrieves the actual nodes parsed and used by the compiler for template type-checking. This is advantageous for the language service, which may need to query other APIs in `TemplateTypeChecker` that require the same nodes used to bind the template while generating the TCB. Fixes #38352 PR Close #38355 --- .../src/ngtsc/typecheck/api/checker.ts | 8 ++++ .../src/ngtsc/typecheck/src/checker.ts | 23 +++++++++++ .../src/ngtsc/typecheck/src/context.ts | 41 +++++++++++++++++-- .../ngtsc/typecheck/test/type_checker_spec.ts | 35 ++++++++++++++++ 4 files changed, 104 insertions(+), 3 deletions(-) diff --git a/packages/compiler-cli/src/ngtsc/typecheck/api/checker.ts b/packages/compiler-cli/src/ngtsc/typecheck/api/checker.ts index 32a804c4e7..3eac4dfebd 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/api/checker.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/api/checker.ts @@ -28,6 +28,14 @@ export interface TemplateTypeChecker { */ resetOverrides(): void; + /** + * Retrieve the template in use for the given component. + * + * If the template has been overridden via `overrideComponentTemplate`, this will retrieve the + * overridden template nodes. + */ + getTemplate(component: ts.ClassDeclaration): TmplAstNode[]|null; + /** * Provide a new template string that will be used in place of the user-defined template when * checking or operating on the given component. diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/checker.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/checker.ts index defdc7f705..b91825c55f 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/checker.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/checker.ts @@ -48,6 +48,29 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker { } } + getTemplate(component: ts.ClassDeclaration): TmplAstNode[]|null { + this.ensureShimForComponent(component); + + const sf = component.getSourceFile(); + const sfPath = absoluteFromSourceFile(sf); + const shimPath = this.typeCheckingStrategy.shimPathForComponent(component); + + const fileRecord = this.getFileData(sfPath); + + if (!fileRecord.shimData.has(shimPath)) { + return []; + } + + const templateId = fileRecord.sourceManager.getTemplateId(component); + const shimRecord = fileRecord.shimData.get(shimPath)!; + + if (!shimRecord.templates.has(templateId)) { + return null; + } + + return shimRecord.templates.get(templateId)!.template; + } + overrideComponentTemplate(component: ts.ClassDeclaration, template: string): {nodes: TmplAstNode[], errors?: ParseError[]} { const {nodes, errors} = parseTemplate(template, 'override.html', { diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/context.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/context.ts index 748ee71d74..fa80d5b0b3 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/context.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/context.ts @@ -6,14 +6,14 @@ * found in the LICENSE file at https://angular.io/license */ -import {ParseSourceFile, R3TargetBinder, SchemaMetadata, TmplAstNode} from '@angular/compiler'; +import {BoundTarget, ParseSourceFile, R3TargetBinder, SchemaMetadata, TmplAstNode} from '@angular/compiler'; import * as ts from 'typescript'; import {absoluteFromSourceFile, AbsoluteFsPath} from '../../file_system'; import {NoopImportRewriter, Reference, ReferenceEmitter} from '../../imports'; import {ClassDeclaration, ReflectionHost} from '../../reflection'; import {ImportManager} from '../../translator'; -import {ComponentToShimMappingStrategy, TemplateSourceMapping, TypeCheckableDirectiveMeta, TypeCheckBlockMetadata, TypeCheckContext, TypeCheckingConfig, TypeCtorMetadata} from '../api'; +import {ComponentToShimMappingStrategy, TemplateId, TemplateSourceMapping, TypeCheckableDirectiveMeta, TypeCheckBlockMetadata, TypeCheckContext, TypeCheckingConfig, TypeCtorMetadata} from '../api'; import {TemplateDiagnostic} from './diagnostics'; import {DomSchemaChecker, RegistryDomSchemaChecker} from './dom'; @@ -41,6 +41,28 @@ export interface ShimTypeCheckingData { * Whether any inline operations for the input file were required to generate this shim. */ hasInlines: boolean; + + /** + * Map of `TemplateId` to information collected about the template during the template + * type-checking process. + */ + templates: Map; +} + +/** + * Data tracked for each template processed by the template type-checking system. + */ +export interface TemplateData { + /** + * Template nodes for which the TCB was generated. + */ + template: TmplAstNode[]; + + /** + * `BoundTarget` which was used to generate the TCB, and contains bindings for the associated + * template nodes. + */ + boundTarget: BoundTarget; } /** @@ -79,6 +101,12 @@ export interface PendingShimData { * Shim file in the process of being generated. */ file: TypeCheckFile; + + + /** + * Map of `TemplateId` to information collected about the template as it's ingested. + */ + templates: Map; } /** @@ -195,6 +223,7 @@ export class TypeCheckContextImpl implements TypeCheckContext { const fileData = this.dataForFile(ref.node.getSourceFile()); const shimData = this.pendingShimForComponent(ref.node); const boundTarget = binder.bind({template}); + // Get all of the directives used in the template and record type constructors for all of them. for (const dir of boundTarget.getUsedDirectives()) { const dirRef = dir.ref as Reference>; @@ -221,6 +250,11 @@ export class TypeCheckContextImpl implements TypeCheckContext { }); } } + const templateId = fileData.sourceManager.getTemplateId(ref.node); + shimData.templates.set(templateId, { + template, + boundTarget, + }); const tcbRequiresInline = requiresInlineTypeCheckBlock(ref.node); @@ -231,7 +265,6 @@ export class TypeCheckContextImpl implements TypeCheckContext { // and inlining would be required. // Record diagnostics to indicate the issues with this template. - const templateId = fileData.sourceManager.getTemplateId(ref.node); if (tcbRequiresInline) { shimData.oobRecorder.requiresInlineTcb(templateId, ref.node); } @@ -348,6 +381,7 @@ export class TypeCheckContextImpl implements TypeCheckContext { ], hasInlines: pendingFileData.hasInlines, path: pendingShimData.file.fileName, + templates: pendingShimData.templates, }); updates.set(pendingShimData.file.fileName, pendingShimData.file.render()); } @@ -380,6 +414,7 @@ export class TypeCheckContextImpl implements TypeCheckContext { oobRecorder: new OutOfBandDiagnosticRecorderImpl(fileData.sourceManager), file: new TypeCheckFile( shimPath, this.config, this.refEmitter, this.reflector, this.compilerHost), + templates: new Map(), }); } return fileData.shimData.get(shimPath)!; diff --git a/packages/compiler-cli/src/ngtsc/typecheck/test/type_checker_spec.ts b/packages/compiler-cli/src/ngtsc/typecheck/test/type_checker_spec.ts index c6e148b961..b4d95d1b5a 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/test/type_checker_spec.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/test/type_checker_spec.ts @@ -353,5 +353,40 @@ runInEachFileSystem(os => { expect(diags2[0].messageText).toContain('invalid-element-b'); expect(diags2[0].messageText).not.toContain('invalid-element-a'); }); + + describe('getTemplateOfComponent()', () => { + it('should provide access to a component\'s real template', () => { + const fileName = absoluteFrom('/main.ts'); + const {program, templateTypeChecker} = setup([{ + fileName, + templates: { + 'Cmp': '
Template
', + }, + }]); + const cmp = getClass(getSourceFileOrError(program, fileName), 'Cmp'); + + const nodes = templateTypeChecker.getTemplate(cmp)!; + expect(nodes).not.toBeNull(); + expect(nodes[0].sourceSpan.start.file.content).toBe('
Template
'); + }); + + it('should provide access to an overridden template', () => { + const fileName = absoluteFrom('/main.ts'); + const {program, templateTypeChecker} = setup([{ + fileName, + templates: { + 'Cmp': '
Template
', + }, + }]); + const cmp = getClass(getSourceFileOrError(program, fileName), 'Cmp'); + + templateTypeChecker.overrideComponentTemplate(cmp, '
Overridden
'); + templateTypeChecker.getDiagnosticsForComponent(cmp); + + const nodes = templateTypeChecker.getTemplate(cmp)!; + expect(nodes).not.toBeNull(); + expect(nodes[0].sourceSpan.start.file.content).toBe('
Overridden
'); + }); + }); }); }); From 8cd4099db93742d17264eebcc3ba1999b9f3db7c Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Sun, 16 Aug 2020 14:17:56 +0100 Subject: [PATCH 071/629] fix(localize): include the last placeholder in parsed translation text (#38452) When creating a `ParsedTranslation` from a set of message parts and placeholder names a textual representation of the message is computed. Previously the last placeholder and text segment were missing from this computed message string. PR Close #38452 --- .../localize/src/utils/src/translations.ts | 2 +- .../src/utils/test/translations_spec.ts | 20 ++++++++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/localize/src/utils/src/translations.ts b/packages/localize/src/utils/src/translations.ts index 8e5094b71f..fb0e7aeaae 100644 --- a/packages/localize/src/utils/src/translations.ts +++ b/packages/localize/src/utils/src/translations.ts @@ -113,7 +113,7 @@ export function parseTranslation(messageString: TargetMessage): ParsedTranslatio export function makeParsedTranslation( messageParts: string[], placeholderNames: string[] = []): ParsedTranslation { let messageString = messageParts[0]; - for (let i = 0; i < placeholderNames.length - 1; i++) { + for (let i = 0; i < placeholderNames.length; i++) { messageString += `{$${placeholderNames[i]}}${messageParts[i + 1]}`; } return { diff --git a/packages/localize/src/utils/test/translations_spec.ts b/packages/localize/src/utils/test/translations_spec.ts index 8ccc10cb04..3ec4a5da01 100644 --- a/packages/localize/src/utils/test/translations_spec.ts +++ b/packages/localize/src/utils/test/translations_spec.ts @@ -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 {computeMsgId, makeTemplateObject, ParsedTranslation, parseTranslation, TargetMessage, translate} from '..'; +import {computeMsgId, makeParsedTranslation, makeTemplateObject, ParsedTranslation, parseTranslation, TargetMessage, translate} from '..'; describe('utils', () => { describe('makeTemplateObject', () => { @@ -22,6 +22,24 @@ describe('utils', () => { }); }); + describe('makeParsedTranslation()', () => { + it('should compute a template object from the parts', () => { + expect(makeParsedTranslation(['a', 'b', 'c'], ['ph1', 'ph2']).messageParts) + .toEqual(makeTemplateObject(['a', 'b', 'c'], ['a', 'b', 'c'])); + }); + + it('should include the placeholder names', () => { + expect(makeParsedTranslation(['a', 'b', 'c'], ['ph1', 'ph2']).placeholderNames).toEqual([ + 'ph1', 'ph2' + ]); + }); + + it('should compute the message string from the parts and placeholder names', () => { + expect(makeParsedTranslation(['a', 'b', 'c'], ['ph1', 'ph2']).text) + .toEqual('a{$ph1}b{$ph2}c'); + }); + }); + describe('parseTranslation', () => { it('should extract the messageParts as a TemplateStringsArray', () => { const translation = parseTranslation('a{$one}b{$two}c'); From 68a9a01a648f9332b34f272146546faaca934476 Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Sun, 16 Aug 2020 16:11:30 +0100 Subject: [PATCH 072/629] fix(localize): parse all parts of a translation with nested HTML (#38452) Previously nested container placeholders (i.e. HTML elements) were not being fully parsed from translation files. This resulted in bad translation of messages that contain these placeholders. Note that this causes the canonical message ID to change for such messages. Currently all messages generated from templates use "legacy" message ids that are not affected by this change, so this fix should not be seen as a breaking change. Fixes #38422 PR Close #38452 --- .../message_serializer.ts | 26 ++-------- .../xliff1_translation_parser_spec.ts | 40 ++++++++++++++ .../xliff2_translation_parser_spec.ts | 52 +++++++++++++++++-- .../xtb_translation_parser_spec.ts | 32 ++++++++++++ 4 files changed, 124 insertions(+), 26 deletions(-) diff --git a/packages/localize/src/tools/src/translate/translation_files/message_serialization/message_serializer.ts b/packages/localize/src/tools/src/translate/translation_files/message_serialization/message_serializer.ts index ea9cdefacc..995e6fbebd 100644 --- a/packages/localize/src/tools/src/translate/translation_files/message_serialization/message_serializer.ts +++ b/packages/localize/src/tools/src/translate/translation_files/message_serialization/message_serializer.ts @@ -75,29 +75,9 @@ export class MessageSerializer extends BaseVisitor { } visitContainedNodes(nodes: Node[]): void { - const length = nodes.length; - let index = 0; - while (index < length) { - if (!this.isPlaceholderContainer(nodes[index])) { - const startOfContainedNodes = index; - while (index < length - 1) { - index++; - if (this.isPlaceholderContainer(nodes[index])) { - break; - } - } - if (index - startOfContainedNodes > 1) { - // Only create a container if there are two or more contained Nodes in a row - this.renderer.startContainer(); - visitAll(this, nodes.slice(startOfContainedNodes, index - 1)); - this.renderer.closeContainer(); - } - } - if (index < length) { - nodes[index].visit(this, undefined); - } - index++; - } + this.renderer.startContainer(); + visitAll(this, nodes); + this.renderer.closeContainer(); } visitPlaceholder(name: string, body: string|undefined): void { diff --git a/packages/localize/src/tools/test/translate/translation_files/translation_parsers/xliff1_translation_parser_spec.ts b/packages/localize/src/tools/test/translate/translation_files/translation_parsers/xliff1_translation_parser_spec.ts index de347c5de8..c69b839433 100644 --- a/packages/localize/src/tools/test/translate/translation_files/translation_parsers/xliff1_translation_parser_spec.ts +++ b/packages/localize/src/tools/test/translate/translation_files/translation_parsers/xliff1_translation_parser_spec.ts @@ -212,6 +212,46 @@ describe('Xliff1TranslationParser', () => { ['INTERPOLATION', 'START_BOLD_TEXT', 'CLOSE_BOLD_TEXT'])); }); + it('should extract nested placeholder containers (i.e. nested HTML elements)', () => { + /** + * Source HTML: + * + * ``` + *
+ * translatable element with placeholders {{ interpolation}} + *
+ * ``` + */ + const XLIFF = [ + ``, + ` `, + ` `, + ` `, + ` translatable element with placeholders `, + ` tnemele elbatalsnart sredlohecalp htiw`, + ` `, + ` file.ts`, + ` 3`, + ` `, + ` `, + ` `, + ` `, + ``, + ].join('\n'); + const result = doParse('/some/file.xlf', XLIFF); + expect(result.translations[ɵcomputeMsgId( + 'translatable {$START_TAG_SPAN}element {$START_BOLD_TEXT}with placeholders' + + '{$CLOSE_BOLD_TEXT}{$CLOSE_TAG_SPAN} {$INTERPOLATION}')]) + .toEqual(ɵmakeParsedTranslation( + ['', '', ' tnemele', ' elbatalsnart ', 'sredlohecalp htiw', ''], [ + 'START_TAG_SPAN', + 'INTERPOLATION', + 'CLOSE_TAG_SPAN', + 'START_BOLD_TEXT', + 'CLOSE_BOLD_TEXT', + ])); + }); + it('should extract translations with placeholders containing hyphens', () => { /** * Source HTML: diff --git a/packages/localize/src/tools/test/translate/translation_files/translation_parsers/xliff2_translation_parser_spec.ts b/packages/localize/src/tools/test/translate/translation_files/translation_parsers/xliff2_translation_parser_spec.ts index 82ef7484ad..9bcae2900b 100644 --- a/packages/localize/src/tools/test/translate/translation_files/translation_parsers/xliff2_translation_parser_spec.ts +++ b/packages/localize/src/tools/test/translate/translation_files/translation_parsers/xliff2_translation_parser_spec.ts @@ -172,13 +172,13 @@ describe( * Source HTML: * * ``` - *
translatable element >with placeholders {{ interpolation}}
+ *
translatable element with placeholders {{ interpolation}}
* ``` */ const XLIFF = [ ``, ` `, - ` `, + ` `, ` `, ` file.ts:3`, ` `, @@ -193,12 +193,58 @@ describe( const result = doParse('/some/file.xlf', XLIFF); expect( result.translations[ɵcomputeMsgId( - 'translatable element {$START_BOLD_TEXT}with placeholders{$LOSE_BOLD_TEXT} {$INTERPOLATION}')]) + 'translatable element {$START_BOLD_TEXT}with placeholders{$CLOSE_BOLD_TEXT} {$INTERPOLATION}')]) .toEqual(ɵmakeParsedTranslation( ['', ' tnemele elbatalsnart ', 'sredlohecalp htiw', ''], ['INTERPOLATION', 'START_BOLD_TEXT', 'CLOSE_BOLD_TEXT'])); }); + it('should extract nested placeholder containers (i.e. nested HTML elements)', () => { + /** + * Source HTML: + * + * ``` + *
+ * translatable element with placeholders {{ interpolation}} + *
+ * ``` + */ + const XLIFF = [ + ``, + ` `, + ` `, + ` `, + ` file.ts:3`, + ` `, + ` `, + ` translatable element with placeholders` + + ` `, + ` tnemele` + + ` elbatalsnart sredlohecalp htiw`, + ` `, + ` `, + ` `, + ``, + ].join('\n'); + const result = doParse('/some/file.xlf', XLIFF); + expect( + result.translations[ɵcomputeMsgId( + 'translatable {$START_TAG_SPAN}element {$START_BOLD_TEXT}with placeholders' + + '{$CLOSE_BOLD_TEXT}{$CLOSE_TAG_SPAN} {$INTERPOLATION}')]) + .toEqual(ɵmakeParsedTranslation( + ['', '', ' tnemele', ' elbatalsnart ', 'sredlohecalp htiw', ''], [ + 'START_TAG_SPAN', + 'INTERPOLATION', + 'CLOSE_TAG_SPAN', + 'START_BOLD_TEXT', + 'CLOSE_BOLD_TEXT', + ])); + }); + it('should extract translations with simple ICU expressions', () => { /** * Source HTML: diff --git a/packages/localize/src/tools/test/translate/translation_files/translation_parsers/xtb_translation_parser_spec.ts b/packages/localize/src/tools/test/translate/translation_files/translation_parsers/xtb_translation_parser_spec.ts index 77a568044d..20ce004695 100644 --- a/packages/localize/src/tools/test/translate/translation_files/translation_parsers/xtb_translation_parser_spec.ts +++ b/packages/localize/src/tools/test/translate/translation_files/translation_parsers/xtb_translation_parser_spec.ts @@ -140,6 +140,38 @@ describe('XtbTranslationParser', () => { ɵmakeParsedTranslation(['', 'rab', ''], ['START_PARAGRAPH', 'CLOSE_PARAGRAPH'])); }); + it('should extract nested placeholder containers (i.e. nested HTML elements)', () => { + /** + * Source HTML: + * + * ``` + *
+ * translatable element with placeholders {{ interpolation}} + *
+ * ``` + */ + const XLIFF = [ + ``, + ``, + ` ` + + ` tnemele elbatalsnart sredlohecalp htiw` + + ``, + ``, + ].join('\n'); + const result = doParse('/some/file.xtb', XLIFF); + expect(result.translations[ɵcomputeMsgId( + 'translatable {$START_TAG_SPAN}element {$START_BOLD_TEXT}with placeholders' + + '{$CLOSE_BOLD_TEXT}{$CLOSE_TAG_SPAN} {$INTERPOLATION}')]) + .toEqual(ɵmakeParsedTranslation( + ['', '', ' tnemele', ' elbatalsnart ', 'sredlohecalp htiw', ''], [ + 'START_TAG_SPAN', + 'INTERPOLATION', + 'CLOSE_TAG_SPAN', + 'START_BOLD_TEXT', + 'CLOSE_BOLD_TEXT', + ])); + }); + it('should extract translations with simple ICU expressions', () => { const XTB = [ ``, From f245c6bb15aa79817a166d711e6ec63b2d522d47 Mon Sep 17 00:00:00 2001 From: Bjarki Date: Thu, 13 Aug 2020 21:33:40 +0000 Subject: [PATCH 073/629] fix(core): remove closing body tag from inert DOM builder (#38454) Fix a bug in the HTML sanitizer where an unclosed iframe tag would result in an escaped closing body tag as the output: _sanitizeHtml(document, '