diff --git a/packages/core/src/hydration/annotate.ts b/packages/core/src/hydration/annotate.ts index 17c2e11a90ca3e..39c4946ebe901b 100644 --- a/packages/core/src/hydration/annotate.ts +++ b/packages/core/src/hydration/annotate.ts @@ -11,16 +11,17 @@ import {ViewEncapsulation} from '../metadata'; import {Renderer2} from '../render'; import {collectNativeNodes, collectNativeNodesInLContainer} from '../render3/collect_native_nodes'; import {getComponentDef} from '../render3/definition'; +import { computeI18nSerialization } from '../render3/i18n/i18n_hydration'; import {CONTAINER_HEADER_OFFSET, LContainer} from '../render3/interfaces/container'; import {isTNodeShape, TNode, TNodeType} from '../render3/interfaces/node'; import {RElement} from '../render3/interfaces/renderer_dom'; -import {hasI18n, isComponentHost, isLContainer, isProjectionTNode, isRootView} from '../render3/interfaces/type_checks'; +import {isComponentHost, isLContainer, isProjectionTNode, isRootView} from '../render3/interfaces/type_checks'; import {CONTEXT, HEADER_OFFSET, HOST, LView, PARENT, RENDERER, TView, TVIEW, TViewType} from '../render3/interfaces/view'; import {unwrapLView, unwrapRNode} from '../render3/util/view_utils'; import {TransferState} from '../transfer_state'; import {unsupportedProjectionOfDomNodes} from './error_handling'; -import {CONTAINERS, DISCONNECTED_NODES, ELEMENT_CONTAINERS, MULTIPLIER, NODES, NUM_ROOT_NODES, SerializedContainerView, SerializedView, TEMPLATE_ID, TEMPLATES} from './interfaces'; +import {CONTAINERS, DISCONNECTED_NODES, ELEMENT_CONTAINERS, I18N_ICU_DATA, MULTIPLIER, NODES, NUM_ROOT_NODES, SerializedContainerView, SerializedView, TEMPLATE_ID, TEMPLATES} from './interfaces'; import {calcPathForNode, isDisconnectedNode} from './node_lookup_utils'; import {isInSkipHydrationBlock, SKIP_HYDRATION_ATTR_NAME} from './skip_hydration'; import {getLNodeForHydration, NGH_ATTR_NAME, NGH_DATA_KEY, TextNodeMarker} from './utils'; @@ -311,10 +312,18 @@ function appendDisconnectedNodeIndex(ngh: SerializedView, tNode: TNode) { function serializeLView(lView: LView, context: HydrationContext): SerializedView { const ngh: SerializedView = {}; const tView = lView[TVIEW]; + // Iterate over DOM element references in an LView. for (let i = HEADER_OFFSET; i < tView.bindingStartIndex; i++) { const tNode = tView.data[i] as TNode; const noOffsetIndex = i - HEADER_OFFSET; + + const i18nICUData = computeI18nSerialization(lView, i); + if (i18nICUData) { + ngh[I18N_ICU_DATA] ??= {}; + ngh[I18N_ICU_DATA][noOffsetIndex] = i18nICUData; + } + // Skip processing of a given slot in the following cases: // - Local refs (e.g.
) take up an extra slot in LViews // to store the same element. In this case, there is no information in @@ -526,7 +535,7 @@ function componentUsesShadowDomEncapsulation(lView: LView): boolean { function annotateHostElementForHydration( element: RElement, lView: LView, context: HydrationContext): number|null { const renderer = lView[RENDERER]; - if (hasI18n(lView) || componentUsesShadowDomEncapsulation(lView)) { + if (componentUsesShadowDomEncapsulation(lView)) { // Attach the skip hydration attribute if this component: // - either has i18n blocks, since hydrating such blocks is not yet supported // - or uses ShadowDom view encapsulation, since Domino doesn't support diff --git a/packages/core/src/hydration/interfaces.ts b/packages/core/src/hydration/interfaces.ts index 5b1a25c8a7d5ce..f17520543684d8 100644 --- a/packages/core/src/hydration/interfaces.ts +++ b/packages/core/src/hydration/interfaces.ts @@ -36,6 +36,7 @@ export const NUM_ROOT_NODES = 'r'; export const TEMPLATE_ID = 'i'; // as it's also an "id" export const NODES = 'n'; export const DISCONNECTED_NODES = 'd'; +export const I18N_ICU_DATA = 'l'; /** * Represents element containers within this view, stored as key-value pairs @@ -92,6 +93,11 @@ export interface SerializedView { * logic for such nodes and instead use a regular "creation mode". */ [DISCONNECTED_NODES]?: number[]; + + /** + * TODO: Map an LView to a queue of selected ICU cases. + */ + [I18N_ICU_DATA]?: Record; } /** @@ -165,6 +171,12 @@ export interface DehydratedView { * nodes detected in this view at serialization time. */ disconnectedNodes?: Set|null; + + /** + * TODO: Maps an LView to a first node to begin hydrating at. + * This is generated by i18n. + */ + nodeMap?: Record; } /** diff --git a/packages/core/src/hydration/node_lookup_utils.ts b/packages/core/src/hydration/node_lookup_utils.ts index 0001c257cd8cf2..4c2b134798acd6 100644 --- a/packages/core/src/hydration/node_lookup_utils.ts +++ b/packages/core/src/hydration/node_lookup_utils.ts @@ -43,6 +43,14 @@ export function isDisconnectedNode(tNode: TNode, lView: LView) { !(unwrapRNode(lView[tNode.index]) as Node)?.isConnected; } +/** + * TODO: Like `locateNextRNode`, but for when the node is expected to be directly + * known. + */ +export function locateRNodeByIndex(hydrationInfo: DehydratedView, index: number): T|null { + return (hydrationInfo.nodeMap?.[index] as T) ?? null; +} + /** * Locate a node in DOM tree that corresponds to a given TNode. * @@ -54,49 +62,51 @@ export function isDisconnectedNode(tNode: TNode, lView: LView) { */ export function locateNextRNode( hydrationInfo: DehydratedView, tView: TView, lView: LView, tNode: TNode): T|null { - let native: RNode|null = null; const noOffsetIndex = getNoOffsetIndex(tNode); - const nodes = hydrationInfo.data[NODES]; - if (nodes?.[noOffsetIndex]) { - // We know the exact location of the node. - native = locateRNodeByPath(nodes[noOffsetIndex], lView); - } else if (tView.firstChild === tNode) { - // We create a first node in this view, so we use a reference - // to the first child in this DOM segment. - native = hydrationInfo.firstChild; - } else { - // Locate a node based on a previous sibling or a parent node. - const previousTNodeParent = tNode.prev === null; - const previousTNode = (tNode.prev ?? tNode.parent)!; - ngDevMode && - assertDefined( - previousTNode, - 'Unexpected state: current TNode does not have a connection ' + - 'to the previous node or a parent node.'); - if (isFirstElementInNgContainer(tNode)) { - const noOffsetParentIndex = getNoOffsetIndex(tNode.parent!); - native = getSegmentHead(hydrationInfo, noOffsetParentIndex); + let native: RNode|null = locateRNodeByIndex(hydrationInfo, noOffsetIndex); + if (native == null) { + const nodes = hydrationInfo.data[NODES]; + if (nodes?.[noOffsetIndex]) { + // We know the exact location of the node. + native = locateRNodeByPath(nodes[noOffsetIndex], lView); + } else if (tView.firstChild === tNode) { + // We create a first node in this view, so we use a reference + // to the first child in this DOM segment. + native = hydrationInfo.firstChild; } else { - let previousRElement = getNativeByTNode(previousTNode, lView); - if (previousTNodeParent) { - native = (previousRElement as RElement).firstChild; + // Locate a node based on a previous sibling or a parent node. + const previousTNodeParent = tNode.prev === null; + const previousTNode = (tNode.prev ?? tNode.parent)!; + ngDevMode && + assertDefined( + previousTNode, + 'Unexpected state: current TNode does not have a connection ' + + 'to the previous node or a parent node.'); + if (isFirstElementInNgContainer(tNode)) { + const noOffsetParentIndex = getNoOffsetIndex(tNode.parent!); + native = getSegmentHead(hydrationInfo, noOffsetParentIndex); } else { - // If the previous node is an element, but it also has container info, - // this means that we are processing a node like `
`, which is - // represented in the DOM as `
...`. - // In this case, there are nodes *after* this element and we need to skip - // all of them to reach an element that we are looking for. - const noOffsetPrevSiblingIndex = getNoOffsetIndex(previousTNode); - const segmentHead = getSegmentHead(hydrationInfo, noOffsetPrevSiblingIndex); - if (previousTNode.type === TNodeType.Element && segmentHead) { - const numRootNodesToSkip = - calcSerializedContainerSize(hydrationInfo, noOffsetPrevSiblingIndex); - // `+1` stands for an anchor comment node after all the views in this container. - const nodesToSkip = numRootNodesToSkip + 1; - // First node after this segment. - native = siblingAfter(nodesToSkip, segmentHead); + let previousRElement = getNativeByTNode(previousTNode, lView); + if (previousTNodeParent) { + native = (previousRElement as RElement).firstChild; } else { - native = previousRElement.nextSibling; + // If the previous node is an element, but it also has container info, + // this means that we are processing a node like `
`, which is + // represented in the DOM as `
...`. + // In this case, there are nodes *after* this element and we need to skip + // all of them to reach an element that we are looking for. + const noOffsetPrevSiblingIndex = getNoOffsetIndex(previousTNode); + const segmentHead = getSegmentHead(hydrationInfo, noOffsetPrevSiblingIndex); + if (previousTNode.type === TNodeType.Element && segmentHead) { + const numRootNodesToSkip = + calcSerializedContainerSize(hydrationInfo, noOffsetPrevSiblingIndex); + // `+1` stands for an anchor comment node after all the views in this container. + const nodesToSkip = numRootNodesToSkip + 1; + // First node after this segment. + native = siblingAfter(nodesToSkip, segmentHead); + } else { + native = previousRElement.nextSibling; + } } } } diff --git a/packages/core/src/render3/i18n/i18n_apply.ts b/packages/core/src/render3/i18n/i18n_apply.ts index 27d376d0d59c09..91853ef30d59a7 100644 --- a/packages/core/src/render3/i18n/i18n_apply.ts +++ b/packages/core/src/render3/i18n/i18n_apply.ts @@ -7,8 +7,10 @@ */ import {RuntimeError, RuntimeErrorCode} from '../../errors'; +import { locateRNodeByIndex } from '../../hydration/node_lookup_utils'; +import { isDisconnectedNode, markRNodeAsClaimedByHydration } from '../../hydration/utils'; import {getPluralCase} from '../../i18n/localization'; -import {assertDefined, assertDomNode, assertEqual, assertGreaterThan, assertIndexInRange, throwError} from '../../util/assert'; +import {assertDefined, assertDomNode, assertEqual, assertGreaterThan, assertIndexInRange, assertNotDefined, throwError} from '../../util/assert'; import {assertIndexInExpandoRange, assertTIcu} from '../assert'; import {attachPatchData} from '../context_discovery'; import {elementPropertyInternal, setElementAttribute} from '../instructions/shared'; @@ -16,9 +18,9 @@ import {ELEMENT_MARKER, I18nCreateOpCode, I18nCreateOpCodes, I18nUpdateOpCode, I import {TNode} from '../interfaces/node'; import {RElement, RNode, RText} from '../interfaces/renderer_dom'; import {SanitizerFn} from '../interfaces/sanitization'; -import {HEADER_OFFSET, LView, RENDERER, TView} from '../interfaces/view'; +import {HEADER_OFFSET, HYDRATION, LView, RENDERER, TVIEW, TView} from '../interfaces/view'; import {createCommentNode, createElementNode, createTextNode, nativeInsertBefore, nativeParentNode, nativeRemoveNode, updateTextNode} from '../node_manipulation'; -import {getBindingIndex} from '../state'; +import {getBindingIndex, isInSkipHydrationBlock, lastNodeWasCreated, wasLastNodeCreated} from '../state'; import {renderStringify} from '../util/stringify_utils'; import {getNativeByIndex, unwrapRNode} from '../util/view_utils'; @@ -101,19 +103,50 @@ export function applyCreateOpCodes( 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) { + + ngDevMode && assertNotDefined(lView[index], 'view should not exist'); + + let rNode = lView[index] = locateOrCreateNode(lView, index, text, isComment ? Node.COMMENT_NODE : Node.TEXT_NODE); + + if ((appendNow && parentRNode !== null) && wasLastNodeCreated()) { nativeInsertBefore(renderer, parentRNode, rNode, insertInFrontOf, false); } } } +function locateOrCreateNode(lView: LView, index: number, textOrName: string, nodeType: number) { + const hydrationInfo = lView[HYDRATION]; + const isNodeCreationMode = !hydrationInfo || isInSkipHydrationBlock() || isDisconnectedNode(hydrationInfo, index); + + lastNodeWasCreated(isNodeCreationMode); + + if (isNodeCreationMode) { + const renderer = lView[RENDERER]; + switch (nodeType) { + case Node.COMMENT_NODE: + return createCommentNode(renderer, textOrName); + + case Node.TEXT_NODE: + return createTextNode(renderer, textOrName); + + case Node.ELEMENT_NODE: + return createElementNode(renderer, textOrName, null); + } + } + + const native = locateRNodeByIndex(hydrationInfo!, index - HEADER_OFFSET) as RNode; + + if (ngDevMode) { + // TODO: Ideally we'd reuse `validateMatchingNode` here, but not all i18n nodes have + // a valid TNode. + assertEqual((native as Node).nodeType, nodeType, 'expected matching node type'); + nodeType === Node.ELEMENT_NODE && assertEqual((native as Element).tagName.toLowerCase(), textOrName.toLowerCase(), 'expected matching tag name'); + markRNodeAsClaimedByHydration(native); + } + + return native; +} + /** * Apply `I18nMutateOpCodes` OpCodes. * @@ -141,7 +174,7 @@ export function applyMutableOpCodes( if (lView[textNodeIndex] === null) { ngDevMode && ngDevMode.rendererCreateTextNode++; ngDevMode && assertIndexInRange(lView, textNodeIndex); - lView[textNodeIndex] = createTextNode(renderer, opCode); + lView[textNodeIndex] = locateOrCreateNode(lView, textNodeIndex, opCode, Node.TEXT_NODE); } } else if (typeof opCode == 'number') { switch (opCode & IcuCreateOpCode.MASK_INSTRUCTION) { @@ -218,8 +251,7 @@ export function applyMutableOpCodes( `Expected "${commentValue}" to be a comment node value`); ngDevMode && ngDevMode.rendererCreateComment++; ngDevMode && assertIndexInExpandoRange(lView, commentNodeIndex); - const commentRNode = lView[commentNodeIndex] = - createCommentNode(renderer, commentValue); + const commentRNode = lView[commentNodeIndex] = locateOrCreateNode(lView, commentNodeIndex, commentValue, Node.COMMENT_NODE); // FIXME(misko): Attaching patch data is only needed for the root (Also add tests) attachPatchData(commentRNode, lView); } @@ -236,7 +268,7 @@ export function applyMutableOpCodes( ngDevMode && ngDevMode.rendererCreateElement++; ngDevMode && assertIndexInExpandoRange(lView, elementNodeIndex); const elementRNode = lView[elementNodeIndex] = - createElementNode(renderer, tagName, null); + locateOrCreateNode(lView, elementNodeIndex, tagName, Node.ELEMENT_NODE); // FIXME(misko): Attaching patch data is only needed for the root (Also add tests) attachPatchData(elementRNode, lView); } diff --git a/packages/core/src/render3/i18n/i18n_hydration.ts b/packages/core/src/render3/i18n/i18n_hydration.ts new file mode 100644 index 00000000000000..6965e1044170db --- /dev/null +++ b/packages/core/src/render3/i18n/i18n_hydration.ts @@ -0,0 +1,207 @@ +/** + * @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 { DehydratedView, I18N_ICU_DATA } from "../../hydration/interfaces"; +import { locateNextRNode, siblingAfter } from "../../hydration/node_lookup_utils"; +import { getNgContainerSize } from "../../hydration/utils"; +import { assertDefined, assertNotEqual } from "../../util/assert"; +import { I18nNode, I18nNodeKind, I18nPlaceholderType, TI18n } from "../interfaces/i18n"; +import { TNode } from "../interfaces/node"; +import { RNode } from "../interfaces/renderer_dom"; +import { HEADER_OFFSET, HYDRATION, LView, TVIEW } from "../interfaces/view"; + +/** + * TODO: Compute the i18n data (i.e. ICU cases, if any) to serialize for the given LView. + */ +export function computeI18nSerialization(lView: LView, index: number): Array|null { + const tView = lView[TVIEW]; + const tI18n = tView.data[index] as TI18n | undefined; + + if (!tI18n || !tI18n.ast) { + return null; + } + + const decisions: Array = []; + tI18n.ast.forEach(node => computeCaseQueue(node, lView, decisions)); + return decisions.length > 0 ? decisions : null; +} + +function computeCaseQueue(node: I18nNode, lView: LView, decisions: Array) { + switch (node.kind) { + case I18nNodeKind.ELEMENT: + case I18nNodeKind.PLACEHOLDER: + node.children.forEach(node => computeCaseQueue(node, lView, decisions)); + break; + + case I18nNodeKind.ICU: + const currentCase = lView[node.currentCaseLViewIndex] as number; + if (currentCase < 0) { + const caseIdx = ~currentCase; + decisions.push(caseIdx); + node.cases[caseIdx].forEach(node => computeCaseQueue(node, lView, decisions)); + } + break; + } +} + +interface DeserializationContext { + lView: LView; + disconnectedNodes: Set; + nodeMap: Record; + caseQueue: number[]; + hydrationInfo: DehydratedView; +} + +/** + * TODO: Updates the hydration info for the given LView, based on the serialized + * i18n data, if available. + * + * In particular, this uses the serialized i18n data to walk over the AST and map + * each LView to a given DOM element. This is used to update the `nodeMap` and + * `disconnectedNodes` values in the LView's hydration info, so that Angular can + * hydrate as usual. + */ +export function computeI18nDeserialization(lView: LView, index: number): void { + const hydrationInfo = lView[HYDRATION]; + if (!hydrationInfo) { + return; + } + + const tView = lView[TVIEW]; + const tI18n = tView.data[index] as TI18n; + const caseQueue = hydrationInfo?.data[I18N_ICU_DATA]?.[index - HEADER_OFFSET] ?? []; + + ngDevMode && assertDefined(tI18n, 'Expected i18n data'); + ngDevMode && assertDefined(tI18n.ast, 'Expected valid i18n data'); + + const firstChildIndex = tI18n.ast[0].index; + const tNode = tView.data[firstChildIndex] as TNode; + const rootNode: Node = locateNextRNode(hydrationInfo, tView, lView, tNode) as Node; + + ngDevMode && assertDefined(rootNode, 'expected root node'); + + const nodeMap = hydrationInfo.nodeMap ??= {}; + const disconnectedNodes = hydrationInfo.disconnectedNodes ??= new Set(); + + const context: DeserializationContext = { + lView, + disconnectedNodes, + nodeMap, + caseQueue: [...caseQueue], + hydrationInfo, + }; + const state: HydrationState = { + currentNode: rootNode, + }; + computeDeserializationData(context, tI18n.ast, state); +} + +interface HydrationState { + currentNode: Node | null; +} + +const enum MarkOptions { + NONE = 0, + CLAIM +} + +function markHydrationRoot(context: DeserializationContext, astNode: I18nNode, state: HydrationState | null, options: MarkOptions): void { + const noOffsetIndex = astNode.index - HEADER_OFFSET; + const domNode = state?.currentNode; + + if (domNode != null) { + context.nodeMap[noOffsetIndex] = domNode; + if (options === MarkOptions.CLAIM) { + state!.currentNode = domNode.nextSibling; + } + } else { + context.disconnectedNodes.add(noOffsetIndex); + } +} + +function computeDeserializationData(context: DeserializationContext, astNodeOrNodes: I18nNode | I18nNode[], state: HydrationState | null) { + if (Array.isArray(astNodeOrNodes)) { + for (let i = 0; i < astNodeOrNodes.length; i++) { + computeDeserializationData(context, astNodeOrNodes[i], state); + } + } else { + const astNode = astNodeOrNodes; + switch (astNode.kind) { + case I18nNodeKind.TEXT: { + markHydrationRoot(context, astNode, state, MarkOptions.CLAIM); + break; + } + + case I18nNodeKind.ELEMENT: { + const childState = state == null ? null : { currentNode: state.currentNode?.firstChild ?? null }; + computeDeserializationData(context, astNode.children, childState); + markHydrationRoot(context, astNode, state, MarkOptions.CLAIM); + break; + } + + case I18nNodeKind.PLACEHOLDER: { + const containerSize = getNgContainerSize(context.hydrationInfo, astNode.index - HEADER_OFFSET); + + switch (astNode.type) { + case I18nPlaceholderType.ELEMENT: { + let childState = state; + let markOptions = MarkOptions.NONE; + + if (containerSize == null) { + // Elements have an actual representation in the DOM, + // so we need to traverse their children, and continue + // hydrating from the next sibling. + childState = state == null ? null : { currentNode: state.currentNode?.firstChild ?? null }; + markOptions = MarkOptions.CLAIM; + } + + // Hydration expects to find the head of the element. + markHydrationRoot(context, astNode, state, markOptions); + computeDeserializationData(context, astNode.children, childState); + + if (containerSize != null && state?.currentNode) { + // Skip over the anchor element for containers. The element + // will be claimed when the container is hydrated. + state.currentNode = state.currentNode.nextSibling; + } + break; + } + + case I18nPlaceholderType.SUBTEMPLATE: { + ngDevMode && assertNotEqual(containerSize, null, 'expected a container size'); + + // Hydration expects to find the head of the template. + markHydrationRoot(context, astNode, state, MarkOptions.NONE); + + if (state?.currentNode) { + // Skip over the template children since the template will take care + // of hydrating them. + state.currentNode = siblingAfter(containerSize!, state.currentNode); + + // Also skip over the achor element. We don't include this in the + // siblingAfter above because it's OK for the node to be null, such + // as when we've reached the end of a leaf. + state.currentNode = state.currentNode?.nextSibling ?? null; + } + break; + } + } + break; + } + + case I18nNodeKind.ICU: { + const selectedCase = state?.currentNode ? context.caseQueue.shift()! : null; + for (let i = 0; i < astNode.cases.length; i++) { + computeDeserializationData(context, astNode.cases[i], selectedCase === i ? state : null); + } + markHydrationRoot(context, astNode, state, MarkOptions.CLAIM); + break; + } + } + } +} diff --git a/packages/core/src/render3/i18n/i18n_parse.ts b/packages/core/src/render3/i18n/i18n_parse.ts index 224a7a0523c26a..5b78183c058b39 100644 --- a/packages/core/src/render3/i18n/i18n_parse.ts +++ b/packages/core/src/render3/i18n/i18n_parse.ts @@ -17,7 +17,7 @@ import {CharCode} from '../../util/char_code'; import {loadIcuContainerVisitor} from '../instructions/i18n_icu_container_visitor'; import {allocExpando, createTNodeAtIndex} from '../instructions/shared'; import {getDocument} from '../interfaces/document'; -import {ELEMENT_MARKER, I18nCreateOpCode, I18nCreateOpCodes, I18nRemoveOpCodes, I18nUpdateOpCode, I18nUpdateOpCodes, ICU_MARKER, IcuCreateOpCode, IcuCreateOpCodes, IcuExpression, IcuType, TI18n, TIcu} from '../interfaces/i18n'; +import {ELEMENT_MARKER, I18nCreateOpCode, I18nCreateOpCodes, I18nElementNode, I18nICUNode, I18nNode, I18nNodeKind, I18nPlaceholderNode, I18nPlaceholderType, I18nRemoveOpCodes, I18nTextNode, I18nUpdateOpCode, I18nUpdateOpCodes, ICU_MARKER, IcuCreateOpCode, IcuCreateOpCodes, IcuExpression, IcuType, TI18n, TIcu} from '../interfaces/i18n'; import {TNode, TNodeType} from '../interfaces/node'; import {SanitizerFn} from '../interfaces/sanitization'; import {HEADER_OFFSET, LView, TView} from '../interfaces/view'; @@ -88,6 +88,7 @@ export function i18nStartFirstCreatePass( const createOpCodes: I18nCreateOpCodes = [] as any; const updateOpCodes: I18nUpdateOpCodes = [] as any; const existingTNodeStack: TNode[][] = [[]]; + const astStack: Array> = [[]]; if (ngDevMode) { attachDebugGetter(createOpCodes, i18nCreateOpCodesToString); attachDebugGetter(updateOpCodes, i18nUpdateOpCodesToString); @@ -107,8 +108,9 @@ export function i18nStartFirstCreatePass( const text = part as string; ngDevMode && assertString(text, 'Parsed ICU part should be string'); if (text !== '') { - i18nStartFirstCreatePassProcessTextNode( + const textNodeIndex = i18nStartFirstCreatePassProcessTextNode( tView, rootTNode, existingTNodeStack[0], createOpCodes, updateOpCodes, lView, text); + astStack[0].push({ kind: I18nNodeKind.TEXT, index: textNodeIndex, debug: text }); } } else { // `j` is Even therefor `part` is an `ICUExpression` @@ -129,7 +131,7 @@ export function i18nStartFirstCreatePass( ngDevMode && assertGreaterThanOrEqual( icuNodeIndex, HEADER_OFFSET, 'Index must be in absolute LView offset'); - icuStart(tView, lView, updateOpCodes, parentTNodeIndex, icuExpression, icuNodeIndex); + icuStart(tView, lView, astStack[0], updateOpCodes, parentTNodeIndex, icuExpression, icuNodeIndex); } } } else { @@ -141,11 +143,21 @@ export function i18nStartFirstCreatePass( const index = HEADER_OFFSET + Number.parseInt(value.substring((isClosing ? 2 : 1))); if (isClosing) { existingTNodeStack.shift(); + astStack.shift(); setCurrentTNode(getCurrentParentTNode()!, false); } else { const tNode = createTNodePlaceholder(tView, existingTNodeStack[0], index); existingTNodeStack.unshift([]); setCurrentTNode(tNode, true); + + const ast: I18nPlaceholderNode = { + kind: I18nNodeKind.PLACEHOLDER, + index, + children: [], + type: type === CharCode.HASH ? I18nPlaceholderType.ELEMENT : I18nPlaceholderType.SUBTEMPLATE, + }; + astStack[0].push(ast); + astStack.unshift(ast.children); } } } @@ -153,6 +165,7 @@ export function i18nStartFirstCreatePass( tView.data[index] = { create: createOpCodes, update: updateOpCodes, + ast: astStack[0], }; } @@ -230,13 +243,15 @@ function createTNodeAndAddOpCode( */ function i18nStartFirstCreatePassProcessTextNode( tView: TView, rootTNode: TNode|null, existingTNodes: TNode[], createOpCodes: I18nCreateOpCodes, - updateOpCodes: I18nUpdateOpCodes, lView: LView, text: string): void { + updateOpCodes: I18nUpdateOpCodes, lView: LView, text: string): number { const hasBinding = text.match(BINDING_REGEXP); const tNode = createTNodeAndAddOpCode( tView, rootTNode, existingTNodes, lView, createOpCodes, hasBinding ? null : text, false); + const index = tNode.index; if (hasBinding) { - generateBindingUpdateOpCodes(updateOpCodes, text, tNode.index, null, 0, null); + generateBindingUpdateOpCodes(updateOpCodes, text, index, null, 0, null); } + return index; } /** @@ -442,7 +457,7 @@ export function getTranslationForTemplate(message: string, subTemplateIndex: num * - `tView.data[anchorIdx]` points to the `TIcuContainerNode` if ICU is root (`null` otherwise) */ export function icuStart( - tView: TView, lView: LView, updateOpCodes: I18nUpdateOpCodes, parentIdx: number, + tView: TView, lView: LView, ast: Array, updateOpCodes: I18nUpdateOpCodes, parentIdx: number, icuExpression: IcuExpression, anchorIdx: number) { ngDevMode && assertDefined(icuExpression, 'ICU expression must be defined'); let bindingMask = 0; @@ -458,6 +473,7 @@ export function icuStart( addUpdateIcuSwitch(updateOpCodes, icuExpression, anchorIdx); setTIcu(tView, anchorIdx, tIcu); const values = icuExpression.values; + const cases: I18nNode[][] = []; for (let i = 0; i < values.length; i++) { // Each value is an array of strings & other ICU expressions const valueArr = values[i]; @@ -471,14 +487,17 @@ export function icuStart( valueArr[j] = ``; } } + const ast: I18nNode[] = []; + cases.push(ast); bindingMask = parseIcuCase( - tView, tIcu, lView, updateOpCodes, parentIdx, icuExpression.cases[i], + tView, tIcu, lView, ast, updateOpCodes, parentIdx, icuExpression.cases[i], valueArr.join(''), nestedIcus) | bindingMask; } if (bindingMask) { addUpdateIcuUpdate(updateOpCodes, bindingMask, anchorIdx); } + ast.push({ kind: I18nNodeKind.ICU, index: anchorIdx, cases, currentCaseLViewIndex: tIcu.currentCaseLViewIndex } as I18nICUNode); } /** @@ -586,7 +605,7 @@ export function i18nParseTextIntoPartsAndICU(pattern: string): (string|IcuExpres * */ export function parseIcuCase( - tView: TView, tIcu: TIcu, lView: LView, updateOpCodes: I18nUpdateOpCodes, parentIdx: number, + tView: TView, tIcu: TIcu, lView: LView, ast: Array, updateOpCodes: I18nUpdateOpCodes, parentIdx: number, caseName: string, unsafeCaseHtml: string, nestedIcus: IcuExpression[]): number { const create: IcuCreateOpCodes = [] as any; const remove: I18nRemoveOpCodes = [] as any; @@ -607,7 +626,7 @@ export function parseIcuCase( const inertRootNode = getTemplateContent(inertBodyElement!) as Element || inertBodyElement; if (inertRootNode) { return walkIcuTree( - tView, tIcu, lView, updateOpCodes, create, remove, update, inertRootNode, parentIdx, + tView, tIcu, lView, ast, updateOpCodes, create, remove, update, inertRootNode, parentIdx, nestedIcus, 0); } else { return 0; @@ -615,7 +634,7 @@ export function parseIcuCase( } function walkIcuTree( - tView: TView, tIcu: TIcu, lView: LView, sharedUpdateOpCodes: I18nUpdateOpCodes, + tView: TView, tIcu: TIcu, lView: LView, ast: Array, sharedUpdateOpCodes: I18nUpdateOpCodes, create: IcuCreateOpCodes, remove: I18nRemoveOpCodes, update: I18nUpdateOpCodes, parentNode: Element, parentIdx: number, nestedIcus: IcuExpression[], depth: number): number { let bindingMask = 0; @@ -654,9 +673,12 @@ function walkIcuTree( addCreateAttribute(create, newIndex, attr); } } + const elementNode: I18nElementNode = { kind: I18nNodeKind.ELEMENT, index: newIndex, children: [], debug: tagName }; + ast.push(elementNode); + // Parse the children of this node (if any) bindingMask = walkIcuTree( - tView, tIcu, lView, sharedUpdateOpCodes, create, remove, update, + tView, tIcu, lView, elementNode.children, sharedUpdateOpCodes, create, remove, update, currentNode as Element, newIndex, nestedIcus, depth + 1) | bindingMask; addRemoveNode(remove, newIndex, depth); @@ -671,6 +693,7 @@ function walkIcuTree( bindingMask = generateBindingUpdateOpCodes(update, value, newIndex, null, 0, null) | bindingMask; } + ast.push({ kind: I18nNodeKind.TEXT, index: newIndex, debug: value } as I18nTextNode); break; case Node.COMMENT_NODE: // Check if the comment node is a placeholder for a nested ICU @@ -682,7 +705,7 @@ function walkIcuTree( addCreateNodeAndAppend( create, ICU_MARKER, ngDevMode ? `nested ICU ${nestedIcuIndex}` : '', parentIdx, newIndex); - icuStart(tView, lView, sharedUpdateOpCodes, parentIdx, icuExpression, newIndex); + icuStart(tView, lView, ast, sharedUpdateOpCodes, parentIdx, icuExpression, newIndex); addRemoveNestedIcu(remove, newIndex, depth); } break; diff --git a/packages/core/src/render3/instructions/i18n.ts b/packages/core/src/render3/instructions/i18n.ts index 37925ae2d24f33..b0a4477fd7522d 100644 --- a/packages/core/src/render3/instructions/i18n.ts +++ b/packages/core/src/render3/instructions/i18n.ts @@ -19,6 +19,7 @@ import {DECLARATION_COMPONENT_VIEW, FLAGS, HEADER_OFFSET, LViewFlags, T_HOST, TV import {getClosestRElement} from '../node_manipulation'; import {getCurrentParentTNode, getLView, getTView, nextBindingIndex, setInI18nBlock} from '../state'; import {getConstant} from '../util/view_utils'; +import { computeI18nDeserialization } from '../i18n/i18n_hydration'; /** * Marks a block of text as translatable. @@ -77,8 +78,9 @@ export function ɵɵi18nStart( // 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; + lView[parentTNode.index] : + null; + computeI18nDeserialization(lView, adjustedIndex); applyCreateOpCodes(lView, tI18n.create, parentRNode, insertInFrontOf); setInI18nBlock(true); } diff --git a/packages/core/src/render3/interfaces/i18n.ts b/packages/core/src/render3/interfaces/i18n.ts index 4b38b15a3af649..0947adab34b94f 100644 --- a/packages/core/src/render3/interfaces/i18n.ts +++ b/packages/core/src/render3/interfaces/i18n.ts @@ -341,6 +341,11 @@ export interface TI18n { * DOM are required. */ update: I18nUpdateOpCodes; + + /** + * TODO: The AST for the i18n message. + */ + ast: Array; } /** @@ -408,3 +413,46 @@ export interface IcuExpression { cases: string[]; values: (string|IcuExpression)[][]; } + +export type I18nNode = I18nTextNode | I18nElementNode | I18nICUNode | I18nPlaceholderNode; + +export type I18nTextNode = { + kind: I18nNodeKind.TEXT; + index: number; + debug?: string; +} + +export type I18nElementNode = { + kind: I18nNodeKind.ELEMENT; + index: number; + children: Array; + debug?: string; +} + +export type I18nICUNode = { + kind: I18nNodeKind.ICU; + index: number; + cases: Array>; + currentCaseLViewIndex: number; +} + +export type I18nPlaceholderNode = { + kind: I18nNodeKind.PLACEHOLDER; + index: number; + children: Array; + type: I18nPlaceholderType; +} + +// TODO: These are strings for debugging. +export const enum I18nPlaceholderType { + ELEMENT = 'ELEMENT', + SUBTEMPLATE = 'SUBTEMPLATE', +} + +// TODO: These are strings for debugging. +export const enum I18nNodeKind { + TEXT = 'TEXT', + ELEMENT = 'ELEMENT', + PLACEHOLDER = 'PLACEHOLDER', + ICU = 'ICU', +}