Skip to content

Commit c823841

Browse files
thePunderWomanAndrewKushnir
authored andcommitted
fix(core): ensures immediate trigger fires properly with lazy loaded routes (#60203)
In the case that a route was lazy loaded, some triggers would never properly finish hydrating due to things firing before the route finished resolving. This will find the topmost parent defer block and ensure the registry knows about it before trying to hydrate. In the case that the registry does not yet know, just the affected triggers await app stability before initializing. fixes #59997 PR Close #60203
1 parent e044b4c commit c823841

File tree

4 files changed

+179
-22
lines changed

4 files changed

+179
-22
lines changed

packages/core/src/defer/registry.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,16 @@ export class DehydratedBlockRegistry {
3838

3939
add(blockId: string, info: DehydratedDeferBlock) {
4040
this.registry.set(blockId, info);
41+
// It's possible that hydration is queued that's waiting for the
42+
// resolution of a lazy loaded route. In this case, we ensure
43+
// the callback function is called to continue the hydration process
44+
// for the queued block set.
45+
if (this.awaitingCallbacks.has(blockId)) {
46+
const awaitingCallbacks = this.awaitingCallbacks.get(blockId)!;
47+
for (const cb of awaitingCallbacks) {
48+
cb();
49+
}
50+
}
4151
}
4252

4353
get(blockId: string): DehydratedDeferBlock | null {
@@ -55,6 +65,7 @@ export class DehydratedBlockRegistry {
5565
this.jsActionMap.delete(blockId);
5666
this.invokeTriggerCleanupFns(blockId);
5767
this.hydrating.delete(blockId);
68+
this.awaitingCallbacks.delete(blockId);
5869
}
5970
if (this.size === 0) {
6071
this.contract.instance?.cleanUp();
@@ -87,6 +98,15 @@ export class DehydratedBlockRegistry {
8798
// Blocks that are being hydrated.
8899
hydrating = new Map<string, PromiseWithResolvers<void>>();
89100

101+
// Blocks that are awaiting a defer instruction finish.
102+
private awaitingCallbacks = new Map<string, Function[]>();
103+
104+
awaitParentBlock(topmostParentBlock: string, callback: Function) {
105+
const parentBlockAwaitCallbacks = this.awaitingCallbacks.get(topmostParentBlock) ?? [];
106+
parentBlockAwaitCallbacks.push(callback);
107+
this.awaitingCallbacks.set(topmostParentBlock, parentBlockAwaitCallbacks);
108+
}
109+
90110
/** @nocollapse */
91111
static ɵprov = /** @pureOrBreakMyCode */ /* @__PURE__ */ ɵɵdefineInjectable({
92112
token: DehydratedBlockRegistry,

packages/core/src/defer/triggering.ts

Lines changed: 61 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -359,8 +359,8 @@ export function triggerDeferBlock(triggerType: TriggerType, lView: LView, tNode:
359359
}
360360

361361
/**
362-
* The core mechanism for incremental hydration. This triggers
363-
* hydration for all the blocks in the tree that need to be hydrated
362+
* The core mechanism for incremental hydration. This triggers or
363+
* queues hydration for all the blocks in the tree that need to be hydrated
364364
* and keeps track of all those blocks that were hydrated along the way.
365365
*
366366
* Note: the `replayQueuedEventsFn` is only provided when hydration is invoked
@@ -381,31 +381,70 @@ export async function triggerHydrationFromBlockName(
381381
return;
382382
}
383383

384-
// The parent promise is the possible case of a list of defer blocks already being queued
385-
// If it is queued, it'll exist; otherwise it'll be null. The hydration queue will contain all
386-
// elements that need to be hydrated, sans any that have promises already
384+
// Trigger resource loading and hydration for the blocks in the queue in the order of highest block
385+
// to lowest block. Once a block has finished resource loading, after next render fires after hydration
386+
// finishes. The new block will have its defer instruction called and will be in the registry.
387+
// Due to timing related to potential nested control flow, this has to be scheduled after the next render.
387388
const {parentBlockPromise, hydrationQueue} = getParentBlockHydrationQueue(blockName, injector);
389+
if (hydrationQueue.length === 0) return;
390+
391+
// It's possible that the hydrationQueue topmost item is actually in the process of hydrating and has
392+
// a promise already. In that case, we don't want to destroy that promise and queue it again.
393+
if (parentBlockPromise !== null) {
394+
hydrationQueue.shift();
395+
}
388396

389397
// The hydrating map in the registry prevents re-triggering hydration for a block that's already in
390398
// the hydration queue. Here we generate promises for each of the blocks about to be hydrated
391399
populateHydratingStateForQueue(dehydratedBlockRegistry, hydrationQueue);
392400

393-
// Trigger resource loading and hydration for the blocks in the queue in the order of highest block
394-
// to lowest block. Once a block has finished resource loading, after next render fires after hydration
395-
// finishes. The new block will have its defer instruction called and will be in the registry.
396-
// Due to timing related to potential nested control flow, this has to be scheduled after the next render.
401+
// We await this after populating the hydration state so we can prevent re-triggering hydration for
402+
// the same blocks while this promise is being awaited.
403+
if (parentBlockPromise !== null) {
404+
await parentBlockPromise;
405+
}
406+
407+
const topmostParentBlock = hydrationQueue[0];
408+
if (dehydratedBlockRegistry.has(topmostParentBlock)) {
409+
// the topmost parent block is already in the registry and we can proceed
410+
// with hydration.
411+
await triggerHydrationForBlockQueue(injector, hydrationQueue, replayQueuedEventsFn);
412+
} else {
413+
// the topmost parent block is not yet in the registry, which may mean
414+
// a lazy loaded route, a control flow branch was taken, a route has
415+
// been navigated, etc. So we need to queue up the hydration process
416+
// so that it can be finished after the top block has had its defer
417+
// instruction executed.
418+
dehydratedBlockRegistry.awaitParentBlock(
419+
topmostParentBlock,
420+
async () =>
421+
await triggerHydrationForBlockQueue(injector, hydrationQueue, replayQueuedEventsFn),
422+
);
423+
}
424+
}
425+
426+
/**
427+
* The core mechanism for incremental hydration. This triggers
428+
* hydration for all the blocks in the tree that need to be hydrated
429+
* and keeps track of all those blocks that were hydrated along the way.
430+
*
431+
* Note: the `replayQueuedEventsFn` is only provided when hydration is invoked
432+
* as a result of an event replay (via JsAction). When hydration is invoked from
433+
* an instruction set (e.g. `deferOnImmediate`) - there is no need to replay any
434+
* events.
435+
*/
436+
export async function triggerHydrationForBlockQueue(
437+
injector: Injector,
438+
hydrationQueue: string[],
439+
replayQueuedEventsFn?: Function,
440+
): Promise<void> {
441+
const dehydratedBlockRegistry = injector.get(DEHYDRATED_BLOCK_REGISTRY);
442+
const blocksBeingHydrated = dehydratedBlockRegistry.hydrating;
397443

398444
// Indicate that we have some pending async work.
399445
const pendingTasks = injector.get(PendingTasksInternal);
400446
const taskId = pendingTasks.add();
401447

402-
// If the parent block was being hydrated, but the process has
403-
// not yet complete, wait until parent block promise settles before
404-
// going over dehydrated blocks from the queue.
405-
if (parentBlockPromise !== null) {
406-
await parentBlockPromise;
407-
}
408-
409448
// Actually do the triggering and hydration of the queue of blocks
410449
for (let blockQueueIdx = 0; blockQueueIdx < hydrationQueue.length; blockQueueIdx++) {
411450
const dehydratedBlockId = hydrationQueue[blockQueueIdx];
@@ -443,8 +482,10 @@ export async function triggerHydrationFromBlockName(
443482
}
444483
}
445484

446-
// Await hydration completion for the requested block.
447-
await blocksBeingHydrated.get(blockName)?.promise;
485+
const lastBlockName = hydrationQueue[hydrationQueue.length - 1];
486+
487+
// Await hydration completion for the last block.
488+
await blocksBeingHydrated.get(lastBlockName)?.promise;
448489

449490
// All async work is done, remove the taskId from the registry.
450491
pendingTasks.remove(taskId);
@@ -456,7 +497,7 @@ export async function triggerHydrationFromBlockName(
456497

457498
// Cleanup after hydration of all affected defer blocks.
458499
cleanupHydratedDeferBlocks(
459-
dehydratedBlockRegistry.get(blockName),
500+
dehydratedBlockRegistry.get(lastBlockName),
460501
hydrationQueue,
461502
dehydratedBlockRegistry,
462503
injector.get(ApplicationRef),
@@ -683,7 +724,7 @@ function setTimerTriggers(injector: Injector, elementTriggers: ElementTrigger[])
683724
function setImmediateTriggers(injector: Injector, elementTriggers: ElementTrigger[]) {
684725
for (const elementTrigger of elementTriggers) {
685726
// Note: we intentionally avoid awaiting each call and instead kick off
686-
// th hydration process simultaneously for all defer blocks with this trigger;
727+
// the hydration process simultaneously for all defer blocks with this trigger;
687728
triggerHydrationFromBlockName(injector, elementTrigger.blockName);
688729
}
689730
}

packages/core/src/hydration/utils.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -586,7 +586,6 @@ export function getParentBlockHydrationQueue(
586586
isTopMostDeferBlock = dehydratedBlockRegistry.has(currentBlockId);
587587
const hydratingParentBlock = dehydratedBlockRegistry.hydrating.get(currentBlockId);
588588
if (parentBlockPromise === null && hydratingParentBlock != null) {
589-
// TODO: add an ngDevMode asset that `hydratingParentBlock.promise` exists and is of type Promise.
590589
parentBlockPromise = hydratingParentBlock.promise;
591590
break;
592591
}

packages/platform-server/test/incremental_hydration_spec.ts

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1202,7 +1202,7 @@ describe('platform-server partial hydration integration', () => {
12021202
appRef.tick();
12031203

12041204
expect(appHostNode.outerHTML).toContain('<span id="test">end</span>');
1205-
}, 100_000);
1205+
});
12061206

12071207
describe('idle', () => {
12081208
/**
@@ -2619,5 +2619,102 @@ describe('platform-server partial hydration integration', () => {
26192619

26202620
expect(appHostNode.outerHTML).toContain('<p>OtherCmp content</p>');
26212621
});
2622+
2623+
it('should trigger immediate with a lazy loaded route', async () => {
2624+
@Component({
2625+
selector: 'nested-more',
2626+
template: `
2627+
<div>
2628+
@defer(hydrate on immediate) {
2629+
<button id="click-me" (click)="clickMe()">Click me I'm dehydrated?</button>
2630+
<p id="hydrated">{{hydrated()}}</p>
2631+
}
2632+
</div>
2633+
`,
2634+
})
2635+
class NestedMoreCmp {
2636+
hydrated = signal('nope');
2637+
constructor() {
2638+
if (!isPlatformServer(inject(PLATFORM_ID))) {
2639+
this.hydrated.set('yup');
2640+
}
2641+
}
2642+
}
2643+
@Component({
2644+
selector: 'nested',
2645+
imports: [NestedMoreCmp],
2646+
template: `
2647+
<div>
2648+
@defer(hydrate on interaction) {
2649+
<nested-more />
2650+
}
2651+
</div>
2652+
`,
2653+
})
2654+
class NestedCmp {}
2655+
2656+
@Component({
2657+
selector: 'lazy',
2658+
imports: [NestedCmp],
2659+
template: `
2660+
@defer (hydrate on interaction) {
2661+
<nested />
2662+
}
2663+
`,
2664+
})
2665+
class LazyCmp {}
2666+
2667+
const routes: Routes = [
2668+
{
2669+
path: '',
2670+
loadComponent: () => dynamicImportOf(LazyCmp, 50),
2671+
},
2672+
];
2673+
2674+
@Component({
2675+
selector: 'app',
2676+
imports: [RouterOutlet],
2677+
template: `
2678+
Works!
2679+
<router-outlet />
2680+
`,
2681+
})
2682+
class SimpleComponent {
2683+
location = inject(Location);
2684+
}
2685+
2686+
const appId = 'custom-app-id';
2687+
const providers = [
2688+
{provide: APP_ID, useValue: appId},
2689+
{provide: PlatformLocation, useClass: MockPlatformLocation},
2690+
provideRouter(routes),
2691+
] as unknown as Provider[];
2692+
const hydrationFeatures = () => [withIncrementalHydration()];
2693+
2694+
const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures});
2695+
const ssrContents = getAppContents(html);
2696+
2697+
expect(ssrContents).toContain(
2698+
`<button id="click-me" jsaction="click:;" ngb="d2">Click me I'm dehydrated?</button>`,
2699+
);
2700+
expect(ssrContents).toContain(`<p id="hydrated">nope</p>`);
2701+
2702+
resetTViewsFor(SimpleComponent, LazyCmp);
2703+
2704+
const doc = getDocument();
2705+
const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, {
2706+
envProviders: [...providers],
2707+
hydrationFeatures,
2708+
});
2709+
const compRef = getComponentRef<SimpleComponent>(appRef);
2710+
await appRef.whenStable();
2711+
await allPendingDynamicImports();
2712+
const appHostNode = compRef.location.nativeElement;
2713+
2714+
expect(appHostNode.outerHTML).toContain(
2715+
`<button id="click-me">Click me I'm dehydrated?</button>`,
2716+
);
2717+
expect(appHostNode.outerHTML).toContain(`<p id="hydrated">yup</p>`);
2718+
});
26222719
});
26232720
});

0 commit comments

Comments
 (0)