diff --git a/packages/core/src/hydration/annotate.ts b/packages/core/src/hydration/annotate.ts index 9eacf9bbe5c..93c85443eeb 100644 --- a/packages/core/src/hydration/annotate.ts +++ b/packages/core/src/hydration/annotate.ts @@ -11,13 +11,15 @@ import {collectNativeNodes} from '../render3/collect_native_nodes'; import {CONTAINER_HEADER_OFFSET, LContainer} from '../render3/interfaces/container'; import {TNode, TNodeType} from '../render3/interfaces/node'; import {RElement} from '../render3/interfaces/renderer_dom'; -import {isLContainer, isRootView} from '../render3/interfaces/type_checks'; +import {isLContainer, isProjectionTNode, isRootView} from '../render3/interfaces/type_checks'; import {HEADER_OFFSET, HOST, LView, RENDERER, TView, TVIEW, TViewType} from '../render3/interfaces/view'; import {unwrapRNode} from '../render3/util/view_utils'; import {TransferState} from '../transfer_state'; -import {CONTAINERS, ELEMENT_CONTAINERS, NUM_ROOT_NODES, SerializedContainerView, SerializedView, TEMPLATE_ID, TEMPLATES} from './interfaces'; -import {SKIP_HYDRATION_ATTR_NAME} from './skip_hydration'; +import {unsupportedProjectionOfDomNodes} from './error_handling'; +import {CONTAINERS, ELEMENT_CONTAINERS, NODES, NUM_ROOT_NODES, SerializedContainerView, SerializedView, TEMPLATE_ID, TEMPLATES} from './interfaces'; +import {calcPathForNode} from './node_lookup_utils'; +import {isInSkipHydrationBlock, SKIP_HYDRATION_ATTR_NAME} from './skip_hydration'; import {getComponentLViewForHydration, NGH_ATTR_NAME, NGH_DATA_KEY, TextNodeMarker} from './utils'; /** @@ -167,6 +169,17 @@ function serializeLContainer( return views; } +/** + * Helper function to produce a node path (which navigation steps runtime logic + * 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) { + const noOffsetIndex = tNode.index - HEADER_OFFSET; + ngh[NODES] ??= {}; + ngh[NODES][noOffsetIndex] = calcPathForNode(tNode, lView); +} + /** * Serializes the lView data into a SerializedView object that will later be added * to the TransferState storage and referenced using the `ngh` attribute on a host @@ -190,6 +203,33 @@ function serializeLView(lView: LView, context: HydrationContext): SerializedView if (!tNode) { continue; } + if (Array.isArray(tNode.projection)) { + for (const projectionHeadTNode of tNode.projection) { + // We may have `null`s in slots with no projected content. + if (!projectionHeadTNode) continue; + + if (!Array.isArray(projectionHeadTNode)) { + // If we process re-projected content (i.e. `` + // appears at projection location), skip annotations for this content + // since all DOM nodes in this projection were handled while processing + // a parent lView, which contains those nodes. + if (!isProjectionTNode(projectionHeadTNode) && + !isInSkipHydrationBlock(projectionHeadTNode)) { + appendSerializedNodePath(ngh, projectionHeadTNode, lView); + } + } else { + // If a value is an array, it means that we are processing a projection + // where projectable nodes were passed in as DOM nodes (for example, when + // calling `ViewContainerRef.createComponent(CmpA, {projectableNodes: [...]})`). + // + // In this scenario, nodes can come from anywhere (either created manually, + // accessed via `document.querySelector`, etc) and may be in any state + // (attached or detached from the DOM tree). As a result, we can not reliably + // restore the state for such cases during hydration. + throw unsupportedProjectionOfDomNodes(); + } + } + } if (isLContainer(lView[i])) { // Serialize information about a template. const embeddedTView = tNode.tView; @@ -227,6 +267,19 @@ function serializeLView(lView: LView, context: HydrationContext): SerializedView // those nodes to reach a corresponding anchor node (comment node). ngh[ELEMENT_CONTAINERS] ??= {}; ngh[ELEMENT_CONTAINERS][noOffsetIndex] = calcNumRootNodes(tView, lView, tNode.child); + } else if (tNode.type & TNodeType.Projection) { + // Current TNode represents an `` slot, thus it has no + // DOM elements associated with it, so the **next sibling** node would + // not be able to find an anchor. In this case, use full path instead. + let nextTNode = tNode.next; + // Skip over all `` slots in a row. + while (nextTNode !== null && (nextTNode.type & TNodeType.Projection)) { + nextTNode = nextTNode.next; + } + if (nextTNode && !isInSkipHydrationBlock(nextTNode)) { + // Handle a tNode after the `` slot. + appendSerializedNodePath(ngh, nextTNode, lView); + } } else { // Handle cases where text nodes can be lost after DOM serialization: // 1. When there is an *empty text node* in DOM: in this case, this @@ -262,6 +315,14 @@ function serializeLView(lView: LView, context: HydrationContext): SerializedView context.corruptedTextNodes.set(rNode, TextNodeMarker.Separator); } } + + if (tNode.projectionNext && tNode.projectionNext !== tNode.next && + !isInSkipHydrationBlock(tNode.projectionNext)) { + // Check if projection next is not the same as next, in which case + // the node would not be found at creation time at runtime and we + // need to provide a location for that node. + appendSerializedNodePath(ngh, tNode.projectionNext, lView); + } } } } diff --git a/packages/core/src/hydration/compression.ts b/packages/core/src/hydration/compression.ts new file mode 100644 index 00000000000..b61d146cf55 --- /dev/null +++ b/packages/core/src/hydration/compression.ts @@ -0,0 +1,73 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {NodeNavigationStep, REFERENCE_NODE_BODY, REFERENCE_NODE_HOST} from './interfaces'; + +/** + * Regexp that extracts a reference node information from the compressed node location. + * The reference node is represented as either: + * - a number which points to an LView slot + * - the `b` char which indicates that the lookup should start from the `document.body` + * - the `h` char to start lookup from the component host node (`lView[HOST]`) + */ +const REF_EXTRACTOR_REGEXP = + new RegExp(`^(\\d+)*(${REFERENCE_NODE_BODY}|${REFERENCE_NODE_HOST})*(.*)`); + +/** + * Helper function that takes a reference node location and a set of navigation steps + * (from the reference node) to a target node and outputs a string that represents + * a location. + * + * For example, given: referenceNode = 'b' (body) and path = ['firstChild', 'firstChild', + * 'nextSibling'], the function returns: `bf2n`. + */ +export function compressNodeLocation(referenceNode: string, path: NodeNavigationStep[]): string { + const result: Array = [referenceNode]; + for (const segment of path) { + const lastIdx = result.length - 1; + if (lastIdx > 0 && result[lastIdx - 1] === segment) { + // An empty string in a count slot represents 1 occurrence of an instruction. + const value = (result[lastIdx] || 1) as number; + result[lastIdx] = value + 1; + } else { + // Adding a new segment to the path. + // Using an empty string in a counter field to avoid encoding `1`s + // into the path, since they are implicit (e.g. `f1n1` vs `fn`), so + // it's enough to have a single char in this case. + result.push(segment, ''); + } + } + return result.join(''); +} + +/** + * Helper function that reverts the `compressNodeLocation` and transforms a given + * string into an array where at 0th position there is a reference node info and + * after that it contains information (in pairs) about a navigation step and the + * number of repetitions. + * + * For example, the path like 'bf2n' will be transformed to: + * ['b', 'firstChild', 2, 'nextSibling', 1]. + * + * This information is later consumed by the code that navigates the DOM to find + * a given node by its location. + */ +export function decompressNodeLocation(path: string): + [string|number, ...(number | NodeNavigationStep)[]] { + const matches = path.match(REF_EXTRACTOR_REGEXP)!; + const [_, refNodeId, refNodeName, rest] = matches; + // If a reference node is represented by an index, transform it to a number. + const ref = refNodeId ? parseInt(refNodeId, 10) : refNodeName; + const steps: (number|NodeNavigationStep)[] = []; + // Match all segments in a path. + for (const [_, step, count] of rest.matchAll(/(f|n)(\d*)/g)) { + const repeat = parseInt(count, 10) || 1; + steps.push(step as NodeNavigationStep, repeat); + } + return [ref, ...steps]; +} diff --git a/packages/core/src/hydration/error_handling.ts b/packages/core/src/hydration/error_handling.ts index 497d00c5f0a..94c44d6fb0d 100644 --- a/packages/core/src/hydration/error_handling.ts +++ b/packages/core/src/hydration/error_handling.ts @@ -42,3 +42,23 @@ export function validateNodeExists(node: RNode|null): void { throw new Error(`Hydration expected an element to be present at this location.`); } } + +export function nodeNotFoundError(lView: LView, tNode: TNode): Error { + // TODO: improve error message and use RuntimeError instead. + return new Error('During serialization, Angular was unable to find an element in the DOM'); +} + +export function nodeNotFoundAtPathError(host: Node, path: string): Error { + // TODO: improve error message and use RuntimeError instead. + return new Error('During hydration Angular was unable to locate a node'); +} + +export function unsupportedProjectionOfDomNodes(): Error { + // TODO: improve error message and use RuntimeError instead. + return new Error( + 'During serialization, Angular detected DOM nodes ' + + 'that were created outside of Angular context and provided as projectable nodes ' + + '(likely via `ViewContainerRef.createComponent` or `createComponent` APIs). ' + + 'Hydration is not supported for such cases, consider refactoring the code to avoid ' + + 'this pattern or using `ngSkipHydration` on the host element of the component.'); +} diff --git a/packages/core/src/hydration/interfaces.ts b/packages/core/src/hydration/interfaces.ts index 3f8fd49e650..46f322555fe 100644 --- a/packages/core/src/hydration/interfaces.ts +++ b/packages/core/src/hydration/interfaces.ts @@ -8,6 +8,22 @@ import {RNode} from '../render3/interfaces/renderer_dom'; + +/** Encodes that the node lookup should start from the host node of this component. */ +export const REFERENCE_NODE_HOST = 'h'; + +/** Encodes that the node lookup should start from the document body node. */ +export const REFERENCE_NODE_BODY = 'b'; + +/** + * Describes navigation steps that the runtime logic need to perform, + * starting from a given (known) element. + */ +export enum NodeNavigationStep { + FirstChild = 'f', + NextSibling = 'n', +} + /** * Keys within serialized view data structure to represent various * parts. See the `SerializedView` interface below for additional information. @@ -17,6 +33,7 @@ export const TEMPLATES = 't'; export const CONTAINERS = 'c'; export const NUM_ROOT_NODES = 'r'; export const TEMPLATE_ID = 'i'; // as it's also an "id" +export const NODES = 'n'; /** * Represents element containers within this view, stored as key-value pairs @@ -55,6 +72,14 @@ export interface SerializedView { * of serialized information about views within this container. */ [CONTAINERS]?: Record; + + /** + * Serialized information about nodes in a template. + * Key-value pairs where a key is an index of the corresponding + * DOM node in an LView and the value is a path that describes + * the location of this node (as a set of navigation instructions). + */ + [NODES]?: Record; } /** diff --git a/packages/core/src/hydration/node_lookup_utils.ts b/packages/core/src/hydration/node_lookup_utils.ts index c98edc523f0..ca1728fb4e9 100644 --- a/packages/core/src/hydration/node_lookup_utils.ts +++ b/packages/core/src/hydration/node_lookup_utils.ts @@ -8,19 +8,29 @@ import {TNode, TNodeType} from '../render3/interfaces/node'; import {RElement, RNode} from '../render3/interfaces/renderer_dom'; -import {HEADER_OFFSET, LView, TView} from '../render3/interfaces/view'; -import {getNativeByTNode} from '../render3/util/view_utils'; +import {HEADER_OFFSET, HOST, LView, TView} from '../render3/interfaces/view'; +import {getFirstNativeNode} from '../render3/node_manipulation'; +import {ɵɵresolveBody} from '../render3/util/misc_utils'; +import {renderStringify} from '../render3/util/stringify_utils'; +import {getNativeByTNode, unwrapRNode} from '../render3/util/view_utils'; import {assertDefined} from '../util/assert'; -import {validateSiblingNodeExists} from './error_handling'; -import {DehydratedView} from './interfaces'; +import {compressNodeLocation, decompressNodeLocation} from './compression'; +import {nodeNotFoundAtPathError, nodeNotFoundError, validateSiblingNodeExists} from './error_handling'; +import {DehydratedView, NodeNavigationStep, NODES, REFERENCE_NODE_BODY, REFERENCE_NODE_HOST} from './interfaces'; import {calcSerializedContainerSize, getSegmentHead} from './utils'; + /** Whether current TNode is a first node in an . */ function isFirstElementInNgContainer(tNode: TNode): boolean { return !tNode.prev && tNode.parent?.type === TNodeType.ElementContainer; } +/** Returns an instruction index (subtracting HEADER_OFFSET). */ +function getNoOffsetIndex(tNode: TNode): number { + return tNode.index - HEADER_OFFSET; +} + /** * Locate a node in DOM tree that corresponds to a given TNode. * @@ -33,7 +43,12 @@ function isFirstElementInNgContainer(tNode: TNode): boolean { export function locateNextRNode( hydrationInfo: DehydratedView, tView: TView, lView: LView, tNode: TNode): T|null { let native: RNode|null = null; - if (tView.firstChild === tNode) { + 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; @@ -47,7 +62,7 @@ export function locateNextRNode( 'Unexpected state: current TNode does not have a connection ' + 'to the previous node or a parent node.'); if (isFirstElementInNgContainer(tNode)) { - const noOffsetParentIndex = tNode.parent!.index - HEADER_OFFSET; + const noOffsetParentIndex = getNoOffsetIndex(tNode.parent!); native = getSegmentHead(hydrationInfo, noOffsetParentIndex); } else { let previousRElement = getNativeByTNode(previousTNode, lView); @@ -59,7 +74,7 @@ export function locateNextRNode( // represented in the DOM as `
...`. // 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 = previousTNode.index - HEADER_OFFSET; + const noOffsetPrevSiblingIndex = getNoOffsetIndex(previousTNode); const segmentHead = getSegmentHead(hydrationInfo, noOffsetPrevSiblingIndex); if (previousTNode.type === TNodeType.Element && segmentHead) { const numRootNodesToSkip = @@ -88,3 +103,184 @@ export function siblingAfter(skip: number, from: RNode): T|null } return currentNode as T; } + +/** + * Helper function to produce a string representation of the navigation steps + * (in terms of `nextSibling` and `firstChild` navigations). Used in error + * messages in dev mode. + */ +function stringifyNavigationInstructions(instructions: (number|NodeNavigationStep)[]): string { + const container = []; + let i = 0; + while (i < instructions.length) { + const step = instructions[i++]; + const repeat = instructions[i++] as number; + for (let r = 0; r < repeat; r++) { + container.push(step === NodeNavigationStep.FirstChild ? 'firstChild' : 'nextSibling'); + } + } + return container.join('.'); +} + +/** + * Helper function that navigates from a starting point node (the `from` node) + * using provided set of navigation instructions (within `path` argument). + */ +function navigateToNode(from: Node, instructions: (number|NodeNavigationStep)[]): RNode { + let node = from; + let i = 0; + while (i < instructions.length) { + const step = instructions[i++]; + const repeat = instructions[i++] as number; + for (let r = 0; r < repeat; r++) { + if (ngDevMode && !node) { + throw nodeNotFoundAtPathError(from, stringifyNavigationInstructions(instructions)); + } + switch (step) { + case NodeNavigationStep.FirstChild: + node = node.firstChild!; + break; + case NodeNavigationStep.NextSibling: + node = node.nextSibling!; + break; + } + } + } + if (ngDevMode && !node) { + throw nodeNotFoundAtPathError(from, stringifyNavigationInstructions(instructions)); + } + return node as RNode; +} + +/** + * Locates an RNode given a set of navigation instructions (which also contains + * a starting point node info). + */ +function locateRNodeByPath(path: string, lView: LView): RNode { + const [referenceNode, ...navigationInstructions] = decompressNodeLocation(path); + let ref: Element; + if (referenceNode === REFERENCE_NODE_HOST) { + ref = lView[0] as unknown as Element; + } else if (referenceNode === REFERENCE_NODE_BODY) { + ref = ɵɵresolveBody(lView[0] as unknown as RElement & {ownerDocument: Document}); + } else { + const parentElementId = Number(referenceNode); + ref = unwrapRNode((lView as any)[parentElementId + HEADER_OFFSET]) as Element; + } + return navigateToNode(ref, navigationInstructions); +} + +/** + * Generate a list of DOM navigation operations to get from node `start` to node `finish`. + * + * Note: assumes that node `start` occurs before node `finish` in an in-order traversal of the DOM + * tree. That is, we should be able to get from `start` to `finish` purely by using `.firstChild` + * and `.nextSibling` operations. + */ +export function navigateBetween(start: Node, finish: Node): NodeNavigationStep[]|null { + if (start === finish) { + return []; + } else if (start.parentElement == null || finish.parentElement == null) { + return null; + } else if (start.parentElement === finish.parentElement) { + return navigateBetweenSiblings(start, finish); + } else { + // `finish` is a child of its parent, so the parent will always have a child. + const parent = finish.parentElement!; + + const parentPath = navigateBetween(start, parent); + const childPath = navigateBetween(parent.firstChild!, finish); + if (!parentPath || !childPath) return null; + + return [ + // First navigate to `finish`'s parent + ...parentPath, + // Then to its first child. + NodeNavigationStep.FirstChild, + // And finally from that node to `finish` (maybe a no-op if we're already there). + ...childPath, + ]; + } +} + +/** + * Calculates a path between 2 sibling nodes (generates a number of `NextSibling` navigations). + */ +function navigateBetweenSiblings(start: Node, finish: Node): NodeNavigationStep[] { + const nav: NodeNavigationStep[] = []; + let node: Node|null = null; + for (node = start; node != null && node !== finish; node = node.nextSibling) { + nav.push(NodeNavigationStep.NextSibling); + } + return node === null ? [] : nav; +} + +/** + * Calculates a path between 2 nodes in terms of `nextSibling` and `firstChild` + * navigations: + * - the `from` node is a known node, used as an starting point for the lookup + * (the `fromNodeName` argument is a string representation of the node). + * - the `to` node is a node that the runtime logic would be looking up, + * using the path generated by this function. + */ +export function calcPathBetween(from: Node, to: Node, fromNodeName: string): string|null { + const path = navigateBetween(from, to); + return path === null ? null : compressNodeLocation(fromNodeName, path); +} + +/** + * Invoked at serialization time (on the server) when a set of navigation + * instructions needs to be generated for a TNode. + */ +export function calcPathForNode(tNode: TNode, lView: LView): string { + const parentTNode = tNode.parent; + let parentIndex: number|string; + let parentRNode: RNode; + let referenceNodeName: string; + if (parentTNode === null) { + // No parent TNode - use host element as a reference node. + parentIndex = referenceNodeName = REFERENCE_NODE_HOST; + parentRNode = lView[HOST]!; + } else { + // Use parent TNode as a reference node. + parentIndex = parentTNode.index; + parentRNode = unwrapRNode(lView[parentIndex]); + referenceNodeName = renderStringify(parentIndex - HEADER_OFFSET); + } + let rNode = unwrapRNode(lView[tNode.index]); + if (tNode.type & TNodeType.AnyContainer) { + // For nodes, instead of serializing a reference + // to the anchor comment node, serialize a location of the first + // DOM element. Paired with the container size (serialized as a part + // of `ngh.containers`), it should give enough information for runtime + // to hydrate nodes in this container. + const firstRNode = getFirstNativeNode(lView, tNode); + + // If container is not empty, use a reference to the first element, + // otherwise, rNode would point to an anchor comment node. + if (firstRNode) { + rNode = firstRNode; + } + } + let path: string|null = calcPathBetween(parentRNode as Node, rNode as Node, referenceNodeName); + if (path === null && parentRNode !== rNode) { + // Searching for a path between elements within a host node failed. + // Trying to find a path to an element starting from the `document.body` instead. + // + // Important note: this type of reference is relatively unstable, since Angular + // may not be able to control parts of the page that the runtime logic navigates + // through. This is mostly needed to cover "portals" use-case (like menus, dialog boxes, + // etc), where nodes are content-projected (including direct DOM manipulations) outside + // of the host node. The better solution is to provide APIs to work with "portals", + // at which point this code path would not be needed. + const body = (parentRNode as Node).ownerDocument!.body as Node; + path = calcPathBetween(body, rNode as Node, REFERENCE_NODE_BODY); + + if (path === null) { + // If the path is still empty, it's likely that this node is detached and + // won't be found during hydration. + throw nodeNotFoundError(lView, tNode); + } + } + return path!; +} diff --git a/packages/core/src/render3/instructions/projection.ts b/packages/core/src/render3/instructions/projection.ts index c0d8b104f0d..be6865eca38 100644 --- a/packages/core/src/render3/instructions/projection.ts +++ b/packages/core/src/render3/instructions/projection.ts @@ -8,10 +8,10 @@ import {newArray} from '../../util/array_utils'; import {TAttributes, TElementNode, TNode, TNodeFlags, TNodeType} from '../interfaces/node'; import {ProjectionSlots} from '../interfaces/projection'; -import {DECLARATION_COMPONENT_VIEW, HEADER_OFFSET, T_HOST} from '../interfaces/view'; +import {DECLARATION_COMPONENT_VIEW, HEADER_OFFSET, HYDRATION, T_HOST} from '../interfaces/view'; import {applyProjection} from '../node_manipulation'; import {getProjectAsAttrValue, isNodeMatchingSelectorList, isSelectorInSelectorList} from '../node_selector_matcher'; -import {getLView, getTView, setCurrentTNodeAsNotParent} from '../state'; +import {getLView, getTView, isInSkipHydrationBlock, setCurrentTNodeAsNotParent} from '../state'; import {getOrCreateTNode} from './shared'; @@ -129,7 +129,10 @@ export function ɵɵprojection( // `` has no content setCurrentTNodeAsNotParent(); - if ((tProjectionNode.flags & TNodeFlags.isDetached) !== TNodeFlags.isDetached) { + const hydrationInfo = lView[HYDRATION]; + const isNodeCreationMode = !hydrationInfo || isInSkipHydrationBlock(); + if (isNodeCreationMode && + (tProjectionNode.flags & TNodeFlags.isDetached) !== TNodeFlags.isDetached) { // re-distribution of projectable nodes is stored on a component's view level applyProjection(tView, lView, tProjectionNode); } diff --git a/packages/core/src/render3/interfaces/type_checks.ts b/packages/core/src/render3/interfaces/type_checks.ts index f3da536094e..e9fb3c94bac 100644 --- a/packages/core/src/render3/interfaces/type_checks.ts +++ b/packages/core/src/render3/interfaces/type_checks.ts @@ -8,7 +8,7 @@ import {LContainer, TYPE} from './container'; import {ComponentDef, DirectiveDef} from './definition'; -import {TNode, TNodeFlags} from './node'; +import {TNode, TNodeFlags, TNodeType} from './node'; import {RNode} from './renderer_dom'; import {FLAGS, LView, LViewFlags} from './view'; @@ -48,3 +48,7 @@ export function isComponentDef(def: DirectiveDef): def is ComponentDef export function isRootView(target: LView): boolean { return (target[FLAGS] & LViewFlags.IsRoot) !== 0; } + +export function isProjectionTNode(tNode: TNode): boolean { + return (tNode.type & TNodeType.Projection) === TNodeType.Projection; +} diff --git a/packages/core/src/render3/node_manipulation.ts b/packages/core/src/render3/node_manipulation.ts index eed0d97f1f8..3b1171b8165 100644 --- a/packages/core/src/render3/node_manipulation.ts +++ b/packages/core/src/render3/node_manipulation.ts @@ -731,7 +731,7 @@ export function appendChild( * * Native nodes are returned in the order in which those appear in the native tree (DOM). */ -function getFirstNativeNode(lView: LView, tNode: TNode|null): RNode|null { +export function getFirstNativeNode(lView: LView, tNode: TNode|null): RNode|null { if (tNode !== null) { ngDevMode && assertTNodeType( diff --git a/packages/core/test/hydration/compression_spec.ts b/packages/core/test/hydration/compression_spec.ts new file mode 100644 index 00000000000..88ac0ec72d2 --- /dev/null +++ b/packages/core/test/hydration/compression_spec.ts @@ -0,0 +1,46 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {compressNodeLocation, decompressNodeLocation} from '../../src/hydration/compression'; +import {NodeNavigationStep, REFERENCE_NODE_BODY, REFERENCE_NODE_HOST} from '../../src/hydration/interfaces'; + +describe('compression of node location', () => { + it('should handle basic cases', () => { + const fc = NodeNavigationStep.FirstChild; + const ns = NodeNavigationStep.NextSibling; + const cases = [ + [[REFERENCE_NODE_HOST, fc, 1], 'hf'], + [[REFERENCE_NODE_BODY, fc, 1], 'bf'], + [[0, fc, 1], '0f'], + [[15, fc, 1], '15f'], + [[15, fc, 4], '15f4'], + [[5, fc, 4, ns, 1, fc, 1], '5f4nf'], + [[7, ns, 4, fc, 1, ns, 1], '7n4fn'], + ]; + cases.forEach((_case) => { + const [origSteps, path] = _case as [(string | number)[], string]; + const refNode = origSteps.shift()!; + + // Transform the short version of instructions (e.g. [fc, 4, ns, 2]) + // to a long version (e.g. [fc, fc, fc, fc, ns, ns]). + const steps = []; + let i = 0; + while (i < origSteps.length) { + const step = origSteps[i++]; + const repeat = origSteps[i++] as number; + for (let r = 0; r < repeat; r++) { + steps.push(step); + } + } + + // Check that one type can be converted to another and vice versa. + expect(compressNodeLocation(String(refNode), steps as NodeNavigationStep[])).toEqual(path); + expect(decompressNodeLocation(path)).toEqual([refNode, ...origSteps]); + }); + }); +}); diff --git a/packages/platform-server/test/hydration_spec.ts b/packages/platform-server/test/hydration_spec.ts index 7a85b8f3728..b9624eb34a8 100644 --- a/packages/platform-server/test/hydration_spec.ts +++ b/packages/platform-server/test/hydration_spec.ts @@ -7,7 +7,7 @@ */ import {CommonModule, DOCUMENT, isPlatformServer, NgComponentOutlet, NgFor, NgIf, NgTemplateOutlet} from '@angular/common'; -import {APP_ID, ApplicationRef, Component, ComponentRef, destroyPlatform, ElementRef, getPlatform, inject, Input, PLATFORM_ID, Provider, TemplateRef, Type, ViewChild, ViewContainerRef, ɵgetComponentDef as getComponentDef, ɵprovideHydrationSupport as provideHydrationSupport, ɵsetDocument} from '@angular/core'; +import {APP_ID, ApplicationRef, Component, ComponentRef, createComponent, destroyPlatform, ElementRef, EnvironmentInjector, getPlatform, inject, Input, PLATFORM_ID, Provider, TemplateRef, Type, ViewChild, ViewContainerRef, ɵgetComponentDef as getComponentDef, ɵprovideHydrationSupport as provideHydrationSupport, ɵsetDocument} from '@angular/core'; import {TestBed} from '@angular/core/testing'; import {bootstrapApplication} from '@angular/platform-browser'; @@ -1765,5 +1765,576 @@ describe('platform-server integration', () => { verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); }); + + describe('content projection', () => { + it('should project plain text', async () => { + @Component({ + standalone: true, + selector: 'projector-cmp', + template: ` +
+ +
+ `, + }) + class ProjectorCmp { + } + + @Component({ + standalone: true, + imports: [ProjectorCmp], + selector: 'app', + template: ` + + Projected content is just a plain text. + + `, + }) + class SimpleComponent { + } + + const html = await ssr(SimpleComponent); + const ssrContents = getAppContents(html); + + expect(ssrContents).toContain('(appRef); + appRef.tick(); + + const clientRootNode = compRef.location.nativeElement; + verifyAllNodesClaimedForHydration(clientRootNode); + verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); + }); + + it('should project plain text and HTML elements', async () => { + @Component({ + standalone: true, + selector: 'projector-cmp', + template: ` +
+ +
+ `, + }) + class ProjectorCmp { + } + + @Component({ + standalone: true, + imports: [ProjectorCmp], + selector: 'app', + template: ` + + Projected content is a plain text. + Also the content has some tags + + `, + }) + class SimpleComponent { + } + + const html = await ssr(SimpleComponent); + const ssrContents = getAppContents(html); + + expect(ssrContents).toContain('(appRef); + appRef.tick(); + + const clientRootNode = compRef.location.nativeElement; + verifyAllNodesClaimedForHydration(clientRootNode); + verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); + }); + + it('should support re-projection of contents', async () => { + @Component({ + standalone: true, + selector: 'reprojector-cmp', + template: ` +
+ +
+ `, + }) + class ReprojectorCmp { + } + + @Component({ + standalone: true, + selector: 'projector-cmp', + imports: [ReprojectorCmp], + template: ` + + Before + + After + + `, + }) + class ProjectorCmp { + } + + @Component({ + standalone: true, + imports: [ProjectorCmp], + selector: 'app', + template: ` + + Projected content is a plain text. + + `, + }) + class SimpleComponent { + } + + const html = await ssr(SimpleComponent); + const ssrContents = getAppContents(html); + + expect(ssrContents).toContain('(appRef); + appRef.tick(); + + const clientRootNode = compRef.location.nativeElement; + verifyAllNodesClaimedForHydration(clientRootNode); + verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); + }); + + it('should project contents into different slots', async () => { + @Component({ + standalone: true, + selector: 'projector-cmp', + template: ` +
+ Header slot: + Main slot: + Footer slot: + +
+ `, + }) + class ProjectorCmp { + } + + @Component({ + standalone: true, + imports: [ProjectorCmp], + selector: 'app', + template: ` + + +

H1

+
Footer
+
Header
+
Main
+

H2

+
+ `, + }) + class SimpleComponent { + } + + const html = await ssr(SimpleComponent); + const ssrContents = getAppContents(html); + + expect(ssrContents).toContain('(appRef); + appRef.tick(); + + const clientRootNode = compRef.location.nativeElement; + verifyAllNodesClaimedForHydration(clientRootNode); + verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); + }); + + // FIXME(akushnir): this is a special use-case that will be covered in a followup PR. + // This would require some extra logic to detect if some nodes were "dropped" during the + // content projection operation. + xit('should support partial projection (when some nodes are not projected)', async () => { + @Component({ + standalone: true, + selector: 'projector-cmp', + template: ` +
+ Header slot: + Main slot: + Footer slot: + +
+ `, + }) + class ProjectorCmp { + } + + @Component({ + standalone: true, + imports: [ProjectorCmp], + selector: 'app', + template: ` + + +

This node is not projected.

+
Footer
+
Header
+
Main
+

This node is not projected as well.

+
+ `, + }) + class SimpleComponent { + } + + const html = await ssr(SimpleComponent); + const ssrContents = getAppContents(html); + + expect(ssrContents).toContain('(appRef); + appRef.tick(); + + const clientRootNode = compRef.location.nativeElement; + verifyAllNodesClaimedForHydration(clientRootNode); + verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); + }); + + it('should project contents with *ngIf\'s', async () => { + @Component({ + standalone: true, + selector: 'projector-cmp', + template: ` +
+ +
+ `, + }) + class ProjectorCmp { + } + + @Component({ + standalone: true, + imports: [ProjectorCmp, CommonModule], + selector: 'app', + template: ` + +

Header with an ngIf condition.

+
+ `, + }) + class SimpleComponent { + visible = true; + } + + const html = await ssr(SimpleComponent); + const ssrContents = getAppContents(html); + + expect(ssrContents).toContain('(appRef); + appRef.tick(); + + const clientRootNode = compRef.location.nativeElement; + verifyAllNodesClaimedForHydration(clientRootNode); + verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); + }); + + it('should project contents with *ngFor', async () => { + @Component({ + standalone: true, + selector: 'projector-cmp', + template: ` +
+ +
+ `, + }) + class ProjectorCmp { + } + + @Component({ + standalone: true, + imports: [ProjectorCmp, CommonModule], + selector: 'app', + template: ` + +

Item {{ item }}

+
+ `, + }) + class SimpleComponent { + items = [1, 2, 3]; + } + + const html = await ssr(SimpleComponent); + const ssrContents = getAppContents(html); + + expect(ssrContents).toContain('(appRef); + appRef.tick(); + + const clientRootNode = compRef.location.nativeElement; + verifyAllNodesClaimedForHydration(clientRootNode); + verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); + }); + + it('should support projecting contents outside of a current host element', async () => { + @Component({ + standalone: true, + selector: 'dynamic-cmp', + template: `
`, + }) + class DynamicComponent { + @ViewChild('target', {read: ViewContainerRef}) vcRef!: ViewContainerRef; + + createView(tmplRef: TemplateRef) { + this.vcRef.createEmbeddedView(tmplRef); + } + } + + @Component({ + standalone: true, + selector: 'projector-cmp', + template: ` + + + + `, + }) + class ProjectorCmp { + @ViewChild('ref', {read: TemplateRef}) tmplRef!: TemplateRef; + + appRef = inject(ApplicationRef); + environmentInjector = inject(EnvironmentInjector); + doc = inject(DOCUMENT) as Document; + isServer = isPlatformServer(inject(PLATFORM_ID)); + + ngAfterViewInit() { + // Create a host DOM node outside of the main app's host node + // to emulate a situation where a host node already exists + // on a page. + let hostElement: Element; + if (this.isServer) { + hostElement = this.doc.createElement('portal-app'); + this.doc.body.insertBefore(hostElement, this.doc.body.firstChild); + } else { + hostElement = this.doc.querySelector('portal-app')!; + } + + const cmp = createComponent( + DynamicComponent, {hostElement, environmentInjector: this.environmentInjector}); + cmp.changeDetectorRef.detectChanges(); + cmp.instance.createView(this.tmplRef); + this.appRef.attachView(cmp.hostView); + } + } + + @Component({ + standalone: true, + imports: [ProjectorCmp, CommonModule], + selector: 'app', + template: ` + +
Header
+
+ `, + }) + class SimpleComponent { + visible = true; + } + + const html = await ssr(SimpleComponent); + const ssrContents = getAppContents(html); + + expect(ssrContents).toContain('(appRef); + appRef.tick(); + + const clientRootNode = compRef.location.nativeElement; + const portalRootNode = clientRootNode.ownerDocument.body.firstChild; + verifyAllNodesClaimedForHydration(clientRootNode); + verifyAllNodesClaimedForHydration(portalRootNode.firstChild); + const clientContents = stripUtilAttributes(portalRootNode.outerHTML, false) + + stripUtilAttributes(clientRootNode.outerHTML, false); + expect(clientContents) + .toBe( + stripUtilAttributes(stripTransferDataScript(ssrContents), false), + 'Client and server contents mismatch'); + }); + + it('should handle projected containers inside other containers', async () => { + @Component({ + standalone: true, + selector: 'child-comp', + template: '', + }) + class ChildComp { + } + + @Component({ + standalone: true, + selector: 'root-comp', + template: '', + }) + class RootComp { + } + + @Component({ + standalone: true, + selector: 'app', + imports: [CommonModule, RootComp, ChildComp], + template: ` + + + {{ item }}| + + + ` + }) + class MyApp { + items: number[] = [1, 2, 3]; + } + + const html = await ssr(MyApp); + const ssrContents = getAppContents(html); + + expect(ssrContents).toContain('(appRef); + appRef.tick(); + + const clientRootNode = compRef.location.nativeElement; + verifyAllNodesClaimedForHydration(clientRootNode); + verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); + }); + + it('should throw an error when projecting DOM nodes via ViewContainerRef.createComponent API', + async () => { + @Component({ + standalone: true, + selector: 'dynamic', + template: ` + + + `, + }) + class DynamicComponent { + } + + @Component({ + standalone: true, + selector: 'app', + imports: [NgIf, NgFor], + template: ` +
+
Hi! This is the main content.
+ `, + }) + class SimpleComponent { + @ViewChild('target', {read: ViewContainerRef}) vcr!: ViewContainerRef; + + ngAfterViewInit() { + const div = document.createElement('div'); + const p = document.createElement('p'); + const span = document.createElement('span'); + const b = document.createElement('b'); + // In this test we create DOM nodes outside of Angular context + // (i.e. not using Angular APIs) and try to content-project them. + // This is an unsupported pattern and we expect an exception. + const compRef = this.vcr.createComponent( + DynamicComponent, {projectableNodes: [[div, p], [span, b]]}); + compRef.changeDetectorRef.detectChanges(); + } + } + + try { + await ssr(SimpleComponent); + } catch (error: unknown) { + expect((error as Error).toString()) + .toContain( + 'During serialization, Angular detected DOM nodes that ' + + 'were created outside of Angular context'); + } + }); + + it('should throw an error when projecting DOM nodes via createComponent function call', + async () => { + @Component({ + standalone: true, + selector: 'dynamic', + template: ` + + + `, + }) + class DynamicComponent { + } + + @Component({ + standalone: true, + selector: 'app', + imports: [NgIf, NgFor], + template: ` +
+
Hi! This is the main content.
+ `, + }) + class SimpleComponent { + @ViewChild('target', {read: ViewContainerRef}) vcr!: ViewContainerRef; + envInjector = inject(EnvironmentInjector); + + ngAfterViewInit() { + const div = document.createElement('div'); + const p = document.createElement('p'); + const span = document.createElement('span'); + const b = document.createElement('b'); + // In this test we create DOM nodes outside of Angular context + // (i.e. not using Angular APIs) and try to content-project them. + // This is an unsupported pattern and we expect an exception. + const compRef = createComponent(DynamicComponent, { + environmentInjector: this.envInjector, + projectableNodes: [[div, p], [span, b]] + }); + compRef.changeDetectorRef.detectChanges(); + } + } + + try { + await ssr(SimpleComponent); + } catch (error: unknown) { + expect((error as Error).toString()) + .toContain( + 'During serialization, Angular detected DOM nodes that ' + + 'were created outside of Angular context'); + } + }); + }); }); });