mirror of
https://github.com/angular/angular
synced 2026-05-24 09:28:37 +00:00
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
This commit is contained in:
parent
77ac4cd324
commit
4b23221b4e
2 changed files with 109 additions and 16 deletions
|
|
@ -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 {
|
||||
// <ng-container> 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<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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: `<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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue