diff --git a/packages/core/src/hydration/annotate.ts b/packages/core/src/hydration/annotate.ts index c94e64b74b42f..1cd80e4ad5253 100644 --- a/packages/core/src/hydration/annotate.ts +++ b/packages/core/src/hydration/annotate.ts @@ -402,6 +402,10 @@ function serializeLView(lView: LView, context: HydrationContext): SerializedView if (!(targetNode as HTMLElement).hasAttribute(SKIP_HYDRATION_ATTR_NAME)) { annotateHostElementForHydration(targetNode as RElement, lView[i], context); } + // Include node path info to the annotation in case `tNode.next` (which hydration + // relies upon by default) is different from the `tNode.projectionNext`. This helps + // hydration runtime logic to find the right node. + annotateNextNodePath(ngh, tNode, lView); } else { // case if (tNode.type & TNodeType.ElementContainer) { @@ -462,19 +466,29 @@ function serializeLView(lView: LView, context: HydrationContext): SerializedView } } - if (tNode.projectionNext && tNode.projectionNext !== tNode.next && - !isInSkipHydrationBlock(tNode.projectionNext)) { - // Check if projection next is not the same as next, in which case - // the node would not be found at creation time at runtime and we - // need to provide a location for that node. - appendSerializedNodePath(ngh, tNode.projectionNext, lView); - } + // Include node path info to the annotation in case `tNode.next` (which hydration + // relies upon by default) is different from the `tNode.projectionNext`. This helps + // hydration runtime logic to find the right node. + annotateNextNodePath(ngh, tNode, lView); } } } return ngh; } +/** + * If `tNode.projectionNext` is different from `tNode.next` - it means that + * the next `tNode` after projection is different from the one in the original + * template. In this case we need to serialize a path to that next node, so that + * it can be found at the right location at runtime. + */ +function annotateNextNodePath(ngh: SerializedView, tNode: TNode, lView: LView) { + if (tNode.projectionNext && tNode.projectionNext !== tNode.next && + !isInSkipHydrationBlock(tNode.projectionNext)) { + appendSerializedNodePath(ngh, tNode.projectionNext, lView); + } +} + /** * Determines whether a component instance that is represented * by a given LView uses `ViewEncapsulation.ShadowDom`. diff --git a/packages/platform-server/test/hydration_spec.ts b/packages/platform-server/test/hydration_spec.ts index 448ff232811e1..c2e2cf266a481 100644 --- a/packages/platform-server/test/hydration_spec.ts +++ b/packages/platform-server/test/hydration_spec.ts @@ -4268,6 +4268,108 @@ describe('platform-server hydration integration', () => { verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); + it('should handle multiple nodes projected in a single slot', async () => { + @Component({ + standalone: true, + selector: 'projector-cmp', + template: ` + + + `, + }) + class ProjectorCmp { + } + + @Component({selector: 'foo', standalone: true, template: ''}) + class FooCmp { + } + + @Component({selector: 'bar', standalone: true, template: ''}) + class BarCmp { + } + + @Component({ + standalone: true, + imports: [ProjectorCmp, FooCmp, BarCmp], + selector: 'app', + template: ` + + + + + + `, + }) + class SimpleComponent { + } + + const html = await ssr(SimpleComponent); + const ssrContents = getAppContents(html); + + expect(ssrContents).toContain('(appRef); + appRef.tick(); + + const clientRootNode = compRef.location.nativeElement; + verifyAllNodesClaimedForHydration(clientRootNode); + verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); + }); + + it('should handle multiple nodes projected in a single slot (different order)', async () => { + @Component({ + standalone: true, + selector: 'projector-cmp', + template: ` + + + `, + }) + class ProjectorCmp { + } + + @Component({selector: 'foo', standalone: true, template: ''}) + class FooCmp { + } + + @Component({selector: 'bar', standalone: true, template: ''}) + class BarCmp { + } + + @Component({ + standalone: true, + imports: [ProjectorCmp, FooCmp, BarCmp], + selector: 'app', + template: ` + + + + + + `, + }) + class SimpleComponent { + } + + const html = await ssr(SimpleComponent); + const ssrContents = getAppContents(html); + + expect(ssrContents).toContain('(appRef); + appRef.tick(); + + const clientRootNode = compRef.location.nativeElement; + verifyAllNodesClaimedForHydration(clientRootNode); + verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); + }); + it('should handle empty projection slots within ', async () => { @Component({ standalone: true,