Skip to content

Commit

Permalink
fix(core): handle hydration of view containers that use component hos…
Browse files Browse the repository at this point in the history
…ts as anchors

This commit fixes an issue where serialization of a view container fails in case it uses a component host as an anchor. This fix is similar to the fix from angular#51247, but for cases when we insert a component (that acts as a host for a view container) deeper in a hierarchy.

Resolves angular#51318.
  • Loading branch information
AndrewKushnir committed Aug 22, 2023
1 parent 9152de1 commit 7073cd7
Show file tree
Hide file tree
Showing 3 changed files with 215 additions and 52 deletions.
87 changes: 62 additions & 25 deletions packages/core/src/hydration/annotate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@
import {ApplicationRef} from '../application_ref';
import {ViewEncapsulation} from '../metadata';
import {Renderer2} from '../render';
import {collectNativeNodes} from '../render3/collect_native_nodes';
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 {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';
import {unwrapRNode} from '../render3/util/view_utils';
import {unwrapLView, unwrapRNode} from '../render3/util/view_utils';
import {TransferState} from '../transfer_state';

import {unsupportedProjectionOfDomNodes} from './error_handling';
Expand Down Expand Up @@ -92,6 +92,16 @@ function calcNumRootNodes(tView: TView, lView: LView, tNode: TNode|null): number
return rootNodes.length;
}

/**
* Computes the number of root nodes in all views in a given LContainer.
*/
function calcNumRootNodesInLContainer(lContainer: LContainer): number {
const rootNodes: unknown[] = [];
collectNativeNodesInLContainer(lContainer, rootNodes);
return rootNodes.length;
}


