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 Aug 29, 2023
1 parent ab0f9ee commit d08ff5e
Show file tree
Hide file tree
Showing 9 changed files with 234 additions and 17 deletions.
13 changes: 8 additions & 5 deletions packages/core/src/hydration/annotate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +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 {isTDeferBlockDetailsShape} from '../render3/interfaces/defer';
import {TNode, TNodeType} from '../render3/interfaces/node';
import {RElement} from '../render3/interfaces/renderer_dom';
import {hasI18n, isComponentHost, isLContainer, isProjectionTNode, isRootView} from '../render3/interfaces/type_checks';
Expand Down Expand Up @@ -279,11 +280,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. If that's the case, just
// skip this slot and move to the next one.
// - Defer block details object. This slot doesn't require serialization.
if (!tNode || isTDeferBlockDetailsShape(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
50 changes: 43 additions & 7 deletions packages/core/src/linker/view_container_ref.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand All @@ -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.
Expand Down Expand Up @@ -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;
}
36 changes: 34 additions & 2 deletions packages/core/src/render3/instructions/defer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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).
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);

// 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(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, !skipDomInsertion);
}
}

Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/render3/interfaces/defer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,12 @@ export const enum DeferBlockInstanceState {
ERROR
}

/** Helper function to detect if a given value matches a `TDeferBlockDetails` shape */
export function isTDeferBlockDetailsShape(value: unknown): value is TDeferBlockDetails {
return value != null && typeof value === 'object' &&
typeof (value as TDeferBlockDetails).primaryTmplIndex === 'number';
}

export const DEFER_BLOCK_STATE = 0;

/**
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/render3/view_manipulation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,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, hydrationInfo?: DehydratedContainerView|null}): LView<T> {
const embeddedTView = templateTNode.tView!;
ngDevMode && assertDefined(embeddedTView, 'TView must be defined for a template node.');
ngDevMode && assertTNodeForLView(templateTNode, declarationLView);
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
Original file line number Diff line number Diff line change
Expand Up @@ -911,6 +911,9 @@
{
"name": "hasSkipHydrationAttrOnRElement"
},
{
"name": "hasSkipHydrationAttrOnTNode"
},
{
"name": "hostReportError"
},
Expand Down Expand Up @@ -1148,6 +1151,9 @@
{
"name": "onLeave"
},
{
"name": "populateDehydratedViewsInContainerImpl"
},
{
"name": "processInjectorTypesWithProviders"
},
Expand Down
Loading

0 comments on commit d08ff5e

Please sign in to comment.