Skip to content

Commit 307f8ee

Browse files
fix(core): avoid duplicated content during hydration while processing a component with i18n (#50644)
This commit updates an internal hydration logic to make sure that the content of components with i18n blocks is cleaned up before we start rendering it. Resolves #50627. PR Close #50644
1 parent ee073cd commit 307f8ee

File tree

8 files changed

+70
-13
lines changed

8 files changed

+70
-13
lines changed

packages/core/src/hydration/annotate.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {getComponentDef} from '../render3/definition';
1313
import {CONTAINER_HEADER_OFFSET, LContainer} from '../render3/interfaces/container';
1414
import {TNode, TNodeType} from '../render3/interfaces/node';
1515
import {RElement} from '../render3/interfaces/renderer_dom';
16-
import {isComponentHost, isLContainer, isProjectionTNode, isRootView} from '../render3/interfaces/type_checks';
16+
import {hasI18n, isComponentHost, isLContainer, isProjectionTNode, isRootView} from '../render3/interfaces/type_checks';
1717
import {CONTEXT, FLAGS, HEADER_OFFSET, HOST, LView, LViewFlags, RENDERER, TView, TVIEW, TViewType} from '../render3/interfaces/view';
1818
import {unwrapRNode} from '../render3/util/view_utils';
1919
import {TransferState} from '../transfer_state';
@@ -412,8 +412,7 @@ function componentUsesShadowDomEncapsulation(lView: LView): boolean {
412412
function annotateHostElementForHydration(
413413
element: RElement, lView: LView, context: HydrationContext): void {
414414
const renderer = lView[RENDERER];
415-
if ((lView[FLAGS] & LViewFlags.HasI18n) === LViewFlags.HasI18n ||
416-
componentUsesShadowDomEncapsulation(lView)) {
415+
if (hasI18n(lView) || componentUsesShadowDomEncapsulation(lView)) {
417416
// Attach the skip hydration attribute if this component:
418417
// - either has i18n blocks, since hydrating such blocks is not yet supported
419418
// - or uses ShadowDom view encapsulation, since Domino doesn't support

packages/core/src/hydration/skip_hydration.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88

99
import {TNode, TNodeFlags} from '../render3/interfaces/node';
10-
import {LView} from '../render3/interfaces/view';
10+
import {RElement} from '../render3/interfaces/renderer_dom';
1111

1212
/**
1313
* The name of an attribute that can be added to the hydration boundary node
@@ -16,9 +16,9 @@ import {LView} from '../render3/interfaces/view';
1616
export const SKIP_HYDRATION_ATTR_NAME = 'ngSkipHydration';
1717

1818
/**
19-
* Helper function to check if a given node has the 'ngSkipHydration' attribute
19+
* Helper function to check if a given TNode has the 'ngSkipHydration' attribute.
2020
*/
21-
export function hasNgSkipHydrationAttr(tNode: TNode): boolean {
21+
export function hasSkipHydrationAttrOnTNode(tNode: TNode): boolean {
2222
const SKIP_HYDRATION_ATTR_NAME_LOWER_CASE = SKIP_HYDRATION_ATTR_NAME.toLowerCase();
2323

2424
const attrs = tNode.mergedAttrs;
@@ -36,6 +36,13 @@ export function hasNgSkipHydrationAttr(tNode: TNode): boolean {
3636
return false;
3737
}
3838

39+
/**
40+
* Helper function to check if a given RElement has the 'ngSkipHydration' attribute.
41+
*/
42+
export function hasSkipHydrationAttrOnRElement(rNode: RElement): boolean {
43+
return rNode.hasAttribute(SKIP_HYDRATION_ATTR_NAME);
44+
}
45+
3946
/**
4047
* Checks whether a TNode has a flag to indicate that it's a part of
4148
* a skip hydration block.
@@ -56,7 +63,7 @@ export function hasInSkipHydrationBlockFlag(tNode: TNode): boolean {
5663
export function isInSkipHydrationBlock(tNode: TNode): boolean {
5764
let currentTNode: TNode|null = tNode.parent;
5865
while (currentTNode) {
59-
if (hasNgSkipHydrationAttr(currentTNode)) {
66+
if (hasSkipHydrationAttrOnTNode(currentTNode)) {
6067
return true;
6168
}
6269
currentTNode = currentTNode.parent;

packages/core/src/render3/instructions/element.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
import {invalidSkipHydrationHost, validateMatchingNode, validateNodeExists} from '../../hydration/error_handling';
1010
import {locateNextRNode} from '../../hydration/node_lookup_utils';
11-
import {hasNgSkipHydrationAttr} from '../../hydration/skip_hydration';
11+
import {hasSkipHydrationAttrOnRElement, hasSkipHydrationAttrOnTNode} from '../../hydration/skip_hydration';
1212
import {getSerializedContainerViews, isDisconnectedNode, markRNodeAsClaimedByHydration, setSegmentHead} from '../../hydration/utils';
1313
import {assertDefined, assertEqual, assertIndexInRange} from '../../util/assert';
1414
import {assertFirstCreatePass, assertHasParent} from '../assert';
@@ -17,7 +17,7 @@ import {registerPostOrderHooks} from '../hooks';
1717
import {hasClassInput, hasStyleInput, TAttributes, TElementNode, TNode, TNodeFlags, TNodeType} from '../interfaces/node';
1818
import {Renderer} from '../interfaces/renderer';
1919
import {RElement} from '../interfaces/renderer_dom';
20-
import {isComponentHost, isContentQueryHost, isDirectiveHost} from '../interfaces/type_checks';
20+
import {hasI18n, isComponentHost, isContentQueryHost, isDirectiveHost} from '../interfaces/type_checks';
2121
import {HEADER_OFFSET, HYDRATION, LView, RENDERER, TView} from '../interfaces/view';
2222
import {assertTNodeType} from '../node_assert';
2323
import {appendChild, clearElementContents, createElementNode, setupStaticAttributes} from '../node_manipulation';
@@ -230,8 +230,11 @@ function locateOrCreateElementNodeImpl(
230230
}
231231

232232
// Checks if the skip hydration attribute is present during hydration so we know to
233-
// skip attempting to hydrate this block.
234-
if (hydrationInfo && hasNgSkipHydrationAttr(tNode)) {
233+
// skip attempting to hydrate this block. We check both TNode and RElement for an
234+
// attribute: the RElement case is needed for i18n cases, when we add it to host
235+
// elements during the annotation phase (after all internal data structures are setup).
236+
if (hydrationInfo &&
237+
(hasSkipHydrationAttrOnTNode(tNode) || hasSkipHydrationAttrOnRElement(native))) {
235238
if (isComponentHost(tNode)) {
236239
enterSkipHydrationBlock(tNode);
237240

packages/core/src/render3/instructions/shared.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {Injector} from '../../di/injector';
1010
import {ErrorHandler} from '../../error_handler';
1111
import {RuntimeError, RuntimeErrorCode} from '../../errors';
1212
import {DehydratedView} from '../../hydration/interfaces';
13-
import {hasInSkipHydrationBlockFlag, SKIP_HYDRATION_ATTR_NAME} from '../../hydration/skip_hydration';
13+
import {hasInSkipHydrationBlockFlag, hasSkipHydrationAttrOnRElement, SKIP_HYDRATION_ATTR_NAME} from '../../hydration/skip_hydration';
1414
import {PRESERVE_HOST_CONTENT, PRESERVE_HOST_CONTENT_DEFAULT} from '../../hydration/tokens';
1515
import {processTextNodeMarkersBeforeHydration} from '../../hydration/utils';
1616
import {DoCheck, OnChanges, OnInit} from '../../interface/lifecycle_hooks';
@@ -497,7 +497,7 @@ let _applyRootElementTransformImpl: typeof applyRootElementTransformImpl =
497497
* @param rootElement the app root HTML Element
498498
*/
499499
export function applyRootElementTransformImpl(rootElement: HTMLElement) {
500-
if (rootElement.hasAttribute(SKIP_HYDRATION_ATTR_NAME)) {
500+
if (hasSkipHydrationAttrOnRElement(rootElement)) {
501501
// Handle a situation when the `ngSkipHydration` attribute is applied
502502
// to the root node of an application. In this case, we should clear
503503
// the contents and render everything from scratch.

packages/core/src/render3/interfaces/renderer_dom.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ export interface RElement extends RNode {
6767
className: string;
6868
tagName: string;
6969
textContent: string|null;
70+
hasAttribute(name: string): boolean;
7071
getAttribute(name: string): string|null;
7172
setAttribute(name: string, value: string|TrustedHTML|TrustedScript|TrustedScriptURL): void;
7273
removeAttribute(name: string): void;

packages/core/src/render3/interfaces/type_checks.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,7 @@ export function isRootView(target: LView): boolean {
5252
export function isProjectionTNode(tNode: TNode): boolean {
5353
return (tNode.type & TNodeType.Projection) === TNodeType.Projection;
5454
}
55+
56+
export function hasI18n(lView: LView): boolean {
57+
return (lView[FLAGS] & LViewFlags.HasI18n) === LViewFlags.HasI18n;
58+
}

packages/core/test/bundling/hydration/bundle.golden_symbols.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -905,6 +905,9 @@
905905
{
906906
"name": "hasInSkipHydrationBlockFlag"
907907
},
908+
{
909+
"name": "hasSkipHydrationAttrOnRElement"
910+
},
908911
{
909912
"name": "hostReportError"
910913
},

packages/platform-server/test/hydration_spec.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1842,6 +1842,46 @@ describe('platform-server hydration integration', () => {
18421842
verifyAllNodesClaimedForHydration(clientRootNode);
18431843
verifyClientAndSSRContentsMatch(ssrContents, clientRootNode);
18441844
});
1845+
1846+
it('should exclude components with i18n from hydration automatically', async () => {
1847+
@Component({
1848+
standalone: true,
1849+
selector: 'nested',
1850+
template: `
1851+
<div i18n>Hi!</div>
1852+
`,
1853+
})
1854+
class NestedComponent {
1855+
}
1856+
1857+
@Component({
1858+
standalone: true,
1859+
imports: [NestedComponent],
1860+
selector: 'app',
1861+
template: `
1862+
Nested component with i18n inside
1863+
(the content of this component would be excluded from hydration):
1864+
<nested />
1865+
`,
1866+
})
1867+
class SimpleComponent {
1868+
}
1869+
1870+
const html = await ssr(SimpleComponent);
1871+
const ssrContents = getAppContents(html);
1872+
1873+
expect(ssrContents).toContain('<app ngh');
1874+
1875+
resetTViewsFor(SimpleComponent);
1876+
1877+
const appRef = await hydrate(html, SimpleComponent);
1878+
const compRef = getComponentRef<SimpleComponent>(appRef);
1879+
appRef.tick();
1880+
1881+
const clientRootNode = compRef.location.nativeElement;
1882+
verifyAllNodesClaimedForHydration(clientRootNode);
1883+
verifyClientAndSSRContentsMatch(ssrContents, clientRootNode);
1884+
});
18451885
});
18461886

18471887
describe('ShadowDom encapsulation', () => {

0 commit comments

Comments
 (0)