diff --git a/goldens/circular-deps/packages.json b/goldens/circular-deps/packages.json index 8c26c0a489..efe573fadd 100644 --- a/goldens/circular-deps/packages.json +++ b/goldens/circular-deps/packages.json @@ -223,6 +223,7 @@ "packages/core/src/render3/assert.ts", "packages/core/src/render3/interfaces/container.ts", "packages/core/src/render3/interfaces/node.ts", + "packages/core/src/render3/interfaces/i18n.ts", "packages/core/src/render3/interfaces/view.ts", "packages/core/src/di/injector.ts", "packages/core/src/di/r3_injector.ts", @@ -239,6 +240,7 @@ "packages/core/src/render3/assert.ts", "packages/core/src/render3/interfaces/container.ts", "packages/core/src/render3/interfaces/node.ts", + "packages/core/src/render3/interfaces/i18n.ts", "packages/core/src/render3/interfaces/view.ts", "packages/core/src/metadata.ts", "packages/core/src/di.ts", @@ -262,6 +264,7 @@ "packages/core/src/render3/assert.ts", "packages/core/src/render3/interfaces/container.ts", "packages/core/src/render3/interfaces/node.ts", + "packages/core/src/render3/interfaces/i18n.ts", "packages/core/src/render3/interfaces/view.ts", "packages/core/src/render3/interfaces/definition.ts", "packages/core/src/core.ts", @@ -968,11 +971,13 @@ [ "packages/core/src/render3/interfaces/container.ts", "packages/core/src/render3/interfaces/node.ts", + "packages/core/src/render3/interfaces/i18n.ts", "packages/core/src/render3/interfaces/view.ts" ], [ "packages/core/src/render3/interfaces/definition.ts", "packages/core/src/render3/interfaces/node.ts", + "packages/core/src/render3/interfaces/i18n.ts", "packages/core/src/render3/interfaces/view.ts" ], [ @@ -980,13 +985,23 @@ "packages/core/src/render3/interfaces/view.ts" ], [ - "packages/core/src/render3/interfaces/node.ts", + "packages/core/src/render3/interfaces/i18n.ts", + "packages/core/src/render3/interfaces/node.ts" + ], + [ + "packages/core/src/render3/interfaces/i18n.ts", "packages/core/src/render3/interfaces/view.ts" ], [ - "packages/core/src/render3/interfaces/node.ts", + "packages/core/src/render3/interfaces/i18n.ts", "packages/core/src/render3/interfaces/view.ts", - "packages/core/src/render3/interfaces/query.ts" + "packages/core/src/render3/interfaces/node.ts" + ], + [ + "packages/core/src/render3/interfaces/i18n.ts", + "packages/core/src/render3/interfaces/view.ts", + "packages/core/src/render3/interfaces/query.ts", + "packages/core/src/render3/interfaces/node.ts" ], [ "packages/core/src/render3/interfaces/query.ts", diff --git a/goldens/public-api/core/core.d.ts b/goldens/public-api/core/core.d.ts index b128f9b3ae..0bbed1ad02 100644 --- a/goldens/public-api/core/core.d.ts +++ b/goldens/public-api/core/core.d.ts @@ -799,7 +799,7 @@ export declare abstract class Renderer2 { abstract createElement(name: string, namespace?: string | null): any; abstract createText(value: string): any; abstract destroy(): void; - abstract insertBefore(parent: any, newChild: any, refChild: any): void; + abstract insertBefore(parent: any, newChild: any, refChild: any, isMove?: boolean): void; abstract listen(target: 'window' | 'document' | 'body' | any, eventName: string, callback: (event: any) => boolean | void): () => void; abstract nextSibling(node: any): any; abstract parentNode(node: any): any; diff --git a/goldens/size-tracking/aio-payloads.json b/goldens/size-tracking/aio-payloads.json index 814224a7fb..48c8029749 100755 --- a/goldens/size-tracking/aio-payloads.json +++ b/goldens/size-tracking/aio-payloads.json @@ -12,7 +12,7 @@ "master": { "uncompressed": { "runtime-es2015": 3037, - "main-es2015": 447742, + "main-es2015": 448676, "polyfills-es2015": 52415 } } diff --git a/goldens/size-tracking/integration-payloads.json b/goldens/size-tracking/integration-payloads.json index b632516078..75e91afd4d 100644 --- a/goldens/size-tracking/integration-payloads.json +++ b/goldens/size-tracking/integration-payloads.json @@ -3,7 +3,7 @@ "master": { "uncompressed": { "runtime-es2015": 1485, - "main-es2015": 140199, + "main-es2015": 140899, "polyfills-es2015": 36571 } } @@ -12,7 +12,7 @@ "master": { "uncompressed": { "runtime-es2015": 1485, - "main-es2015": 16650, + "main-es2015": 17092, "polyfills-es2015": 36657 } } @@ -21,7 +21,7 @@ "master": { "uncompressed": { "runtime-es2015": 1485, - "main-es2015": 146417, + "main-es2015": 147242, "polyfills-es2015": 36571 } } @@ -30,7 +30,7 @@ "master": { "uncompressed": { "runtime-es2015": 1485, - "main-es2015": 135003, + "main-es2015": 136096, "polyfills-es2015": 37248 } } @@ -39,7 +39,7 @@ "master": { "uncompressed": { "runtime-es2015": 2289, - "main-es2015": 241850, + "main-es2015": 242460, "polyfills-es2015": 36938, "5-es2015": 751 } @@ -49,7 +49,7 @@ "master": { "uncompressed": { "runtime-es2015": 2289, - "main-es2015": 217827, + "main-es2015": 218527, "polyfills-es2015": 36723, "5-es2015": 781 } diff --git a/packages/core/src/render/api.ts b/packages/core/src/render/api.ts index 4a745a05c9..1aeb38b2fc 100644 --- a/packages/core/src/render/api.ts +++ b/packages/core/src/render/api.ts @@ -158,8 +158,13 @@ export abstract class Renderer2 { * @param parent The parent node. * @param newChild The new child nodes. * @param refChild The existing child node before which `newChild` is inserted. + * @param isMove Optional argument which signifies if the current `insertBefore` is a result of a + * move. Animation uses this information to trigger move animations. In the past the Animation + * would always assume that any `insertBefore` is a move. This is not strictly true because + * with runtime i18n it is possible to invoke `insertBefore` as a result of i18n and it should + * not trigger an animation move. */ - abstract insertBefore(parent: any, newChild: any, refChild: any): void; + abstract insertBefore(parent: any, newChild: any, refChild: any, isMove?: boolean): void; /** * Implement this callback to remove a child node from the host element's DOM. * @param parent The parent node. diff --git a/packages/core/src/render3/assert.ts b/packages/core/src/render3/assert.ts index 32c0c57c2f..3e14a28016 100644 --- a/packages/core/src/render3/assert.ts +++ b/packages/core/src/render3/assert.ts @@ -10,6 +10,7 @@ import {assertDefined, assertEqual, assertNumber, throwError} from '../util/asse import {getComponentDef, getNgModuleDef} from './definition'; import {LContainer} from './interfaces/container'; import {DirectiveDef} from './interfaces/definition'; +import {TIcu} from './interfaces/i18n'; import {NodeInjectorOffset} from './interfaces/injector'; import {TNode} from './interfaces/node'; import {isLContainer, isLView} from './interfaces/type_checks'; @@ -25,13 +26,28 @@ export function assertTNodeForLView(tNode: TNode, lView: LView) { } export function assertTNodeForTView(tNode: TNode, tView: TView) { - assertDefined(tNode, 'TNode must be defined'); + assertTNode(tNode); tNode.hasOwnProperty('tView_') && assertEqual( (tNode as any as {tView_: TView}).tView_, tView, 'This TNode does not belong to this TView.'); } +export function assertTNode(tNode: TNode) { + assertDefined(tNode, 'TNode must be defined'); + if (!(tNode && typeof tNode === 'object' && tNode.hasOwnProperty('directiveStylingLast'))) { + throwError('Not of type TNode, got: ' + tNode); + } +} + + +export function assertTIcu(tIcu: TIcu) { + assertDefined(tIcu, 'Expected TIcu to be defined'); + if (!(typeof tIcu.currentCaseLViewIndex === 'number')) { + throwError('Object is not of TIcu type.'); + } +} + export function assertComponentType( actual: any, msg: string = 'Type passed in is not ComponentType, it does not have \'ɵcmp\' property.') { @@ -106,18 +122,15 @@ export function assertIndexInDeclRange(lView: LView, index: number) { export function assertIndexInVarsRange(lView: LView, index: number) { const tView = lView[1]; assertBetween( - tView.bindingStartIndex, (tView as any as {i18nStartIndex: number}).i18nStartIndex, index); -} - -export function assertIndexInI18nRange(lView: LView, index: number) { - const tView = lView[1]; - assertBetween( - (tView as any as {i18nStartIndex: number}).i18nStartIndex, tView.expandoStartIndex, index); + tView.bindingStartIndex, + (tView as any as {originalExpandoStartIndex: number}).originalExpandoStartIndex, index); } export function assertIndexInExpandoRange(lView: LView, index: number) { const tView = lView[1]; - assertBetween(tView.expandoStartIndex, lView.length, index); + assertBetween( + (tView as any as {originalExpandoStartIndex: number}).originalExpandoStartIndex, lView.length, + index); } export function assertBetween(lower: number, upper: number, index: number) { diff --git a/packages/core/src/render3/component.ts b/packages/core/src/render3/component.ts index f532f3f086..9027bb9fef 100644 --- a/packages/core/src/render3/component.ts +++ b/packages/core/src/render3/component.ts @@ -163,6 +163,7 @@ export function renderComponent( * @param rNode Render host element. * @param def ComponentDef * @param rootView The parent view where the host node is stored + * @param rendererFactory Factory to be used for creating child renderers. * @param hostRenderer The current renderer * @param sanitizer The sanitizer, if provided * @@ -174,7 +175,10 @@ export function createRootComponentView( const tView = rootView[TVIEW]; ngDevMode && assertIndexInRange(rootView, 0 + HEADER_OFFSET); rootView[0 + HEADER_OFFSET] = rNode; - const tNode: TElementNode = getOrCreateTNode(tView, 0, TNodeType.Element, null, null); + // '#host' is added here as we don't know the real host DOM name (we don't want to read it) and at + // the same time we want to communicate the the debug `TNode` that this is a special `TNode` + // representing a host element. + const tNode = getOrCreateTNode(tView, 0, TNodeType.Element, '#host', null); const mergedAttrs = tNode.mergedAttrs = def.hostAttrs; if (mergedAttrs !== null) { computeStaticStyling(tNode, mergedAttrs, true); diff --git a/packages/core/src/render3/component_ref.ts b/packages/core/src/render3/component_ref.ts index a12fe44d46..9aac1aacd0 100644 --- a/packages/core/src/render3/component_ref.ts +++ b/packages/core/src/render3/component_ref.ts @@ -23,13 +23,13 @@ import {assertComponentType} from './assert'; import {createRootComponent, createRootComponentView, createRootContext, LifecycleHooksFeature} from './component'; import {getComponentDef} from './definition'; import {NodeInjector} from './di'; -import {createLView, createTView, elementCreate, locateHostElement, renderView} from './instructions/shared'; +import {createLView, createTView, locateHostElement, renderView} from './instructions/shared'; import {ComponentDef} from './interfaces/definition'; import {TContainerNode, TElementContainerNode, TElementNode, TNode} from './interfaces/node'; import {domRendererFactory3, RendererFactory3, RNode} from './interfaces/renderer'; import {LView, LViewFlags, TViewType} from './interfaces/view'; import {MATH_ML_NAMESPACE, SVG_NAMESPACE} from './namespaces'; -import {writeDirectClass} from './node_manipulation'; +import {createElementNode, writeDirectClass} from './node_manipulation'; import {extractAttrsAndClassesFromSelector, stringifyCSSSelectorList} from './node_selector_matcher'; import {enterView, leaveView} from './state'; import {setUpAttributes} from './util/attrs_utils'; @@ -147,8 +147,8 @@ export class ComponentFactory extends viewEngine_ComponentFactory { const elementName = this.componentDef.selectors[0][0] as string || 'div'; const hostRNode = rootSelectorOrNode ? locateHostElement(hostRenderer, rootSelectorOrNode, this.componentDef.encapsulation) : - elementCreate( - elementName, rendererFactory.createRenderer(null, this.componentDef), + createElementNode( + rendererFactory.createRenderer(null, this.componentDef), elementName, getNamespace(elementName)); const rootFlags = this.componentDef.onPush ? LViewFlags.Dirty | LViewFlags.IsRoot : diff --git a/packages/core/src/render3/context_discovery.ts b/packages/core/src/render3/context_discovery.ts index cf924e82b8..fe28f11f06 100644 --- a/packages/core/src/render3/context_discovery.ts +++ b/packages/core/src/render3/context_discovery.ts @@ -204,11 +204,7 @@ function findViaNativeElement(lView: LView, target: RElement): number { * Locates the next tNode (child, sibling or parent). */ function traverseNextElement(tNode: TNode): TNode|null { - if (tNode.child && tNode.child.parent === tNode) { - // FIXME(misko): checking if `tNode.child.parent === tNode` should not be necessary - // We have added it here because i18n creates TNode's which are not valid, so this is a work - // around. The i18n code will be refactored in #39003 and once it lands this extra check can be - // deleted. + if (tNode.child) { return tNode.child; } else if (tNode.next) { return tNode.next; diff --git a/packages/core/src/render3/i18n/i18n.md b/packages/core/src/render3/i18n/i18n.md index f58e4587d6..05669f0dd5 100644 --- a/packages/core/src/render3/i18n/i18n.md +++ b/packages/core/src/render3/i18n/i18n.md @@ -115,10 +115,6 @@ The i18n markers are: - `index`: the index of the `template` instruction, as defined in the template instructions (e.g. `template(index, ...)`). - `block`: the index of the parent sub-template block, in which this child sub-template block was declared. -- `�!{index}:{block}�/�/!{index}:{block}�`: *Projection block*: Marks the beginning and end of that was embedded in the original translation block. - - `index`: the index of the projection, as defined in the template instructions (e.g. `projection(index, ...)`). - - `block` (*optional*): the index of the parent sub-template block, in which this child sub-template block was declared. - No other i18n marker format is supported. The i18n markers in the example above can be interpreted as follows: diff --git a/packages/core/src/render3/i18n/i18n_apply.ts b/packages/core/src/render3/i18n/i18n_apply.ts index fa4a44f100..48c38fb4c9 100644 --- a/packages/core/src/render3/i18n/i18n_apply.ts +++ b/packages/core/src/render3/i18n/i18n_apply.ts @@ -7,63 +7,108 @@ */ import {getPluralCase} from '../../i18n/localization'; -import {assertDefined, assertEqual, assertIndexInRange} from '../../util/assert'; +import {assertDefined, assertDomNode, assertEqual, assertGreaterThan, assertIndexInRange, throwError} from '../../util/assert'; +import {assertIndexInExpandoRange, assertTIcu} from '../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 {elementPropertyInternal, setElementAttribute, textBindingInternal} from '../instructions/shared'; +import {COMMENT_MARKER, ELEMENT_MARKER, getCurrentICUCaseIndex, getParentFromI18nMutateOpCode, getRefFromI18nMutateOpCode, I18nCreateOpCode, I18nCreateOpCodes, I18nMutateOpCode, I18nMutateOpCodes, I18nUpdateOpCode, I18nUpdateOpCodes, IcuType, TI18n, TIcu} from '../interfaces/i18n'; +import {TNode} from '../interfaces/node'; +import {RElement, RNode, 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, getCurrentTNode, getLView, getTView, setCurrentTNode, setCurrentTNodeAsNotParent} from '../state'; +import {HEADER_OFFSET, LView, RENDERER, TView} from '../interfaces/view'; +import {createCommentNode, createElementNode, createTextNode, nativeInsertBefore, nativeParentNode, nativeRemoveNode, updateTextNode} from '../node_manipulation'; +import {getBindingIndex} from '../state'; import {renderStringify} from '../util/misc_utils'; -import {getNativeByIndex, getNativeByTNode, getTNode, load} from '../util/view_utils'; - +import {getNativeByIndex, unwrapRNode} from '../util/view_utils'; import {getLocaleId} from './i18n_locale_id'; +import {getTIcu} from './i18n_util'; -const i18nIndexStack: number[] = []; -let i18nIndexStackPointer = -1; - -function popI18nIndex() { - return i18nIndexStack[i18nIndexStackPointer--]; -} - -export function pushI18nIndex(index: number) { - i18nIndexStack[++i18nIndexStackPointer] = index; -} +/** + * Keep track of which input bindings in `ɵɵi18nExp` have changed. + * + * This is used to efficiently update expressions in i18n only when the corresponding input has + * changed. + * + * 1) Each bit represents which of the `ɵɵi18nExp` has changed. + * 2) There are 32 bits allowed in JS. + * 3) Bit 32 is special as it is shared for all changes past 32. (In other words if you have more + * than 32 `ɵɵi18nExp` then all changes past 32nd `ɵɵi18nExp` will be mapped to same bit. This means + * that we may end up changing more than we need to. But i18n expressions with 32 bindings is rare + * so in practice it should not be an issue.) + */ let changeMask = 0b0; -let shiftsCounter = 0; -export function setMaskBit(bit: boolean) { - if (bit) { - changeMask = changeMask | (1 << shiftsCounter); +/** + * Keeps track of which bit needs to be updated in `changeMask` + * + * This value gets incremented on every call to `ɵɵi18nExp` + */ +let changeMaskCounter = 0; + +/** + * Keep track of which input bindings in `ɵɵi18nExp` have changed. + * + * `setMaskBit` gets invoked by each call to `ɵɵi18nExp`. + * + * @param hasChange did `ɵɵi18nExp` detect a change. + */ +export function setMaskBit(hasChange: boolean) { + if (hasChange) { + changeMask = changeMask | (1 << Math.min(changeMaskCounter, 31)); } - shiftsCounter++; + changeMaskCounter++; } export function applyI18n(tView: TView, lView: LView, index: number) { - if (shiftsCounter > 0) { + if (changeMaskCounter > 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); + // When `index` points to an `ɵɵi18nAttributes` then we have an array otherwise `TI18n` + const updateOpCodes: I18nUpdateOpCodes = + Array.isArray(tI18n) ? tI18n as I18nUpdateOpCodes : (tI18n as TI18n).update; + const bindingsStartIndex = getBindingIndex() - changeMaskCounter - 1; + applyUpdateOpCodes(tView, lView, updateOpCodes, bindingsStartIndex, changeMask); + } + // Reset changeMask & maskBit to default for the next update cycle + changeMask = 0b0; + changeMaskCounter = 0; +} - // Reset changeMask & maskBit to default for the next update cycle - changeMask = 0b0; - shiftsCounter = 0; + +/** + * Apply `I18nCreateOpCodes` op-codes as stored in `TI18n.create`. + * + * Creates text (and comment) nodes which are internationalized. + * + * @param lView Current lView + * @param createOpCodes Set of op-codes to apply + * @param parentRNode Parent node (so that direct children can be added eagerly) or `null` if it is + * a root node. + * @param insertInFrontOf DOM node that should be used as an anchor. + */ +export function applyCreateOpCodes( + lView: LView, createOpCodes: I18nCreateOpCodes, parentRNode: RElement|null, + insertInFrontOf: RElement|null): void { + const renderer = lView[RENDERER]; + for (let i = 0; i < createOpCodes.length; i++) { + const opCode = createOpCodes[i++] as any; + const text = createOpCodes[i] as string; + const isComment = (opCode & I18nCreateOpCode.COMMENT) === I18nCreateOpCode.COMMENT; + const appendNow = + (opCode & I18nCreateOpCode.APPEND_EAGERLY) === I18nCreateOpCode.APPEND_EAGERLY; + const index = opCode >>> I18nCreateOpCode.SHIFT; + let rNode = lView[index]; + if (rNode === null) { + // We only create new DOM nodes if they don't already exist: If ICU switches case back to a + // case which was already instantiated, no need to create new DOM nodes. + rNode = lView[index] = + isComment ? renderer.createComment(text) : createTextNode(renderer, text); + } + if (appendNow && parentRNode !== null) { + nativeInsertBefore(renderer, parentRNode, rNode, insertInFrontOf, false); + } } } @@ -71,72 +116,86 @@ export function applyI18n(tView: TView, lView: LView, index: 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 mutableOpCodes Mutable OpCodes to process * @param lView Current `LView` + * @param anchorRNode place where the i18n node should be inserted. */ -export function applyCreateOpCodes( - tView: TView, rootindex: number, createOpCodes: I18nMutateOpCodes, lView: LView): number[] { +export function applyMutableOpCodes( + tView: TView, mutableOpCodes: I18nMutateOpCodes, lView: LView, anchorRNode: RNode): void { + ngDevMode && assertDomNode(anchorRNode); 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]; + // `rootIdx` represents the node into which all inserts happen. + let rootIdx: number|null = null; + // `rootRNode` represents the real node into which we insert. This can be different from + // `lView[rootIdx]` if we have projection. + // - null we don't have a parent (as can be the case in when we are inserting into a root of + // LView which has no parent.) + // - `RElement` The element representing the root after taking projection into account. + let rootRNode!: RElement|null; + for (let i = 0; i < mutableOpCodes.length; i++) { + const opCode = mutableOpCodes[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); - setCurrentTNodeAsNotParent(); + const textNodeIndex = mutableOpCodes[++i] as number; + if (lView[textNodeIndex] === null) { + ngDevMode && ngDevMode.rendererCreateTextNode++; + ngDevMode && assertIndexInRange(lView, textNodeIndex); + lView[textNodeIndex] = createTextNode(renderer, opCode); + } } 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]!; + const parentIdx = getParentFromI18nMutateOpCode(opCode); + if (rootIdx === null) { + // The first operation should save the `rootIdx` because the first operation + // must insert into the root. (Only subsequent operations can insert into a dynamic + // parent) + rootIdx = parentIdx; + rootRNode = nativeParentNode(renderer, anchorRNode); + } + let insertInFrontOf: RNode|null; + let parentRNode: RElement|null; + if (parentIdx === rootIdx) { + insertInFrontOf = anchorRNode; + parentRNode = rootRNode; } else { - destinationTNode = getTNode(tView, destinationNodeIndex); + insertInFrontOf = null; + parentRNode = unwrapRNode(lView[parentIdx]) as RElement; } - 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) { - setCurrentTNode(currentTNode, isParent); + // FIXME(misko): Refactor with `processI18nText` + if (parentRNode !== null) { + // This can happen if the `LView` we are adding to is not attached to a parent `LView`. + // In such a case there is no "root" we can attach to. This is fine, as we still need to + // create the elements. When the `LView` gets later added to a parent these "root" nodes + // get picked up and added. + ngDevMode && assertDomNode(parentRNode); + const refIdx = getRefFromI18nMutateOpCode(opCode); + ngDevMode && assertGreaterThan(refIdx, HEADER_OFFSET, 'Missing ref'); + // `unwrapRNode` is not needed here as all of these point to RNodes as part of the i18n + // which can't have components. + const child = lView[refIdx] as RElement; + ngDevMode && assertDomNode(child); + nativeInsertBefore(renderer, parentRNode, child, insertInFrontOf, false); + const tIcu = getTIcu(tView, refIdx); + if (tIcu !== null && typeof tIcu === 'object') { + // If we just added a comment node which has ICU then that ICU may have already been + // rendered and therefore we need to re-add it here. + ngDevMode && assertTIcu(tIcu); + const caseIndex = getCurrentICUCaseIndex(tIcu, lView); + if (caseIndex !== null) { + applyMutableOpCodes(tView, tIcu.create[caseIndex], lView, lView[tIcu.anchorIdx]); + } + } } break; - case I18nMutateOpCode.ElementEnd: - const elementIndex = opCode >>> I18nMutateOpCode.SHIFT_REF; - previousTNode = currentTNode = getTNode(tView, elementIndex); - setCurrentTNode(currentTNode, false); - break; case I18nMutateOpCode.Attr: const elementNodeIndex = opCode >>> I18nMutateOpCode.SHIFT_REF; - const attrName = createOpCodes[++i] as string; - const attrValue = createOpCodes[++i] as string; + const attrName = mutableOpCodes[++i] as string; + const attrValue = mutableOpCodes[++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); + setElementAttribute( + renderer, getNativeByIndex(elementNodeIndex - HEADER_OFFSET, lView) as RElement, null, + null, attrName, attrValue, null); break; default: throw new Error(`Unable to determine the type of mutate operation for "${opCode}"`); @@ -144,45 +203,44 @@ export function applyCreateOpCodes( } 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 - setCurrentTNodeAsNotParent(); + const commentValue = mutableOpCodes[++i] as string; + const commentNodeIndex = mutableOpCodes[++i] as number; + if (lView[commentNodeIndex] === null) { + ngDevMode && + assertEqual( + typeof commentValue, 'string', + `Expected "${commentValue}" to be a comment node value`); + ngDevMode && ngDevMode.rendererCreateComment++; + ngDevMode && assertIndexInExpandoRange(lView, commentNodeIndex); + const commentRNode = lView[commentNodeIndex] = + createCommentNode(renderer, commentValue); + // FIXME(misko): Attaching patch data is only needed for the root (Also add tests) + attachPatchData(commentRNode, lView); + } 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); + const tagName = mutableOpCodes[++i] as string; + const elementNodeIndex = mutableOpCodes[++i] as number; + if (lView[elementNodeIndex] === null) { + ngDevMode && + assertEqual( + typeof tagName, 'string', + `Expected "${tagName}" to be an element node tag name`); + + ngDevMode && ngDevMode.rendererCreateElement++; + ngDevMode && assertIndexInExpandoRange(lView, elementNodeIndex); + const elementRNode = lView[elementNodeIndex] = + createElementNode(renderer, tagName, null); + // FIXME(misko): Attaching patch data is only needed for the root (Also add tests) + attachPatchData(elementRNode, lView); + } break; default: - throw new Error(`Unable to determine the type of mutate operation for "${opCode}"`); + ngDevMode && + throwError(`Unable to determine the type of mutate operation for "${opCode}"`); } } } - - setCurrentTNodeAsNotParent(); - - return visitedNodes; } @@ -190,7 +248,6 @@ export function applyCreateOpCodes( * 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` @@ -198,9 +255,8 @@ export function applyCreateOpCodes( * `bindingsStartIndex`) */ export function applyUpdateOpCodes( - tView: TView, tIcus: TIcu[]|null, lView: LView, updateOpCodes: I18nUpdateOpCodes, - bindingsStartIndex: number, changeMask: number) { - let caseCreated = false; + tView: TView, lView: LView, updateOpCodes: I18nUpdateOpCodes, bindingsStartIndex: number, + changeMask: number) { 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; @@ -218,31 +274,54 @@ export function applyUpdateOpCodes( // Negative opCode represent `i18nExp` values offset. value += renderStringify(lView[bindingsStartIndex - opCode]); } else { - const nodeIndex = opCode >>> I18nUpdateOpCode.SHIFT_REF; + 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); + const tNodeOrTagName = tView.data[nodeIndex] as TNode | string; + ngDevMode && assertDefined(tNodeOrTagName, 'Expecting TNode or string'); + if (typeof tNodeOrTagName === 'string') { + // IF we don't have a `TNode`, then we are an element in ICU (as ICU content does + // not have TNode), in which case we know that there are no directives, and hence + // we use attribute setting. + setElementAttribute( + lView[RENDERER], lView[nodeIndex], null, tNodeOrTagName, propName, value, + sanitizeFn); + } else { + elementPropertyInternal( + tView, tNodeOrTagName, lView, propName, value, lView[RENDERER], sanitizeFn, + false); + } break; case I18nUpdateOpCode.Text: - textBindingInternal(lView, nodeIndex, value); + const rText = lView[nodeIndex] as RText | null; + rText !== null && updateTextNode(lView[RENDERER], rText, value); break; case I18nUpdateOpCode.IcuSwitch: - caseCreated = - applyIcuSwitchCase(tView, tIcus!, updateOpCodes[++j] as number, lView, value); + applyIcuSwitchCase(tView, getTIcu(tView, nodeIndex)!, lView, value); break; case I18nUpdateOpCode.IcuUpdate: - applyIcuUpdateCase( - tView, tIcus!, updateOpCodes[++j] as number, bindingsStartIndex, lView, - caseCreated); + applyIcuUpdateCase(tView, getTIcu(tView, nodeIndex)!, bindingsStartIndex, lView); break; } } } } + } else { + const opCode = updateOpCodes[i + 1] as number; + if (opCode > 0 && (opCode & I18nUpdateOpCode.MASK_OPCODE) === I18nUpdateOpCode.IcuUpdate) { + // Special case for the `icuUpdateCase`. It could be that the mask did not match, but + // we still need to execute `icuUpdateCase` because the case has changed recently due to + // previous `icuSwitchCase` instruction. (`icuSwitchCase` and `icuUpdateCase` always come in + // pairs.) + const nodeIndex = (opCode >>> I18nUpdateOpCode.SHIFT_REF); + const tIcu = getTIcu(tView, nodeIndex)!; + const currentIndex = lView[tIcu.currentCaseLViewIndex]; + if (currentIndex < 0) { + applyIcuUpdateCase(tView, tIcu, bindingsStartIndex, lView); + } + } } i += skipCodes; } @@ -252,25 +331,23 @@ export function applyUpdateOpCodes( * 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 tIcu Current `TIcu` * @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]; +function applyIcuUpdateCase(tView: TView, tIcu: TIcu, bindingsStartIndex: number, lView: LView) { ngDevMode && assertIndexInRange(lView, tIcu.currentCaseLViewIndex); - const activeCaseIndex = lView[tIcu.currentCaseLViewIndex]; + let 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); + let mask = changeMask; + if (activeCaseIndex < 0) { + // Clear the flag. + // Negative number means that the ICU was freshly created and we need to force the update. + activeCaseIndex = lView[tIcu.currentCaseLViewIndex] = ~activeCaseIndex; + // -1 is same as all bits on, which simulates creation since it marks all bits dirty + mask = -1; + } + applyUpdateOpCodes(tView, lView, tIcu.update[activeCaseIndex], bindingsStartIndex, mask); } } @@ -280,48 +357,39 @@ function applyIcuUpdateCase( * 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 tIcu Current `TIcu` * @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); - +function applyIcuSwitchCase(tView: TView, tIcu: TIcu, lView: LView, value: string) { // 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; + let activeCaseIndex = getCurrentICUCaseIndex(tIcu, lView); + if (activeCaseIndex !== caseIndex) { + applyIcuSwitchCaseRemove(tView, tIcu, lView); + lView[tIcu.currentCaseLViewIndex] = caseIndex === null ? null : ~caseIndex; + if (caseIndex !== null) { + // Add the nodes for the new case + const anchorRNode = lView[tIcu.anchorIdx]; + if (anchorRNode) { + ngDevMode && assertDomNode(anchorRNode); + applyMutableOpCodes(tView, tIcu.create[caseIndex], lView, anchorRNode); + } + } } - return caseCreated; } /** - * Apply OpCodes associated with tearing down of DOM. + * Apply OpCodes associated with tearing ICU case. * * 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 tIcu Current `TIcu` * @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]; +function applyIcuSwitchCaseRemove(tView: TView, tIcu: TIcu, lView: LView) { + let activeCaseIndex = getCurrentICUCaseIndex(tIcu, lView); if (activeCaseIndex !== null) { const removeCodes = tIcu.remove[activeCaseIndex]; for (let k = 0; k < removeCodes.length; k++) { @@ -329,158 +397,17 @@ function applyIcuSwitchCaseRemove(tView: TView, tIcus: TIcu[], tIcuIndex: 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); + nativeRemoveNode( + lView[RENDERER], getNativeByIndex(nodeOrIcuIndex - HEADER_OFFSET, lView)); break; case I18nMutateOpCode.RemoveNestedIcu: - applyIcuSwitchCaseRemove(tView, tIcus, nodeOrIcuIndex, lView); + applyIcuSwitchCaseRemove(tView, getTIcu(tView, 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; - // FIXME(misko): Checking `tNode.parent` is a temporary workaround until we properly - // refactor the i18n code in #38707 and this code will be deleted. - if (tNode.parent === null) { - tView.firstChild = tNode; - } else { - 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 = getCurrentTNode(); - - // 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 (lastCreatedNode !== null && 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 && removedPhTNode) { - // 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 currentTNode = getCurrentTNode(); - 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, 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 (currentTNode && currentTNode.next === tNode) { - currentTNode.next = null; - } - - return tNode; -} - /** * Returns the index of the current case of an ICU expression depending on the main binding value @@ -488,7 +415,7 @@ function createDynamicNodeAtIndex( * @param icuExpression * @param bindingValue The value of the main binding used by this ICU expression */ -function getCaseIndex(icuExpression: TIcu, bindingValue: string): number { +function getCaseIndex(icuExpression: TIcu, bindingValue: string): number|null { let index = icuExpression.cases.indexOf(bindingValue); if (index === -1) { switch (icuExpression.type) { @@ -506,5 +433,5 @@ function getCaseIndex(icuExpression: TIcu, bindingValue: string): number { } } } - return index; + return index === -1 ? null : index; } diff --git a/packages/core/src/render3/i18n/i18n_debug.ts b/packages/core/src/render3/i18n/i18n_debug.ts index df7ee78a31..1c0b6c6fc5 100644 --- a/packages/core/src/render3/i18n/i18n_debug.ts +++ b/packages/core/src/render3/i18n/i18n_debug.ts @@ -7,8 +7,38 @@ */ import {assertNumber, assertString} from '../../util/assert'; +import {COMMENT_MARKER, ELEMENT_MARKER, getInstructionFromI18nMutateOpCode, getParentFromI18nMutateOpCode, getRefFromI18nMutateOpCode, I18nCreateOpCode, I18nMutateOpCode, I18nMutateOpCodes, I18nUpdateOpCode, I18nUpdateOpCodes} from '../interfaces/i18n'; -import {COMMENT_MARKER, ELEMENT_MARKER, getInstructionFromI18nMutateOpCode, getParentFromI18nMutateOpCode, getRefFromI18nMutateOpCode, I18nMutateOpCode, I18nMutateOpCodes, I18nUpdateOpCode, I18nUpdateOpCodes} from '../interfaces/i18n'; + +/** + * Converts `I18nCreateOpCodes` array into a human readable format. + * + * This function is attached to the `I18nCreateOpCodes.debug` property if `ngDevMode` is enabled. + * This function provides a human readable view of the opcodes. This is useful when debugging the + * application as well as writing more readable tests. + * + * @param this `I18nCreateOpCodes` if attached as a method. + * @param opcodes `I18nCreateOpCodes` if invoked as a function. + */ +export function i18nCreateOpCodesToString( + this: I18nUpdateOpCodes|void, opcodes?: I18nUpdateOpCodes): string[] { + const createOpCodes: I18nUpdateOpCodes = opcodes || (Array.isArray(this) ? this : []); + let lines: string[] = []; + for (let i = 0; i < createOpCodes.length; i++) { + const opCode = createOpCodes[i++] as any; + const text = createOpCodes[i] as string; + const isComment = (opCode & I18nCreateOpCode.COMMENT) === I18nCreateOpCode.COMMENT; + const appendNow = + (opCode & I18nCreateOpCode.APPEND_EAGERLY) === I18nCreateOpCode.APPEND_EAGERLY; + const index = opCode >>> I18nCreateOpCode.SHIFT; + lines.push(`lView[${index}] = document.${isComment ? 'createComment' : 'createText'}(${ + JSON.stringify(text)});`); + if (appendNow) { + lines.push(`parent.appendChild(lView[${index}]);`); + } + } + return lines; +} /** * Converts `I18nUpdateOpCodes` array into a human readable format. @@ -37,9 +67,9 @@ export function i18nUpdateOpCodesToString( const value = sanitizationFn ? `(${sanitizationFn})($$$)` : '$$$'; return `(lView[${ref}] as Element).setAttribute('${attrName}', ${value})`; case I18nUpdateOpCode.IcuSwitch: - return `icuSwitchCase(lView[${ref}] as Comment, ${parser.consumeNumber()}, $$$)`; + return `icuSwitchCase(${ref}, $$$)`; case I18nUpdateOpCode.IcuUpdate: - return `icuUpdateCase(lView[${ref}] as Comment, ${parser.consumeNumber()})`; + return `icuUpdateCase(${ref})`; } throw new Error('unexpected OpCode'); } @@ -57,7 +87,9 @@ export function i18nUpdateOpCodesToString( statement += value; } else if (value < 0) { // Negative numbers are ref indexes - statement += '${lView[' + (0 - value) + ']}'; + // Here `i` refers to current binding index. It is to signify that the value is relative, + // rather than absolute. + statement += '${lView[i' + value + ']}'; } else { // Positive numbers are operations. const opCodeText = consumeOpCode(value); @@ -89,9 +121,6 @@ export function i18nMutateOpCodesToString( const parent = getParentFromI18nMutateOpCode(opCode); const ref = getRefFromI18nMutateOpCode(opCode); switch (getInstructionFromI18nMutateOpCode(opCode)) { - case I18nMutateOpCode.Select: - lastRef = ref; - return ''; case I18nMutateOpCode.AppendChild: return `(lView[${parent}] as Element).appendChild(lView[${lastRef}])`; case I18nMutateOpCode.Remove: @@ -99,8 +128,6 @@ export function i18nMutateOpCodesToString( case I18nMutateOpCode.Attr: return `(lView[${ref}] as Element).setAttribute("${parser.consumeString()}", "${ parser.consumeString()}")`; - case I18nMutateOpCode.ElementEnd: - return `setCurrentTNode(tView.data[${ref}] as TNode)`; case I18nMutateOpCode.RemoveNestedIcu: return `removeNestedICU(${ref})`; } diff --git a/packages/core/src/render3/i18n/i18n_insert_before_index.ts b/packages/core/src/render3/i18n/i18n_insert_before_index.ts new file mode 100644 index 0000000000..fc88f18041 --- /dev/null +++ b/packages/core/src/render3/i18n/i18n_insert_before_index.ts @@ -0,0 +1,86 @@ +/** + * @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 {assertEqual} from '../../util/assert'; +import {TNode, TNodeType} from '../interfaces/node'; + +/** + * Add `tNode` to `previousTNodes` list and update relevant `TNode`s in `previousTNodes` list + * `tNode.insertBeforeIndex`. + * + * Things to keep in mind: + * 1. All i18n text nodes are encoded as `TNodeType.Element` and are created eagerly by the + * `ɵɵi18nStart` instruction. + * 2. All `TNodeType.Placeholder` `TNodes` are elements which will be created later by + * `ɵɵelementStart` instruction. + * 3. `ɵɵelementStart` instruction will create `TNode`s in the ascending `TNode.index` order. (So a + * smaller index `TNode` is guaranteed to be created before a larger one) + * + * We use the above three invariants to determine `TNode.insertBeforeIndex`. + * + * In an ideal world `TNode.insertBeforeIndex` would always be `TNode.next.index`. However, + * this will not work because `TNode.next.index` may be larger than `TNode.index` which means that + * the next node is not yet created and therefore we can't insert in front of it. + * + * Rule1: `TNode.insertBeforeIndex = null` if `TNode.next === null` (Initial condition, as we don't + * know if there will be further `TNode`s inserted after.) + * Rule2: If `previousTNode` is created after the `tNode` being inserted, then + * `previousTNode.insertBeforeNode = tNode.index` (So when a new `tNode` is added we check + * previous to see if we can update its `insertBeforeTNode`) + * + * See `TNode.insertBeforeIndex` for more context. + * + * @param previousTNodes A list of previous TNodes so that we can easily traverse `TNode`s in + * reverse order. (If `TNode` would have `previous` this would not be necessary.) + * @param newTNode A TNode to add to the `previousTNodes` list. + */ +export function addTNodeAndUpdateInsertBeforeIndex(previousTNodes: TNode[], newTNode: TNode) { + // Start with Rule1 + ngDevMode && + assertEqual(newTNode.insertBeforeIndex, null, 'We expect that insertBeforeIndex is not set'); + + previousTNodes.push(newTNode); + if (previousTNodes.length > 1) { + for (let i = previousTNodes.length - 2; i >= 0; i--) { + const existingTNode = previousTNodes[i]; + // Text nodes are created eagerly and so they don't need their `indexBeforeIndex` updated. + // It is safe to ignore them. + if (!isI18nText(existingTNode)) { + if (isNewTNodeCreatedBefore(existingTNode, newTNode) && + getInsertBeforeIndex(existingTNode) === null) { + // If it was created before us in time, (and it does not yet have `insertBeforeIndex`) + // then add the `insertBeforeIndex`. + setInsertBeforeIndex(existingTNode, newTNode.index); + } + } + } + } +} + +function isI18nText(tNode: TNode): boolean { + return tNode.type !== TNodeType.Placeholder; +} + +function isNewTNodeCreatedBefore(existingTNode: TNode, newTNode: TNode): boolean { + return isI18nText(newTNode) || existingTNode.index > newTNode.index; +} + +function getInsertBeforeIndex(tNode: TNode): number|null { + const index = tNode.insertBeforeIndex; + return Array.isArray(index) ? index[0] : index; +} + +function setInsertBeforeIndex(tNode: TNode, value: number): void { + const index = tNode.insertBeforeIndex; + if (Array.isArray(index)) { + // Array is stored if we have to insert child nodes. See `TNode.insertBeforeIndex` + index[0] = value; + } else { + tNode.insertBeforeIndex = value; + } +} diff --git a/packages/core/src/render3/i18n/i18n_parse.ts b/packages/core/src/render3/i18n/i18n_parse.ts index 57f357dfcc..79667119a5 100644 --- a/packages/core/src/render3/i18n/i18n_parse.ts +++ b/packages/core/src/render3/i18n/i18n_parse.ts @@ -11,20 +11,23 @@ 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 {assertDefined, assertEqual, assertGreaterThanOrEqual, assertOneOf, assertString} from '../../util/assert'; +import {CharCode} from '../../util/char_code'; +import {loadIcuContainerVisitor} from '../instructions/i18n_icu_container_visitor'; +import {allocExpando, createTNodeAtIndex, 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 {COMMENT_MARKER, ELEMENT_MARKER, ensureIcuContainerVisitorLoaded, I18nCreateOpCode, I18nCreateOpCodes, I18nMutateOpCode, i18nMutateOpCode, I18nMutateOpCodes, I18nUpdateOpCode, I18nUpdateOpCodes, IcuExpression, IcuType, TI18n, TIcu} from '../interfaces/i18n'; +import {TNode, 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 {getCurrentTNode, isCurrentTNodeParent} from '../state'; +import {HEADER_OFFSET, LView, TView} from '../interfaces/view'; +import {getCurrentParentTNode, getCurrentTNode, setCurrentTNode} from '../state'; import {attachDebugGetter} from '../util/debug_utils'; import {getNativeByIndex, getTNode} from '../util/view_utils'; -import {i18nMutateOpCodesToString, i18nUpdateOpCodesToString} from './i18n_debug'; +import {i18nCreateOpCodesToString, i18nMutateOpCodesToString, i18nUpdateOpCodesToString} from './i18n_debug'; +import {addTNodeAndUpdateInsertBeforeIndex} from './i18n_insert_before_index'; +import {createTNodePlaceholder, setTIcu, setTNodeInsertBeforeIndex} from './i18n_util'; @@ -33,22 +36,9 @@ 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: @@ -62,148 +52,170 @@ function replaceNgsp(value: string): string { return value.replace(NGSP_UNICODE_REGEXP, ' '); } - /** - * See `i18nStart` above. + * Create dynamic nodes from i18n translation block. + * + * - Text nodes are created synchronously + * - TNodes are linked into tree lazily + * + * @param tView Current `TView` + * @parentTNodeIndex index to the parent TNode of this i18n block + * @param lView Current `LView` + * @param index Index of `ɵɵi18nStart` instruction. + * @param message Message to translate. + * @param subTemplateIndex Index into the sub template of message translation. (ie in case of + * `ngIf`) (-1 otherwise) */ -export function i18nStartFirstPass( - lView: LView, tView: TView, index: number, message: string, subTemplateIndex?: number) { - const startIndex = tView.blueprint.length - HEADER_OFFSET; - i18nVarsCount = 0; - const currentTNode = getCurrentTNode()!; - const parentTNode = isCurrentTNodeParent() ? currentTNode : currentTNode && currentTNode.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 && currentTNode !== parentTNode) { - let previousTNodeIndex = currentTNode.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 (!isCurrentTNodeParent()) { - previousTNodeIndex = ~previousTNodeIndex; - } - // Create an OpCode to select the previous TNode - createOpCodes.push(previousTNodeIndex << I18nMutateOpCode.SHIFT_REF | I18nMutateOpCode.Select); - } +export function i18nStartFirstCreatePass( + tView: TView, parentTNodeIndex: number, lView: LView, index: number, message: string, + subTemplateIndex: number) { + const rootTNode = getCurrentParentTNode(); + const createOpCodes: I18nCreateOpCodes = []; const updateOpCodes: I18nUpdateOpCodes = []; + const existingTNodeStack: TNode[][] = [[]]; if (ngDevMode) { + attachDebugGetter(createOpCodes, i18nCreateOpCodesToString); 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); + message = getTranslationForTemplate(message, subTemplateIndex); + const msgParts = replaceNgsp(message).split(PH_REGEXP); + for (let i = 0; i < msgParts.length; i++) { + let value = msgParts[i]; + if ((i & 1) === 0) { + // Even indexes are text (including bindings & ICU expressions) + const parts = i18nParseTextIntoPartsAndICU(value); + for (let j = 0; j < parts.length; j++) { + let part = parts[j]; + if ((j & 1) === 0) { + // `j` is odd therefore `part` is string + const text = part as string; + ngDevMode && assertString(text, 'Parsed ICU part should be string'); + if (text !== '') { + i18nStartFirstCreatePassProcessTextNode( + tView, rootTNode, existingTNodeStack[0], createOpCodes, updateOpCodes, lView, text); } } 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; + // `j` is Even therefor `part` is an `ICUExpression` + const icuExpression: IcuExpression = part 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 (ngDevMode && typeof icuExpression !== 'object') { + throw new Error(`Unable to parse ICU expression in "${message}" message.`); } + const icuContainerTNode = createTNodeAndAddOpCode( + tView, rootTNode, existingTNodeStack[0], lView, createOpCodes, + ngDevMode ? `ICU ${index}:${icuExpression.mainBinding}` : '', true); + const icuNodeIndex = icuContainerTNode.index; + ngDevMode && + assertGreaterThanOrEqual( + icuNodeIndex, HEADER_OFFSET, 'Index must be in absolute LView offset'); + icuStart(tView, lView, updateOpCodes, parentTNodeIndex, icuExpression, icuNodeIndex); } + } + } else { + // Odd indexes are placeholders (elements and sub-templates) + // At this point value is something like: '/#1:2' (orginally coming from '�/#1:2�') + const isClosing = value.charCodeAt(0) === CharCode.SLASH; + const type = value.charCodeAt(isClosing ? 1 : 0); + ngDevMode && assertOneOf(type, CharCode.STAR, CharCode.HASH, CharCode.EXCLAMATION); + const index = HEADER_OFFSET + Number.parseInt(value.substring((isClosing ? 2 : 1))); + if (isClosing) { + existingTNodeStack.shift(); + setCurrentTNode(getCurrentParentTNode()!, false); } 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); - } - } - } + const tNode = createTNodePlaceholder(tView, existingTNodeStack[0], index); + existingTNodeStack.unshift([]); + setCurrentTNode(tNode, true); } } } - if (i18nVarsCount > 0) { - allocExpando(tView, lView, i18nVarsCount); - } - - // NOTE: local var needed to properly assert the type of `TI18n`. - const tI18n: TI18n = { - vars: i18nVarsCount, + tView.data[index + HEADER_OFFSET] = { create: createOpCodes, update: updateOpCodes, - icus: icuExpressions.length ? icuExpressions : null, }; +} - tView.data[index + HEADER_OFFSET] = tI18n; +/** + * Allocate space in i18n Range add create OpCode instruction to crete a text or comment node. + * + * @param tView Current `TView` needed to allocate space in i18n range. + * @param rootTNode Root `TNode` of the i18n block. This node determines if the new TNode will be + * added as part of the `i18nStart` instruction or as part of the `TNode.insertBeforeIndex`. + * @param existingTNodes internal state for `addTNodeAndUpdateInsertBeforeIndex`. + * @param lView Current `LView` needed to allocate space in i18n range. + * @param createOpCodes Array storing `I18nCreateOpCodes` where new opCodes will be added. + * @param text Text to be added when the `Text` or `Comment` node will be created. + * @param isICU true if a `Comment` node for ICU (instead of `Text`) node should be created. + */ +function createTNodeAndAddOpCode( + tView: TView, rootTNode: TNode|null, existingTNodes: TNode[], lView: LView, + createOpCodes: I18nCreateOpCodes, text: string, isICU: boolean): TNode { + const i18nNodeIdx = allocExpando(tView, lView, 1); + let opCode = i18nNodeIdx << I18nCreateOpCode.SHIFT; + let parentTNode = getCurrentParentTNode(); + + if (rootTNode === parentTNode) { + // FIXME(misko): A null `parentTNode` should represent when we fall of the `LView` boundry. + // (there is no parent), but in some circumstances (because we are inconsistent about how we set + // `previousOrParentTNode`) it could point to `rootTNode` So this is a work around. + parentTNode = null; + } + if (parentTNode === null) { + // If we don't have a parent that means that we can eagerly add nodes. + // If we have a parent than these nodes can't be added now (as the parent has not been created + // yet) and instead the `parentTNode` is responsible for adding it. See + // `TNode.insertBeforeIndex` + opCode |= I18nCreateOpCode.APPEND_EAGERLY; + } + if (isICU) { + opCode |= I18nCreateOpCode.COMMENT; + ensureIcuContainerVisitorLoaded(loadIcuContainerVisitor); + } + createOpCodes.push(opCode, text); + const tNode = createTNodeAtIndex( + tView, i18nNodeIdx, isICU ? TNodeType.IcuContainer : TNodeType.Element, null, null); + addTNodeAndUpdateInsertBeforeIndex(existingTNodes, tNode); + const tNodeIdx = tNode.index; + setCurrentTNode(tNode, false /* Text nodes are self closing */); + if (parentTNode !== null && rootTNode !== parentTNode) { + // We are a child of deeper node (rather than a direct child of `i18nStart` instruction.) + // We have to make sure to add ourselves to the parent. + setTNodeInsertBeforeIndex(parentTNode, tNodeIdx); + } + return tNode; +} + +/** + * Processes text node in i18n block. + * + * Text nodes can have: + * - Create instruction in `createOpCodes` for creating the text node. + * - Allocate spec for text node in i18n range of `LView` + * - If contains binding: + * - bindings => allocate space in i18n range of `LView` to store the binding value. + * - populate `updateOpCodes` with update instructions. + * + * @param tView Current `TView` + * @param rootTNode Root `TNode` of the i18n block. This node determines if the new TNode will + * be added as part of the `i18nStart` instruction or as part of the + * `TNode.insertBeforeIndex`. + * @param existingTNodes internal state for `addTNodeAndUpdateInsertBeforeIndex`. + * @param createOpCodes Location where the creation OpCodes will be stored. + * @param lView Current `LView` + * @param text The translated text (which may contain binding) + */ +function i18nStartFirstCreatePassProcessTextNode( + tView: TView, rootTNode: TNode|null, existingTNodes: TNode[], createOpCodes: I18nCreateOpCodes, + updateOpCodes: I18nUpdateOpCodes, lView: LView, text: string): void { + const hasBinding = text.match(BINDING_REGEXP); + const tNode = createTNodeAndAddOpCode( + tView, rootTNode, existingTNodes, lView, createOpCodes, hasBinding ? '' : text, false); + if (hasBinding) { + generateBindingUpdateOpCodes(updateOpCodes, text, tNode.index); + } } /** @@ -212,7 +224,7 @@ export function i18nStartFirstPass( export function i18nAttributesFirstPass( lView: LView, tView: TView, index: number, values: string[]) { const previousElement = getCurrentTNode()!; - const previousElementIndex = previousElement.index - HEADER_OFFSET; + const previousElementIndex = previousElement.index; const updateOpCodes: I18nUpdateOpCodes = []; if (ngDevMode) { attachDebugGetter(updateOpCodes, i18nUpdateOpCodesToString); @@ -233,11 +245,10 @@ export function i18nAttributesFirstPass( const hasBinding = !!value.match(BINDING_REGEXP); if (hasBinding) { if (tView.firstCreatePass && tView.data[index + HEADER_OFFSET] === null) { - addAllToArray( - generateBindingUpdateOpCodes(value, previousElementIndex, attrName), updateOpCodes); + generateBindingUpdateOpCodes(updateOpCodes, value, previousElementIndex, attrName); } } else { - const tNode = getTNode(tView, previousElementIndex); + const tNode = getTNode(tView, previousElementIndex - HEADER_OFFSET); // Set attributes for Elements only, for other types (like ElementContainer), // only set inputs below if (tNode.type === TNodeType.Element) { @@ -248,7 +259,9 @@ export function i18nAttributesFirstPass( if (dataValue) { setInputsForProperty(tView, lView, dataValue, attrName, value); if (ngDevMode) { - const element = getNativeByIndex(previousElementIndex, lView) as RElement | RComment; + const element = + getNativeByIndex(previousElementIndex - HEADER_OFFSET, lView) as RElement | + RComment; setNgReflectProperties(lView, element, tNode.type, dataValue, value); } } @@ -266,15 +279,22 @@ export function i18nAttributesFirstPass( /** * Generate the OpCodes to update the bindings of a string. * + * @param updateOpCodes Place where the update opcodes will be stored. * @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 + updateOpCodes: I18nUpdateOpCodes, str: string, destinationNode: number, attrName?: string, + sanitizeFn: SanitizerFn|null = null): number { + ngDevMode && + assertGreaterThanOrEqual( + destinationNode, HEADER_OFFSET, 'Index must be in absolute LView offset'); + const maskIndex = updateOpCodes.length; // Location of mask + const sizeIndex = maskIndex + 1; // location of size for skipping + updateOpCodes.push(null, null); // Alloc space for mask and size + const startIndex = maskIndex + 2; // location of first allocation. if (ngDevMode) { attachDebugGetter(updateOpCodes, i18nUpdateOpCodesToString); } @@ -301,9 +321,9 @@ export function generateBindingUpdateOpCodes( if (attrName) { updateOpCodes.push(attrName, sanitizeFn); } - updateOpCodes[0] = mask; - updateOpCodes[1] = updateOpCodes.length - 2; - return updateOpCodes; + updateOpCodes[maskIndex] = mask; + updateOpCodes[sizeIndex] = updateOpCodes.length - startIndex; + return mask; } function getBindingMask(icuExpression: IcuExpression, mask = 0): number { @@ -325,26 +345,21 @@ function getBindingMask(icuExpression: IcuExpression, mask = 0): number { 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) + * 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; +export function isRootTemplateMessage(subTemplateIndex: number): subTemplateIndex is - 1 { + return subTemplateIndex === -1; } @@ -385,8 +400,8 @@ function removeInnerTemplateTranslation(message: string): string { /** * 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. + * This method is used for extracting a part of the message associated with a template. A + * translated message can span multiple templates. * * Example: * ``` @@ -397,7 +412,7 @@ function removeInnerTemplateTranslation(message: string): string { * @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) { +export function getTranslationForTemplate(message: string, subTemplateIndex: number) { if (isRootTemplateMessage(subTemplateIndex)) { // We want the root template message, ignore all sub-templates return removeInnerTemplateTranslation(message); @@ -413,19 +428,27 @@ export function getTranslationForTemplate(message: string, subTemplateIndex?: nu /** * Generate the OpCodes for ICU expressions. * - * @param tIcus * @param icuExpression - * @param startIndex - * @param expandoStartIndex + * @param index Index where the anchor is stored and an optional `TIcuContainerNode` + * - `lView[anchorIdx]` points to a `Comment` node representing the anchor for the ICU. + * - `tView.data[anchorIdx]` points to the `TIcuContainerNode` if ICU is root (`null` otherwise) */ 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[][] = []; + tView: TView, lView: LView, updateOpCodes: I18nUpdateOpCodes, parentIdx: number, + icuExpression: IcuExpression, anchorIdx: number) { + ngDevMode && assertDefined(icuExpression, 'ICU expression must be defined'); + let bindingMask = 0; + const tIcu: TIcu = { + type: icuExpression.type, + currentCaseLViewIndex: allocExpando(tView, lView, 1), + anchorIdx, + cases: [], + create: [], + remove: [], + update: [] + }; + addUpdateIcuSwitch(updateOpCodes, icuExpression, anchorIdx); + setTIcu(tView, anchorIdx, tIcu); const values = icuExpression.values; for (let i = 0; i < values.length; i++) { // Each value is an array of strings & other ICU expressions @@ -440,29 +463,14 @@ export function icuStart( 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); + bindingMask = parseIcuCase( + tView, tIcu, lView, updateOpCodes, parentIdx, icuExpression.cases[i], + valueArr.join(''), nestedIcus) | + bindingMask; + } + if (bindingMask) { + addUpdateIcuUpdate(updateOpCodes, bindingMask, anchorIdx); } - 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); } /** @@ -487,7 +495,7 @@ export function parseICUBlock(pattern: string): IcuExpression { return ''; }); - const parts = extractParts(pattern) as string[]; + const parts = i18nParseTextIntoPartsAndICU(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(); @@ -499,7 +507,7 @@ export function parseICUBlock(pattern: string): IcuExpression { cases.push(key); } - const blocks = extractParts(parts[pos++]) as string[]; + const blocks = i18nParseTextIntoPartsAndICU(parts[pos++]) as string[]; if (cases.length > values.length) { values.push(blocks); } @@ -510,51 +518,17 @@ export function parseICUBlock(pattern: string): IcuExpression { } -/** - * 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. + * 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. - * + * @returns An `Array` where: + * - odd positions: `string` => text between ICU expressions + * - even positions: `ICUExpression` => ICU expression parsed into `ICUExpression` record. */ -function extractParts(pattern: string): (string|IcuExpression)[] { +export function i18nParseTextIntoPartsAndICU(pattern: string): (string|IcuExpression)[] { if (!pattern) { return []; } @@ -602,131 +576,147 @@ function extractParts(pattern: string): (string|IcuExpression)[] { /** * 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!; - } +export function parseIcuCase( + tView: TView, tIcu: TIcu, lView: LView, updateOpCodes: I18nUpdateOpCodes, parentIdx: number, + caseName: string, unsafeCaseHtml: string, nestedIcus: IcuExpression[]): number { + const create: I18nMutateOpCodes = []; + const remove: I18nMutateOpCodes = []; + const update: I18nUpdateOpCodes = []; + if (ngDevMode) { + attachDebugGetter(create, i18nMutateOpCodesToString); + attachDebugGetter(remove, i18nMutateOpCodesToString); + attachDebugGetter(update, i18nUpdateOpCodesToString); + } + tIcu.cases.push(caseName); + tIcu.create.push(create); + tIcu.remove.push(remove); + tIcu.update.push(update); - 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); - } + const inertBodyHelper = getInertBodyHelper(getDocument()); + const inertBodyElement = inertBodyHelper.getInertBodyElement(unsafeCaseHtml); + ngDevMode && assertDefined(inertBodyElement, 'Unable to generate inert body element'); + const inertRootNode = getTemplateContent(inertBodyElement!) as Element || inertBodyElement; + if (inertRootNode) { + return walkIcuTree( + tView, tIcu, lView, updateOpCodes, create, remove, update, inertRootNode, parentIdx, + nestedIcus, 0); + } else { + return 0; } } + +function walkIcuTree( + tView: TView, tIcu: TIcu, lView: LView, sharedUpdateOpCodes: I18nUpdateOpCodes, + create: I18nMutateOpCodes, remove: I18nMutateOpCodes, update: I18nUpdateOpCodes, + parentNode: Element, parentIdx: number, nestedIcus: IcuExpression[], depth: number): number { + let bindingMask = 0; + let currentNode = parentNode.firstChild; + while (currentNode) { + const newIndex = allocExpando(tView, lView, 1); + switch (currentNode.nodeType) { + case Node.ELEMENT_NODE: + const element = currentNode as Element; + const tagName = element.tagName.toLowerCase(); + if (VALID_ELEMENTS.hasOwnProperty(tagName)) { + addCreateNodeAndAppend(create, ELEMENT_MARKER, tagName, parentIdx, newIndex); + tView.data[newIndex] = tagName; + 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]) { + generateBindingUpdateOpCodes( + update, attr.value, newIndex, attr.name, _sanitizeUrl); + } else if (SRCSET_ATTRS[lowerAttrName]) { + generateBindingUpdateOpCodes( + update, attr.value, newIndex, attr.name, sanitizeSrcset); + } else { + generateBindingUpdateOpCodes(update, attr.value, newIndex, attr.name); + } + } else { + ngDevMode && console.warn(` WARNING: + ignoring unsafe attribute value ${lowerAttrName} on element $ { + tagName + } (see http://g.co/ng/security#xss)`); + } + } else { + create.push( + newIndex << I18nMutateOpCode.SHIFT_REF | I18nMutateOpCode.Attr, attr.name, + attr.value); + } + } + // Parse the children of this node (if any) + bindingMask = walkIcuTree( + tView, tIcu, lView, sharedUpdateOpCodes, create, remove, update, + currentNode as Element, newIndex, nestedIcus, depth + 1) | + bindingMask; + addRemoveNode(remove, newIndex, depth); + } + break; + case Node.TEXT_NODE: + const value = currentNode.textContent || ''; + const hasBinding = value.match(BINDING_REGEXP); + addCreateNodeAndAppend(create, null, hasBinding ? '' : value, parentIdx, newIndex); + addRemoveNode(remove, newIndex, depth); + if (hasBinding) { + bindingMask = generateBindingUpdateOpCodes(update, value, newIndex) | bindingMask; + } + break; + case Node.COMMENT_NODE: + // Check if the comment node is a placeholder for a nested ICU + const isNestedIcu = NESTED_ICU.exec(currentNode.textContent || ''); + if (isNestedIcu) { + const nestedIcuIndex = parseInt(isNestedIcu[1], 10); + const icuExpression: IcuExpression = nestedIcus[nestedIcuIndex]; + // Create the comment node that will anchor the ICU expression + addCreateNodeAndAppend( + create, COMMENT_MARKER, ngDevMode ? `nested ICU ${nestedIcuIndex}` : '', parentIdx, + newIndex); + icuStart(tView, lView, sharedUpdateOpCodes, parentIdx, icuExpression, newIndex); + addRemoveNestedIcu(remove, newIndex, depth); + } + break; + } + currentNode = currentNode.nextSibling; + } + return bindingMask; +} +function addRemoveNode(remove: I18nMutateOpCodes, index: number, depth: number) { + if (depth === 0) { + remove.push(index << I18nMutateOpCode.SHIFT_REF | I18nMutateOpCode.Remove); + } +} + +function addRemoveNestedIcu(remove: I18nMutateOpCodes, index: number, depth: number) { + if (depth === 0) { + remove.push(index << I18nMutateOpCode.SHIFT_REF | I18nMutateOpCode.RemoveNestedIcu); + remove.push(index << I18nMutateOpCode.SHIFT_REF | I18nMutateOpCode.Remove); + } +} + +function addUpdateIcuSwitch( + update: I18nUpdateOpCodes, icuExpression: IcuExpression, index: number) { + update.push( + toMaskBit(icuExpression.mainBinding), 2, -1 - icuExpression.mainBinding, + index << I18nUpdateOpCode.SHIFT_REF | I18nUpdateOpCode.IcuSwitch); +} + +function addUpdateIcuUpdate(update: I18nUpdateOpCodes, bindingMask: number, index: number) { + update.push(bindingMask, 1, index << I18nUpdateOpCode.SHIFT_REF | I18nUpdateOpCode.IcuUpdate); +} + +function addCreateNodeAndAppend( + create: I18nMutateOpCodes, marker: null|COMMENT_MARKER|ELEMENT_MARKER, text: string, + appendToParentIdx: number, createAtIdx: number) { + if (marker !== null) { + create.push(marker); + } + create.push( + text, createAtIdx, + i18nMutateOpCode(I18nMutateOpCode.AppendChild, appendToParentIdx, createAtIdx)); +} diff --git a/packages/core/src/render3/i18n/i18n_util.ts b/packages/core/src/render3/i18n/i18n_util.ts new file mode 100644 index 0000000000..e0e7d46ddd --- /dev/null +++ b/packages/core/src/render3/i18n/i18n_util.ts @@ -0,0 +1,107 @@ +/** + * @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 {assertEqual, throwError} from '../../util/assert'; +import {assertTIcu, assertTNode} from '../assert'; +import {createTNodeAtIndex} from '../instructions/shared'; +import {TIcu} from '../interfaces/i18n'; +import {TIcuContainerNode, TNode, TNodeType} from '../interfaces/node'; +import {TView} from '../interfaces/view'; +import {assertNodeType} from '../node_assert'; +import {addTNodeAndUpdateInsertBeforeIndex} from './i18n_insert_before_index'; + + +/** + * Retrieve `TIcu` at a given `index`. + * + * The `TIcu` can be stored either directly (if it is nested ICU) OR + * it is stored inside tho `TIcuContainer` if it is top level ICU. + * + * The reason for this is that the top level ICU need a `TNode` so that they are part of the render + * tree, but nested ICU's have no TNode, because we don't know ahead of time if the nested ICU is + * expressed (parent ICU may have selected a case which does not contain it.) + * + * @param tView Current `TView`. + * @param index Index where the value should be read from. + */ +export function getTIcu(tView: TView, index: number): TIcu|null { + const value = tView.data[index] as null | TIcu | TIcuContainerNode | string; + if (value === null || typeof value === 'string') return null; + if (ngDevMode && + !(value.hasOwnProperty('tViews') || value.hasOwnProperty('currentCaseLViewIndex'))) { + throwError('We expect to get \'null\'|\'TIcu\'|\'TIcuContainer\', but got: ' + value); + } + // Here the `value.hasOwnProperty('currentCaseLViewIndex')` is a polymorphic read as it can be + // either TIcu or TIcuContainerNode. This is not ideal, but we still think it is OK because it + // will be just two cases which fits into the browser inline cache (inline cache can take up to + // 4) + const tIcu = value.hasOwnProperty('currentCaseLViewIndex') ? + value : + (value as TIcuContainerNode).tagName as any; + ngDevMode && assertTIcu(tIcu); + return tIcu; +} + +/** + * Store `TIcu` at a give `index`. + * + * The `TIcu` can be stored either directly (if it is nested ICU) OR + * it is stored inside tho `TIcuContainer` if it is top level ICU. + * + * The reason for this is that the top level ICU need a `TNode` so that they are part of the render + * tree, but nested ICU's have no TNode, because we don't know ahead of time if the nested ICU is + * expressed (parent ICU may have selected a case which does not contain it.) + * + * @param tView Current `TView`. + * @param index Index where the value should be stored at in `Tview.data` + * @param tIcu The TIcu to store. + */ +export function setTIcu(tView: TView, index: number, tIcu: TIcu): void { + const tNode = tView.data[index] as null | TIcuContainerNode; + ngDevMode && + assertEqual( + tNode === null || tNode.hasOwnProperty('tViews'), true, + 'We expect to get \'null\'|\'TIcuContainer\''); + if (tNode === null) { + tView.data[index] = tIcu; + } else { + ngDevMode && assertNodeType(tNode, TNodeType.IcuContainer); + // FIXME(misko): This is a hack which allows us to associate `TI18n` with `TNode`. + // This should be refactored so that one can attach arbitrary data with `TNode` + tNode.tagName = tIcu as any; + } +} + +/** + * Set `TNode.insertBeforeIndex` taking the `Array` into account. + * + * See `TNode.insertBeforeIndex` + */ +export function setTNodeInsertBeforeIndex(tNode: TNode, index: number) { + ngDevMode && assertTNode(tNode); + let insertBeforeIndex = tNode.insertBeforeIndex; + if (insertBeforeIndex === null) { + insertBeforeIndex = tNode.insertBeforeIndex = + [null!/* may be updated to number later */, index]; + } else { + assertEqual(Array.isArray(insertBeforeIndex), true, 'Expecting array here'); + (insertBeforeIndex as number[]).push(index); + } +} + +/** + * Create `TNode.type=TNodeType.Placeholder` node. + * + * See `TNodeType.Placeholder` for more information. + */ +export function createTNodePlaceholder( + tView: TView, previousTNodes: TNode[], index: number): TNode { + const tNode = createTNodeAtIndex(tView, index, TNodeType.Placeholder, null, null); + addTNodeAndUpdateInsertBeforeIndex(previousTNodes, tNode); + return tNode; +} diff --git a/packages/core/src/render3/instructions/element.ts b/packages/core/src/render3/instructions/element.ts index 19ac9f650e..6fc23174f2 100644 --- a/packages/core/src/render3/instructions/element.ts +++ b/packages/core/src/render3/instructions/element.ts @@ -10,19 +10,18 @@ import {assertDefined, assertEqual, assertIndexInRange} from '../../util/assert' import {assertFirstCreatePass, assertHasParent} from '../assert'; import {attachPatchData} from '../context_discovery'; import {registerPostOrderHooks} from '../hooks'; -import {hasClassInput, hasStyleInput, TAttributes, TElementNode, TNode, TNodeType} from '../interfaces/node'; +import {hasClassInput, hasStyleInput, TAttributes, TElementNode, TNode, TNodeFlags, TNodeType} from '../interfaces/node'; import {RElement} from '../interfaces/renderer'; import {isContentQueryHost, isDirectiveHost} from '../interfaces/type_checks'; -import {HEADER_OFFSET, LView, RENDERER, T_HOST, TVIEW, TView} from '../interfaces/view'; +import {HEADER_OFFSET, LView, RENDERER, TView} from '../interfaces/view'; import {assertNodeType} from '../node_assert'; -import {appendChild, writeDirectClass, writeDirectStyle} from '../node_manipulation'; +import {appendChild, createElementNode, writeDirectClass, writeDirectStyle} from '../node_manipulation'; import {decreaseElementDepthCount, getBindingIndex, getCurrentTNode, getElementDepthCount, getLView, getNamespace, getTView, increaseElementDepthCount, isCurrentTNodeParent, setCurrentTNode, setCurrentTNodeAsNotParent} from '../state'; import {computeStaticStyling} from '../styling/static_styling'; import {setUpAttributes} from '../util/attrs_utils'; import {getConstant} from '../util/view_utils'; - import {setDirectiveInputsWhichShadowsStyling} from './property'; -import {createDirectivesInstances, elementCreate, executeContentQueries, getOrCreateTNode, matchingSchemas, resolveDirectives, saveResolvedLocalsInData} from './shared'; +import {createDirectivesInstances, executeContentQueries, getOrCreateTNode, matchingSchemas, resolveDirectives, saveResolvedLocalsInData} from './shared'; function elementStartFirstCreatePass( @@ -78,12 +77,10 @@ export function ɵɵelementStart( assertEqual( getBindingIndex(), tView.bindingStartIndex, 'elements should be created before any bindings'); - ngDevMode && ngDevMode.rendererCreateElement++; ngDevMode && assertIndexInRange(lView, adjustedIndex); const renderer = lView[RENDERER]; - const native = lView[adjustedIndex] = elementCreate(name, renderer, getNamespace()); - + const native = lView[adjustedIndex] = createElementNode(renderer, name, getNamespace()); const tNode = tView.firstCreatePass ? elementStartFirstCreatePass(index, tView, lView, native, name, attrsIndex, localRefsIndex) : tView.data[adjustedIndex] as TElementNode; @@ -102,7 +99,11 @@ export function ɵɵelementStart( writeDirectStyle(renderer, native, styles); } - appendChild(tView, lView, native, tNode); + if ((tNode.flags & TNodeFlags.isDetached) !== TNodeFlags.isDetached) { + // In the i18n case, the translation may have removed this element, so only add it if it is not + // detached. See `TNodeType.Placeholder` and `LFrame.inI18n` for more context. + appendChild(tView, lView, native, tNode); + } // any immediate children of a component or template container must be pre-emptively // monkey-patched with the component view data so that the element can be inspected diff --git a/packages/core/src/render3/instructions/i18n.ts b/packages/core/src/render3/instructions/i18n.ts index 1ae2c7e5b5..ddc9c1bc61 100644 --- a/packages/core/src/render3/instructions/i18n.ts +++ b/packages/core/src/render3/instructions/i18n.ts @@ -10,15 +10,16 @@ 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 {applyCreateOpCodes, applyI18n, setMaskBit} from '../i18n/i18n_apply'; +import {i18nAttributesFirstPass, i18nStartFirstCreatePass} from '../i18n/i18n_parse'; import {i18nPostprocess} from '../i18n/i18n_postprocess'; -import {HEADER_OFFSET} from '../interfaces/view'; -import {getLView, getTView, nextBindingIndex} from '../state'; +import {TI18n} from '../interfaces/i18n'; +import {TElementNode, TNodeType} from '../interfaces/node'; +import {HEADER_OFFSET, T_HOST} from '../interfaces/view'; +import {getClosestRElement} from '../node_manipulation'; +import {getCurrentParentTNode, getLView, getTView, nextBindingIndex, setInI18nBlock} from '../state'; import {getConstant} from '../util/view_utils'; -import {setDelayProjection} from './projection'; - /** * Marks a block of text as translatable. * @@ -34,10 +35,6 @@ import {setDelayProjection} from './projection'; * 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. @@ -48,16 +45,28 @@ import {setDelayProjection} from './projection'; * * @codeGenApi */ -export function ɵɵi18nStart(index: number, messageIndex: number, subTemplateIndex?: number): void { +export function ɵɵi18nStart( + index: number, messageIndex: number, subTemplateIndex: number = -1): void { const tView = getTView(); + const lView = getLView(); ngDevMode && assertDefined(tView, `tView should be defined`); const message = getConstant(tView.consts, messageIndex)!; - 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); + const parentTNode = getCurrentParentTNode() as TElementNode | null; + if (tView.firstCreatePass) { + i18nStartFirstCreatePass( + tView, parentTNode === null ? 0 : parentTNode.index, lView, index, message, + subTemplateIndex); } + const tI18n = tView.data[HEADER_OFFSET + index] as TI18n; + const sameViewParentTNode = parentTNode === lView[T_HOST] ? null : parentTNode; + const parentRNode = getClosestRElement(tView, sameViewParentTNode, lView); + // If `parentTNode` is an `ElementContainer` than it has ``. + // When we do inserts we have to make sure to insert in front of ``. + const insertInFrontOf = parentTNode && parentTNode.type === TNodeType.ElementContainer ? + lView[parentTNode.index] : + null; + applyCreateOpCodes(lView, tI18n.create, parentRNode, insertInFrontOf); + setInI18nBlock(true); } @@ -69,12 +78,7 @@ export function ɵɵi18nStart(index: number, messageIndex: number, subTemplateIn * @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); + setInI18nBlock(false); } /** diff --git a/packages/core/src/render3/instructions/i18n_icu_container_visitor.ts b/packages/core/src/render3/instructions/i18n_icu_container_visitor.ts new file mode 100644 index 0000000000..89b4217929 --- /dev/null +++ b/packages/core/src/render3/instructions/i18n_icu_container_visitor.ts @@ -0,0 +1,93 @@ +/** + * @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 {assertDomNode, assertEqual, assertNumber, assertNumberInRange} from '../../util/assert'; +import {assertTIcu, assertTNodeForLView} from '../assert'; +import {EMPTY_ARRAY} from '../empty'; +import {getCurrentICUCaseIndex, I18nMutateOpCode, I18nMutateOpCodes, TIcu} from '../interfaces/i18n'; +import {TIcuContainerNode} from '../interfaces/node'; +import {RNode} from '../interfaces/renderer'; +import {LView, TVIEW} from '../interfaces/view'; + +export function loadIcuContainerVisitor() { + const _stack: any[] = []; + let _index: number = -1; + let _lView: LView; + let _removes: I18nMutateOpCodes; + + /** + * Retrieves a set of root nodes from `TIcu.remove`. Used by `TNodeType.ICUContainer` + * to determine which root belong to the ICU. + * + * Example of usage. + * ``` + * const nextRNode = icuContainerIteratorStart(tIcuContainerNode, lView); + * let rNode: RNode|null; + * while(rNode = nextRNode()) { + * console.log(rNode); + * } + * ``` + * + * @param tIcuContainerNode Current `TIcuContainerNode` + * @param lView `LView` where the `RNode`s should be looked up. + */ + function icuContainerIteratorStart(tIcuContainerNode: TIcuContainerNode, lView: LView): () => + RNode | null { + _lView = lView; + while (_stack.length) _stack.pop(); + // FIXME(misko): This is a hack which allows us to associate `TI18n` with `TNode`. + // This should be refactored so that one can attach arbitrary data with `TNode` + ngDevMode && assertTNodeForLView(tIcuContainerNode, lView); + const tIcu: TIcu = tIcuContainerNode.tagName as any; + enterIcu(tIcu, lView); + return icuContainerIteratorNext; + } + + function enterIcu(tIcu: TIcu, lView: LView) { + _index = 0; + const currentCase = getCurrentICUCaseIndex(tIcu, lView); + if (currentCase !== null) { + ngDevMode && assertNumberInRange(currentCase, 0, tIcu.cases.length - 1); + _removes = tIcu.remove[currentCase]; + } else { + _removes = EMPTY_ARRAY; + } + } + + + function icuContainerIteratorNext(): RNode|null { + if (_index < _removes.length) { + const removeOpCode = _removes[_index++] as number; + ngDevMode && assertNumber(removeOpCode, 'Expecting OpCode number'); + const opCode = removeOpCode & I18nMutateOpCode.MASK_INSTRUCTION; + if (opCode === I18nMutateOpCode.Remove) { + const rNode = _lView[removeOpCode >>> I18nMutateOpCode.SHIFT_REF]; + ngDevMode && assertDomNode(rNode); + return rNode; + } else { + ngDevMode && + assertEqual(opCode, I18nMutateOpCode.RemoveNestedIcu, 'Expecting RemoveNestedIcu'); + _stack.push(_index, _removes); + const tIcu = _lView[TVIEW].data[removeOpCode >>> I18nMutateOpCode.SHIFT_REF] as TIcu; + ngDevMode && assertTIcu(tIcu); + enterIcu(tIcu, _lView); + return icuContainerIteratorNext(); + } + } else { + if (_stack.length === 0) { + return null; + } else { + _removes = _stack.pop(); + _index = _stack.pop(); + return icuContainerIteratorNext(); + } + } + } + + return icuContainerIteratorStart; +} diff --git a/packages/core/src/render3/instructions/lview_debug.ts b/packages/core/src/render3/instructions/lview_debug.ts index 17f6d56c9d..de52469d60 100644 --- a/packages/core/src/render3/instructions/lview_debug.ts +++ b/packages/core/src/render3/instructions/lview_debug.ts @@ -17,7 +17,7 @@ import {getInjectorIndex} from '../di'; import {CONTAINER_HEADER_OFFSET, HAS_TRANSPLANTED_VIEWS, LContainer, MOVED_VIEWS, NATIVE} from '../interfaces/container'; import {ComponentTemplate, DirectiveDef, DirectiveDefList, PipeDefList, ViewQueriesFunction} from '../interfaces/definition'; import {NO_PARENT_INJECTOR, NodeInjectorOffset} from '../interfaces/injector'; -import {AttributeMarker, PropertyAliases, TConstants, TContainerNode, TElementNode, TNode as ITNode, TNodeFlags, TNodeProviderIndexes, TNodeType, TNodeTypeAsString} from '../interfaces/node'; +import {AttributeMarker, InsertBeforeIndex, PropertyAliases, TConstants, TContainerNode, TElementNode, TNode as ITNode, TNodeFlags, TNodeProviderIndexes, TNodeType, TNodeTypeAsString} from '../interfaces/node'; import {SelectorFlags} from '../interfaces/projection'; import {LQueries, TQueries} from '../interfaces/query'; import {RComment, RElement, Renderer3, RendererFactory3, RNode} from '../interfaces/renderer'; @@ -160,7 +160,13 @@ export const TViewConstructor = class TView implements ITView { return TViewTypeAsString[this.type] || `TViewType.?${this.type}?`; } - get i18nStartIndex(): number { + /** + * Returns initial value of `expandoStartIndex`. + */ + // FIXME(misko): `originalExpandoStartIndex` should not be needed because it should be the same as + // `expandoStartIndex`. However `expandoStartIndex` is misnamed as it changes as more items get + // allocated in expando. + get originalExpandoStartIndex(): number { return HEADER_OFFSET + this._decls + this._vars; } }; @@ -170,6 +176,7 @@ class TNode implements ITNode { public tView_: TView, // public type: TNodeType, // public index: number, // + public insertBeforeIndex: InsertBeforeIndex, // public injectorIndex: number, // public directiveStart: number, // public directiveEnd: number, // @@ -249,8 +256,13 @@ class TNode implements ITNode { } get template_(): string { + if (this.tagName === null && this.type === TNodeType.Element) return '#text'; const buf: string[] = []; - buf.push('<', this.tagName || this.type_); + const tagName = typeof this.tagName === 'string' && this.tagName || this.type_; + buf.push('<', tagName); + if (this.flags) { + buf.push(' ', this.flags_); + } if (this.attrs) { for (let i = 0; i < this.attrs.length;) { const attrName = this.attrs[i++]; @@ -263,7 +275,7 @@ class TNode implements ITNode { } buf.push('>'); processTNodeChildren(this.child, buf); - buf.push(''); + buf.push(''); return buf.join(''); } @@ -444,7 +456,7 @@ export class LViewDebug implements ILViewDebug { return toHtml(this._raw_lView[HOST], true); } get html(): string { - return (this.nodes || []).map(node => toHtml(node.native, true)).join(''); + return (this.nodes || []).map(mapToHTML).join(''); } get context(): {}|null { return this._raw_lView[CONTEXT]; @@ -458,7 +470,9 @@ export class LViewDebug implements ILViewDebug { const tNode = lView[TVIEW].firstChild; return toDebugNodes(tNode, lView); } - + get template(): string { + return (this.tView as any as {template_: string}).template_; + } get tView(): ITView { return this._raw_lView[TVIEW]; } @@ -504,20 +518,15 @@ export class LViewDebug implements ILViewDebug { const tView = this.tView; return toLViewRange( tView, this._raw_lView, tView.bindingStartIndex, - (tView as any as {i18nStartIndex: number}).i18nStartIndex); - } - - get i18n(): LViewDebugRange { - const tView = this.tView; - return toLViewRange( - tView, this._raw_lView, (tView as any as {i18nStartIndex: number}).i18nStartIndex, - tView.expandoStartIndex); + (tView as any as {originalExpandoStartIndex: number}).originalExpandoStartIndex); } get expando(): LViewDebugRange { const tView = this.tView as any as {_decls: number, _vars: number}; return toLViewRange( - this.tView, this._raw_lView, this.tView.expandoStartIndex, this._raw_lView.length); + this.tView, this._raw_lView, + (tView as any as {originalExpandoStartIndex: number}).originalExpandoStartIndex, + this._raw_lView.length); } /** @@ -534,6 +543,16 @@ export class LViewDebug implements ILViewDebug { } } +function mapToHTML(node: DebugNode): string { + if (node.type === 'ElementContainer') { + return (node.children || []).map(mapToHTML).join(''); + } else if (node.type === 'IcuContainer') { + throw new Error('Not implemented'); + } else { + return toHtml(node.native, true) || ''; + } +} + function toLViewRange(tView: TView, lView: LView, start: number, end: number): LViewDebugRange { let content: LViewDebugRangeContent[] = []; for (let index = start; index < end; index++) { diff --git a/packages/core/src/render3/instructions/projection.ts b/packages/core/src/render3/instructions/projection.ts index 4c82c4c103..4b4f56d38c 100644 --- a/packages/core/src/render3/instructions/projection.ts +++ b/packages/core/src/render3/instructions/projection.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ import {newArray} from '../../util/array_utils'; -import {TAttributes, TElementNode, TNode, TNodeType} from '../interfaces/node'; +import {TAttributes, TElementNode, TNode, TNodeFlags, TNodeType} from '../interfaces/node'; import {ProjectionSlots} from '../interfaces/projection'; import {DECLARATION_COMPONENT_VIEW, T_HOST} from '../interfaces/view'; import {applyProjection} from '../node_manipulation'; @@ -103,11 +103,6 @@ export function ɵɵprojectionDef(projectionSlots?: ProjectionSlots): void { } } -let delayProjection = false; -export function setDelayProjection(value: boolean) { - delayProjection = value; -} - /** * Inserts previously re-distributed projected nodes. This instruction must be preceded by a call @@ -133,8 +128,7 @@ export function ɵɵprojection( // `` has no content setCurrentTNodeAsNotParent(); - // We might need to delay the projection of nodes if they are in the middle of an i18n block - if (!delayProjection) { + if ((tProjectionNode.flags & TNodeFlags.isDetached) !== TNodeFlags.isDetached) { // re-distribution of projectable nodes is stored on a component's view level applyProjection(tView, lView, tProjectionNode); } diff --git a/packages/core/src/render3/instructions/shared.ts b/packages/core/src/render3/instructions/shared.ts index 863a3832f8..54c2ceb813 100644 --- a/packages/core/src/render3/instructions/shared.ts +++ b/packages/core/src/render3/instructions/shared.ts @@ -5,43 +5,43 @@ * 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 {Injector} from '../../di'; -import {ErrorHandler} from '../../error_handler'; -import {DoCheck, OnChanges, OnInit} from '../../interface/lifecycle_hooks'; -import {CUSTOM_ELEMENTS_SCHEMA, NO_ERRORS_SCHEMA, SchemaMetadata} from '../../metadata/schema'; -import {ViewEncapsulation} from '../../metadata/view'; -import {validateAgainstEventAttributes, validateAgainstEventProperties} from '../../sanitization/sanitization'; -import {Sanitizer} from '../../sanitization/sanitizer'; -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'; -import {stringify} from '../../util/stringify'; -import {assertFirstCreatePass, assertLContainer, assertLView, assertTNodeForLView} from '../assert'; -import {attachPatchData} from '../context_discovery'; -import {getFactoryDef} from '../definition'; -import {diPublicInInjector, getNodeInjectable, getOrCreateNodeInjectorForNode} from '../di'; -import {throwMultipleComponentError} from '../errors'; -import {executeCheckHooks, executeInitAndCheckHooks, incrementInitPhaseFlags} from '../hooks'; -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 {NodeInjectorFactory, NodeInjectorOffset} from '../interfaces/injector'; -import {AttributeMarker, InitialInputData, InitialInputs, LocalRefExtractor, PropertyAliases, PropertyAliasValue, TAttributes, TConstantsOrFactory, TContainerNode, TDirectiveHostNode, TElementContainerNode, TElementNode, TIcuContainerNode, TNode, TNodeFlags, TNodeProviderIndexes, TNodeType, TProjectionNode} from '../interfaces/node'; -import {isProceduralRenderer, RComment, RElement, Renderer3, RendererFactory3, RNode, RText} from '../interfaces/renderer'; -import {SanitizerFn} from '../interfaces/sanitization'; -import {isComponentDef, isComponentHost, isContentQueryHost, isRootView} from '../interfaces/type_checks'; -import {CHILD_HEAD, CHILD_TAIL, CLEANUP, CONTEXT, DECLARATION_COMPONENT_VIEW, DECLARATION_VIEW, FLAGS, HEADER_OFFSET, HOST, InitPhaseState, INJECTOR, LView, LViewFlags, NEXT, PARENT, RENDERER, RENDERER_FACTORY, RootContext, RootContextFlags, SANITIZER, T_HOST, TData, TRANSPLANTED_VIEWS_TO_REFRESH, TVIEW, TView, TViewType} from '../interfaces/view'; -import {assertNodeNotOfTypes, assertNodeOfPossibleTypes} from '../node_assert'; -import {isInlineTemplate, isNodeMatchingSelectorList} from '../node_selector_matcher'; -import {enterView, getBindingsEnabled, getCurrentDirectiveIndex, getCurrentTNode, getSelectedIndex, isCurrentTNodeParent, isInCheckNoChangesMode, leaveView, setBindingIndex, setBindingRootForHostBindings, setCurrentDirectiveIndex, setCurrentQueryIndex, setCurrentTNode, setIsInCheckNoChangesMode, setSelectedIndex} from '../state'; -import {NO_CHANGE} from '../tokens'; -import {isAnimationProp, mergeHostAttrs} from '../util/attrs_utils'; -import {INTERPOLATION_DELIMITER, renderStringify, stringifyForError} from '../util/misc_utils'; -import {getFirstLContainer, getLViewParent, getNextLContainer} from '../util/view_traversal_utils'; -import {getComponentLViewByIndex, getNativeByIndex, getNativeByTNode, isCreationMode, readPatchedLView, resetPreOrderHookFlags, unwrapLView, updateTransplantedViewCount, viewAttachedToChangeDetector} from '../util/view_utils'; - -import {selectIndexInternal} from './advance'; -import {attachLContainerDebug, attachLViewDebug, cloneToLViewFromTViewBlueprint, cloneToTViewData, LCleanup, LViewBlueprint, MatchesArray, TCleanup, TNodeDebug, TNodeInitialInputs, TNodeLocalNames, TViewComponents, TViewConstructor} from './lview_debug'; +import { Injector } from '../../di'; +import { ErrorHandler } from '../../error_handler'; +import { DoCheck, OnChanges, OnInit } from '../../interface/lifecycle_hooks'; +import { CUSTOM_ELEMENTS_SCHEMA, NO_ERRORS_SCHEMA, SchemaMetadata } from '../../metadata/schema'; +import { ViewEncapsulation } from '../../metadata/view'; +import { validateAgainstEventAttributes, validateAgainstEventProperties } from '../../sanitization/sanitization'; +import { Sanitizer } from '../../sanitization/sanitizer'; +import { assertDefined, assertDomNode, assertEqual, assertGreaterThan, assertIndexInRange, assertLessThan, assertNotEqual, assertNotSame, assertSame, assertString } from '../../util/assert'; +import { createNamedArrayType } from '../../util/named_array_type'; +import { initNgDevMode } from '../../util/ng_dev_mode'; +import { normalizeDebugBindingName, normalizeDebugBindingValue } from '../../util/ng_reflect'; +import { stringify } from '../../util/stringify'; +import { assertFirstCreatePass, assertFirstUpdatePass, assertLContainer, assertLView, assertTNodeForLView, assertTNodeForTView } from '../assert'; +import { attachPatchData } from '../context_discovery'; +import { getFactoryDef } from '../definition'; +import { diPublicInInjector, getNodeInjectable, getOrCreateNodeInjectorForNode } from '../di'; +import { throwMultipleComponentError } from '../errors'; +import { executeCheckHooks, executeInitAndCheckHooks, incrementInitPhaseFlags } from '../hooks'; +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 { NodeInjectorFactory, NodeInjectorOffset } from '../interfaces/injector'; +import { AttributeMarker, InitialInputData, InitialInputs, LocalRefExtractor, PropertyAliases, PropertyAliasValue, TAttributes, TConstantsOrFactory, TContainerNode, TDirectiveHostNode, TElementContainerNode, TElementNode, TIcuContainerNode, TNode, TNodeFlags, TNodeProviderIndexes, TNodeType, TProjectionNode } from '../interfaces/node'; +import { isProceduralRenderer, RComment, RElement, Renderer3, RendererFactory3, RNode, RText } from '../interfaces/renderer'; +import { SanitizerFn } from '../interfaces/sanitization'; +import { isComponentDef, isComponentHost, isContentQueryHost, isRootView } from '../interfaces/type_checks'; +import { CHILD_HEAD, CHILD_TAIL, CLEANUP, CONTEXT, DECLARATION_COMPONENT_VIEW, DECLARATION_VIEW, FLAGS, HEADER_OFFSET, HOST, InitPhaseState, INJECTOR, LView, LViewFlags, NEXT, PARENT, RENDERER, RENDERER_FACTORY, RootContext, RootContextFlags, SANITIZER, TData, TRANSPLANTED_VIEWS_TO_REFRESH, TVIEW, TView, TViewType, T_HOST } from '../interfaces/view'; +import { assertNodeNotOfTypes, assertNodeOfPossibleTypes } from '../node_assert'; +import { updateTextNode } from '../node_manipulation'; +import { isInlineTemplate, isNodeMatchingSelectorList } from '../node_selector_matcher'; +import { enterView, getBindingsEnabled, getCurrentDirectiveIndex, getCurrentParentTNode, getCurrentTNode, getCurrentTNodePlaceholderOk, getSelectedIndex, isCurrentTNodeParent, isInCheckNoChangesMode, isInI18nBlock, leaveView, setBindingIndex, setBindingRootForHostBindings, setCurrentDirectiveIndex, setCurrentQueryIndex, setCurrentTNode, setIsInCheckNoChangesMode, setSelectedIndex } from '../state'; +import { NO_CHANGE } from '../tokens'; +import { isAnimationProp, mergeHostAttrs } from '../util/attrs_utils'; +import { INTERPOLATION_DELIMITER, renderStringify, stringifyForError } from '../util/misc_utils'; +import { getFirstLContainer, getLViewParent, getNextLContainer } from '../util/view_traversal_utils'; +import { getComponentLViewByIndex, getNativeByIndex, getNativeByTNode, isCreationMode, readPatchedLView, resetPreOrderHookFlags, unwrapLView, updateTransplantedViewCount, viewAttachedToChangeDetector } from '../util/view_utils'; +import { selectIndexInternal } from './advance'; +import { attachLContainerDebug, attachLViewDebug, cloneToLViewFromTViewBlueprint, cloneToTViewData, LCleanup, LViewBlueprint, MatchesArray, TCleanup, TNodeDebug, TNodeInitialInputs, TNodeLocalNames, TViewComponents, TViewConstructor } from './lview_debug'; @@ -155,21 +155,6 @@ function renderChildComponents(hostLView: LView, components: number[]): void { } } -/** - * Creates a native element from a tag name, using a renderer. - * @param name the tag name - * @param renderer A renderer to use - * @returns the element created - */ -export function elementCreate(name: string, renderer: Renderer3, namespace: string|null): RElement { - if (isProceduralRenderer(renderer)) { - return renderer.createElement(name, namespace); - } else { - return namespace === null ? renderer.createElement(name) : - renderer.createElementNS(namespace, name); - } -} - export function createLView( parentLView: LView|null, tView: TView, context: T|null, flags: LViewFlags, host: RElement|null, tHostNode: TNode|null, rendererFactory: RendererFactory3|null, renderer: Renderer3|null, @@ -230,17 +215,34 @@ export function getOrCreateTNode( TElementNode&TContainerNode&TElementContainerNode&TProjectionNode&TIcuContainerNode { // Keep this function short, so that the VM will inline it. const adjustedIndex = index + HEADER_OFFSET; - const tNode = tView.data[adjustedIndex] as TNode || - createTNodeAtIndex(tView, adjustedIndex, type, name, attrs); + let tNode = tView.data[adjustedIndex] as TNode; + if (tNode === null) { + tNode = createTNodeAtIndex(tView, adjustedIndex, type, name, attrs); + if (isInI18nBlock()) { + // If we are in i18n block then all elements should be pre declared through `Placeholder` + // See `TNodeType.Placeholder` and `LFrame.inI18n` for more context. + // If the `TNode` was not pre-declared than it means it was not mentioned which means it was + // removed, so we mark it as detached. + tNode.flags |= TNodeFlags.isDetached; + } + } else if (tNode.type == TNodeType.Placeholder) { + tNode.type = type; + tNode.tagName = name; + tNode.attrs = attrs; + const parent = getCurrentParentTNode(); + tNode.injectorIndex = parent === null ? -1 : parent.injectorIndex; + ngDevMode && assertTNodeForTView(tNode, tView); + ngDevMode && assertEqual(index + HEADER_OFFSET, tNode.index, 'Expecting same index'); + } setCurrentTNode(tNode, true); return tNode as TElementNode & TContainerNode & TElementContainerNode & TProjectionNode & TIcuContainerNode; } -function createTNodeAtIndex( +export function createTNodeAtIndex( tView: TView, adjustedIndex: number, type: TNodeType, name: string|null, attrs: TAttributes|null) { - const currentTNode = getCurrentTNode(); + const currentTNode = getCurrentTNodePlaceholderOk(); const isParent = isCurrentTNodeParent(); const parent = isParent ? currentTNode : currentTNode && currentTNode.parent; // Parents cannot cross component boundaries because components will be used in multiple places. @@ -253,11 +255,18 @@ function createTNodeAtIndex( tView.firstChild = tNode; } if (currentTNode !== null) { - if (isParent && currentTNode.child == null && tNode.parent !== null) { - // We are in the same view, which means we are adding content node to the parent view. - currentTNode.child = tNode; - } else if (!isParent) { - currentTNode.next = tNode; + if (isParent) { + // FIXME(misko): This logic looks unnecessarily complicated. Could we simplify? + if (currentTNode.child == null && tNode.parent !== null) { + // We are in the same view, which means we are adding content node to the parent view. + currentTNode.child = tNode; + } + } else { + if (currentTNode.next === null) { + // In the case of i18n the `currentTNode` may already be linked, in which case we don't want + // to break the links which i18n created. + currentTNode.next = tNode; + } } } return tNode; @@ -266,36 +275,40 @@ function createTNodeAtIndex( /** * When elements are created dynamically after a view blueprint is created (e.g. through - * i18nApply() or ComponentFactory.create), we need to adjust the blueprint for future + * i18nApply()), we need to adjust the blueprint for future * template passes. * * @param tView `TView` associated with `LView` - * @param view The `LView` containing the blueprint to adjust + * @param lView The `LView` containing the blueprint to adjust * @param numSlotsToAlloc The number of slots to alloc in the LView, should be >0 */ -export function allocExpando(tView: TView, lView: LView, numSlotsToAlloc: number) { - ngDevMode && - assertGreaterThan( - numSlotsToAlloc, 0, 'The number of slots to alloc should be greater than 0'); - if (numSlotsToAlloc > 0) { - if (tView.firstCreatePass) { - for (let i = 0; i < numSlotsToAlloc; i++) { - tView.blueprint.push(null); - tView.data.push(null); - lView.push(null); - } - - // We should only increment the expando start index if there aren't already directives - // and injectors saved in the "expando" section - if (!tView.expandoInstructions) { - tView.expandoStartIndex += numSlotsToAlloc; - } else { - // Since we're adding the dynamic nodes into the expando section, we need to let the host - // bindings know that they should skip x slots - tView.expandoInstructions.push(numSlotsToAlloc); - } - } +export function allocExpando(tView: TView, lView: LView, numSlotsToAlloc: number): number { + if (ngDevMode) { + assertGreaterThan(numSlotsToAlloc, 0, 'The number of slots to alloc should be greater than 0'); + assertEqual(tView.data.length, lView.length, 'Expecting LView to be same size as TView'); + assertEqual( + tView.data.length, tView.blueprint.length, 'Expecting Blueprint to be same size as TView'); + assertFirstUpdatePass(tView); } + const allocIdx = lView.length; + for (let i = 0; i < numSlotsToAlloc; i++) { + tView.blueprint.push(null); + tView.data.push(null); + lView.push(null); + } + + // We should only increment the expando start index if there aren't already directives + // and injectors saved in the "expando" section + if (!tView.expandoInstructions) { + tView.expandoStartIndex += numSlotsToAlloc; + } else { + // Since we're adding the dynamic nodes into the expando section, we need to let the host + // bindings know that they should skip x slots + // FIXME(misko): Refactor `expandoInstructions` so that it does not rely on relative binding + // offsets, but absolute values which Means we would not have to store it here. + tView.expandoInstructions.push(numSlotsToAlloc); + } + return allocIdx; } @@ -824,12 +837,14 @@ export function createTNode( tagName: string|null, attrs: TAttributes|null): TNode { ngDevMode && assertNotSame(attrs, undefined, '\'undefined\' is not valid value for \'attrs\''); ngDevMode && ngDevMode.tNode++; + ngDevMode && tParent && assertTNodeForTView(tParent, tView); let injectorIndex = tParent ? tParent.injectorIndex : -1; const tNode = ngDevMode ? new TNodeDebug( tView, // tView_: TView type, // type: TNodeType adjustedIndex, // index: number + null, // insertBeforeIndex: null|-1|number|number[] injectorIndex, // injectorIndex: number -1, // directiveStart: number -1, // directiveEnd: number @@ -862,6 +877,7 @@ export function createTNode( { type: type, index: adjustedIndex, + insertBeforeIndex: null, injectorIndex: injectorIndex, directiveStart: -1, directiveEnd: -1, @@ -1509,7 +1525,12 @@ export function elementAttributeInternal( `Host bindings are not valid on ng-container or ng-template.`); } const element = getNativeByTNode(tNode, lView) as RElement; - const renderer = lView[RENDERER]; + setElementAttribute(lView[RENDERER], element, namespace, tNode.tagName, name, value, sanitizer); +} + +export function setElementAttribute( + renderer: Renderer3, element: RElement, namespace: string|null|undefined, tagName: string|null, + name: string, value: any, sanitizer: SanitizerFn|null|undefined) { if (value == null) { ngDevMode && ngDevMode.rendererRemoveAttribute++; isProceduralRenderer(renderer) ? renderer.removeAttribute(element, name, namespace) : @@ -1517,7 +1538,7 @@ export function elementAttributeInternal( } else { ngDevMode && ngDevMode.rendererSetAttribute++; const strValue = - sanitizer == null ? renderStringify(value) : sanitizer(value, tNode.tagName || '', name); + sanitizer == null ? renderStringify(value) : sanitizer(value, tagName || '', name); if (isProceduralRenderer(renderer)) { @@ -2065,11 +2086,10 @@ export function setInputsForProperty( * Updates a text binding at a given index in a given LView. */ export function textBindingInternal(lView: LView, index: number, value: string): void { + ngDevMode && assertString(value, 'Value should be a string'); ngDevMode && assertNotSame(value, NO_CHANGE as any, 'value should not be NO_CHANGE'); 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++; - const renderer = lView[RENDERER]; - isProceduralRenderer(renderer) ? renderer.setValue(element, value) : element.textContent = value; + updateTextNode(lView[RENDERER], element, value); } diff --git a/packages/core/src/render3/instructions/text.ts b/packages/core/src/render3/instructions/text.ts index 3571d8d5cc..5dc5fdacbf 100644 --- a/packages/core/src/render3/instructions/text.ts +++ b/packages/core/src/render3/instructions/text.ts @@ -38,7 +38,7 @@ export function ɵɵtext(index: number, value: string = ''): void { getOrCreateTNode(tView, index, TNodeType.Element, null, null) : tView.data[adjustedIndex] as TElementNode; - const textNative = lView[adjustedIndex] = createTextNode(value, lView[RENDERER]); + const textNative = lView[adjustedIndex] = createTextNode(lView[RENDERER], value); appendChild(tView, lView, textNative, tNode); // Text nodes are self closing. diff --git a/packages/core/src/render3/interfaces/i18n.ts b/packages/core/src/render3/interfaces/i18n.ts index 7270e0ece9..3b93b1b055 100644 --- a/packages/core/src/render3/interfaces/i18n.ts +++ b/packages/core/src/render3/interfaces/i18n.ts @@ -6,7 +6,11 @@ * found in the LICENSE file at https://angular.io/license */ +import {assertGreaterThan, assertGreaterThanOrEqual} from '../../util/assert'; +import {TIcuContainerNode} from './node'; +import {RNode} from './renderer'; import {SanitizerFn} from './sanitization'; +import {LView} from './view'; /** * `I18nMutateOpCode` defines OpCodes for `I18nMutateOpCodes` array. @@ -43,6 +47,7 @@ export const enum I18nMutateOpCode { /** * Mask for OpCode */ + // FIXME(misko): Shrink mask to 2 bits as 4 choices can fit into two bits. MASK_INSTRUCTION = 0b111, /** @@ -53,11 +58,6 @@ export const enum I18nMutateOpCode { // 11111110000000000 // 65432109876543210 - /** - * Instruction to select a node. (next OpCode will contain the operation.) - */ - Select = 0b000, - /** * Instruction to append the current node to `PARENT`. */ @@ -73,29 +73,37 @@ export const enum I18nMutateOpCode { */ Attr = 0b100, - /** - * Instruction to simulate elementEnd() - */ - ElementEnd = 0b101, - /** * Instruction to removed the nested ICU. */ RemoveNestedIcu = 0b110, } +// FIXME(misko): These function are technically not interfaces, and so we may consider moving them +// elsewhere. + +// FIXME(misko): rename to `getParentFromI18nCreateOpCode` export function getParentFromI18nMutateOpCode(mergedCode: number): number { return mergedCode >>> I18nMutateOpCode.SHIFT_PARENT; } +// FIXME(misko): rename to `getRefFromI18nCreateOpCode` export function getRefFromI18nMutateOpCode(mergedCode: number): number { return (mergedCode & I18nMutateOpCode.MASK_REF) >>> I18nMutateOpCode.SHIFT_REF; } +// FIXME(misko): rename to `getInstructionFromI18nCreateOpCode` export function getInstructionFromI18nMutateOpCode(mergedCode: number): number { return mergedCode & I18nMutateOpCode.MASK_INSTRUCTION; } +// FIXME(misko): rename to `i18nCreateOpCode` +export function i18nMutateOpCode(opCode: I18nMutateOpCode, parentIdx: number, refIdx: number) { + ngDevMode && assertGreaterThanOrEqual(parentIdx, 0, 'Missing parent index'); + ngDevMode && assertGreaterThan(refIdx, 0, 'Missing ref index'); + return opCode | parentIdx << I18nMutateOpCode.SHIFT_PARENT | refIdx << I18nMutateOpCode.SHIFT_REF; +} + /** * Marks that the next string is an element name. * @@ -113,6 +121,7 @@ export interface ELEMENT_MARKER { * * See `I18nMutateOpCodes` documentation. */ +// FIXME(misko): Rename to ICU marker export const COMMENT_MARKER: COMMENT_MARKER = { marker: 'comment' }; @@ -132,6 +141,62 @@ export interface I18nDebug { debug?: string[]; } +/** + * Array storing OpCode for dynamically creating `i18n` translation DOM elements. + * + * This array creates a sequence of `Text` and `Comment` (as ICU anchor) DOM elements. It consists + * of a pair of `number` and `string` pairs which encode the operations for the creation of the + * translated block. + * + * The number is shifted and encoded according to `I18nCreateOpCode` + * + * Pseudocode: + * ``` + * const i18nCreateOpCodes = [ + * 10 << I18nCreateOpCode.SHIFT, "Text Node add to DOM", + * 11 << I18nCreateOpCode.SHIFT | I18nCreateOpCode.COMMENT, "Comment Node add to DOM", + * 12 << I18nCreateOpCode.SHIFT | I18nCreateOpCode.APPEND_LATER, "Text Node added later" + * ]; + * + * for(var i=0; i> I18nCreateOpCode.SHIFT; + * const text = i18NCreateOpCodes[i]; + * let node: Text|Comment; + * if (opcode & I18nCreateOpCode.COMMENT === I18nCreateOpCode.COMMENT) { + * node = lView[~index] = document.createComment(text); + * } else { + * node = lView[index] = document.createText(text); + * } + * if (opcode & I18nCreateOpCode.APPEND_EAGERLY !== I18nCreateOpCode.APPEND_EAGERLY) { + * parentNode.appendChild(node); + * } + * } + * ``` + */ +export interface I18nCreateOpCodes extends Array, I18nDebug {} + +/** + * See `I18nCreateOpCodes` + */ +export enum I18nCreateOpCode { + /** + * Number of bits to shift index so that it can be combined with the `APPEND_EAGERLY` and + * `COMMENT`. + */ + SHIFT = 2, + + /** + * Should the node be appended to parent imedditatly after creation. + */ + APPEND_EAGERLY = 0b01, + + /** + * If set the node should be comment (rather than a text) node. + */ + COMMENT = 0b10, +} + /** * Array storing OpCode for dynamically creating `i18n` blocks. @@ -289,38 +354,18 @@ export interface I18nUpdateOpCodes extends Array * Store information for the i18n translation block. */ export interface TI18n { - /** - * Number of slots to allocate in expando. - * - * 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; - /** * A set of OpCodes which will create the Text Nodes and ICU anchors for the translation blocks. * * NOTE: The ICU anchors are filled in with ICU Update OpCode. */ - create: I18nMutateOpCodes; + create: I18nCreateOpCodes; /** * A set of OpCodes which will be executed on each change detection to determine if any changes to * DOM are required. */ update: I18nUpdateOpCodes; - - /** - * A list of ICUs in a translation block (or `null` if block has no ICUs). - * - * Example: - * Given: `
You have {count, plural, ...} and {state, switch, ...}
` - * There would be 2 ICUs in this array. - * 1. `{count, plural, ...}` - * 2. `{state, switch, ...}` - */ - icus: TIcu[]|null; } /** @@ -338,53 +383,24 @@ export interface TIcu { type: IcuType; /** - * Number of slots to allocate in expando for each 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. + * Index in `LView` where the anchor node is stored. `` */ - vars: number[]; + anchorIdx: 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. + * + * If the value stored is: + * `null`: No current case selected. + * `<0`: A flag which means that the ICU just switched and that `icuUpdate` must be executed + * regardless of the `mask`. (After the execution the flag is cleared) + * `>=0` A currently selected case index. */ currentCaseLViewIndex: number; - /** - * An optional array of child/sub ICUs. - * - * In case of nested ICUs such as: - * ``` - * {�0�, plural, - * =0 {zero} - * other {�0� {�1�, select, - * cat {cats} - * dog {dogs} - * other {animals} - * }! - * } - * } - * ``` - * When the parent ICU is changing it must clean up child ICUs as well. For this reason it needs - * to know which child ICUs to run clean up for as well. - * - * In the above example this would be: - * ```ts - * [ - * [], // `=0` has no sub ICUs - * [1], // `other` has one subICU at `1`st index. - * ] - * ``` - * - * The reason why it is Array of Arrays is because first array represents the case, and second - * represents the child ICUs to clean up. There may be more than one child ICUs per case. - */ - childIcus: number[][]; - /** * A list of case values which the current ICU will try to match. * @@ -395,11 +411,13 @@ export interface TIcu { /** * A set of OpCodes to apply in order to build up the DOM render tree for the ICU */ + // FIXME(misko): Rename `I18nMutateOpCodes` to `I18nCreateOpCodes`. create: I18nMutateOpCodes[]; /** * A set of OpCodes to apply in order to destroy the DOM render tree for the ICU. */ + // FIXME(misko): Rename `I18nMutateOpCodes` to `I18nRemoveOpCodes`. remove: I18nMutateOpCodes[]; /** @@ -412,6 +430,9 @@ export interface TIcu { // failure based on types. export const unusedValueExportToPlacateAjd = 1; +/** + * Parsed ICU expression + */ export interface IcuExpression { type: IcuType; mainBinding: number; @@ -419,33 +440,39 @@ export interface IcuExpression { 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; +let _icuContainerIterate: (tIcuContainerNode: TIcuContainerNode, lView: LView) => + (() => RNode | null); - /** - * 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; +/** + * Iterator which provides ability to visit all of the `TIcuContainerNode` root `RNode`s. + */ +export function icuContainerIterate(tIcuContainerNode: TIcuContainerNode, lView: LView): () => + RNode | null { + return _icuContainerIterate(tIcuContainerNode, lView); } + +/** + * Ensures that `IcuContainerVisitor`'s implementation is present. + * + * This function is invoked when i18n instruction comes across an ICU. The purpose is to allow the + * bundler to tree shake ICU logic and only load it if ICU instruction is executed. + */ +export function ensureIcuContainerVisitorLoaded( + loader: () => ((tIcuContainerNode: TIcuContainerNode, lView: LView) => (() => RNode | null))) { + if (_icuContainerIterate === undefined) { + // Do not inline this function. We want to keep `ensureIcuContainerVisitorLoaded` light, so it + // can be inlined into call-site. + _icuContainerIterate = loader(); + } +} + + +/** + * Returns current ICU case. + * + * We store negative numbers for cases which have just been switched. This function removes that. + */ +export function getCurrentICUCaseIndex(tIcu: TIcu, lView: LView) { + const currentCase: number|null = lView[tIcu.currentCaseLViewIndex]; + return currentCase === null ? currentCase : (currentCase < 0 ? ~currentCase : currentCase); +} \ No newline at end of file diff --git a/packages/core/src/render3/interfaces/node.ts b/packages/core/src/render3/interfaces/node.ts index 67b6efd229..a8b71f46d7 100644 --- a/packages/core/src/render3/interfaces/node.ts +++ b/packages/core/src/render3/interfaces/node.ts @@ -7,6 +7,7 @@ */ import {KeyValueArray} from '../../util/array_utils'; import {TStylingRange} from '../interfaces/styling'; +import {TIcu} from './i18n'; import {CssSelector} from './projection'; import {RNode} from './renderer'; import {LView, TView} from './view'; @@ -16,9 +17,11 @@ import {LView, TView} from './view'; * TNodeType corresponds to the {@link TNode} `type` property. */ export const enum TNodeType { + // FIXME(misko): Add `Text` type so that it would be much easier to reason/debug about `TNode`s. /** * The TNode contains information about an {@link LContainer} for embedded views. */ + // FIXME(misko): Verify that we still need a `Container`, at the very least update the text. Container = 0, /** * The TNode contains information about an `` projection @@ -36,6 +39,20 @@ export const enum TNodeType { * The TNode contains information about an ICU comment used in `i18n`. */ IcuContainer = 4, + /** + * Special node type representing a placeholder for future `TNode` at this location. + * + * I18n translation blocks are created before the element nodes which they contain. (I18n blocks + * can span over many elements.) Because i18n `TNode`s (representing text) are created first they + * often may need to point to element `TNode`s which are not yet created. In such a case we create + * a `Placeholder` `TNode`. This allows the i18n to structurally link the `TNode`s together + * without knowing any information about the future nodes which will be at that location. + * + * On `firstCreatePass` When element instruction executes it will try to create a `TNode` at that + * location. Seeing a `Placeholder` `TNode` already there tells the system that it should reuse + * existing `TNode` (rather than create a new one) and just update the missing information. + */ + Placeholder = 5, } /** @@ -47,7 +64,8 @@ export const TNodeTypeAsString = [ 'Projection', // 1 'Element', // 2 'ElementContainer', // 3 - 'IcuContainer' // 4 + 'IcuContainer', // 4 + 'Placeholder', // 5 ] as const; @@ -293,6 +311,59 @@ export interface TNode { */ index: number; + /** + * Insert before existing DOM node index. + * + * When DOM nodes are being inserted, normally they are being appended as they are created. + * Under i18n case, the translated text nodes are created ahead of time as part of the + * `ɵɵi18nStart` instruction which means that this `TNode` can't just be appended and instead + * needs to be inserted using `insertBeforeIndex` semantics. + * + * Additionally sometimes it is necessary to insert new text nodes as a child of this `TNode`. In + * such a case the value stores an array of text nodes to insert. + * + * Example: + * ``` + *
+ * Hello World! + *
+ * ``` + * In the above example the `ɵɵi18nStart` instruction can create `Hello `, `World` and `!` text + * nodes. It can also insert `Hello ` and `!` text node as a child of `
`, but it can't + * insert `World` because the `` node has not yet been created. In such a case the + * `` `TNode` will have an array which will direct the `` to not only insert + * itself in front of `!` but also to insert the `World` (created by `ɵɵi18nStart`) into `` + * itself. + * + * Pseudo code: + * ``` + * if (insertBeforeIndex === null) { + * // append as normal + * } else if (Array.isArray(insertBeforeIndex)) { + * // First insert current `TNode` at correct location + * const currentNode = lView[this.index]; + * parentNode.insertBefore(currentNode, lView[this.insertBeforeIndex[0]]); + * // Now append all of the children + * for(let i=1; i void)|null; appendChild(parent: RElement, newChild: RNode): void; - insertBefore(parent: RNode, newChild: RNode, refChild: RNode|null): void; + insertBefore(parent: RNode, newChild: RNode, refChild: RNode|null, isMove?: boolean): void; removeChild(parent: RElement, oldChild: RNode, isHostElement?: boolean): void; selectRootElement(selectorOrNode: string|any, preserveContent?: boolean): RElement; diff --git a/packages/core/src/render3/interfaces/view.ts b/packages/core/src/render3/interfaces/view.ts index 504bb542dd..ca2c415694 100644 --- a/packages/core/src/render3/interfaces/view.ts +++ b/packages/core/src/render3/interfaces/view.ts @@ -14,7 +14,7 @@ import {Sanitizer} from '../../sanitization/sanitizer'; import {LContainer} from './container'; import {ComponentDef, ComponentTemplate, DirectiveDef, DirectiveDefList, HostBindingsFunction, PipeDef, PipeDefList, ViewQueriesFunction} from './definition'; -import {I18nUpdateOpCodes, TI18n} from './i18n'; +import {I18nUpdateOpCodes, TI18n, TIcu} from './i18n'; import {TConstants, TNode, TNodeTypeAsString} from './node'; import {PlayerHandler} from './player'; import {LQueries, TQueries} from './query'; @@ -839,7 +839,7 @@ export type DestroyHookData = (HookEntry|HookData)[]; */ export type TData = (TNode|PipeDef|DirectiveDef|ComponentDef|number|TStylingRange|TStylingKey| - Type|InjectionToken|TI18n|I18nUpdateOpCodes|null|string)[]; + Type|InjectionToken|TI18n|I18nUpdateOpCodes|TIcu|null|string)[]; // Note: This hack is necessary so we don't erroneously get a circular dependency // failure based on types. @@ -872,6 +872,11 @@ export interface LViewDebug { indexWithinInitPhase: number, }; + /** + * Associated TView + */ + readonly tView: TView; + /** * Parent view (or container) */ @@ -894,6 +899,12 @@ export interface LViewDebug { */ readonly nodes: DebugNode[]; + /** + * Template structure (no instance data). + * (Shows how TNodes are connected) + */ + readonly template: string; + /** * HTML representation of the `LView`. * @@ -921,11 +932,6 @@ export interface LViewDebug { */ readonly vars: LViewDebugRange; - /** - * Sub range of `LView` containing i18n (translated DOM elements). - */ - readonly i18n: LViewDebugRange; - /** * Sub range of `LView` containing expando (used by DI). */ diff --git a/packages/core/src/render3/node_manipulation.ts b/packages/core/src/render3/node_manipulation.ts index 69b503a0d9..fff333bff8 100644 --- a/packages/core/src/render3/node_manipulation.ts +++ b/packages/core/src/render3/node_manipulation.ts @@ -9,16 +9,17 @@ import {ViewEncapsulation} from '../metadata/view'; import {Renderer2} from '../render/api'; import {addToArray, removeFromArray} from '../util/array_utils'; -import {assertDefined, assertDomNode, assertEqual, assertString} from '../util/assert'; +import {assertDefined, assertDomNode, assertEqual, assertIndexInRange, assertString} from '../util/assert'; import {assertLContainer, assertLView, assertTNodeForLView} from './assert'; import {attachPatchData} from './context_discovery'; import {CONTAINER_HEADER_OFFSET, HAS_TRANSPLANTED_VIEWS, LContainer, MOVED_VIEWS, NATIVE, unusedValueExportToPlacateAjd as unused1} from './interfaces/container'; import {ComponentDef} from './interfaces/definition'; +import {icuContainerIterate} from './interfaces/i18n'; import {NodeInjectorFactory} from './interfaces/injector'; -import {TElementNode, TNode, TNodeFlags, TNodeType, TProjectionNode, unusedValueExportToPlacateAjd as unused2} from './interfaces/node'; +import {TElementNode, TIcuContainerNode, TNode, TNodeFlags, TNodeType, TProjectionNode, unusedValueExportToPlacateAjd as unused2} from './interfaces/node'; import {unusedValueExportToPlacateAjd as unused3} from './interfaces/projection'; -import {isProceduralRenderer, ProceduralRenderer3, RElement, Renderer3, RNode, RText, unusedValueExportToPlacateAjd as unused4} from './interfaces/renderer'; +import {isProceduralRenderer, ProceduralRenderer3, RComment, RElement, Renderer3, RNode, RText, unusedValueExportToPlacateAjd as unused4} from './interfaces/renderer'; import {isLContainer, isLView} from './interfaces/type_checks'; import {CHILD_HEAD, CLEANUP, DECLARATION_COMPONENT_VIEW, DECLARATION_LCONTAINER, DestroyHookData, FLAGS, HookData, HookFn, HOST, LView, LViewFlags, NEXT, PARENT, QUERIES, RENDERER, T_HOST, TVIEW, TView, TViewType, unusedValueExportToPlacateAjd as unused5} from './interfaces/view'; import {assertNodeOfPossibleTypes, assertNodeType} from './node_assert'; @@ -77,10 +78,10 @@ function applyToElementOrContainer( if (beforeNode == null) { nativeAppendChild(renderer, parent, rNode); } else { - nativeInsertBefore(renderer, parent, rNode, beforeNode || null); + nativeInsertBefore(renderer, parent, rNode, beforeNode || null, true); } } else if (action === WalkTNodeTreeAction.Insert && parent !== null) { - nativeInsertBefore(renderer, parent, rNode, beforeNode || null); + nativeInsertBefore(renderer, parent, rNode, beforeNode || null, true); } else if (action === WalkTNodeTreeAction.Detach) { nativeRemoveNode(renderer, rNode, isComponent); } else if (action === WalkTNodeTreeAction.Destroy) { @@ -93,13 +94,44 @@ function applyToElementOrContainer( } } -export function createTextNode(value: string, renderer: Renderer3): RText { +export function createTextNode(renderer: Renderer3, value: string): RText { ngDevMode && ngDevMode.rendererCreateTextNode++; ngDevMode && ngDevMode.rendererSetText++; return isProceduralRenderer(renderer) ? renderer.createText(value) : renderer.createTextNode(value); } +export function updateTextNode(renderer: Renderer3, rNode: RText, value: string): void { + ngDevMode && ngDevMode.rendererSetText++; + isProceduralRenderer(renderer) ? renderer.setValue(rNode, value) : rNode.textContent = value; +} + +export function createCommentNode(renderer: Renderer3, value: string): RComment { + ngDevMode && ngDevMode.rendererCreateComment++; + // isProceduralRenderer check is not needed because both `Renderer2` and `Renderer3` have the same + // method name. + return renderer.createComment(value); +} + +/** + * Creates a native element from a tag name, using a renderer. + * @param renderer A renderer to use + * @param name the tag name + * @param namespace Optional namespace for element. + * @returns the element created + */ +export function createElementNode( + renderer: Renderer3, name: string, namespace: string|null): RElement { + ngDevMode && ngDevMode.rendererCreateElement++; + if (isProceduralRenderer(renderer)) { + return renderer.createElement(name, namespace); + } else { + return namespace === null ? renderer.createElement(name) : + renderer.createElementNS(namespace, name); + } +} + + /** * Removes all DOM elements associated with a view. * @@ -479,11 +511,34 @@ function executeOnDestroys(tView: TView, lView: LView): void { * parent container, which itself is disconnected. For example the parent container is part * of a View which has not be inserted or is made for projection but has not been inserted * into destination. + * + * @param tView: Current `TView`. + * @param tNode: `TNode` for which we wish to retrieve render parent. + * @param lView: Current `LView`. */ -function getRenderParent(tView: TView, tNode: TNode, currentView: LView): RElement|null { +export function getParentRElement(tView: TView, tNode: TNode, lView: LView): RElement|null { + return getClosestRElement(tView, tNode.parent, lView); +} + +/** + * Get closest `RElement` or `null` if it can't be found. + * + * If `TNode` is `TNodeType.Element` => return `RElement` at `LView[tNode.index]` location. + * If `TNode` is `TNodeType.ElementContainer|IcuContain` => return the parent (recursively). + * If `TNode` is `null` then return host `RElement`: + * - return `null` if projection + * - return `null` if parent container is disconnected (we have no parent.) + * + * @param tView: Current `TView`. + * @param tNode: `TNode` for which we wish to retrieve `RElement` (or `null` if host element is + * needed). + * @param lView: Current `LView`. + * @returns `null` if the `RElement` can't be determined at this time (no parent / projection) + */ +export function getClosestRElement(tView: TView, tNode: TNode|null, lView: LView): RElement|null { + let parentTNode: TNode|null = tNode; // Skip over element and ICU containers as those are represented by a comment node and // can't be used as a render parent. - let parentTNode = tNode.parent; while (parentTNode != null && (parentTNode.type === TNodeType.ElementContainer || parentTNode.type === TNodeType.IcuContainer)) { @@ -496,21 +551,14 @@ function getRenderParent(tView: TView, tNode: TNode, currentView: LView): REleme if (parentTNode === null) { // We are inserting a root element of the component view into the component host element and // it should always be eager. - return currentView[HOST]; + return lView[HOST]; } else { - const isIcuCase = tNode && tNode.type === TNodeType.IcuContainer; - // If the parent of this node is an ICU container, then it is represented by comment node and we - // need to use it as an anchor. If it is projected then it's direct parent node is the renderer. - if (isIcuCase && tNode.flags & TNodeFlags.isProjected) { - return getNativeByTNode(tNode, currentView).parentNode as RElement; - } - - ngDevMode && assertNodeType(parentTNode, TNodeType.Element); + // ngDevMode && assertTNodeType(parentTNode, TNodeType.AnyRNode | TNodeType.Container); if (parentTNode.flags & TNodeFlags.isComponentHost) { + ngDevMode && assertTNodeForLView(parentTNode, lView); const tData = tView.data; const tNode = tData[parentTNode.index] as TNode; const encapsulation = (tData[tNode.directiveStart] as ComponentDef).encapsulation; - // We've got a parent which is an element in the current view. We just need to verify if the // parent element is not a component. Component's content nodes are not inserted immediately // because they will be projected, and so doing insert at this point would be wasteful. @@ -523,7 +571,7 @@ function getRenderParent(tView: TView, tNode: TNode, currentView: LView): REleme } } - return getNativeByTNode(parentTNode, currentView) as RElement; + return getNativeByTNode(parentTNode, lView) as RElement; } } @@ -533,12 +581,13 @@ function getRenderParent(tView: TView, tNode: TNode, currentView: LView): REleme * actual renderer being used. */ export function nativeInsertBefore( - renderer: Renderer3, parent: RElement, child: RNode, beforeNode: RNode|null): void { + renderer: Renderer3, parent: RElement, child: RNode, beforeNode: RNode|null, + isMove: boolean): void { ngDevMode && ngDevMode.rendererInsertBefore++; if (isProceduralRenderer(renderer)) { - renderer.insertBefore(parent, child, beforeNode); + renderer.insertBefore(parent, child, beforeNode, isMove); } else { - parent.insertBefore(child, beforeNode, true); + parent.insertBefore(child, beforeNode, isMove); } } @@ -553,9 +602,9 @@ function nativeAppendChild(renderer: Renderer3, parent: RElement, child: RNode): } function nativeAppendOrInsertBefore( - renderer: Renderer3, parent: RElement, child: RNode, beforeNode: RNode|null) { + renderer: Renderer3, parent: RElement, child: RNode, beforeNode: RNode|null, isMove: boolean) { if (beforeNode !== null) { - nativeInsertBefore(renderer, parent, child, beforeNode); + nativeInsertBefore(renderer, parent, child, beforeNode, isMove); } else { nativeAppendChild(renderer, parent, child); } @@ -586,15 +635,28 @@ export function nativeNextSibling(renderer: Renderer3, node: RNode): RNode|null } /** - * Finds a native "anchor" node for cases where we can't append a native child directly - * (`appendChild`) and need to use a reference (anchor) node for the `insertBefore` operation. - * @param parentTNode - * @param lView + * Find a node in front of which `currentTNode` should be inserted. + * + * This method determines the `RNode` in front of which we should insert the `currentRNode`. This + * takes `TNode.insertBeforeIndex` into account. + * + * @param parentTNode parent `TNode` + * @param currentTNode current `TNode` (The node which we would like to insert into the DOM) + * @param lView current `LView` */ -function getNativeAnchorNode(parentTNode: TNode, lView: LView): RNode|null { - if (parentTNode.type === TNodeType.ElementContainer || - parentTNode.type === TNodeType.IcuContainer) { - return getNativeByTNode(parentTNode, lView); +function getInsertInFrontOfRNode(parentTNode: TNode, currentTNode: TNode, lView: LView): RNode| + null { + const tNodeInsertBeforeIndex = currentTNode.insertBeforeIndex; + const insertBeforeIndex = + Array.isArray(tNodeInsertBeforeIndex) ? tNodeInsertBeforeIndex[0] : tNodeInsertBeforeIndex; + if (insertBeforeIndex === null) { + if (parentTNode.type === TNodeType.ElementContainer || + parentTNode.type === TNodeType.IcuContainer) { + return getNativeByTNode(parentTNode, lView); + } + } else { + ngDevMode && assertIndexInRange(lView, insertBeforeIndex); + return unwrapRNode(lView[insertBeforeIndex]); } return null; } @@ -602,27 +664,69 @@ function getNativeAnchorNode(parentTNode: TNode, lView: LView): RNode|null { /** * Appends the `child` native node (or a collection of nodes) to the `parent`. * - * The element insertion might be delayed {@link canInsertNativeNode}. - * * @param tView The `TView' to be appended * @param lView The current LView - * @param childEl The native child (or children) that should be appended + * @param childRNode The native child (or children) that should be appended * @param childTNode The TNode of the child element - * @returns Whether or not the child was appended */ export function appendChild( - tView: TView, lView: LView, childEl: RNode|RNode[], childTNode: TNode): void { - const renderParent = getRenderParent(tView, childTNode, lView); - if (renderParent != null) { - const renderer = lView[RENDERER]; - const parentTNode: TNode = childTNode.parent || lView[T_HOST]!; - const anchorNode = getNativeAnchorNode(parentTNode, lView); - if (Array.isArray(childEl)) { - for (let i = 0; i < childEl.length; i++) { - nativeAppendOrInsertBefore(renderer, renderParent, childEl[i], anchorNode); + tView: TView, lView: LView, childRNode: RNode|RNode[], childTNode: TNode): void { + const parentRNode = getParentRElement(tView, childTNode, lView); + const renderer = lView[RENDERER]; + const parentTNode: TNode = childTNode.parent || lView[T_HOST]!; + const anchorNode = getInsertInFrontOfRNode(parentTNode, childTNode, lView); + if (parentRNode != null) { + if (Array.isArray(childRNode)) { + for (let i = 0; i < childRNode.length; i++) { + nativeAppendOrInsertBefore(renderer, parentRNode, childRNode[i], anchorNode, false); } } else { - nativeAppendOrInsertBefore(renderer, renderParent, childEl, anchorNode); + nativeAppendOrInsertBefore(renderer, parentRNode, childRNode, anchorNode, false); + } + } + + const tNodeInsertBeforeIndex = childTNode.insertBeforeIndex; + if (Array.isArray(tNodeInsertBeforeIndex) && + (childTNode.flags & TNodeFlags.isComponentHost) === 0) { + // An array indicates that there are i18n nodes that need to be added as children of this + // `rChildNode`. These i18n nodes were created before this `rChildNode` was available and so + // only now can be added. The first element of the array is the normal index where we should + // insert the `rChildNode`. Additional elements are the extra nodes to be added as children of + // `rChildNode`. + processI18nText(renderer, childTNode, lView, childRNode, parentRNode, tNodeInsertBeforeIndex); + } +} + +/** + * Process `TNode.insertBeforeIndex` by adding i18n text nodes. + * + * See `TNode.insertBeforeIndex` + * + * @param renderer + * @param childTNode + * @param lView + * @param childRNode + * @param parentRElement + * @param i18nChildren + */ +function processI18nText( + renderer: Renderer3, childTNode: TNode, lView: LView, childRNode: RNode|RNode[], + parentRElement: RElement|null, i18nChildren: number[]): void { + ngDevMode && assertDomNode(childRNode); + const isProcedural = isProceduralRenderer(renderer); + let i18nParent: RElement|null = childRNode as RElement; + let anchorRNode: RNode|null = null; + if (childTNode.type !== TNodeType.Element) { + anchorRNode = i18nParent; + i18nParent = parentRElement; + } + const isViewRoot = childTNode.parent === null; + if (i18nParent !== null) { + for (let i = 1; i < i18nChildren.length; i++) { + // No need to `unwrapRNode` because all of the indexes point to i18n text nodes. + // see `assertDomNode` below. + const i18nChild = lView[i18nChildren[i]]; + nativeInsertBefore(renderer, i18nParent, i18nChild, anchorRNode, false); } } } @@ -644,7 +748,7 @@ function getFirstNativeNode(lView: LView, tNode: TNode|null): RNode|null { return getNativeByTNode(tNode, lView); } else if (tNodeType === TNodeType.Container) { return getBeforeNodeForView(-1, lView[tNode.index]); - } else if (tNodeType === TNodeType.ElementContainer || tNodeType === TNodeType.IcuContainer) { + } else if (tNodeType === TNodeType.ElementContainer) { const elIcuContainerChild = tNode.child; if (elIcuContainerChild !== null) { return getFirstNativeNode(lView, elIcuContainerChild); @@ -656,6 +760,11 @@ function getFirstNativeNode(lView: LView, tNode: TNode|null): RNode|null { return unwrapRNode(rNodeOrLContainer); } } + } else if (tNodeType === TNodeType.IcuContainer) { + let nextRNode = icuContainerIterate(tNode as TIcuContainerNode, lView); + let rNode: RNode|null = nextRNode(); + // If the ICU container has no nodes, than we use the ICU anchor as the node. + return rNode || unwrapRNode(lView[tNode.index]); } else { const componentView = lView[DECLARATION_COMPONENT_VIEW]; const componentHost = componentView[T_HOST] as TElementNode; @@ -698,6 +807,7 @@ export function getBeforeNodeForView(viewIndexInContainer: number, lContainer: L * @param isHostElement A flag indicating if a node to be removed is a host of a component. */ export function nativeRemoveNode(renderer: Renderer3, rNode: RNode, isHostElement?: boolean): void { + ngDevMode && ngDevMode.rendererRemoveNode++; const nativeParent = nativeParentNode(renderer, rNode); if (nativeParent) { nativeRemoveChild(renderer, nativeParent, rNode, isHostElement); @@ -711,7 +821,7 @@ export function nativeRemoveNode(renderer: Renderer3, rNode: RNode, isHostElemen */ function applyNodes( renderer: Renderer3, action: WalkTNodeTreeAction, tNode: TNode|null, lView: LView, - renderParent: RElement|null, beforeNode: RNode|null, isProjection: boolean) { + parentRElement: RElement|null, beforeNode: RNode|null, isProjection: boolean) { while (tNode != null) { ngDevMode && assertTNodeForLView(tNode, lView); ngDevMode && assertNodeOfPossibleTypes(tNode, [ @@ -727,15 +837,22 @@ function applyNodes( } } if ((tNode.flags & TNodeFlags.isDetached) !== TNodeFlags.isDetached) { - if (tNodeType === TNodeType.ElementContainer || tNodeType === TNodeType.IcuContainer) { - applyNodes(renderer, action, tNode.child, lView, renderParent, beforeNode, false); - applyToElementOrContainer(action, renderer, renderParent, rawSlotValue, beforeNode); + if (tNodeType === TNodeType.ElementContainer) { + applyNodes(renderer, action, tNode.child, lView, parentRElement, beforeNode, false); + applyToElementOrContainer(action, renderer, parentRElement, rawSlotValue, beforeNode); + } else if (tNodeType === TNodeType.IcuContainer) { + const nextRNode = icuContainerIterate(tNode as TIcuContainerNode, lView); + let rNode: RNode|null; + while (rNode = nextRNode()) { + applyToElementOrContainer(action, renderer, parentRElement, rNode, beforeNode); + } + applyToElementOrContainer(action, renderer, parentRElement, rawSlotValue, beforeNode); } else if (tNodeType === TNodeType.Projection) { applyProjectionRecursive( - renderer, action, lView, tNode as TProjectionNode, renderParent, beforeNode); + renderer, action, lView, tNode as TProjectionNode, parentRElement, beforeNode); } else { ngDevMode && assertNodeOfPossibleTypes(tNode, [TNodeType.Element, TNodeType.Container]); - applyToElementOrContainer(action, renderer, renderParent, rawSlotValue, beforeNode); + applyToElementOrContainer(action, renderer, parentRElement, rawSlotValue, beforeNode); } } tNode = isProjection ? tNode.projectionNext : tNode.next; @@ -763,19 +880,19 @@ function applyNodes( * @param lView The LView which needs to be inserted, detached, destroyed. * @param renderer Renderer to use * @param action action to perform (insert, detach, destroy) - * @param renderParent parent DOM element for insertion (Removal does not need it). + * @param parentRElement parent DOM element for insertion (Removal does not need it). * @param beforeNode Before which node the insertions should happen. */ function applyView( tView: TView, lView: LView, renderer: Renderer3, action: WalkTNodeTreeAction.Destroy, - renderParent: null, beforeNode: null): void; + parentRElement: null, beforeNode: null): void; function applyView( tView: TView, lView: LView, renderer: Renderer3, action: WalkTNodeTreeAction, - renderParent: RElement|null, beforeNode: RNode|null): void; + parentRElement: RElement|null, beforeNode: RNode|null): void; function applyView( tView: TView, lView: LView, renderer: Renderer3, action: WalkTNodeTreeAction, - renderParent: RElement|null, beforeNode: RNode|null): void { - applyNodes(renderer, action, tView.firstChild, lView, renderParent, beforeNode, false); + parentRElement: RElement|null, beforeNode: RNode|null): void { + applyNodes(renderer, action, tView.firstChild, lView, parentRElement, beforeNode, false); } /** @@ -790,11 +907,11 @@ function applyView( */ export function applyProjection(tView: TView, lView: LView, tProjectionNode: TProjectionNode) { const renderer = lView[RENDERER]; - const renderParent = getRenderParent(tView, tProjectionNode, lView); + const parentRNode = getParentRElement(tView, tProjectionNode, lView); const parentTNode = tProjectionNode.parent || lView[T_HOST]!; - let beforeNode = getNativeAnchorNode(parentTNode, lView); + let beforeNode = getInsertInFrontOfRNode(parentTNode, tProjectionNode, lView); applyProjectionRecursive( - renderer, WalkTNodeTreeAction.Create, lView, tProjectionNode, renderParent, beforeNode); + renderer, WalkTNodeTreeAction.Create, lView, tProjectionNode, parentRNode, beforeNode); } /** @@ -808,12 +925,12 @@ export function applyProjection(tView: TView, lView: LView, tProjectionNode: TPr * @param action action to perform (insert, detach, destroy) * @param lView The LView which needs to be inserted, detached, destroyed. * @param tProjectionNode node to project - * @param renderParent parent DOM element for insertion/removal. + * @param parentRElement parent DOM element for insertion/removal. * @param beforeNode Before which node the insertions should happen. */ function applyProjectionRecursive( renderer: Renderer3, action: WalkTNodeTreeAction, lView: LView, - tProjectionNode: TProjectionNode, renderParent: RElement|null, beforeNode: RNode|null) { + tProjectionNode: TProjectionNode, parentRElement: RElement|null, beforeNode: RNode|null) { const componentLView = lView[DECLARATION_COMPONENT_VIEW]; const componentNode = componentLView[T_HOST] as TElementNode; ngDevMode && @@ -827,13 +944,13 @@ function applyProjectionRecursive( // This should be refactored and cleaned up. for (let i = 0; i < nodeToProjectOrRNodes.length; i++) { const rNode = nodeToProjectOrRNodes[i]; - applyToElementOrContainer(action, renderer, renderParent, rNode, beforeNode); + applyToElementOrContainer(action, renderer, parentRElement, rNode, beforeNode); } } else { let nodeToProject: TNode|null = nodeToProjectOrRNodes; const projectedComponentLView = componentLView[PARENT] as LView; applyNodes( - renderer, action, nodeToProject, projectedComponentLView, renderParent, beforeNode, true); + renderer, action, nodeToProject, projectedComponentLView, parentRElement, beforeNode, true); } } @@ -848,31 +965,31 @@ function applyProjectionRecursive( * @param renderer Renderer to use * @param action action to perform (insert, detach, destroy) * @param lContainer The LContainer which needs to be inserted, detached, destroyed. - * @param renderParent parent DOM element for insertion/removal. + * @param parentRElement parent DOM element for insertion/removal. * @param beforeNode Before which node the insertions should happen. */ function applyContainer( renderer: Renderer3, action: WalkTNodeTreeAction, lContainer: LContainer, - renderParent: RElement|null, beforeNode: RNode|null|undefined) { + parentRElement: RElement|null, beforeNode: RNode|null|undefined) { ngDevMode && assertLContainer(lContainer); const anchor = lContainer[NATIVE]; // LContainer has its own before node. const native = unwrapRNode(lContainer); // An LContainer can be created dynamically on any node by injecting ViewContainerRef. - // Asking for a ViewContainerRef on an element will result in a creation of a separate anchor node - // (comment in the DOM) that will be different from the LContainer's host node. In this particular - // case we need to execute action on 2 nodes: + // Asking for a ViewContainerRef on an element will result in a creation of a separate anchor + // node (comment in the DOM) that will be different from the LContainer's host node. In this + // particular case we need to execute action on 2 nodes: // - container's host node (this is done in the executeActionOnElementOrContainer) // - container's host node (this is done here) if (anchor !== native) { - // This is very strange to me (Misko). I would expect that the native is same as anchor. I don't - // see a reason why they should be different, but they are. + // This is very strange to me (Misko). I would expect that the native is same as anchor. I + // don't see a reason why they should be different, but they are. // // If they are we need to process the second anchor as well. - applyToElementOrContainer(action, renderer, renderParent, anchor, beforeNode); + applyToElementOrContainer(action, renderer, parentRElement, anchor, beforeNode); } for (let i = CONTAINER_HEADER_OFFSET; i < lContainer.length; i++) { const lView = lContainer[i] as LView; - applyView(lView[TVIEW], lView, renderer, action, renderParent, anchor); + applyView(lView[TVIEW], lView, renderer, action, parentRElement, anchor); } } @@ -908,8 +1025,8 @@ export function applyStyling( } } } else { - // TODO(misko): Can't import RendererStyleFlags2.DashCase as it causes imports to be resolved in - // different order which causes failures. Using direct constant as workaround for now. + // TODO(misko): Can't import RendererStyleFlags2.DashCase as it causes imports to be resolved + // in different order which causes failures. Using direct constant as workaround for now. const flags = prop.indexOf('-') == -1 ? undefined : 2 /* RendererStyleFlags2.DashCase */; if (value == null /** || value === undefined */) { ngDevMode && ngDevMode.rendererRemoveStyle++; diff --git a/packages/core/src/render3/state.ts b/packages/core/src/render3/state.ts index 453a4679ca..6b0f15a13e 100644 --- a/packages/core/src/render3/state.ts +++ b/packages/core/src/render3/state.ts @@ -6,10 +6,10 @@ * found in the LICENSE file at https://angular.io/license */ -import {assertDefined, assertEqual} from '../util/assert'; +import {assertDefined, assertEqual, assertNotEqual} from '../util/assert'; import {assertLViewOrUndefined, assertTNodeForTView} from './assert'; import {DirectiveDef} from './interfaces/definition'; -import {TNode} from './interfaces/node'; +import {TNode, TNodeType} from './interfaces/node'; import {CONTEXT, DECLARATION_VIEW, LView, OpaqueViewState, TData, TVIEW, TView} from './interfaces/view'; import {MATH_ML_NAMESPACE, SVG_NAMESPACE} from './namespaces'; import {getTNode} from './util/view_utils'; @@ -116,6 +116,22 @@ interface LFrame { * `LView[currentDirectiveIndex]` is directive instance. */ currentDirectiveIndex: number; + + /** + * Are we currently in i18n block as denoted by `ɵɵelementStart` and `ɵɵelementEnd`. + * + * This information is needed because while we are in i18n block all elements must be pre-declared + * in the translation. (i.e. `Hello �#2�World�/#2�!` pre-declares element at `�#2�` location.) + * This allocates `TNodeType.Placeholder` element at location `2`. If translator removes `�#2�` + * from translation than the runtime must also ensure tha element at `2` does not get inserted + * into the DOM. The translation does not carry information about deleted elements. Therefor the + * only way to know that an element is deleted is that it was not pre-declared in the translation. + * + * This flag works by ensuring that elements which are created without pre-declaration + * (`TNodeType.Placeholder`) are not inserted into the DOM render tree. (It does mean that the + * element still gets instantiated along with all of its behavior [directives]) + */ + inI18n: boolean; } /** @@ -166,12 +182,21 @@ interface InstructionState { isInCheckNoChangesMode: boolean; } -export const instructionState: InstructionState = { +const instructionState: InstructionState = { lFrame: createLFrame(null), bindingsEnabled: true, isInCheckNoChangesMode: false, }; +/** + * Returns true if the instruction state stack is empty. + * + * Intended to be called from tests only (tree shaken otherwise). + */ +export function specOnlyIsInstructionStateEmpty(): boolean { + return instructionState.lFrame.parent === null; +} + export function getElementDepthCount() { return instructionState.lFrame.elementDepthCount; @@ -265,14 +290,30 @@ export function ɵɵrestoreView(viewToRestore: OpaqueViewState) { instructionState.lFrame.contextLView = viewToRestore as any as LView; } + export function getCurrentTNode(): TNode|null { + let currentTNode = getCurrentTNodePlaceholderOk(); + while (currentTNode !== null && currentTNode.type === TNodeType.Placeholder) { + currentTNode = currentTNode.parent; + } + return currentTNode; +} + +export function getCurrentTNodePlaceholderOk(): TNode|null { return instructionState.lFrame.currentTNode; } -export function setCurrentTNode(tNode: TNode, isParent: boolean) { - ngDevMode && assertTNodeForTView(tNode, instructionState.lFrame.tView); - instructionState.lFrame.currentTNode = tNode; - instructionState.lFrame.isParent = isParent; +export function getCurrentParentTNode(): TNode|null { + const lFrame = instructionState.lFrame; + const currentTNode = lFrame.currentTNode; + return lFrame.isParent ? currentTNode : currentTNode!.parent; +} + +export function setCurrentTNode(tNode: TNode|null, isParent: boolean) { + ngDevMode && tNode && assertTNodeForTView(tNode, instructionState.lFrame.tView); + const lFrame = instructionState.lFrame; + lFrame.currentTNode = tNode; + lFrame.isParent = isParent; } export function isCurrentTNodeParent(): boolean { @@ -328,6 +369,14 @@ export function incrementBindingIndex(count: number): number { return index; } +export function isInI18nBlock() { + return instructionState.lFrame.inI18n; +} + +export function setInI18nBlock(isInI18nBlock: boolean): void { + instructionState.lFrame.inI18n = isInI18nBlock; +} + /** * Set a new binding root index so that host template functions can execute. * @@ -429,6 +478,7 @@ export function enterView(newView: LView): void { newLFrame.tView = tView; newLFrame.contextLView = newView!; newLFrame.bindingIndex = tView.bindingStartIndex; + newLFrame.inI18n = false; } /** @@ -443,20 +493,21 @@ function allocLFrame() { function createLFrame(parent: LFrame|null): LFrame { const lFrame: LFrame = { - currentTNode: null, // - isParent: true, // - lView: null!, // - tView: null!, // - selectedIndex: 0, // - contextLView: null!, // - elementDepthCount: 0, // - currentNamespace: null, // - currentDirectiveIndex: -1, // - bindingRootIndex: -1, // - bindingIndex: -1, // - currentQueryIndex: 0, // - parent: parent!, // - child: null, // + currentTNode: null, + isParent: true, + lView: null!, + tView: null!, + selectedIndex: 0, + contextLView: null!, + elementDepthCount: 0, + currentNamespace: null, + currentDirectiveIndex: -1, + bindingRootIndex: -1, + bindingIndex: -1, + currentQueryIndex: 0, + parent: parent!, + child: null, + inI18n: false, }; parent !== null && (parent.child = lFrame); // link the new LFrame for reuse. return lFrame; diff --git a/packages/core/src/render3/util/view_utils.ts b/packages/core/src/render3/util/view_utils.ts index 574c3d2215..bd95000995 100644 --- a/packages/core/src/render3/util/view_utils.ts +++ b/packages/core/src/render3/util/view_utils.ts @@ -7,10 +7,10 @@ */ import {assertDefined, assertDomNode, assertGreaterThan, assertIndexInRange, assertLessThan} from '../../util/assert'; -import {assertTNodeForLView} from '../assert'; +import {assertTNode, assertTNodeForLView} from '../assert'; import {LContainer, TYPE} from '../interfaces/container'; import {LContext, MONKEY_PATCH_KEY_NAME} from '../interfaces/context'; -import {TConstants, TNode, TNodeType} from '../interfaces/node'; +import {TConstants, TNode} from '../interfaces/node'; import {isProceduralRenderer, RNode} from '../interfaces/renderer'; import {isLContainer, isLView} from '../interfaces/type_checks'; import {FLAGS, HEADER_OFFSET, HOST, LView, LViewFlags, PARENT, PREORDER_HOOK_FLAGS, RENDERER, TData, TRANSPLANTED_VIEWS_TO_REFRESH, TView} from '../interfaces/view'; @@ -117,10 +117,13 @@ export function getNativeByTNodeOrNull(tNode: TNode|null, lView: LView): RNode|n } +// fixme(misko): The return Type should be `TNode|null` export function getTNode(tView: TView, index: number): TNode { ngDevMode && assertGreaterThan(index, -1, 'wrong index for TNode'); - ngDevMode && assertLessThan(index, tView.data.length, 'wrong index for TNode'); - return tView.data[index + HEADER_OFFSET] as TNode; + ngDevMode && assertLessThan(index, tView.data.length - HEADER_OFFSET, 'wrong index for TNode'); + const tNode = tView.data[index + HEADER_OFFSET] as TNode; + ngDevMode && tNode !== null && assertTNode(tNode); + return tNode; } /** Retrieves a value from any `LView` or `TData`. */ diff --git a/packages/core/src/render3/view_engine_compatibility.ts b/packages/core/src/render3/view_engine_compatibility.ts index 7a29718943..415b13998e 100644 --- a/packages/core/src/render3/view_engine_compatibility.ts +++ b/packages/core/src/render3/view_engine_compatibility.ts @@ -20,7 +20,7 @@ import {assertDefined, assertEqual, assertGreaterThan, assertLessThan} from '../ import {assertLContainer, assertNodeInjector} from './assert'; import {getParentInjectorLocation, NodeInjector} from './di'; -import {addToViewTree, createLContainer, createLView, renderView} from './instructions/shared'; +import {addToViewTree, createLContainer, createLView, createTNode, renderView} from './instructions/shared'; import {CONTAINER_HEADER_OFFSET, LContainer, NATIVE, VIEW_REFS} from './interfaces/container'; import {NodeInjectorOffset} from './interfaces/injector'; import {TContainerNode, TDirectiveHostNode, TElementContainerNode, TElementNode, TNode, TNodeType} from './interfaces/node'; @@ -287,9 +287,9 @@ export function createContainerRef( // Physical operation of adding the DOM nodes. const beforeNode = getBeforeNodeForView(adjustedIdx, lContainer); const renderer = lView[RENDERER]; - const renderParent = nativeParentNode(renderer, lContainer[NATIVE] as RElement | RComment); - if (renderParent !== null) { - addViewToContainer(tView, lContainer[T_HOST], renderer, lView, renderParent, beforeNode); + const parentRNode = nativeParentNode(renderer, lContainer[NATIVE] as RElement | RComment); + if (parentRNode !== null) { + addViewToContainer(tView, lContainer[T_HOST], renderer, lView, parentRNode, beforeNode); } (viewRef as ViewRef).attachToViewContainerRef(this); @@ -388,9 +388,14 @@ export function createContainerRef( const hostNative = getNativeByTNode(hostTNode, hostView)!; const parentOfHostNative = nativeParentNode(renderer, hostNative); nativeInsertBefore( - renderer, parentOfHostNative!, commentNode, nativeNextSibling(renderer, hostNative)); + renderer, parentOfHostNative!, commentNode, nativeNextSibling(renderer, hostNative), + false); } else { - appendChild(hostView[TVIEW], hostView, commentNode, hostTNode); + // The TNode created here is bogus, in that it is not added to the TView. It is only created + // to allow us to create a dynamic Comment node. + const commentTNode = createTNode( + hostView[TVIEW], hostTNode.parent, TNodeType.Container, hostTNode.type, null, null); + appendChild(hostView[TVIEW], hostView, commentNode, commentTNode); } } diff --git a/packages/core/src/render3/view_ref.ts b/packages/core/src/render3/view_ref.ts index 5977e5c973..5db245b0f1 100644 --- a/packages/core/src/render3/view_ref.ts +++ b/packages/core/src/render3/view_ref.ts @@ -11,11 +11,14 @@ import {ChangeDetectorRef as viewEngine_ChangeDetectorRef} from '../change_detec import {ViewContainerRef as viewEngine_ViewContainerRef} from '../linker/view_container_ref'; import {EmbeddedViewRef as viewEngine_EmbeddedViewRef, InternalViewRef as viewEngine_InternalViewRef} from '../linker/view_ref'; import {assertDefined} from '../util/assert'; + import {checkNoChangesInRootView, checkNoChangesInternal, detectChangesInRootView, detectChangesInternal, markViewDirty, storeCleanupWithContext} from './instructions/shared'; import {CONTAINER_HEADER_OFFSET} from './interfaces/container'; -import {TElementNode, TNode, TNodeType} from './interfaces/node'; +import {icuContainerIterate} from './interfaces/i18n'; +import {TElementNode, TIcuContainerNode, TNode, TNodeType} from './interfaces/node'; +import {RNode} from './interfaces/renderer'; import {isLContainer} from './interfaces/type_checks'; -import {CONTEXT, DECLARATION_COMPONENT_VIEW, FLAGS, HOST, LView, LViewFlags, T_HOST, TVIEW, TView} from './interfaces/view'; +import {CONTEXT, DECLARATION_COMPONENT_VIEW, FLAGS, LView, LViewFlags, T_HOST, TVIEW, TView} from './interfaces/view'; import {assertNodeOfPossibleTypes} from './node_assert'; import {destroyLView, renderDetachView} from './node_manipulation'; import {getLViewParent} from './util/view_traversal_utils'; @@ -346,8 +349,14 @@ function collectNativeNodes( } const tNodeType = tNode.type; - if (tNodeType === TNodeType.ElementContainer || tNodeType === TNodeType.IcuContainer) { + if (tNodeType === TNodeType.ElementContainer) { collectNativeNodes(tView, lView, tNode.child, result); + } else if (tNodeType === TNodeType.IcuContainer) { + const nextRNode = icuContainerIterate(tNode as TIcuContainerNode, lView); + let rNode: RNode|null; + while (rNode = nextRNode()) { + result.push(rNode); + } } else if (tNodeType === TNodeType.Projection) { const componentView = lView[DECLARATION_COMPONENT_VIEW]; const componentHost = componentView[T_HOST] as TElementNode; diff --git a/packages/core/src/util/assert.ts b/packages/core/src/util/assert.ts index 68d456a24a..5fdd5bf0b5 100644 --- a/packages/core/src/util/assert.ts +++ b/packages/core/src/util/assert.ts @@ -102,18 +102,25 @@ export function throwError(msg: string, actual?: any, expected?: any, comparison export function assertDomNode(node: any): asserts node is Node { // If we're in a worker, `Node` will not be defined. - assertEqual( - (typeof Node !== 'undefined' && node instanceof Node) || - (typeof node === 'object' && node != null && - node.constructor.name === 'WebWorkerRenderNode'), - true, `The provided value must be an instance of a DOM Node but got ${stringify(node)}`); + if (!(typeof Node !== 'undefined' && node instanceof Node) && + !(typeof node === 'object' && node != null && + node.constructor.name === 'WebWorkerRenderNode')) { + throwError(`The provided value must be an instance of a DOM Node but got ${stringify(node)}`); + } } export function assertIndexInRange(arr: any[], index: number) { assertDefined(arr, 'Array must be defined.'); const maxLen = arr.length; - if (index < 0 || index > maxLen) { + if (index < 0 || index >= maxLen) { throwError(`Index expected to be less than ${maxLen} but got ${index}`); } } + + +export function assertOneOf(value: any, ...validValues: any[]) { + if (validValues.indexOf(value) !== -1) return true; + throwError(`Expected value to be one of ${JSON.stringify(validValues)} but was ${ + JSON.stringify(value)}.`); +} \ No newline at end of file diff --git a/packages/core/src/util/char_code.ts b/packages/core/src/util/char_code.ts index 6680ebacad..0a6fe41461 100644 --- a/packages/core/src/util/char_code.ts +++ b/packages/core/src/util/char_code.ts @@ -12,10 +12,24 @@ export const enum CharCode { UPPER_CASE = ~32, // & with this will make the char uppercase SPACE = 32, // " " + EXCLAMATION = 33, // "!" DOUBLE_QUOTE = 34, // "\"" + HASH = 35, // "#" SINGLE_QUOTE = 39, // "'" OPEN_PAREN = 40, // "(" CLOSE_PAREN = 41, // ")" + STAR = 42, // "*" + SLASH = 47, // "/" + _0 = 48, // "0" + _1 = 49, // "1" + _2 = 50, // "2" + _3 = 51, // "3" + _4 = 52, // "4" + _5 = 53, // "5" + _6 = 54, // "6" + _7 = 55, // "7" + _8 = 56, // "8" + _9 = 57, // "9" COLON = 58, // ":" DASH = 45, // "-" UNDERSCORE = 95, // "_" diff --git a/packages/core/src/view/services.ts b/packages/core/src/view/services.ts index 6edf41e185..83808ce281 100644 --- a/packages/core/src/view/services.ts +++ b/packages/core/src/view/services.ts @@ -752,7 +752,7 @@ export class DebugRenderer2 implements Renderer2 { this.delegate.appendChild(parent, newChild); } - insertBefore(parent: any, newChild: any, refChild: any): void { + insertBefore(parent: any, newChild: any, refChild: any, isMove?: boolean): void { const debugEl = getDebugNode(parent); const debugChildEl = getDebugNode(newChild); const debugRefEl = getDebugNode(refChild)!; @@ -760,7 +760,7 @@ export class DebugRenderer2 implements Renderer2 { debugEl.insertBefore(debugRefEl, debugChildEl); } - this.delegate.insertBefore(parent, newChild, refChild); + this.delegate.insertBefore(parent, newChild, refChild, isMove); } removeChild(parent: any, oldChild: any): void { diff --git a/packages/core/test/acceptance/debug_spec.ts b/packages/core/test/acceptance/debug_spec.ts index ef1e3d3ea3..5047501d71 100644 --- a/packages/core/test/acceptance/debug_spec.ts +++ b/packages/core/test/acceptance/debug_spec.ts @@ -64,7 +64,7 @@ onlyInIvy('Ivy specific').describe('Debug Representation', () => { length: 1, content: [{index: HEADER_OFFSET + 2, t: null, l: 'World'}] }); - expect(myComponentView.i18n).toEqual({ + expect(myComponentView.expando).toEqual({ start: HEADER_OFFSET + 3, end: HEADER_OFFSET + 4, length: 1, @@ -74,8 +74,6 @@ onlyInIvy('Ivy specific').describe('Debug Representation', () => { l: matchDomText('Hello World') }] }); - expect(myComponentView.expando) - .toEqual({start: HEADER_OFFSET + 4, end: HEADER_OFFSET + 4, length: 0, content: []}); }); }); }); diff --git a/packages/core/test/acceptance/i18n_spec.ts b/packages/core/test/acceptance/i18n_spec.ts index 21adb9a110..ddabec0d61 100644 --- a/packages/core/test/acceptance/i18n_spec.ts +++ b/packages/core/test/acceptance/i18n_spec.ts @@ -14,11 +14,8 @@ 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 {getComponentLView} 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'; @@ -27,19 +24,19 @@ import {onlyInIvy} from '@angular/private/testing'; import {BehaviorSubject} from 'rxjs'; + onlyInIvy('Ivy i18n logic').describe('runtime i18n', () => { beforeEach(() => { TestBed.configureTestingModule({ declarations: [AppComp, DirectiveWithTplRef, UppercasePipe], - // In some of the tests we use made-up tag names for better readability, however they'll - // cause validation errors. Add the `NO_ERRORS_SCHEMA` so that we don't have to declare - // dummy components for each one of them. + // In some of the tests we use made-up tag names for better readability, however + // they'll cause validation errors. Add the `NO_ERRORS_SCHEMA` so that we don't have + // to declare dummy components for each one of them. schemas: [NO_ERRORS_SCHEMA], }); }); afterEach(() => { - setDelayProjection(false); clearTranslations(); }); @@ -105,7 +102,8 @@ onlyInIvy('Ivy i18n logic').describe('runtime i18n', () => { {{ obj?.getA()?.b }}
`); - // the `obj` field is not yet defined, so 2nd and 3rd interpolations return empty strings + // the `obj` field is not yet defined, so 2nd and 3rd interpolations return empty + // strings expect(fixture.nativeElement.innerHTML).toEqual(`
ANGULAR - - (fr)
`); fixture.componentRef.instance.obj = { @@ -545,9 +543,9 @@ onlyInIvy('Ivy i18n logic').describe('runtime i18n', () => { TestBed.configureTestingModule({ providers: [ {provide: DOCUMENT, useFactory: _document, deps: []}, - // TODO(FW-811): switch back to default server renderer (i.e. remove the line below) - // once it starts to support Ivy namespace format (URIs) correctly. For now, use - // `DomRenderer` that supports Ivy namespace format. + // TODO(FW-811): switch back to default server renderer (i.e. remove the line + // below) once it starts to support Ivy namespace format (URIs) correctly. For + // now, use `DomRenderer` that supports Ivy namespace format. {provide: RendererFactory2, useClass: DomRendererFactory2} ], }); @@ -633,70 +631,15 @@ onlyInIvy('Ivy i18n logic').describe('runtime i18n', () => { jasmine.objectContaining({index: HEADER_OFFSET + 3, l: exclamation}), ] }); - expect(lViewDebug.i18n) + expect(lViewDebug.expando) .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])', - 'setCurrentTNode(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. + // 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, @@ -704,112 +647,42 @@ onlyInIvy('Ivy i18n logic').describe('runtime i18n', () => { =1 {one minute ago} other {{{count}} minutes ago} } - `); + `.trim()); const lView = getComponentLView(fixture.componentInstance); const lViewDebug = lView.debug!; + fixture.detectChanges(); 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()']); + 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}), - ] - }); + expect(lViewDebug.nodes[0].children.map(toTypeContent)).toEqual([]); + expect(fixture.nativeElement.innerHTML).toEqual('just now'); }); - // FIXME(misko): re-enable and fix this use case. - xit('should support multiple ICUs', () => { + it('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} + {name, select, + Angular {Mr. Angular} + other {Sir} } `); const lView = getComponentLView(fixture.componentInstance); - expect(lView.debug!.nodes.map(toTypeContent)).toEqual(['IcuContainer()']); + expect(lView.debug!.nodes.map(toTypeContent)).toEqual([ + 'IcuContainer()', + '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([]); + expect(fixture.nativeElement.innerHTML) + .toEqual('just nowMr. Angular'); }); }); }); @@ -905,19 +778,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', () => { @@ -955,20 +828,32 @@ 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', () => { @@ -982,7 +867,7 @@ onlyInIvy('Ivy i18n logic').describe('runtime i18n', () => { other {({{name}})} }`); expect(fixture.nativeElement.innerHTML) - .toEqual(`
(Angular)
`); @@ -1001,7 +886,7 @@ onlyInIvy('Ivy i18n logic').describe('runtime i18n', () => { const fixture = initWithTemplate(AppComp, `{name, select, other {({{name}})} }`); - expect(fixture.nativeElement.innerHTML).toEqual(`(Angular)`); + expect(fixture.nativeElement.innerHTML).toEqual(`(Angular)`); }); it('inside ', () => { @@ -1036,12 +921,12 @@ onlyInIvy('Ivy i18n logic').describe('runtime i18n', () => { other {animals} }!} }`); - expect(fixture.nativeElement.innerHTML).toEqual(`
zero
`); + expect(fixture.nativeElement.innerHTML).toEqual(`
zero
`); fixture.componentRef.instance.count = 4; fixture.detectChanges(); expect(fixture.nativeElement.innerHTML) - .toEqual(`
4 animaux!
`); + .toEqual(`
4 animaux!
`); }); it('nested with interpolations in "other" blocks', () => { @@ -1061,16 +946,16 @@ onlyInIvy('Ivy i18n logic').describe('runtime i18n', () => { }!} other {other - {{count}}} }`); - expect(fixture.nativeElement.innerHTML).toEqual(`
zero
`); + expect(fixture.nativeElement.innerHTML).toEqual(`
zero
`); fixture.componentRef.instance.count = 2; fixture.detectChanges(); expect(fixture.nativeElement.innerHTML) - .toEqual(`
2 animaux!
`); + .toEqual(`
2 animaux!
`); fixture.componentRef.instance.count = 4; fixture.detectChanges(); - expect(fixture.nativeElement.innerHTML).toEqual(`
autre - 4
`); + expect(fixture.nativeElement.innerHTML).toEqual(`
autre - 4
`); }); it('should return the correct plural form for ICU expressions when using "ro" locale', () => { @@ -1103,31 +988,31 @@ onlyInIvy('Ivy i18n logic').describe('runtime i18n', () => { =other {lots of emails} }`); - expect(fixture.nativeElement.innerHTML).toEqual('no email'); + expect(fixture.nativeElement.innerHTML).toEqual('no email'); // Change detection cycle, no model changes fixture.detectChanges(); - expect(fixture.nativeElement.innerHTML).toEqual('no email'); + expect(fixture.nativeElement.innerHTML).toEqual('no email'); fixture.componentInstance.count = 3; fixture.detectChanges(); - expect(fixture.nativeElement.innerHTML).toEqual('a few emails'); + expect(fixture.nativeElement.innerHTML).toEqual('a few emails'); fixture.componentInstance.count = 1; fixture.detectChanges(); - expect(fixture.nativeElement.innerHTML).toEqual('one email'); + expect(fixture.nativeElement.innerHTML).toEqual('one email'); fixture.componentInstance.count = 10; fixture.detectChanges(); - expect(fixture.nativeElement.innerHTML).toEqual('a few emails'); + expect(fixture.nativeElement.innerHTML).toEqual('a few emails'); fixture.componentInstance.count = 20; fixture.detectChanges(); - expect(fixture.nativeElement.innerHTML).toEqual('lots of emails'); + expect(fixture.nativeElement.innerHTML).toEqual('lots of emails'); fixture.componentInstance.count = 0; fixture.detectChanges(); - expect(fixture.nativeElement.innerHTML).toEqual('no email'); + expect(fixture.nativeElement.innerHTML).toEqual('no email'); }); it(`should return the correct plural form for ICU expressions when using "es" locale`, () => { @@ -1154,31 +1039,31 @@ onlyInIvy('Ivy i18n logic').describe('runtime i18n', () => { =other {lots of emails} }`); - expect(fixture.nativeElement.innerHTML).toEqual('no email'); + expect(fixture.nativeElement.innerHTML).toEqual('no email'); // Change detection cycle, no model changes fixture.detectChanges(); - expect(fixture.nativeElement.innerHTML).toEqual('no email'); + expect(fixture.nativeElement.innerHTML).toEqual('no email'); fixture.componentInstance.count = 3; fixture.detectChanges(); - expect(fixture.nativeElement.innerHTML).toEqual('lots of emails'); + expect(fixture.nativeElement.innerHTML).toEqual('lots of emails'); fixture.componentInstance.count = 1; fixture.detectChanges(); - expect(fixture.nativeElement.innerHTML).toEqual('one email'); + expect(fixture.nativeElement.innerHTML).toEqual('one email'); fixture.componentInstance.count = 10; fixture.detectChanges(); - expect(fixture.nativeElement.innerHTML).toEqual('lots of emails'); + expect(fixture.nativeElement.innerHTML).toEqual('lots of emails'); fixture.componentInstance.count = 20; fixture.detectChanges(); - expect(fixture.nativeElement.innerHTML).toEqual('lots of emails'); + expect(fixture.nativeElement.innerHTML).toEqual('lots of emails'); fixture.componentInstance.count = 0; fixture.detectChanges(); - expect(fixture.nativeElement.innerHTML).toEqual('no email'); + expect(fixture.nativeElement.innerHTML).toEqual('no email'); }); it('projection', () => { @@ -1273,12 +1158,12 @@ onlyInIvy('Ivy i18n logic').describe('runtime i18n', () => { const fixture = TestBed.createComponent(App); fixture.detectChanges(); expect(fixture.debugElement.nativeElement.innerHTML) - .toContain('
ONE
'); + .toContain('
ONE
'); fixture.componentRef.instance.count = 2; fixture.detectChanges(); expect(fixture.debugElement.nativeElement.innerHTML) - .toContain('
OTHER
'); + .toContain('
OTHER
'); // destroy component fixture.componentInstance.condition = false; @@ -1290,7 +1175,7 @@ onlyInIvy('Ivy i18n logic').describe('runtime i18n', () => { fixture.componentInstance.count = 1; fixture.detectChanges(); expect(fixture.debugElement.nativeElement.innerHTML) - .toContain('
ONE
'); + .toContain('
ONE
'); }); it('with nested ICU expression and inside a container when creating a view via vcr.createEmbeddedView', @@ -1362,12 +1247,12 @@ onlyInIvy('Ivy i18n logic').describe('runtime i18n', () => { fixture.detectChanges(); expect(fixture.debugElement.nativeElement.innerHTML) .toBe( - '
2 animals!
'); + '
2 animals!
'); fixture.componentRef.instance.count = 1; fixture.detectChanges(); expect(fixture.debugElement.nativeElement.innerHTML) - .toBe('
ONE
'); + .toBe('
ONE
'); }); it('with nested containers', () => { @@ -1602,7 +1487,8 @@ onlyInIvy('Ivy i18n logic').describe('runtime i18n', () => { fixture.componentInstance.count = 2; fixture.detectChanges(); - // check switching to an existing case after processing nested ICU without matching case + // check switching to an existing case after processing nested ICU without matching + // case expect(fixture.nativeElement.textContent.trim()).toBe('deux (select) - deux (plural)'); fixture.componentInstance.count = 1; @@ -1651,26 +1537,17 @@ onlyInIvy('Ivy i18n logic').describe('runtime i18n', () => { 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', () => { + it('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', () => { @@ -1953,7 +1830,8 @@ onlyInIvy('Ivy i18n logic').describe('runtime i18n', () => { const fixture = initWithTemplate(AppComp, `
`); - // the `obj` field is not yet defined, so 2nd and 3rd interpolations return empty strings + // the `obj` field is not yet defined, so 2nd and 3rd interpolations return empty + // strings expect(fixture.nativeElement.firstChild.title).toEqual(`ANGULAR - - (fr)`); fixture.componentRef.instance.obj = { @@ -2106,8 +1984,8 @@ onlyInIvy('Ivy i18n logic').describe('runtime i18n', () => { const innerDiv: HTMLElement = fixture.nativeElement.querySelector('div[inner]'); // Note that ideally we'd just compare the innerHTML here, but different browsers return - // the order of attributes differently. E.g. most browsers preserve the declaration order, - // but IE does not. + // the order of attributes differently. E.g. most browsers preserve the declaration + // order, but IE does not. expect(outerDiv.getAttribute('title')).toBe('début 2 milieu 1 fin'); expect(outerDiv.getAttribute('class')).toBe('foo'); expect(outerDiv.textContent!.trim()).toBe('traduction: un email'); @@ -2491,13 +2369,13 @@ onlyInIvy('Ivy i18n logic').describe('runtime i18n', () => { fixture.detectChanges(); expect(fixture.nativeElement.innerHTML) .toEqual( - `
Contenu enfant et projection depuis Parent
`); + `
Contenu enfant et projection depuis Parent
`); fixture.componentRef.instance.name = 'angular'; fixture.detectChanges(); expect(fixture.nativeElement.innerHTML) .toEqual( - `
Contenu enfant et projection depuis Angular
`); + `
Contenu enfant et projection depuis Angular
`); }); it(`shouldn't project deleted projections in i18n blocks`, () => { @@ -2850,6 +2728,301 @@ onlyInIvy('Ivy i18n logic').describe('runtime i18n', () => { expect(fixture.nativeElement.textContent).toContain('a b'); }); }); + + describe('viewContainerRef with i18n', () => { + it('should create ViewContainerRef with i18n', () => { + // This test demonstrates an issue with creating a `ViewContainerRef` and having i18n at the + // parent element. The reason this broke is that in this case the `ViewContainerRef` creates + // an dynamic anchor comment but uses `HostTNode` for it which is incorrect. `appendChild` + // then tries to add internationalization to the comment node and fails. + @Component({ + template: ` +
before|
inside
|after
+ ` + }) + class MyApp { + } + + @Directive({selector: '[myDir]'}) + class MyDir { + constructor(vcRef: ViewContainerRef) { + myDir = this; + } + } + let myDir!: MyDir; + + + TestBed.configureTestingModule({declarations: [MyApp, MyDir]}); + const fixture = TestBed.createComponent(MyApp); + fixture.detectChanges(); + expect(myDir).toBeDefined(); + expect(fixture.nativeElement.textContent).toEqual(`before|inside|after`); + }); + }); + + it('should create ICU with attributes', () => { + // This test demonstrates an issue with setting attributes on ICU elements. + // NOTE: This test is extracted from g3. + @Component({ + template: ` +

{ + registerItemCount, plural, + =0 {Your cart} + =1 {Your cart (1 item)} + other { + Your cart ({{ + registerItemCount + }} items) + } + }

` + }) + class MyApp { + registerItemCount = 1; + } + + TestBed.configureTestingModule({declarations: [MyApp]}); + const fixture = TestBed.createComponent(MyApp); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toEqual(`Your cart (1 item)`); + }); + + it('should not insertBeforeIndex non-projected content text', () => { + // This test demonstrates an issue with setting attributes on ICU elements. + // NOTE: This test is extracted from g3. + @Component({template: `
before|TextNotProjected|after
`}) + class MyApp { + } + + @Component({ + selector: 'child', + template: 'CHILD', + }) + class Child { + } + + TestBed.configureTestingModule({declarations: [MyApp, Child]}); + const fixture = TestBed.createComponent(MyApp); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toEqual(`before|CHILD|after`); + }); + + it('should create a pipe inside i18n block', () => { + // This test demonstrates an issue with i18n messing up `getCurrentTNode` which subsequently + // breaks the DI. The issue is that the `i18nStartFirstCreatePass` would create placeholder + // NODES, and than leave `getCurrentTNode` in undetermined state which would then break DI. + // NOTE: This test is extracted from g3. + @Component({ + template: ` +
A
+
{{(null | async)||'B'}}
` + }) + class MyApp { + } + + TestBed.configureTestingModule({declarations: [MyApp]}); + const fixture = TestBed.createComponent(MyApp); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toEqual(`AB`); + }); + + + it('should copy injector information unto placeholder', () => { + // This test demonstrates an issue with i18n Placeholders loosing `injectorIndex` information. + // NOTE: This test is extracted from g3. + @Component({ + template: ` + + + Text + + ` + }) + class MyApp { + } + + @Component({selector: 'parent'}) + class Parent { + } + + @Component({selector: 'middle'}) + class Middle { + } + @Component({selector: 'child'}) + class Child { + constructor(public middle: Middle) { + child = this; + } + } + let child!: Child; + + + TestBed.configureTestingModule({declarations: [MyApp, Parent, Middle, Child]}); + const fixture = TestBed.createComponent(MyApp); + fixture.detectChanges(); + expect(child.middle).toBeInstanceOf(Middle); + }); + + it('should allow container in gotClosestRElement', () => { + // A second iteration of the loop will have `Container` `TNode`s pass through the system. + // NOTE: This test is extracted from g3. + @Component({ + template: ` +
+ X + +
` + }) + class MyApp { + } + + TestBed.configureTestingModule({declarations: [MyApp]}); + const fixture = TestBed.createComponent(MyApp); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toEqual(`XX`); + }); + + + it('should link text after ICU', () => { + // i18n block must restore the current `currentTNode` so that trailing text node can link to it. + // NOTE: This test is extracted from g3. + @Component({ + template: ` + + {{'['}} + {index, plural, =1 {1} other {*}} + {index, plural, =1 {one} other {many}} + {{'-'}} + + + {{'-'}} + {index, plural, =1 {first} other {rest}} + {{']'}} + + / + + {{'['}} + {index, plural, =1 {1} other {*}} + {index, plural, =1 {one} other {many}} + {{'-'}} + + + {{'-'}} + {index, plural, =1 {first} other {rest}} + {{']'}} + + ` + }) + class MyApp { + } + + TestBed.configureTestingModule({declarations: [MyApp]}); + const fixture = TestBed.createComponent(MyApp); + fixture.detectChanges(); + const textContent = fixture.nativeElement.textContent as string; + expect(textContent.split('/').map(s => s.trim())).toEqual([ + '[ 1 one - + - first ] [ * many - + - rest ]', + '[ 1 one - + - first ] [ * many - + - rest ]', + ]); + }); + + it('should ignore non-instantiated ICUs on update', () => { + // Demonstrates an issue of same selector expression used in nested ICUs, causes non + // instantiated nested ICUs to be updated. + // NOTE: This test is extracted from g3. + @Component({ + template: ` + before| + { retention.unit, select, + SECONDS { + {retention.durationInUnits, plural, + =1 {1 second} + other {{{retention.durationInUnits}} seconds} + } + } + DAYS { + {retention.durationInUnits, plural, + =1 {1 day} + other {{{retention.durationInUnits}} days} + } + } + MONTHS { + {retention.durationInUnits, plural, + =1 {1 month} + other {{{retention.durationInUnits}} months} + } + } + YEARS { + {retention.durationInUnits, plural, + =1 {1 year} + other {{{retention.durationInUnits}} years} + } + } + other {} + } + |after. + ` + }) + class MyApp { + retention = { + durationInUnits: 10, + unit: 'SECONDS', + }; + } + + TestBed.configureTestingModule({declarations: [MyApp]}); + const fixture = TestBed.createComponent(MyApp); + fixture.detectChanges(); + const textContent = fixture.nativeElement.textContent as string; + expect(textContent.replace(/\s+/g, ' ').trim()).toEqual(`before| 10 seconds |after.`); + }); + + it('should render attributes defined in ICUs', () => { + // NOTE: This test is extracted from g3. + @Component({ + template: ` +
{ + parameters.length, + plural, + =1 {Affects parameter {{parameters[0].name}}} + other {Affects {{parameters.length}} parameters, including {{parameters[0].name}}} + }
+ ` + }) + class MyApp { + parameters = [{name: 'void_abt_param'}]; + } + + TestBed.configureTestingModule({declarations: [MyApp]}); + const fixture = TestBed.createComponent(MyApp); + fixture.detectChanges(); + const span = (fixture.nativeElement as HTMLElement).querySelector('span')!; + expect(span.getAttribute('attr')).toEqual('should_be_present'); + expect(span.getAttribute('class')).toEqual('parameter-name'); + }); + + it('should support different ICUs cases for each *ngFor iteration', () => { + @Component({ + template: ` +
    +
  • { + item, plural, + =1 {one} + =2 {two} + },
  • +
` + }) + class MyApp { + items = [1, 2]; + } + + TestBed.configureTestingModule({declarations: [MyApp]}); + const fixture = TestBed.createComponent(MyApp); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toEqual(`one,two,`); + + fixture.componentInstance.items = [2, 1]; + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toEqual(`two,one,`); + }); }); function initWithTemplate(compType: Type, template: string) { diff --git a/packages/core/test/animation/animation_integration_spec.ts b/packages/core/test/animation/animation_integration_spec.ts index 648c4967af..02f5704682 100644 --- a/packages/core/test/animation/animation_integration_spec.ts +++ b/packages/core/test/animation/animation_integration_spec.ts @@ -2510,6 +2510,33 @@ describe('animation tests', function() { }); }); + it('should not animate i18n insertBefore', () => { + // I18n uses `insertBefore` API to insert nodes in correct order. Animation assumes that + // any `insertBefore` is a move and tries to animate it. + // NOTE: This test was extracted from `g3` + @Component({ + template: `
Hello World!
`, + animations: [ + trigger( + 'myAnimation', + [ + transition('* => *', [animate(1000)]), + ]), + ] + }) + class Cmp { + } + + TestBed.configureTestingModule({declarations: [Cmp]}); + const fixture = TestBed.createComponent(Cmp); + fixture.detectChanges(); + const players = getLog(); + const span = fixture.debugElement.nativeElement.querySelector('span'); + expect(span.innerText).toEqual('World'); + // We should not insert `ng-star-inserted` into the span class. + expect(span.className).not.toContain('ng-star-inserted'); + }); + describe('animation listeners', () => { it('should trigger a `start` state change listener for when the animation changes state from void => state', fakeAsync(() => { diff --git a/packages/core/test/bundling/cyclic_import/bundle.golden_symbols.json b/packages/core/test/bundling/cyclic_import/bundle.golden_symbols.json index d728771a54..96d90bb28b 100644 --- a/packages/core/test/bundling/cyclic_import/bundle.golden_symbols.json +++ b/packages/core/test/bundling/cyclic_import/bundle.golden_symbols.json @@ -155,9 +155,6 @@ { "name": "generatePropertyAliases" }, - { - "name": "isInCheckNoChangesMode" - }, { "name": "getClosureSafeProperty" }, @@ -173,6 +170,9 @@ { "name": "getCurrentTNode" }, + { + "name": "getCurrentTNodePlaceholderOk" + }, { "name": "getFirstLContainer" }, @@ -245,6 +245,9 @@ { "name": "isCurrentTNodeParent" }, + { + "name": "isInCheckNoChangesMode" + }, { "name": "isInlineTemplate" }, @@ -281,6 +284,9 @@ { "name": "nativeAppendOrInsertBefore" }, + { + "name": "nativeInsertBefore" + }, { "name": "nextNgElementId" }, @@ -344,6 +350,9 @@ { "name": "setUpAttributes" }, + { + "name": "unwrapRNode" + }, { "name": "updateTransplantedViewCount" }, diff --git a/packages/core/test/bundling/forms/bundle.golden_symbols.json b/packages/core/test/bundling/forms/bundle.golden_symbols.json index e8ecdcc6bb..30129f0e8b 100644 --- a/packages/core/test/bundling/forms/bundle.golden_symbols.json +++ b/packages/core/test/bundling/forms/bundle.golden_symbols.json @@ -797,6 +797,9 @@ { "name": "createDirectivesInstances" }, + { + "name": "createElementNode" + }, { "name": "createElementRef" }, @@ -815,6 +818,9 @@ { "name": "createPlatformFactory" }, + { + "name": "createTNode" + }, { "name": "createTView" }, @@ -857,9 +863,6 @@ { "name": "domRendererFactory3" }, - { - "name": "elementCreate" - }, { "name": "empty" }, @@ -947,9 +950,6 @@ { "name": "generatePropertyAliases" }, - { - "name": "isInCheckNoChangesMode" - }, { "name": "getClosureSafeProperty" }, @@ -965,6 +965,9 @@ { "name": "getCurrentTNode" }, + { + "name": "getCurrentTNodePlaceholderOk" + }, { "name": "getDOM" }, @@ -1097,6 +1100,9 @@ { "name": "hostReportError" }, + { + "name": "icuContainerIterate" + }, { "name": "identity" }, @@ -1181,6 +1187,9 @@ { "name": "isFunction" }, + { + "name": "isInCheckNoChangesMode" + }, { "name": "isInlineTemplate" }, @@ -1484,9 +1493,6 @@ { "name": "setBindingRootForHostBindings" }, - { - "name": "setIsInCheckNoChangesMode" - }, { "name": "setCurrentDirectiveIndex" }, @@ -1514,6 +1520,9 @@ { "name": "setInputsFromAttrs" }, + { + "name": "setIsInCheckNoChangesMode" + }, { "name": "setLocaleId" }, diff --git a/packages/core/test/bundling/hello_world/bundle.golden_symbols.json b/packages/core/test/bundling/hello_world/bundle.golden_symbols.json index f9022bcdb9..d39dda1a43 100644 --- a/packages/core/test/bundling/hello_world/bundle.golden_symbols.json +++ b/packages/core/test/bundling/hello_world/bundle.golden_symbols.json @@ -107,9 +107,6 @@ { "name": "extractPipeDef" }, - { - "name": "isInCheckNoChangesMode" - }, { "name": "getClosureSafeProperty" }, @@ -122,6 +119,9 @@ { "name": "getCurrentTNode" }, + { + "name": "getCurrentTNodePlaceholderOk" + }, { "name": "getFirstLContainer" }, @@ -158,6 +158,9 @@ { "name": "invertObject" }, + { + "name": "isInCheckNoChangesMode" + }, { "name": "isProceduralRenderer" }, @@ -173,6 +176,9 @@ { "name": "nativeAppendOrInsertBefore" }, + { + "name": "nativeInsertBefore" + }, { "name": "nextNgElementId" }, @@ -218,10 +224,16 @@ { "name": "setSelectedIndex" }, + { + "name": "unwrapRNode" + }, { "name": "updateTransplantedViewCount" }, { "name": "viewAttachedToChangeDetector" + }, + { + "name": "ɵɵtext" } ] \ No newline at end of file diff --git a/packages/core/test/bundling/router/bundle.golden_symbols.json b/packages/core/test/bundling/router/bundle.golden_symbols.json index 8f93e8055f..01b94b278a 100644 --- a/packages/core/test/bundling/router/bundle.golden_symbols.json +++ b/packages/core/test/bundling/router/bundle.golden_symbols.json @@ -1031,6 +1031,9 @@ { "name": "createContainerRef" }, + { + "name": "createElementNode" + }, { "name": "createElementRef" }, @@ -1064,6 +1067,9 @@ { "name": "createRouterScroller" }, + { + "name": "createTNode" + }, { "name": "createTView" }, @@ -1139,9 +1145,6 @@ { "name": "domRendererFactory3" }, - { - "name": "elementCreate" - }, { "name": "empty" }, @@ -1259,9 +1262,6 @@ { "name": "getBootstrapListener" }, - { - "name": "isInCheckNoChangesMode" - }, { "name": "getClosureSafeProperty" }, @@ -1280,6 +1280,9 @@ { "name": "getCurrentTNode" }, + { + "name": "getCurrentTNodePlaceholderOk" + }, { "name": "getDOM" }, @@ -1439,6 +1442,9 @@ { "name": "hostReportError" }, + { + "name": "icuContainerIterate" + }, { "name": "identity" }, @@ -1517,6 +1523,9 @@ { "name": "isFunction" }, + { + "name": "isInCheckNoChangesMode" + }, { "name": "isInlineTemplate" }, @@ -1817,9 +1826,6 @@ { "name": "setBindingRootForHostBindings" }, - { - "name": "setIsInCheckNoChangesMode" - }, { "name": "setCurrentDirectiveIndex" }, @@ -1847,6 +1853,9 @@ { "name": "setInputsFromAttrs" }, + { + "name": "setIsInCheckNoChangesMode" + }, { "name": "setLocaleId" }, diff --git a/packages/core/test/bundling/todo/bundle.golden_symbols.json b/packages/core/test/bundling/todo/bundle.golden_symbols.json index e6a729ac4d..a650b90fa8 100644 --- a/packages/core/test/bundling/todo/bundle.golden_symbols.json +++ b/packages/core/test/bundling/todo/bundle.golden_symbols.json @@ -257,6 +257,9 @@ { "name": "createLView" }, + { + "name": "createTNode" + }, { "name": "createTView" }, @@ -329,9 +332,6 @@ { "name": "generatePropertyAliases" }, - { - "name": "isInCheckNoChangesMode" - }, { "name": "getClosureSafeProperty" }, @@ -347,6 +347,9 @@ { "name": "getCurrentTNode" }, + { + "name": "getCurrentTNodePlaceholderOk" + }, { "name": "getDebugContext" }, @@ -440,6 +443,9 @@ { "name": "hasTagAndTypeMatch" }, + { + "name": "icuContainerIterate" + }, { "name": "includeViewProviders" }, @@ -485,6 +491,9 @@ { "name": "isDirectiveHost" }, + { + "name": "isInCheckNoChangesMode" + }, { "name": "isInlineTemplate" }, @@ -641,9 +650,6 @@ { "name": "setBindingRootForHostBindings" }, - { - "name": "setIsInCheckNoChangesMode" - }, { "name": "setCurrentDirectiveIndex" }, @@ -668,6 +674,9 @@ { "name": "setInputsFromAttrs" }, + { + "name": "setIsInCheckNoChangesMode" + }, { "name": "setSelectedIndex" }, diff --git a/packages/core/test/render3/i18n/i18n_insert_before_index_spec.ts b/packages/core/test/render3/i18n/i18n_insert_before_index_spec.ts new file mode 100644 index 0000000000..396c09199e --- /dev/null +++ b/packages/core/test/render3/i18n/i18n_insert_before_index_spec.ts @@ -0,0 +1,183 @@ +/** + * @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 {addTNodeAndUpdateInsertBeforeIndex} from '@angular/core/src/render3/i18n/i18n_insert_before_index'; +import {createTNode} from '@angular/core/src/render3/instructions/shared'; +import {TNode, TNodeType} from '@angular/core/src/render3/interfaces/node'; +import {matchTNode} from '../matchers'; + + +describe('addTNodeAndUpdateInsertBeforeIndex', () => { + function tNode(index: number, type: TNodeType, insertBeforeIndex: number|null = null): TNode { + const tNode = createTNode(null!, null, type, index, null, null); + tNode.insertBeforeIndex = insertBeforeIndex; + return tNode; + } + + function tPlaceholderElementNode(index: number, insertBeforeIndex: number|null = null) { + return tNode(index, TNodeType.Placeholder, insertBeforeIndex); + } + + function tI18NTextNode(index: number, insertBeforeIndex: number|null = null) { + return tNode(index, TNodeType.Element, insertBeforeIndex); + } + + it('should add first node', () => { + const previousTNodes: TNode[] = []; + addTNodeAndUpdateInsertBeforeIndex(previousTNodes, tPlaceholderElementNode(20)); + expect(previousTNodes).toEqual([ + matchTNode({index: 20, insertBeforeIndex: null}), + ]); + }); + + describe('when adding a placeholder', () => { + describe('whose index is greater than those already there', () => { + it('should not update the `insertBeforeIndex` values', () => { + const previousTNodes: TNode[] = [ + tPlaceholderElementNode(20), + tPlaceholderElementNode(21), + ]; + addTNodeAndUpdateInsertBeforeIndex(previousTNodes, tPlaceholderElementNode(22)); + expect(previousTNodes).toEqual([ + matchTNode({index: 20, insertBeforeIndex: null}), + matchTNode({index: 21, insertBeforeIndex: null}), + matchTNode({index: 22, insertBeforeIndex: null}), + ]); + }); + }); + + describe('whose index is smaller than current nodes', () => { + it('should update the previous insertBeforeIndex', () => { + const previousTNodes: TNode[] = [ + tPlaceholderElementNode(20), + tPlaceholderElementNode(21), + ]; + addTNodeAndUpdateInsertBeforeIndex(previousTNodes, tPlaceholderElementNode(19)); + expect(previousTNodes).toEqual([ + matchTNode({index: 20, insertBeforeIndex: 19}), + matchTNode({index: 21, insertBeforeIndex: 19}), + matchTNode({index: 19, insertBeforeIndex: null}), + ]); + }); + + it('should not update the previous insertBeforeIndex if it is already set', () => { + const previousTNodes: TNode[] = [ + tPlaceholderElementNode(20, 19), + tPlaceholderElementNode(21, 19), + tPlaceholderElementNode(19), + ]; + addTNodeAndUpdateInsertBeforeIndex(previousTNodes, tPlaceholderElementNode(18)); + expect(previousTNodes).toEqual([ + matchTNode({index: 20, insertBeforeIndex: 19}), + matchTNode({index: 21, insertBeforeIndex: 19}), + matchTNode({index: 19, insertBeforeIndex: 18}), + matchTNode({index: 18, insertBeforeIndex: null}), + ]); + }); + + it('should not update the previous insertBeforeIndex if it is created after', () => { + const previousTNodes: TNode[] = [ + tPlaceholderElementNode(20, 15), + tPlaceholderElementNode(21, 15), + tPlaceholderElementNode(15), + ]; + addTNodeAndUpdateInsertBeforeIndex(previousTNodes, tPlaceholderElementNode(18)); + expect(previousTNodes).toEqual([ + matchTNode({index: 20, insertBeforeIndex: 15}), + matchTNode({index: 21, insertBeforeIndex: 15}), + matchTNode({index: 15, insertBeforeIndex: null}), + matchTNode({index: 18, insertBeforeIndex: null}), + ]); + }); + }); + }); + + describe('when adding a i18nText', () => { + describe('whose index is greater than those already there', () => { + it('should not update the `insertBeforeIndex` values', () => { + const previousTNodes: TNode[] = [ + tPlaceholderElementNode(20), + tPlaceholderElementNode(21), + ]; + addTNodeAndUpdateInsertBeforeIndex(previousTNodes, tI18NTextNode(22)); + expect(previousTNodes).toEqual([ + matchTNode({index: 20, insertBeforeIndex: 22}), + matchTNode({index: 21, insertBeforeIndex: 22}), + matchTNode({index: 22, insertBeforeIndex: null}), + ]); + }); + }); + + describe('whose index is smaller than current nodes', () => { + it('should update the previous insertBeforeIndex', () => { + const previousTNodes: TNode[] = [ + tPlaceholderElementNode(20), + tPlaceholderElementNode(21), + ]; + addTNodeAndUpdateInsertBeforeIndex(previousTNodes, tI18NTextNode(19)); + expect(previousTNodes).toEqual([ + matchTNode({index: 20, insertBeforeIndex: 19}), + matchTNode({index: 21, insertBeforeIndex: 19}), + matchTNode({index: 19, insertBeforeIndex: null}), + ]); + }); + + it('should not update the previous insertBeforeIndex if it is already set', () => { + const previousTNodes: TNode[] = [ + tPlaceholderElementNode(20, 19), + tPlaceholderElementNode(21, 19), + tPlaceholderElementNode(19), + ]; + addTNodeAndUpdateInsertBeforeIndex(previousTNodes, tI18NTextNode(18)); + expect(previousTNodes).toEqual([ + matchTNode({index: 20, insertBeforeIndex: 19}), + matchTNode({index: 21, insertBeforeIndex: 19}), + matchTNode({index: 19, insertBeforeIndex: 18}), + matchTNode({index: 18, insertBeforeIndex: null}), + ]); + }); + + it('should not update the previous insertBeforeIndex if it is created after', () => { + const previousTNodes: TNode[] = [ + tPlaceholderElementNode(20, 15), + tPlaceholderElementNode(21, 15), + tPlaceholderElementNode(15), + ]; + addTNodeAndUpdateInsertBeforeIndex(previousTNodes, tI18NTextNode(18)); + expect(previousTNodes).toEqual([ + matchTNode({index: 20, insertBeforeIndex: 15}), + matchTNode({index: 21, insertBeforeIndex: 15}), + matchTNode({index: 15, insertBeforeIndex: 18}), + matchTNode({index: 18, insertBeforeIndex: null}), + ]); + }); + }); + }); + + describe('scenario', () => { + it('should rearrange the nodes', () => { + const previousTNodes: TNode[] = []; + addTNodeAndUpdateInsertBeforeIndex(previousTNodes, tPlaceholderElementNode(22)); + addTNodeAndUpdateInsertBeforeIndex(previousTNodes, tPlaceholderElementNode(28)); + addTNodeAndUpdateInsertBeforeIndex(previousTNodes, tPlaceholderElementNode(24)); + addTNodeAndUpdateInsertBeforeIndex(previousTNodes, tPlaceholderElementNode(25)); + addTNodeAndUpdateInsertBeforeIndex(previousTNodes, tI18NTextNode(29)); + addTNodeAndUpdateInsertBeforeIndex(previousTNodes, tPlaceholderElementNode(23)); + addTNodeAndUpdateInsertBeforeIndex(previousTNodes, tPlaceholderElementNode(27)); + expect(previousTNodes).toEqual([ + matchTNode({index: 22, insertBeforeIndex: 29}), + matchTNode({index: 28, insertBeforeIndex: 24}), + matchTNode({index: 24, insertBeforeIndex: 29}), + matchTNode({index: 25, insertBeforeIndex: 29}), + matchTNode({index: 29, insertBeforeIndex: null}), + matchTNode({index: 23, insertBeforeIndex: null}), + matchTNode({index: 27, insertBeforeIndex: null}), + ]); + }); + }); +}); \ No newline at end of file diff --git a/packages/core/test/render3/i18n/i18n_parse_spec.ts b/packages/core/test/render3/i18n/i18n_parse_spec.ts new file mode 100644 index 0000000000..054dd23c7b --- /dev/null +++ b/packages/core/test/render3/i18n/i18n_parse_spec.ts @@ -0,0 +1,289 @@ +/** + * @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 {ɵɵi18nApply, ɵɵi18nExp} from '@angular/core'; +import {applyCreateOpCodes} from '@angular/core/src/render3/i18n/i18n_apply'; +import {i18nStartFirstCreatePass} from '@angular/core/src/render3/i18n/i18n_parse'; +import {getTIcu} from '@angular/core/src/render3/i18n/i18n_util'; +import {IcuType, TI18n} from '@angular/core/src/render3/interfaces/i18n'; +import {HEADER_OFFSET} from '@angular/core/src/render3/interfaces/view'; +import {expect} from '@angular/core/testing/src/testing_internal'; +import {matchTI18n, matchTIcu} from '../matchers'; +import {debugMatch} from '../utils'; +import {ViewFixture} from '../view_fixture'; + +describe('i18n_parse', () => { + let fixture: ViewFixture; + beforeEach(() => fixture = new ViewFixture({decls: 1, vars: 1})); + + describe('icu', () => { + it('should parse simple text', () => { + const tI18n = toT18n('some text'); + expect(tI18n).toEqual(matchTI18n({ + create: debugMatch([ + 'lView[22] = document.createText("some text");', + 'parent.appendChild(lView[22]);', + ]), + update: [], + })); + + fixture.apply(() => applyCreateOpCodes(fixture.lView, tI18n.create, fixture.host, null)); + expect(fixture.host.innerHTML).toEqual('some text'); + }); + + it('should parse simple ICU', () => { + // TData | LView + // ---------------------------+------------------------------- + // ----- DECL ----- + // 20: TI18n | + // ----- VARS ----- + // 21: Binding for ICU | + // ----- EXPANDO ----- + // 22: null | #text(before|) + // 23: TIcu | + // 24: null | currently selected ICU case + // 25: null | #text(caseA) + // 26: null | #text(otherCase) + // 27: null | #text(|after) + const tI18n = toT18n(`before|{ + �0�, select, + A {caseA} + other {otherCase} + }|after`); + expect(tI18n).toEqual(matchTI18n({ + create: debugMatch([ + 'lView[22] = document.createText("before|");', + 'parent.appendChild(lView[22]);', + 'lView[23] = document.createComment("ICU 0:0");', + 'parent.appendChild(lView[23]);', + 'lView[27] = document.createText("|after");', + 'parent.appendChild(lView[27]);', + ]), + update: debugMatch([ + 'if (mask & 0b1) { icuSwitchCase(23, `${lView[i-1]}`); }', + ]) + })); + expect(getTIcu(fixture.tView, 23)).toEqual(matchTIcu({ + type: IcuType.select, + anchorIdx: 23, + currentCaseLViewIndex: 24, + cases: ['A', 'other'], + create: [ + debugMatch([ + 'lView[25] = document.createTextNode("caseA")', + '(lView[0] as Element).appendChild(lView[25])' + ]), + debugMatch([ + 'lView[26] = document.createTextNode("otherCase")', + '(lView[0] as Element).appendChild(lView[26])', + ]) + ], + update: [ + debugMatch([]), + debugMatch([]), + ], + remove: [ + debugMatch(['(lView[0] as Element).remove(lView[25])']), + debugMatch(['(lView[0] as Element).remove(lView[26])']) + ], + })); + + fixture.apply(() => { + applyCreateOpCodes(fixture.lView, tI18n.create, fixture.host, null); + expect(fixture.host.innerHTML).toEqual('before||after'); + }); + fixture.apply(() => { + ɵɵi18nExp('A'); + ɵɵi18nApply(0); // index 0 + HEADER_OFFSET = 20; + expect(fixture.host.innerHTML).toEqual('before|caseA|after'); + }); + fixture.apply(() => { + ɵɵi18nExp('x'); + ɵɵi18nApply(0); // index 0 + HEADER_OFFSET = 20; + expect(fixture.host.innerHTML).toEqual('before|otherCase|after'); + }); + fixture.apply(() => { + ɵɵi18nExp('A'); + ɵɵi18nApply(0); // index 0 + HEADER_OFFSET = 20; + expect(fixture.host.innerHTML).toEqual('before|caseA|after'); + }); + }); + + it('should parse HTML in ICU', () => { + const tI18n = toT18n(`{ + �0�, select, + A {Hello world!} + other {
{�0�, select, 0 {nested0} other {nestedOther}}
} + }`); + fixture.apply(() => { + applyCreateOpCodes(fixture.lView, tI18n.create, fixture.host, null); + expect(fixture.host.innerHTML).toEqual(''); + }); + fixture.apply(() => { + ɵɵi18nExp('A'); + ɵɵi18nApply(0); // index 0 + HEADER_OFFSET = 20; + expect(fixture.host.innerHTML).toEqual('Hello world!'); + }); + fixture.apply(() => { + ɵɵi18nExp('x'); + ɵɵi18nApply(0); // index 0 + HEADER_OFFSET = 20; + expect(fixture.host.innerHTML) + .toEqual('
nestedOther
'); + }); + fixture.apply(() => { + ɵɵi18nExp('A'); + ɵɵi18nApply(0); // index 0 + HEADER_OFFSET = 20; + expect(fixture.host.innerHTML).toEqual('Hello world!'); + }); + }); + + + it('should parse nested ICU', () => { + fixture = new ViewFixture({decls: 1, vars: 3}); + // TData | LView + // ---------------------------+------------------------------- + // ----- DECL ----- + // 20: TI18n | + // ----- VARS ----- + // 21: Binding for parent ICU | + // 22: Binding for child ICU | + // 23: Binding for child ICU | + // ----- EXPANDO ----- + // 24: TIcu (parent) | + // 25: null | currently selected ICU case + // 26: null | #text( parentA ) + // 27: TIcu (child) | + // 28: null | currently selected ICU case + // 29: null | #text(nested0) + // 30: null | #text({{�2�}}) + // 31: null | #text( ) + // 32: null | #text( parentOther ) + const tI18n = toT18n(`{ + �0�, select, + A {parentA {�1�, select, 0 {nested0} other {�2�}}!} + other {parentOther} + }`); + expect(tI18n).toEqual(matchTI18n({ + create: debugMatch([ + 'lView[24] = document.createComment("ICU 0:0");', + 'parent.appendChild(lView[24]);', + ]), + update: debugMatch([ + 'if (mask & 0b1) { icuSwitchCase(24, `${lView[i-1]}`); }', + 'if (mask & 0b10) { icuSwitchCase(27, `${lView[i-2]}`); }', + 'if (mask & 0b100) { icuUpdateCase(27); }', + ]), + })); + expect(getTIcu(fixture.tView, 24)).toEqual(matchTIcu({ + type: IcuType.select, + anchorIdx: 24, + currentCaseLViewIndex: 25, + cases: ['A', 'other'], + create: [ + debugMatch([ + 'lView[26] = document.createTextNode("parentA ")', + '(lView[0] as Element).appendChild(lView[26])', + 'lView[27] = document.createComment("nested ICU 0")', + '(lView[0] as Element).appendChild(lView[27])', + 'lView[31] = document.createTextNode("!")', + '(lView[0] as Element).appendChild(lView[31])', + ]), + debugMatch([ + 'lView[32] = document.createTextNode("parentOther")', + '(lView[0] as Element).appendChild(lView[32])', + ]) + ], + update: [ + debugMatch([]), + debugMatch([]), + ], + remove: [ + debugMatch([ + '(lView[0] as Element).remove(lView[26])', + 'removeNestedICU(27)', + '(lView[0] as Element).remove(lView[27])', + '(lView[0] as Element).remove(lView[31])', + ]), + debugMatch([ + '(lView[0] as Element).remove(lView[32])', + ]) + ], + })); + + expect(getTIcu(fixture.tView, 27)).toEqual(matchTIcu({ + type: IcuType.select, + anchorIdx: 27, + currentCaseLViewIndex: 28, + cases: ['0', 'other'], + create: [ + debugMatch([ + 'lView[29] = document.createTextNode("nested0")', + '(lView[0] as Element).appendChild(lView[29])' + ]), + debugMatch([ + 'lView[30] = document.createTextNode("")', + '(lView[0] as Element).appendChild(lView[30])', + ]) + ], + update: [ + debugMatch([]), + debugMatch([ + 'if (mask & 0b100) { (lView[30] as Text).textContent = `${lView[i-3]}`; }', + ]), + ], + remove: [ + debugMatch(['(lView[0] as Element).remove(lView[29])']), + debugMatch(['(lView[0] as Element).remove(lView[30])']) + ], + })); + + fixture.apply(() => { + applyCreateOpCodes(fixture.lView, tI18n.create, fixture.host, null); + expect(fixture.host.innerHTML).toEqual(''); + }); + fixture.apply(() => { + ɵɵi18nExp('A'); + ɵɵi18nExp('0'); + ɵɵi18nExp('value1'); + ɵɵi18nApply(0); // index 0 + HEADER_OFFSET = 20; + expect(fixture.host.innerHTML).toEqual('parentA nested0!'); + }); + fixture.apply(() => { + ɵɵi18nExp('A'); + ɵɵi18nExp('x'); + ɵɵi18nExp('value1'); + ɵɵi18nApply(0); // index 0 + HEADER_OFFSET = 20; + expect(fixture.host.innerHTML).toEqual('parentA value1!'); + }); + fixture.apply(() => { + ɵɵi18nExp('x'); + ɵɵi18nExp('x'); + ɵɵi18nExp('value2'); + ɵɵi18nApply(0); // index 0 + HEADER_OFFSET = 20; + expect(fixture.host.innerHTML).toEqual('parentOther'); + }); + fixture.apply(() => { + ɵɵi18nExp('A'); + ɵɵi18nExp('A'); + ɵɵi18nExp('value2'); + ɵɵi18nApply(0); // index 0 + HEADER_OFFSET = 20; + expect(fixture.host.innerHTML).toEqual('parentA value2!'); + }); + }); + }); + + function toT18n(text: string) { + const tNodeIndex = 0; + fixture.enterView(); + i18nStartFirstCreatePass(fixture.tView, 0, fixture.lView, tNodeIndex, text, -1); + fixture.leaveView(); + const tI18n = fixture.tView.data[tNodeIndex + HEADER_OFFSET] as TI18n; + expect(tI18n).toEqual(matchTI18n({})); + return tI18n; + } +}); diff --git a/packages/core/test/render3/i18n/i18n_spec.ts b/packages/core/test/render3/i18n/i18n_spec.ts index 42bc350251..c1447114ab 100644 --- a/packages/core/test/render3/i18n/i18n_spec.ts +++ b/packages/core/test/render3/i18n/i18n_spec.ts @@ -7,41 +7,44 @@ */ import {ɵɵi18nAttributes, ɵɵi18nPostprocess, ɵɵi18nStart} from '@angular/core'; -import {getTranslationForTemplate} from '@angular/core/src/render3/i18n/i18n_parse'; -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 {ɵɵi18n} from '@angular/core/src/core'; +import {getTranslationForTemplate, i18nStartFirstCreatePass} from '@angular/core/src/render3/i18n/i18n_parse'; +import {getTIcu} from '@angular/core/src/render3/i18n/i18n_util'; +import {TElementNode, TNodeType} from '@angular/core/src/render3/interfaces/node'; +import {getCurrentTNode} from '@angular/core/src/render3/state'; +import {ɵɵelementEnd, ɵɵelementStart} from '../../../src/render3/instructions/all'; +import {I18nCreateOpCode, I18nUpdateOpCodes, TI18n, TIcu} from '../../../src/render3/interfaces/i18n'; +import {HEADER_OFFSET, LView, TVIEW, TView} from '../../../src/render3/interfaces/view'; import {getNativeByIndex} from '../../../src/render3/util/view_utils'; +import {matchTNode} from '../matchers'; import {TemplateFixture} from '../render_util'; import {debugMatch} from '../utils'; +import {ViewFixture} from '../view_fixture'; + describe('Runtime i18n', () => { - afterEach(() => { - setDelayProjection(false); - }); describe('getTranslationForTemplate', () => { it('should crop messages for the selected template', () => { let message = `simple text`; - expect(getTranslationForTemplate(message)).toEqual(message); + expect(getTranslationForTemplate(message, -1)).toEqual(message); message = `Hello �0�!`; - expect(getTranslationForTemplate(message)).toEqual(message); + expect(getTranslationForTemplate(message, -1)).toEqual(message); message = `Hello �#2��0��/#2�!`; - expect(getTranslationForTemplate(message)).toEqual(message); + expect(getTranslationForTemplate(message, -1)).toEqual(message); // Embedded sub-templates message = `�0� is rendered as: �*2:1�before�*1:2�middle�/*1:2�after�/*2:1�!`; - expect(getTranslationForTemplate(message)).toEqual('�0� is rendered as: �*2:1��/*2:1�!'); + expect(getTranslationForTemplate(message, -1)).toEqual('�0� is rendered as: �*2:1��/*2:1�!'); expect(getTranslationForTemplate(message, 1)).toEqual('before�*1:2��/*1:2�after'); expect(getTranslationForTemplate(message, 2)).toEqual('middle'); // Embedded & sibling sub-templates message = `�0� is rendered as: �*2:1�before�*1:2�middle�/*1:2�after�/*2:1� and also �*4:3�before�*1:4�middle�/*1:4�after�/*4:3�!`; - expect(getTranslationForTemplate(message)) + expect(getTranslationForTemplate(message, -1)) .toEqual('�0� is rendered as: �*2:1��/*2:1� and also �*4:3��/*4:3�!'); expect(getTranslationForTemplate(message, 1)).toEqual('before�*1:2��/*1:2�after'); expect(getTranslationForTemplate(message, 2)).toEqual('middle'); @@ -51,20 +54,19 @@ describe('Runtime i18n', () => { it('should throw if the template is malformed', () => { const message = `�*2:1�message!`; - expect(() => getTranslationForTemplate(message)).toThrowError(/Tag mismatch/); + expect(() => getTranslationForTemplate(message, -1)).toThrowError(/Tag mismatch/); }); }); + let tView: TView; + function getOpCodes( - messageOrAtrs: string|string[], createTemplate: () => void, updateTemplate: (() => void)|null, - nbDecls: number, index: number): TI18n|I18nUpdateOpCodes { - const fixture = new TemplateFixture({ - create: createTemplate, - update: updateTemplate || undefined, - decls: nbDecls, - consts: [messageOrAtrs] - }); - const tView = fixture.hostView[TVIEW]; + messageOrAtrs: string|string[], createTemplate: () => void, + updateTemplate: (() => void)|undefined, nbDecls: number, index: number): TI18n| + I18nUpdateOpCodes { + const fixture = new TemplateFixture( + {create: createTemplate, update: updateTemplate, decls: nbDecls, consts: [messageOrAtrs]}); + tView = fixture.hostView[TVIEW]; return tView.data[index + HEADER_OFFSET] as TI18n; } @@ -72,19 +74,19 @@ describe('Runtime i18n', () => { it('for text', () => { const message = 'simple text'; const nbConsts = 1; - const index = 0; + const index = 1; const opCodes = getOpCodes(message, () => { + ɵɵelementStart(0, 'div'); ɵɵi18nStart(index, 0); - }, null, nbConsts, index) as TI18n; + ɵɵelementEnd(); + }, undefined, nbConsts, index) as TI18n; expect(opCodes).toEqual({ - vars: 1, create: debugMatch([ - 'lView[1] = document.createTextNode("simple text")', - '(lView[0] as Element).appendChild(lView[1])' + `lView[${HEADER_OFFSET + 1}] = document.createText("simple text");`, + `parent.appendChild(lView[${HEADER_OFFSET + 1}]);`, ]), update: [], - icus: null }); }); @@ -95,29 +97,23 @@ describe('Runtime i18n', () => { const nbConsts = 4; const index = 1; const opCodes = getOpCodes(message, () => { + ɵɵelementStart(0, 'div'); ɵɵi18nStart(index, 0); - }, null, nbConsts, index); + ɵɵelementEnd(); + }, undefined, nbConsts, index); expect(opCodes).toEqual({ - vars: 5, create: debugMatch([ - 'lView[4] = document.createTextNode("Hello ")', - '(lView[1] as Element).appendChild(lView[4])', - '(lView[1] as Element).appendChild(lView[2])', - 'lView[5] = document.createTextNode("world")', - '(lView[2] as Element).appendChild(lView[5])', - 'setCurrentTNode(tView.data[2] as TNode)', - 'lView[6] = document.createTextNode(" and ")', - '(lView[1] as Element).appendChild(lView[6])', - '(lView[1] as Element).appendChild(lView[3])', - 'lView[7] = document.createTextNode("universe")', - '(lView[3] as Element).appendChild(lView[7])', - 'setCurrentTNode(tView.data[3] as TNode)', - 'lView[8] = document.createTextNode("!")', - '(lView[1] as Element).appendChild(lView[8])', + `lView[${HEADER_OFFSET + 4}] = document.createText("Hello ");`, + `parent.appendChild(lView[${HEADER_OFFSET + 4}]);`, + `lView[${HEADER_OFFSET + 5}] = document.createText("world");`, + `lView[${HEADER_OFFSET + 6}] = document.createText(" and ");`, + `parent.appendChild(lView[${HEADER_OFFSET + 6}]);`, + `lView[${HEADER_OFFSET + 7}] = document.createText("universe");`, + `lView[${HEADER_OFFSET + 8}] = document.createText("!");`, + `parent.appendChild(lView[${HEADER_OFFSET + 8}]);`, ]), update: [], - icus: null }); }); @@ -126,22 +122,23 @@ describe('Runtime i18n', () => { const nbConsts = 2; const index = 1; const opCodes = getOpCodes(message, () => { + ɵɵelementStart(0, 'div'); ɵɵi18nStart(index, 0); - }, null, nbConsts, index); + ɵɵelementEnd(); + }, undefined, nbConsts, index); expect((opCodes as any).update.debug).toEqual([ - 'if (mask & 0b1) { (lView[2] as Text).textContent = `Hello ${lView[1]}!`; }' + 'if (mask & 0b1) { (lView[22] as Text).textContent = `Hello ${lView[i-1]}!`; }' ]); expect(opCodes).toEqual({ - vars: 1, create: debugMatch([ - 'lView[2] = document.createTextNode("")', - '(lView[1] as Element).appendChild(lView[2])', + `lView[${HEADER_OFFSET + 2}] = document.createText("");`, + `parent.appendChild(lView[${HEADER_OFFSET + 2}]);`, + ]), + update: debugMatch([ + 'if (mask & 0b1) { (lView[22] as Text).textContent = `Hello ${lView[i-1]}!`; }', ]), - update: debugMatch( - ['if (mask & 0b1) { (lView[2] as Text).textContent = `Hello ${lView[1]}!`; }']), - icus: null }); }); @@ -150,18 +147,19 @@ describe('Runtime i18n', () => { const nbConsts = 2; const index = 1; const opCodes = getOpCodes(message, () => { + ɵɵelementStart(0, 'div'); ɵɵi18nStart(index, 0); - }, null, nbConsts, index); + ɵɵelementEnd(); + }, undefined, nbConsts, index); expect(opCodes).toEqual({ - vars: 1, create: debugMatch([ - 'lView[2] = document.createTextNode("")', '(lView[1] as Element).appendChild(lView[2])' + `lView[${HEADER_OFFSET + 2}] = document.createText("");`, + `parent.appendChild(lView[${HEADER_OFFSET + 2}]);`, ]), update: debugMatch([ - 'if (mask & 0b11) { (lView[2] as Text).textContent = `Hello ${lView[1]} and ${lView[2]}, again ${lView[1]}!`; }' + 'if (mask & 0b11) { (lView[22] as Text).textContent = `Hello ${lView[i-1]} and ${lView[i-2]}, again ${lView[i-1]}!`; }', ]), - icus: null }); }); @@ -182,63 +180,56 @@ describe('Runtime i18n', () => { let nbConsts = 3; let index = 1; let opCodes = getOpCodes(message, () => { + ɵɵelementStart(0, 'div'); ɵɵi18nStart(index, 0); - }, null, nbConsts, index); + ɵɵelementEnd(); + }, undefined, nbConsts, index); expect(opCodes).toEqual({ - vars: 2, create: debugMatch([ - 'lView[3] = document.createTextNode("")', '(lView[1] as Element).appendChild(lView[3])', - '(lView[1] as Element).appendChild(lView[16381])', - 'lView[4] = document.createTextNode("!")', '(lView[1] as Element).appendChild(lView[4])' + `lView[${HEADER_OFFSET + 3}] = document.createText("");`, + `parent.appendChild(lView[${HEADER_OFFSET + 3}]);`, + `lView[${HEADER_OFFSET + 4}] = document.createText("!");`, + `parent.appendChild(lView[${HEADER_OFFSET + 4}]);`, ]), update: debugMatch([ - 'if (mask & 0b1) { (lView[3] as Text).textContent = `${lView[1]} is rendered as: `; }' + 'if (mask & 0b1) { (lView[23] as Text).textContent = `${lView[i-1]} is rendered as: `; }', ]), - icus: null }); /**** First sub-template ****/ // �#1:1�before�*2:2�middle�/*2:2�after�/#1:1� nbConsts = 3; - index = 0; + index = 1; opCodes = getOpCodes(message, () => { + ɵɵelementStart(0, 'div'); ɵɵi18nStart(index, 0, 1); - }, null, nbConsts, index); + }, undefined, nbConsts, index); expect(opCodes).toEqual({ - vars: 2, create: debugMatch([ - '(lView[0] as Element).appendChild(lView[1])', - 'lView[3] = document.createTextNode("before")', - '(lView[1] as Element).appendChild(lView[3])', - '(lView[1] as Element).appendChild(lView[16381])', - 'lView[4] = document.createTextNode("after")', - '(lView[1] as Element).appendChild(lView[4])', 'setCurrentTNode(tView.data[1] as TNode)' + `lView[${HEADER_OFFSET + 3}] = document.createText("before");`, + `lView[${HEADER_OFFSET + 4}] = document.createText("after");`, ]), update: [], - icus: null }); /**** Second sub-template ****/ // middle nbConsts = 2; - index = 0; + index = 1; opCodes = getOpCodes(message, () => { + ɵɵelementStart(0, 'div'); ɵɵi18nStart(index, 0, 2); - }, null, nbConsts, index); + }, undefined, nbConsts, index); expect(opCodes).toEqual({ - vars: 1, create: debugMatch([ - '(lView[0] as Element).appendChild(lView[1])', - 'lView[2] = document.createTextNode("middle")', - '(lView[1] as Element).appendChild(lView[2])', 'setCurrentTNode(tView.data[1] as TNode)' + `lView[${HEADER_OFFSET + 2}] = document.createText("middle");`, ]), update: [], - icus: null }); }); @@ -248,82 +239,81 @@ describe('Runtime i18n', () => { =1 {one email} other {�0� emails} }`; - const nbConsts = 1; - const index = 0; + const nbConsts = 2; + const index = 1; const opCodes = getOpCodes(message, () => { + ɵɵelementStart(0, 'div'); ɵɵi18nStart(index, 0); - }, null, nbConsts, index) as TI18n; + ɵɵelementEnd(); + }, undefined, nbConsts, index) as TI18n; expect(opCodes).toEqual({ - vars: 6, - update: debugMatch([ - 'if (mask & 0b1) { icuSwitchCase(lView[1] as Comment, 0, `${lView[1]}`); }', - 'if (mask & 0b11) { icuUpdateCase(lView[1] as Comment, 0); }', - ]), create: debugMatch([ - 'lView[1] = document.createComment("ICU 1")', - '(lView[0] as Element).appendChild(lView[1])', + `lView[${HEADER_OFFSET + 2}] = document.createComment("ICU 1:0");`, + `parent.appendChild(lView[${HEADER_OFFSET + 2}]);`, ]), - icus: [{ - type: 1, - currentCaseLViewIndex: 22, - vars: [5, 4, 4], - childIcus: [[], [], []], - cases: ['0', '1', 'other'], - create: [ - debugMatch([ - 'lView[3] = document.createTextNode("no ")', - '(lView[1] as Element).appendChild(lView[3])', - '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[3] = document.createTextNode("one ")', - '(lView[1] as Element).appendChild(lView[3])', - '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[3] = document.createTextNode("")', - '(lView[1] as Element).appendChild(lView[3])', - '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[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[3])', - '(lView[0] as Element).remove(lView[5])', - '(lView[0] as Element).remove(lView[4])', - ]), - debugMatch([ - '(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[3] as Text).textContent = `${lView[1]} `; }', - 'if (mask & 0b10) { (lView[4] as Element).setAttribute(\'title\', `${lView[2]}`); }' - ]) - ] - }] + update: debugMatch([ + 'if (mask & 0b1) { icuSwitchCase(22, `${lView[i-1]}`); }', + 'if (mask & 0b1) { icuUpdateCase(22); }', + ]), + }); + expect(getTIcu(tView, 22)).toEqual({ + type: 1, + currentCaseLViewIndex: 23, + anchorIdx: 22, + cases: ['0', '1', 'other'], + create: [ + debugMatch([ + `lView[${HEADER_OFFSET + 4}] = document.createTextNode("no ")`, + `(lView[${HEADER_OFFSET + 0}] as Element).appendChild(lView[${HEADER_OFFSET + 4}])`, + 'lView[25] = document.createElement("b")', + `(lView[${HEADER_OFFSET + 0}] as Element).appendChild(lView[${HEADER_OFFSET + 5}])`, + '(lView[25] as Element).setAttribute("title", "none")', + `lView[${HEADER_OFFSET + 6}] = document.createTextNode("emails")`, + `(lView[${HEADER_OFFSET + 5}] as Element).appendChild(lView[${HEADER_OFFSET + 6}])`, + `lView[${HEADER_OFFSET + 7}] = document.createTextNode("!")`, + `(lView[${HEADER_OFFSET + 0}] as Element).appendChild(lView[${HEADER_OFFSET + 7}])`, + ]), + debugMatch([ + `lView[${HEADER_OFFSET + 8}] = document.createTextNode("one ")`, + `(lView[${HEADER_OFFSET + 0}] as Element).appendChild(lView[${HEADER_OFFSET + 8}])`, + 'lView[29] = document.createElement("i")', + `(lView[${HEADER_OFFSET + 0}] as Element).appendChild(lView[${HEADER_OFFSET + 9}])`, + 'lView[30] = document.createTextNode("email")', + '(lView[29] as Element).appendChild(lView[30])', + ]), + debugMatch([ + 'lView[31] = document.createTextNode("")', + '(lView[20] as Element).appendChild(lView[31])', + 'lView[32] = document.createElement("span")', + '(lView[20] as Element).appendChild(lView[32])', + 'lView[33] = document.createTextNode("emails")', + '(lView[32] as Element).appendChild(lView[33])', + ]), + ], + remove: [ + debugMatch([ + '(lView[0] as Element).remove(lView[24])', + '(lView[0] as Element).remove(lView[25])', + '(lView[0] as Element).remove(lView[27])', + ]), + debugMatch([ + '(lView[0] as Element).remove(lView[28])', + '(lView[0] as Element).remove(lView[29])', + ]), + debugMatch([ + '(lView[0] as Element).remove(lView[31])', + '(lView[0] as Element).remove(lView[32])', + ]), + ], + update: [ + debugMatch([]), + debugMatch([]), + debugMatch([ + 'if (mask & 0b1) { (lView[31] as Text).textContent = `${lView[i-1]} `; }', + 'if (mask & 0b10) { (lView[32] as Element).setAttribute(\'title\', `${lView[i-2]}`); }', + ]), + ] }); }); @@ -336,91 +326,91 @@ describe('Runtime i18n', () => { other {animals} }!} }`; - const nbConsts = 1; - const index = 0; + const nbConsts = 2; + const index = 1; const opCodes = getOpCodes(message, () => { - ɵɵi18nStart(index, 0); - }, null, nbConsts, index); + ɵɵelementStart(0, 'div'); + ɵɵi18n(index, 0); + ɵɵelementEnd(); + }, undefined, nbConsts, index); expect(opCodes).toEqual({ - vars: 9, create: debugMatch([ - 'lView[1] = document.createComment("ICU 1")', - '(lView[0] as Element).appendChild(lView[1])' + `lView[${HEADER_OFFSET + 2}] = document.createComment("ICU 1:0");`, + `parent.appendChild(lView[${HEADER_OFFSET + 2}]);`, ]), update: debugMatch([ - 'if (mask & 0b1) { icuSwitchCase(lView[1] as Comment, 1, `${lView[1]}`); }', - 'if (mask & 0b11) { icuUpdateCase(lView[1] as Comment, 1); }' + 'if (mask & 0b1) { icuSwitchCase(22, `${lView[i-1]}`); }', + 'if (mask & 0b10) { icuSwitchCase(26, `${lView[i-2]}`); }', + 'if (mask & 0b1) { icuUpdateCase(22); }', ]), - icus: [ - { - type: 0, - vars: [2, 2, 2], - currentCaseLViewIndex: 26, - childIcus: [[], [], []], - cases: ['cat', 'dog', 'other'], - create: [ - debugMatch([ - 'lView[7] = document.createTextNode("cats")', - '(lView[4] as Element).appendChild(lView[7])' - ]), - debugMatch([ - 'lView[7] = document.createTextNode("dogs")', - '(lView[4] as Element).appendChild(lView[7])' - ]), - debugMatch([ - 'lView[7] = document.createTextNode("animals")', - '(lView[4] as Element).appendChild(lView[7])' - ]), - ], - remove: [ - 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([]), - debugMatch([]), - debugMatch([]), - ] - }, - { - type: 1, - vars: [2, 6], - childIcus: [[], [0]], - currentCaseLViewIndex: 22, - cases: ['0', 'other'], - create: [ - debugMatch([ - 'lView[3] = document.createTextNode("zero")', - '(lView[1] as Element).appendChild(lView[3])' - ]), - debugMatch([ - 'lView[3] = document.createTextNode("")', - '(lView[1] as Element).appendChild(lView[3])', - '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[3])']), - debugMatch([ - '(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[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); }' - ]), - ] - } - ] + }); + expect(getTIcu(tView, 22)).toEqual({ + type: 1, + anchorIdx: 22, + currentCaseLViewIndex: 23, + cases: ['0', 'other'], + create: [ + debugMatch([ + `lView[${HEADER_OFFSET + 4}] = document.createTextNode("zero")`, + `(lView[${HEADER_OFFSET + 0}] as Element).appendChild(lView[${HEADER_OFFSET + 4}])`, + ]), + debugMatch([ + `lView[${HEADER_OFFSET + 5}] = document.createTextNode("")`, + `(lView[${HEADER_OFFSET + 0}] as Element).appendChild(lView[${HEADER_OFFSET + 5}])`, + 'lView[26] = document.createComment("nested ICU 0")', + `(lView[${HEADER_OFFSET + 0}] as Element).appendChild(lView[${HEADER_OFFSET + 6}])`, + 'lView[31] = document.createTextNode("!")', + '(lView[20] as Element).appendChild(lView[31])', + ]), + ], + update: [ + debugMatch([]), + debugMatch([ + 'if (mask & 0b1) { (lView[25] as Text).textContent = `${lView[i-1]} `; }', + ]), + ], + remove: [ + debugMatch([ + '(lView[0] as Element).remove(lView[24])', + ]), + debugMatch([ + '(lView[0] as Element).remove(lView[25])', + 'removeNestedICU(26)', + '(lView[0] as Element).remove(lView[26])', + '(lView[0] as Element).remove(lView[31])', + ]), + ], + }); + expect(tView.data[26]).toEqual({ + type: 0, + anchorIdx: 26, + currentCaseLViewIndex: 27, + cases: ['cat', 'dog', 'other'], + create: [ + debugMatch([ + `lView[${HEADER_OFFSET + 8}] = document.createTextNode("cats")`, + `(lView[${HEADER_OFFSET + 0}] as Element).appendChild(lView[${HEADER_OFFSET + 8}])`, + ]), + debugMatch([ + `lView[${HEADER_OFFSET + 9}] = document.createTextNode("dogs")`, + `(lView[${HEADER_OFFSET + 0}] as Element).appendChild(lView[${HEADER_OFFSET + 9}])`, + ]), + debugMatch([ + 'lView[30] = document.createTextNode("animals")', + '(lView[20] as Element).appendChild(lView[30])', + ]), + ], + update: [ + debugMatch([]), + debugMatch([]), + debugMatch([]), + ], + remove: [ + debugMatch(['(lView[0] as Element).remove(lView[28])']), + debugMatch(['(lView[0] as Element).remove(lView[29])']), + debugMatch(['(lView[0] as Element).remove(lView[30])']) + ], }); }); }); @@ -459,10 +449,10 @@ describe('Runtime i18n', () => { ɵɵelementStart(0, 'div'); ɵɵi18nAttributes(index, 0); ɵɵelementEnd(); - }, null, nbConsts, index); + }, undefined, nbConsts, index); expect(opCodes).toEqual(debugMatch([ - 'if (mask & 0b1) { (lView[0] as Element).setAttribute(\'title\', `Hello ${lView[1]}!`); }' + 'if (mask & 0b1) { (lView[20] as Element).setAttribute(\'title\', `Hello ${lView[i-1]}!`); }', ])); }); @@ -475,10 +465,10 @@ describe('Runtime i18n', () => { ɵɵelementStart(0, 'div'); ɵɵi18nAttributes(index, 0); ɵɵelementEnd(); - }, null, nbConsts, index); + }, undefined, nbConsts, index); expect(opCodes).toEqual(debugMatch([ - 'if (mask & 0b11) { (lView[0] as Element).setAttribute(\'title\', `Hello ${lView[1]} and ${lView[2]}, again ${lView[1]}!`); }' + 'if (mask & 0b11) { (lView[20] as Element).setAttribute(\'title\', `Hello ${lView[i-1]} and ${lView[i-2]}, again ${lView[i-1]}!`); }', ])); }); @@ -491,11 +481,11 @@ describe('Runtime i18n', () => { ɵɵelementStart(0, 'div'); ɵɵi18nAttributes(index, 0); ɵɵelementEnd(); - }, null, nbConsts, index); + }, undefined, nbConsts, index); expect(opCodes).toEqual(debugMatch([ - 'if (mask & 0b1) { (lView[0] as Element).setAttribute(\'title\', `Hello ${lView[1]}!`); }', - 'if (mask & 0b1) { (lView[0] as Element).setAttribute(\'aria-label\', `Hello ${lView[1]}!`); }' + 'if (mask & 0b1) { (lView[20] as Element).setAttribute(\'title\', `Hello ${lView[i-1]}!`); }', + 'if (mask & 0b1) { (lView[20] as Element).setAttribute(\'aria-label\', `Hello ${lView[i-1]}!`); }', ])); }); }); @@ -638,4 +628,125 @@ describe('Runtime i18n', () => { .toThrowError(); }); }); -}); \ No newline at end of file + + describe('i18nStartFirstCreatePass', () => { + let fixture: ViewFixture; + let divTNode: TElementNode; + const DECLS = 20; + const VARS = 10; + beforeEach(() => { + fixture = new ViewFixture({decls: DECLS, vars: VARS}); + fixture.enterView(); + ɵɵelementStart(0, 'div'); + divTNode = getCurrentTNode() as TElementNode; + }); + + afterEach(ViewFixture.cleanUp); + + function i18nRangeOffset(offset: number): number { + return HEADER_OFFSET + DECLS + VARS + offset; + } + + function i18nRangeOffsetOpcode( + offset: number, + {appendLater, comment}: {appendLater?: boolean, comment?: boolean} = {}): number { + let index = i18nRangeOffset(offset) << I18nCreateOpCode.SHIFT; + if (!appendLater) { + index |= I18nCreateOpCode.APPEND_EAGERLY; + } + if (comment) { + index |= I18nCreateOpCode.COMMENT; + } + return index; + } + + it('should process text node with no siblings and no children', () => { + i18nStartFirstCreatePass(fixture.tView, 0, fixture.lView, 1, 'Hello World!', -1); + const ti18n = fixture.tView.data[HEADER_OFFSET + 1] as TI18n; + // Expect that we only create the `Hello World!` text node and nothing else. + expect(ti18n.create).toEqual([ + i18nRangeOffsetOpcode(0), 'Hello World!', // + ]); + const lViewDebug = fixture.lView.debug!; + expect(lViewDebug.template).toEqual('
#text
'); + }); + + it('should process text with a child node', () => { + i18nStartFirstCreatePass(fixture.tView, 0, fixture.lView, 1, 'Hello �#2��/#2�!', -1); + const ti18n = fixture.tView.data[HEADER_OFFSET + 1] as TI18n; + expect(ti18n.create).toEqual([ + i18nRangeOffsetOpcode(0), 'Hello ', // + i18nRangeOffsetOpcode(1), '!', // + ]); + // Leave behind `Placeholder` to be picked up by `TNode` creation. + expect(fixture.tView.data[HEADER_OFFSET + 2]).toEqual(matchTNode({ + type: TNodeType.Placeholder, + // It should insert itself in front of "!" + insertBeforeIndex: i18nRangeOffset(1), + })); + const lViewDebug = fixture.lView.debug!; + expect(lViewDebug.template).toEqual('
#text#text
'); + }); + + it('should process text with a child node that has text', () => { + i18nStartFirstCreatePass(fixture.tView, 0, fixture.lView, 1, 'Hello �#2�World�/#2�!', -1); + const ti18n = fixture.tView.data[HEADER_OFFSET + 1] as TI18n; + expect(ti18n.create).toEqual([ + i18nRangeOffsetOpcode(0), 'Hello ', // + i18nRangeOffsetOpcode(1, {appendLater: true}), 'World', // + i18nRangeOffsetOpcode(2), '!', // + ]); + // Leave behind `Placeholder` to be picked up by `TNode` creation. + expect(fixture.tView.data[HEADER_OFFSET + 2]).toEqual(matchTNode({ + type: TNodeType.Placeholder, + insertBeforeIndex: [ + i18nRangeOffset(2), // It should insert itself in front of "!" + i18nRangeOffset(1), // It should append "World" + ] + })); + }); + + it('should process text with a child node that has text and with bindings', () => { + i18nStartFirstCreatePass( + fixture.tView, 0, fixture.lView, 1, + '�0� �#2��1��/#2�!' /* {{salutation}} {{name}}! */, -1); + const ti18n = fixture.tView.data[HEADER_OFFSET + 1] as TI18n; + expect(ti18n.create).toEqual([ + i18nRangeOffsetOpcode(0), '', // 1 is saved for binding + i18nRangeOffsetOpcode(1, {appendLater: true}), '', // 3 is saved for binding + i18nRangeOffsetOpcode(2), '!', // + ]); + // Leave behind `insertBeforeIndex` to be picked up by `TNode` creation. + expect(fixture.tView.data[HEADER_OFFSET + 2]).toEqual(matchTNode({ + type: TNodeType.Placeholder, + insertBeforeIndex: [ + i18nRangeOffset(2), // It should insert itself in front of "!" + i18nRangeOffset(1), // It should append child text node "{{name}}" + ], + })); + expect(ti18n.update).toEqual(debugMatch([ + 'if (mask & 0b1) { (lView[50] as Text).textContent = `${lView[i-1]} `; }', + 'if (mask & 0b10) { (lView[51] as Text).textContent = `${lView[i-2]}`; }' + ])); + const lViewDebug = fixture.lView.debug!; + expect(lViewDebug.template).toEqual('
#text#text#text
'); + }); + + it('should process text with a child template', () => { + i18nStartFirstCreatePass(fixture.tView, 0, fixture.lView, 1, 'Hello �*2:1�World�/*2:1�!', -1); + const ti18n = fixture.tView.data[HEADER_OFFSET + 1] as TI18n; + expect(ti18n.create.debug).toEqual([ + 'lView[50] = document.createText("Hello ");', + 'parent.appendChild(lView[50]);', + 'lView[51] = document.createText("!");', + 'parent.appendChild(lView[51]);', + ]); + // Leave behind `Placeholder` to be picked up by `TNode` creation. + // It should insert itself in front of "!" + expect(fixture.tView.data[HEADER_OFFSET + 2]).toEqual(matchTNode({ + type: TNodeType.Placeholder, + insertBeforeIndex: 51, + })); + }); + }); +}); diff --git a/packages/core/test/render3/i18n_debug_spec.ts b/packages/core/test/render3/i18n_debug_spec.ts index def02d69dd..68a8cb9306 100644 --- a/packages/core/test/render3/i18n_debug_spec.ts +++ b/packages/core/test/render3/i18n_debug_spec.ts @@ -6,8 +6,8 @@ * found in the LICENSE file at https://angular.io/license */ -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'; +import {i18nCreateOpCodesToString, i18nMutateOpCodesToString, i18nUpdateOpCodesToString} from '@angular/core/src/render3/i18n/i18n_debug'; +import {COMMENT_MARKER, ELEMENT_MARKER, I18nCreateOpCode, I18nMutateOpCode, I18nUpdateOpCode} from '@angular/core/src/render3/interfaces/i18n'; describe('i18n debug', () => { describe('i18nUpdateOpCodesToString', () => { @@ -25,7 +25,7 @@ describe('i18n debug', () => { 1 << I18nUpdateOpCode.SHIFT_REF | I18nUpdateOpCode.Text, ])) .toEqual( - ['if (mask & 0b11) { (lView[1] as Text).textContent = `pre ${lView[4]} post`; }']); + ['if (mask & 0b11) { (lView[1] as Text).textContent = `pre ${lView[i-4]} post`; }']); }); it('should print Attribute opCode', () => { @@ -42,23 +42,21 @@ describe('i18n debug', () => { 'title', (v) => v, ])) .toEqual([ - 'if (mask & 0b1) { (lView[1] as Element).setAttribute(\'title\', `pre ${lView[4]} in ${lView[3]} post`); }', - 'if (mask & 0b10) { (lView[1] as Element).setAttribute(\'title\', (function (v) { return v; })(`pre ${lView[4]} in ${lView[3]} post`)); }' + 'if (mask & 0b1) { (lView[1] as Element).setAttribute(\'title\', `pre ${lView[i-4]} in ${lView[i-3]} post`); }', + 'if (mask & 0b10) { (lView[1] as Element).setAttribute(\'title\', (function (v) { return v; })(`pre ${lView[i-4]} in ${lView[i-3]} post`)); }' ]); }); it('should print icuSwitch opCode', () => { expect(i18nUpdateOpCodesToString([ - 0b100, 2, -5, 12 << I18nUpdateOpCode.SHIFT_REF | I18nUpdateOpCode.IcuSwitch, - 2 // FIXME(misko): Should be part of IcuSwitch - ])).toEqual(['if (mask & 0b100) { icuSwitchCase(lView[12] as Comment, 2, `${lView[5]}`); }']); + 0b100, 2, -5, 12 << I18nUpdateOpCode.SHIFT_REF | I18nUpdateOpCode.IcuSwitch + ])).toEqual(['if (mask & 0b100) { icuSwitchCase(12, `${lView[i-5]}`); }']); }); it('should print icuUpdate opCode', () => { expect(i18nUpdateOpCodesToString([ - 0b1000, 2, 13 << I18nUpdateOpCode.SHIFT_REF | I18nUpdateOpCode.IcuUpdate, - 3 // FIXME(misko): should be part of IcuUpdate - ])).toEqual(['if (mask & 0b1000) { icuUpdateCase(lView[13] as Comment, 3); }']); + 0b1000, 1, 13 << I18nUpdateOpCode.SHIFT_REF | I18nUpdateOpCode.IcuUpdate + ])).toEqual(['if (mask & 0b1000) { icuUpdateCase(13); }']); }); }); @@ -67,14 +65,6 @@ describe('i18n debug', () => { expect(i18nMutateOpCodesToString([])).toEqual([]); }); - it('should print Move', () => { - expect(i18nMutateOpCodesToString([ - 1 << I18nMutateOpCode.SHIFT_REF | I18nMutateOpCode.Select, - 2 << I18nMutateOpCode.SHIFT_PARENT | 0 << I18nMutateOpCode.SHIFT_REF | - I18nMutateOpCode.AppendChild, - ])).toEqual(['(lView[2] as Element).appendChild(lView[1])']); - }); - it('should print text AppendChild', () => { expect(i18nMutateOpCodesToString([ 'xyz', 0, @@ -125,16 +115,34 @@ describe('i18n debug', () => { ])).toEqual(['(lView[1] as Element).setAttribute("attr", "value")']); }); - it('should print ElementEnd', () => { - expect(i18nMutateOpCodesToString([ - 1 << I18nMutateOpCode.SHIFT_REF | I18nMutateOpCode.ElementEnd, - ])).toEqual(['setCurrentTNode(tView.data[1] as TNode)']); - }); - it('should print RemoveNestedIcu', () => { expect(i18nMutateOpCodesToString([ 1 << I18nMutateOpCode.SHIFT_REF | I18nMutateOpCode.RemoveNestedIcu, ])).toEqual(['removeNestedICU(1)']); }); }); + + describe('i18nCreateOpCodesToString', () => { + it('should print nothing', () => { + expect(i18nCreateOpCodesToString([])).toEqual([]); + }); + + it('should print text/comment creation', () => { + expect(i18nCreateOpCodesToString([ + 10 << I18nCreateOpCode.SHIFT, 'text at 10', // + 11 << I18nCreateOpCode.SHIFT | I18nCreateOpCode.APPEND_EAGERLY, 'text at 11, append', // + 12 << I18nCreateOpCode.SHIFT | I18nCreateOpCode.COMMENT, 'comment at 12', // + 13 << I18nCreateOpCode.SHIFT | I18nCreateOpCode.COMMENT | I18nCreateOpCode.APPEND_EAGERLY, + 'comment at 13, append', // + ])) + .toEqual([ + 'lView[10] = document.createText("text at 10");', + 'lView[11] = document.createText("text at 11, append");', + 'parent.appendChild(lView[11]);', + 'lView[12] = document.createComment("comment at 12");', + 'lView[13] = document.createComment("comment at 13, append");', + 'parent.appendChild(lView[13]);', + ]); + }); + }); }); diff --git a/packages/core/test/render3/is_shape_of.ts b/packages/core/test/render3/is_shape_of.ts index 554a3ade6b..972c6fa487 100644 --- a/packages/core/test/render3/is_shape_of.ts +++ b/packages/core/test/render3/is_shape_of.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {TI18n} from '@angular/core/src/render3/interfaces/i18n'; +import {TI18n, TIcu} from '@angular/core/src/render3/interfaces/i18n'; import {TNode} from '@angular/core/src/render3/interfaces/node'; import {TView} from '@angular/core/src/render3/interfaces/view'; @@ -75,10 +75,26 @@ export function isTI18n(obj: any): obj is TI18n { return isShapeOf(obj, ShapeOfTI18n); } const ShapeOfTI18n: ShapeOf = { - vars: true, create: true, update: true, - icus: true, +}; + + +/** + * Determines if `obj` matches the shape `TIcu`. + * @param obj + */ +export function isTIcu(obj: any): obj is TIcu { + return isShapeOf(obj, ShapeOfTIcu); +} +const ShapeOfTIcu: ShapeOf = { + type: true, + anchorIdx: true, + currentCaseLViewIndex: true, + cases: true, + create: true, + remove: true, + update: true }; @@ -133,6 +149,7 @@ export function isTNode(obj: any): obj is TNode { const ShapeOfTNode: ShapeOf = { type: true, index: true, + insertBeforeIndex: true, injectorIndex: true, directiveStart: true, directiveEnd: true, diff --git a/packages/core/test/render3/matchers.ts b/packages/core/test/render3/matchers.ts index 59826612ab..7e15888d10 100644 --- a/packages/core/test/render3/matchers.ts +++ b/packages/core/test/render3/matchers.ts @@ -6,11 +6,11 @@ * found in the LICENSE file at https://angular.io/license */ -import {TI18n} from '@angular/core/src/render3/interfaces/i18n'; +import {I18nDebug, I18nMutateOpCodes, TI18n, TIcu} from '@angular/core/src/render3/interfaces/i18n'; import {TNode} from '@angular/core/src/render3/interfaces/node'; import {TView} from '@angular/core/src/render3/interfaces/view'; -import {isDOMElement, isDOMText, isTI18n, isTNode, isTView} from './is_shape_of'; +import {isDOMElement, isDOMText, isTI18n, isTIcu, isTNode, isTView} from './is_shape_of'; /** @@ -37,22 +37,17 @@ export function matchObjectShape( return true; }; matcher.jasmineToString = function() { - return `${toString(_actual, false)} != ${toString(expected, true)})`; + let errors: string[] = []; + if (!_actual || typeof _actual !== 'object') { + return `Expecting ${jasmine.pp(expect)} got ${jasmine.pp(_actual)}`; + } + for (const key in expected) { + if (expected.hasOwnProperty(key) && !jasmine.matchersUtil.equals(_actual[key], expected[key])) + errors.push(`\n property obj.${key} to equal ${expected[key]} but got ${_actual[key]}`); + } + return errors.join('\n'); }; - function toString(obj: any, isExpected: boolean) { - if (isExpected || shapePredicate(obj)) { - const props = - Object.keys(expected).map(key => `${key}: ${JSON.stringify((obj as any)[key])}`); - if (isExpected === false) { - // Push something to let the user know that there may be other ignored properties in actual - props.push('...'); - } - return `${name}({${props.length === 0 ? '' : '\n ' + props.join(',\n ') + '\n'}})`; - } else { - return JSON.stringify(obj); - } - } return matcher; } @@ -116,6 +111,26 @@ export function matchTI18n(expected?: Partial): jasmine.AsymmetricMatcher } +/** + * Asymmetric matcher which matches a `T1cu` of a given shape. + * + * Expected usage: + * ``` + * expect(tNode).toEqual(matchTIcu({type: TIcuType.select})); + * expect({ + * type: TIcuType.select + * }).toEqual({ + * node: matchT18n({type: TIcuType.select}) + * }); + * ``` + * + * @param expected optional properties which the `TIcu` must contain. + */ +export function matchTIcu(expected?: Partial): jasmine.AsymmetricMatcher { + return matchObjectShape('TIcu', isTIcu, expected); +} + + /** * Asymmetric matcher which matches a DOM Element. @@ -214,5 +229,27 @@ export function matchDomText(expectedText: string|undefined = undefined): return `[${actualStr} != ${expectedStr}]`; }; + return matcher; +} + +export function matchI18nMutableOpCodes(expectedMutableOpCodes: string[]): + jasmine.AsymmetricMatcher { + const matcher = function() {}; + let _actual: any = null; + + matcher.asymmetricMatch = function(actual: any) { + _actual = actual; + if (!Array.isArray(actual)) return false; + const debug = (actual as I18nDebug).debug as undefined | string[]; + if (expectedMutableOpCodes && (!jasmine.matchersUtil.equals(debug, expectedMutableOpCodes))) { + return false; + } + return true; + }; + matcher.jasmineToString = function() { + const debug = (_actual as I18nDebug).debug as undefined | string[]; + return `[${JSON.stringify(debug)} != ${expectedMutableOpCodes}]`; + }; + return matcher; } \ No newline at end of file diff --git a/packages/core/test/render3/matchers_spec.ts b/packages/core/test/render3/matchers_spec.ts index f873a124bf..62fd5c76c4 100644 --- a/packages/core/test/render3/matchers_spec.ts +++ b/packages/core/test/render3/matchers_spec.ts @@ -41,13 +41,8 @@ describe('render3 matchers', () => { it('should produce human readable errors', () => { const matcher = matchMyShape({propA: 'different'}); expect(matcher.asymmetricMatch(myShape, [])).toEqual(false); - expect(matcher.jasmineToString!()).toEqual(dedent` - MyShape({ - propA: "value", - ... - }) != MyShape({ - propA: "different" - }))`); + expect(matcher.jasmineToString!()) + .toEqual('\n property obj.propA to equal different but got value'); }); }); diff --git a/packages/core/test/render3/utils.ts b/packages/core/test/render3/utils.ts index 988b54b196..4787b18e5e 100644 --- a/packages/core/test/render3/utils.ts +++ b/packages/core/test/render3/utils.ts @@ -7,6 +7,7 @@ * found in the LICENSE file at https://angular.io/license */ + /** Template string function that can be used to strip indentation from a given string literal. */ export function dedent(strings: TemplateStringsArray, ...values: any[]) { let joinedString = ''; @@ -59,15 +60,55 @@ function numOfWhiteSpaceLeadingChars(text: string): number { * * @param expected Expected value. */ +// FIXME(misko): rename to `matchDebug` to be consistent with other API. export function debugMatch(expected: T): any { const matcher = function() {}; - let actual: any = null; + let actual: any = debugMatch; matcher.asymmetricMatch = function(objectWithDebug: any) { return jasmine.matchersUtil.equals(actual = objectWithDebug.debug, expected); }; matcher.jasmineToString = function() { - return `<${JSON.stringify(actual)} != ${JSON.stringify(expected)}>`; + if (actual === debugMatch) { + // `asymmetricMatch` never got called hence no error to display + return ''; + } + return buildFailureMessage(actual, expected); }; return matcher; } + +export function buildFailureMessage(actual: any, expected: any): string { + const diffs: string[] = []; + listPropertyDifferences(diffs, '', actual, expected, 5); + return '\n ' + diffs.join('\n '); +} + +function listPropertyDifferences( + diffs: string[], path: string, actual: any, expected: any, depth: number) { + if (actual === expected) return; + if (typeof actual !== typeof expected) { + diffs.push(`${path}: Expected ${jasmine.pp(actual)} to be ${jasmine.pp(expected)}`); + } else if (depth && Array.isArray(expected)) { + if (!Array.isArray(actual)) { + diffs.push(`${path}: Expected ${jasmine.pp(expected)} but was ${jasmine.pp(actual)}`); + } else { + const maxLength = Math.max(actual.length, expected.length); + listPropertyDifferences(diffs, path + '.length', expected.length, actual.length, depth - 1); + for (let i = 0; i < maxLength; i++) { + const actualItem = actual[i]; + const expectedItem = expected[i]; + listPropertyDifferences(diffs, path + '[' + i + ']', actualItem, expectedItem, depth - 1); + } + } + } else if ( + depth && expected && typeof expected === 'object' && actual && typeof actual === 'object') { + new Set(Object.keys(expected).concat(Object.keys(actual))).forEach((key) => { + const actualItem = actual[key]; + const expectedItem = expected[key]; + listPropertyDifferences(diffs, path + '.' + key, actualItem, expectedItem, depth - 1); + }); + } else { + diffs.push(`${path}: Expected ${jasmine.pp(actual)} to be ${jasmine.pp(expected)}`); + } +} diff --git a/packages/core/test/render3/view_fixture.ts b/packages/core/test/render3/view_fixture.ts new file mode 100644 index 0000000000..f330e3f76c --- /dev/null +++ b/packages/core/test/render3/view_fixture.ts @@ -0,0 +1,84 @@ +/** + * @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 {ComponentTemplate} from '@angular/core/src/render3'; +import {createLView, createTNode, createTView} from '@angular/core/src/render3/instructions/shared'; +import {TConstants, TElementNode, TNodeType} from '@angular/core/src/render3/interfaces/node'; +import {domRendererFactory3} from '@angular/core/src/render3/interfaces/renderer'; +import {LView, LViewFlags, T_HOST, TView, TViewType} from '@angular/core/src/render3/interfaces/view'; +import {enterView, leaveView, specOnlyIsInstructionStateEmpty} from '@angular/core/src/render3/state'; +import {noop} from '@angular/core/src/util/noop'; + +/** + * Fixture useful for testing operations which need `LView` / `TView` + */ +export class ViewFixture { + /** + * Clean up the `LFrame` stack between tests. + */ + static cleanUp() { + while (!specOnlyIsInstructionStateEmpty()) { + leaveView(); + } + } + + /** + * DOM element which acts as a host to the `LView`. + */ + host: HTMLElement; + + tView: TView; + + lView: LView; + + constructor({template, decls, vars, consts, context}: { + decls?: number, + vars?: number, + template?: ComponentTemplate, + consts?: TConstants, + context?: {} + } = {}) { + const hostRenderer = domRendererFactory3.createRenderer(null, null); + this.host = hostRenderer.createElement('host-element') as HTMLElement; + const hostTView = createTView(TViewType.Root, null, null, 1, 0, null, null, null, null, null); + const hostLView = createLView( + null, hostTView, {}, LViewFlags.CheckAlways | LViewFlags.IsRoot, null, null, + domRendererFactory3, hostRenderer, null, null); + + + this.tView = createTView( + TViewType.Component, null, template || noop, decls || 0, vars || 0, null, null, null, null, + consts || null); + const hostTNode = + createTNode(hostTView, null, TNodeType.Element, 0, 'host-element', null) as TElementNode; + this.lView = createLView( + hostLView, this.tView, context || {}, LViewFlags.CheckAlways, this.host, hostTNode, + domRendererFactory3, hostRenderer, null, null); + } + + /** + * If you use `ViewFixture` and `enter()`, please add `afterEach(ViewFixture.cleanup);` to ensure + * that he global `LFrame` stack gets cleaned up between the tests. + */ + enterView() { + enterView(this.lView); + } + + leaveView() { + leaveView(); + } + + apply(fn: () => void) { + this.enterView(); + try { + fn(); + } finally { + this.leaveView(); + } + } +} \ No newline at end of file diff --git a/packages/core/test/render3/view_utils_spec.ts b/packages/core/test/render3/view_utils_spec.ts index b7f30fdc36..f5d38a0adf 100644 --- a/packages/core/test/render3/view_utils_spec.ts +++ b/packages/core/test/render3/view_utils_spec.ts @@ -6,22 +6,21 @@ * found in the LICENSE file at https://angular.io/license */ -import {createLContainer, createLView, createTNode, createTView} from '@angular/core/src/render3/instructions/shared'; +import {createLContainer, createTNode} from '@angular/core/src/render3/instructions/shared'; import {isLContainer, isLView} from '@angular/core/src/render3/interfaces/type_checks'; -import {TViewType} from '@angular/core/src/render3/interfaces/view'; +import {ViewFixture} from './view_fixture'; describe('view_utils', () => { - it('should verify unwrap methods', () => { - const div = document.createElement('div'); - const tView = createTView(TViewType.Root, null, null, 0, 0, null, null, null, null, null); - const lView = createLView(null, tView, {}, 0, div, null, {} as any, {} as any, null, null); + it('should verify unwrap methods (isLView and isLContainer)', () => { + const viewFixture = new ViewFixture(); const tNode = createTNode(null!, null, 3, 0, 'div', []); - const lContainer = createLContainer(lView, lView, div, tNode); + const lContainer = + createLContainer(viewFixture.lView, viewFixture.lView, viewFixture.host, tNode); - expect(isLView(lView)).toBe(true); + expect(isLView(viewFixture.lView)).toBe(true); expect(isLView(lContainer)).toBe(false); - expect(isLContainer(lView)).toBe(false); + expect(isLContainer(viewFixture.lView)).toBe(false); expect(isLContainer(lContainer)).toBe(true); }); }); diff --git a/packages/platform-browser/animations/src/animation_renderer.ts b/packages/platform-browser/animations/src/animation_renderer.ts index fa80ef6330..a7f6adffa6 100644 --- a/packages/platform-browser/animations/src/animation_renderer.ts +++ b/packages/platform-browser/animations/src/animation_renderer.ts @@ -166,9 +166,10 @@ export class BaseAnimationRenderer implements Renderer2 { this.engine.onInsert(this.namespaceId, newChild, parent, false); } - insertBefore(parent: any, newChild: any, refChild: any): void { + insertBefore(parent: any, newChild: any, refChild: any, isMove: boolean = true): void { this.delegate.insertBefore(parent, newChild, refChild); - this.engine.onInsert(this.namespaceId, newChild, parent, true); + // If `isMove` true than we should animate this insert. + this.engine.onInsert(this.namespaceId, newChild, parent, isMove); } removeChild(parent: any, oldChild: any, isHostElement: boolean): void {