Skip to content

Commit

Permalink
refactor(core): adjust defer block behavior on the server (angular#51530
Browse files Browse the repository at this point in the history
)

This commit updates the runtime implementation of defer blocks to avoid their triggering on the server. This behavior was described in the RFC (angular#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.

PR Close angular#51530
  • Loading branch information
AndrewKushnir authored and thePunderWoman committed Sep 1, 2023
1 parent 40bb45f commit 1aff106
Show file tree
Hide file tree
Showing 14 changed files with 525 additions and 42 deletions.
14 changes: 8 additions & 6 deletions packages/core/src/hydration/annotate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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. <div #localRef>) 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. <div #localRef>) 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;
}

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/hydration/cleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
}
Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/linker/template_ref.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export abstract class TemplateRef<C> {
*/
abstract createEmbeddedViewImpl(
context: C, injector?: Injector,
hydrationInfo?: DehydratedContainerView|null): EmbeddedViewRef<C>;
dehydratedView?: DehydratedContainerView|null): EmbeddedViewRef<C>;

/**
* Returns an `ssrId` associated with a TView, which was used to
Expand Down Expand Up @@ -118,9 +118,9 @@ const R3TemplateRef = class TemplateRef<T> extends ViewEngineTemplateRef<T> {
*/
override createEmbeddedViewImpl(
context: T, injector?: Injector,
hydrationInfo?: DehydratedContainerView): EmbeddedViewRef<T> {
dehydratedView?: DehydratedContainerView): EmbeddedViewRef<T> {
const embeddedLView = createAndRenderEmbeddedLView(
this._declarationLView, this._declarationTContainer, context, {injector, hydrationInfo});
this._declarationLView, this._declarationTContainer, context, {injector, dehydratedView});
return new R3_ViewRef<T>(embeddedLView);
}
};
Expand Down
77 changes: 53 additions & 24 deletions packages/core/src/linker/view_container_ref.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 || <any>{}, 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 || <any>{}, injector, dehydratedView);
this.insertImpl(viewRef, index, shouldAddViewToDom(this._hostTNode, dehydratedView));
return viewRef;
}

Expand Down Expand Up @@ -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<any>)._lView!;
const tView = lView[TVIEW];

if (ngDevMode && viewRef.destroyed) {
throw new Error('Cannot insert a destroyed View in a ViewContainer!');
Expand Down Expand Up @@ -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<any>).attachToViewContainerRef();
addToArray(getOrCreateViewRefs(lContainer), adjustedIdx, viewRef);
Expand Down Expand Up @@ -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
Expand All @@ -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;
Expand All @@ -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.
Expand Down Expand Up @@ -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;
}
33 changes: 30 additions & 3 deletions packages/core/src/render3/instructions/defer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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).
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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));
}
}

Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down
20 changes: 18 additions & 2 deletions packages/core/src/render3/interfaces/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

/**
Expand All @@ -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.
*/
Expand Down
17 changes: 15 additions & 2 deletions packages/core/src/render3/view_manipulation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -21,7 +22,7 @@ import {addViewToDOM, destroyLView, detachView, getBeforeNodeForView, insertView

export function createAndRenderEmbeddedLView<T>(
declarationLView: LView<unknown>, templateTNode: TNode, context: T,
options?: {injector?: Injector, hydrationInfo?: DehydratedContainerView}): LView<T> {
options?: {injector?: Injector, dehydratedView?: DehydratedContainerView|null}): LView<T> {
const embeddedTView = templateTNode.tView!;
ngDevMode && assertDefined(embeddedTView, 'TView must be defined for a template node.');
ngDevMode && assertTNodeForLView(templateTNode, declarationLView);
Expand All @@ -31,7 +32,7 @@ export function createAndRenderEmbeddedLView<T>(
const viewFlags = isSignalView ? LViewFlags.SignalView : LViewFlags.CheckAlways;
const embeddedLView = createLView<T>(
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);
Expand Down Expand Up @@ -60,6 +61,18 @@ export function getLViewFromLContainer<T>(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<unknown>, index: number, addToDOM = true): void {
const tView = lView[TVIEW];
Expand Down
11 changes: 10 additions & 1 deletion packages/core/test/acceptance/defer_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -26,10 +27,18 @@ function clearDirectiveDefs(type: Type<unknown>): 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',
Expand Down
Loading

0 comments on commit 1aff106

Please sign in to comment.