mirror of
https://github.com/angular/angular
synced 2026-05-24 09:28:37 +00:00
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:
parent
da1ed98dfd
commit
bbc2efcda2
5 changed files with 110 additions and 16 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -563,9 +563,6 @@
|
|||
{
|
||||
"name": "Subscription"
|
||||
},
|
||||
{
|
||||
"name": "TEMPLATES"
|
||||
},
|
||||
{
|
||||
"name": "TESTABILITY"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -551,9 +551,6 @@
|
|||
{
|
||||
"name": "Subscription"
|
||||
},
|
||||
{
|
||||
"name": "TEMPLATES"
|
||||
},
|
||||
{
|
||||
"name": "TESTABILITY"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -455,9 +455,6 @@
|
|||
{
|
||||
"name": "Subscription"
|
||||
},
|
||||
{
|
||||
"name": "TEMPLATES"
|
||||
},
|
||||
{
|
||||
"name": "TESTABILITY"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue