Skip to content

Commit bd08d1d

Browse files
fix(core): Prevents race condition of cleanup for incremental hydration (angular#58722)
When hydrating a tree of blocks, this prevents cleanup from firing more than once per tree. It also ensures the cleanup happens after hydration has finished. fixes: angular#58690 PR Close angular#58722
1 parent 1fe001e commit bd08d1d

File tree

8 files changed

+420
-143
lines changed

8 files changed

+420
-143
lines changed

packages/core/src/defer/registry.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ export class DehydratedBlockRegistry {
8585
}
8686

8787
// Blocks that are being hydrated.
88-
hydrating = new Set<string>();
88+
hydrating = new Map<string, PromiseWithResolvers<void>>();
8989

9090
/** @nocollapse */
9191
static ɵprov = /** @pureOrBreakMyCode */ /* @__PURE__ */ ɵɵdefineInjectable({

packages/core/src/defer/rendering.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -320,13 +320,6 @@ function applyDeferBlockState(
320320
lDetails[ON_COMPLETE_FNS] = null;
321321
}
322322
}
323-
324-
if (newState === DeferBlockState.Complete && Array.isArray(lDetails[ON_COMPLETE_FNS])) {
325-
for (const callback of lDetails[ON_COMPLETE_FNS]) {
326-
callback();
327-
}
328-
lDetails[ON_COMPLETE_FNS] = null;
329-
}
330323
}
331324

332325
/**

packages/core/src/defer/triggering.ts

Lines changed: 117 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {afterNextRender} from '../render3/after_render/hooks';
1010
import {Injector} from '../di';
1111
import {internalImportProvidersFrom} from '../di/provider_collection';
1212
import {RuntimeError, RuntimeErrorCode} from '../errors';
13-
import {cleanupDeferBlock} from '../hydration/cleanup';
13+
import {cleanupHydratedDeferBlocks} from '../hydration/cleanup';
1414
import {BlockSummary, ElementTrigger, NUM_ROOT_NODES} from '../hydration/interfaces';
1515
import {
1616
assertSsrIdDefined,
@@ -39,7 +39,6 @@ import {
3939
DeferBlockState,
4040
DeferBlockTrigger,
4141
DeferDependenciesLoadingState,
42-
DehydratedDeferBlock,
4342
LDeferBlockDetails,
4443
ON_COMPLETE_FNS,
4544
SSR_BLOCK_STATE,
@@ -63,6 +62,7 @@ import {
6362
getPrimaryBlockTNode,
6463
getTDeferBlockDetails,
6564
} from './utils';
65+
import {ApplicationRef} from '../application/application_ref';
6666

6767
/**
6868
* Schedules triggering of a defer block for `on idle` and `on timer` conditions.
@@ -337,61 +337,126 @@ export function triggerDeferBlock(lView: LView, tNode: TNode) {
337337
}
338338

339339
/**
340-
* Triggers hydration from a given defer block's unique SSR ID.
341-
* This includes firing any queued events that need to be replayed
342-
* and handling any post hydration cleanup.
340+
* The core mechanism for incremental hydration. This triggers
341+
* hydration for all the blocks in the tree that need to be hydrated
342+
* and keeps track of all those blocks that were hydrated along the way.
343+
*
344+
* Note: the `replayQueuedEventsFn` is only provided when hydration is invoked
345+
* as a result of an event replay (via JsAction). When hydration is invoked from
346+
* an instruction set (e.g. `deferOnImmediate`) - there is no need to replay any
347+
* events.
343348
*/
344349
export async function triggerHydrationFromBlockName(
345350
injector: Injector,
346351
blockName: string,
347-
replayFn: Function = () => {},
348-
): Promise<void> {
349-
const {deferBlock, hydratedBlocks} = await triggerBlockTreeHydrationByName(injector, blockName);
350-
replayFn(hydratedBlocks);
351-
await cleanupDeferBlock(deferBlock, hydratedBlocks, injector);
352+
replayQueuedEventsFn?: Function,
353+
) {
354+
const dehydratedBlockRegistry = injector.get(DEHYDRATED_BLOCK_REGISTRY);
355+
const blocksBeingHydrated = dehydratedBlockRegistry.hydrating;
356+
357+
// Make sure we don't hydrate/trigger the same thing multiple times
358+
if (blocksBeingHydrated.has(blockName)) {
359+
return;
360+
}
361+
362+
// The parent promise is the possible case of a list of defer blocks already being queued
363+
// If it is queued, it'll exist; otherwise it'll be null. The hydration queue will contain all
364+
// elements that need to be hydrated, sans any that have promises already
365+
const {parentBlockPromise, hydrationQueue} = getParentBlockHydrationQueue(blockName, injector);
366+
367+
// The hydrating map in the registry prevents re-triggering hydration for a block that's already in
368+
// the hydration queue. Here we generate promises for each of the blocks about to be hydrated
369+
populateHydratingStateForQueue(dehydratedBlockRegistry, hydrationQueue);
370+
371+
// Trigger resource loading and hydration for the blocks in the queue in the order of highest block
372+
// to lowest block. Once a block has finished resource loading, after next render fires after hydration
373+
// finishes. The new block will have its defer instruction called and will be in the registry.
374+
// Due to timing related to potential nested control flow, this has to be scheduled after the next render.
375+
376+
// Indicate that we have some pending async work.
377+
const pendingTasks = injector.get(PendingTasksInternal);
378+
const taskId = pendingTasks.add();
379+
380+
// If the parent block was being hydrated, but the process has
381+
// not yet complete, wait until parent block promise settles before
382+
// going over dehydrated blocks from the queue.
383+
if (parentBlockPromise !== null) {
384+
await parentBlockPromise;
385+
}
386+
387+
// Actually do the triggering and hydration of the queue of blocks
388+
for (const dehydratedBlockId of hydrationQueue) {
389+
await triggerDeferBlockResourceLoading(dehydratedBlockId, dehydratedBlockRegistry);
390+
await nextRender(injector);
391+
// TODO(incremental-hydration): assert (in dev mode) that a defer block is present in the dehydrated registry
392+
// at this point. If not - it means that the block has not been hydrated, for example due to different
393+
// `@if` conditions on the client and the server. If we detect this case, we should also do the cleanup
394+
// of all child block (promises, registry state, etc).
395+
// TODO(incremental-hydration): call `rejectFn` when lDetails[DEFER_BLOCK_STATE] is `DeferBlockState.Error`.
396+
blocksBeingHydrated.get(dehydratedBlockId)!.resolve();
397+
398+
// TODO(incremental-hydration): consider adding a wait for stability here
399+
}
400+
401+
// Await hydration completion for the requested block.
402+
await blocksBeingHydrated.get(blockName)?.promise;
403+
404+
// All async work is done, remove the taskId from the registry.
405+
pendingTasks.remove(taskId);
406+
407+
// Replay any queued events, if any exist and the replay operation was requested.
408+
if (replayQueuedEventsFn) {
409+
replayQueuedEventsFn(hydrationQueue);
410+
}
411+
412+
// Cleanup after hydration of all affected defer blocks.
413+
cleanupHydratedDeferBlocks(
414+
dehydratedBlockRegistry.get(blockName),
415+
hydrationQueue,
416+
dehydratedBlockRegistry,
417+
injector.get(ApplicationRef),
418+
);
352419
}
353420

354421
/**
355-
* Triggers the resource loading for a defer block and passes back a promise
356-
* to handle cleanup on completion
422+
* Generates a new promise for every defer block in the hydrating queue
357423
*/
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).
424+
function populateHydratingStateForQueue(registry: DehydratedBlockRegistry, queue: string[]) {
425+
for (let blockId of queue) {
426+
registry.hydrating.set(blockId, Promise.withResolvers());
427+
}
428+
}
370429

430+
// Waits for the next render cycle to complete
431+
function nextRender(injector: Injector): Promise<void> {
371432
let resolve: VoidFunction;
372433
const promise = new Promise<void>((resolveFn) => {
373434
resolve = resolveFn;
374435
});
436+
afterNextRender(() => resolve(), {injector});
437+
return promise;
438+
}
375439

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-
);
440+
function triggerDeferBlockResourceLoading(
441+
dehydratedBlockId: string,
442+
dehydratedBlockRegistry: DehydratedBlockRegistry,
443+
) {
444+
let resolve: Function;
445+
const promise = new Promise((resolveFn) => (resolve = resolveFn));
446+
const deferBlock = dehydratedBlockRegistry.get(dehydratedBlockId);
447+
// Since we trigger hydration for nested defer blocks in a sequence (parent -> child),
448+
// there is a chance that a defer block may not be present at hydration time. For example,
449+
// when a nested block was in an `@if` condition, which has changed.
450+
if (deferBlock !== null) {
451+
const {tNode, lView} = deferBlock;
452+
const lDetails = getLDeferBlockDetails(lView, tNode);
453+
onDeferBlockCompletion(lDetails, () => resolve());
454+
triggerDeferBlock(lView, tNode);
455+
456+
// TODO(incremental-hydration): handle the cleanup for cases when
457+
// defer block is no longer present during hydration (e.g. `@if` condition
458+
// has changed during hydration/rendering).
459+
}
395460
return promise;
396461
}
397462

