Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 5fe57d4

Browse files
fix(core): fixes issues with control flow and incremental hydration (#58644)
If a defer block is nested inside control flow while also being nested underneath a defer block all using incremental hydration, timing issues prevented the child nodes from being properly hydrated. This ensures hydration happens on next render. PR Close #58644
1 parent f503c15 commit 5fe57d4

File tree

3 files changed

+125
-16
lines changed

3 files changed

+125
-16
lines changed

‎packages/core/src/defer/registry.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,11 @@ export class DehydratedBlockRegistry {
3535
private cleanupFns = new Map<string, Function[]>();
3636
private jsActionMap: Map<string, Set<Element>> = inject(JSACTION_BLOCK_ELEMENT_MAP);
3737
private contract: EventContractDetails = inject(JSACTION_EVENT_CONTRACT);
38+
3839
add(blockId: string, info: DehydratedDeferBlock) {
3940
this.registry.set(blockId, info);
4041
}
42+
4143
get(blockId: string): DehydratedDeferBlock | null {
4244
return this.registry.get(blockId) ?? null;
4345
}

‎packages/core/src/defer/triggering.ts

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9+
import {afterNextRender} from '../render3/after_render/hooks';
910
import {Injector} from '../di';
1011
import {internalImportProvidersFrom} from '../di/provider_collection';
1112
import {RuntimeError, RuntimeErrorCode} from '../errors';
@@ -46,7 +47,7 @@ import {
4647
TDeferBlockDetails,
4748
TriggerType,
4849
} from './interfaces';
49-
import {DEHYDRATED_BLOCK_REGISTRY} from './registry';
50+
import {DEHYDRATED_BLOCK_REGISTRY, DehydratedBlockRegistry} from './registry';
5051
import {
5152
DEFER_BLOCK_CONFIG,
5253
DEFER_BLOCK_DEPENDENCY_INTERCEPTOR,
@@ -354,12 +355,43 @@ export async function triggerHydrationFromBlockName(
354355
* Triggers the resource loading for a defer block and passes back a promise
355356
* to handle cleanup on completion
356357
*/
357-
export function triggerAndWaitForCompletion(deferBlock: DehydratedDeferBlock): Promise<void> {
358-
const lDetails = getLDeferBlockDetails(deferBlock.lView, deferBlock.tNode);
359-
const promise = new Promise<void>((resolve) => {
360-
onDeferBlockCompletion(lDetails, resolve);
358+
export function triggerAndWaitForCompletion(
359+
dehydratedBlockId: string,
360+
dehydratedBlockRegistry: DehydratedBlockRegistry,
361+
injector: Injector,
362+
): Promise<void> {
363+
// TODO(incremental-hydration): This is a temporary fix to resolve control flow
364+
// cases where nested defer blocks are inside control flow. We wait for each nested
365+
// defer block to load and render before triggering the next one in a sequence. This is
366+
// needed to ensure that corresponding LViews & LContainers are available for a block
367+
// before we trigger it. We need to investigate how to get rid of the `afterNextRender`
368+
// calls (in the nearest future) and do loading of all dependencies of nested defer blocks
369+
// in parallel (later).
370+
371+
let resolve: VoidFunction;
372+
const promise = new Promise<void>((resolveFn) => {
373+
resolve = resolveFn;
361374
});
362-
triggerDeferBlock(deferBlock.lView, deferBlock.tNode);
375+
376+
afterNextRender(
377+
() => {
378+
const deferBlock = dehydratedBlockRegistry.get(dehydratedBlockId);
379+
// Since we trigger hydration for nested defer blocks in a sequence (parent -> child),
380+
// there is a chance that a defer block may not be present at hydration time. For example,
381+
// when a nested block was in an `@if` condition, which has changed.
382+
// TODO(incremental-hydration): add tests to verify the behavior mentioned above.
383+
if (deferBlock !== null) {
384+
const {tNode, lView} = deferBlock;
385+
const lDetails = getLDeferBlockDetails(lView, tNode);
386+
onDeferBlockCompletion(lDetails, resolve);
387+
triggerDeferBlock(lView, tNode);
388+
// TODO(incremental-hydration): handle the cleanup for cases when
389+
// defer block is no longer present during hydration (e.g. `@if` condition
390+
// has changed during hydration/rendering).
391+
}
392+
},
393+
{injector},
394+
);
363395
return promise;
364396
}
365397

@@ -401,12 +433,9 @@ async function triggerBlockTreeHydrationByName(
401433

402434
// Step 3: hydrate each block in the queue. It will be in descending order from the top down.
403435
for (const dehydratedBlockId of hydrationQueue) {
404-
// The registry will have the item in the queue after each loop.
405-
const deferBlock = dehydratedBlockRegistry.get(dehydratedBlockId)!;
406-
407436
// Step 4: Run the actual trigger function to fetch dependencies.
408437
// Triggering a block adds any of its child defer blocks to the registry.
409-
await triggerAndWaitForCompletion(deferBlock);
438+
await triggerAndWaitForCompletion(dehydratedBlockId, dehydratedBlockRegistry, injector);
410439
}
411440

412441
const hydratedBlocks = new Set<string>(hydrationQueue);

‎packages/platform-server/test/incremental_hydration_spec.ts

Lines changed: 84 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,9 @@ import {
2828
} from '@angular/platform-browser';
2929
import {TestBed} from '@angular/core/testing';
3030
import {PLATFORM_BROWSER_ID} from '@angular/common/src/platform_id';
31-
import {DEHYDRATED_BLOCK_REGISTRY, DehydratedBlockRegistry} from '@angular/core/src/defer/registry';
31+
import {DEHYDRATED_BLOCK_REGISTRY} from '@angular/core/src/defer/registry';
3232
import {JSACTION_BLOCK_ELEMENT_MAP} from '@angular/core/src/hydration/tokens';
33-
import {
34-
EventContractDetails,
35-
JSACTION_EVENT_CONTRACT,
36-
} from '@angular/core/src/event_delegation_utils';
33+
import {JSACTION_EVENT_CONTRACT} from '@angular/core/src/event_delegation_utils';
3734

3835
describe('platform-server partial hydration integration', () => {
3936
const originalWindow = globalThis.window;
@@ -1445,6 +1442,88 @@ describe('platform-server partial hydration integration', () => {
14451442
});
14461443
});
14471444

1445+
describe('control flow', () => {
1446+
it('should support hydration for all items in a for loop', async () => {
1447+
@Component({
1448+
standalone: true,
1449+
selector: 'app',
1450+
template: `
1451+
<main>
1452+
@defer (on interaction; hydrate on interaction) {
1453+
<div id="main" (click)="fnA()">
1454+
<p>Main defer block rendered!</p>
1455+
@for (item of items; track $index) {
1456+
@defer (on interaction; hydrate on interaction) {
1457+
<article id="item-{{item}}">
1458+
defer block {{item}} rendered!
1459+
<span (click)="fnB()">{{value()}}</span>
1460+
</article>
1461+
} @placeholder {
1462+
<span>Outer block placeholder</span>
1463+
}
1464+
}
1465+
</div>
1466+
} @placeholder {
1467+
<span>Outer block placeholder</span>
1468+
}
1469+
</main>
1470+
`,
1471+
})
1472+
class SimpleComponent {
1473+
value = signal('start');
1474+
items = [1, 2, 3, 4, 5, 6];
1475+
fnA() {}
1476+
fnB() {
1477+
this.value.set('end');
1478+
}
1479+
}
1480+
1481+
const appId = 'custom-app-id';
1482+
const providers = [{provide: APP_ID, useValue: appId}];
1483+
const hydrationFeatures = () => [withIncrementalHydration()];
1484+
1485+
const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures});
1486+
const ssrContents = getAppContents(html);
1487+
1488+
// <main> uses "eager" `custom-app-id` namespace.
1489+
// <div>s inside a defer block have `d0` as a namespace.
1490+
expect(ssrContents).toContain('<article id="item-1" jsaction="click:;keydown:;"');
1491+
// Outer defer block is rendered.
1492+
expect(ssrContents).toContain('defer block 1 rendered');
1493+
1494+
// Internal cleanup before we do server->client transition in this test.
1495+
resetTViewsFor(SimpleComponent);
1496+
1497+
////////////////////////////////
1498+
const doc = getDocument();
1499+
const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, {
1500+
envProviders: [...providers, {provide: PLATFORM_ID, useValue: 'browser'}],
1501+
hydrationFeatures,
1502+
});
1503+
const compRef = getComponentRef<SimpleComponent>(appRef);
1504+
appRef.tick();
1505+
await whenStable(appRef);
1506+
1507+
const appHostNode = compRef.location.nativeElement;
1508+
1509+
expect(appHostNode.outerHTML).toContain('<article id="item-1" jsaction="click:;keydown:;"');
1510+
1511+
// Emit an event inside of a defer block, which should result
1512+
// in triggering the defer block (start loading deps, etc) and
1513+
// subsequent hydration.
1514+
const article = doc.getElementById('item-1')!;
1515+
const clickEvent = new CustomEvent('click', {bubbles: true});
1516+
article.dispatchEvent(clickEvent);
1517+
await timeout(1000); // wait for defer blocks to resolve
1518+
1519+
appRef.tick();
1520+
expect(appHostNode.outerHTML).not.toContain(
1521+
'<article id="item-1" jsaction="click:;keydown:;"',
1522+
);
1523+
expect(appHostNode.outerHTML).not.toContain('<span>Outer block placeholder</span>');
1524+
});
1525+
});
1526+
14481527
describe('cleanup', () => {
14491528
it('should cleanup partial hydration blocks appropriately', async () => {
14501529
@Component({
@@ -1585,7 +1664,6 @@ describe('platform-server partial hydration integration', () => {
15851664

15861665
await timeout(1000); // wait for defer blocks to resolve
15871666

1588-
appRef.tick();
15891667
expect(registry.size).toBe(1);
15901668
expect(registry.has('d0')).toBeFalsy();
15911669
expect(jsActionMap.size).toBe(1);

0 commit comments

Comments
 (0)