From bcac1e4d58f5c1117b1dd84b683e08e6ca99402c Mon Sep 17 00:00:00 2001 From: Andrew Kushnir Date: Wed, 29 Nov 2023 19:16:40 -0800 Subject: [PATCH] fix(core): support swapping hydrated views in `@for` loops 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. --- packages/core/src/hydration/interfaces.ts | 5 + .../core/src/render3/view_manipulation.ts | 17 ++- .../platform-server/test/hydration_spec.ts | 107 ++++++++++++++++++ 3 files changed, 125 insertions(+), 4 deletions(-) diff --git a/packages/core/src/hydration/interfaces.ts b/packages/core/src/hydration/interfaces.ts index b5b2fc1e0e97..5b1a25c8a7d5 100644 --- a/packages/core/src/hydration/interfaces.ts +++ b/packages/core/src/hydration/interfaces.ts @@ -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; diff --git a/packages/core/src/render3/view_manipulation.ts b/packages/core/src/render3/view_manipulation.ts index d5684e73387a..792047f03996 100644 --- a/packages/core/src/render3/view_manipulation.ts +++ b/packages/core/src/render3/view_manipulation.ts @@ -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( @@ -70,17 +70,18 @@ export function getLViewFromLContainer(lContainer: LContainer, index: number) */ export function shouldAddViewToDom( tNode: TNode, dehydratedView?: DehydratedContainerView|null): boolean { - return !dehydratedView || hasInSkipHydrationBlockFlag(tNode); + return !dehydratedView || dehydratedView.firstChild === null || + hasInSkipHydrationBlockFlag(tNode); } export function addLViewToLContainer( lContainer: LContainer, lView: LView, 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]; @@ -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| diff --git a/packages/platform-server/test/hydration_spec.ts b/packages/platform-server/test/hydration_spec.ts index c2e2cf266a48..ee9fb52894e6 100644 --- a/packages/platform-server/test/hydration_spec.ts +++ b/packages/platform-server/test/hydration_spec.ts @@ -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(`(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: '', + }) + class SimpleComponent { + } + + const html = await ssr(SimpleComponent); + const ssrContents = getAppContents(html); + + expect(ssrContents).toContain(`(appRef); + appRef.tick(); + + const clientRootNode = compRef.location.nativeElement; + verifyAllNodesClaimedForHydration(clientRootNode); + verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); + }); + it('should support a single text interpolation', async () => { @Component({ standalone: true, @@ -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) { +
{{ item }}
+ } + `, + }) + 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(`(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', () => {