Skip to content

Commit

Permalink
refactor(core): adjust defer block behavior on the server
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.
  • Loading branch information
AndrewKushnir committed Sep 1, 2023
1 parent 3658e25 commit a175166
Show file tree
Hide file tree
Showing 10 changed files with 501 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 @@ -310,13 +310,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 @@ -433,21 +430,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 @@ -485,7 +478,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 @@ -601,6 +594,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 @@ -625,16 +635,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 @@ -648,7 +664,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 @@ -676,8 +692,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 a175166

Please sign in to comment.