From a17516679a12c3590de6bc72c12e605b81b79d08 Mon Sep 17 00:00:00 2001 From: Andrew Kushnir Date: Sun, 27 Aug 2023 16:19:16 -0700 Subject: [PATCH] refactor(core): adjust defer block behavior on the server This commit updates the runtime implementation of defer blocks to avoid their triggering on the server. This behavior was described in the RFC (https://github.com/angular/angular/discussions/50716, see "Server Side Rendering Behavior" section): only a placeholder is rendered on the server at this moment. This commit also updates the logic to make sure that the placeholder content is hydrated after SSR. --- packages/core/src/hydration/annotate.ts | 14 +- packages/core/src/hydration/cleanup.ts | 2 +- packages/core/src/linker/template_ref.ts | 6 +- .../core/src/linker/view_container_ref.ts | 77 ++-- .../core/src/render3/instructions/defer.ts | 33 +- packages/core/src/render3/interfaces/node.ts | 20 +- .../core/src/render3/view_manipulation.ts | 17 +- packages/core/test/acceptance/defer_spec.ts | 11 +- .../hydration/bundle.golden_symbols.json | 6 + .../platform-server/test/hydration_spec.ts | 357 ++++++++++++++++++ 10 files changed, 501 insertions(+), 42 deletions(-) diff --git a/packages/core/src/hydration/annotate.ts b/packages/core/src/hydration/annotate.ts index 74fadaeb3403e0..c94e64b74b42f4 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 842321eaf5baa2..f26837b78f026d 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 7e467fbc7eea83..b64f542d1b7265 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 bc58f9c402ccab..c5173cfa1b40e5 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'; @@ -310,13 +310,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; } @@ -433,21 +430,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!'); @@ -485,7 +478,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); @@ -601,6 +594,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 @@ -625,16 +635,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; @@ -648,7 +664,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. @@ -676,8 +692,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 50d17da892d928..37e42876cd586f 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 b51e350cf76158..1b93ff57469181 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 1f480e6d2536eb..d5684e73387aea 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 473da1d052dbb6..cb983cf4fe0afb 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/hydration/bundle.golden_symbols.json b/packages/core/test/bundling/hydration/bundle.golden_symbols.json index a681d934525cd1..ce7e2382590209 100644 --- a/packages/core/test/bundling/hydration/bundle.golden_symbols.json +++ b/packages/core/test/bundling/hydration/bundle.golden_symbols.json @@ -914,6 +914,9 @@ { "name": "hasSkipHydrationAttrOnRElement" }, + { + "name": "hasSkipHydrationAttrOnTNode" + }, { "name": "hostReportError" }, @@ -1151,6 +1154,9 @@ { "name": "onLeave" }, + { + "name": "populateDehydratedViewsInContainerImpl" + }, { "name": "processInjectorTypesWithProviders" }, diff --git a/packages/platform-server/test/hydration_spec.ts b/packages/platform-server/test/hydration_spec.ts index 2affa61088d75e..87caec9f4b4118 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 () => {