Skip to content

Commit

Permalink
fix(core): support content projection and VCRs in i18n (#54823)
Browse files Browse the repository at this point in the history
Modifies the i18n pre-hydration logic to correctly support content projection and elements that act as view containers.

PR Close #54823
  • Loading branch information
devknoll authored and atscott committed Mar 29, 2024
1 parent 146306a commit f44a5e4
Show file tree
Hide file tree
Showing 6 changed files with 310 additions and 73 deletions.
26 changes: 16 additions & 10 deletions packages/core/src/hydration/annotate.ts
Expand Up @@ -21,7 +21,7 @@ import {unwrapLView, unwrapRNode} from '../render3/util/view_utils';
import {TransferState} from '../transfer_state';

import {unsupportedProjectionOfDomNodes} from './error_handling';
import {isI18nHydrationEnabled, isI18nHydrationSupportEnabled, trySerializeI18nBlock} from './i18n';
import {getOrComputeI18nChildren, isI18nHydrationEnabled, isI18nHydrationSupportEnabled, trySerializeI18nBlock} from './i18n';
import {CONTAINERS, DISCONNECTED_NODES, ELEMENT_CONTAINERS, I18N_DATA, MULTIPLIER, NODES, NUM_ROOT_NODES, SerializedContainerView, SerializedView, TEMPLATE_ID, TEMPLATES} from './interfaces';
import {calcPathForNode, isDisconnectedNode} from './node_lookup_utils';
import {isInSkipHydrationBlock, SKIP_HYDRATION_ATTR_NAME} from './skip_hydration';
Expand Down Expand Up @@ -83,6 +83,7 @@ export interface HydrationContext {
serializedViewCollection: SerializedViewCollection;
corruptedTextNodes: Map<HTMLElement, TextNodeMarker>;
isI18nHydrationEnabled: boolean;
i18nChildren: Map<TView, Set<number>|null>;
}

/**
Expand Down Expand Up @@ -177,6 +178,7 @@ export function annotateForHydration(appRef: ApplicationRef, doc: Document) {
serializedViewCollection,
corruptedTextNodes,
isI18nHydrationEnabled: isI18nHydrationEnabledVal,
i18nChildren: new Map(),
};
if (isLContainer(lNode)) {
annotateLContainerForHydration(lNode, context);
Expand Down Expand Up @@ -287,10 +289,11 @@ function serializeLContainer(
* needs to take to locate a node) and stores it in the `NODES` section of the
* current serialized view.
*/
function appendSerializedNodePath(ngh: SerializedView, tNode: TNode, lView: LView) {
function appendSerializedNodePath(
ngh: SerializedView, tNode: TNode, lView: LView, excludedParentNodes: Set<number>|null) {
const noOffsetIndex = tNode.index - HEADER_OFFSET;
ngh[NODES] ??= {};
ngh[NODES][noOffsetIndex] = calcPathForNode(tNode, lView);
ngh[NODES][noOffsetIndex] = calcPathForNode(tNode, lView, excludedParentNodes);
}

/**
Expand Down Expand Up @@ -318,6 +321,7 @@ function appendDisconnectedNodeIndex(ngh: SerializedView, tNode: TNode) {
function serializeLView(lView: LView, context: HydrationContext): SerializedView {
const ngh: SerializedView = {};
const tView = lView[TVIEW];
const i18nChildren = getOrComputeI18nChildren(tView, context);
// Iterate over DOM element references in an LView.
for (let i = HEADER_OFFSET; i < tView.bindingStartIndex; i++) {
const tNode = tView.data[i] as TNode;
Expand Down Expand Up @@ -379,7 +383,7 @@ function serializeLView(lView: LView, context: HydrationContext): SerializedView
// <ng-content *ngIf="false" />).
appendDisconnectedNodeIndex(ngh, projectionHeadTNode);
} else {
appendSerializedNodePath(ngh, projectionHeadTNode, lView);
appendSerializedNodePath(ngh, projectionHeadTNode, lView, i18nChildren);
}
}
} else {
Expand All @@ -397,7 +401,7 @@ function serializeLView(lView: LView, context: HydrationContext): SerializedView
}
}

conditionallyAnnotateNodePath(ngh, tNode, lView);
conditionallyAnnotateNodePath(ngh, tNode, lView, i18nChildren);

if (isLContainer(lView[i])) {
// Serialize information about a template.
Expand Down Expand Up @@ -448,7 +452,7 @@ function serializeLView(lView: LView, context: HydrationContext): SerializedView
}
if (nextTNode && !isInSkipHydrationBlock(nextTNode)) {
// Handle a tNode after the `<ng-content>` slot.
appendSerializedNodePath(ngh, nextTNode, lView);
appendSerializedNodePath(ngh, nextTNode, lView, i18nChildren);
}
} else {
if (tNode.type & TNodeType.Text) {
Expand All @@ -467,17 +471,19 @@ function serializeLView(lView: LView, context: HydrationContext): SerializedView
* 1. If `tNode.projectionNext` is different from `tNode.next` - it means that
* the next `tNode` after projection is different from the one in the original
* template. Since hydration relies on `tNode.next`, this serialized info
* if required to help runtime code find the node at the correct location.
* is required to help runtime code find the node at the correct location.
* 2. In certain content projection-based use-cases, it's possible that only
* a content of a projected element is rendered. In this case, content nodes
* require an extra annotation, since runtime logic can't rely on parent-child
* connection to identify the location of a node.
*/
function conditionallyAnnotateNodePath(ngh: SerializedView, tNode: TNode, lView: LView<unknown>) {
function conditionallyAnnotateNodePath(
ngh: SerializedView, tNode: TNode, lView: LView<unknown>,
excludedParentNodes: Set<number>|null) {
// Handle case #1 described above.
if (tNode.projectionNext && tNode.projectionNext !== tNode.next &&
!isInSkipHydrationBlock(tNode.projectionNext)) {
appendSerializedNodePath(ngh, tNode.projectionNext, lView);
appendSerializedNodePath(ngh, tNode.projectionNext, lView, excludedParentNodes);
}

// Handle case #2 described above.
Expand All @@ -486,7 +492,7 @@ function conditionallyAnnotateNodePath(ngh: SerializedView, tNode: TNode, lView:
// annotation is needed.
if (tNode.prev === null && tNode.parent !== null && isDisconnectedNode(tNode.parent, lView) &&
!isDisconnectedNode(tNode, lView)) {
appendSerializedNodePath(ngh, tNode, lView);
appendSerializedNodePath(ngh, tNode, lView, excludedParentNodes);
}
}

Expand Down
195 changes: 143 additions & 52 deletions packages/core/src/hydration/i18n.ts
Expand Up @@ -8,21 +8,21 @@

import {inject, Injector} from '../di';
import {I18nNode, I18nNodeKind, I18nPlaceholderType, TI18n} from '../render3/interfaces/i18n';
import {TNode} from '../render3/interfaces/node';
import {TNode, TNodeType} from '../render3/interfaces/node';
import {RNode} from '../render3/interfaces/renderer_dom';
import {HEADER_OFFSET, HYDRATION, LView, TVIEW} from '../render3/interfaces/view';
import {HEADER_OFFSET, HYDRATION, LView, TView, TVIEW} from '../render3/interfaces/view';
import {unwrapRNode} from '../render3/util/view_utils';
import {assertDefined, assertNotEqual} from '../util/assert';

import type {HydrationContext} from './annotate';
import {DehydratedView, I18N_DATA} from './interfaces';
import {locateNextRNode} from './node_lookup_utils';
import {locateNextRNode, tryLocateRNodeByPath} from './node_lookup_utils';
import {IS_I18N_HYDRATION_ENABLED} from './tokens';
import {getNgContainerSize, initDisconnectedNodes, processTextNodeBeforeSerialization} from './utils';
import {getNgContainerSize, initDisconnectedNodes, isSerializedElementContainer, processTextNodeBeforeSerialization} from './utils';

let _isI18nHydrationSupportEnabled = false;

let _prepareI18nBlockForHydrationImpl: typeof prepareI18nBlockForHydrationImpl = (lView, index) => {
let _prepareI18nBlockForHydrationImpl: typeof prepareI18nBlockForHydrationImpl = () => {
// noop unless `enablePrepareI18nBlockForHydrationImpl` is invoked.
};

Expand All @@ -40,9 +40,12 @@ export function isI18nHydrationSupportEnabled() {
*
* @param lView lView with the i18n block
* @param index index of the i18n block in the lView
* @param parentTNode TNode of the parent of the i18n block
* @param subTemplateIndex sub-template index, or -1 for the main template
*/
export function prepareI18nBlockForHydration(lView: LView, index: number): void {
_prepareI18nBlockForHydrationImpl(lView, index);
export function prepareI18nBlockForHydration(
lView: LView, index: number, parentTNode: TNode|null, subTemplateIndex: number): void {
_prepareI18nBlockForHydrationImpl(lView, index, parentTNode, subTemplateIndex);
}

export function enablePrepareI18nBlockForHydrationImpl() {
Expand All @@ -54,6 +57,65 @@ export function isI18nHydrationEnabled(injector?: Injector) {
return injector.get(IS_I18N_HYDRATION_ENABLED, false);
}

/**
* Collects, if not already cached, all of the indices in the
* given TView which are children of an i18n block.
*
* Since i18n blocks don't introduce a parent TNode, this is necessary
* in order to determine which indices in a LView are translated.
*/
export function getOrComputeI18nChildren(tView: TView, context: HydrationContext): Set<number>|
null {
let i18nChildren = context.i18nChildren.get(tView);
if (i18nChildren === undefined) {
i18nChildren = collectI18nChildren(tView);
context.i18nChildren.set(tView, i18nChildren);
}
return i18nChildren;
}

function collectI18nChildren(tView: TView): Set<number>|null {
const children = new Set<number>();

function collectI18nViews(node: I18nNode) {
children.add(node.index);

switch (node.kind) {
case I18nNodeKind.ELEMENT:
case I18nNodeKind.PLACEHOLDER: {
for (const childNode of node.children) {
collectI18nViews(childNode);
}
break;
}

case I18nNodeKind.ICU: {
for (const caseNodes of node.cases) {
for (const caseNode of caseNodes) {
collectI18nViews(caseNode);
}
}
break;
}
}
}

// Traverse through the AST of each i18n block in the LView,
// and collect every instruction index.
for (let i = HEADER_OFFSET; i < tView.bindingStartIndex; i++) {
const tI18n = tView.data[i] as TI18n | undefined;
if (!tI18n || !tI18n.ast) {
continue;
}

for (const node of tI18n.ast) {
collectI18nViews(node);
}
}

return children.size === 0 ? null : children;
}

/**
* Attempts to serialize i18n data for an i18n block, located at
* the given view and instruction index.
Expand Down Expand Up @@ -111,6 +173,7 @@ function serializeI18nBlock(
*/
interface I18nHydrationContext {
hydrationInfo: DehydratedView;
lView: LView;
i18nNodes: Map<number, RNode|null>;
disconnectedNodes: Set<number>;
caseQueue: number[];
Expand Down Expand Up @@ -185,12 +248,12 @@ function skipSiblingNodes(state: I18nHydrationState, skip: number) {
/**
* Fork the given state into a new state for hydrating children.
*/
function forkChildHydrationState(state: I18nHydrationState) {
const currentNode = state.currentNode as Node | null;
return {currentNode: currentNode?.firstChild ?? null, isConnected: state.isConnected};
function forkHydrationState(state: I18nHydrationState, nextNode: Node|null) {
return {currentNode: nextNode, isConnected: state.isConnected};
}

function prepareI18nBlockForHydrationImpl(lView: LView, index: number) {
function prepareI18nBlockForHydrationImpl(
lView: LView, index: number, parentTNode: TNode|null, subTemplateIndex: number) {
if (!isI18nHydrationSupportEnabled()) {
return;
}
Expand All @@ -206,40 +269,54 @@ function prepareI18nBlockForHydrationImpl(lView: LView, index: number) {
assertDefined(
tI18n, 'Expected i18n data to be present in a given TView slot during hydration');

const firstAstNode = tI18n.ast[0];
if (firstAstNode) {
// Hydration for an i18n block begins at the RNode for the first AST node.
//
// Since the first AST node is a top-level node created by `i18nStartFirstCreatePass`,
// it should always have a valid TNode. This means we can use the normal `locateNextRNode`
// function to determine where to begin.
//
// It's OK if nothing is located, as that also means that there is nothing to clean up.
// Downstream error handling will detect this and provide proper context.
const tNode = tView.data[firstAstNode.index] as TNode;
ngDevMode && assertDefined(tNode, 'expected top-level i18n AST node to have TNode');

const rootNode = locateNextRNode(hydrationInfo, tView, lView, tNode) as Node | null;
const disconnectedNodes = initDisconnectedNodes(hydrationInfo) ?? new Set();
const i18nNodes = hydrationInfo.i18nNodes ??= new Map<number, RNode|null>();
const caseQueue = hydrationInfo.data[I18N_DATA]?.[index - HEADER_OFFSET] ?? [];

collectI18nNodesFromDom(
{hydrationInfo, i18nNodes, disconnectedNodes, caseQueue},
{currentNode: rootNode, isConnected: true}, tI18n.ast);

// Nodes from inactive ICU cases should be considered disconnected. We track them above
// because they aren't (and shouldn't be) serialized. Since we may mutate or create a
// new set, we need to be sure to write the expected value back to the DehydratedView.
hydrationInfo.disconnectedNodes = disconnectedNodes.size === 0 ? null : disconnectedNodes;
function findHydrationRoot() {
if (subTemplateIndex < 0) {
// This is the root of an i18n block. In this case, our hydration root will
// depend on where our parent TNode (i.e. the block with i18n applied) is
// in the DOM.
ngDevMode && assertDefined(parentTNode, 'Expected parent TNode while hydrating i18n root');
const rootNode = locateNextRNode(hydrationInfo!, tView, lView, parentTNode!) as Node;

// If this i18n block is attached to an <ng-container>, then we want to begin
// hydrating directly with the RNode. Otherwise, for a TNode with a physical DOM
// element, we want to recurse into the first child and begin there.
return (parentTNode!.type & TNodeType.ElementContainer) ? rootNode : rootNode.firstChild;
}

// This is a nested template in an i18n block. In this case, the entire view
// is translated, and part of a dehydrated view in a container. This means that
// we can simply begin hydration with the first dehydrated child.
return hydrationInfo?.firstChild as Node;
}

const currentNode = findHydrationRoot();
ngDevMode && assertDefined(currentNode, 'Expected root i18n node during hydration');

const disconnectedNodes = initDisconnectedNodes(hydrationInfo) ?? new Set();
const i18nNodes = hydrationInfo.i18nNodes ??= new Map<number, RNode|null>();
const caseQueue = hydrationInfo.data[I18N_DATA]?.[index - HEADER_OFFSET] ?? [];

collectI18nNodesFromDom(
{hydrationInfo, lView, i18nNodes, disconnectedNodes, caseQueue},
{currentNode, isConnected: true}, tI18n.ast);

// Nodes from inactive ICU cases should be considered disconnected. We track them above
// because they aren't (and shouldn't be) serialized. Since we may mutate or create a
// new set, we need to be sure to write the expected value back to the DehydratedView.
hydrationInfo.disconnectedNodes = disconnectedNodes.size === 0 ? null : disconnectedNodes;
}

function collectI18nNodesFromDom(
context: I18nHydrationContext, state: I18nHydrationState, nodeOrNodes: I18nNode|I18nNode[]) {
if (Array.isArray(nodeOrNodes)) {
for (let i = 0; i < nodeOrNodes.length; i++) {
collectI18nNodesFromDom(context, state, nodeOrNodes[i]);
for (const node of nodeOrNodes) {
// If the node is being projected elsewhere, we need to temporarily
// branch the state to that location to continue hydration.
// Otherwise, we continue hydration from the current location.
const targetNode =
tryLocateRNodeByPath(context.hydrationInfo, context.lView, node.index - HEADER_OFFSET);
const nextState = targetNode ? forkHydrationState(state, targetNode as Node) : state;
collectI18nNodesFromDom(context, nextState, node);
}
} else {
switch (nodeOrNodes.kind) {
Expand All @@ -252,7 +329,9 @@ function collectI18nNodesFromDom(

case I18nNodeKind.ELEMENT: {
// Recurse into the current element's children...
collectI18nNodesFromDom(context, forkChildHydrationState(state), nodeOrNodes.children);
collectI18nNodesFromDom(
context, forkHydrationState(state, state.currentNode?.firstChild ?? null),
nodeOrNodes.children);

// And claim the parent element itself.
const currentNode = appendI18nNodeToCollection(context, state, nodeOrNodes);
Expand All @@ -261,29 +340,41 @@ function collectI18nNodesFromDom(
}

case I18nNodeKind.PLACEHOLDER: {
const containerSize =
getNgContainerSize(context.hydrationInfo, nodeOrNodes.index - HEADER_OFFSET);
const noOffsetIndex = nodeOrNodes.index - HEADER_OFFSET;
const {hydrationInfo} = context;
const containerSize = getNgContainerSize(hydrationInfo, noOffsetIndex);

switch (nodeOrNodes.type) {
case I18nPlaceholderType.ELEMENT: {
// Hydration expects to find the head of the element.
const currentNode = appendI18nNodeToCollection(context, state, nodeOrNodes);

if (containerSize === null) {
// Non-container elements represent an actual node in the DOM, so we
// need to continue hydration with the children, and claim the node.
collectI18nNodesFromDom(
context, forkChildHydrationState(state), nodeOrNodes.children);
setCurrentNode(state, currentNode?.nextSibling ?? null);
} else {
// Containers only have an anchor comment, so we need to continue
// hydrating from siblings.
// A TNode for the node may not yet if we're hydrating during the first pass,
// so use the serialized data to determine if this is an <ng-container>.
if (isSerializedElementContainer(hydrationInfo, noOffsetIndex)) {
// An <ng-container> doesn't have a physical DOM node, so we need to
// continue hydrating from siblings.
collectI18nNodesFromDom(context, state, nodeOrNodes.children);

// Skip over the anchor element too. It will be claimed by the
// Skip over the anchor element. It will be claimed by the
// downstream container hydration.
const nextNode = skipSiblingNodes(state, 1);
setCurrentNode(state, nextNode);
} else {
// Non-container elements represent an actual node in the DOM, so we
// need to continue hydration with the children, and claim the node.
collectI18nNodesFromDom(
context, forkHydrationState(state, state.currentNode?.firstChild ?? null),
nodeOrNodes.children);
setCurrentNode(state, currentNode?.nextSibling ?? null);

// Elements can also be the anchor of a view container, so there may
// be elements after this node that we need to skip.
if (containerSize !== null) {
// `+1` stands for an anchor node after all of the views in the container.
const nextNode = skipSiblingNodes(state, containerSize + 1);
setCurrentNode(state, nextNode);
}
}
break;
}
Expand Down

0 comments on commit f44a5e4

Please sign in to comment.