) 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 () => {