Skip to content

Commit 8e366e8

Browse files
AndrewKushnirdylhunn
authored andcommitted
fix(core): support swapping hydrated views in @for loops (#53274)
This commit fixes an issue where swapping hydrated views was not possible in the new control flow repeater. The problem was caused by the fact that an internal representation of a view had no indication that hydration is completed and further detaching/attaching should work in a regular (non-hydration) mode. This commit adds a logic that resets a pointer to a dehydrated content and we use this as an indication that the view is swtiched to a regular mode. Resolves #53163. PR Close #53274
1 parent 58a96e0 commit 8e366e8

File tree

3 files changed

+125
-4
lines changed

3 files changed

+125
-4
lines changed

packages/core/src/hydration/interfaces.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,11 @@ export interface DehydratedView {
139139
/**
140140
* A reference to the first child in a DOM segment associated
141141
* with a given hydration boundary.
142+
*
143+
* Once a view becomes hydrated, the value is set to `null`, which
144+
* indicates that further detaching/attaching view actions should result
145+
* in invoking corresponding DOM actions (attaching DOM nodes action is
146+
* skipped when we hydrate, since nodes are already in the DOM).
142147
*/
143148
firstChild: RNode|null;
144149

packages/core/src/render3/view_manipulation.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {createLView} from './instructions/shared';
1717
import {CONTAINER_HEADER_OFFSET, LContainer, NATIVE} from './interfaces/container';
1818
import {TNode} from './interfaces/node';
1919
import {RComment, RElement} from './interfaces/renderer_dom';
20-
import {DECLARATION_LCONTAINER, FLAGS, LView, LViewFlags, QUERIES, RENDERER, T_HOST, TVIEW} from './interfaces/view';
20+
import {DECLARATION_LCONTAINER, FLAGS, HYDRATION, LView, LViewFlags, QUERIES, RENDERER, T_HOST, TVIEW} from './interfaces/view';
2121
import {addViewToDOM, destroyLView, detachView, getBeforeNodeForView, insertView, nativeParentNode} from './node_manipulation';
2222

2323
export function createAndRenderEmbeddedLView<T>(
@@ -70,17 +70,18 @@ export function getLViewFromLContainer<T>(lContainer: LContainer, index: number)
7070
*/
7171
export function shouldAddViewToDom(
7272
tNode: TNode, dehydratedView?: DehydratedContainerView|null): boolean {
73-
return !dehydratedView || hasInSkipHydrationBlockFlag(tNode);
73+
return !dehydratedView || dehydratedView.firstChild === null ||
74+
hasInSkipHydrationBlockFlag(tNode);
7475
}
7576

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

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

83-
// insert to the view to the DOM tree
84+
// Insert elements that belong to this view into the DOM tree
8485
if (addToDOM) {
8586
const beforeNode = getBeforeNodeForView(index, lContainer);
8687
const renderer = lView[RENDERER];
@@ -89,6 +90,14 @@ export function addLViewToLContainer(
8990
addViewToDOM(tView, lContainer[T_HOST], renderer, lView, parentRNode, beforeNode);
9091
}
9192
}
93+
94+
// When in hydration mode, reset the pointer to the first child in
95+
// the dehydrated view. This indicates that the view was hydrated and
96+
// further attaching/detaching should work with this view as normal.
97+
const hydrationInfo = lView[HYDRATION];
98+
if (hydrationInfo !== null && hydrationInfo.firstChild !== null) {
99+
hydrationInfo.firstChild = null;
100+
}
92101
}
93102

94103
export function removeLViewFromLContainer(lContainer: LContainer, index: number): LView<unknown>|

packages/platform-server/test/hydration_spec.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,65 @@ describe('platform-server hydration integration', () => {
467467
verifyClientAndSSRContentsMatch(ssrContents, clientRootNode);
468468
});
469469

470+
it('should hydrate root components with empty templates', async () => {
471+
@Component({
472+
standalone: true,
473+
selector: 'app',
474+
template: '',
475+
})
476+
class SimpleComponent {
477+
}
478+
479+
const html = await ssr(SimpleComponent);
480+
const ssrContents = getAppContents(html);
481+
482+
expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`);
483+
484+
resetTViewsFor(SimpleComponent);
485+
486+
const appRef = await hydrate(html, SimpleComponent);
487+
const compRef = getComponentRef<SimpleComponent>(appRef);
488+
appRef.tick();
489+
490+
const clientRootNode = compRef.location.nativeElement;
491+
verifyAllNodesClaimedForHydration(clientRootNode);
492+
verifyClientAndSSRContentsMatch(ssrContents, clientRootNode);
493+
});
494+
495+
it('should hydrate child components with empty templates', async () => {
496+
@Component({
497+
standalone: true,
498+
selector: 'child',
499+
template: '',
500+
})
501+
class ChildComponent {
502+
}
503+
504+
@Component({
505+
standalone: true,
506+
imports: [ChildComponent],
507+
selector: 'app',
508+
template: '<child />',
509+
})
510+
class SimpleComponent {
511+
}
512+
513+
const html = await ssr(SimpleComponent);
514+
const ssrContents = getAppContents(html);
515+
516+
expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`);
517+
518+
resetTViewsFor(SimpleComponent, ChildComponent);
519+
520+
const appRef = await hydrate(html, SimpleComponent);
521+
const compRef = getComponentRef<SimpleComponent>(appRef);
522+
appRef.tick();
523+
524+
const clientRootNode = compRef.location.nativeElement;
525+
verifyAllNodesClaimedForHydration(clientRootNode);
526+
verifyClientAndSSRContentsMatch(ssrContents, clientRootNode);
527+
});
528+
470529
it('should support a single text interpolation', async () => {
471530
@Component({
472531
standalone: true,
@@ -6469,6 +6528,54 @@ describe('platform-server hydration integration', () => {
64696528
[4, 5].map(id => compRef.location.nativeElement.querySelector(`[id=${id}]`));
64706529
verifyAllNodesClaimedForHydration(clientRootNode, Array.from(clientRenderedItems));
64716530
});
6531+
6532+
it('should handle a reconciliation with swaps', async () => {
6533+
@Component({
6534+
selector: 'app',
6535+
standalone: true,
6536+
template: `
6537+
@for(item of items; track item) {
6538+
<div>{{ item }}</div>
6539+
}
6540+
`,
6541+
})
6542+
class SimpleComponent {
6543+
items = ['a', 'b', 'c'];
6544+
6545+
swap() {
6546+
// Reshuffling of the array will result in
6547+
// "swap" operations in repeater.
6548+
this.items = ['b', 'c', 'a'];
6549+
}
6550+
}
6551+
6552+
const html = await ssr(SimpleComponent);
6553+
const ssrContents = getAppContents(html);
6554+
6555+
expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`);
6556+
6557+
resetTViewsFor(SimpleComponent);
6558+
6559+
expect(ssrContents).toContain('a');
6560+
expect(ssrContents).toContain('b');
6561+
expect(ssrContents).toContain('c');
6562+
6563+
const appRef = await hydrate(html, SimpleComponent);
6564+
const compRef = getComponentRef<SimpleComponent>(appRef);
6565+
appRef.tick();
6566+
6567+
await whenStable(appRef);
6568+
6569+
const root: HTMLElement = compRef.location.nativeElement;
6570+
const divs = root.querySelectorAll('div');
6571+
expect(divs.length).toBe(3);
6572+
6573+
compRef.instance.swap();
6574+
compRef.changeDetectorRef.detectChanges();
6575+
6576+
const divsAfterSwap = root.querySelectorAll('div');
6577+
expect(divsAfterSwap.length).toBe(3);
6578+
});
64726579
});
64736580

64746581
describe('Router', () => {

0 commit comments

Comments
 (0)