diff --git a/packages/core/src/render3/instructions/template.ts b/packages/core/src/render3/instructions/template.ts
index e726497cb82..79564cd64d6 100644
--- a/packages/core/src/render3/instructions/template.ts
+++ b/packages/core/src/render3/instructions/template.ts
@@ -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);
diff --git a/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json b/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json
index 8284344acfa..5714dfc72dc 100644
--- a/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json
+++ b/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json
@@ -563,9 +563,6 @@
{
"name": "Subscription"
},
- {
- "name": "TEMPLATES"
- },
{
"name": "TESTABILITY"
},
diff --git a/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json b/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json
index 0956b6bc8d0..a45833ff188 100644
--- a/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json
+++ b/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json
@@ -551,9 +551,6 @@
{
"name": "Subscription"
},
- {
- "name": "TEMPLATES"
- },
{
"name": "TESTABILITY"
},
diff --git a/packages/core/test/bundling/todo/bundle.golden_symbols.json b/packages/core/test/bundling/todo/bundle.golden_symbols.json
index b5574a5330d..3a123351045 100644
--- a/packages/core/test/bundling/todo/bundle.golden_symbols.json
+++ b/packages/core/test/bundling/todo/bundle.golden_symbols.json
@@ -455,9 +455,6 @@
{
"name": "Subscription"
},
- {
- "name": "TEMPLATES"
- },
{
"name": "TESTABILITY"
},
diff --git a/packages/platform-server/test/hydration_spec.ts b/packages/platform-server/test/hydration_spec.ts
index cd6d6d76641..2a8e35d6434 100644
--- a/packages/platform-server/test/hydration_spec.ts
+++ b/packages/platform-server/test/hydration_spec.ts
@@ -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: `
+ Hello world
+ `
+ })
+ class Nested {
+ }
+
+ @Component({
+ standalone: true,
+ selector: 'app',
+ imports: [NgIf, Nested],
+ template: `
+
+
+
+
+ `,
+ })
+ class SimpleComponent {
+ }
+
+ const html = await ssr(SimpleComponent);
+ const ssrContents = getAppContents(html);
+
+ expect(ssrContents).toContain(`(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: `
+ Hello world
+ `
+ })
+ class Nested {
+ }
+
+ @Component({
+ standalone: true,
+ selector: 'app',
+ imports: [NgIf, Nested],
+ template: `
+
+
+
+
+ `,
+ })
+ class SimpleComponent {
+ }
+
+ const html = await ssr(SimpleComponent);
+ const ssrContents = getAppContents(html);
+
+ expect(ssrContents).toContain(`(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,