Skip to content

Commit

Permalink
fix(core): take skip hydration flag into account while hydrating i18n…
Browse files Browse the repository at this point in the history
… blocks

This commit updates serialization and hydration i18n logic to take into account situations when i18n blocks are located within "skip hydration" blocks.

Resolves angular#57105.
  • Loading branch information
AndrewKushnir committed Aug 8, 2024
1 parent 7919982 commit d0a736e
Show file tree
Hide file tree
Showing 9 changed files with 135 additions and 3 deletions.
1 change: 0 additions & 1 deletion packages/core/src/hydration/annotate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
*/

import {ApplicationRef} from '../application/application_ref';
import {APP_ID} from '../application/application_tokens';
import {isDetachedByI18n} from '../i18n/utils';
import {ViewEncapsulation} from '../metadata';
import {Renderer2} from '../render';
Expand Down
11 changes: 10 additions & 1 deletion packages/core/src/hydration/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {assertDefined, assertNotEqual} from '../util/assert';
import type {HydrationContext} from './annotate';
import {DehydratedIcuData, DehydratedView, I18N_DATA} from './interfaces';
import {isDisconnectedRNode, locateNextRNode, tryLocateRNodeByPath} from './node_lookup_utils';
import {isI18nInSkipHydrationBlock} from './skip_hydration';
import {IS_I18N_HYDRATION_ENABLED} from './tokens';
import {
getNgContainerSize,
Expand Down Expand Up @@ -187,6 +188,11 @@ export function trySerializeI18nBlock(
return null;
}

const parentTNode = tView.data[tI18n.parentTNodeIndex] as TNode;
if (parentTNode && isI18nInSkipHydrationBlock(parentTNode)) {
return null;
}

const serializedI18nBlock: SerializedI18nBlock = {
caseQueue: [],
disconnectedNodes: new Set(),
Expand Down Expand Up @@ -401,7 +407,10 @@ function prepareI18nBlockForHydrationImpl(
parentTNode: TNode | null,
subTemplateIndex: number,
) {
if (!isI18nHydrationSupportEnabled()) {
if (
!isI18nHydrationSupportEnabled() ||
(parentTNode && isI18nInSkipHydrationBlock(parentTNode))
) {
return;
}

Expand Down
13 changes: 13 additions & 0 deletions packages/core/src/hydration/skip_hydration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,16 @@ export function isInSkipHydrationBlock(tNode: TNode): boolean {
}
return false;
}

/**
* Check if an i18n block is in a skip hydration section by looking at a parent TNode
* to determine if this TNode is in a skip hydration section or the TNode has
* the `ngSkipHydration` attribute.
*/
export function isI18nInSkipHydrationBlock(parentTNode: TNode): boolean {
return (
hasInSkipHydrationBlockFlag(parentTNode) ||
hasSkipHydrationAttrOnTNode(parentTNode) ||
isInSkipHydrationBlock(parentTNode)
);
}
2 changes: 1 addition & 1 deletion packages/core/src/linker/view_container_ref.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {Injector} from '../di/injector';
import {EnvironmentInjector} from '../di/r3_injector';
import {validateMatchingNode} from '../hydration/error_handling';
import {CONTAINERS} from '../hydration/interfaces';
import {hasInSkipHydrationBlockFlag, isInSkipHydrationBlock} from '../hydration/skip_hydration';
import {isInSkipHydrationBlock} from '../hydration/skip_hydration';
import {
getSegmentHead,
isDisconnectedNode,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/render3/i18n/i18n_parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@ export function i18nStartFirstCreatePass(
create: createOpCodes,
update: updateOpCodes,
ast: astStack[0],
parentTNodeIndex,
};
}

Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/render3/interfaces/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,11 @@ export interface TI18n {
* while the Update and Create OpCodes are used at runtime.
*/
ast: Array<I18nNode>;

/**
* Index of a parent TNode, which represents a host node for this i18n block.
*/
parentTNodeIndex: number;
}

/**
Expand Down
9 changes: 9 additions & 0 deletions packages/core/test/render3/i18n/i18n_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ describe('Runtime i18n', () => {
]),
update: [] as unknown as I18nUpdateOpCodes,
ast: [{kind: 0, index: HEADER_OFFSET + 1}],
parentTNodeIndex: HEADER_OFFSET,
});
});

Expand Down Expand Up @@ -154,6 +155,7 @@ describe('Runtime i18n', () => {
},
{kind: 0, index: HEADER_OFFSET + 8},
],
parentTNodeIndex: HEADER_OFFSET,
});
});

Expand Down Expand Up @@ -189,6 +191,7 @@ describe('Runtime i18n', () => {
}] as Text).textContent = \`Hello \${lView[i-1]}!\`; }`,
]),
ast: [{kind: 0, index: HEADER_OFFSET + 2}],
parentTNodeIndex: HEADER_OFFSET,
});
});

Expand Down Expand Up @@ -218,6 +221,7 @@ describe('Runtime i18n', () => {
}] as Text).textContent = \`Hello \${lView[i-1]} and \${lView[i-2]}, again \${lView[i-1]}!\`; }`,
]),
ast: [{kind: 0, index: HEADER_OFFSET + 2}],
parentTNodeIndex: HEADER_OFFSET,
});
});

Expand Down Expand Up @@ -264,6 +268,7 @@ describe('Runtime i18n', () => {
{kind: 2, index: HEADER_OFFSET + 2, children: [], type: 1},
{kind: 0, index: HEADER_OFFSET + 4},
],
parentTNodeIndex: HEADER_OFFSET,
});

/**** First sub-template ****/
Expand Down Expand Up @@ -298,6 +303,7 @@ describe('Runtime i18n', () => {
type: 0,
},
],
parentTNodeIndex: HEADER_OFFSET,
});

/**** Second sub-template ****/
Expand Down Expand Up @@ -325,6 +331,7 @@ describe('Runtime i18n', () => {
type: 0,
},
],
parentTNodeIndex: HEADER_OFFSET,
});
});

