From be1999f97826376fceb7ee456690559589b416c5 Mon Sep 17 00:00:00 2001 From: Andrew Kushnir Date: Fri, 28 Jul 2023 17:34:45 -0700 Subject: [PATCH] fix(core): handle hydration of view containers for root components 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 the `ngh` for a root view which holds LContainer). Previously, we were only including component-related information, thus all the views in the view container remained dehydrated and duplicated (client-rendered from scratch) on the client. Resolves #51157. --- packages/core/src/hydration/annotate.ts | 85 ++++- packages/core/src/hydration/cleanup.ts | 19 +- packages/core/src/hydration/utils.ts | 79 +++-- packages/core/src/render3/component_ref.ts | 9 +- .../hydration/bundle.golden_symbols.json | 9 +- .../platform-server/test/hydration_spec.ts | 311 +++++++++++++++++- 6 files changed, 458 insertions(+), 54 deletions(-) diff --git a/packages/core/src/hydration/annotate.ts b/packages/core/src/hydration/annotate.ts index 674ecec3b1531..5243725eba42d 100644 --- a/packages/core/src/hydration/annotate.ts +++ b/packages/core/src/hydration/annotate.ts @@ -8,21 +8,22 @@ 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 {CONTEXT, HEADER_OFFSET, HOST, LView, PARENT, RENDERER, TView, TVIEW, TViewType} from '../render3/interfaces/view'; import {unwrapRNode} from '../render3/util/view_utils'; import {TransferState} from '../transfer_state'; import {unsupportedProjectionOfDomNodes} from './error_handling'; import {CONTAINERS, DISCONNECTED_NODES, ELEMENT_CONTAINERS, MULTIPLIER, NODES, NUM_ROOT_NODES, SerializedContainerView, SerializedView, TEMPLATE_ID, TEMPLATES} from './interfaces'; import {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 {isInSkipHydrationBlock, SKIP_HYDRATION_ATTR_NAME} from './skip_hydration'; +import {getLNodeForHydration, NGH_ATTR_NAME, NGH_DATA_KEY, TextNodeMarker} from './utils'; /** * A collection that tracks all serialized views (`ngh` DOM annotations) @@ -91,6 +92,54 @@ function calcNumRootNodes(tView: TView, lView: LView, tNode: TNode|null): number return rootNodes.length; } +/** + * Annotates root level component's LView for hydration, + * see `annotateHostElementForHydration` for additional information. + */ +function annotateComponentLViewForHydration(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; +} + +/** + * Annotates root level LContainer for hydration. This happens when a root component + * injects ViewContainerRef, thus making the component an anchor for a view container. + * This function serializes the component itself as well as all views from the view + * container. + */ +function annotateLContainerForHydration(lContainer: LContainer, context: HydrationContext) { + const componentLView = lContainer[HOST] as LView; + + // Serialize the root component itself. + const componentLViewNghIndex = annotateComponentLViewForHydration(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); + + const renderer = componentLView[RENDERER] as Renderer2; + + // 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 the `ngh` for a root view which holds LContainer). + 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 +152,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 { + annotateComponentLViewForHydration(lNode, context); } + insertCorruptedTextNodeMarkers(corruptedTextNodes, doc); } } @@ -408,9 +457,11 @@ function componentUsesShadowDomEncapsulation(lView: LView): boolean { * @param element The Host element to be annotated * @param lView The associated LView * @param context The hydration context + * @returns An index of serialized view from the transfer state object + * or `null` when a given component can not be serialized. */ 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 +470,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 4f897bfb17a02..842321eaf5baa 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,20 @@ 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 { + // Cleanup in the root component view + const componentLView = lNode[HOST] as LView; + cleanupLView(componentLView); + + // Cleanup in all views within this view container + cleanupLContainer(lNode); + } ngDevMode && ngDevMode.dehydratedViewsCleanupRuns++; } } diff --git a/packages/core/src/hydration/utils.ts b/packages/core/src/hydration/utils.ts index 900e59d53b1b8..a6980bf371990 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'; @@ -69,15 +70,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 trying 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 the `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 @@ -101,9 +121,31 @@ 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; + + // We use `0` here, since this is the slot (right after the HEADER_OFFSET) + // where a component LView or an LContainer is located in a root LView. + 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 for all indices. + 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 @@ -125,15 +167,18 @@ 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 the necessary object from a given ViewRef to serialize: + * - an LView for component views + * - an LContainer for cases when component acts as a ViewContainerRef anchor + * - `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]; @@ -148,12 +193,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 7f83210259c4d..560bbeedda6b2 100644 --- a/packages/core/src/render3/component_ref.ts +++ b/packages/core/src/render3/component_ref.ts @@ -215,12 +215,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 @@ -382,7 +387,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/core/test/bundling/hydration/bundle.golden_symbols.json b/packages/core/test/bundling/hydration/bundle.golden_symbols.json index 9d5f7bae9cef9..d77385589c9d4 100644 --- a/packages/core/test/bundling/hydration/bundle.golden_symbols.json +++ b/packages/core/test/bundling/hydration/bundle.golden_symbols.json @@ -788,9 +788,6 @@ { "name": "getComponentLViewByIndex" }, - { - "name": "getComponentLViewForHydration" - }, { "name": "getCurrentTNode" }, @@ -827,6 +824,9 @@ { "name": "getInjectorIndex" }, + { + "name": "getLNodeForHydration" + }, { "name": "getLView" }, @@ -1235,6 +1235,9 @@ { "name": "setInjectImplementation" }, + { + "name": "setSegmentHead" + }, { "name": "setSelectedIndex" }, diff --git a/packages/platform-server/test/hydration_spec.ts b/packages/platform-server/test/hydration_spec.ts index c49d3eec9d769..553e939923039 100644 --- a/packages/platform-server/test/hydration_spec.ts +++ b/packages/platform-server/test/hydration_spec.ts @@ -70,10 +70,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; } /** @@ -108,11 +105,22 @@ function whenStable(appRef: ApplicationRef): Promise { } function verifyClientAndSSRContentsMatch(ssrContents: string, clientAppRootElement: HTMLElement) { - const clientContents = - stripTransferDataScript(stripUtilAttributes(clientAppRootElement.outerHTML, false)); + const clientContents = stripSsrIntegrityMarker( + stripTransferDataScript(stripUtilAttributes(clientAppRootElement.outerHTML, false))); ssrContents = stripSsrIntegrityMarker(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