Skip to content

Commit 4b23221

Browse files
AndrewKushnirdylhunn
authored andcommitted
fix(core): support hydration for cases when content is re-projected using ng-template (#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 #53276. PR Close #53304
1 parent 77ac4cd commit 4b23221

File tree

2 files changed

+109
-16
lines changed

2 files changed

+109
-16
lines changed

packages/core/src/hydration/annotate.ts

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,9 @@ function serializeLView(lView: LView, context: HydrationContext): SerializedView
372372
}
373373
}
374374
}
375+
376+
conditionallyAnnotateNodePath(ngh, tNode, lView);
377+
375378
if (isLContainer(lView[i])) {
376379
// Serialize information about a template.
377380
const embeddedTView = tNode.tView;
@@ -402,10 +405,6 @@ function serializeLView(lView: LView, context: HydrationContext): SerializedView
402405
if (!(targetNode as HTMLElement).hasAttribute(SKIP_HYDRATION_ATTR_NAME)) {
403406
annotateHostElementForHydration(targetNode as RElement, lView[i], context);
404407
}
405-
// Include node path info to the annotation in case `tNode.next` (which hydration
406-
// relies upon by default) is different from the `tNode.projectionNext`. This helps
407-
// hydration runtime logic to find the right node.
408-
annotateNextNodePath(ngh, tNode, lView);
409408
} else {
410409
// <ng-container> case
411410
if (tNode.type & TNodeType.ElementContainer) {
@@ -465,28 +464,39 @@ function serializeLView(lView: LView, context: HydrationContext): SerializedView
465464
context.corruptedTextNodes.set(rNode, TextNodeMarker.Separator);
466465
}
467466
}
468-
469-
// Include node path info to the annotation in case `tNode.next` (which hydration
470-
// relies upon by default) is different from the `tNode.projectionNext`. This helps
471-
// hydration runtime logic to find the right node.
472-
annotateNextNodePath(ngh, tNode, lView);
473467
}
474468
}
475469
}
476470
return ngh;
477471
}
478472

