Skip to content

Commit

Permalink
fix(core): support hydration for cases when content is re-projected u…
Browse files Browse the repository at this point in the history
…sing ng-template (angular#53304)

This commit fixes an issue with hydration, which happens when a content is projected in a certain way, leaving host elements non-projected, but the child content projected.

The fix is to detect such situations and add extra annotations to help runtime logic locate those elements at the right locations.

Resolves angular#53276.

PR Close angular#53304
  • Loading branch information
AndrewKushnir authored and tbondwilkinson committed Dec 6, 2023
1 parent 2693a9f commit 7840c97
Show file tree
Hide file tree
Showing 2 changed files with 109 additions and 16 deletions.
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

0 comments on commit 7840c97

Please sign in to comment.