diff --git a/packages/core/src/hydration/annotate.ts b/packages/core/src/hydration/annotate.ts index 674ecec3b15313..beba3d52f3a2aa 100644 --- a/packages/core/src/hydration/annotate.ts +++ b/packages/core/src/hydration/annotate.ts @@ -8,13 +8,14 @@ import {ApplicationRef} from '../application_ref'; import {ViewEncapsulation} from '../metadata'; +import {Renderer2} from '../render'; import {collectNativeNodes} from '../render3/collect_native_nodes'; import {getComponentDef} from '../render3/definition'; import {CONTAINER_HEADER_OFFSET, LContainer} from '../render3/interfaces/container'; import {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 {CONTEXT, FLAGS, HEADER_OFFSET, HOST, LView, LViewFlags, RENDERER, TView, TVIEW, TViewType} from '../render3/interfaces/view'; +import {hasI18n, isComponentHost, isLContainer, isLView, isProjectionTNode, isRootView} from '../render3/interfaces/type_checks'; +import {CONTEXT, FLAGS, HEADER_OFFSET, HOST, LView, LViewFlags, PARENT, RENDERER, TView, TVIEW, TViewType} from '../render3/interfaces/view'; import {unwrapRNode} from '../render3/util/view_utils'; import {TransferState} from '../transfer_state'; @@ -22,7 +23,7 @@ import {unsupportedProjectionOfDomNodes} from './error_handling'; import {CONTAINERS, DISCONNECTED_NODES, ELEMENT_CONTAINERS, MULTIPLIER, NODES, NUM_ROOT_NODES, SerializedContainerView, SerializedView, TEMPLATE_ID, TEMPLATES} from './interfaces'; import {calcPathForNode} from './node_lookup_utils'; import {hasInSkipHydrationBlockFlag, isInSkipHydrationBlock, SKIP_HYDRATION_ATTR_NAME} from './skip_hydration'; -import {getComponentLViewForHydration, NGH_ATTR_NAME, NGH_DATA_KEY, TextNodeMarker} from './utils'; +import {getLNodeForHydration, NGH_ATTR_NAME, NGH_DATA_KEY, TextNodeMarker} from './utils'; /** * A collection that tracks all serialized views (`ngh` DOM annotations) @@ -91,6 +92,43 @@ function calcNumRootNodes(tView: TView, lView: LView, tNode: TNode|null): number return rootNodes.length; } +// TODO: add docs. +function annotateLViewForHydration(lView: LView, context: HydrationContext): number|null { + const hostElement = lView[HOST]; + // Root elements might also be annotated with the `ngSkipHydration` attribute, + // check if it's present before starting the serialization process. + if (hostElement && !(hostElement as HTMLElement).hasAttribute(SKIP_HYDRATION_ATTR_NAME)) { + return annotateHostElementForHydration(hostElement as HTMLElement, lView, context); + } + return null; +} + +// TODO: add docs. +function annotateLContainerForHydration(lContainer: LContainer, context: HydrationContext) { + const componentLView = lContainer[HOST] as LView; + + // Serialize root component itself. + const componentLViewNghIndex = annotateLViewForHydration(componentLView, context); + + const hostElement = unwrapRNode(componentLView[HOST]!) as HTMLElement; + + // Serialize all views within this view container. + const rootLView = lContainer[PARENT]; + const rootLViewNghIndex = annotateHostElementForHydration(hostElement, rootLView, context); + + // TODO: assert whether we have `ngSkipHydration` on this hostElement and + // whether there are any views in this LContainer. If so, produce a console.warn + // to indicate that the DOM for those views would *not* be cleaned up, which + // might result in duplicate content on a page. + + const renderer = componentLView[RENDERER] as Renderer2; + + // Combine both indices into a single string and + // apply to the host node as an `ngh` attribute value. + const finalIndex = `${componentLViewNghIndex}|${rootLViewNghIndex}`; + renderer.setAttribute(hostElement, NGH_ATTR_NAME, finalIndex); +} + /** * Annotates all components bootstrapped in a given ApplicationRef * with info needed for hydration. @@ -103,21 +141,21 @@ export function annotateForHydration(appRef: ApplicationRef, doc: Document) { const corruptedTextNodes = new Map(); const viewRefs = appRef._views; for (const viewRef of viewRefs) { - const lView = getComponentLViewForHydration(viewRef); + const lNode = getLNodeForHydration(viewRef); + // An `lView` might be `null` if a `ViewRef` represents // an embedded view (not a component view). - if (lView !== null) { - const hostElement = lView[HOST]; - // Root elements might also be annotated with the `ngSkipHydration` attribute, - // check if it's present before starting the serialization process. - if (hostElement && !(hostElement as HTMLElement).hasAttribute(SKIP_HYDRATION_ATTR_NAME)) { - const context: HydrationContext = { - serializedViewCollection, - corruptedTextNodes, - }; - annotateHostElementForHydration(hostElement as HTMLElement, lView, context); - insertCorruptedTextNodeMarkers(corruptedTextNodes, doc); + if (lNode !== null) { + const context: HydrationContext = { + serializedViewCollection, + corruptedTextNodes, + }; + if (isLContainer(lNode)) { + annotateLContainerForHydration(lNode, context); + } else { + annotateLViewForHydration(lNode, context); } + insertCorruptedTextNodeMarkers(corruptedTextNodes, doc); } } @@ -410,7 +448,7 @@ function componentUsesShadowDomEncapsulation(lView: LView): boolean { * @param context The hydration context */ function annotateHostElementForHydration( - element: RElement, lView: LView, context: HydrationContext): void { + element: RElement, lView: LView, context: HydrationContext): number|null { const renderer = lView[RENDERER]; if (hasI18n(lView) || componentUsesShadowDomEncapsulation(lView)) { // Attach the skip hydration attribute if this component: @@ -419,10 +457,12 @@ function annotateHostElementForHydration( // shadow DOM, so we can not guarantee that client and server representations // would exactly match renderer.setAttribute(element, SKIP_HYDRATION_ATTR_NAME, ''); + return null; } else { const ngh = serializeLView(lView, context); const index = context.serializedViewCollection.add(ngh); renderer.setAttribute(element, NGH_ATTR_NAME, index.toString()); + return index; } } diff --git a/packages/core/src/hydration/cleanup.ts b/packages/core/src/hydration/cleanup.ts index 4f897bfb17a02c..1b486b4ba89a26 100644 --- a/packages/core/src/hydration/cleanup.ts +++ b/packages/core/src/hydration/cleanup.ts @@ -10,14 +10,14 @@ import {ApplicationRef} from '../application_ref'; import {CONTAINER_HEADER_OFFSET, DEHYDRATED_VIEWS, LContainer} from '../render3/interfaces/container'; import {Renderer} from '../render3/interfaces/renderer'; import {RNode} from '../render3/interfaces/renderer_dom'; -import {isLContainer} from '../render3/interfaces/type_checks'; +import {isLContainer, isLView} from '../render3/interfaces/type_checks'; import {HEADER_OFFSET, HOST, LView, PARENT, RENDERER, TVIEW} from '../render3/interfaces/view'; import {nativeRemoveNode} from '../render3/node_manipulation'; import {EMPTY_ARRAY} from '../util/empty'; import {validateSiblingNodeExists} from './error_handling'; import {DehydratedContainerView, NUM_ROOT_NODES} from './interfaces'; -import {getComponentLViewForHydration} from './utils'; +import {getLNodeForHydration} from './utils'; /** * Removes all dehydrated views from a given LContainer: @@ -92,11 +92,15 @@ function cleanupLView(lView: LView) { export function cleanupDehydratedViews(appRef: ApplicationRef) { const viewRefs = appRef._views; for (const viewRef of viewRefs) { - const lView = getComponentLViewForHydration(viewRef); + const lNode = getLNodeForHydration(viewRef); // An `lView` might be `null` if a `ViewRef` represents // an embedded view (not a component view). - if (lView !== null && lView[HOST] !== null) { - cleanupLView(lView); + if (lNode !== null && lNode[HOST] !== null) { + if (isLView(lNode)) { + cleanupLView(lNode); + } else { + cleanupLContainer(lNode); + } ngDevMode && ngDevMode.dehydratedViewsCleanupRuns++; } } diff --git a/packages/core/src/hydration/utils.ts b/packages/core/src/hydration/utils.ts index a72fbd420d1734..0e869fbefb7bd0 100644 --- a/packages/core/src/hydration/utils.ts +++ b/packages/core/src/hydration/utils.ts @@ -9,10 +9,11 @@ import {Injector} from '../di/injector'; import {ViewRef} from '../linker/view_ref'; +import {LContainer} from '../render3/interfaces/container'; import {getDocument} from '../render3/interfaces/document'; import {RElement, RNode} from '../render3/interfaces/renderer_dom'; -import {isLContainer, isRootView} from '../render3/interfaces/type_checks'; -import {HEADER_OFFSET, HOST, LView, TVIEW, TViewType} from '../render3/interfaces/view'; +import {isRootView} from '../render3/interfaces/type_checks'; +import {HEADER_OFFSET, LView, TVIEW, TViewType} from '../render3/interfaces/view'; import {makeStateKey, TransferState} from '../transfer_state'; import {assertDefined} from '../util/assert'; @@ -64,15 +65,34 @@ export const enum TextNodeMarker { * * @param rNode Component's host element. * @param injector Injector that this component has access to. + * @param isRootView Specifies whether we try to read hydration info for the root view. */ let _retrieveHydrationInfoImpl: typeof retrieveHydrationInfoImpl = - (rNode: RElement, injector: Injector) => null; + (rNode: RElement, injector: Injector, isRootView?: boolean) => null; -export function retrieveHydrationInfoImpl(rNode: RElement, injector: Injector): DehydratedView| - null { - const nghAttrValue = rNode.getAttribute(NGH_ATTR_NAME); +export function retrieveHydrationInfoImpl( + rNode: RElement, injector: Injector, isRootView = false): DehydratedView|null { + let nghAttrValue = rNode.getAttribute(NGH_ATTR_NAME); if (nghAttrValue == null) return null; + // For cases when a root component also acts as an anchor node for a ViewContainerRef + // (for example, when ViewContainerRef is injected in a root component), there is a need + // to serialize information about the component itself, as well as an LContainer that + // represents this ViewContainerRef. Effectively, we need to serialize 2 pieces of info: + // (1) hydration info for the root component itself and (2) hydration info for the + // ViewContainerRef instance (an LContainer). Each piece of information is included into + // the hydration data (in the TransferState object) separately, thus we end up with 2 ids. + // Since we only have 1 root element, we encode both bits of info into a single string: + // ids are separated by the `|` char (e.g. `10|25`, where `10` is the ngh for a component view + // and 25 is teh ngh for a root view which holds LContainer). + const [componentViewNgh, rootViewNgh] = nghAttrValue.split('|'); + nghAttrValue = isRootView ? rootViewNgh : componentViewNgh; + if (!nghAttrValue) return null; + + // We've read one of the ngh ids, keep the remaining one, so that + // we can set it back on the DOM element. + const remainingNgh = isRootView ? componentViewNgh : (rootViewNgh ? `|${rootViewNgh}` : ''); + let data: SerializedView = {}; // An element might have an empty `ngh` attribute value (e.g. ``), // which means that no special annotations are required. Do not attempt to read @@ -96,9 +116,28 @@ export function retrieveHydrationInfoImpl(rNode: RElement, injector: Injector): data, firstChild: rNode.firstChild ?? null, }; - // The `ngh` attribute is cleared from the DOM node now - // that the data has been retrieved. - rNode.removeAttribute(NGH_ATTR_NAME); + + if (isRootView) { + // If there is hydration info present for the root view, it means that there was + // a ViewContainerRef injected in the root component. The root component host element + // acted as an anchor node in this scenario. As a result, the DOM nodes that represent + // embedded views in this ViewContainerRef are located as siblings to the host node, + // i.e. `<#VIEW1><#VIEW2>...`. In this case, the current + // node becomes the first child of this root view and the next sibling is the first + // element in the DOM segment. + dehydratedView.firstChild = rNode; + setSegmentHead(dehydratedView, 0, rNode.nextSibling); + } + + if (remainingNgh) { + // If we have only used one of the ngh ids, store the remaining one + // back on this RNode. + rNode.setAttribute(NGH_ATTR_NAME, remainingNgh); + } else { + // The `ngh` attribute is cleared from the DOM node now + // that the data has been retrieved. + rNode.removeAttribute(NGH_ATTR_NAME); + } // Note: don't check whether this node was claimed for hydration, // because this node might've been previously claimed while processing @@ -120,15 +159,16 @@ export function enableRetrieveHydrationInfoImpl() { * Retrieves hydration info by reading the value from the `ngh` attribute * and accessing a corresponding slot in TransferState storage. */ -export function retrieveHydrationInfo(rNode: RElement, injector: Injector): DehydratedView|null { - return _retrieveHydrationInfoImpl(rNode, injector); +export function retrieveHydrationInfo( + rNode: RElement, injector: Injector, isRootView = false): DehydratedView|null { + return _retrieveHydrationInfoImpl(rNode, injector, isRootView); } /** - * Retrieves an instance of a component LView from a given ViewRef. - * Returns an instance of a component LView or `null` in case of an embedded view. + * Retrieves an instance of a component LView or an LContainer from a given ViewRef. + * Returns an instance of a component LView or an LContainer, or `null` in case of an embedded view. */ -export function getComponentLViewForHydration(viewRef: ViewRef): LView|null { +export function getLNodeForHydration(viewRef: ViewRef): LView|LContainer|null { // Reading an internal field from `ViewRef` instance. let lView = (viewRef as any)._lView as LView; const tView = lView[TVIEW]; @@ -143,12 +183,6 @@ export function getComponentLViewForHydration(viewRef: ViewRef): LView|null { lView = lView[HEADER_OFFSET]; } - // If a `ViewContainerRef` was injected in a component class, this resulted - // in an LContainer creation at that location. In this case, the component - // LView is in the LContainer's `HOST` slot. - if (isLContainer(lView)) { - lView = lView[HOST]; - } return lView; } diff --git a/packages/core/src/render3/component_ref.ts b/packages/core/src/render3/component_ref.ts index ad4c369bc58d8a..c8569ec9584d9c 100644 --- a/packages/core/src/render3/component_ref.ts +++ b/packages/core/src/render3/component_ref.ts @@ -212,12 +212,17 @@ export class ComponentFactory extends AbstractComponentFactory { LViewFlags.CheckAlways | LViewFlags.IsRoot; const rootFlags = this.componentDef.signals ? signalFlags : nonSignalFlags; + let hydrationInfo: DehydratedView|null = null; + if (hostRNode !== null) { + hydrationInfo = retrieveHydrationInfo(hostRNode, rootViewInjector, true /* isRootView */); + } + // Create the root view. Uses empty TView and ContentTemplate. const rootTView = createTView(TViewType.Root, null, null, 1, 0, null, null, null, null, null, null); const rootLView = createLView( null, rootTView, null, rootFlags, null, null, environment, hostRenderer, rootViewInjector, - null, null); + null, hydrationInfo); // rootView is the parent when bootstrapping // TODO(misko): it looks like we are entering view here but we don't really need to as @@ -386,7 +391,7 @@ function createRootComponentView( const tView = rootView[TVIEW]; applyRootComponentStyling(rootDirectives, tNode, hostRNode, hostRenderer); - // Hydration info is on the host element and needs to be retreived + // Hydration info is on the host element and needs to be retrieved // and passed to the component LView. let hydrationInfo: DehydratedView|null = null; if (hostRNode !== null) { diff --git a/packages/platform-server/test/hydration_spec.ts b/packages/platform-server/test/hydration_spec.ts index 557e6a2f114efa..7bb38ae2aea1b8 100644 --- a/packages/platform-server/test/hydration_spec.ts +++ b/packages/platform-server/test/hydration_spec.ts @@ -69,10 +69,7 @@ function getComponentRef(appRef: ApplicationRef): ComponentRef { */ function getAppContents(html: string): string { const result = stripUtilAttributes(html, true).match(/(.*?)<\/body>/s); - if (!result) { - throw new Error('Invalid HTML structure is provided.'); - } - return result[1]; + return result ? result[1] : html; } /** @@ -106,7 +103,13 @@ function verifyClientAndSSRContentsMatch(ssrContents: string, clientAppRootEleme const clientContents = stripTransferDataScript(stripUtilAttributes(clientAppRootElement.outerHTML, false)); ssrContents = stripTransferDataScript(stripUtilAttributes(ssrContents, false)); - expect(clientContents).toBe(ssrContents, 'Client and server contents mismatch'); + expect(getAppContents(clientContents)).toBe(ssrContents, 'Client and server contents mismatch'); +} + +/** Checks whether a given element is a