diff --git a/packages/core/src/hydration/annotate.ts b/packages/core/src/hydration/annotate.ts index 5243725eba42dd..d88649052d8ed1 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} 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'; @@ -279,11 +279,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/view_container_ref.ts b/packages/core/src/linker/view_container_ref.ts index bc58f9c402ccab..00410c9e99851a 100644 --- a/packages/core/src/linker/view_container_ref.ts +++ b/packages/core/src/linker/view_container_ref.ts @@ -601,6 +601,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 +642,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 +671,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 +699,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..baf8d935a11d3e 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'; @@ -18,11 +21,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 {ɵɵ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 +94,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 +334,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); + + // Add view elements into the DOM if there was no hydration information available + // for this view or this view was in a skip hydration block (which means that the content + // needs to be re-created and inserted into the DOM). + const addToDOM = !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, addToDOM); } } @@ -332,6 +359,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 +487,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..dbf6f620b68616 100644 --- a/packages/core/src/render3/interfaces/node.ts +++ b/packages/core/src/render3/interfaces/node.ts @@ -77,7 +77,7 @@ export const enum TNodeType { // 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: + 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..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/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 d77385589c9d42..dc3e08f0e2026d 100644 --- a/packages/core/test/bundling/hydration/bundle.golden_symbols.json +++ b/packages/core/test/bundling/hydration/bundle.golden_symbols.json @@ -911,6 +911,9 @@ { "name": "hasSkipHydrationAttrOnRElement" }, + { + "name": "hasSkipHydrationAttrOnTNode" + }, { "name": "hostReportError" }, @@ -1148,6 +1151,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 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 () => {