Skip to content

Commit

Permalink
fix(core): add i18n hydration
Browse files Browse the repository at this point in the history
  • Loading branch information
devknoll committed Feb 2, 2024
1 parent 74aa8a3 commit cbd17b9
Show file tree
Hide file tree
Showing 8 changed files with 414 additions and 71 deletions.
15 changes: 12 additions & 3 deletions packages/core/src/hydration/annotate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,17 @@ import {ViewEncapsulation} from '../metadata';
import {Renderer2} from '../render';
import {collectNativeNodes, collectNativeNodesInLContainer} from '../render3/collect_native_nodes';
import {getComponentDef} from '../render3/definition';
import { computeI18nSerialization } from '../render3/i18n/i18n_hydration';
import {CONTAINER_HEADER_OFFSET, LContainer} from '../render3/interfaces/container';
import {isTNodeShape, TNode, TNodeType} from '../render3/interfaces/node';
import {RElement} from '../render3/interfaces/renderer_dom';
import {hasI18n, isComponentHost, isLContainer, isProjectionTNode, isRootView} from '../render3/interfaces/type_checks';
import {isComponentHost, isLContainer, isProjectionTNode, isRootView} from '../render3/interfaces/type_checks';
import {CONTEXT, HEADER_OFFSET, HOST, LView, PARENT, RENDERER, TView, TVIEW, TViewType} from '../render3/interfaces/view';
import {unwrapLView, unwrapRNode} from '../render3/util/view_utils';
import {TransferState} from '../transfer_state';