479473
/**
480-
* If `tNode.projectionNext` is different from `tNode.next` - it means that
481-
* the next `tNode` after projection is different from the one in the original
482-
* template. In this case we need to serialize a path to that next node, so that
483-
* it can be found at the right location at runtime.
474+
* Serializes node location in cases when it's needed, specifically:
475+
*
476+
* 1. If `tNode.projectionNext` is different from `tNode.next` - it means that
477+
* the next `tNode` after projection is different from the one in the original
478+
* template. Since hydration relies on `tNode.next`, this serialized info
479+
* if required to help runtime code find the node at the correct location.
480+
* 2. In certain content projection-based use-cases, it's possible that only
481+
* a content of a projected element is rendered. In this case, content nodes
482+
* require an extra annotation, since runtime logic can't rely on parent-child
483+
* connection to identify the location of a node.
484484
*/
485-
function annotateNextNodePath(ngh: SerializedView, tNode: TNode, lView: LView<unknown>) {
485+
function conditionallyAnnotateNodePath(ngh: SerializedView, tNode: TNode, lView: LView<unknown>) {
486+
// Handle case #1 described above.
486487
if (tNode.projectionNext && tNode.projectionNext !== tNode.next &&
487488
!isInSkipHydrationBlock(tNode.projectionNext)) {
488489
appendSerializedNodePath(ngh, tNode.projectionNext, lView);
489490
}
491+
492+
// Handle case #2 described above.
493+
// Note: we only do that for the first node (i.e. when `tNode.prev === null`),
494+
// the rest of the nodes would rely on the current node location, so no extra
495+
// annotation is needed.
496+
if (tNode.prev === null && tNode.parent !== null && isDisconnectedNode(tNode.parent, lView) &&
497+
!isDisconnectedNode(tNode, lView)) {
498+
appendSerializedNodePath(ngh, tNode, lView);
499+
}
490500
}
491501

492502
/**
@@ -574,5 +584,5 @@ function isContentProjectedNode(tNode: TNode): boolean {
574584
*/
575585
function isDisconnectedNode(tNode: TNode, lView: LView) {
576586
return !(tNode.type & TNodeType.Projection) && !!lView[tNode.index] &&
577-
!(unwrapRNode(lView[tNode.index]) as Node).isConnected;
587+
!(unwrapRNode(lView[tNode.index]) as Node)?.isConnected;
578588
}

packages/platform-server/test/hydration_spec.ts

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import '@angular/localize/init';
1010

1111
import {CommonModule, DOCUMENT, isPlatformServer, NgComponentOutlet, NgFor, NgIf, NgTemplateOutlet, PlatformLocation} from '@angular/common';
1212
import {MockPlatformLocation} from '@angular/common/testing';
13-
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';
13+
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';
1414
import {Console} from '@angular/core/src/console';
1515
import {SSR_CONTENT_INTEGRITY_MARKER} from '@angular/core/src/hydration/utils';
1616
import {getComponentDef} from '@angular/core/src/render3/definition';
@@ -4168,6 +4168,89 @@ describe('platform-server hydration integration', () => {
41684168
verifyClientAndSSRContentsMatch(ssrContents, clientRootNode);
41694169
});
41704170

4171+
it('should allow re-projection of child content', async () => {
4172+
@Component({
4173+
standalone: true,
4174+
selector: 'mat-step',
4175+
template: `<ng-template><ng-content /></ng-template>`,
4176+
})
4177+
class MatStep {
4178+
@ViewChild(TemplateRef, {static: true}) content!: TemplateRef<any>;
4179+
}
4180+
4181+
@Component({
4182+
standalone: true,
4183+
selector: 'mat-stepper',
4184+
imports: [NgTemplateOutlet],
4185+
template: `
4186+
@for (step of steps; track step) {
4187+
<ng-container [ngTemplateOutlet]="step.content" />
4188+
}
4189+
`,
4190+
})
4191+
class MatStepper {
4192+
@ContentChildren(MatStep) steps!: QueryList<MatStep>;
4193+
}
4194+
4195+
@Component({
4196+
standalone: true,
4197+
selector: 'nested-cmp',
4198+
template: 'Nested cmp content',
4199+
})
4200+
class NestedCmp {
4201+
}
4202+
4203+
@Component({
4204+
standalone: true,
4205+
imports: [MatStepper, MatStep, NgIf, NestedCmp],
4206+
selector: 'app',
4207+
template: `
4208+
<mat-stepper>
4209+
<mat-step>Text-only content</mat-step>
4210+
4211+
<mat-step>
4212+
<ng-container>Using ng-containers</ng-container>
4213+
</mat-step>
4214+
4215+
<mat-step>
4216+
<ng-container *ngIf="true">
4217+
Using ng-containers with *ngIf
4218+
</ng-container>
4219+
</mat-step>
4220+
4221+
<mat-step>
4222+
@if (true) {
4223+
Using built-in control flow (if)
4224+
}
4225+
</mat-step>
4226+
4227+
<mat-step>
4228+
<nested-cmp />
4229+
</mat-step>
4230+
4231+
</mat-stepper>
4232+
`,
4233+
})
4234+
class App {
4235+
}
4236+
4237+
const html = await ssr(App);
4238+
const ssrContents = getAppContents(html);
4239+
4240+
expect(ssrContents).toContain('<app ngh');
4241+
4242+
resetTViewsFor(App, MatStepper, NestedCmp);
4243+
4244+
const appRef = await hydrate(html, App);
4245+
const compRef = getComponentRef<App>(appRef);
4246+
appRef.tick();
4247+
4248+
const clientRootNode = compRef.location.nativeElement;
4249+
verifyAllNodesClaimedForHydration(clientRootNode);
4250+
verifyClientAndSSRContentsMatch(ssrContents, clientRootNode);
4251+
});
4252+
4253+
41714254
it('should project plain text and HTML elements', async () => {
41724255
@Component({
41734256
standalone: true,

0 commit comments

Comments
 (0)