Expand Down Expand Up @@ -390,6 +397,7 @@ describe('Runtime i18n', () => {
currentCaseLViewIndex: HEADER_OFFSET + 3,
},
],
parentTNodeIndex: HEADER_OFFSET,
});
expect(getTIcu(tView, HEADER_OFFSET + 2)).toEqual(<TIcu>{
type: 1,
Expand Down Expand Up @@ -511,6 +519,7 @@ describe('Runtime i18n', () => {
currentCaseLViewIndex: HEADER_OFFSET + 3,
},
],
parentTNodeIndex: HEADER_OFFSET,
});
expect(getTIcu(tView, HEADER_OFFSET + 2)).toEqual({
type: 1,
Expand Down
1 change: 1 addition & 0 deletions packages/core/test/render3/is_shape_of.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ const ShapeOfTI18n: ShapeOf<TI18n> = {
create: true,
update: true,
ast: true,
parentTNodeIndex: true,
};

/**
Expand Down
95 changes: 95 additions & 0 deletions packages/platform-server/test/hydration_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,10 @@ function verifyNodeHasMismatchInfo(doc: Document, selector = 'app'): void {
expect(readHydrationInfo(doc.querySelector(selector)!)?.status).toBe(HydrationStatus.Mismatched);
}

function verifyNodeHasSkipHydrationMarker(element: HTMLElement): void {
expect(readHydrationInfo(element)?.status).toBe(HydrationStatus.Skipped);
}

/** Checks whether a given element is a <script> that contains transfer state data. */
function isTransferStateScript(el: HTMLElement): boolean {
return (
Expand Down Expand Up @@ -2744,6 +2748,97 @@ describe('platform-server hydration integration', () => {
const clientContents = stripExcessiveSpaces(clientRootNode.innerHTML);
expect(clientContents).toBe('<ol><li>1</li><li>2</li><li>3</li><!--container--></ol>');
});

describe('with ngSkipHydration', () => {
it('should skip hydration when ngSkipHydration and i18n attributes are present on a same node', async () => {
loadTranslations({
[computeMsgId(' Some {$START_TAG_STRONG}strong{$CLOSE_TAG_STRONG} content ')]:
'Some normal content',
});

@Component({
standalone: true,
selector: 'cmp-a',
template: `<ng-content />`,
})
class CmpA {}

@Component({
standalone: true,
selector: 'app',
imports: [CmpA],
template: `
<cmp-a i18n ngSkipHydration>
Some <strong>strong</strong> content
</cmp-a>
`,
})
class SimpleComponent {}

const hydrationFeatures = [withI18nSupport()] as unknown as HydrationFeature<any>[];
const html = await ssr(SimpleComponent, {hydrationFeatures});
const ssrContents = getAppContents(html);
expect(ssrContents).toContain('<app ngh');

resetTViewsFor(SimpleComponent);

const appRef = await renderAndHydrate(doc, html, SimpleComponent, {hydrationFeatures});
const compRef = getComponentRef<SimpleComponent>(appRef);
appRef.tick();

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

const cmpA = clientRootNode.querySelector('cmp-a');
expect(cmpA.textContent).toBe('Some normal content');
verifyNodeHasSkipHydrationMarker(cmpA);
});

it('should skip hydration when i18n is inside of an ngSkipHydration block', async () => {
loadTranslations({
[computeMsgId('strong')]: 'very strong',
});

@Component({
standalone: true,
selector: 'cmp-a',
template: `<ng-content />`,
})
class CmpA {}

@Component({
standalone: true,
selector: 'app',
imports: [CmpA],
template: `
<cmp-a ngSkipHydration>
Some <strong i18n>strong</strong> content
</cmp-a>
`,
})
class SimpleComponent {}

const hydrationFeatures = [withI18nSupport()] as unknown as HydrationFeature<any>[];
const html = await ssr(SimpleComponent, {hydrationFeatures});
const ssrContents = getAppContents(html);
expect(ssrContents).toContain('<app ngh');

resetTViewsFor(SimpleComponent);

const appRef = await renderAndHydrate(doc, html, SimpleComponent, {hydrationFeatures});
const compRef = getComponentRef<SimpleComponent>(appRef);
appRef.tick();

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

const cmpA = clientRootNode.querySelector('cmp-a');
expect(cmpA.textContent.trim()).toBe('Some very strong content');
verifyNodeHasSkipHydrationMarker(cmpA);
});
});
});

// Note: hydration for i18n blocks is not *yet* fully supported, so the tests
Expand Down

0 comments on commit d0a736e

Please sign in to comment.