From 4fc1581bbcd98e607eb2bbd9976c64c92a70d827 Mon Sep 17 00:00:00 2001 From: Andrew Kushnir Date: Wed, 29 Nov 2023 14:27:33 -0800 Subject: [PATCH] fix(core): handle hydration of multiple nodes projected in a single slot (#53270) This commit updates the logic to handle hydration of multiple nodes projected in a single slot. Currently, in case component nodes are content-projected and their order is changed during the projection, hydration can not find the correct element. With this fix, extra annotation info would be included for such nodes and hydration logic at runtime will use it to locate the right element. Resolves #53246. PR Close #53270 --- packages/core/src/hydration/annotate.ts | 28 +++-- .../platform-server/test/hydration_spec.ts | 102 ++++++++++++++++++ 2 files changed, 123 insertions(+), 7 deletions(-) diff --git a/packages/core/src/hydration/annotate.ts b/packages/core/src/hydration/annotate.ts index c94e64b74b4..1cd80e4ad52 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 448ff232811..c2e2cf266a4 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,