From f6e9369adbbea147b27c86469b113224188ca292 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 | 13 +- packages/core/src/hydration/cleanup.ts | 2 +- .../core/src/linker/view_container_ref.ts | 40 +++++- .../core/src/render3/instructions/defer.ts | 38 +++++- packages/core/src/render3/interfaces/defer.ts | 6 + .../core/src/render3/view_manipulation.ts | 2 +- .../platform-server/test/hydration_spec.ts | 125 ++++++++++++++++++ 7 files changed, 209 insertions(+), 17 deletions(-) diff --git a/packages/core/src/hydration/annotate.ts b/packages/core/src/hydration/annotate.ts index 5243725eba42dd..5625a382b26b14 100644 --- a/packages/core/src/hydration/annotate.ts +++ b/packages/core/src/hydration/annotate.ts @@ -12,6 +12,7 @@ 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 {isTDeferBlockDetailsShape} from '../render3/interfaces/defer'; 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'; @@ -279,11 +280,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. If that's the case, just + // skip this slot and move to the next one. + // - Defer block details object. This slot doesn't require serialization. + if (!tNode || isTDeferBlockDetailsShape(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/view_container_ref.ts b/packages/core/src/linker/view_container_ref.ts index bc58f9c402ccab..56cef00b8ae24f 100644 --- a/packages/core/src/linker/view_container_ref.ts +++ b/packages/core/src/linker/view_container_ref.ts @@ -601,6 +601,20 @@ 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 whether an anchor comment node creation is needed for this container. + */ +export function populateDehydratedViewsInContainer(lContainer: LContainer): boolean { + return _populateDehydratedViewsInContainer(lContainer, getLView(), getCurrentTNode()!); +} /** * Regular creation mode: an anchor is created and @@ -625,16 +639,17 @@ 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. */ -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 false; + } const hydrationInfo = hostLView[HYDRATION]; const noOffsetIndex = hostTNode.index - HEADER_OFFSET; @@ -648,7 +663,7 @@ function locateOrCreateAnchorNode( // Regular creation mode. if (isNodeCreationMode) { - return createAnchorNode(lContainer, hostLView, hostTNode, slotValue); + return true; } // Hydration mode, looking up an anchor node and dehydrated views in DOM. @@ -676,8 +691,19 @@ function locateOrCreateAnchorNode( lContainer[NATIVE] = commentNode as RComment; lContainer[DEHYDRATED_VIEWS] = dehydratedViews; + + return false; +} + +function locateOrCreateAnchorNode( + lContainer: LContainer, hostLView: LView, hostTNode: TNode, slotValue: any): void { + if (_populateDehydratedViewsInContainer(lContainer, hostLView, hostTNode)) { + // Anchor comment node creation was requested 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 e0af79ddf32ffb..6912f67a134a99 100644 --- a/packages/core/src/render3/instructions/defer.ts +++ b/packages/core/src/render3/instructions/defer.ts @@ -7,6 +7,9 @@ */ import {InjectionToken, Injector} from '../../di'; +import {hasInSkipHydrationBlockFlag} from '../../hydration/skip_hydration'; +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'; @@ -19,11 +22,22 @@ 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 {NO_CHANGE} from '../tokens'; +import {isPlatformBrowser} from '../util/misc_utils'; import {getConstant, getTNode, removeLViewOnDestroy, storeLViewOnDestroy} from '../util/view_utils'; import {addLViewToLContainer, createAndRenderEmbeddedLView, removeLViewFromLContainer} 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). @@ -81,6 +95,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; @@ -302,9 +321,17 @@ 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; + + const hydrationInfo = findMatchingDehydratedView(lContainer, tNode.tView!.ssrId); + + // 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(tNode); + removeLViewFromLContainer(lContainer, viewIndex); - const embeddedLView = createAndRenderEmbeddedLView(hostLView, tNode, null); - addLViewToLContainer(lContainer, embeddedLView, viewIndex); + const embeddedLView = createAndRenderEmbeddedLView(hostLView, tNode, null, {hydrationInfo}); + addLViewToLContainer(lContainer, embeddedLView, viewIndex, !skipDomInsertion); } } @@ -319,6 +346,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 @@ -439,8 +468,11 @@ function renderDeferStateAfterResourceLoading( 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. @@ -452,7 +484,7 @@ function triggerDeferBlock(lView: LView, tNode: TNode) { case DeferDependenciesLoadingState.NOT_STARTED: const adjustedIndex = tDetails.primaryTmplIndex + HEADER_OFFSET; const primaryBlockTNode = getTNode(lView[TVIEW], adjustedIndex) as TContainerNode; - triggerResourceLoading(tDetails, primaryBlockTNode, lView[INJECTOR]!); + triggerResourceLoading(tDetails, primaryBlockTNode, injector); // The `loadingState` might have changed to "loading". if ((tDetails.loadingState as DeferDependenciesLoadingState) === diff --git a/packages/core/src/render3/interfaces/defer.ts b/packages/core/src/render3/interfaces/defer.ts index 9fdb0891043c24..694eecc0eaf2f1 100644 --- a/packages/core/src/render3/interfaces/defer.ts +++ b/packages/core/src/render3/interfaces/defer.ts @@ -115,6 +115,12 @@ export const enum DeferBlockInstanceState { ERROR } +/** Helper function to detect if a given value matches a `TDeferBlockDetails` shape */ +export function isTDeferBlockDetailsShape(value: unknown): value is TDeferBlockDetails { + return value != null && typeof value === 'object' && + typeof (value as TDeferBlockDetails).primaryTmplIndex === 'number'; +} + export const DEFER_BLOCK_STATE = 0; /** diff --git a/packages/core/src/render3/view_manipulation.ts b/packages/core/src/render3/view_manipulation.ts index 1f480e6d2536eb..ab0042bfb0c204 100644 --- a/packages/core/src/render3/view_manipulation.ts +++ b/packages/core/src/render3/view_manipulation.ts @@ -21,7 +21,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, hydrationInfo?: DehydratedContainerView|null}): LView { const embeddedTView = templateTNode.tView!; ngDevMode && assertDefined(embeddedTView, 'TView must be defined for a template node.'); ngDevMode && assertTNodeForLView(templateTNode, declarationLView); diff --git a/packages/platform-server/test/hydration_spec.ts b/packages/platform-server/test/hydration_spec.ts index 553e9399230397..ea82e0c1a485d2 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. @@ -2111,6 +2118,124 @@ 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); + expect(clientRootNode.outerHTML).toContain('Hi!'); + }); + + it('should hydrated placeholder block', 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; + } + + const html = await ssr(SimpleComponent); + const ssrContents = getAppContents(html); + expect(ssrContents).toContain('(appRef); + appRef.tick(); + + await whenStable(appRef); + + const clientRootNode = compRef.location.nativeElement; + verifyAllNodesClaimedForHydration(clientRootNode); + verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); + }); + }); + describe('ShadowDom encapsulation', () => { it('should append skip hydration flag if component uses ShadowDom encapsulation', async () => {