import {unsupportedProjectionOfDomNodes} from './error_handling';
import {CONTAINERS, DISCONNECTED_NODES, ELEMENT_CONTAINERS, MULTIPLIER, NODES, NUM_ROOT_NODES, SerializedContainerView, SerializedView, TEMPLATE_ID, TEMPLATES} from './interfaces';
import {CONTAINERS, DISCONNECTED_NODES, ELEMENT_CONTAINERS, I18N_ICU_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';
import {getLNodeForHydration, NGH_ATTR_NAME, NGH_DATA_KEY, TextNodeMarker} from './utils';
Expand Down Expand Up @@ -311,10 +312,18 @@ function appendDisconnectedNodeIndex(ngh: SerializedView, tNode: TNode) {
function serializeLView(lView: LView, context: HydrationContext): SerializedView {
const ngh: SerializedView = {};
const tView = lView[TVIEW];

// Iterate over DOM element references in an LView.
for (let i = HEADER_OFFSET; i < tView.bindingStartIndex; i++) {
const tNode = tView.data[i] as TNode;
const noOffsetIndex = i - HEADER_OFFSET;

const i18nICUData = computeI18nSerialization(lView, i);
if (i18nICUData) {
ngh[I18N_ICU_DATA] ??= {};
ngh[I18N_ICU_DATA][noOffsetIndex] = i18nICUData;
}

// Skip processing of a given slot in the following cases:
// - Local refs (e.g. <div #localRef>) take up an extra slot in LViews
// to store the same element. In this case, there is no information in
Expand Down Expand Up @@ -526,7 +535,7 @@ function componentUsesShadowDomEncapsulation(lView: LView): boolean {
function annotateHostElementForHydration(
element: RElement, lView: LView, context: HydrationContext): number|null {
const renderer = lView[RENDERER];
if (hasI18n(lView) || componentUsesShadowDomEncapsulation(lView)) {
if (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
12 changes: 12 additions & 0 deletions packages/core/src/hydration/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export const NUM_ROOT_NODES = 'r';
export const TEMPLATE_ID = 'i'; // as it's also an "id"
export const NODES = 'n';
export const DISCONNECTED_NODES = 'd';
export const I18N_ICU_DATA = 'l';

/**
* Represents element containers within this view, stored as key-value pairs
Expand Down Expand Up @@ -92,6 +93,11 @@ export interface SerializedView {
* logic for such nodes and instead use a regular "creation mode".
*/
[DISCONNECTED_NODES]?: number[];

/**
* TODO: Map an LView to a queue of selected ICU cases.
*/
[I18N_ICU_DATA]?: Record<number, number[]>;
}

/**
Expand Down Expand Up @@ -165,6 +171,12 @@ export interface DehydratedView {
* nodes detected in this view at serialization time.
*/
disconnectedNodes?: Set<number>|null;

/**
* TODO: Maps an LView to a first node to begin hydrating at.
* This is generated by i18n.
*/
nodeMap?: Record<number, RNode>;
}

/**
Expand Down
88 changes: 49 additions & 39 deletions packages/core/src/hydration/node_lookup_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,14 @@ export function isDisconnectedNode(tNode: TNode, lView: LView) {
!(unwrapRNode(lView[tNode.index]) as Node)?.isConnected;
}

/**
* TODO: Like `locateNextRNode`, but for when the node is expected to be directly
* known.
*/
export function locateRNodeByIndex<T extends RNode>(hydrationInfo: DehydratedView, index: number): T|null {
return (hydrationInfo.nodeMap?.[index] as T) ?? null;
}

/**
* Locate a node in DOM tree that corresponds to a given TNode.
*
Expand All @@ -54,49 +62,51 @@ export function isDisconnectedNode(tNode: TNode, lView: LView) {
*/
export function locateNextRNode<T extends RNode>(
hydrationInfo: DehydratedView, tView: TView, lView: LView<unknown>, tNode: TNode): T|null {
let native: RNode|null = null;
const noOffsetIndex = getNoOffsetIndex(tNode);
const nodes = hydrationInfo.data[NODES];
if (nodes?.[noOffsetIndex]) {
// We know the exact location of the node.
native = locateRNodeByPath(nodes[noOffsetIndex], lView);
} else if (tView.firstChild === tNode) {
// We create a first node in this view, so we use a reference
// to the first child in this DOM segment.
native = hydrationInfo.firstChild;
} else {
// Locate a node based on a previous sibling or a parent node.
const previousTNodeParent = tNode.prev === null;
const previousTNode = (tNode.prev ?? tNode.parent)!;
ngDevMode &&
assertDefined(
previousTNode,
'Unexpected state: current TNode does not have a connection ' +
'to the previous node or a parent node.');
if (isFirstElementInNgContainer(tNode)) {
const noOffsetParentIndex = getNoOffsetIndex(tNode.parent!);
native = getSegmentHead(hydrationInfo, noOffsetParentIndex);
let native: RNode|null = locateRNodeByIndex(hydrationInfo, noOffsetIndex);
if (native == null) {
const nodes = hydrationInfo.data[NODES];
if (nodes?.[noOffsetIndex]) {
// We know the exact location of the node.
native = locateRNodeByPath(nodes[noOffsetIndex], lView);
} else if (tView.firstChild === tNode) {
// We create a first node in this view, so we use a reference
// to the first child in this DOM segment.
native = hydrationInfo.firstChild;
} else {
let previousRElement = getNativeByTNode(previousTNode, lView);
if (previousTNodeParent) {
native = (previousRElement as RElement).firstChild;
// Locate a node based on a previous sibling or a parent node.
const previousTNodeParent = tNode.prev === null;
const previousTNode = (tNode.prev ?? tNode.parent)!;
ngDevMode &&
assertDefined(
previousTNode,
'Unexpected state: current TNode does not have a connection ' +
'to the previous node or a parent node.');
if (isFirstElementInNgContainer(tNode)) {
const noOffsetParentIndex = getNoOffsetIndex(tNode.parent!);
native = getSegmentHead(hydrationInfo, noOffsetParentIndex);
} else {
// If the previous node is an element, but it also has container info,
// this means that we are processing a node like `<div #vcrTarget>`, which is
// represented in the DOM as `<div></div>...<!--container-->`.
// In this case, there are nodes *after* this element and we need to skip
// all of them to reach an element that we are looking for.
const noOffsetPrevSiblingIndex = getNoOffsetIndex(previousTNode);
const segmentHead = getSegmentHead(hydrationInfo, noOffsetPrevSiblingIndex);
if (previousTNode.type === TNodeType.Element && segmentHead) {
const numRootNodesToSkip =
calcSerializedContainerSize(hydrationInfo, noOffsetPrevSiblingIndex);
// `+1` stands for an anchor comment node after all the views in this container.
const nodesToSkip = numRootNodesToSkip + 1;
// First node after this segment.
native = siblingAfter(nodesToSkip, segmentHead);
let previousRElement = getNativeByTNode(previousTNode, lView);
if (previousTNodeParent) {
native = (previousRElement as RElement).firstChild;
} else {
native = previousRElement.nextSibling;
// If the previous node is an element, but it also has container info,
// this means that we are processing a node like `<div #vcrTarget>`, which is
// represented in the DOM as `<div></div>...<!--container-->`.
// In this case, there are nodes *after* this element and we need to skip
// all of them to reach an element that we are looking for.
const noOffsetPrevSiblingIndex = getNoOffsetIndex(previousTNode);
const segmentHead = getSegmentHead(hydrationInfo, noOffsetPrevSiblingIndex);
if (previousTNode.type === TNodeType.Element && segmentHead) {
const numRootNodesToSkip =
calcSerializedContainerSize(hydrationInfo, noOffsetPrevSiblingIndex);
// `+1` stands for an anchor comment node after all the views in this container.
const nodesToSkip = numRootNodesToSkip + 1;
// First node after this segment.
native = siblingAfter(nodesToSkip, segmentHead);
} else {
native = previousRElement.nextSibling;
}
}
}
}
Expand Down
62 changes: 47 additions & 15 deletions packages/core/src/render3/i18n/i18n_apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,20 @@
*/

import {RuntimeError, RuntimeErrorCode} from '../../errors';
import { locateRNodeByIndex } from '../../hydration/node_lookup_utils';
import { isDisconnectedNode, markRNodeAsClaimedByHydration } from '../../hydration/utils';
import {getPluralCase} from '../../i18n/localization';
import {assertDefined, assertDomNode, assertEqual, assertGreaterThan, assertIndexInRange, throwError} from '../../util/assert';
import {assertDefined, assertDomNode, assertEqual, assertGreaterThan, assertIndexInRange, assertNotDefined, throwError} from '../../util/assert';
import {assertIndexInExpandoRange, assertTIcu} from '../assert';
import {attachPatchData} from '../context_discovery';
import {elementPropertyInternal, setElementAttribute} from '../instructions/shared';
import {ELEMENT_MARKER, I18nCreateOpCode, I18nCreateOpCodes, I18nUpdateOpCode, I18nUpdateOpCodes, ICU_MARKER, IcuCreateOpCode, IcuCreateOpCodes, IcuType, TI18n, TIcu} from '../interfaces/i18n';
import {TNode} from '../interfaces/node';
import {RElement, RNode, RText} from '../interfaces/renderer_dom';
import {SanitizerFn} from '../interfaces/sanitization';
import {HEADER_OFFSET, LView, RENDERER, TView} from '../interfaces/view';
import {HEADER_OFFSET, HYDRATION, LView, RENDERER, TVIEW, TView} from '../interfaces/view';
import {createCommentNode, createElementNode, createTextNode, nativeInsertBefore, nativeParentNode, nativeRemoveNode, updateTextNode} from '../node_manipulation';
import {getBindingIndex} from '../state';
import {getBindingIndex, isInSkipHydrationBlock, lastNodeWasCreated, wasLastNodeCreated} from '../state';
import {renderStringify} from '../util/stringify_utils';
import {getNativeByIndex, unwrapRNode} from '../util/view_utils';

Expand Down Expand Up @@ -101,19 +103,50 @@ export function applyCreateOpCodes(
const appendNow =
(opCode & I18nCreateOpCode.APPEND_EAGERLY) === I18nCreateOpCode.APPEND_EAGERLY;
const index = opCode >>> I18nCreateOpCode.SHIFT;
let rNode = lView[index];
if (rNode === null) {
// We only create new DOM nodes if they don't already exist: If ICU switches case back to a
// case which was already instantiated, no need to create new DOM nodes.
rNode = lView[index] =
isComment ? renderer.createComment(text) : createTextNode(renderer, text);
}
if (appendNow && parentRNode !== null) {

ngDevMode && assertNotDefined(lView[index], 'view should not exist');

let rNode = lView[index] = locateOrCreateNode(lView, index, text, isComment ? Node.COMMENT_NODE : Node.TEXT_NODE);

if ((appendNow && parentRNode !== null) && wasLastNodeCreated()) {
nativeInsertBefore(renderer, parentRNode, rNode, insertInFrontOf, false);
}
}
}

function locateOrCreateNode(lView: LView, index: number, textOrName: string, nodeType: number) {
const hydrationInfo = lView[HYDRATION];
const isNodeCreationMode = !hydrationInfo || isInSkipHydrationBlock() || isDisconnectedNode(hydrationInfo, index);

lastNodeWasCreated(isNodeCreationMode);

if (isNodeCreationMode) {
const renderer = lView[RENDERER];
switch (nodeType) {
case Node.COMMENT_NODE:
return createCommentNode(renderer, textOrName);

case Node.TEXT_NODE:
return createTextNode(renderer, textOrName);

case Node.ELEMENT_NODE:
return createElementNode(renderer, textOrName, null);
}
}

const native = locateRNodeByIndex(hydrationInfo!, index - HEADER_OFFSET) as RNode;

if (ngDevMode) {
// TODO: Ideally we'd reuse `validateMatchingNode` here, but not all i18n nodes have
// a valid TNode.
assertEqual((native as Node).nodeType, nodeType, 'expected matching node type');
nodeType === Node.ELEMENT_NODE && assertEqual((native as Element).tagName.toLowerCase(), textOrName.toLowerCase(), 'expected matching tag name');
markRNodeAsClaimedByHydration(native);
}

return native;
}

/**
* Apply `I18nMutateOpCodes` OpCodes.
*
Expand Down Expand Up @@ -141,7 +174,7 @@ export function applyMutableOpCodes(
if (lView[textNodeIndex] === null) {
ngDevMode && ngDevMode.rendererCreateTextNode++;
ngDevMode && assertIndexInRange(lView, textNodeIndex);
lView[textNodeIndex] = createTextNode(renderer, opCode);
lView[textNodeIndex] = locateOrCreateNode(lView, textNodeIndex, opCode, Node.TEXT_NODE);
}
} else if (typeof opCode == 'number') {
switch (opCode & IcuCreateOpCode.MASK_INSTRUCTION) {
Expand Down Expand Up @@ -218,8 +251,7 @@ export function applyMutableOpCodes(
`Expected "${commentValue}" to be a comment node value`);
ngDevMode && ngDevMode.rendererCreateComment++;
ngDevMode && assertIndexInExpandoRange(lView, commentNodeIndex);
const commentRNode = lView[commentNodeIndex] =
createCommentNode(renderer, commentValue);
const commentRNode = lView[commentNodeIndex] = locateOrCreateNode(lView, commentNodeIndex, commentValue, Node.COMMENT_NODE);
// FIXME(misko): Attaching patch data is only needed for the root (Also add tests)
attachPatchData(commentRNode, lView);
}
Expand All @@ -236,7 +268,7 @@ export function applyMutableOpCodes(
ngDevMode && ngDevMode.rendererCreateElement++;
ngDevMode && assertIndexInExpandoRange(lView, elementNodeIndex);
const elementRNode = lView[elementNodeIndex] =
createElementNode(renderer, tagName, null);
locateOrCreateNode(lView, elementNodeIndex, tagName, Node.ELEMENT_NODE);
// FIXME(misko): Attaching patch data is only needed for the root (Also add tests)
attachPatchData(elementRNode, lView);
}
Expand Down
Loading

0 comments on commit cbd17b9

Please sign in to comment.