@@ -406,47 +471,6 @@ function onDeferBlockCompletion(lDetails: LDeferBlockDetails, callback: VoidFunc
406471
lDetails[ON_COMPLETE_FNS].push(callback);
407472
}
408473

409-
/**
410-
* The core mechanism for incremental hydration. This triggers
411-
* hydration for all the blocks in the tree that need to be hydrated and keeps
412-
* track of all those blocks that were hydrated along the way.
413-
*/
414-
async function triggerBlockTreeHydrationByName(
415-
injector: Injector,
416-
blockName: string,
417-
): Promise<{
418-
deferBlock: DehydratedDeferBlock | null;
419-
hydratedBlocks: Set<string>;
420-
}> {
421-
const dehydratedBlockRegistry = injector.get(DEHYDRATED_BLOCK_REGISTRY);
422-
423-
// Make sure we don't hydrate/trigger the same thing multiple times
424-
if (dehydratedBlockRegistry.hydrating.has(blockName))
425-
return {deferBlock: null, hydratedBlocks: new Set<string>()};
426-
427-
// Step 1: Get the queue of items that needs to be hydrated
428-
const hydrationQueue = getParentBlockHydrationQueue(blockName, injector);
429-
430-
// Step 2: Add all the items in the queue to the registry at once so we don't trigger hydration on them while
431-
// the sequence of triggers fires.
432-
hydrationQueue.forEach((id) => dehydratedBlockRegistry.hydrating.add(id));
433-
434-
// Step 3: hydrate each block in the queue. It will be in descending order from the top down.
435-
for (const dehydratedBlockId of hydrationQueue) {
436-
// Step 4: Run the actual trigger function to fetch dependencies.
437-
// Triggering a block adds any of its child defer blocks to the registry.
438-
await triggerAndWaitForCompletion(dehydratedBlockId, dehydratedBlockRegistry, injector);
439-
}
440-
441-
const hydratedBlocks = new Set<string>(hydrationQueue);
442-
443-
// The last item in the queue was the original target block;
444-
const hydratedBlockId = hydrationQueue.slice(-1)[0];
445-
const hydratedBlock = dehydratedBlockRegistry.get(hydratedBlockId)!;
446-
447-
return {deferBlock: hydratedBlock, hydratedBlocks};
448-
}
449-
450474
/**
451475
* Determines whether "hydrate" triggers should be activated. Triggers are activated in the following cases:
452476
* - on the server, when incremental hydration is enabled, to trigger the block and render the main content
@@ -576,7 +600,7 @@ export function processAndInitTriggers(
576600
setTimerTriggers(injector, timerElements);
577601
}
578602

579-
async function setIdleTriggers(injector: Injector, elementTriggers: ElementTrigger[]) {
603+
function setIdleTriggers(injector: Injector, elementTriggers: ElementTrigger[]) {
580604
for (const elementTrigger of elementTriggers) {
581605
const registry = injector.get(DEHYDRATED_BLOCK_REGISTRY);
582606
const onInvoke = () => triggerHydrationFromBlockName(injector, elementTrigger.blockName);
@@ -585,35 +609,34 @@ async function setIdleTriggers(injector: Injector, elementTriggers: ElementTrigg
585609
}
586610
}
587611

588-
async function setViewportTriggers(injector: Injector, elementTriggers: ElementTrigger[]) {
612+
function setViewportTriggers(injector: Injector, elementTriggers: ElementTrigger[]) {
589613
if (elementTriggers.length > 0) {
590614
const registry = injector.get(DEHYDRATED_BLOCK_REGISTRY);
591615
for (let elementTrigger of elementTriggers) {
592616
const cleanupFn = onViewport(
593617
elementTrigger.el,
594-
async () => {
595-
await triggerHydrationFromBlockName(injector, elementTrigger.blockName);
596-
},
618+
() => triggerHydrationFromBlockName(injector, elementTrigger.blockName),
597619
injector,
598620
);
599621
registry.addCleanupFn(elementTrigger.blockName, cleanupFn);
600622
}
601623
}
602624
}
603625

604-
async function setTimerTriggers(injector: Injector, elementTriggers: ElementTrigger[]) {
626+
function setTimerTriggers(injector: Injector, elementTriggers: ElementTrigger[]) {
605627
for (const elementTrigger of elementTriggers) {
606628
const registry = injector.get(DEHYDRATED_BLOCK_REGISTRY);
607-
const onInvoke = async () =>
608-
await triggerHydrationFromBlockName(injector, elementTrigger.blockName);
629+
const onInvoke = () => triggerHydrationFromBlockName(injector, elementTrigger.blockName);
609630
const timerFn = onTimer(elementTrigger.delay!);
610631
const cleanupFn = timerFn(onInvoke, injector);
611632
registry.addCleanupFn(elementTrigger.blockName, cleanupFn);
612633
}
613634
}
614635

615-
async function setImmediateTriggers(injector: Injector, elementTriggers: ElementTrigger[]) {
636+
function setImmediateTriggers(injector: Injector, elementTriggers: ElementTrigger[]) {
616637
for (const elementTrigger of elementTriggers) {
617-
await triggerHydrationFromBlockName(injector, elementTrigger.blockName);
638+
// Note: we intentionally avoid awaiting each call and instead kick off
639+
// th hydration process simultaneously for all defer blocks with this trigger;
640+
triggerHydrationFromBlockName(injector, elementTrigger.blockName);
618641
}
619642
}

packages/core/src/hydration/cleanup.ts

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

9-
import {ApplicationRef, whenStable} from '../application/application_ref';
9+
import {ApplicationRef} from '../application/application_ref';
1010
import {DehydratedDeferBlock} from '../defer/interfaces';
11-
import {DEHYDRATED_BLOCK_REGISTRY} from '../defer/registry';
12-
import {Injector} from '../di';
11+
import {DehydratedBlockRegistry} from '../defer/registry';
1312
import {
1413
CONTAINER_HEADER_OFFSET,
1514
DEHYDRATED_VIEWS,
@@ -138,20 +137,15 @@ export function cleanupDehydratedViews(appRef: ApplicationRef) {
138137
* hydrated. This removes all the jsaction attributes, timers, observers,
139138
* dehydrated views and containers
140139
*/
141-
export async function cleanupDeferBlock(
140+
export function cleanupHydratedDeferBlocks(
142141
deferBlock: DehydratedDeferBlock | null,
143-
hydratedBlocks: Set<string>,
144-
injector: Injector,
145-
): Promise<void> {
142+
hydratedBlocks: string[],
143+
registry: DehydratedBlockRegistry,
144+
appRef: ApplicationRef,
145+
): void {
146146
if (deferBlock !== null) {
147-
// hydratedBlocks is a set, and needs to be converted to an array
148-
// for removing listeners
149-
const registry = injector.get(DEHYDRATED_BLOCK_REGISTRY);
150-
registry.cleanup([...hydratedBlocks]);
147+
registry.cleanup(hydratedBlocks);
151148
cleanupLContainer(deferBlock.lContainer);
152-
cleanupDehydratedViews(injector.get(ApplicationRef));
149+
cleanupDehydratedViews(appRef);
153150
}
154-
// we need to wait for app stability here so we don't continue before
155-
// the hydration process has finished, which could result in problems
156-
return whenStable(injector.get(ApplicationRef));
157151
}

packages/core/src/hydration/event_replay.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -250,24 +250,25 @@ export function invokeRegisteredReplayListeners(
250250
}
251251
}
252252

