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 27, 2023
1 parent d3edddc commit f6e9369
Show file tree
Hide file tree
Showing 7 changed files with 209 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
40 changes: 33 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,20 @@ 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 whether an anchor comment node creation is needed for this container.
*/
export function populateDehydratedViewsInContainer(lContainer: LContainer): boolean {
return _populateDehydratedViewsInContainer(lContainer, getLView(), getCurrentTNode()!);
}

/**
* Regular creation mode: an anchor is created and
Expand All @@ -625,16 +639,17 @@ 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.
*/
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 false;
}

const hydrationInfo = hostLView[HYDRATION];
const noOffsetIndex = hostTNode.index - HEADER_OFFSET;
Expand All @@ -648,7 +663,7 @@ function locateOrCreateAnchorNode(

// Regular creation mode.
if (isNodeCreationMode) {
return createAnchorNode(lContainer, hostLView, hostTNode, slotValue);
return true;
}

// Hydration mode, looking up an anchor node and dehydrated views in DOM.
Expand Down Expand Up @@ -676,8 +691,19 @@ function locateOrCreateAnchorNode(

lContainer[NATIVE] = commentNode as RComment;
lContainer[DEHYDRATED_VIEWS] = dehydratedViews;

return false;
}

function locateOrCreateAnchorNode(
lContainer: LContainer, hostLView: LView, hostTNode: TNode, slotValue: any): void {
if (_populateDehydratedViewsInContainer(lContainer, hostLView, hostTNode)) {
// Anchor comment node creation was requested for this container.
createAnchorNode(lContainer, hostLView, hostTNode, slotValue);
}
}

export function enableLocateOrCreateContainerRefImpl() {
_locateOrCreateAnchorNode = locateOrCreateAnchorNode;
_populateDehydratedViewsInContainer = populateDehydratedViewsInContainerImpl;
}
38 changes: 35 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,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 @@ -19,11 +22,22 @@ 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 {NO_CHANGE} from '../tokens';
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 @@ -81,6 +95,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 @@ -302,9 +321,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 @@ -319,6 +346,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 @@ -439,8 +468,11 @@ function renderDeferStateAfterResourceLoading(
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 All @@ -452,7 +484,7 @@ function triggerDeferBlock(lView: LView, tNode: TNode) {
case DeferDependenciesLoadingState.NOT_STARTED:
const adjustedIndex = tDetails.primaryTmplIndex + HEADER_OFFSET;
const primaryBlockTNode = getTNode(lView[TVIEW], adjustedIndex) as TContainerNode;
triggerResourceLoading(tDetails, primaryBlockTNode, lView[INJECTOR]!);
triggerResourceLoading(tDetails, primaryBlockTNode, injector);

// The `loadingState` might have changed to "loading".
if ((tDetails.loadingState as DeferDependenciesLoadingState) ===
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
125 changes: 125 additions & 0 deletions packages/platform-server/test/hydration_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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}
<my-lazy-cmp />
{: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('<app ngh');

// Even though trigger condition is `true`,
// the defer block remains in the "placeholder" mode on the server.
expect(ssrContents).toContain('Visible: true.');
expect(ssrContents).toContain('Placeholder');

resetTViewsFor(SimpleComponent);

const appRef = await hydrate(html, SimpleComponent);
const compRef = getComponentRef<SimpleComponent>(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('<my-lazy-cmp>Hi!</my-lazy-cmp>');
});

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}
<my-lazy-cmp />
{: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('<app ngh');
expect(ssrContents).toContain('Placeholder');

resetTViewsFor(SimpleComponent);

const appRef = await hydrate(html, SimpleComponent);
const compRef = getComponentRef<SimpleComponent>(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 () => {
Expand Down

0 comments on commit f6e9369

Please sign in to comment.