Skip to content

Commit 4fc1581

Browse files
AndrewKushnirdylhunn
authored andcommitted
fix(core): handle hydration of multiple nodes projected in a single slot (#53270)
This commit updates the logic to handle hydration of multiple nodes projected in a single slot. Currently, in case component nodes are content-projected and their order is changed during the projection, hydration can not find the correct element. With this fix, extra annotation info would be included for such nodes and hydration logic at runtime will use it to locate the right element. Resolves #53246. PR Close #53270
1 parent 81e9489 commit 4fc1581

File tree

2 files changed

+123
-7
lines changed

2 files changed

+123
-7
lines changed

packages/core/src/hydration/annotate.ts

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,10 @@ function serializeLView(lView: LView, context: HydrationContext): SerializedView
402402
if (!(targetNode as HTMLElement).hasAttribute(SKIP_HYDRATION_ATTR_NAME)) {
403403
annotateHostElementForHydration(targetNode as RElement, lView[i], context);
404404
}
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);
405409
} else {
406410
// <ng-container> case
407411
if (tNode.type & TNodeType.ElementContainer) {
@@ -462,19 +466,29 @@ function serializeLView(lView: LView, context: HydrationContext): SerializedView
462466
}
463467
}
464468

465-
if (tNode.projectionNext && tNode.projectionNext !== tNode.next &&
466-
!isInSkipHydrationBlock(tNode.projectionNext)) {
467-
// Check if projection next is not the same as next, in which case
468-
// the node would not be found at creation time at runtime and we
469-
// need to provide a location for that node.
470-
appendSerializedNodePath(ngh, tNode.projectionNext, lView);
471-
}
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);
472473
}
473474
}
474475
}
475476
return ngh;
476477
}
477478

479+
/**
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.
484+
*/
485+
function annotateNextNodePath(ngh: SerializedView, tNode: TNode, lView: LView<unknown>) {
486+
if (tNode.projectionNext && tNode.projectionNext !== tNode.next &&
487+
!isInSkipHydrationBlock(tNode.projectionNext)) {
488+
appendSerializedNodePath(ngh, tNode.projectionNext, lView);
489+
}
490+
}
491+
478492
/**
479493
* Determines whether a component instance that is represented
480494
* by a given LView uses `ViewEncapsulation.ShadowDom`.

packages/platform-server/test/hydration_spec.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4268,6 +4268,108 @@ describe('platform-server hydration integration', () => {
42684268
verifyClientAndSSRContentsMatch(ssrContents, clientRootNode);
42694269
});
42704270

4271+
it('should handle multiple nodes projected in a single slot', async () => {
4272+
@Component({
4273+
standalone: true,
4274+
selector: 'projector-cmp',
4275+
template: `
4276+
<ng-content select="foo" />
4277+
<ng-content select="bar" />
4278+
`,
4279+
})
4280+
class ProjectorCmp {
4281+
}
4282+
4283+
@Component({selector: 'foo', standalone: true, template: ''})
4284+
class FooCmp {
4285+
}
4286+
4287+
@Component({selector: 'bar', standalone: true, template: ''})
4288+
class BarCmp {
4289+
}
4290+
4291+
@Component({
4292+
standalone: true,
4293+
imports: [ProjectorCmp, FooCmp, BarCmp],
4294+
selector: 'app',
4295+
template: `
4296+
<projector-cmp>
4297+
<foo />
4298+
<bar />
4299+
<foo />
4300+
</projector-cmp>
4301+
`,
4302+
})
4303+
class SimpleComponent {
4304+
}
4305+
4306+
const html = await ssr(SimpleComponent);
4307+
const ssrContents = getAppContents(html);
4308+
4309+
expect(ssrContents).toContain('<app ngh');
4310+
4311+
resetTViewsFor(SimpleComponent, ProjectorCmp);
4312+
4313+
const appRef = await hydrate(html, SimpleComponent);
4314+
const compRef = getComponentRef<SimpleComponent>(appRef);
4315+
appRef.tick();
4316+
4317+
const clientRootNode = compRef.location.nativeElement;
4318+
verifyAllNodesClaimedForHydration(clientRootNode);
4319+
verifyClientAndSSRContentsMatch(ssrContents, clientRootNode);
4320+
});
4321+
4322+
it('should handle multiple nodes projected in a single slot (different order)', async () => {
4323+
@Component({
4324+
standalone: true,
4325+
selector: 'projector-cmp',
4326+
template: `
4327+
<ng-content select="foo" />
4328+
<ng-content select="bar" />
4329+
`,
4330+
})
4331+
class ProjectorCmp {
4332+
}
4333+
4334+
@Component({selector: 'foo', standalone: true, template: ''})
4335+
class FooCmp {
4336+
}
4337+
4338+
@Component({selector: 'bar', standalone: true, template: ''})
4339+
class BarCmp {
4340+
}
4341+
4342+
@Component({
4343+
standalone: true,
4344+
imports: [ProjectorCmp, FooCmp, BarCmp],
4345+
selector: 'app',
4346+
template: `
4347+
<projector-cmp>
4348+
<bar />
4349+
<foo />
4350+
<bar />
4351+
</projector-cmp>
4352+
`,
4353+
})
4354+
class SimpleComponent {
4355+
}
4356+
4357+
const html = await ssr(SimpleComponent);
4358+
const ssrContents = getAppContents(html);
4359+
4360+
expect(ssrContents).toContain('<app ngh');
4361+
4362+
resetTViewsFor(SimpleComponent, ProjectorCmp);
4363+
4364+
const appRef = await hydrate(html, SimpleComponent);
4365+
const compRef = getComponentRef<SimpleComponent>(appRef);
4366+
appRef.tick();
4367+
4368+
const clientRootNode = compRef.location.nativeElement;
4369+
verifyAllNodesClaimedForHydration(clientRootNode);
4370+
verifyClientAndSSRContentsMatch(ssrContents, clientRootNode);
4371+
});
4372+
42714373
it('should handle empty projection slots within <ng-container>', async () => {
42724374
@Component({
42734375
standalone: true,

0 commit comments

Comments
 (0)