Skip to content

Commit

Permalink
fix(core): avoid duplicated content during hydration while processing…
Browse files Browse the repository at this point in the history
… 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
  • Loading branch information
AndrewKushnir authored and pkozlowski-opensource committed Jun 13, 2023
1 parent d154e64 commit 05ac086
Show file tree
Hide file tree
Showing 8 changed files with 70 additions and 13 deletions.
5 changes: 2 additions & 3 deletions packages/core/src/hydration/annotate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down
15 changes: 11 additions & 4 deletions packages/core/src/hydration/skip_hydration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand All @@ -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.
Expand All @@ -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;
Expand Down
11 changes: 7 additions & 4 deletions packages/core/src/render3/instructions/element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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);

Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/render3/instructions/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/render3/interfaces/renderer_dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/render3/interfaces/type_checks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -893,6 +893,9 @@
{
"name": "hasInSkipHydrationBlockFlag"
},
{
"name": "hasSkipHydrationAttrOnRElement"
},
{
"name": "hostReportError"
},
Expand Down
40 changes: 40 additions & 0 deletions packages/platform-server/test/hydration_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: `
<div i18n>Hi!</div>
`,
})
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):
<nested />
`,
})
class SimpleComponent {
}

const html = await ssr(SimpleComponent);
const ssrContents = getAppContents(html);

expect(ssrContents).toContain('<app ngh');

resetTViewsFor(SimpleComponent);

const appRef = await hydrate(html, SimpleComponent);
const compRef = getComponentRef<SimpleComponent>(appRef);
appRef.tick();

const clientRootNode = compRef.location.nativeElement;
verifyAllNodesClaimedForHydration(clientRootNode);
verifyClientAndSSRContentsMatch(ssrContents, clientRootNode);
});
});

describe('ShadowDom encapsulation', () => {
Expand Down

0 comments on commit 05ac086

Please sign in to comment.