diff --git a/packages/core/src/hydration/annotate.ts b/packages/core/src/hydration/annotate.ts index 1cd80e4ad52..914801ddea3 100644 --- a/packages/core/src/hydration/annotate.ts +++ b/packages/core/src/hydration/annotate.ts @@ -372,6 +372,9 @@ function serializeLView(lView: LView, context: HydrationContext): SerializedView } } } + + conditionallyAnnotateNodePath(ngh, tNode, lView); + if (isLContainer(lView[i])) { // Serialize information about a template. const embeddedTView = tNode.tView; @@ -402,10 +405,6 @@ 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) { @@ -465,11 +464,6 @@ function serializeLView(lView: LView, context: HydrationContext): SerializedView context.corruptedTextNodes.set(rNode, TextNodeMarker.Separator); } } - - // 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); } } } @@ -477,16 +471,32 @@ function serializeLView(lView: LView, context: HydrationContext): SerializedView } /** - * 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. + * Serializes node location in cases when it's needed, specifically: + * + * 1. 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. Since hydration relies on `tNode.next`, this serialized info + * if required to help runtime code find the node at the correct location. + * 2. In certain content projection-based use-cases, it's possible that only + * a content of a projected element is rendered. In this case, content nodes + * require an extra annotation, since runtime logic can't rely on parent-child + * connection to identify the location of a node. */ -function annotateNextNodePath(ngh: SerializedView, tNode: TNode, lView: LView) { +function conditionallyAnnotateNodePath(ngh: SerializedView, tNode: TNode, lView: LView) { + // Handle case #1 described above. if (tNode.projectionNext && tNode.projectionNext !== tNode.next && !isInSkipHydrationBlock(tNode.projectionNext)) { appendSerializedNodePath(ngh, tNode.projectionNext, lView); } + + // Handle case #2 described above. + // Note: we only do that for the first node (i.e. when `tNode.prev === null`), + // the rest of the nodes would rely on the current node location, so no extra + // annotation is needed. + if (tNode.prev === null && tNode.parent !== null && isDisconnectedNode(tNode.parent, lView) && + !isDisconnectedNode(tNode, lView)) { + appendSerializedNodePath(ngh, tNode, lView); + } } /** @@ -574,5 +584,5 @@ function isContentProjectedNode(tNode: TNode): boolean { */ function isDisconnectedNode(tNode: TNode, lView: LView) { return !(tNode.type & TNodeType.Projection) && !!lView[tNode.index] && - !(unwrapRNode(lView[tNode.index]) as Node).isConnected; + !(unwrapRNode(lView[tNode.index]) as Node)?.isConnected; } diff --git a/packages/platform-server/test/hydration_spec.ts b/packages/platform-server/test/hydration_spec.ts index c2e2cf266a4..497e9460b05 100644 --- a/packages/platform-server/test/hydration_spec.ts +++ b/packages/platform-server/test/hydration_spec.ts @@ -10,7 +10,7 @@ import '@angular/localize/init'; import {CommonModule, DOCUMENT, isPlatformServer, NgComponentOutlet, NgFor, NgIf, NgTemplateOutlet, PlatformLocation} from '@angular/common'; import {MockPlatformLocation} from '@angular/common/testing'; -import {afterRender, ApplicationRef, Component, ComponentRef, createComponent, destroyPlatform, Directive, ElementRef, EnvironmentInjector, ErrorHandler, getPlatform, inject, Injectable, Input, NgZone, PLATFORM_ID, Provider, TemplateRef, Type, ViewChild, ViewContainerRef, ViewEncapsulation, ɵsetDocument, ɵwhenStable as whenStable} from '@angular/core'; +import {afterRender, ApplicationRef, Component, ComponentRef, ContentChildren, createComponent, destroyPlatform, Directive, ElementRef, EnvironmentInjector, ErrorHandler, getPlatform, inject, Injectable, Input, NgZone, PLATFORM_ID, Provider, QueryList, TemplateRef, Type, ViewChild, ViewContainerRef, ViewEncapsulation, ɵsetDocument, ɵwhenStable as whenStable} from '@angular/core'; import {Console} from '@angular/core/src/console'; import {SSR_CONTENT_INTEGRITY_MARKER} from '@angular/core/src/hydration/utils'; import {getComponentDef} from '@angular/core/src/render3/definition'; @@ -4168,6 +4168,89 @@ describe('platform-server hydration integration', () => { verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); + it('should allow re-projection of child content', async () => { + @Component({ + standalone: true, + selector: 'mat-step', + template: ``, + }) + class MatStep { + @ViewChild(TemplateRef, {static: true}) content!: TemplateRef; + } + + @Component({ + standalone: true, + selector: 'mat-stepper', + imports: [NgTemplateOutlet], + template: ` + @for (step of steps; track step) { + + } + `, + }) + class MatStepper { + @ContentChildren(MatStep) steps!: QueryList; + } + + @Component({ + standalone: true, + selector: 'nested-cmp', + template: 'Nested cmp content', + }) + class NestedCmp { + } + + @Component({ + standalone: true, + imports: [MatStepper, MatStep, NgIf, NestedCmp], + selector: 'app', + template: ` + + Text-only content + + + Using ng-containers + + + + + Using ng-containers with *ngIf + + + + + @if (true) { + Using built-in control flow (if) + } + + + + + + + + `, + }) + class App { + } + + const html = await ssr(App); + const ssrContents = getAppContents(html); + + expect(ssrContents).toContain('(appRef); + appRef.tick(); + + const clientRootNode = compRef.location.nativeElement; + verifyAllNodesClaimedForHydration(clientRootNode); + verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); + }); + + it('should project plain text and HTML elements', async () => { @Component({ standalone: true,