253-
export async function hydrateAndInvokeBlockListeners(
253+
function hydrateAndInvokeBlockListeners(
254254
blockName: string,
255255
injector: Injector,
256256
event: Event,
257257
currentTarget: Element,
258258
) {
259259
blockEventQueue.push({event, currentTarget});
260-
await triggerHydrationFromBlockName(injector, blockName, replayQueuedBlockEvents);
260+
triggerHydrationFromBlockName(injector, blockName, replayQueuedBlockEvents);
261261
}
262262

263-
function replayQueuedBlockEvents(hydratedBlocks: Set<string>) {
263+
function replayQueuedBlockEvents(hydratedBlocks: string[]) {
264264
// clone the queue
265265
const queue = [...blockEventQueue];
266+
const hydrated = new Set<string>(hydratedBlocks);
266267
// empty it
267268
blockEventQueue = [];
268269
for (let {event, currentTarget} of queue) {
269270
const blockName = currentTarget.getAttribute(DEFER_BLOCK_SSR_ID_ATTRIBUTE)!;
270-
if (hydratedBlocks.has(blockName)) {
271+
if (hydrated.has(blockName)) {
271272
invokeListeners(event, currentTarget);
272273
} else {
273274
// requeue events that weren't yet hydrated

0 commit comments

Comments
 (0)