refactor(core): hydrate components of the same type used with and without ngSkipHydration (#49943)

This commit updates hydration logic to hanlde a case when the same component is used multiple times in a template and in some of those cases, component is opted-out of hydration, for example:

```
<cmp ngSkipHydration />
<cmp />
```

Previously, the first occurrence of the `<cmp>` would result in storing the `ssrId` on a TView as `null` (since hydration is disabled for the component) and the second component instance reused the `null` as a value, thus also skipping hydration.

With the changes from this commit, the `ssrId` would be set when we come across a hydratable instance. We also make sure that the `ssrId` value never changes after we first set it to a non-`null` value.

PR Close #49943
This commit is contained in:
Andrew Kushnir 2023-04-19 16:36:02 -07:00 committed by Dylan Hunn
parent da1ed98dfd
commit bbc2efcda2
5 changed files with 110 additions and 16 deletions

View file

@ -9,6 +9,7 @@ import {validateMatchingNode, validateNodeExists} from '../../hydration/error_ha
import {TEMPLATES} from '../../hydration/interfaces';
import {locateNextRNode, siblingAfter} from '../../hydration/node_lookup_utils';
import {calcSerializedContainerSize, isDisconnectedNode, markRNodeAsClaimedByHydration, setSegmentHead} from '../../hydration/utils';
import {assertEqual} from '../../util/assert';
import {assertFirstCreatePass} from '../assert';
import {attachPatchData} from '../context_discovery';
import {registerPostOrderHooks} from '../hooks';
@ -30,12 +31,7 @@ function templateFirstCreatePass(
ngDevMode && assertFirstCreatePass(tView);
ngDevMode && ngDevMode.firstCreatePass++;
const tViewConsts = tView.consts;
let ssrId: string|null = null;
const hydrationInfo = lView[HYDRATION];
if (hydrationInfo) {
const noOffsetIndex = index - HEADER_OFFSET;
ssrId = hydrationInfo.data[TEMPLATES]?.[noOffsetIndex] ?? null;
}
// TODO(pk): refactor getOrCreateTNode to have the "create" only version
const tNode = getOrCreateTNode(
tView, index, TNodeType.Container, tagName || null,
@ -46,7 +42,7 @@ function templateFirstCreatePass(
const embeddedTView = tNode.tView = createTView(
TViewType.Embedded, tNode, templateFn, decls, vars, tView.directiveRegistry,
tView.pipeRegistry, null, tView.schemas, tViewConsts, ssrId);
tView.pipeRegistry, null, tView.schemas, tViewConsts, null /* ssrId */);
if (tView.queries !== null) {
tView.queries.template(tView, tNode);
@ -135,6 +131,25 @@ function locateOrCreateContainerAnchorImpl(
return createContainerAnchorImpl(tView, lView, tNode, index);
}
const ssrId = hydrationInfo.data[TEMPLATES]?.[index] ?? null;
// Apply `ssrId` value to the underlying TView if it was not previously set.
//
// There might be situations when the same component is present in a template
// multiple times and some instances are opted-out of using hydration via
// `ngSkipHydration` attribute. In this scenario, at the time a TView is created,
// the `ssrId` might be `null` (if the first component is opted-out of hydration).
// The code below makes sure that the `ssrId` is applied to the TView if it's still
// `null` and verifies we never try to override it with a different value.
if (ssrId !== null && tNode.tView !== null) {
if (tNode.tView.ssrId === null) {
tNode.tView.ssrId = ssrId;
} else {
ngDevMode &&
assertEqual(tNode.tView.ssrId, ssrId, 'Unexpected value of the `ssrId` for this TView');
}
}
// Hydration mode, looking up existing elements in DOM.
const currentRNode = locateNextRNode(hydrationInfo, tView, lView, tNode)!;
ngDevMode && validateNodeExists(currentRNode);

View file

@ -563,9 +563,6 @@
{
"name": "Subscription"
},
{
"name": "TEMPLATES"
},
{
"name": "TESTABILITY"
},

View file

@ -551,9 +551,6 @@
{
"name": "Subscription"
},
{
"name": "TEMPLATES"
},
{
"name": "TESTABILITY"
},

View file

@ -455,9 +455,6 @@
{
"name": "Subscription"
},
{
"name": "TEMPLATES"
},
{
"name": "TESTABILITY"
},

View file

@ -1960,6 +1960,94 @@ describe('platform-server integration', () => {
verifyClientAndSSRContentsMatch(ssrContents, clientRootNode);
});
it('should allow the same component with and without hydration in the same template ' +
'(when component with `ngSkipHydration` goes first)',
async () => {
@Component({
standalone: true,
selector: 'nested',
imports: [NgIf],
template: `
<ng-container *ngIf="true">Hello world</ng-container>
`
})
class Nested {
}
@Component({
standalone: true,
selector: 'app',
imports: [NgIf, Nested],
template: `
<nested ngSkipHydration />
<nested />
<nested ngSkipHydration />
<nested />
`,
})
class SimpleComponent {
}
const html = await ssr(SimpleComponent);
const ssrContents = getAppContents(html);
expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`);
resetTViewsFor(SimpleComponent, Nested);
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 allow the same component with and without hydration in the same template ' +
'(when component without `ngSkipHydration` goes first)',
async () => {
@Component({
standalone: true,
selector: 'nested',
imports: [NgIf],
template: `
<ng-container *ngIf="true">Hello world</ng-container>
`
})
class Nested {
}
@Component({
standalone: true,
selector: 'app',
imports: [NgIf, Nested],
template: `
<nested />
<nested ngSkipHydration />
<nested />
<nested ngSkipHydration />
`,
})
class SimpleComponent {
}
const html = await ssr(SimpleComponent);
const ssrContents = getAppContents(html);
expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`);
resetTViewsFor(SimpleComponent, Nested);
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 hydrate when the value of an attribute is "ngskiphydration"', async () => {
@Component({
standalone: true,