diff --git a/packages/core/src/hydration/annotate.ts b/packages/core/src/hydration/annotate.ts index f77222260d4b..674ecec3b153 100644 --- a/packages/core/src/hydration/annotate.ts +++ b/packages/core/src/hydration/annotate.ts @@ -13,7 +13,7 @@ import {getComponentDef} from '../render3/definition'; import {CONTAINER_HEADER_OFFSET, LContainer} from '../render3/interfaces/container'; import {TNode, TNodeType} from '../render3/interfaces/node'; import {RElement} from '../render3/interfaces/renderer_dom'; -import {isComponentHost, isLContainer, isProjectionTNode, isRootView} from '../render3/interfaces/type_checks'; +import {hasI18n, isComponentHost, isLContainer, isProjectionTNode, isRootView} from '../render3/interfaces/type_checks'; import {CONTEXT, FLAGS, HEADER_OFFSET, HOST, LView, LViewFlags, RENDERER, TView, TVIEW, TViewType} from '../render3/interfaces/view'; import {unwrapRNode} from '../render3/util/view_utils'; import {TransferState} from '../transfer_state'; @@ -412,8 +412,7 @@ function componentUsesShadowDomEncapsulation(lView: LView): boolean { function annotateHostElementForHydration( element: RElement, lView: LView, context: HydrationContext): void { const renderer = lView[RENDERER]; - if ((lView[FLAGS] & LViewFlags.HasI18n) === LViewFlags.HasI18n || - componentUsesShadowDomEncapsulation(lView)) { + if (hasI18n(lView) || componentUsesShadowDomEncapsulation(lView)) { // Attach the skip hydration attribute if this component: // - either has i18n blocks, since hydrating such blocks is not yet supported // - or uses ShadowDom view encapsulation, since Domino doesn't support diff --git a/packages/core/src/hydration/skip_hydration.ts b/packages/core/src/hydration/skip_hydration.ts index bb8de2557518..b3f0e3649be3 100644 --- a/packages/core/src/hydration/skip_hydration.ts +++ b/packages/core/src/hydration/skip_hydration.ts @@ -7,7 +7,7 @@ */ import {TNode, TNodeFlags} from '../render3/interfaces/node'; -import {LView} from '../render3/interfaces/view'; +import {RElement} from '../render3/interfaces/renderer_dom'; /** * The name of an attribute that can be added to the hydration boundary node @@ -16,9 +16,9 @@ import {LView} from '../render3/interfaces/view'; export const SKIP_HYDRATION_ATTR_NAME = 'ngSkipHydration'; /** - * Helper function to check if a given node has the 'ngSkipHydration' attribute + * Helper function to check if a given TNode has the 'ngSkipHydration' attribute. */ -export function hasNgSkipHydrationAttr(tNode: TNode): boolean { +export function hasSkipHydrationAttrOnTNode(tNode: TNode): boolean { const SKIP_HYDRATION_ATTR_NAME_LOWER_CASE = SKIP_HYDRATION_ATTR_NAME.toLowerCase(); const attrs = tNode.mergedAttrs; @@ -36,6 +36,13 @@ export function hasNgSkipHydrationAttr(tNode: TNode): boolean { return false; } +/** + * Helper function to check if a given RElement has the 'ngSkipHydration' attribute. + */ +export function hasSkipHydrationAttrOnRElement(rNode: RElement): boolean { + return rNode.hasAttribute(SKIP_HYDRATION_ATTR_NAME); +} + /** * Checks whether a TNode has a flag to indicate that it's a part of * a skip hydration block. @@ -56,7 +63,7 @@ export function hasInSkipHydrationBlockFlag(tNode: TNode): boolean { export function isInSkipHydrationBlock(tNode: TNode): boolean { let currentTNode: TNode|null = tNode.parent; while (currentTNode) { - if (hasNgSkipHydrationAttr(currentTNode)) { + if (hasSkipHydrationAttrOnTNode(currentTNode)) { return true; } currentTNode = currentTNode.parent; diff --git a/packages/core/src/render3/instructions/element.ts b/packages/core/src/render3/instructions/element.ts index a3638dc83dd7..450b1b5a66b4 100644 --- a/packages/core/src/render3/instructions/element.ts +++ b/packages/core/src/render3/instructions/element.ts @@ -8,7 +8,7 @@ import {invalidSkipHydrationHost, validateMatchingNode, validateNodeExists} from '../../hydration/error_handling'; import {locateNextRNode} from '../../hydration/node_lookup_utils'; -import {hasNgSkipHydrationAttr} from '../../hydration/skip_hydration'; +import {hasSkipHydrationAttrOnRElement, hasSkipHydrationAttrOnTNode} from '../../hydration/skip_hydration'; import {getSerializedContainerViews, isDisconnectedNode, markRNodeAsClaimedByHydration, setSegmentHead} from '../../hydration/utils'; import {assertDefined, assertEqual, assertIndexInRange} from '../../util/assert'; import {assertFirstCreatePass, assertHasParent} from '../assert'; @@ -17,7 +17,7 @@ import {registerPostOrderHooks} from '../hooks'; import {hasClassInput, hasStyleInput, TAttributes, TElementNode, TNode, TNodeFlags, TNodeType} from '../interfaces/node'; import {Renderer} from '../interfaces/renderer'; import {RElement} from '../interfaces/renderer_dom'; -import {isComponentHost, isContentQueryHost, isDirectiveHost} from '../interfaces/type_checks'; +import {hasI18n, isComponentHost, isContentQueryHost, isDirectiveHost} from '../interfaces/type_checks'; import {HEADER_OFFSET, HYDRATION, LView, RENDERER, TView} from '../interfaces/view'; import {assertTNodeType} from '../node_assert'; import {appendChild, clearElementContents, createElementNode, setupStaticAttributes} from '../node_manipulation'; @@ -230,8 +230,11 @@ function locateOrCreateElementNodeImpl( } // Checks if the skip hydration attribute is present during hydration so we know to - // skip attempting to hydrate this block. - if (hydrationInfo && hasNgSkipHydrationAttr(tNode)) { + // skip attempting to hydrate this block. We check both TNode and RElement for an + // attribute: the RElement case is needed for i18n cases, when we add it to host + // elements during the annotation phase (after all internal data structures are setup). + if (hydrationInfo && + (hasSkipHydrationAttrOnTNode(tNode) || hasSkipHydrationAttrOnRElement(native))) { if (isComponentHost(tNode)) { enterSkipHydrationBlock(tNode); diff --git a/packages/core/src/render3/instructions/shared.ts b/packages/core/src/render3/instructions/shared.ts index 327372a6f88c..477d2bb10424 100644 --- a/packages/core/src/render3/instructions/shared.ts +++ b/packages/core/src/render3/instructions/shared.ts @@ -10,7 +10,7 @@ import {Injector} from '../../di/injector'; import {ErrorHandler} from '../../error_handler'; import {RuntimeError, RuntimeErrorCode} from '../../errors'; import {DehydratedView} from '../../hydration/interfaces'; -import {hasInSkipHydrationBlockFlag, SKIP_HYDRATION_ATTR_NAME} from '../../hydration/skip_hydration'; +import {hasInSkipHydrationBlockFlag, hasSkipHydrationAttrOnRElement, SKIP_HYDRATION_ATTR_NAME} from '../../hydration/skip_hydration'; import {PRESERVE_HOST_CONTENT, PRESERVE_HOST_CONTENT_DEFAULT} from '../../hydration/tokens'; import {processTextNodeMarkersBeforeHydration} from '../../hydration/utils'; import {DoCheck, OnChanges, OnInit} from '../../interface/lifecycle_hooks'; @@ -497,7 +497,7 @@ let _applyRootElementTransformImpl: typeof applyRootElementTransformImpl = * @param rootElement the app root HTML Element */ export function applyRootElementTransformImpl(rootElement: HTMLElement) { - if (rootElement.hasAttribute(SKIP_HYDRATION_ATTR_NAME)) { + if (hasSkipHydrationAttrOnRElement(rootElement)) { // Handle a situation when the `ngSkipHydration` attribute is applied // to the root node of an application. In this case, we should clear // the contents and render everything from scratch. diff --git a/packages/core/src/render3/interfaces/renderer_dom.ts b/packages/core/src/render3/interfaces/renderer_dom.ts index 216df00a039d..f4ead6176b1f 100644 --- a/packages/core/src/render3/interfaces/renderer_dom.ts +++ b/packages/core/src/render3/interfaces/renderer_dom.ts @@ -67,6 +67,7 @@ export interface RElement extends RNode { className: string; tagName: string; textContent: string|null; + hasAttribute(name: string): boolean; getAttribute(name: string): string|null; setAttribute(name: string, value: string|TrustedHTML|TrustedScript|TrustedScriptURL): void; removeAttribute(name: string): void; diff --git a/packages/core/src/render3/interfaces/type_checks.ts b/packages/core/src/render3/interfaces/type_checks.ts index e9fb3c94bac7..5d2a67434e89 100644 --- a/packages/core/src/render3/interfaces/type_checks.ts +++ b/packages/core/src/render3/interfaces/type_checks.ts @@ -52,3 +52,7 @@ export function isRootView(target: LView): boolean { export function isProjectionTNode(tNode: TNode): boolean { return (tNode.type & TNodeType.Projection) === TNodeType.Projection; } + +export function hasI18n(lView: LView): boolean { + return (lView[FLAGS] & LViewFlags.HasI18n) === LViewFlags.HasI18n; +} diff --git a/packages/core/test/bundling/hydration/bundle.golden_symbols.json b/packages/core/test/bundling/hydration/bundle.golden_symbols.json index 2d01939370ae..35a0b4e30e49 100644 --- a/packages/core/test/bundling/hydration/bundle.golden_symbols.json +++ b/packages/core/test/bundling/hydration/bundle.golden_symbols.json @@ -905,6 +905,9 @@ { "name": "hasInSkipHydrationBlockFlag" }, + { + "name": "hasSkipHydrationAttrOnRElement" + }, { "name": "hostReportError" }, diff --git a/packages/platform-server/test/hydration_spec.ts b/packages/platform-server/test/hydration_spec.ts index 0b54159d4cd4..557e6a2f114e 100644 --- a/packages/platform-server/test/hydration_spec.ts +++ b/packages/platform-server/test/hydration_spec.ts @@ -1842,6 +1842,46 @@ describe('platform-server hydration integration', () => { verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); + + it('should exclude components with i18n from hydration automatically', async () => { + @Component({ + standalone: true, + selector: 'nested', + template: ` +
Hi!
+ `, + }) + class NestedComponent { + } + + @Component({ + standalone: true, + imports: [NestedComponent], + selector: 'app', + template: ` + Nested component with i18n inside + (the content of this component would be excluded from hydration): + + `, + }) + 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); + }); }); describe('ShadowDom encapsulation', () => {