From ced237693adeaaded5ff4428702a23503dd710be Mon Sep 17 00:00:00 2001 From: Andrew Kushnir Date: Tue, 14 Mar 2023 09:26:06 -0700 Subject: [PATCH] refactor(core): adding hydration for content projection (#49454) This commit adds serialization and hydration logic for content projection. While hydration for regular elements relies on their location in the TNode tree, the content projection may move elements around, so in order to hydrate them correcty, the runtime needs some extra information. This commit adds a serialization logic that adds element locations (instructions on how to navigate to a particular element from another known location of other element) into the hydration state for the following cases: - when a TNode is a first element in projection segment (other nodes are linked from that node) - when a TNode's next sibling is different before and after projection (we serialize extra info about the template-based sibling) - when a TNode's previous sibling was a content projection (i.e. `` slot), because we can not rely on the previous element in this case (projection happens at a later point) PR Close #49454 --- packages/core/src/hydration/annotate.ts | 67 +- packages/core/src/hydration/compression.ts | 73 +++ packages/core/src/hydration/error_handling.ts | 20 + packages/core/src/hydration/interfaces.ts | 25 + .../core/src/hydration/node_lookup_utils.ts | 210 ++++++- .../src/render3/instructions/projection.ts | 9 +- .../src/render3/interfaces/type_checks.ts | 6 +- .../core/src/render3/node_manipulation.ts | 2 +- .../core/test/hydration/compression_spec.ts | 46 ++ .../platform-server/test/hydration_spec.ts | 573 +++++++++++++++++- 10 files changed, 1015 insertions(+), 16 deletions(-) create mode 100644 packages/core/src/hydration/compression.ts create mode 100644 packages/core/test/hydration/compression_spec.ts 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'); + } + }); + }); }); });