diff --git a/packages/core/src/hydration/annotate.ts b/packages/core/src/hydration/annotate.ts index bbc851e662175..c99454ddf16e0 100644 --- a/packages/core/src/hydration/annotate.ts +++ b/packages/core/src/hydration/annotate.ts @@ -11,6 +11,7 @@ import {APP_ID} from '../application/application_tokens'; import {isDetachedByI18n} from '../i18n/utils'; import {ViewEncapsulation} from '../metadata'; import {Renderer2} from '../render'; +import {assertTNode} from '../render3/assert'; import {collectNativeNodes, collectNativeNodesInLContainer} from '../render3/collect_native_nodes'; import {getComponentDef} from '../render3/definition'; import {CONTAINER_HEADER_OFFSET, LContainer} from '../render3/interfaces/container'; @@ -362,7 +363,8 @@ function appendSerializedNodePath( ) { const noOffsetIndex = tNode.index - HEADER_OFFSET; ngh[NODES] ??= {}; - ngh[NODES][noOffsetIndex] = calcPathForNode(tNode, lView, excludedParentNodes); + // Ensure we don't calculate the path multiple times. + ngh[NODES][noOffsetIndex] ??= calcPathForNode(tNode, lView, excludedParentNodes); } /** @@ -370,8 +372,11 @@ function appendSerializedNodePath( * This info is needed at runtime to avoid DOM lookups for this element * and instead, the element would be created from scratch. */ -function appendDisconnectedNodeIndex(ngh: SerializedView, tNode: TNode) { - const noOffsetIndex = tNode.index - HEADER_OFFSET; +function appendDisconnectedNodeIndex(ngh: SerializedView, tNodeOrNoOffsetIndex: TNode | number) { + const noOffsetIndex = + typeof tNodeOrNoOffsetIndex === 'number' + ? tNodeOrNoOffsetIndex + : tNodeOrNoOffsetIndex.index - HEADER_OFFSET; ngh[DISCONNECTED_NODES] ??= []; if (!ngh[DISCONNECTED_NODES].includes(noOffsetIndex)) { ngh[DISCONNECTED_NODES].push(noOffsetIndex); @@ -404,7 +409,18 @@ function serializeLView(lView: LView, context: HydrationContext): SerializedView const i18nData = trySerializeI18nBlock(lView, i, context); if (i18nData) { ngh[I18N_DATA] ??= {}; - ngh[I18N_DATA][noOffsetIndex] = i18nData; + ngh[I18N_DATA][noOffsetIndex] = i18nData.caseQueue; + + for (const nodeNoOffsetIndex of i18nData.disconnectedNodes) { + appendDisconnectedNodeIndex(ngh, nodeNoOffsetIndex); + } + + for (const nodeNoOffsetIndex of i18nData.disjointNodes) { + const tNode = tView.data[nodeNoOffsetIndex + HEADER_OFFSET] as TNode; + ngDevMode && assertTNode(tNode); + appendSerializedNodePath(ngh, tNode, lView, i18nChildren); + } + continue; } diff --git a/packages/core/src/hydration/i18n.ts b/packages/core/src/hydration/i18n.ts index 4519007c023b2..0896b7739354d 100644 --- a/packages/core/src/hydration/i18n.ts +++ b/packages/core/src/hydration/i18n.ts @@ -8,18 +8,19 @@ import {inject, Injector} from '../di'; import {isRootTemplateMessage} from '../render3/i18n/i18n_util'; -import {I18nNode, I18nNodeKind, I18nPlaceholderType, TI18n} from '../render3/interfaces/i18n'; -import {TNode, TNodeType} from '../render3/interfaces/node'; +import {createIcuIterator} from '../render3/instructions/i18n_icu_container_visitor'; +import {I18nNode, I18nNodeKind, I18nPlaceholderType, TI18n, TIcu} from '../render3/interfaces/i18n'; +import {isTNodeShape, TNode, TNodeType} from '../render3/interfaces/node'; import type {Renderer} from '../render3/interfaces/renderer'; import type {RNode} from '../render3/interfaces/renderer_dom'; import {HEADER_OFFSET, HYDRATION, LView, RENDERER, TView, TVIEW} from '../render3/interfaces/view'; -import {nativeRemoveNode} from '../render3/node_manipulation'; +import {getFirstNativeNode, nativeRemoveNode} from '../render3/node_manipulation'; import {unwrapRNode} from '../render3/util/view_utils'; import {assertDefined, assertNotEqual} from '../util/assert'; import type {HydrationContext} from './annotate'; import {DehydratedIcuData, DehydratedView, I18N_DATA} from './interfaces'; -import {locateNextRNode, tryLocateRNodeByPath} from './node_lookup_utils'; +import {isDisconnectedRNode, locateNextRNode, tryLocateRNodeByPath} from './node_lookup_utils'; import {IS_I18N_HYDRATION_ENABLED} from './tokens'; import { getNgContainerSize, @@ -130,6 +131,38 @@ function collectI18nChildren(tView: TView): Set | null { return children.size === 0 ? null : children; } +/** + * Resulting data from serializing an i18n block. + */ +export interface SerializedI18nBlock { + /** + * A queue of active ICU cases from a depth-first traversal + * of the i18n AST. This is serialized to the client in order + * to correctly associate DOM nodes with i18n nodes during + * hydration. + */ + caseQueue: Array; + + /** + * A set of indices in the lView of the block for nodes + * that are disconnected from the DOM. In i18n, this can + * happen when using content projection but some nodes are + * not selected by an . + */ + disconnectedNodes: Set; + + /** + * A set of indices in the lView of the block for nodes + * considered "disjoint", indicating that we need to serialize + * a path to the node in order to hydrate it. + * + * A node is considered disjoint when its RNode does not + * directly follow the RNode of the previous i18n node, for + * example, because of content projection. + */ + disjointNodes: Set; +} + /** * Attempts to serialize i18n data for an i18n block, located at * the given view and instruction index. @@ -143,7 +176,7 @@ export function trySerializeI18nBlock( lView: LView, index: number, context: HydrationContext, -): Array | null { +): SerializedI18nBlock | null { if (!context.isI18nHydrationEnabled) { return null; } @@ -154,38 +187,123 @@ export function trySerializeI18nBlock( return null; } - const caseQueue: number[] = []; - tI18n.ast.forEach((node) => serializeI18nBlock(lView, caseQueue, context, node)); - return caseQueue.length > 0 ? caseQueue : null; + const serializedI18nBlock: SerializedI18nBlock = { + caseQueue: [], + disconnectedNodes: new Set(), + disjointNodes: new Set(), + }; + serializeI18nBlock(lView, serializedI18nBlock, context, tI18n.ast); + + return serializedI18nBlock.caseQueue.length === 0 && + serializedI18nBlock.disconnectedNodes.size === 0 && + serializedI18nBlock.disjointNodes.size === 0 + ? null + : serializedI18nBlock; } function serializeI18nBlock( lView: LView, - caseQueue: number[], + serializedI18nBlock: SerializedI18nBlock, + context: HydrationContext, + nodes: I18nNode[], +): Node | null { + let prevRNode = null; + for (const node of nodes) { + const nextRNode = serializeI18nNode(lView, serializedI18nBlock, context, node); + if (nextRNode) { + if (isDisjointNode(prevRNode, nextRNode)) { + serializedI18nBlock.disjointNodes.add(node.index - HEADER_OFFSET); + } + prevRNode = nextRNode; + } + } + return prevRNode; +} + +/** + * Helper to determine whether the given nodes are "disjoint". + * + * The i18n hydration process walks through the DOM and i18n nodes + * at the same time. It expects the sibling DOM node of the previous + * i18n node to be the first node of the next i18n node. + * + * In cases of content projection, this won't always be the case. So + * when we detect that, we mark the node as "disjoint", ensuring that + * we will serialize the path to the node. This way, when we hydrate the + * i18n node, we will be able to find the correct place to start. + */ +function isDisjointNode(prevNode: Node | null, nextNode: Node) { + return prevNode && prevNode.nextSibling !== nextNode; +} + +/** + * Process the given i18n node for serialization. + * Returns the first RNode for the i18n node to begin hydration. + */ +function serializeI18nNode( + lView: LView, + serializedI18nBlock: SerializedI18nBlock, context: HydrationContext, node: I18nNode, -) { +): Node | null { + const maybeRNode = unwrapRNode(lView[node.index]!); + if (!maybeRNode || isDisconnectedRNode(maybeRNode)) { + serializedI18nBlock.disconnectedNodes.add(node.index - HEADER_OFFSET); + return null; + } + + const rNode = maybeRNode as Node; switch (node.kind) { - case I18nNodeKind.TEXT: - const rNode = unwrapRNode(lView[node.index]!); + case I18nNodeKind.TEXT: { processTextNodeBeforeSerialization(context, rNode); break; + } case I18nNodeKind.ELEMENT: - case I18nNodeKind.PLACEHOLDER: - node.children.forEach((node) => serializeI18nBlock(lView, caseQueue, context, node)); + case I18nNodeKind.PLACEHOLDER: { + serializeI18nBlock(lView, serializedI18nBlock, context, node.children); break; + } - case I18nNodeKind.ICU: + case I18nNodeKind.ICU: { const currentCase = lView[node.currentCaseLViewIndex] as number | null; if (currentCase != null) { // i18n uses a negative value to signal a change to a new case, so we // need to invert it to get the proper value. const caseIdx = currentCase < 0 ? ~currentCase : currentCase; - caseQueue.push(caseIdx); - node.cases[caseIdx].forEach((node) => serializeI18nBlock(lView, caseQueue, context, node)); + serializedI18nBlock.caseQueue.push(caseIdx); + serializeI18nBlock(lView, serializedI18nBlock, context, node.cases[caseIdx]); } break; + } + } + + return getFirstNativeNodeForI18nNode(lView, node) as Node | null; +} + +/** + * Helper function to get the first native node to begin hydrating + * the given i18n node. + */ +function getFirstNativeNodeForI18nNode(lView: LView, node: I18nNode) { + const tView = lView[TVIEW]; + const maybeTNode = tView.data[node.index]; + + if (node.kind === I18nNodeKind.ICU) { + // A nested ICU container won't have an actual TNode. In that case, we can use + // an iterator to find the first child. + const icuIterator = createIcuIterator(maybeTNode as TIcu, lView); + let rNode: RNode | null = icuIterator(); + + // If the ICU container has no nodes, then we use the ICU anchor as the node. + return rNode ?? unwrapRNode(lView[node.index]); + } else if (isTNodeShape(maybeTNode)) { + // If the node is backed by an actual TNode, we can simply delegate. + return getFirstNativeNode(lView, maybeTNode); + } else { + // Otherwise, the node is a text or trivial element in an ICU container, + // and we can just use the RNode directly. + return unwrapRNode(lView[node.index]) ?? null; } } @@ -346,19 +464,28 @@ function collectI18nNodesFromDom( nodeOrNodes: I18nNode | I18nNode[], ) { if (Array.isArray(nodeOrNodes)) { + let nextState = state; for (const node of nodeOrNodes) { - // If the node is being projected elsewhere, we need to temporarily - // branch the state to that location to continue hydration. - // Otherwise, we continue hydration from the current location. + // Whenever a node doesn't directly follow the previous RNode, it + // is given a path. We need to resume collecting nodes from that location + // until and unless we find another disjoint node. const targetNode = tryLocateRNodeByPath( context.hydrationInfo, context.lView, node.index - HEADER_OFFSET, ); - const nextState = targetNode ? forkHydrationState(state, targetNode as Node) : state; + if (targetNode) { + nextState = forkHydrationState(state, targetNode as Node); + } collectI18nNodesFromDom(context, nextState, node); } } else { + if (context.disconnectedNodes.has(nodeOrNodes.index - HEADER_OFFSET)) { + // i18n nodes can be considered disconnected if e.g. they were projected. + // In that case, we have to make sure to skip over them. + return; + } + switch (nodeOrNodes.kind) { case I18nNodeKind.TEXT: { // Claim a text node for hydration diff --git a/packages/core/src/hydration/node_lookup_utils.ts b/packages/core/src/hydration/node_lookup_utils.ts index 12254e7648685..b007772c79eb9 100644 --- a/packages/core/src/hydration/node_lookup_utils.ts +++ b/packages/core/src/hydration/node_lookup_utils.ts @@ -48,19 +48,26 @@ function getNoOffsetIndex(tNode: TNode): number { /** * Check whether a given node exists, but is disconnected from the DOM. - * - * Note: we leverage the fact that we have this information available in the DOM emulation - * layer (in Domino) for now. Longer-term solution should not rely on the DOM emulation and - * only use internal data structures and state to compute this information. */ export function isDisconnectedNode(tNode: TNode, lView: LView) { return ( !(tNode.type & TNodeType.Projection) && !!lView[tNode.index] && - !(unwrapRNode(lView[tNode.index]) as Node)?.isConnected + isDisconnectedRNode(unwrapRNode(lView[tNode.index])) ); } +/** + * Check whether the given node exists, but is disconnected from the DOM. + * + * Note: we leverage the fact that we have this information available in the DOM emulation + * layer (in Domino) for now. Longer-term solution should not rely on the DOM emulation and + * only use internal data structures and state to compute this information. + */ +export function isDisconnectedRNode(rNode: RNode | null) { + return !!rNode && !(rNode as Node).isConnected; +} + /** * Locate a node in an i18n tree that corresponds to a given instruction index. * @@ -352,7 +359,7 @@ export function calcPathForNode( referenceNodeName = renderStringify(parentIndex - HEADER_OFFSET); } let rNode = unwrapRNode(lView[tNode.index]); - if (tNode.type & TNodeType.AnyContainer) { + if (tNode.type & (TNodeType.AnyContainer | TNodeType.Icu)) { // For nodes, instead of serializing a reference // to the anchor comment node, serialize a location of the first // DOM element. Paired with the container size (serialized as a part diff --git a/packages/core/src/render3/instructions/i18n_icu_container_visitor.ts b/packages/core/src/render3/instructions/i18n_icu_container_visitor.ts index 2ac99ecda2aab..a724dbd37e159 100644 --- a/packages/core/src/render3/instructions/i18n_icu_container_visitor.ts +++ b/packages/core/src/render3/instructions/i18n_icu_container_visitor.ts @@ -15,11 +15,59 @@ import {TIcuContainerNode} from '../interfaces/node'; import {RNode} from '../interfaces/renderer_dom'; import {LView, TVIEW} from '../interfaces/view'; +interface IcuIteratorState { + stack: any[]; + index: number; + lView?: LView; + removes?: I18nRemoveOpCodes; +} + +type IcuIterator = () => RNode | null; + +function enterIcu(state: IcuIteratorState, tIcu: TIcu, lView: LView) { + state.index = 0; + const currentCase = getCurrentICUCaseIndex(tIcu, lView); + if (currentCase !== null) { + ngDevMode && assertNumberInRange(currentCase, 0, tIcu.cases.length - 1); + state.removes = tIcu.remove[currentCase]; + } else { + state.removes = EMPTY_ARRAY as any; + } +} + +function icuContainerIteratorNext(state: IcuIteratorState): RNode | null { + if (state.index < state.removes!.length) { + const removeOpCode = state.removes![state.index++] as number; + ngDevMode && assertNumber(removeOpCode, 'Expecting OpCode number'); + if (removeOpCode > 0) { + const rNode = state.lView![removeOpCode]; + ngDevMode && assertDomNode(rNode); + return rNode; + } else { + state.stack.push(state.index, state.removes); + // ICUs are represented by negative indices + const tIcuIndex = ~removeOpCode; + const tIcu = state.lView![TVIEW].data[tIcuIndex] as TIcu; + ngDevMode && assertTIcu(tIcu); + enterIcu(state, tIcu, state.lView!); + return icuContainerIteratorNext(state); + } + } else { + if (state.stack.length === 0) { + return null; + } else { + state.removes = state.stack.pop(); + state.index = state.stack.pop(); + return icuContainerIteratorNext(state); + } + } +} + export function loadIcuContainerVisitor() { - const _stack: any[] = []; - let _index: number = -1; - let _lView: LView; - let _removes: I18nRemoveOpCodes; + const _state: IcuIteratorState = { + stack: [], + index: -1, + }; /** * Retrieves a set of root nodes from `TIcu.remove`. Used by `TNodeType.ICUContainer` @@ -40,52 +88,24 @@ export function loadIcuContainerVisitor() { function icuContainerIteratorStart( tIcuContainerNode: TIcuContainerNode, lView: LView, - ): () => RNode | null { - _lView = lView; - while (_stack.length) _stack.pop(); + ): IcuIterator { + _state.lView = lView; + while (_state.stack.length) _state.stack.pop(); ngDevMode && assertTNodeForLView(tIcuContainerNode, lView); - enterIcu(tIcuContainerNode.value, lView); - return icuContainerIteratorNext; - } - - function enterIcu(tIcu: TIcu, lView: LView) { - _index = 0; - const currentCase = getCurrentICUCaseIndex(tIcu, lView); - if (currentCase !== null) { - ngDevMode && assertNumberInRange(currentCase, 0, tIcu.cases.length - 1); - _removes = tIcu.remove[currentCase]; - } else { - _removes = EMPTY_ARRAY as any; - } - } - - function icuContainerIteratorNext(): RNode | null { - if (_index < _removes.length) { - const removeOpCode = _removes[_index++] as number; - ngDevMode && assertNumber(removeOpCode, 'Expecting OpCode number'); - if (removeOpCode > 0) { - const rNode = _lView[removeOpCode]; - ngDevMode && assertDomNode(rNode); - return rNode; - } else { - _stack.push(_index, _removes); - // ICUs are represented by negative indices - const tIcuIndex = ~removeOpCode; - const tIcu = _lView[TVIEW].data[tIcuIndex] as TIcu; - ngDevMode && assertTIcu(tIcu); - enterIcu(tIcu, _lView); - return icuContainerIteratorNext(); - } - } else { - if (_stack.length === 0) { - return null; - } else { - _removes = _stack.pop(); - _index = _stack.pop(); - return icuContainerIteratorNext(); - } - } + enterIcu(_state, tIcuContainerNode.value, lView); + return icuContainerIteratorNext.bind(null, _state); } return icuContainerIteratorStart; } + +export function createIcuIterator(tIcu: TIcu, lView: LView): IcuIterator { + const state: IcuIteratorState = { + stack: [], + index: -1, + lView, + }; + ngDevMode && assertTIcu(tIcu); + enterIcu(state, tIcu, lView); + return icuContainerIteratorNext.bind(null, state); +} diff --git a/packages/platform-server/test/hydration_spec.ts b/packages/platform-server/test/hydration_spec.ts index 2582be4b2008d..ce27e0ad69fbb 100644 --- a/packages/platform-server/test/hydration_spec.ts +++ b/packages/platform-server/test/hydration_spec.ts @@ -2122,6 +2122,146 @@ describe('platform-server hydration integration', () => { expect(content.innerHTML).toBe('two
one
'); }); + it('should support interleaving projected content', async () => { + @Component({ + standalone: true, + selector: 'app-content', + template: `Start Middle End`, + }) + class ContentComponent {} + + @Component({ + standalone: true, + selector: 'app', + template: ` + + Span + Middle Start + Middle End +
Div
+
+ `, + imports: [ContentComponent], + }) + class SimpleComponent {} + + const hydrationFeatures = [withI18nSupport()] as unknown as HydrationFeature[]; + const html = await ssr(SimpleComponent, {hydrationFeatures}); + const ssrContents = getAppContents(html); + expect(ssrContents).toContain('(appRef); + appRef.tick(); + + const clientRootNode = compRef.location.nativeElement; + verifyAllNodesClaimedForHydration(clientRootNode); + verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); + + const content = clientRootNode.querySelector('app-content'); + expect(content.innerHTML).toBe('Start
Div
Middle Span End'); + }); + + it('should support disjoint nodes', async () => { + @Component({ + standalone: true, + selector: 'app-content', + template: `Start Middle End`, + }) + class ContentComponent {} + + @Component({ + standalone: true, + selector: 'app', + template: ` + + Inner Start + Span + { count, plural, other { Hello World! }} + Inner End + + `, + imports: [ContentComponent], + }) + class SimpleComponent { + count = 0; + } + + const hydrationFeatures = [withI18nSupport()] as unknown as HydrationFeature[]; + const html = await ssr(SimpleComponent, {hydrationFeatures}); + const ssrContents = getAppContents(html); + expect(ssrContents).toContain('(appRef); + appRef.tick(); + + const clientRootNode = compRef.location.nativeElement; + verifyAllNodesClaimedForHydration(clientRootNode); + verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); + + const content = clientRootNode.querySelector('app-content'); + expect(content.innerHTML).toBe( + 'Start Inner Start Hello World! Inner End Middle Span End', + ); + }); + + it('should support nested content projection', async () => { + @Component({ + standalone: true, + selector: 'app-content-inner', + template: `Start Middle End`, + }) + class InnerContentComponent {} + + @Component({ + standalone: true, + selector: 'app-content-outer', + template: ``, + imports: [InnerContentComponent], + }) + class OuterContentComponent {} + + @Component({ + standalone: true, + selector: 'app', + template: ` + + Outer Start + Span + { count, plural, other { Hello World! }} + Outer End + + `, + imports: [OuterContentComponent], + }) + class SimpleComponent {} + + const hydrationFeatures = [withI18nSupport()] as unknown as HydrationFeature[]; + const html = await ssr(SimpleComponent, {hydrationFeatures}); + const ssrContents = getAppContents(html); + expect(ssrContents).toContain('(appRef); + appRef.tick(); + + const clientRootNode = compRef.location.nativeElement; + verifyAllNodesClaimedForHydration(clientRootNode); + verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); + + const content = clientRootNode.querySelector('app-content-outer'); + expect(content.innerHTML).toBe( + 'Start Outer Start Span Hello World! Outer End Middle End', + ); + }); + it('should support hosting projected content', async () => { @Component({ standalone: true, @@ -2157,6 +2297,88 @@ describe('platform-server hydration integration', () => { expect(div.innerHTML).toBe('Start Middle End'); }); + it('should support projecting multiple elements', async () => { + @Component({ + standalone: true, + selector: 'app-content', + template: ``, + }) + class ContentComponent {} + + @Component({ + standalone: true, + selector: 'app', + template: ` + + Start + Middle + End + + `, + imports: [ContentComponent], + }) + class SimpleComponent {} + + const hydrationFeatures = [withI18nSupport()] as unknown as HydrationFeature[]; + const html = await ssr(SimpleComponent, {hydrationFeatures}); + const ssrContents = getAppContents(html); + expect(ssrContents).toContain('(appRef); + appRef.tick(); + + const clientRootNode = compRef.location.nativeElement; + verifyAllNodesClaimedForHydration(clientRootNode); + verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); + + const content = clientRootNode.querySelector('app-content'); + expect(content.innerHTML).toMatch(/ Start Middle<\/span> End /); + }); + + it('should support disconnecting i18n nodes during projection', async () => { + @Component({ + standalone: true, + selector: 'app-content', + template: `Start End`, + }) + class ContentComponent {} + + @Component({ + standalone: true, + selector: 'app', + template: ` + + Middle Start + Middle + Middle End + + `, + imports: [ContentComponent], + }) + class SimpleComponent {} + + const hydrationFeatures = [withI18nSupport()] as unknown as HydrationFeature[]; + const html = await ssr(SimpleComponent, {hydrationFeatures}); + const ssrContents = getAppContents(html); + expect(ssrContents).toContain('(appRef); + appRef.tick(); + + const clientRootNode = compRef.location.nativeElement; + verifyAllNodesClaimedForHydration(clientRootNode); + verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); + + const content = clientRootNode.querySelector('app-content'); + expect(content.innerHTML).toBe('Start Middle End'); + }); + it('should support using translated views as view container anchors', async () => { @Component({ standalone: true,