From 84272e2227b4c8bd028ff2211cbe56270ee0c37a Mon Sep 17 00:00:00 2001 From: Olivier Combe Date: Mon, 18 Jun 2018 16:55:43 +0200 Subject: [PATCH] feat(ivy): runtime i18n (#24037) PR Close #24037 --- .../core/src/core_render3_private_export.ts | 7 + packages/core/src/render3/i18n.ts | 460 ++++++ packages/core/src/render3/index.ts | 13 +- packages/core/src/render3/instructions.ts | 47 +- packages/core/src/render3/interfaces/node.ts | 13 + packages/core/src/render3/ng_dev_mode.ts | 4 + .../core/src/render3/node_manipulation.ts | 32 +- .../render3/compiler_canonical/i18n_spec.ts | 139 ++ packages/core/test/render3/i18n_spec.ts | 1316 +++++++++++++++++ .../render3/node_selector_matcher_spec.ts | 3 +- 10 files changed, 2011 insertions(+), 23 deletions(-) create mode 100644 packages/core/src/render3/i18n.ts create mode 100644 packages/core/test/render3/compiler_canonical/i18n_spec.ts create mode 100644 packages/core/test/render3/i18n_spec.ts diff --git a/packages/core/src/core_render3_private_export.ts b/packages/core/src/core_render3_private_export.ts index 11e7feb6a9..c312ee55cd 100644 --- a/packages/core/src/core_render3_private_export.ts +++ b/packages/core/src/core_render3_private_export.ts @@ -87,6 +87,13 @@ export { DirectiveDefInternal as ɵDirectiveDef, PipeDef as ɵPipeDef, whenRendered as ɵwhenRendered, + iA as ɵiA, + iEM as ɵiEM, + iI as ɵiI, + iIV as ɵIV, + iM as ɵiM, + I18nInstruction as ɵI18nInstruction, + I18nExpInstruction as ɵI18nExpInstruction, } from './render3/index'; export { bypassSanitizationTrustHtml as ɵbypassSanitizationTrustHtml, diff --git a/packages/core/src/render3/i18n.ts b/packages/core/src/render3/i18n.ts new file mode 100644 index 0000000000..9871b2e56f --- /dev/null +++ b/packages/core/src/render3/i18n.ts @@ -0,0 +1,460 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {assertEqual, assertLessThan} from './assert'; +import {NO_CHANGE, bindingUpdated, createLNode, getPreviousOrParentNode, getRenderer, getViewData, load} from './instructions'; +import {LContainerNode, LElementNode, LNode, TNodeType} from './interfaces/node'; +import {BINDING_INDEX} from './interfaces/view'; +import {appendChild, createTextNode, getParentLNode, removeChild} from './node_manipulation'; +import {stringify} from './util'; + +/** + * A list of flags to encode the i18n instructions used to translate the template. + * We shift the flags by 29 so that 30 & 31 & 32 bits contains the instructions. + */ +export const enum I18nInstructions { + Text = 1 << 29, + Element = 2 << 29, + Expression = 3 << 29, + CloseNode = 4 << 29, + RemoveNode = 5 << 29, + /** Used to decode the number encoded with the instruction. */ + IndexMask = (1 << 29) - 1, + /** Used to test the type of instruction. */ + InstructionMask = ~((1 << 29) - 1), +} + +/** + * Represents the instructions used to translate the template. + * Instructions can be a placeholder index, a static text or a simple bit field (`I18nFlag`). + * When the instruction is the flag `Text`, it is always followed by its text value. + */ +export type I18nInstruction = number | string; +/** + * Represents the instructions used to translate attributes containing expressions. + * Even indexes contain static strings, while odd indexes contain the index of the expression whose + * value will be concatenated into the final translation. + */ +export type I18nExpInstruction = number | string; +/** Mapping of placeholder names to their absolute indexes in their templates. */ +export type PlaceholderMap = { + [name: string]: number +}; +const i18nTagRegex = /\{\$([^}]+)\}/g; + +/** + * Takes a translation string, the initial list of placeholders (elements and expressions) and the + * indexes of their corresponding expression nodes to return a list of instructions for each + * template function. + * + * Because embedded templates have different indexes for each placeholder, each parameter (except + * the translation) is an array, where each value corresponds to a different template, by order of + * appearance. + * + * @param translation A translation string where placeholders are represented by `{$name}` + * @param elements An array containing, for each template, the maps of element placeholders and + * their indexes. + * @param expressions An array containing, for each template, the maps of expression placeholders + * and their indexes. + * @param tmplContainers An array of template container placeholders whose content should be ignored + * when generating the instructions for their parent template. + * @param lastChildIndex The index of the last child of the i18n node. Used when the i18n block is + * an ng-container. + * + * @returns A list of instructions used to translate each template. + */ +export function i18nMapping( + translation: string, elements: (PlaceholderMap | null)[] | null, + expressions?: (PlaceholderMap | null)[] | null, tmplContainers?: string[] | null, + lastChildIndex?: number | null): I18nInstruction[][] { + const translationParts = translation.split(i18nTagRegex); + const instructions: I18nInstruction[][] = []; + + generateMappingInstructions( + 0, translationParts, instructions, elements, expressions, tmplContainers, lastChildIndex); + + return instructions; +} + +/** + * Internal function that reads the translation parts and generates a set of instructions for each + * template. + * + * See `i18nMapping()` for more details. + * + * @param index The current index in `translationParts`. + * @param translationParts The translation string split into an array of placeholders and text + * elements. + * @param instructions The current list of instructions to update. + * @param elements An array containing, for each template, the maps of element placeholders and + * their indexes. + * @param expressions An array containing, for each template, the maps of expression placeholders + * and their indexes. + * @param tmplContainers An array of template container placeholders whose content should be ignored + * when generating the instructions for their parent template. + * @param lastChildIndex The index of the last child of the i18n node. Used when the i18n block is + * an ng-container. + * @returns the current index in `translationParts` + */ +function generateMappingInstructions( + index: number, translationParts: string[], instructions: I18nInstruction[][], + elements: (PlaceholderMap | null)[] | null, expressions?: (PlaceholderMap | null)[] | null, + tmplContainers?: string[] | null, lastChildIndex?: number | null): number { + const tmplIndex = instructions.length; + const tmplInstructions: I18nInstruction[] = []; + const phVisited = []; + let openedTagCount = 0; + let maxIndex = 0; + + instructions.push(tmplInstructions); + + for (; index < translationParts.length; index++) { + const value = translationParts[index]; + + // Odd indexes are placeholders + if (index & 1) { + let phIndex; + + if (elements && elements[tmplIndex] && + typeof(phIndex = elements[tmplIndex] ![value]) !== 'undefined') { + // The placeholder represents a DOM element + // Add an instruction to move the element + tmplInstructions.push(phIndex | I18nInstructions.Element); + phVisited.push(value); + openedTagCount++; + } else if ( + expressions && expressions[tmplIndex] && + typeof(phIndex = expressions[tmplIndex] ![value]) !== 'undefined') { + // The placeholder represents an expression + // Add an instruction to move the expression + tmplInstructions.push(phIndex | I18nInstructions.Expression); + phVisited.push(value); + } else { // It is a closing tag + tmplInstructions.push(I18nInstructions.CloseNode); + + if (tmplIndex > 0) { + openedTagCount--; + + // If we have reached the closing tag for this template, exit the loop + if (openedTagCount === 0) { + break; + } + } + } + + if (typeof phIndex !== 'undefined' && phIndex > maxIndex) { + maxIndex = phIndex; + } + + if (tmplContainers && tmplContainers.indexOf(value) !== -1 && + tmplContainers.indexOf(value) >= tmplIndex) { + index = generateMappingInstructions( + index, translationParts, instructions, elements, expressions, tmplContainers, + lastChildIndex); + } + + } else if (value) { + // It's a non-empty string, create a text node + tmplInstructions.push(I18nInstructions.Text, value); + } + } + + // Check if some elements from the template are missing from the translation + if (elements) { + const tmplElements = elements[tmplIndex]; + + if (tmplElements) { + const phKeys = Object.keys(tmplElements); + + for (let i = 0; i < phKeys.length; i++) { + const ph = phKeys[i]; + + if (phVisited.indexOf(ph) === -1) { + let index = tmplElements[ph]; + // Add an instruction to remove the element + tmplInstructions.push(index | I18nInstructions.RemoveNode); + + if (index > maxIndex) { + maxIndex = index; + } + } + } + } + } + + // Check if some expressions from the template are missing from the translation + if (expressions) { + const tmplExpressions = expressions[tmplIndex]; + + if (tmplExpressions) { + const phKeys = Object.keys(tmplExpressions); + + for (let i = 0; i < phKeys.length; i++) { + const ph = phKeys[i]; + + if (phVisited.indexOf(ph) === -1) { + let index = tmplExpressions[ph]; + if (ngDevMode) { + assertLessThan( + index.toString(2).length, 28, `Index ${index} is too big and will overflow`); + } + // Add an instruction to remove the expression + tmplInstructions.push(index | I18nInstructions.RemoveNode); + + if (index > maxIndex) { + maxIndex = index; + } + } + } + } + } + + if (tmplIndex === 0 && typeof lastChildIndex === 'number') { + // The current parent is an ng-container and it has more children after the translation that we + // need to append to keep the order of the DOM nodes correct + for (let i = maxIndex + 1; i <= lastChildIndex; i++) { + if (ngDevMode) { + assertLessThan(i.toString(2).length, 28, `Index ${i} is too big and will overflow`); + } + // We consider those additional placeholders as expressions because we don't care about + // their children, all we need to do is to append them + tmplInstructions.push(i | I18nInstructions.Expression); + } + } + + return index; +} + +function appendI18nNode(node: LNode, parentNode: LNode, previousNode: LNode) { + if (ngDevMode) { + ngDevMode.rendererMoveNode++; + } + + const viewData = getViewData(); + + appendChild(parentNode, node.native || null, viewData); + + if (previousNode === parentNode && parentNode.pChild === null) { + parentNode.pChild = node; + } else { + previousNode.pNextOrParent = node; + } + + // Template containers also have a comment node for the `ViewContainerRef` that should be moved + if (node.tNode.type === TNodeType.Container && node.dynamicLContainerNode) { + // (node.native as RComment).textContent = 'test'; + // console.log(node.native); + appendChild(parentNode, node.dynamicLContainerNode.native || null, viewData); + node.pNextOrParent = node.dynamicLContainerNode; + return node.dynamicLContainerNode; + } + + return node; +} + +/** + * Takes a list of instructions generated by `i18nMapping()` to transform the template accordingly. + * + * @param startIndex Index of the first element to translate (for instance the first child of the + * element with the i18n attribute). + * @param instructions The list of instructions to apply on the current view. + */ +export function i18nApply(startIndex: number, instructions: I18nInstruction[]): void { + const viewData = getViewData(); + if (ngDevMode) { + assertEqual(viewData[BINDING_INDEX], -1, 'i18nApply should be called before any binding'); + } + + if (!instructions) { + return; + } + + const renderer = getRenderer(); + let localParentNode: LNode = getParentLNode(load(startIndex)) || getPreviousOrParentNode(); + let localPreviousNode: LNode = localParentNode; + + for (let i = 0; i < instructions.length; i++) { + const instruction = instructions[i] as number; + switch (instruction & I18nInstructions.InstructionMask) { + case I18nInstructions.Element: + const element: LNode = load(instruction & I18nInstructions.IndexMask); + localPreviousNode = appendI18nNode(element, localParentNode, localPreviousNode); + localParentNode = element; + break; + case I18nInstructions.Expression: + const expr: LNode = load(instruction & I18nInstructions.IndexMask); + localPreviousNode = appendI18nNode(expr, localParentNode, localPreviousNode); + break; + case I18nInstructions.Text: + if (ngDevMode) { + ngDevMode.rendererCreateTextNode++; + } + const value = instructions[++i]; + const textRNode = createTextNode(value, renderer); + // If we were to only create a `RNode` then projections won't move the text. + // But since this text doesn't have an index in `LViewData`, we need to create an + // `LElementNode` with the index -1 so that it isn't saved in `LViewData` + const textLNode = createLNode(-1, TNodeType.Element, textRNode, null, null); + textLNode.dynamicParent = localParentNode as LElementNode | LContainerNode; + localPreviousNode = appendI18nNode(textLNode, localParentNode, localPreviousNode); + break; + case I18nInstructions.CloseNode: + localPreviousNode = localParentNode; + localParentNode = getParentLNode(localParentNode) !; + break; + case I18nInstructions.RemoveNode: + if (ngDevMode) { + ngDevMode.rendererRemoveNode++; + } + const index = instruction & I18nInstructions.IndexMask; + const removedNode: LNode|LContainerNode = load(index); + const parentNode = getParentLNode(removedNode) !; + removeChild(parentNode, removedNode.native || null, viewData); + + // For template containers we also need to remove their `ViewContainerRef` from the DOM + if (removedNode.tNode.type === TNodeType.Container && removedNode.dynamicLContainerNode) { + removeChild(parentNode, removedNode.dynamicLContainerNode.native || null, viewData); + removedNode.dynamicLContainerNode.tNode.detached = true; + } + break; + } + } +} + +/** + * Takes a translation string and the initial list of expressions and returns a list of instructions + * that will be used to translate an attribute. + * Even indexes contain static strings, while odd indexes contain the index of the expression whose + * value will be concatenated into the final translation. + */ +export function i18nExpMapping( + translation: string, placeholders: PlaceholderMap): I18nExpInstruction[] { + const staticText: I18nExpInstruction[] = translation.split(i18nTagRegex); + // odd indexes are placeholders + for (let i = 1; i < staticText.length; i += 2) { + staticText[i] = placeholders[staticText[i]]; + } + return staticText; +} + +/** + * Checks if the value of up to 8 expressions have changed and replaces them by their values in a + * translation, or returns NO_CHANGE. + * + * @returns The concatenated string when any of the arguments changes, `NO_CHANGE` otherwise. + */ +export function i18nInterpolation( + instructions: I18nExpInstruction[], numberOfExp: number, v0: any, v1?: any, v2?: any, v3?: any, + v4?: any, v5?: any, v6?: any, v7?: any): string|NO_CHANGE { + let different = bindingUpdated(v0); + + if (numberOfExp > 1) { + different = bindingUpdated(v1) || different; + + if (numberOfExp > 2) { + different = bindingUpdated(v2) || different; + + if (numberOfExp > 3) { + different = bindingUpdated(v3) || different; + + if (numberOfExp > 4) { + different = bindingUpdated(v4) || different; + + if (numberOfExp > 5) { + different = bindingUpdated(v5) || different; + + if (numberOfExp > 6) { + different = bindingUpdated(v6) || different; + + if (numberOfExp > 7) { + different = bindingUpdated(v7) || different; + } + } + } + } + } + } + } + + if (!different) { + return NO_CHANGE; + } + + let res = ''; + for (let i = 0; i < instructions.length; i++) { + let value: any; + // Odd indexes are placeholders + if (i & 1) { + switch (instructions[i]) { + case 0: + value = v0; + break; + case 1: + value = v1; + break; + case 2: + value = v2; + break; + case 3: + value = v3; + break; + case 4: + value = v4; + break; + case 5: + value = v5; + break; + case 6: + value = v6; + break; + case 7: + value = v7; + break; + } + + res += stringify(value); + } else { + res += instructions[i]; + } + } + + return res; +} + +/** + * Create a translated interpolation binding with a variable number of expressions. + * + * If there are 1 to 8 expressions then `i18nInterpolation()` should be used instead. It is faster + * because there is no need to create an array of expressions and iterate over it. + * + * @returns The concatenated string when any of the arguments changes, `NO_CHANGE` otherwise. + */ +export function i18nInterpolationV(instructions: I18nExpInstruction[], values: any[]): string| + NO_CHANGE { + let different = false; + for (let i = 0; i < values.length; i++) { + // Check if bindings have changed + bindingUpdated(values[i]) && (different = true); + } + + if (!different) { + return NO_CHANGE; + } + + let res = ''; + for (let i = 0; i < instructions.length; i++) { + // Odd indexes are placeholders + if (i & 1) { + res += stringify(values[instructions[i] as number]); + } else { + res += instructions[i]; + } + } + + return res; +} diff --git a/packages/core/src/render3/index.ts b/packages/core/src/render3/index.ts index 7ff3835145..b9a7ee98fc 100644 --- a/packages/core/src/render3/index.ts +++ b/packages/core/src/render3/index.ts @@ -8,8 +8,8 @@ import {LifecycleHooksFeature, getHostElement, getRenderedText, renderComponent, whenRendered} from './component'; import {NgOnChangesFeature, PublicFeature, defineComponent, defineDirective, defineNgModule, definePipe} from './definition'; +import {I18nExpInstruction, I18nInstruction, i18nExpMapping, i18nInterpolation, i18nInterpolationV} from './i18n'; import {ComponentDefInternal, ComponentTemplate, ComponentType, DirectiveDefFlags, DirectiveDefInternal, DirectiveType, PipeDef} from './interfaces/definition'; - export {ComponentFactory, ComponentFactoryResolver, ComponentRef} from './component_ref'; export {QUERY_READ_CONTAINER_REF, QUERY_READ_ELEMENT_REF, QUERY_READ_FROM_NODE, QUERY_READ_TEMPLATE_REF, directiveInject, injectAttribute, injectChangeDetectorRef, injectElementRef, injectTemplateRef, injectViewContainerRef} from './di'; export {RenderFlags} from './interfaces/definition'; @@ -79,6 +79,16 @@ export { tick, } from './instructions'; +export { + i18nApply as iA, + i18nMapping as iM, + i18nInterpolation as iI, + i18nInterpolationV as iIV, + i18nExpMapping as iEM, + I18nInstruction, + I18nExpInstruction +} from './i18n'; + export {NgModuleDef, NgModuleFactory, NgModuleRef, NgModuleType} from './ng_module_ref'; export { @@ -96,7 +106,6 @@ export { export { QueryList, - query as Q, queryRefresh as qR, } from './query'; diff --git a/packages/core/src/render3/instructions.ts b/packages/core/src/render3/instructions.ts index 88bff707a6..390203239d 100644 --- a/packages/core/src/render3/instructions.ts +++ b/packages/core/src/render3/instructions.ts @@ -6,22 +6,19 @@ * found in the LICENSE file at https://angular.io/license */ -import './ng_dev_mode'; - import {Sanitizer} from '../sanitization/security'; - -import {assertDefined, assertEqual, assertLessThan, assertNotDefined, assertNotEqual, assertSame} from './assert'; +import {assertDefined, assertEqual, assertLessThan, assertNotDefined, assertNotEqual} from './assert'; import {throwCyclicDependencyError, throwErrorIfNoChangesMode, throwMultipleComponentError} from './errors'; import {executeHooks, executeInitHooks, queueInitHooks, queueLifecycleHooks} from './hooks'; import {ACTIVE_INDEX, LContainer, RENDER_PARENT, VIEWS} from './interfaces/container'; import {LInjector} from './interfaces/injector'; +import {AttributeMarker, InitialInputData, InitialInputs, LContainerNode, LElementNode, LNode, LProjectionNode, LTextNode, LViewNode, PropertyAliases, PropertyAliasValue, TAttributes, TContainerNode, TElementNode, TNode, TNodeFlags, TNodeType,} from './interfaces/node'; import {CssSelectorList, LProjection, NG_PROJECT_AS_ATTR_NAME} from './interfaces/projection'; import {LQueries} from './interfaces/query'; -import {BINDING_INDEX, CLEANUP, CONTEXT, CurrentMatchesList, DIRECTIVES, FLAGS, HEADER_OFFSET, HOST_NODE, INJECTOR, LViewData, LViewFlags, NEXT, PARENT, QUERIES, RENDERER, RootContext, SANITIZER, TAIL, TData, TVIEW, TView,} from './interfaces/view'; - -import {AttributeMarker, TAttributes, LContainerNode, LElementNode, LNode, TNodeType, TNodeFlags, LProjectionNode, LTextNode, LViewNode, TNode, TContainerNode, InitialInputData, InitialInputs, PropertyAliases, PropertyAliasValue, TElementNode,} from './interfaces/node'; +import {BINDING_INDEX, CLEANUP, CONTEXT, CurrentMatchesList, DIRECTIVES, FLAGS, HEADER_OFFSET, HOST_NODE, INJECTOR, LViewData, LViewFlags, NEXT, PARENT, QUERIES, RENDERER, RootContext, SANITIZER, TAIL, TVIEW, TView,} from './interfaces/view'; +import './ng_dev_mode'; import {assertNodeType} from './node_assert'; -import {appendChild, insertView, appendProjectedNode, removeView, canInsertNativeNode, createTextNode, getNextLNode, getChildLNode, getParentLNode, getLViewChild} from './node_manipulation'; +import {appendChild, appendProjectedNode, canInsertNativeNode, createTextNode, getChildLNode, getLViewChild, getNextLNode, getParentLNode, insertView, removeChild, removeView} from './node_manipulation'; import {isNodeMatchingSelectorList, matchingSelectorIndex} from './node_selector_matcher'; import {ComponentDefInternal, ComponentTemplate, ComponentQuery, DirectiveDefInternal, DirectiveDefListOrFactory, PipeDefListOrFactory, RenderFlags} from './interfaces/definition'; import {RComment, RElement, RText, Renderer3, RendererFactory3, ProceduralRenderer3, RendererStyleFlags3, isProceduralRenderer} from './interfaces/renderer'; @@ -103,6 +100,11 @@ export function getCurrentSanitizer(): Sanitizer|null { return viewData && viewData[SANITIZER]; } +export function getViewData(): LViewData { + // top level variables should not be exported for performance reasons (PERF_NOTES.md) + return viewData; +} + /** Used to set the parent property when nodes are created. */ let previousOrParentNode: LNode; @@ -319,7 +321,8 @@ export function createLNodeObject( tNode: null !, pNextOrParent: null, dynamicLContainerNode: null, - dynamicParent: null + dynamicParent: null, + pChild: null, }; } @@ -367,8 +370,8 @@ export function createLNode( if (index === -1 || type === TNodeType.View) { // View nodes are not stored in data because they can be added / removed at runtime (which // would cause indices to change). Their TNodes are instead stored in TView.node. - node.tNode = - (state as LViewData)[TVIEW].node || createTNode(type, index, null, null, tParent, null); + node.tNode = (state ? (state as LViewData)[TVIEW].node : null) || + createTNode(type, index, null, null, tParent, null); } else { const adjustedIndex = index + HEADER_OFFSET; @@ -1157,7 +1160,8 @@ export function createTNode( next: null, child: null, parent: parent, - dynamicContainerNode: null + dynamicContainerNode: null, + detached: null }; } @@ -1334,8 +1338,6 @@ export function elementStyle( } } - - ////////////////////////// //// Text ////////////////////////// @@ -1893,7 +1895,16 @@ export function projectionDef( } const componentNode: LElementNode = findComponentHost(viewData); - let componentChild: LNode|null = getChildLNode(componentNode); + let isProjectingI18nNodes = false; + let componentChild: LNode|null; + // for i18n translations we use pChild to point to the next child + // TODO(kara): Remove when removing LNodes + if (componentNode.pChild) { + isProjectingI18nNodes = true; + componentChild = componentNode.pChild; + } else { + componentChild = getChildLNode(componentNode); + } while (componentChild !== null) { // execute selector matching logic if and only if: @@ -1906,7 +1917,11 @@ export function projectionDef( distributedNodes[0].push(componentChild); } - componentChild = getNextLNode(componentChild); + if (isProjectingI18nNodes) { + componentChild = componentChild.pNextOrParent; + } else { + componentChild = getNextLNode(componentChild); + } } ngDevMode && assertDataNext(index + HEADER_OFFSET); diff --git a/packages/core/src/render3/interfaces/node.ts b/packages/core/src/render3/interfaces/node.ts index 9097c82195..7559a67f83 100644 --- a/packages/core/src/render3/interfaces/node.ts +++ b/packages/core/src/render3/interfaces/node.ts @@ -100,6 +100,13 @@ export interface LNode { */ pNextOrParent: LNode|null; + /** + * If this node is part of an i18n block, pointer to the first child after translation + * If this node is not part of an i18n block, this field is null. + */ + // TODO(kara): Remove this when removing pNextOrParent + pChild: LNode|null; + /** * Pointer to the corresponding TNode object, which stores static * data about this node. @@ -342,6 +349,12 @@ export interface TNode { * A pointer to a TContainerNode created by directives requesting ViewContainerRef */ dynamicContainerNode: TNode|null; + + /** + * If this node is part of an i18n block, it indicates whether this container is part of the DOM + * If this node is not part of an i18n block, this field is null. + */ + detached: boolean|null; } /** Static data for an LElementNode */ diff --git a/packages/core/src/render3/ng_dev_mode.ts b/packages/core/src/render3/ng_dev_mode.ts index e3a2d7188b..c75ec09ccb 100644 --- a/packages/core/src/render3/ng_dev_mode.ts +++ b/packages/core/src/render3/ng_dev_mode.ts @@ -27,6 +27,8 @@ declare global { rendererRemoveStyle: number; rendererDestroy: number; rendererDestroyNode: number; + rendererMoveNode: number; + rendererRemoveNode: number; } } @@ -54,6 +56,8 @@ export const ngDevModeResetPerfCounters: () => void = rendererRemoveStyle: 0, rendererDestroy: 0, rendererDestroyNode: 0, + rendererMoveNode: 0, + rendererRemoveNode: 0, }; } ngDevModeResetPerfCounters(); diff --git a/packages/core/src/render3/node_manipulation.ts b/packages/core/src/render3/node_manipulation.ts index 962f0724e2..25c725e5a7 100644 --- a/packages/core/src/render3/node_manipulation.ts +++ b/packages/core/src/render3/node_manipulation.ts @@ -29,6 +29,9 @@ export function getNextLNode(node: LNode): LNode|null { /** Retrieves the first child of a given node */ export function getChildLNode(node: LNode): LNode|null { + if (node.pChild) { + return node.pChild; + } if (node.tNode.child) { const viewData = node.tNode.type === TNodeType.View ? node.data as LViewData : node.view; return viewData[node.tNode.child.index]; @@ -313,7 +316,7 @@ export function insertView( // If the container's renderParent is null, we know that it is a root node of its own parent view // and we should wait until that parent processes its nodes (otherwise, we will insert this view's // nodes twice - once now and once when its parent inserts its views). - if (container.data[RENDER_PARENT] !== null) { + if (container.data[RENDER_PARENT] !== null && !container.tNode.detached) { // Find the node to insert in front of const beforeNode = index + 1 < views.length ? (getChildLNode(views[index + 1]) !).native : container.native; @@ -343,7 +346,9 @@ export function detachView(container: LContainerNode, removeIndex: number): LVie views[removeIndex - 1].data[NEXT] = viewNode.data[NEXT] as LViewData; } views.splice(removeIndex, 1); - addRemoveViewFromContainer(container, viewNode, false); + if (!container.tNode.detached) { + addRemoveViewFromContainer(container, viewNode, false); + } // Notify query that view has been removed const removedLview = viewNode.data; if (removedLview[QUERIES]) { @@ -506,7 +511,7 @@ export function canInsertNativeNode(parent: LNode, currentView: LViewData): bool /** * Appends the `child` element to the `parent`. * - * The element insertion might be delayed {@link canInsertNativeNode} + * The element insertion might be delayed {@link canInsertNativeNode}. * * @param parent The parent to which to append the child * @param child The child that should be appended @@ -515,7 +520,7 @@ export function canInsertNativeNode(parent: LNode, currentView: LViewData): bool */ export function appendChild(parent: LNode, child: RNode | null, currentView: LViewData): boolean { if (child !== null && canInsertNativeNode(parent, currentView)) { - // We only add element if not in View or not projected. + // We only add the element if not in View or not projected. const renderer = currentView[RENDERER]; isProceduralRenderer(renderer) ? renderer.appendChild(parent.native !as RElement, child) : parent.native !.appendChild(child); @@ -524,6 +529,25 @@ export function appendChild(parent: LNode, child: RNode | null, currentView: LVi return false; } +/** + * Removes the `child` element of the `parent` from the DOM. + * + * @param parent The parent from which to remove the child + * @param child The child that should be removed + * @param currentView The current LView + * @returns Whether or not the child was removed + */ +export function removeChild(parent: LNode, child: RNode | null, currentView: LViewData): boolean { + if (child !== null && canInsertNativeNode(parent, currentView)) { + // We only remove the element if not in View or not projected. + const renderer = currentView[RENDERER]; + isProceduralRenderer(renderer) ? renderer.removeChild(parent.native as RElement, child) : + parent.native !.removeChild(child); + return true; + } + return false; +} + /** * Appends a projected node to the DOM, or in the case of a projected container, * appends the nodes from all of the container's active views to the DOM. diff --git a/packages/core/test/render3/compiler_canonical/i18n_spec.ts b/packages/core/test/render3/compiler_canonical/i18n_spec.ts new file mode 100644 index 0000000000..3811eb5c18 --- /dev/null +++ b/packages/core/test/render3/compiler_canonical/i18n_spec.ts @@ -0,0 +1,139 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {NgForOfContext} from '@angular/common'; +import {Component} from '../../../src/core'; +import * as $r3$ from '../../../src/core_render3_private_export'; +import {NgForOf} from '../common_with_def'; + +/// See: `normative.md` +describe('i18n', () => { + type $RenderFlags$ = $r3$.ɵRenderFlags; + + it('should support html', () => { + type $MyApp$ = MyApp; + const $msg_1$ = `{$START_P}contenu{$END_P}`; + const $i18n_1$ = $r3$.ɵiM($msg_1$, [{START_P: 1}]); + + @Component({selector: 'my-app', template: `

content

`}) + class MyApp { + static ngComponentDef = $r3$.ɵdefineComponent({ + type: MyApp, + selectors: [['my-app']], + factory: () => new MyApp(), + template: function(rf: $RenderFlags$, ctx: $MyApp$) { + if (rf & 1) { + $r3$.ɵE(0, 'div'); + $r3$.ɵE(1, 'p'); + $r3$.ɵe(); + $r3$.ɵe(); + $r3$.ɵiA(1, $i18n_1$[0]); + } + } + }); + } + }); + + it('should support expressions', () => { + type $MyApp$ = MyApp; + const $msg_1$ = `contenu: {$EXP_1}`; + const $i18n_1$ = $r3$.ɵiM($msg_1$, null, [{EXP_1: 1}]); + + @Component({selector: 'my-app', template: `
content: {{exp1}}
`}) + class MyApp { + exp1 = '1'; + static ngComponentDef = $r3$.ɵdefineComponent({ + type: MyApp, + selectors: [['my-app']], + factory: () => new MyApp(), + template: function(rf: $RenderFlags$, ctx: $MyApp$) { + if (rf & 1) { + $r3$.ɵE(0, 'div'); + $r3$.ɵT(1); + $r3$.ɵe(); + $r3$.ɵiA(1, $i18n_1$[0]); + } + if (rf & 2) { + $r3$.ɵt(3, $r3$.ɵb(ctx.exp1)); + } + } + }); + } + }); + + it('should support expressions in attributes', () => { + type $MyApp$ = MyApp; + const $msg_1$ = `titre: {$EXP_1}`; + const $i18n_1$ = $r3$.ɵiEM($msg_1$, {EXP_1: 1}); + + @Component({selector: 'my-app', template: `

`}) + class MyApp { + exp1 = '1'; + static ngComponentDef = $r3$.ɵdefineComponent({ + type: MyApp, + selectors: [['my-app']], + factory: () => new MyApp(), + template: function(rf: $RenderFlags$, ctx: $MyApp$) { + if (rf & 1) { + $r3$.ɵE(0, 'div'); + $r3$.ɵE(1, 'p'); + $r3$.ɵe(); + $r3$.ɵe(); + } + if (rf & 2) { + $r3$.ɵp(0, 'title', $r3$.ɵiI($i18n_1$, 2, ctx.exp1)); + } + } + }); + } + }); + + it('should support embedded templates', () => { + type $MyApp$ = MyApp; + const $msg_1$ = `{$START_LI}valeur: {$EXP_1}!{$END_LI}`; + const $i18n_1$ = + $r3$.ɵiM($msg_1$, [{START_LI: 1}, {START_LI: 0}], [null, {EXP_1: 1}], ['START_LI']); + + @Component({ + selector: 'my-app', + template: `` + }) + class MyApp { + items: string[] = ['1', '2']; + static ngComponentDef = $r3$.ɵdefineComponent({ + type: MyApp, + factory: () => new MyApp(), + selectors: [['my-app']], + template: (rf: $RenderFlags$, myApp: $MyApp$) => { + if (rf & 1) { + $r3$.ɵE(0, 'ul'); + $r3$.ɵC(1, liTemplate, null, ['ngForOf', '']); + $r3$.ɵe(); + $r3$.ɵiA(1, $i18n_1$[0]); + } + if (rf & 2) { + $r3$.ɵp(1, 'ngForOf', $r3$.ɵb(myApp.items)); + } + + function liTemplate(rf1: $RenderFlags$, row: NgForOfContext) { + if (rf1 & 1) { + $r3$.ɵE(0, 'li'); + $r3$.ɵT(1); + $r3$.ɵe(); + $r3$.ɵiA(0, $i18n_1$[1]); + } + if (rf1 & 2) { + $r3$.ɵt(1, $r3$.ɵb(row.$implicit)); + } + } + }, + directives: () => [NgForOf] + }); + } + }); +}); diff --git a/packages/core/test/render3/i18n_spec.ts b/packages/core/test/render3/i18n_spec.ts new file mode 100644 index 0000000000..b83f5080f3 --- /dev/null +++ b/packages/core/test/render3/i18n_spec.ts @@ -0,0 +1,1316 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {NgForOfContext} from '@angular/common'; +import {Component} from '../../src/core'; +import {defineComponent} from '../../src/render3/definition'; +import {i18nApply, i18nExpMapping, i18nInterpolation, i18nInterpolationV, i18nMapping} from '../../src/render3/i18n'; +import {bind, container, containerRefreshEnd, containerRefreshStart, elementEnd, elementProperty, elementStart, embeddedViewEnd, embeddedViewStart, projection, projectionDef, text, textBinding} from '../../src/render3/instructions'; +import {RenderFlags} from '../../src/render3/interfaces/definition'; +import {NgForOf} from './common_with_def'; +import {ComponentFixture, TemplateFixture} from './render_util'; + +describe('Runtime i18n', () => { + it('should support html elements', () => { + // Html tags are replaced by placeholders. + // Open tag placeholders are never re-used (closing tag placeholders can be). + const MSG_DIV_SECTION_1 = + `{$START_C}trad 1{$END_C}{$START_A}trad 2{$START_B}trad 3{$END_B}{$END_A}`; + const i18n_1 = + i18nMapping(MSG_DIV_SECTION_1, [{START_A: 1, START_B: 2, START_REMOVE_ME: 3, START_C: 4}]); + // Initial template: + //
+ // + // + // + // + // + //
+ + // Translated to: + //
+ // trad 1 + // + // trad 2 + // trad 3 + // + //
+ function createTemplate() { + elementStart(0, 'div'); + { // Start of translated section 1 + // - i18n sections do not contain any text() instruction + elementStart(1, 'a'); // START_A + { + elementStart(2, 'b'); // START_B + elementEnd(); + elementStart(3, 'remove-me'); // START_REMOVE_ME + elementEnd(); + } + elementEnd(); + elementStart(4, 'c'); // START_C + elementEnd(); + } // End of translated section 1 + elementEnd(); + i18nApply(1, i18n_1[0]); + } + + const fixture = new TemplateFixture(createTemplate); + expect(fixture.html).toEqual('
trad 1trad 2trad 3
'); + }); + + it('should support expressions', () => { + const MSG_DIV_SECTION_1 = `start {$EXP_2} middle {$EXP_1} end`; + const i18n_1 = i18nMapping(MSG_DIV_SECTION_1, null, [{EXP_1: 1, EXP_2: 2}]); + + class MyApp { + exp1 = '1'; + exp2 = '2'; + + static ngComponentDef = defineComponent({ + type: MyApp, + factory: () => new MyApp(), + selectors: [['my-app']], + // Initial template: + //
+ // {{exp1}} {{exp2}} + //
+ + // Translated to: + //
+ // start {{exp2}} middle {{exp1}} end + //
+ template: (rf: RenderFlags, ctx: MyApp) => { + if (rf & RenderFlags.Create) { + elementStart(0, 'div'); + { + // Start of translated section 1 + // One text node is added per expression in the interpolation + text(1); // EXP_1 + text(2); // EXP_2 + // End of translated section 1 + } + elementEnd(); + i18nApply(1, i18n_1[0]); + } + if (rf & RenderFlags.Update) { + textBinding(1, bind(ctx.exp1)); + textBinding(2, bind(ctx.exp2)); + } + } + }); + } + + const fixture = new ComponentFixture(MyApp); + expect(fixture.html).toEqual('
start 2 middle 1 end
'); + + // Change detection cycle, no model changes + fixture.update(); + expect(fixture.html).toEqual('
start 2 middle 1 end
'); + + // Change the expressions + fixture.component.exp1 = 'expr 1'; + fixture.component.exp2 = 'expr 2'; + fixture.update(); + expect(fixture.html).toEqual('
start expr 2 middle expr 1 end
'); + }); + + it('should support expressions on removed nodes', () => { + const MSG_DIV_SECTION_1 = `message`; + const i18n_1 = i18nMapping(MSG_DIV_SECTION_1, null, [{EXP_1: 1}]); + + class MyApp { + exp1 = '1'; + + static ngComponentDef = defineComponent({ + type: MyApp, + factory: () => new MyApp(), + selectors: [['my-app']], + // Initial template: + //
+ // {{exp1}} + //
+ + // Translated to: + //
+ // message + //
+ template: (rf: RenderFlags, ctx: MyApp) => { + if (rf & RenderFlags.Create) { + elementStart(0, 'div'); + { + // Start of translated section 1 + text(1); // EXP_1 will be removed + // End of translated section 1 + } + elementEnd(); + i18nApply(1, i18n_1[0]); + } + if (rf & RenderFlags.Update) { + textBinding(1, bind(ctx.exp1)); + } + } + }); + } + + const fixture = new ComponentFixture(MyApp); + expect(fixture.html).toEqual('
message
'); + + // Change detection cycle, no model changes + fixture.update(); + expect(fixture.html).toEqual('
message
'); + + // Change the expressions + fixture.component.exp1 = 'expr 1'; + fixture.update(); + expect(fixture.html).toEqual('
message
'); + }); + + it('should support expressions in attributes', () => { + const MSG_DIV_SECTION_1 = `start {$EXP_2} middle {$EXP_1} end`; + const i18n_1 = i18nExpMapping(MSG_DIV_SECTION_1, {EXP_1: 0, EXP_2: 1}); + + class MyApp { + exp1: any = '1'; + exp2: any = '2'; + + static ngComponentDef = defineComponent({ + type: MyApp, + factory: () => new MyApp(), + selectors: [['my-app']], + // Initial template: + //
+ + // Translated to: + //
+ template: (rf: RenderFlags, ctx: MyApp) => { + if (rf & RenderFlags.Create) { + elementStart(0, 'div'); + // Start of translated section 1 + // End of translated section 1 + elementEnd(); + } + if (rf & RenderFlags.Update) { + elementProperty(0, 'title', i18nInterpolation(i18n_1, 2, ctx.exp1, ctx.exp2)); + } + } + }); + } + + const fixture = new ComponentFixture(MyApp); + expect(fixture.html).toEqual('
'); + + // Change detection cycle, no model changes + fixture.update(); + expect(fixture.html).toEqual('
'); + + // Change the expressions + fixture.component.exp1 = function test() {}; + fixture.component.exp2 = null; + fixture.update(); + expect(fixture.html).toEqual('
'); + }); + + it('should support both html elements, expressions and expressions in attributes', () => { + const MSG_DIV_SECTION_1 = `{$EXP_1} {$START_P}trad {$EXP_2}{$END_P}`; + const MSG_ATTR_1 = `start {$EXP_2} middle {$EXP_1} end`; + const i18n_1 = i18nMapping( + MSG_DIV_SECTION_1, + [{START_REMOVE_ME_1: 2, START_REMOVE_ME_2: 3, START_REMOVE_ME_3: 4, START_P: 5}], + [{EXP_1: 1, EXP_2: 6, EXP_3: 7}]); + const i18n_2 = i18nExpMapping(MSG_ATTR_1, {EXP_1: 0, EXP_2: 1}); + + class MyApp { + exp1 = '1'; + exp2 = '2'; + exp3 = '3'; + + static ngComponentDef = defineComponent({ + type: MyApp, + factory: () => new MyApp(), + selectors: [['my-app']], + // Initial template: + //
+ // {{exp1}} + // + // + // + // + //

+ // {{exp2}} + //

+ // {{exp3}} + //
+ + // Translated to: + //
+ // {{exp1}} + //

+ // trad {{exp2}} + //

+ //
+ template: (rf: RenderFlags, ctx: MyApp) => { + if (rf & RenderFlags.Create) { + elementStart(0, 'div'); + { + // Start of translated section 1 + text(1); // EXP_1 + elementStart(2, 'remove-me-1'); // START_REMOVE_ME_1 + { + elementStart(3, 'remove-me-2'); // START_REMOVE_ME_2 + elementEnd(); + elementStart(4, 'remove-me-3'); // START_REMOVE_ME_3 + elementEnd(); + } + elementEnd(); + elementStart(5, 'p'); // START_P + { text(6); } // EXP_2 + elementEnd(); + text(7); // EXP_3 + // End of translated section 1 + } + elementEnd(); + i18nApply(1, i18n_1[0]); + } + if (rf & RenderFlags.Update) { + textBinding(1, bind(ctx.exp1)); + textBinding(6, bind(ctx.exp2)); + textBinding(7, bind(ctx.exp3)); + elementProperty(0, 'title', i18nInterpolation(i18n_2, 2, ctx.exp1, ctx.exp2)); + } + } + }); + } + + const fixture = new ComponentFixture(MyApp); + expect(fixture.html).toEqual('
1

trad 2

'); + + // Change detection cycle, no model changes + fixture.update(); + expect(fixture.html).toEqual('
1

trad 2

'); + + // Change the expressions + fixture.component.exp1 = 'expr 1'; + fixture.component.exp2 = 'expr 2'; + fixture.update(); + expect(fixture.html) + .toEqual('
expr 1

trad expr 2

'); + }); + + it('should support multiple i18n elements', () => { + const MSG_DIV_SECTION_1 = `trad {$EXP_1}`; + const MSG_DIV_SECTION_2 = `{$START_C}trad{$END_C}`; + const MSG_ATTR_1 = `start {$EXP_2} middle {$EXP_1} end`; + const i18n_1 = i18nMapping(MSG_DIV_SECTION_1, null, [{EXP_1: 2}]); + const i18n_2 = i18nMapping(MSG_DIV_SECTION_2, [{START_C: 5}]); + const i18n_3 = i18nExpMapping(MSG_ATTR_1, {EXP_1: 0, EXP_2: 1}); + + class MyApp { + exp1 = '1'; + exp2 = '2'; + + static ngComponentDef = defineComponent({ + type: MyApp, + factory: () => new MyApp(), + selectors: [['my-app']], + // Initial template: + //
+ // + // {{exp1}} + // + // hello + // + // + // + //
+ + // Translated to: + //
+ // + // trad {{exp1}} + // + // hello + // + // trad + // + //
+ template: (rf: RenderFlags, ctx: MyApp) => { + if (rf & RenderFlags.Create) { + elementStart(0, 'div'); + { + elementStart(1, 'a'); + { + // Start of translated section 1 + text(2); // EXP_1 + // End of translated section 1 + } + elementEnd(); + text(3, 'hello'); + elementStart(4, 'b'); + { + // Start of translated section 2 + elementStart(5, 'c'); // START_C + elementEnd(); + // End of translated section 2 + } + elementEnd(); + } + elementEnd(); + i18nApply(2, i18n_1[0]); + i18nApply(5, i18n_2[0]); + } + if (rf & RenderFlags.Update) { + textBinding(2, bind(ctx.exp1)); + elementProperty(4, 'title', i18nInterpolation(i18n_3, 2, ctx.exp1, ctx.exp2)); + } + } + }); + } + + const fixture = new ComponentFixture(MyApp); + expect(fixture.html) + .toEqual('
trad 1hellotrad
'); + + // Change detection cycle, no model changes + fixture.update(); + expect(fixture.html) + .toEqual('
trad 1hellotrad
'); + + // Change the expressions + fixture.component.exp1 = 'expr 1'; + fixture.component.exp2 = 'expr 2'; + fixture.update(); + expect(fixture.html) + .toEqual( + '
trad expr 1hellotrad
'); + }); + + describe('view containers / embedded templates', () => { + it('should support containers', () => { + const MSG_DIV_SECTION_1 = `valeur: {$EXP_1}`; + // The indexes are based on the main template function + const i18n_1 = i18nMapping(MSG_DIV_SECTION_1, null, [{EXP_1: 0}]); + + class MyApp { + exp1 = '1'; + + static ngComponentDef = defineComponent({ + type: MyApp, + factory: () => new MyApp(), + selectors: [['my-app']], + // Initial template: + // before ( + // % if (condition) { // with i18n + // value: {{exp1}} + // % } + // ) after + + // Translated : + // before ( + // % if (condition) { // with i18n + // valeur: {{exp1}} + // % } + // ) after + template: (rf: RenderFlags, myApp: MyApp) => { + if (rf & RenderFlags.Create) { + text(0, 'before ('); + container(1); + text(2, ') after'); + } + if (rf & RenderFlags.Update) { + containerRefreshStart(1); + { + let rf0 = embeddedViewStart(0); + if (rf0 & RenderFlags.Create) { + // Start of translated section 1 + text(0); // EXP_1 + // End of translated section 1 + i18nApply(0, i18n_1[0]); + } + if (rf0 & RenderFlags.Update) { + textBinding(0, bind(myApp.exp1)); + } + embeddedViewEnd(); + } + containerRefreshEnd(); + } + } + }); + } + + const fixture = new ComponentFixture(MyApp); + expect(fixture.html).toEqual('before (valeur: 1) after'); + + // Change detection cycle, no model changes + fixture.update(); + expect(fixture.html).toEqual('before (valeur: 1) after'); + }); + + it('should support ng-container', () => { + const MSG_DIV_SECTION_1 = `{$START_B}{$END_B}`; + // With ng-container the i18n node doesn't create any element at runtime which means that + // its children are not the only children of their parent, some nodes which are not + // translated might also be the children of the same parent. + // This is why we need to pass the `lastChildIndex` to `i18nMapping` + const i18n_1 = i18nMapping(MSG_DIV_SECTION_1, [{START_B: 2, START_C: 3}], null, null, 4); + // Initial template: + //
+ // + // + // + // + // + // + //
+ + // Translated to: + //
+ // + // + // + // + // + //
+ function createTemplate() { + elementStart(0, 'div'); + { + elementStart(1, 'a'); + elementEnd(); + { + // Start of translated section 1 + elementStart(2, 'b'); // START_B + elementEnd(); + elementStart(3, 'c'); // START_C + elementEnd(); + // End of translated section 1 + } + elementStart(4, 'd'); + elementEnd(); + } + elementEnd(); + i18nApply(2, i18n_1[0]); + } + + const fixture = new TemplateFixture(createTemplate); + expect(fixture.html).toEqual('
'); + }); + + it('should support embedded templates', () => { + const MSG_DIV_SECTION_1 = `{$START_LI}valeur: {$EXP_1}!{$END_LI}`; + // The indexes are based on each template function + const i18n_1 = i18nMapping( + MSG_DIV_SECTION_1, [{START_LI: 1}, {START_LI: 0}], [null, {EXP_1: 1}], ['START_LI']); + class MyApp { + items: string[] = ['1', '2']; + + static ngComponentDef = defineComponent({ + type: MyApp, + factory: () => new MyApp(), + selectors: [['my-app']], + // Initial template: + //
    + //
  • value: {{item}}
  • + //
+ + // Translated to: + //
    + //
  • valeur: {{item}}
  • + //
+ template: (rf: RenderFlags, myApp: MyApp) => { + if (rf & RenderFlags.Create) { + elementStart(0, 'ul'); + { + // Start of translated section 1 + container(1, liTemplate, null, ['ngForOf', '']); // START_LI + // End of translated section 1 + } + elementEnd(); + i18nApply(1, i18n_1[0]); + } + if (rf & RenderFlags.Update) { + elementProperty(1, 'ngForOf', bind(myApp.items)); + } + + function liTemplate(rf1: RenderFlags, row: NgForOfContext) { + if (rf1 & RenderFlags.Create) { + // This is a container so the whole template is a translated section + // Start of translated section 2 + elementStart(0, 'li'); // START_LI + { text(1); } // EXP_1 + elementEnd(); + // End of translated section 2 + i18nApply(0, i18n_1[1]); + } + if (rf1 & RenderFlags.Update) { + textBinding(1, bind(row.$implicit)); + } + } + }, + directives: () => [NgForOf] + }); + } + + const fixture = new ComponentFixture(MyApp); + expect(fixture.html).toEqual('
  • valeur: 1!
  • valeur: 2!
'); + + // Change detection cycle, no model changes + fixture.update(); + expect(fixture.html).toEqual('
  • valeur: 1!
  • valeur: 2!
'); + + // Remove the last item + fixture.component.items.length = 1; + fixture.update(); + expect(fixture.html).toEqual('
  • valeur: 1!
'); + + // Change an item + fixture.component.items[0] = 'one'; + fixture.update(); + expect(fixture.html).toEqual('
  • valeur: one!
'); + + // Add an item + fixture.component.items.push('two'); + fixture.update(); + expect(fixture.html).toEqual('
  • valeur: one!
  • valeur: two!
'); + }); + + it('should support sibling embedded templates', () => { + const MSG_DIV_SECTION_1 = + `{$START_LI_0}valeur: {$EXP_1}!{$END_LI_0}{$START_LI_1}valeur bis: {$EXP_2}!{$END_LI_1}`; + // The indexes are based on each template function + const i18n_1 = i18nMapping( + MSG_DIV_SECTION_1, [null, {START_LI_0: 0}, {START_LI_1: 0}], + [{START_LI_0: 1, START_LI_1: 2}, {EXP_1: 1}, {EXP_2: 1}], ['START_LI_0', 'START_LI_1']); + class MyApp { + items: string[] = ['1', '2']; + + static ngComponentDef = defineComponent({ + type: MyApp, + factory: () => new MyApp(), + selectors: [['my-app']], + // Initial template: + //
    + //
  • value: {{item}}
  • + //
  • value bis: {{item}}
  • + //
+ + // Translated to: + //
    + //
  • valeur: {{item}}!
  • + //
  • valeur bis: {{item}}!
  • + //
+ template: (rf: RenderFlags, myApp: MyApp) => { + if (rf & RenderFlags.Create) { + elementStart(0, 'ul'); + { + // Start of translated section 1 + container(1, liTemplate, null, ['ngForOf', '']); // START_LI_0 + container(2, liTemplateBis, null, ['ngForOf', '']); // START_LI_1 + // End of translated section 1 + } + elementEnd(); + i18nApply(1, i18n_1[0]); + } + if (rf & RenderFlags.Update) { + elementProperty(1, 'ngForOf', bind(myApp.items)); + elementProperty(2, 'ngForOf', bind(myApp.items)); + } + + function liTemplate(rf1: RenderFlags, row: NgForOfContext) { + if (rf1 & RenderFlags.Create) { + // This is a container so the whole template is a translated section + // Start of translated section 2 + elementStart(0, 'li'); // START_LI_0 + { text(1); } // EXP_1 + elementEnd(); + // End of translated section 2 + i18nApply(0, i18n_1[1]); + } + if (rf1 & RenderFlags.Update) { + textBinding(1, bind(row.$implicit)); + } + } + + function liTemplateBis(rf1: RenderFlags, row: NgForOfContext) { + if (rf1 & RenderFlags.Create) { + // This is a container so the whole template is a translated section + // Start of translated section 3 + elementStart(0, 'li'); // START_LI_1 + { text(1); } // EXP_2 + elementEnd(); + // End of translated section 3 + i18nApply(0, i18n_1[2]); + } + if (rf1 & RenderFlags.Update) { + textBinding(1, bind(row.$implicit)); + } + } + }, + directives: () => [NgForOf] + }); + } + + const fixture = new ComponentFixture(MyApp); + expect(fixture.html) + .toEqual( + '
  • valeur: 1!
  • valeur: 2!
  • valeur bis: 1!
  • valeur bis: 2!
'); + + // Change detection cycle, no model changes + fixture.update(); + expect(fixture.html) + .toEqual( + '
  • valeur: 1!
  • valeur: 2!
  • valeur bis: 1!
  • valeur bis: 2!
'); + + // Remove the last item + fixture.component.items.length = 1; + fixture.update(); + expect(fixture.html).toEqual('
  • valeur: 1!
  • valeur bis: 1!
'); + + // Change an item + fixture.component.items[0] = 'one'; + fixture.update(); + expect(fixture.html).toEqual('
  • valeur: one!
  • valeur bis: one!
'); + + // Add an item + fixture.component.items.push('two'); + fixture.update(); + expect(fixture.html) + .toEqual( + '
  • valeur: one!
  • valeur: two!
  • valeur bis: one!
  • valeur bis: two!
'); + }); + + it('should support nested embedded templates', () => { + const MSG_DIV_SECTION_1 = `{$START_LI}{$START_SPAN}valeur: {$EXP_1}!{$END_SPAN}{$END_LI}`; + // The indexes are based on each template function + const i18n_1 = i18nMapping( + MSG_DIV_SECTION_1, [null, {START_LI: 0}, {START_SPAN: 0}], + [{START_LI: 1}, {START_SPAN: 1}, {EXP_1: 1}], ['START_LI', 'START_SPAN']); + class MyApp { + items: string[] = ['1', '2']; + + static ngComponentDef = defineComponent({ + type: MyApp, + factory: () => new MyApp(), + selectors: [['my-app']], + // Initial template: + //
    + //
  • + // value: {{item}} + //
  • + //
+ + // Translated to: + //
    + //
  • + // valeur: {{item}}! + //
  • + //
+ template: (rf: RenderFlags, myApp: MyApp) => { + if (rf & RenderFlags.Create) { + elementStart(0, 'ul'); + { + // Start of translated section 1 + container(1, liTemplate, null, ['ngForOf', '']); // START_LI + // End of translated section 1 + } + elementEnd(); + i18nApply(1, i18n_1[0]); + } + if (rf & RenderFlags.Update) { + elementProperty(1, 'ngForOf', bind(myApp.items)); + } + + function liTemplate(rf1: RenderFlags, row: NgForOfContext) { + if (rf1 & RenderFlags.Create) { + // This is a container so the whole template is a translated section + // Start of translated section 2 + elementStart(0, 'li'); // START_LI + { + container(1, spanTemplate, null, ['ngForOf', '']); // START_SPAN + } + elementEnd(); + // End of translated section 2 + i18nApply(0, i18n_1[1]); + } + if (rf1 & RenderFlags.Update) { + elementProperty(1, 'ngForOf', bind(myApp.items)); + } + } + + function spanTemplate(rf1: RenderFlags, row: NgForOfContext) { + if (rf1 & RenderFlags.Create) { + // This is a container so the whole template is a translated section + // Start of translated section 3 + elementStart(0, 'span'); // START_SPAN + { text(1); } // EXP_1 + elementEnd(); + // End of translated section 3 + i18nApply(0, i18n_1[2]); + } + if (rf1 & RenderFlags.Update) { + textBinding(1, bind(row.$implicit)); + } + } + }, + directives: () => [NgForOf] + }); + } + + const fixture = new ComponentFixture(MyApp); + expect(fixture.html) + .toEqual( + '
  • valeur: 1!valeur: 2!
  • valeur: 1!valeur: 2!
'); + + // Change detection cycle, no model changes + fixture.update(); + expect(fixture.html) + .toEqual( + '
  • valeur: 1!valeur: 2!
  • valeur: 1!valeur: 2!
'); + + // Remove the last item + fixture.component.items.length = 1; + fixture.update(); + expect(fixture.html).toEqual('
  • valeur: 1!
'); + + // Change an item + fixture.component.items[0] = 'one'; + fixture.update(); + expect(fixture.html).toEqual('
  • valeur: one!
'); + + // Add an item + fixture.component.items.push('two'); + fixture.update(); + expect(fixture.html) + .toEqual( + '
  • valeur: one!valeur: two!
  • valeur: one!valeur: two!
'); + }); + + it('should be able to move template directives around', () => { + const MSG_DIV_SECTION_1 = + `{$START_LI_0}début{$END_LI_0}{$START_LI_1}valeur: {$EXP_1}{$END_LI_1}fin`; + // The indexes are based on each template function + const i18n_1 = i18nMapping( + MSG_DIV_SECTION_1, [{START_LI_0: 1, START_LI_2: 3}, {START_LI_1: 0}], + [{START_LI_1: 2}, {EXP_1: 1}], ['START_LI_1']); + + class MyApp { + items: string[] = ['first', 'second']; + + static ngComponentDef = defineComponent({ + type: MyApp, + factory: () => new MyApp(), + selectors: [['my-app']], + // Initial template: + //
    + //
  • start
  • + //
  • value: {{item}}
  • + //
  • delete me
  • + //
+ + // Translated to: + //
    + //
  • début
  • + //
  • valeur: {{item}}
  • + // fin + //
+ template: (rf: RenderFlags, myApp: MyApp) => { + if (rf & RenderFlags.Create) { + elementStart(0, 'ul'); + { + // Start of translated section 1 + elementStart(1, 'li'); // START_LI_0 + elementEnd(); + container(2, liTemplate, null, ['ngForOf', '']); // START_LI_1 + elementStart(3, 'li'); // START_LI_2 + { text(4, 'delete me'); } + elementEnd(); + // End of translated section 1 + } + elementEnd(); + i18nApply(1, i18n_1[0]); + } + if (rf & RenderFlags.Update) { + elementProperty(2, 'ngForOf', bind(myApp.items)); + } + + function liTemplate(rf1: RenderFlags, row: NgForOfContext) { + if (rf1 & RenderFlags.Create) { + // This is a container so the whole template is a translated section + // Start of translated section 2 + elementStart(0, 'li'); // START_LI_1 + { text(1); } // EXP_1 + elementEnd(); + // End of translated section 2 + i18nApply(0, i18n_1[1]); + } + if (rf1 & RenderFlags.Update) { + textBinding(1, bind(row.$implicit)); + } + } + }, + directives: () => [NgForOf] + }); + } + + const fixture = new ComponentFixture(MyApp); + expect(fixture.html) + .toEqual('
  • début
  • valeur: first
  • valeur: second
  • fin
'); + + // // Change detection cycle, no model changes + fixture.update(); + expect(fixture.html) + .toEqual('
  • début
  • valeur: first
  • valeur: second
  • fin
'); + + // Remove the last item + fixture.component.items.length = 1; + fixture.update(); + expect(fixture.html).toEqual('
  • début
  • valeur: first
  • fin
'); + + // Change an item + fixture.component.items[0] = 'one'; + fixture.update(); + expect(fixture.html).toEqual('
  • début
  • valeur: one
  • fin
'); + + // Add an item + fixture.component.items.push('two'); + fixture.update(); + expect(fixture.html) + .toEqual('
  • début
  • valeur: one
  • valeur: two
  • fin
'); + }); + + it('should be able to remove containers', () => { + const MSG_DIV_SECTION_1 = `loop`; + // The indexes are based on each template function + const i18n_1 = i18nMapping( + MSG_DIV_SECTION_1, [{START_LI: 1}, {START_LI: 0}], [null, {EXP_1: 1}], ['START_LI']); + + class MyApp { + items: string[] = ['first', 'second']; + + static ngComponentDef = defineComponent({ + type: MyApp, + factory: () => new MyApp(), + selectors: [['my-app']], + // Initial template: + //
    + //
  • value: {{item}}
  • + //
+ + // Translated to: + //
    + // loop + //
+ template: (rf: RenderFlags, myApp: MyApp) => { + if (rf & RenderFlags.Create) { + elementStart(0, 'ul'); + { + // Start of translated section 1 + container(1, liTemplate, undefined, ['ngForOf', '']); // START_LI + // End of translated section 1 + } + elementEnd(); + i18nApply(1, i18n_1[0]); + } + if (rf & RenderFlags.Update) { + elementProperty(1, 'ngForOf', bind(myApp.items)); + } + + function liTemplate(rf1: RenderFlags, row: NgForOfContext) { + if (rf1 & RenderFlags.Create) { + // This is a container so the whole template is a translated section + // Start of translated section 2 + elementStart(0, 'li'); // START_LI + { text(1); } // EXP_1 + elementEnd(); + // End of translated section 2 + i18nApply(0, i18n_1[1]); + } + if (rf1 & RenderFlags.Update) { + textBinding(1, bind(row.$implicit)); + } + } + }, + directives: () => [NgForOf] + }); + } + + const fixture = new ComponentFixture(MyApp); + expect(fixture.html).toEqual('
    loop
'); + + // Change detection cycle, no model changes + fixture.update(); + expect(fixture.html).toEqual('
    loop
'); + + // Remove the last item + fixture.component.items.length = 1; + fixture.update(); + expect(fixture.html).toEqual('
    loop
'); + + // Change an item + fixture.component.items[0] = 'one'; + fixture.update(); + expect(fixture.html).toEqual('
    loop
'); + + // Add an item + fixture.component.items.push('two'); + fixture.update(); + expect(fixture.html).toEqual('
    loop
'); + }); + }); + + describe('projection', () => { + it('should project the translations', () => { + @Component({selector: 'child', template: '

'}) + class Child { + static ngComponentDef = defineComponent({ + type: Child, + selectors: [['child']], + factory: () => new Child(), + template: (rf: RenderFlags, cmp: Child) => { + if (rf & RenderFlags.Create) { + projectionDef(0); + elementStart(1, 'p'); + { projection(2, 0); } + elementEnd(); + } + } + }); + } + + const MSG_DIV_SECTION_1 = + `{$START_CHILD}Je suis projeté depuis {$START_B}{$EXP_1}{$END_B}{$END_CHILD}`; + const i18n_1 = i18nMapping( + MSG_DIV_SECTION_1, [{ + START_CHILD: 1, + START_B: 2, + START_REMOVE_ME_1: 4, + START_REMOVE_ME_2: 5, + START_REMOVE_ME_3: 6 + }], + [{EXP_1: 3}]); + const MSG_ATTR_1 = `Enfant de {$EXP_1}`; + const i18n_2 = i18nExpMapping(MSG_ATTR_1, {EXP_1: 0}); + + @Component({ + selector: 'parent', + template: ` +
+ I am projected from {{name}} + +
` + // Translated to: + //
+ // + // Je suis projeté depuis {{name}} + // + //
+ }) + class Parent { + name: string = 'Parent'; + static ngComponentDef = defineComponent({ + type: Parent, + selectors: [['parent']], + directives: [Child], + factory: () => new Parent(), + template: (rf: RenderFlags, cmp: Parent) => { + if (rf & RenderFlags.Create) { + elementStart(0, 'div'); + { + // Start of translated section 1 + elementStart(1, 'child'); // START_CHILD + { + elementStart(2, 'b'); // START_B + { + text(3); // EXP_1 + elementStart(4, 'remove-me-1'); // START_REMOVE_ME_1 + elementEnd(); + } + elementEnd(); + elementStart(5, 'remove-me-2'); // START_REMOVE_ME_2 + elementEnd(); + } + elementEnd(); + elementStart(6, 'remove-me-3'); // START_REMOVE_ME_3 + elementEnd(); + // End of translated section 1 + } + elementEnd(); + i18nApply(1, i18n_1[0]); + } + if (rf & RenderFlags.Update) { + elementProperty(2, 'title', i18nInterpolation(i18n_2, 1, cmp.name)); + textBinding(3, bind(cmp.name)); + } + } + }); + } + + const fixture = new ComponentFixture(Parent); + expect(fixture.html) + .toEqual( + '

Je suis projeté depuis Parent

'); + }); + + it('should project a translated i18n block', () => { + @Component({selector: 'child', template: '

'}) + class Child { + static ngComponentDef = defineComponent({ + type: Child, + selectors: [['child']], + factory: () => new Child(), + template: (rf: RenderFlags, cmp: Child) => { + if (rf & RenderFlags.Create) { + projectionDef(0); + elementStart(1, 'p'); + { projection(2, 0); } + elementEnd(); + } + } + }); + } + + const MSG_DIV_SECTION_1 = `Je suis projeté depuis {$EXP_1}`; + const i18n_1 = i18nMapping(MSG_DIV_SECTION_1, null, [{EXP_1: 4}]); + const MSG_ATTR_1 = `Enfant de {$EXP_1}`; + const i18n_2 = i18nExpMapping(MSG_ATTR_1, {EXP_1: 0}); + + @Component({ + selector: 'parent', + template: ` +
+ I am projected from {{name}} +
` + // Translated to: + //
+ // + // + // Je suis projeté depuis {{name}} + // + // + //
+ }) + class Parent { + name: string = 'Parent'; + static ngComponentDef = defineComponent({ + type: Parent, + selectors: [['parent']], + directives: [Child], + factory: () => new Parent(), + template: (rf: RenderFlags, cmp: Parent) => { + if (rf & RenderFlags.Create) { + elementStart(0, 'div'); + { + elementStart(1, 'child'); + { + elementStart(2, 'any'); + elementEnd(); + elementStart(3, 'b'); + { + // Start of translated section 1 + text(4); // EXP_1 + // End of translated section 1 + } + elementEnd(); + elementStart(5, 'any'); + elementEnd(); + } + elementEnd(); + } + elementEnd(); + i18nApply(4, i18n_1[0]); + } + if (rf & RenderFlags.Update) { + elementProperty(3, 'title', i18nInterpolation(i18n_2, 1, cmp.name)); + textBinding(4, bind(cmp.name)); + } + } + }); + } + + const fixture = new ComponentFixture(Parent); + expect(fixture.html) + .toEqual( + '

Je suis projeté depuis Parent

'); + }); + + it('should re-project translations when multiple projections', () => { + @Component({selector: 'grand-child', template: '
'}) + class GrandChild { + static ngComponentDef = defineComponent({ + type: GrandChild, + selectors: [['grand-child']], + factory: () => new GrandChild(), + template: (rf: RenderFlags, cmp: Child) => { + if (rf & RenderFlags.Create) { + projectionDef(0); + elementStart(1, 'div'); + { projection(2, 0); } + elementEnd(); + } + } + }); + } + + @Component( + {selector: 'child', template: ''}) + class Child { + static ngComponentDef = defineComponent({ + type: Child, + selectors: [['child']], + directives: [GrandChild], + factory: () => new Child(), + template: (rf: RenderFlags, cmp: Child) => { + if (rf & RenderFlags.Create) { + projectionDef(0); + elementStart(1, 'grand-child'); + { projection(2, 0); } + elementEnd(); + } + } + }); + } + + const MSG_DIV_SECTION_1 = `{$START_B}Bonjour{$END_B} Monde!`; + const i18n_1 = i18nMapping(MSG_DIV_SECTION_1, [{START_B: 1}]); + + @Component({ + selector: 'parent', + template: `Hello World!` + // Translated to: + //
Bonjour Monde!
+ }) + class Parent { + name: string = 'Parent'; + static ngComponentDef = defineComponent({ + type: Parent, + selectors: [['parent']], + directives: [Child], + factory: () => new Parent(), + template: (rf: RenderFlags, cmp: Parent) => { + if (rf & RenderFlags.Create) { + elementStart(0, 'child'); + { + // Start of translated section 1 + elementStart(1, 'b'); // START_B + elementEnd(); + // End of translated section 1 + } + elementEnd(); + i18nApply(1, i18n_1[0]); + } + } + }); + } + + const fixture = new ComponentFixture(Parent); + expect(fixture.html) + .toEqual('
Bonjour Monde!
'); + }); + + it('should project translations with selectors', () => { + @Component({ + selector: 'child', + template: ` + + ` + }) + class Child { + static ngComponentDef = defineComponent({ + type: Child, + selectors: [['child']], + factory: () => new Child(), + template: (rf: RenderFlags, cmp: Child) => { + if (rf & RenderFlags.Create) { + projectionDef(0, [[['span']]], ['span']); + projection(1, 0, 1); + } + } + }); + } + + const MSG_DIV_SECTION_1 = `{$START_SPAN_0}Contenu{$END_SPAN_0}`; + const i18n_1 = i18nMapping(MSG_DIV_SECTION_1, [{START_SPAN_0: 1, START_SPAN_1: 2}]); + + @Component({ + selector: 'parent', + template: ` + + + + + ` + // Translated to: + // Contenu + }) + class Parent { + static ngComponentDef = defineComponent({ + type: Parent, + selectors: [['parent']], + directives: [Child], + factory: () => new Parent(), + template: (rf: RenderFlags, cmp: Parent) => { + if (rf & RenderFlags.Create) { + elementStart(0, 'child'); + { + // Start of translated section 1 + elementStart(1, 'span', ['title', 'keepMe']); // START_SPAN_0 + elementEnd(); + elementStart(2, 'span', ['title', 'deleteMe']); // START_SPAN_1 + elementEnd(); + // End of translated section 1 + } + elementEnd(); + i18nApply(1, i18n_1[0]); + } + } + }); + } + + const fixture = new ComponentFixture(Parent); + expect(fixture.html).toEqual('Contenu'); + }); + }); + + it('i18nInterpolation should return the same value as i18nInterpolationV', () => { + const MSG_DIV_SECTION_1 = `start {$EXP_2} middle {$EXP_1} end`; + const i18n_1 = i18nExpMapping(MSG_DIV_SECTION_1, {EXP_1: 0, EXP_2: 1}); + let interpolation; + let interpolationV; + + class MyApp { + exp1: any = '1'; + exp2: any = '2'; + + static ngComponentDef = defineComponent({ + type: MyApp, + factory: () => new MyApp(), + selectors: [['my-app']], + // Initial template: + //
+ + // Translated to: + //
+ template: (rf: RenderFlags, ctx: MyApp) => { + if (rf & RenderFlags.Create) { + elementStart(0, 'div'); + // Start of translated section 1 + // End of translated section 1 + elementEnd(); + } + if (rf & RenderFlags.Update) { + interpolation = i18nInterpolation(i18n_1, 2, ctx.exp1, ctx.exp2); + interpolationV = i18nInterpolationV(i18n_1, [ctx.exp1, ctx.exp2]); + elementProperty(0, 'title', interpolation); + } + } + }); + } + + const fixture = new ComponentFixture(MyApp); + expect(interpolation).toBeDefined(); + expect(interpolation).toEqual(interpolationV); + }); +}); diff --git a/packages/core/test/render3/node_selector_matcher_spec.ts b/packages/core/test/render3/node_selector_matcher_spec.ts index cf5c2aca36..8cfd3a10df 100644 --- a/packages/core/test/render3/node_selector_matcher_spec.ts +++ b/packages/core/test/render3/node_selector_matcher_spec.ts @@ -24,7 +24,8 @@ function testLStaticData(tagName: string, attrs: TAttributes | null): TNode { next: null, child: null, parent: null, - dynamicContainerNode: null + dynamicContainerNode: null, + detached: null }; }