Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 25 additions & 15 deletions packages/core/src/hydration/annotate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
// <ng-container> case
if (tNode.type & TNodeType.ElementContainer) {
Expand Down Expand Up @@ -465,28 +464,39 @@ 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);
}
}
}
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.
* 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<unknown>) {
function conditionallyAnnotateNodePath(ngh: SerializedView, tNode: TNode, lView: LView<unknown>) {
// 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);
}
}

/**
Expand Down Expand Up @@ -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;
}
85 changes: 84 additions & 1 deletion packages/platform-server/test/hydration_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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: `<ng-template><ng-content /></ng-template>`,
})
class MatStep {
@ViewChild(TemplateRef, {static: true}) content!: TemplateRef<any>;
}

@Component({
standalone: true,
selector: 'mat-stepper',
imports: [NgTemplateOutlet],
template: `
@for (step of steps; track step) {
<ng-container [ngTemplateOutlet]="step.content" />
}
`,
})
class MatStepper {
@ContentChildren(MatStep) steps!: QueryList<MatStep>;
}

@Component({
standalone: true,
selector: 'nested-cmp',
template: 'Nested cmp content',
})
class NestedCmp {
}

@Component({
standalone: true,
imports: [MatStepper, MatStep, NgIf, NestedCmp],
selector: 'app',
template: `
<mat-stepper>
<mat-step>Text-only content</mat-step>

<mat-step>
<ng-container>Using ng-containers</ng-container>
</mat-step>

<mat-step>
<ng-container *ngIf="true">
Using ng-containers with *ngIf
</ng-container>
</mat-step>

<mat-step>
@if (true) {
Using built-in control flow (if)
}
</mat-step>

<mat-step>
<nested-cmp />
</mat-step>

</mat-stepper>
`,
})
class App {
}

const html = await ssr(App);
const ssrContents = getAppContents(html);

expect(ssrContents).toContain('<app ngh');

resetTViewsFor(App, MatStepper, NestedCmp);

const appRef = await hydrate(html, App);
const compRef = getComponentRef<App>(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,
Expand Down