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