Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/core/src/hydration/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,11 @@ export interface DehydratedView {
/**
* A reference to the first child in a DOM segment associated
* with a given hydration boundary.
*
* Once a view becomes hydrated, the value is set to `null`, which
* indicates that further detaching/attaching view actions should result
* in invoking corresponding DOM actions (attaching DOM nodes action is
* skipped when we hydrate, since nodes are already in the DOM).
*/
firstChild: RNode|null;

Expand Down
17 changes: 13 additions & 4 deletions packages/core/src/render3/view_manipulation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {createLView} from './instructions/shared';
import {CONTAINER_HEADER_OFFSET, LContainer, NATIVE} from './interfaces/container';
import {TNode} from './interfaces/node';
import {RComment, RElement} from './interfaces/renderer_dom';
import {DECLARATION_LCONTAINER, FLAGS, LView, LViewFlags, QUERIES, RENDERER, T_HOST, TVIEW} from './interfaces/view';
import {DECLARATION_LCONTAINER, FLAGS, HYDRATION, LView, LViewFlags, QUERIES, RENDERER, T_HOST, TVIEW} from './interfaces/view';
import {addViewToDOM, destroyLView, detachView, getBeforeNodeForView, insertView, nativeParentNode} from './node_manipulation';

export function createAndRenderEmbeddedLView<T>(
Expand Down Expand Up @@ -70,17 +70,18 @@ export function getLViewFromLContainer<T>(lContainer: LContainer, index: number)
*/
export function shouldAddViewToDom(
tNode: TNode, dehydratedView?: DehydratedContainerView|null): boolean {
return !dehydratedView || hasInSkipHydrationBlockFlag(tNode);
return !dehydratedView || dehydratedView.firstChild === null ||

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With this new condition I think that we can now change the implementation of the ViewContainerRef so the insert operations go through the shouldAddViewToDom logic - currently the insert operation has this value hard-coded to true and it means that those 2 operations (create vs. create + insert) have different logic.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@pkozlowski-opensource thanks for the review!

I believe you refer to this code, where true indicates that the contents of a view should always be inserted:

override insert(viewRef: ViewRef, index?: number): ViewRef {
return this.insertImpl(viewRef, index, true);
}

I think that code is still correct. The createComponent and createEmbeddedView functions go through the insertImpl function and pass that flag based on what shouldAddViewToDom returns. For explicit insert calls, always inserting the content looks like a correct thing to do. Please let me know if I'm missing something.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI, we'll proceed with the merge and I'd be happy to make extra changes in a followup PR once we discuss this more.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, let's discuss more in the follow-up PR!

hasInSkipHydrationBlockFlag(tNode);
}

export function addLViewToLContainer(
lContainer: LContainer, lView: LView<unknown>, index: number, addToDOM = true): void {
const tView = lView[TVIEW];

// insert to the view tree so the new view can be change-detected
// Insert into the view tree so the new view can be change-detected
insertView(tView, lView, lContainer, index);

// insert to the view to the DOM tree
// Insert elements that belong to this view into the DOM tree
if (addToDOM) {
const beforeNode = getBeforeNodeForView(index, lContainer);
const renderer = lView[RENDERER];
Expand All @@ -89,6 +90,14 @@ export function addLViewToLContainer(
addViewToDOM(tView, lContainer[T_HOST], renderer, lView, parentRNode, beforeNode);
}
}

// When in hydration mode, reset the pointer to the first child in
// the dehydrated view. This indicates that the view was hydrated and
// further attaching/detaching should work with this view as normal.
const hydrationInfo = lView[HYDRATION];
if (hydrationInfo !== null && hydrationInfo.firstChild !== null) {
hydrationInfo.firstChild = null;
}
}

export function removeLViewFromLContainer(lContainer: LContainer, index: number): LView<unknown>|
Expand Down
107 changes: 107 additions & 0 deletions packages/platform-server/test/hydration_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,65 @@ describe('platform-server hydration integration', () => {
verifyClientAndSSRContentsMatch(ssrContents, clientRootNode);
});

it('should hydrate root components with empty templates', async () => {
@Component({
standalone: true,
selector: 'app',
template: '',
})
class SimpleComponent {
}

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

expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`);

resetTViewsFor(SimpleComponent);

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

const clientRootNode = compRef.location.nativeElement;
verifyAllNodesClaimedForHydration(clientRootNode);
verifyClientAndSSRContentsMatch(ssrContents, clientRootNode);
});

it('should hydrate child components with empty templates', async () => {
@Component({
standalone: true,
selector: 'child',
template: '',
})
class ChildComponent {
}

@Component({
standalone: true,
imports: [ChildComponent],
selector: 'app',
template: '<child />',
})
class SimpleComponent {
}

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

expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`);

resetTViewsFor(SimpleComponent, ChildComponent);

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

const clientRootNode = compRef.location.nativeElement;
verifyAllNodesClaimedForHydration(clientRootNode);
verifyClientAndSSRContentsMatch(ssrContents, clientRootNode);
});

it('should support a single text interpolation', async () => {
@Component({
standalone: true,
Expand Down Expand Up @@ -6386,6 +6445,54 @@ describe('platform-server hydration integration', () => {
[4, 5].map(id => compRef.location.nativeElement.querySelector(`[id=${id}]`));
verifyAllNodesClaimedForHydration(clientRootNode, Array.from(clientRenderedItems));
});

it('should handle a reconciliation with swaps', async () => {
@Component({
selector: 'app',
standalone: true,
template: `
@for(item of items; track item) {
<div>{{ item }}</div>
}
`,
})
class SimpleComponent {
items = ['a', 'b', 'c'];

swap() {
// Reshuffling of the array will result in
// "swap" operations in repeater.
this.items = ['b', 'c', 'a'];
}
}

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

expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`);

resetTViewsFor(SimpleComponent);

expect(ssrContents).toContain('a');
expect(ssrContents).toContain('b');
expect(ssrContents).toContain('c');

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

await whenStable(appRef);

const root: HTMLElement = compRef.location.nativeElement;
const divs = root.querySelectorAll('div');
expect(divs.length).toBe(3);

compRef.instance.swap();
compRef.changeDetectorRef.detectChanges();

const divsAfterSwap = root.querySelectorAll('div');
expect(divsAfterSwap.length).toBe(3);
});
});

describe('Router', () => {
Expand Down