Skip to content

Commit 13d1c8a

Browse files
thePunderWomanalxhub
authored andcommitted
fix(core): fixes timing of hydration cleanup on control flow (angular#60425)
This properly cleans up stale control flow branches in the case that branches change between server and client at the same timing as NgIf / NgSwitch. fixes: angular#58670 fixes: angular#60218 PR Close angular#60425
1 parent 58e1d9e commit 13d1c8a

File tree

5 files changed

+230
-63
lines changed

5 files changed

+230
-63
lines changed

packages/core/src/hydration/views.ts

Lines changed: 111 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@
77
*/
88

99
import {DEHYDRATED_VIEWS, LContainer} from '../render3/interfaces/container';
10+
import {TNode, TNodeFlags} from '../render3/interfaces/node';
1011
import {RNode} from '../render3/interfaces/renderer_dom';
12+
import {isLContainer} from '../render3/interfaces/type_checks';
13+
import {LView, TVIEW} from '../render3/interfaces/view';
1114

1215
import {removeDehydratedViews} from './cleanup';
1316
import {
@@ -61,6 +64,20 @@ export function locateDehydratedViewsInContainer(
6164
*/
6265
let _findMatchingDehydratedViewImpl: typeof findMatchingDehydratedViewImpl = () => null;
6366

67+
/**
68+
* Reference to a function that searches for a matching dehydrated view
69+
* stored on a control flow lContainer and removes the dehydrated content
70+
* once found.
71+
* Returns `null` by default, when hydration is not enabled.
72+
*/
73+
let _findAndReconcileMatchingDehydratedViewsImpl: typeof findAndReconcileMatchingDehydratedViewsImpl =
74+
() => null;
75+
76+
export function enableFindMatchingDehydratedViewImpl() {
77+
_findMatchingDehydratedViewImpl = findMatchingDehydratedViewImpl;
78+
_findAndReconcileMatchingDehydratedViewsImpl = findAndReconcileMatchingDehydratedViewsImpl;
79+
}
80+
6481
/**
6582
* Retrieves the next dehydrated view from the LContainer and verifies that
6683
* it matches a given template id (from the TView that was used to create this
@@ -74,17 +91,8 @@ function findMatchingDehydratedViewImpl(
7491
lContainer: LContainer,
7592
template: string | null,
7693
): DehydratedContainerView | null {
77-
const views = lContainer[DEHYDRATED_VIEWS];
78-
if (!template || views === null || views.length === 0) {
79-
return null;
80-
}
81-
const view = views[0];
82-
// Verify whether the first dehydrated view in the container matches
83-
// the template id passed to this function (that originated from a TView
84-
// that was used to create an instance of an embedded or component views.
85-
if (view.data[TEMPLATE_ID] === template) {
86-
// If the template id matches - extract the first view and return it.
87-
return views.shift()!;
94+
if (hasMatchingDehydratedView(lContainer, template)) {
95+
return lContainer[DEHYDRATED_VIEWS]!.shift()!;
8896
} else {
8997
// Otherwise, we are at the state when reconciliation can not be completed,
9098
// thus we remove all dehydrated views within this container (remove them
@@ -95,13 +103,101 @@ function findMatchingDehydratedViewImpl(
95103
}
96104
}
97105

98-
export function enableFindMatchingDehydratedViewImpl() {
99-
_findMatchingDehydratedViewImpl = findMatchingDehydratedViewImpl;
100-
}
101-
102106
export function findMatchingDehydratedView(
103107
lContainer: LContainer,
104108
template: string | null,
105109
): DehydratedContainerView | null {
106110
return _findMatchingDehydratedViewImpl(lContainer, template);
107111
}
112+
113+
export function findAndReconcileMatchingDehydratedViewsImpl(
114+
lContainer: LContainer,
115+
templateTNode: TNode,
116+
hostLView: LView,
117+
): DehydratedContainerView | null {
118+
if (templateTNode.tView!.ssrId === null) return null;
119+
const dehydratedView = findMatchingDehydratedView(lContainer, templateTNode.tView!.ssrId);
120+
121+
// we know that an ssrId was generated, but we were unable to match it to
122+
// a dehydrated view, which means that we may have changed branches
123+
// between server and client. We'll need to find and remove those
124+
// stale dehydrated views.
125+
if (hostLView[TVIEW].firstUpdatePass && dehydratedView === null) {
126+
removeStaleDehydratedBranch(hostLView, templateTNode);
127+
}
128+
return dehydratedView;
129+
}
130+
131+
export function findAndReconcileMatchingDehydratedViews(
132+
lContainer: LContainer,
133+
templateTNode: TNode,
134+
hostLView: LView,
135+
): DehydratedContainerView | null {
136+
return _findAndReconcileMatchingDehydratedViewsImpl(lContainer, templateTNode, hostLView);
137+
}
138+
139+
/**
140+
* In the case that we have control flow that changes branches between server and
141+
* client, we're left with dehydrated content that will not be used. We need to find
142+
* it and clean it up at the right time so that we don't see duplicate content for
143+
* a few moments before the application reaches stability. This navigates the
144+
* control flow containers by looking at the TNodeFlags to find the matching
145+
* dehydrated content for the branch that is now stale from the server and removes it.
146+
*/
147+
function removeStaleDehydratedBranch(hostLView: LView, tNode: TNode): void {
148+
let currentTNode: TNode | null = tNode;
149+
while (currentTNode) {
150+
// We can return here if we've found the dehydrated view and cleaned it up.
151+
// Otherwise we continue on until we either find it or reach the start of
152+
// the control flow.
153+
if (cleanupMatchingDehydratedViews(hostLView, currentTNode)) return;
154+
155+
if ((currentTNode.flags & TNodeFlags.isControlFlowStart) === TNodeFlags.isControlFlowStart) {
156+
// we've hit the top of the control flow loop
157+
break;
158+
}
159+
160+
currentTNode = currentTNode.prev;
161+
}
162+
163+
currentTNode = tNode.next; // jump to place we started so we can navigate down from there
164+
165+
while (currentTNode) {
166+
if ((currentTNode.flags & TNodeFlags.isInControlFlow) !== TNodeFlags.isInControlFlow) {
167+
// we've exited control flow and need to exit the loop.
168+
break;
169+
}
170+
171+
// Similar to above, we can return here if we've found the dehydrated view
172+
// and cleaned it up. Otherwise we continue on until we either find it or
173+
// reach the end of the control flow.
174+
if (cleanupMatchingDehydratedViews(hostLView, currentTNode)) return;
175+
176+
currentTNode = currentTNode.next;
177+
}
178+
}
179+
180+
function hasMatchingDehydratedView(lContainer: LContainer, template: string | null): boolean {
181+
const views = lContainer[DEHYDRATED_VIEWS];
182+
if (!template || views === null || views.length === 0) {
183+
return false;
184+
}
185+
// Verify whether the first dehydrated view in the container matches
186+
// the template id passed to this function (that originated from a TView
187+
// that was used to create an instance of an embedded or component views.
188+
return views[0].data[TEMPLATE_ID] === template;
189+
}
190+
191+
function cleanupMatchingDehydratedViews(hostLView: LView, currentTNode: TNode): boolean {
192+
const ssrId = currentTNode.tView?.ssrId;
193+
if (ssrId == null /* check both `null` and `undefined` */) return false;
194+
195+
const container = hostLView[currentTNode.index];
196+
// if we can find the dehydrated view in this container, we know we've found the stale view
197+
// and we can remove it.
198+
if (isLContainer(container) && hasMatchingDehydratedView(container, ssrId)) {
199+
removeDehydratedViews(container);
200+
return true;
201+
}
202+
return false;
203+
}

packages/core/src/render3/instructions/control_flow.ts

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ import {setActiveConsumer} from '@angular/core/primitives/signals';
1111
import {TrackByFunction} from '../../change_detection';
1212
import {formatRuntimeError, RuntimeErrorCode} from '../../errors';
1313
import {DehydratedContainerView} from '../../hydration/interfaces';
14-
import {findMatchingDehydratedView} from '../../hydration/views';
14+
import {
15+
findAndReconcileMatchingDehydratedViews,
16+
findMatchingDehydratedView,
17+
} from '../../hydration/views';
1518
import {assertDefined, assertFunction} from '../../util/assert';
1619
import {performanceMarkFeature} from '../../util/performance';
1720
import {assertLContainer, assertLView, assertTNode} from '../assert';
@@ -42,6 +45,7 @@ import {
4245
getLViewFromLContainer,
4346
removeLViewFromLContainer,
4447
} from '../view/container';
48+
import {removeDehydratedViews} from '../../hydration/cleanup';
4549

4650
/**
4751
* Creates an LContainer for an ng-template representing a root node
@@ -84,9 +88,9 @@ export function ɵɵconditionalCreate(
8488
vars,
8589
tagName,
8690
attrs,
91+
TNodeFlags.isControlFlowStart,
8792
localRefsIndex,
8893
localRefExtractor,
89-
TNodeFlags.isControlFlowStart,
9094
);
9195
return ɵɵconditionalBranchCreate;
9296
}
@@ -133,9 +137,9 @@ export function ɵɵconditionalBranchCreate(
133137
vars,
134138
tagName,
135139
attrs,
140+
TNodeFlags.isInControlFlow,
136141
localRefsIndex,
137142
localRefExtractor,
138-
TNodeFlags.isInControlFlow,
139143
);
140144
return ɵɵconditionalBranchCreate;
141145
}
@@ -153,8 +157,6 @@ export function ɵɵconditionalBranchCreate(
153157
export function ɵɵconditional<T>(matchingTemplateIndex: number, contextValue?: T) {
154158
performanceMarkFeature('NgControlFlow');
155159

156-
//TODO(jessica): this is where we navigate the tree to find the right node for proper cleanup
157-
158160
const hostLView = getLView();
159161
const bindingIndex = nextBindingIndex();
160162
const prevMatchingTemplateIndex: number =
@@ -181,9 +183,10 @@ export function ɵɵconditional<T>(matchingTemplateIndex: number, contextValue?:
181183
const nextContainer = getLContainer(hostLView, nextLContainerIndex);
182184
const templateTNode = getExistingTNode(hostLView[TVIEW], nextLContainerIndex);
183185

184-
const dehydratedView = findMatchingDehydratedView(
186+
const dehydratedView = findAndReconcileMatchingDehydratedViews(
185187
nextContainer,
186-
templateTNode.tView!.ssrId,
188+
templateTNode,
189+
hostLView,
187190
);
188191
const embeddedLView = createAndRenderEmbeddedLView(hostLView, templateTNode, contextValue, {
189192
dehydratedView,
@@ -322,9 +325,7 @@ export function ɵɵrepeaterCreate(
322325
vars,
323326
tagName,
324327
getConstant(tView.consts, attrsIndex),
325-
null,
326-
undefined,
327-
TNodeFlags.isControlFlowStart | TNodeFlags.isInControlFlow,
328+
TNodeFlags.isControlFlowStart,
328329
);
329330

330331
if (hasEmptyBlock) {
@@ -342,8 +343,6 @@ export function ɵɵrepeaterCreate(
342343
emptyVars!,
343344
emptyTagName,
344345
getConstant(tView.consts, emptyAttrsIndex),
345-
null,
346-
undefined,
347346
TNodeFlags.isInControlFlow,
348347
);
349348
}
@@ -526,9 +525,10 @@ export function ɵɵrepeater(collection: Iterable<unknown> | undefined | null):
526525
const lContainerForEmpty = getLContainer(hostLView, emptyTemplateIndex);
527526
if (isCollectionEmpty) {
528527
const emptyTemplateTNode = getExistingTNode(hostTView, emptyTemplateIndex);
529-
const dehydratedView = findMatchingDehydratedView(
528+
const dehydratedView = findAndReconcileMatchingDehydratedViews(
530529
lContainerForEmpty,
531-
emptyTemplateTNode.tView!.ssrId,
530+
emptyTemplateTNode,
531+
hostLView,
532532
);
533533
const embeddedLView = createAndRenderEmbeddedLView(
534534
hostLView,
@@ -543,6 +543,14 @@ export function ɵɵrepeater(collection: Iterable<unknown> | undefined | null):
543543
shouldAddViewToDom(emptyTemplateTNode, dehydratedView),
544544
);
545545
} else {
546+
// we know that an ssrId was generated for the empty template, but
547+
// we were unable to match it to a dehydrated view earlier, which
548+
// means that we may have changed branches between server and client.
549+
// We'll need to find and remove the stale empty template view.
550+
if (hostTView.firstUpdatePass) {
551+
removeDehydratedViews(lContainerForEmpty);
552+
}
553+
546554
removeLViewFromLContainer(lContainerForEmpty, 0);
547555
}
548556
}

packages/core/src/render3/instructions/template.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,9 +134,9 @@ export function declareTemplate(
134134
vars: number,
135135
tagName?: string | null,
136136
attrs?: TAttributes | null,
137+
flags?: TNodeFlags,
137138
localRefsIndex?: number | null,
138139
localRefExtractor?: LocalRefExtractor,
139-
flags?: TNodeFlags,
140140
): TNode {
141141
const adjustedIndex = index + HEADER_OFFSET;
142142
const tNode = declarationTView.firstCreatePass
@@ -231,6 +231,7 @@ export function ɵɵtemplate(
231231
vars,
232232
tagName,
233233
attrs,
234+
undefined,
234235
localRefsIndex,
235236
localRefExtractor,
236237
);

packages/core/test/bundling/hydration/bundle.golden_symbols.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,7 @@
247247
"executeViewQueryFn",
248248
"extractDefListOrFactory",
249249
"extractDirectiveDef",
250+
"findMatchingDehydratedViewImpl",
250251
"forEachSingleProvider",
251252
"forwardRef",
252253
"freeConsumers",
@@ -298,6 +299,7 @@
298299
"handleUnhandledError",
299300
"hasApplyArgsData",
300301
"hasInSkipHydrationBlockFlag",
302+
"hasMatchingDehydratedView",
301303
"hasSkipHydrationAttrOnRElement",
302304
"hasSkipHydrationAttrOnTNode",
303305
"icuContainerIterate",

0 commit comments

Comments
 (0)