/**
* Annotates root level component's LView for hydration,
* see `annotateHostElementForHydration` for additional information.
Expand All @@ -113,7 +123,7 @@ function annotateComponentLViewForHydration(lView: LView, context: HydrationCont
* container.
*/
function annotateLContainerForHydration(lContainer: LContainer, context: HydrationContext) {
const componentLView = lContainer[HOST] as LView<unknown>;
const componentLView = unwrapLView(lContainer[HOST]) as LView<unknown>;

// Serialize the root component itself.
const componentLViewNghIndex = annotateComponentLViewForHydration(componentLView, context);
Expand Down Expand Up @@ -191,49 +201,75 @@ export function annotateForHydration(appRef: ApplicationRef, doc: Document) {
function serializeLContainer(
lContainer: LContainer, context: HydrationContext): SerializedContainerView[] {
const views: SerializedContainerView[] = [];
let lastViewAsString: string = '';
let lastViewAsString = '';

for (let i = CONTAINER_HEADER_OFFSET; i < lContainer.length; i++) {
let childLView = lContainer[i] as LView;

// If this is a root view, get an LView for the underlying component,
// because it contains information about the view to serialize.
let template: string;
let numRootNodes: number;
let serializedView: SerializedContainerView|undefined;

if (isRootView(childLView)) {
// If this is a root view, get an LView for the underlying component,
// because it contains information about the view to serialize.
childLView = childLView[HEADER_OFFSET];

// If we have an LContainer at this position, this indicates that the
// host element was used as a ViewContainerRef anchor (e.g. a `ViewContainerRef`
// was injected within the component class). This case requires special handling.
if (isLContainer(childLView)) {
// Calculate the number of root nodes in all views in a given container
// and increment by one to account for an anchor node itself, i.e. in this
// scenario we'll have a layout that would look like this:
// `<app-root /><#VIEW1><#VIEW2>...<!--container-->`
// The `+1` is to capture the `<app-root />` element.
numRootNodes = calcNumRootNodesInLContainer(childLView) + 1;

annotateLContainerForHydration(childLView, context);

const componentLView = unwrapLView(childLView[HOST]) as LView<unknown>;

serializedView = {
[TEMPLATE_ID]: componentLView[TVIEW].ssrId!,
[NUM_ROOT_NODES]: numRootNodes,
};
}
}
const childTView = childLView[TVIEW];

let template: string;
let numRootNodes = 0;
if (childTView.type === TViewType.Component) {
template = childTView.ssrId!;
if (!serializedView) {
const childTView = childLView[TVIEW];

// This is a component view, thus it has only 1 root node: the component
// host node itself (other nodes would be inside that host node).
numRootNodes = 1;
} else {
template = getSsrId(childTView);
numRootNodes = calcNumRootNodes(childTView, childLView, childTView.firstChild);
}
if (childTView.type === TViewType.Component) {
template = childTView.ssrId!;

// This is a component view, thus it has only 1 root node: the component
// host node itself (other nodes would be inside that host node).
numRootNodes = 1;
} else {
template = getSsrId(childTView);
numRootNodes = calcNumRootNodes(childTView, childLView, childTView.firstChild);
}

const view: SerializedContainerView = {
[TEMPLATE_ID]: template,
[NUM_ROOT_NODES]: numRootNodes,
...serializeLView(lContainer[i] as LView, context),
};
serializedView = {
[TEMPLATE_ID]: template,
[NUM_ROOT_NODES]: numRootNodes,
...serializeLView(lContainer[i] as LView, context),
};
}

// Check if the previous view has the same shape (for example, it was
// produced by the *ngFor), in which case bump the counter on the previous
// view instead of including the same information again.
const currentViewAsString = JSON.stringify(view);
const currentViewAsString = JSON.stringify(serializedView);
if (views.length > 0 && currentViewAsString === lastViewAsString) {
const previousView = views[views.length - 1];
previousView[MULTIPLIER] ??= 1;
previousView[MULTIPLIER]++;
} else {
// Record this view as most recently added.
lastViewAsString = currentViewAsString;
views.push(view);
views.push(serializedView);
}
}
return views;
Expand Down Expand Up @@ -355,6 +391,7 @@ function serializeLView(lView: LView, context: HydrationContext): SerializedView
annotateHostElementForHydration(targetNode, hostNode as LView, context);
}
}

ngh[CONTAINERS] ??= {};
ngh[CONTAINERS][noOffsetIndex] = serializeLContainer(lView[i], context);
} else if (Array.isArray(lView[i])) {
Expand Down
60 changes: 33 additions & 27 deletions packages/core/src/render3/collect_native_nodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,17 @@

import {assertParentView} from './assert';
import {icuContainerIterate} from './i18n/i18n_tree_shaking';
import {CONTAINER_HEADER_OFFSET, NATIVE} from './interfaces/container';
import {CONTAINER_HEADER_OFFSET, LContainer, NATIVE} from './interfaces/container';
import {TIcuContainerNode, TNode, TNodeType} from './interfaces/node';
import {RNode} from './interfaces/renderer_dom';
import {isLContainer} from './interfaces/type_checks';
import {DECLARATION_COMPONENT_VIEW, HOST, LView, T_HOST, TVIEW, TView} from './interfaces/view';
import {DECLARATION_COMPONENT_VIEW, HOST, LView, TVIEW, TView} from './interfaces/view';
import {assertTNodeType} from './node_assert';
import {getProjectionNodes} from './node_manipulation';
import {getLViewParent} from './util/view_traversal_utils';
import {unwrapRNode} from './util/view_utils';



export function collectNativeNodes(
tView: TView, lView: LView, tNode: TNode|null, result: any[],
isProjection: boolean = false): any[] {
Expand All @@ -38,30 +37,7 @@ export function collectNativeNodes(
// ViewContainerRef). When we find a LContainer we need to descend into it to collect root nodes
// from the views in this container.
if (isLContainer(lNode)) {
for (let i = CONTAINER_HEADER_OFFSET; i < lNode.length; i++) {
const lViewInAContainer = lNode[i];
const lViewFirstChildTNode = lViewInAContainer[TVIEW].firstChild;
if (lViewFirstChildTNode !== null) {
collectNativeNodes(
lViewInAContainer[TVIEW], lViewInAContainer, lViewFirstChildTNode, result);
}
}

// When an LContainer is created, the anchor (comment) node is:
// - (1) either reused in case of an ElementContainer (<ng-container>)
// - (2) or a new comment node is created
// In the first case, the anchor comment node would be added to the final
// list by the code above (`result.push(unwrapRNode(lNode))`), but the second
// case requires extra handling: the anchor node needs to be added to the
// final list manually. See additional information in the `createAnchorNode`
// function in the `view_container_ref.ts`.
//
// In the first case, the same reference would be stored in the `NATIVE`
// and `HOST` slots in an LContainer. Otherwise, this is the second case and
// we should add an element to the final list.
if (lNode[NATIVE] !== lNode[HOST]) {
result.push(lNode[NATIVE]);
}
collectNativeNodesInLContainer(lNode, result);
}

const tNodeType = tNode.type;
Expand All @@ -88,3 +64,33 @@ export function collectNativeNodes(

return result;
}

/**
* Collects all root nodes in all views in a given LContainer.
*/
export function collectNativeNodesInLContainer(lContainer: LContainer, result: any[]) {
for (let i = CONTAINER_HEADER_OFFSET; i < lContainer.length; i++) {
const lViewInAContainer = lContainer[i];
const lViewFirstChildTNode = lViewInAContainer[TVIEW].firstChild;
if (lViewFirstChildTNode !== null) {
collectNativeNodes(lViewInAContainer[TVIEW], lViewInAContainer, lViewFirstChildTNode, result);
}
}

// When an LContainer is created, the anchor (comment) node is:
// - (1) either reused in case of an ElementContainer (<ng-container>)
// - (2) or a new comment node is created
// In the first case, the anchor comment node would be added to the final
// list by the code in the `collectNativeNodes` function
// (see the `result.push(unwrapRNode(lNode))` line), but the second
// case requires extra handling: the anchor node needs to be added to the
// final list manually. See additional information in the `createAnchorNode`
// function in the `view_container_ref.ts`.
//
// In the first case, the same reference would be stored in the `NATIVE`
// and `HOST` slots in an LContainer. Otherwise, this is the second case and
// we should add an element to the final list.
if (lContainer[NATIVE] !== lContainer[HOST]) {
result.push(lContainer[NATIVE]);
}
}
120 changes: 120 additions & 0 deletions packages/platform-server/test/hydration_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1674,6 +1674,126 @@ describe('platform-server hydration integration', () => {
verifyClientAndSSRContentsMatch(ssrContents, clientRootNode);
});

it('should hydrate dynamically created components using ' +
'another component\'s host node as an anchor',
async () => {
@Component({
standalone: true,
selector: 'another-dynamic',
template: `<span>This is a content of another dynamic component.</span>`,
})
class AnotherDynamicComponent {
vcr = inject(ViewContainerRef);
}

@Component({
standalone: true,
selector: 'dynamic',
template: `<span>This is a content of a dynamic component.</span>`,
})
class DynamicComponent {
vcr = inject(ViewContainerRef);

ngAfterViewInit() {
const compRef = this.vcr.createComponent(AnotherDynamicComponent);
compRef.changeDetectorRef.detectChanges();
}
}

@Component({
standalone: true,
selector: 'app',
template: `<main>Hi! This is the main content.</main>`,
})
class SimpleComponent {
vcr = inject(ViewContainerRef);

ngAfterViewInit() {
const compRef = this.vcr.createComponent(DynamicComponent);
compRef.changeDetectorRef.detectChanges();
}
}

const html = await ssr(SimpleComponent);
const ssrContents = getAppContents(html);

expect(ssrContents).toContain('<app ngh');

resetTViewsFor(SimpleComponent, DynamicComponent);

const appRef = await hydrate(html, SimpleComponent);
const compRef = getComponentRef<SimpleComponent>(appRef);
appRef.tick();

// Compare output starting from the parent node above the component node,
// because component host node also acted as a ViewContainerRef anchor,
// thus there are elements after this node (as next siblings).
const clientRootNode = compRef.location.nativeElement.parentNode;
await whenStable(appRef);

verifyAllChildNodesClaimedForHydration(clientRootNode);
verifyClientAndSSRContentsMatch(ssrContents, clientRootNode);
});

it('should hydrate dynamically created embedded views using ' +
'another component\'s host node as an anchor',
async () => {
@Component({
standalone: true,
selector: 'dynamic',
template: `
<ng-template #tmpl>
<h1>Content of an embedded view</h1>
</ng-template>
<main>Hi! This is the dynamic component content.</main>
`,
})
class DynamicComponent {
@ViewChild('tmpl', {read: TemplateRef}) tmpl!: TemplateRef<unknown>;

vcr = inject(ViewContainerRef);

ngAfterViewInit() {
const viewRef = this.vcr.createEmbeddedView(this.tmpl);
viewRef.detectChanges();
}
}

@Component({
standalone: true,
selector: 'app',
template: `<main>Hi! This is the main content.</main>`,
})
class SimpleComponent {
vcr = inject(ViewContainerRef);

ngAfterViewInit() {
const compRef = this.vcr.createComponent(DynamicComponent);
compRef.changeDetectorRef.detectChanges();
}
}

const html = await ssr(SimpleComponent);
const ssrContents = getAppContents(html);

expect(ssrContents).toContain('<app ngh');

resetTViewsFor(SimpleComponent, DynamicComponent);

const appRef = await hydrate(html, SimpleComponent);
const compRef = getComponentRef<SimpleComponent>(appRef);
appRef.tick();

// Compare output starting from the parent node above the component node,
// because component host node also acted as a ViewContainerRef anchor,
// thus there are elements after this node (as next siblings).
const clientRootNode = compRef.location.nativeElement.parentNode;
await whenStable(appRef);

verifyAllChildNodesClaimedForHydration(clientRootNode);
verifyClientAndSSRContentsMatch(ssrContents, clientRootNode);
});

it('should re-create the views from the ViewContainerRef ' +
'if there is a mismatch in template ids between the current view ' +
'(that is being created) and the first dehydrated view in the list',
Expand Down

0 comments on commit 7073cd7

Please sign in to comment.