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,