Skip to content

Commit

Permalink
fix(core): handle hydration of multiple nodes projected in a single s…
Browse files Browse the repository at this point in the history
…lot (#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
  • Loading branch information
AndrewKushnir authored and dylhunn committed Nov 30, 2023
1 parent 81e9489 commit 4fc1581
Show file tree
Hide file tree
Showing 2 changed files with 123 additions and 7 deletions.
28 changes: 21 additions & 7 deletions packages/core/src/hydration/annotate.ts
Expand Up @@ -402,6 +402,10 @@ 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 @@ -462,19 +466,29 @@ function serializeLView(lView: LView, context: HydrationContext): SerializedView
}
}

if (tNode.projectionNext && tNode.projectionNext !== tNode.next &&
!isInSkipHydrationBlock(tNode.projectionNext)) {
// Check if projection next is not the same as next, in which case
// the node would not be found at creation time at runtime and we
// need to provide a location for that node.
appendSerializedNodePath(ngh, tNode.projectionNext, lView);
}
// 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.
*/
function annotateNextNodePath(ngh: SerializedView, tNode: TNode, lView: LView<unknown>) {
if (tNode.projectionNext && tNode.projectionNext !== tNode.next &&
!isInSkipHydrationBlock(tNode.projectionNext)) {
appendSerializedNodePath(ngh, tNode.projectionNext, lView);
}
}

/**
* Determines whether a component instance that is represented
* by a given LView uses `ViewEncapsulation.ShadowDom`.
Expand Down
102 changes: 102 additions & 0 deletions packages/platform-server/test/hydration_spec.ts
Expand Up @@ -4268,6 +4268,108 @@ describe('platform-server hydration integration', () => {
verifyClientAndSSRContentsMatch(ssrContents, clientRootNode);
});

it('should handle multiple nodes projected in a single slot', async () => {
@Component({
standalone: true,
selector: 'projector-cmp',
template: `
<ng-content select="foo" />
<ng-content select="bar" />
`,
})
class ProjectorCmp {
}

@Component({selector: 'foo', standalone: true, template: ''})
class FooCmp {
}

@Component({selector: 'bar', standalone: true, template: ''})
class BarCmp {
}

@Component({
standalone: true,
imports: [ProjectorCmp, FooCmp, BarCmp],
selector: 'app',
template: `
<projector-cmp>
<foo />
<bar />
<foo />
</projector-cmp>
`,
})
class SimpleComponent {
}

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

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

resetTViewsFor(SimpleComponent, ProjectorCmp);

const appRef = await hydrate(html, SimpleComponent);
const compRef = getComponentRef<SimpleComponent>(appRef);
appRef.tick();

const clientRootNode = compRef.location.nativeElement;
verifyAllNodesClaimedForHydration(clientRootNode);
verifyClientAndSSRContentsMatch(ssrContents, clientRootNode);
});

it('should handle multiple nodes projected in a single slot (different order)', async () => {
@Component({
standalone: true,
selector: 'projector-cmp',
template: `
<ng-content select="foo" />
<ng-content select="bar" />
`,
})
class ProjectorCmp {
}

@Component({selector: 'foo', standalone: true, template: ''})
class FooCmp {
}

@Component({selector: 'bar', standalone: true, template: ''})
class BarCmp {
}

@Component({
standalone: true,
imports: [ProjectorCmp, FooCmp, BarCmp],
selector: 'app',
template: `
<projector-cmp>
<bar />
<foo />
<bar />
</projector-cmp>
`,
})
class SimpleComponent {
}

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

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

resetTViewsFor(SimpleComponent, ProjectorCmp);

const appRef = await hydrate(html, SimpleComponent);
const compRef = getComponentRef<SimpleComponent>(appRef);
appRef.tick();

const clientRootNode = compRef.location.nativeElement;
verifyAllNodesClaimedForHydration(clientRootNode);
verifyClientAndSSRContentsMatch(ssrContents, clientRootNode);
});

it('should handle empty projection slots within <ng-container>', async () => {
@Component({
standalone: true,
Expand Down

0 comments on commit 4fc1581

Please sign in to comment.