diff --git a/packages/core/src/hydration/annotate.ts b/packages/core/src/hydration/annotate.ts index 74fadaeb3403e..c94e64b74b42f 100644 --- a/packages/core/src/hydration/annotate.ts +++ b/packages/core/src/hydration/annotate.ts @@ -12,7 +12,7 @@ import {Renderer2} from '../render'; import {collectNativeNodes, collectNativeNodesInLContainer} 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 {isTNodeShape, TNode, TNodeType} from '../render3/interfaces/node'; import {RElement} from '../render3/interfaces/renderer_dom'; import {hasI18n, isComponentHost, isLContainer, isProjectionTNode, isRootView} from '../render3/interfaces/type_checks'; import {CONTEXT, HEADER_OFFSET, HOST, LView, PARENT, RENDERER, TView, TVIEW, TViewType} from '../render3/interfaces/view'; @@ -315,11 +315,13 @@ function serializeLView(lView: LView, context: HydrationContext): SerializedView for (let i = HEADER_OFFSET; i < tView.bindingStartIndex; i++) { const tNode = tView.data[i] as TNode; const noOffsetIndex = i - HEADER_OFFSET; - // Local refs (e.g.
) take up an extra slot in LViews - // to store the same element. In this case, there is no information in - // a corresponding slot in TNode data structure. If that's the case, just - // skip this slot and move to the next one. - if (!tNode) { + // Skip processing of a given slot in the following cases: + // - Local refs (e.g.
) take up an extra slot in LViews + // to store the same element. In this case, there is no information in + // a corresponding slot in TNode data structure. + // - When a slot contains something other than a TNode. For example, there + // might be some metadata information about a defer block or a control flow block. + if (!isTNodeShape(tNode)) { continue; } diff --git a/packages/core/src/hydration/cleanup.ts b/packages/core/src/hydration/cleanup.ts index 842321eaf5baa..f26837b78f026 100644 --- a/packages/core/src/hydration/cleanup.ts +++ b/packages/core/src/hydration/cleanup.ts @@ -78,7 +78,7 @@ function cleanupLView(lView: LView) { if (isLContainer(lView[i])) { const lContainer = lView[i]; cleanupLContainer(lContainer); - } else if (Array.isArray(lView[i])) { + } else if (isLView(lView[i])) { // This is a component, enter the `cleanupLView` recursively. cleanupLView(lView[i]); } diff --git a/packages/core/src/linker/template_ref.ts b/packages/core/src/linker/template_ref.ts index 7e467fbc7eea8..b64f542d1b726 100644 --- a/packages/core/src/linker/template_ref.ts +++ b/packages/core/src/linker/template_ref.ts @@ -71,7 +71,7 @@ export abstract class TemplateRef { */ abstract createEmbeddedViewImpl( context: C, injector?: Injector, - hydrationInfo?: DehydratedContainerView|null): EmbeddedViewRef; + dehydratedView?: DehydratedContainerView|null): EmbeddedViewRef; /** * Returns an `ssrId` associated with a TView, which was used to @@ -118,9 +118,9 @@ const R3TemplateRef = class TemplateRef extends ViewEngineTemplateRef { */ override createEmbeddedViewImpl( context: T, injector?: Injector, - hydrationInfo?: DehydratedContainerView): EmbeddedViewRef { + dehydratedView?: DehydratedContainerView): EmbeddedViewRef { const embeddedLView = createAndRenderEmbeddedLView( - this._declarationLView, this._declarationTContainer, context, {injector, hydrationInfo}); + this._declarationLView, this._declarationTContainer, context, {injector, dehydratedView}); return new R3_ViewRef(embeddedLView); } }; diff --git a/packages/core/src/linker/view_container_ref.ts b/packages/core/src/linker/view_container_ref.ts index 0318cb3f9e075..f0ccee9aceba0 100644 --- a/packages/core/src/linker/view_container_ref.ts +++ b/packages/core/src/linker/view_container_ref.ts @@ -30,7 +30,7 @@ import {destroyLView, detachView, nativeInsertBefore, nativeNextSibling, nativeP import {getCurrentTNode, getLView} from '../render3/state'; import {getParentInjectorIndex, getParentInjectorView, hasParentInjector} from '../render3/util/injector_utils'; import {getNativeByTNode, unwrapRNode, viewAttachedToContainer} from '../render3/util/view_utils'; -import {addLViewToLContainer} from '../render3/view_manipulation'; +import {addLViewToLContainer, shouldAddViewToDom} from '../render3/view_manipulation'; import {ViewRef as R3ViewRef} from '../render3/view_ref'; import {addToArray, removeFromArray} from '../util/array_utils'; import {assertDefined, assertEqual, assertGreaterThan, assertLessThan, throwError} from '../util/assert'; @@ -344,13 +344,10 @@ const R3ViewContainerRef = class ViewContainerRef extends VE_ViewContainerRef { injector = indexOrOptions.injector; } - const hydrationInfo = findMatchingDehydratedView(this._lContainer, templateRef.ssrId); - const viewRef = templateRef.createEmbeddedViewImpl(context || {}, injector, hydrationInfo); - // If there is a matching dehydrated view, but the host TNode is located in the skip - // hydration block, this means that the content was detached (as a part of the skip - // hydration logic) and it needs to be appended into the DOM. - const skipDomInsertion = !!hydrationInfo && !hasInSkipHydrationBlockFlag(this._hostTNode); - this.insertImpl(viewRef, index, skipDomInsertion); + const dehydratedView = findMatchingDehydratedView(this._lContainer, templateRef.ssrId); + const viewRef = + templateRef.createEmbeddedViewImpl(context || {}, injector, dehydratedView); + this.insertImpl(viewRef, index, shouldAddViewToDom(this._hostTNode, dehydratedView)); return viewRef; } @@ -467,21 +464,17 @@ const R3ViewContainerRef = class ViewContainerRef extends VE_ViewContainerRef { const rNode = dehydratedView?.firstChild ?? null; const componentRef = componentFactory.create(contextInjector, projectableNodes, rNode, environmentInjector); - // If there is a matching dehydrated view, but the host TNode is located in the skip - // hydration block, this means that the content was detached (as a part of the skip - // hydration logic) and it needs to be appended into the DOM. - const skipDomInsertion = !!dehydratedView && !hasInSkipHydrationBlockFlag(this._hostTNode); - this.insertImpl(componentRef.hostView, index, skipDomInsertion); + this.insertImpl( + componentRef.hostView, index, shouldAddViewToDom(this._hostTNode, dehydratedView)); return componentRef; } override insert(viewRef: ViewRef, index?: number): ViewRef { - return this.insertImpl(viewRef, index, false); + return this.insertImpl(viewRef, index, true); } - private insertImpl(viewRef: ViewRef, index?: number, skipDomInsertion?: boolean): ViewRef { + private insertImpl(viewRef: ViewRef, index?: number, addToDOM?: boolean): ViewRef { const lView = (viewRef as R3ViewRef)._lView!; - const tView = lView[TVIEW]; if (ngDevMode && viewRef.destroyed) { throw new Error('Cannot insert a destroyed View in a ViewContainer!'); @@ -519,7 +512,7 @@ const R3ViewContainerRef = class ViewContainerRef extends VE_ViewContainerRef { const adjustedIdx = this._adjustIndex(index); const lContainer = this._lContainer; - addLViewToLContainer(lContainer, lView, adjustedIdx, !skipDomInsertion); + addLViewToLContainer(lContainer, lView, adjustedIdx, addToDOM); (viewRef as R3ViewRef).attachToViewContainerRef(); addToArray(getOrCreateViewRefs(lContainer), adjustedIdx, viewRef); @@ -635,6 +628,23 @@ function insertAnchorNode(hostLView: LView, hostTNode: TNode): RComment { } let _locateOrCreateAnchorNode = createAnchorNode; +let _populateDehydratedViewsInContainer: typeof populateDehydratedViewsInContainerImpl = + (lContainer: LContainer, lView: LView, tNode: TNode) => false; // noop by default + +/** + * Looks up dehydrated views that belong to a given LContainer and populates + * this information into the `LContainer[DEHYDRATED_VIEWS]` slot. When running + * in client-only mode, this function is a noop. + * + * @param lContainer LContainer that should be populated. + * @returns a boolean flag that indicates whether a populating operation + * was successful. The operation might be unsuccessful in case is has completed + * previously, we are rendering in client-only mode or this content is located + * in a skip hydration section. + */ +export function populateDehydratedViewsInContainer(lContainer: LContainer): boolean { + return _populateDehydratedViewsInContainer(lContainer, getLView(), getCurrentTNode()!); +} /** * Regular creation mode: an anchor is created and @@ -659,16 +669,22 @@ function createAnchorNode( } /** - * Hydration logic that looks up: - * - an anchor node in the DOM and stores the node in `lContainer[NATIVE]` - * - all dehydrated views in this container and puts them into `lContainer[DEHYDRATED_VIEWS]` + * Hydration logic that looks up all dehydrated views in this container + * and puts them into `lContainer[DEHYDRATED_VIEWS]` slot. + * + * @returns a boolean flag that indicates whether a populating operation + * was successful. The operation might be unsuccessful in case is has completed + * previously, we are rendering in client-only mode or this content is located + * in a skip hydration section. */ -function locateOrCreateAnchorNode( - lContainer: LContainer, hostLView: LView, hostTNode: TNode, slotValue: any) { +function populateDehydratedViewsInContainerImpl( + lContainer: LContainer, hostLView: LView, hostTNode: TNode): boolean { // We already have a native element (anchor) set and the process // of finding dehydrated views happened (so the `lContainer[DEHYDRATED_VIEWS]` // is not null), exit early. - if (lContainer[NATIVE] && lContainer[DEHYDRATED_VIEWS]) return; + if (lContainer[NATIVE] && lContainer[DEHYDRATED_VIEWS]) { + return true; + } const hydrationInfo = hostLView[HYDRATION]; const noOffsetIndex = hostTNode.index - HEADER_OFFSET; @@ -682,7 +698,7 @@ function locateOrCreateAnchorNode( // Regular creation mode. if (isNodeCreationMode) { - return createAnchorNode(lContainer, hostLView, hostTNode, slotValue); + return false; } // Hydration mode, looking up an anchor node and dehydrated views in DOM. @@ -710,8 +726,21 @@ function locateOrCreateAnchorNode( lContainer[NATIVE] = commentNode as RComment; lContainer[DEHYDRATED_VIEWS] = dehydratedViews; + + return true; +} + +function locateOrCreateAnchorNode( + lContainer: LContainer, hostLView: LView, hostTNode: TNode, slotValue: any): void { + if (!_populateDehydratedViewsInContainer(lContainer, hostLView, hostTNode)) { + // Populating dehydrated views operation returned `false`, which indicates + // that the logic was running in client-only mode, this an anchor comment + // node should be created for this container. + createAnchorNode(lContainer, hostLView, hostTNode, slotValue); + } } export function enableLocateOrCreateContainerRefImpl() { _locateOrCreateAnchorNode = locateOrCreateAnchorNode; + _populateDehydratedViewsInContainer = populateDehydratedViewsInContainerImpl; } diff --git a/packages/core/src/render3/instructions/defer.ts b/packages/core/src/render3/instructions/defer.ts index 50d17da892d92..37e42876cd586 100644 --- a/packages/core/src/render3/instructions/defer.ts +++ b/packages/core/src/render3/instructions/defer.ts @@ -7,6 +7,8 @@ */ import {InjectionToken, Injector} from '../../di'; +import {findMatchingDehydratedView} from '../../hydration/views'; +import {populateDehydratedViewsInContainer} from '../../linker/view_container_ref'; import {assertDefined, assertEqual, throwError} from '../../util/assert'; import {assertIndexInDeclRange, assertLContainer, assertTNodeForLView} from '../assert'; import {bindingUpdated} from '../bindings'; @@ -18,11 +20,22 @@ import {TContainerNode, TNode} from '../interfaces/node'; import {isDestroyed} from '../interfaces/type_checks'; import {HEADER_OFFSET, INJECTOR, LView, PARENT, TVIEW, TView} from '../interfaces/view'; import {getCurrentTNode, getLView, getSelectedTNode, getTView, nextBindingIndex} from '../state'; +import {isPlatformBrowser} from '../util/misc_utils'; import {getConstant, getTNode, removeLViewOnDestroy, storeLViewOnDestroy} from '../util/view_utils'; -import {addLViewToLContainer, createAndRenderEmbeddedLView, removeLViewFromLContainer} from '../view_manipulation'; +import {addLViewToLContainer, createAndRenderEmbeddedLView, removeLViewFromLContainer, shouldAddViewToDom} from '../view_manipulation'; import {ɵɵtemplate} from './template'; +/** + * Returns whether defer blocks should be triggered. + * + * Currently, defer blocks are not triggered on the server, + * only placeholder content is rendered (if provided). + */ +function shouldTriggerDeferBlock(injector: Injector): boolean { + return isPlatformBrowser(injector); +} + /** * Shims for the `requestIdleCallback` and `cancelIdleCallback` functions for environments * where those functions are not available (e.g. Node.js). @@ -80,6 +93,11 @@ export function ɵɵdefer( setTDeferBlockDetails(tView, adjustedIndex, deferBlockConfig); } + // Lookup dehydrated views that belong to this LContainer. + // In client-only mode, this operation is noop. + const lContainer = lView[adjustedIndex]; + populateDehydratedViewsInContainer(lContainer); + // Init instance-specific defer details and store it. const lDetails = []; lDetails[DEFER_BLOCK_STATE] = DeferBlockInstanceState.INITIAL; @@ -315,9 +333,13 @@ function renderDeferBlockState( // There is only 1 view that can be present in an LContainer that // represents a `{#defer}` block, so always refer to the first one. const viewIndex = 0; + removeLViewFromLContainer(lContainer, viewIndex); - const embeddedLView = createAndRenderEmbeddedLView(hostLView, tNode, null); - addLViewToLContainer(lContainer, embeddedLView, viewIndex); + + const dehydratedView = findMatchingDehydratedView(lContainer, tNode.tView!.ssrId); + const embeddedLView = createAndRenderEmbeddedLView(hostLView, tNode, null, {dehydratedView}); + addLViewToLContainer( + lContainer, embeddedLView, viewIndex, shouldAddViewToDom(tNode, dehydratedView)); } } @@ -332,6 +354,8 @@ function triggerResourceLoading( tDetails: TDeferBlockDetails, primaryBlockTNode: TNode, injector: Injector) { const tView = primaryBlockTNode.tView!; + if (!shouldTriggerDeferBlock(injector)) return; + if (tDetails.loadingState !== DeferDependenciesLoadingState.NOT_STARTED) { // If the loading status is different from initial one, it means that // the loading of dependencies is in progress and there is nothing to do @@ -458,8 +482,11 @@ function getPrimaryBlockTNode(tView: TView, tDetails: TDeferBlockDetails): TCont function triggerDeferBlock(lView: LView, tNode: TNode) { const tView = lView[TVIEW]; const lContainer = lView[tNode.index]; + const injector = lView[INJECTOR]!; ngDevMode && assertLContainer(lContainer); + if (!shouldTriggerDeferBlock(injector)) return; + const tDetails = getTDeferBlockDetails(tView, tNode); // Condition is triggered, try to render loading state and start downloading. diff --git a/packages/core/src/render3/interfaces/node.ts b/packages/core/src/render3/interfaces/node.ts index b51e350cf7615..1b93ff5746918 100644 --- a/packages/core/src/render3/interfaces/node.ts +++ b/packages/core/src/render3/interfaces/node.ts @@ -76,8 +76,8 @@ export const enum TNodeType { // if `TNode.type` is one of several choices. // See: https://github.com/microsoft/TypeScript/issues/35875 why we can't refer to existing enum. - AnyRNode = 0b11, // Text | Element, - AnyContainer = 0b1100, // Container | ElementContainer, // See: + AnyRNode = 0b11, // Text | Element + AnyContainer = 0b1100, // Container | ElementContainer } /** @@ -96,6 +96,22 @@ export function toTNodeTypeAsString(tNodeType: TNodeType): string { return text.length > 0 ? text.substring(1) : text; } +/** + * Helper function to detect if a given value matches a `TNode` shape. + * + * The logic uses the `insertBeforeIndex` and its possible values as + * a way to differentiate a TNode shape from other types of objects + * within the `TView.data`. This is not a perfect check, but it can + * be a reasonable differentiator, since we control the shapes of objects + * within `TView.data`. + */ +export function isTNodeShape(value: unknown): value is TNode { + return value != null && typeof value === 'object' && + ((value as TNode).insertBeforeIndex === null || + typeof (value as TNode).insertBeforeIndex === 'number' || + Array.isArray((value as TNode).insertBeforeIndex)); +} + /** * Corresponds to the TNode.flags property. */ diff --git a/packages/core/src/render3/view_manipulation.ts b/packages/core/src/render3/view_manipulation.ts index 1f480e6d2536e..d5684e73387ae 100644 --- a/packages/core/src/render3/view_manipulation.ts +++ b/packages/core/src/render3/view_manipulation.ts @@ -8,6 +8,7 @@ import {Injector} from '../di/injector'; import {DehydratedContainerView} from '../hydration/interfaces'; +import {hasInSkipHydrationBlockFlag} from '../hydration/skip_hydration'; import {assertDefined} from '../util/assert'; import {assertLContainer, assertLView, assertTNodeForLView} from './assert'; @@ -21,7 +22,7 @@ import {addViewToDOM, destroyLView, detachView, getBeforeNodeForView, insertView export function createAndRenderEmbeddedLView( declarationLView: LView, templateTNode: TNode, context: T, - options?: {injector?: Injector, hydrationInfo?: DehydratedContainerView}): LView { + options?: {injector?: Injector, dehydratedView?: DehydratedContainerView|null}): LView { const embeddedTView = templateTNode.tView!; ngDevMode && assertDefined(embeddedTView, 'TView must be defined for a template node.'); ngDevMode && assertTNodeForLView(templateTNode, declarationLView); @@ -31,7 +32,7 @@ export function createAndRenderEmbeddedLView( const viewFlags = isSignalView ? LViewFlags.SignalView : LViewFlags.CheckAlways; const embeddedLView = createLView( declarationLView, embeddedTView, context, viewFlags, null, templateTNode, null, null, null, - options?.injector ?? null, options?.hydrationInfo ?? null); + options?.injector ?? null, options?.dehydratedView ?? null); const declarationLContainer = declarationLView[templateTNode.index]; ngDevMode && assertLContainer(declarationLContainer); @@ -60,6 +61,18 @@ export function getLViewFromLContainer(lContainer: LContainer, index: number) return undefined; } +/** + * Returns whether an elements that belong to a view should be + * inserted into the DOM. For client-only cases, DOM elements are + * always inserted. For hydration cases, we check whether serialized + * info is available for a view and the view is not in a "skip hydration" + * block (in which case view contents was re-created, thus needing insertion). + */ +export function shouldAddViewToDom( + tNode: TNode, dehydratedView?: DehydratedContainerView|null): boolean { + return !dehydratedView || hasInSkipHydrationBlockFlag(tNode); +} + export function addLViewToLContainer( lContainer: LContainer, lView: LView, index: number, addToDOM = true): void { const tView = lView[TVIEW]; diff --git a/packages/core/test/acceptance/defer_spec.ts b/packages/core/test/acceptance/defer_spec.ts index 473da1d052dbb..cb983cf4fe0af 100644 --- a/packages/core/test/acceptance/defer_spec.ts +++ b/packages/core/test/acceptance/defer_spec.ts @@ -6,8 +6,9 @@ * found in the LICENSE file at https://angular.io/license */ +import {ɵPLATFORM_BROWSER_ID as PLATFORM_BROWSER_ID} from '@angular/common'; import {ɵsetEnabledBlockTypes as setEnabledBlockTypes} from '@angular/compiler/src/jit_compiler_facade'; -import {Component, Input, QueryList, Type, ViewChildren, ɵDEFER_BLOCK_DEPENDENCY_INTERCEPTOR} from '@angular/core'; +import {Component, Input, PLATFORM_ID, QueryList, Type, ViewChildren, ɵDEFER_BLOCK_DEPENDENCY_INTERCEPTOR} from '@angular/core'; import {getComponentDef} from '@angular/core/src/render3/definition'; import {TestBed} from '@angular/core/testing'; @@ -26,10 +27,18 @@ function clearDirectiveDefs(type: Type): void { cmpDef!.directiveDefs = null; } +// Set `PLATFORM_ID` to a browser platform value to trigger defer loading +// while running tests in Node. +const COMMON_PROVIDERS = [{provide: PLATFORM_ID, useValue: PLATFORM_BROWSER_ID}]; + describe('#defer', () => { beforeEach(() => setEnabledBlockTypes(['defer'])); afterEach(() => setEnabledBlockTypes([])); + beforeEach(() => { + TestBed.configureTestingModule({providers: COMMON_PROVIDERS}); + }); + it('should transition between placeholder, loading and loaded states', async () => { @Component({ selector: 'my-lazy-cmp', diff --git a/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json b/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json index f2e6a7b64f272..e577db5b6817c 100644 --- a/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json +++ b/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json @@ -1169,6 +1169,9 @@ { "name": "handleError" }, + { + "name": "hasInSkipHydrationBlockFlag" + }, { "name": "hasParentInjector" }, @@ -1676,6 +1679,9 @@ { "name": "shimStylesContent" }, + { + "name": "shouldAddViewToDom" + }, { "name": "shouldSearchParent" }, diff --git a/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json b/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json index 76973d47a79c9..cd07111a53068 100644 --- a/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json +++ b/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json @@ -1133,6 +1133,9 @@ { "name": "handleError" }, + { + "name": "hasInSkipHydrationBlockFlag" + }, { "name": "hasParentInjector" }, @@ -1652,6 +1655,9 @@ { "name": "shimStylesContent" }, + { + "name": "shouldAddViewToDom" + }, { "name": "shouldSearchParent" }, diff --git a/packages/core/test/bundling/hydration/bundle.golden_symbols.json b/packages/core/test/bundling/hydration/bundle.golden_symbols.json index b03c388da19c8..8da0717556ff0 100644 --- a/packages/core/test/bundling/hydration/bundle.golden_symbols.json +++ b/packages/core/test/bundling/hydration/bundle.golden_symbols.json @@ -932,6 +932,9 @@ { "name": "hasSkipHydrationAttrOnRElement" }, + { + "name": "hasSkipHydrationAttrOnTNode" + }, { "name": "hostReportError" }, @@ -1169,6 +1172,9 @@ { "name": "onLeave" }, + { + "name": "populateDehydratedViewsInContainerImpl" + }, { "name": "processInjectorTypesWithProviders" }, diff --git a/packages/core/test/bundling/router/bundle.golden_symbols.json b/packages/core/test/bundling/router/bundle.golden_symbols.json index 8189bbb1f0dd8..6ffd6cf414ce0 100644 --- a/packages/core/test/bundling/router/bundle.golden_symbols.json +++ b/packages/core/test/bundling/router/bundle.golden_symbols.json @@ -1439,6 +1439,9 @@ { "name": "hasEmptyPathConfig" }, + { + "name": "hasInSkipHydrationBlockFlag" + }, { "name": "hasParentInjector" }, @@ -1952,6 +1955,9 @@ { "name": "shimStylesContent" }, + { + "name": "shouldAddViewToDom" + }, { "name": "shouldSearchParent" }, diff --git a/packages/core/test/bundling/todo/bundle.golden_symbols.json b/packages/core/test/bundling/todo/bundle.golden_symbols.json index 5edf1a7890ddf..41858b888ff35 100644 --- a/packages/core/test/bundling/todo/bundle.golden_symbols.json +++ b/packages/core/test/bundling/todo/bundle.golden_symbols.json @@ -1028,6 +1028,9 @@ { "name": "handleError" }, + { + "name": "hasInSkipHydrationBlockFlag" + }, { "name": "hasParentInjector" }, @@ -1442,6 +1445,9 @@ { "name": "shimStylesContent" }, + { + "name": "shouldAddViewToDom" + }, { "name": "shouldSearchParent" }, diff --git a/packages/platform-server/test/hydration_spec.ts b/packages/platform-server/test/hydration_spec.ts index 2affa61088d75..87caec9f4b411 100644 --- a/packages/platform-server/test/hydration_spec.ts +++ b/packages/platform-server/test/hydration_spec.ts @@ -10,6 +10,7 @@ import '@angular/localize/init'; import {CommonModule, DOCUMENT, isPlatformServer, NgComponentOutlet, NgFor, NgIf, NgTemplateOutlet, PlatformLocation} from '@angular/common'; import {MockPlatformLocation} from '@angular/common/testing'; +import {ɵsetEnabledBlockTypes as setEnabledBlockTypes} from '@angular/compiler/src/jit_compiler_facade'; import {afterRender, ApplicationRef, Component, ComponentRef, createComponent, destroyPlatform, Directive, ElementRef, EnvironmentInjector, ErrorHandler, getPlatform, inject, Injectable, Input, NgZone, PLATFORM_ID, Provider, TemplateRef, Type, ViewChild, ViewContainerRef, ViewEncapsulation, ɵsetDocument} from '@angular/core'; import {Console} from '@angular/core/src/console'; import {SSR_CONTENT_INTEGRITY_MARKER} from '@angular/core/src/hydration/utils'; @@ -232,6 +233,12 @@ function withDebugConsole() { } describe('platform-server hydration integration', () => { + // Keep those `beforeEach` and `afterEach` blocks separate, + // since we'll need to remove them once new control flow + // syntax is enabled by default. + beforeEach(() => setEnabledBlockTypes(['defer'])); + afterEach(() => setEnabledBlockTypes([])); + beforeEach(() => { if (typeof ngDevMode === 'object') { // Reset all ngDevMode counters. @@ -2231,6 +2238,356 @@ describe('platform-server hydration integration', () => { }); }); + describe('defer blocks', () => { + it('should not trigger defer blocks on the server', async () => { + @Component({ + selector: 'my-lazy-cmp', + standalone: true, + template: 'Hi!', + }) + class MyLazyCmp { + } + + @Component({ + standalone: true, + selector: 'app', + imports: [MyLazyCmp], + template: ` + Visible: {{ isVisible }}. + + {#defer when isVisible} + + {:loading} + Loading... + {:placeholder} + Placeholder! + {:error} + Failed to load dependencies :( + {/defer} + ` + }) + class SimpleComponent { + isVisible = false; + + ngOnInit() { + setTimeout(() => { + // This changes the triggering condition of the defer block, + // but it should be ignored and the placeholder content should be visible. + this.isVisible = true; + }); + } + } + + const html = await ssr(SimpleComponent); + const ssrContents = getAppContents(html); + expect(ssrContents).toContain('(appRef); + appRef.tick(); + + await whenStable(appRef); + + const clientRootNode = compRef.location.nativeElement; + + // This content is rendered only on the client, since it's + // inside a defer block. + const innerComponent = clientRootNode.querySelector('my-lazy-cmp'); + const exceptions = [innerComponent]; + + verifyAllNodesClaimedForHydration(clientRootNode, exceptions); + + // Verify that defer block renders correctly after hydration and triggering + // loading condition. + expect(clientRootNode.outerHTML).toContain('Hi!'); + }); + + it('should hydrate a placeholder block', async () => { + @Component({ + selector: 'my-lazy-cmp', + standalone: true, + template: 'Hi!', + }) + class MyLazyCmp { + } + + @Component({ + selector: 'my-placeholder-cmp', + standalone: true, + imports: [NgIf], + template: '
Hi!
', + }) + class MyPlaceholderCmp { + } + + @Component({ + standalone: true, + selector: 'app', + imports: [MyLazyCmp, MyPlaceholderCmp], + template: ` + Visible: {{ isVisible }}. + + {#defer when isVisible} + + {:loading} + Loading... + {:placeholder} + Placeholder! + + {:error} + Failed to load dependencies :( + {/defer} + ` + }) + class SimpleComponent { + isVisible = false; + } + + const html = await ssr(SimpleComponent); + const ssrContents = getAppContents(html); + + expect(ssrContents).toContain('
Hi!
'); + + resetTViewsFor(SimpleComponent, MyPlaceholderCmp); + + const appRef = await hydrate(html, SimpleComponent); + const compRef = getComponentRef(appRef); + appRef.tick(); + + await whenStable(appRef); + + const clientRootNode = compRef.location.nativeElement; + verifyAllNodesClaimedForHydration(clientRootNode); + verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); + }); + + it('should render nothing on the server if no placeholder block is provided', async () => { + @Component({ + selector: 'my-lazy-cmp', + standalone: true, + template: 'Hi!', + }) + class MyLazyCmp { + } + + @Component({ + selector: 'my-placeholder-cmp', + standalone: true, + imports: [NgIf], + template: '
Hi!
', + }) + class MyPlaceholderCmp { + } + + @Component({ + standalone: true, + selector: 'app', + imports: [MyLazyCmp, MyPlaceholderCmp], + template: ` + Before|{#defer when isVisible}{/defer}|After + ` + }) + class SimpleComponent { + isVisible = false; + } + + const html = await ssr(SimpleComponent); + const ssrContents = getAppContents(html); + + expect(ssrContents).toContain('|After'); + + resetTViewsFor(SimpleComponent, MyPlaceholderCmp); + + const appRef = await hydrate(html, SimpleComponent); + const compRef = getComponentRef(appRef); + appRef.tick(); + + await whenStable(appRef); + + const clientRootNode = compRef.location.nativeElement; + verifyAllNodesClaimedForHydration(clientRootNode); + verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); + }); + + it('should not hydrate when an entire block in skip hydration section', async () => { + @Component({ + selector: 'my-lazy-cmp', + standalone: true, + template: 'Hi!', + }) + class MyLazyCmp { + } + + @Component({ + standalone: true, + selector: 'projector-cmp', + template: ` +
+ +
+ `, + }) + class ProjectorCmp { + } + + @Component({ + selector: 'my-placeholder-cmp', + standalone: true, + imports: [NgIf], + template: '
Hi!
', + }) + class MyPlaceholderCmp { + } + + @Component({ + standalone: true, + selector: 'app', + imports: [MyLazyCmp, MyPlaceholderCmp, ProjectorCmp], + template: ` + Visible: {{ isVisible }}. + + + {#defer when isVisible} + + {:loading} + Loading... + {:placeholder} + + {:error} + Failed to load dependencies :( + {/defer} + + ` + }) + class SimpleComponent { + isVisible = false; + } + + const html = await ssr(SimpleComponent); + const ssrContents = getAppContents(html); + + expect(ssrContents).toContain('(appRef); + appRef.tick(); + + await whenStable(appRef); + + const clientRootNode = compRef.location.nativeElement; + + // Verify that placeholder nodes were not claimed for hydration, + // i.e. nodes were re-created since placeholder was in skip hydration block. + const placeholderCmp = clientRootNode.querySelector('my-placeholder-cmp'); + verifyNoNodesWereClaimedForHydration(placeholderCmp); + + verifyAllNodesClaimedForHydration(clientRootNode); + verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); + }); + + + it('should not hydrate when a placeholder block in skip hydration section', async () => { + @Component({ + selector: 'my-lazy-cmp', + standalone: true, + template: 'Hi!', + }) + class MyLazyCmp { + } + + @Component({ + standalone: true, + selector: 'projector-cmp', + template: ` +
+ +
+ `, + }) + class ProjectorCmp { + } + + @Component({ + selector: 'my-placeholder-cmp', + standalone: true, + imports: [NgIf], + template: '
Hi!
', + }) + class MyPlaceholderCmp { + } + + @Component({ + standalone: true, + selector: 'app', + imports: [MyLazyCmp, MyPlaceholderCmp, ProjectorCmp], + template: ` + Visible: {{ isVisible }}. + + + {#defer when isVisible} + + {:loading} + Loading... + {:placeholder} + + {:error} + Failed to load dependencies :( + {/defer} + + ` + }) + class SimpleComponent { + isVisible = false; + } + + const html = await ssr(SimpleComponent); + const ssrContents = getAppContents(html); + + expect(ssrContents).toContain('(appRef); + appRef.tick(); + + await whenStable(appRef); + + const clientRootNode = compRef.location.nativeElement; + + // Verify that placeholder nodes were not claimed for hydration, + // i.e. nodes were re-created since placeholder was in skip hydration block. + const placeholderCmp = clientRootNode.querySelector('my-placeholder-cmp'); + verifyNoNodesWereClaimedForHydration(placeholderCmp); + + verifyAllNodesClaimedForHydration(clientRootNode); + verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); + }); + }); + describe('ShadowDom encapsulation', () => { it('should append skip hydration flag if component uses ShadowDom encapsulation', async () => {