Skip to content

Commit

Permalink
refactor(core): add batching for defer blocks with on idle conditions
Browse files Browse the repository at this point in the history
This commit updates runtime logic of defer blocks to schedule a single `requestIdleCallback` for a group of defer blocks created within a single change detection cycle (for example, as a result of a defer block being defined in a for loop).
  • Loading branch information
AndrewKushnir committed Sep 13, 2023
1 parent 59387ee commit 1ead5b9
Show file tree
Hide file tree
Showing 2 changed files with 124 additions and 47 deletions.
139 changes: 100 additions & 39 deletions packages/core/src/render3/instructions/defer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/

import {InjectionToken, Injector} from '../../di';
import {InjectionToken, Injector, ɵɵdefineInjectable} from '../../di';
import {findMatchingDehydratedView} from '../../hydration/views';
import {populateDehydratedViewsInContainer} from '../../linker/view_container_ref';
import {assertDefined, assertEqual, throwError} from '../../util/assert';
Expand Down Expand Up @@ -36,15 +36,6 @@ function shouldTriggerDeferBlock(injector: Injector): boolean {
return isPlatformBrowser(injector);
}

/**
* Shims for the `requestIdleCallback` and `cancelIdleCallback` functions for environments
* where those functions are not available (e.g. Node.js).
*/
const _requestIdleCallback =
typeof requestIdleCallback !== 'undefined' ? requestIdleCallback : setTimeout;
const _cancelIdleCallback =
typeof requestIdleCallback !== 'undefined' ? cancelIdleCallback : clearTimeout;

/**
* Creates runtime data structures for `{#defer}` blocks.
*
Expand Down Expand Up @@ -161,11 +152,7 @@ export function ɵɵdeferOnIdle() {
const tNode = getCurrentTNode()!;

renderPlaceholder(lView, tNode);

// Note: we pass an `lView` as a second argument to cancel an `idle`
// callback in case an LView got destroyed before an `idle` callback
// is invoked.
onIdle(() => triggerDeferBlock(lView, tNode), lView);
onIdle(() => triggerDeferBlock(lView, tNode), lView, true /* withLViewCleanup */);
}

/**
Expand All @@ -183,10 +170,10 @@ export function ɵɵdeferPrefetchOnIdle() {
tDetails.loadingState = DeferDependenciesLoadingState.SCHEDULED;

// In case of prefetching, we intentionally avoid cancelling prefetching if
// an underlying LView get destroyed (thus passing `null` as a second argument),
// because there might be other LViews (that represent embedded views) that
// depend on resource loading.
onIdle(() => triggerResourceLoading(tDetails, tView, lView), null /* LView */);
// an underlying LView get destroyed, because there might be other LViews
// (that represent embedded views) that depend on resource loading.
const prefetch = () => triggerResourceLoading(tDetails, tView, lView);
onIdle(prefetch, lView, false /* withLViewCleanup */);
}
}

Expand Down Expand Up @@ -263,30 +250,30 @@ export function ɵɵdeferPrefetchOnViewport(target?: unknown) {} // TODO: imple
* Helper function to schedule a callback to be invoked when a browser becomes idle.
*
* @param callback A function to be invoked when a browser becomes idle.
* @param lView An optional LView that hosts an instance of a defer block. LView is
* used to register a cleanup callback in case that LView got destroyed before
* callback was invoked. In this case, an `idle` callback is never invoked. This is
* helpful for cases when a defer block has scheduled rendering, but an underlying
* LView got destroyed prior to th block rendering.
* @param lView LView that hosts an instance of a defer block.
* @param withLViewCleanup A flag that indicates whether a scheduled callback
* should be cancelled in case an LView is destroyed before a callback
* was invoked.
*/
function onIdle(callback: VoidFunction, lView: LView|null) {
let id: number;
const removeIdleCallback = () => _cancelIdleCallback(id);
id = _requestIdleCallback(() => {
removeIdleCallback();
if (lView !== null) {
// The idle callback is invoked, we no longer need
// to retain a cleanup callback in an LView.
removeLViewOnDestroy(lView, removeIdleCallback);
}
callback();
}) as number;

if (lView !== null) {
function onIdle(callback: VoidFunction, lView: LView, withLViewCleanup: boolean) {
const injector = lView[INJECTOR]!;
const scheduler = injector.get(OnIdleScheduler);

let wrappedCallback: VoidFunction;
const removeCallback = () => scheduler.remove(wrappedCallback);

wrappedCallback = withLViewCleanup ? () => {
callback();
removeLViewOnDestroy(lView, removeCallback);
} : callback;

scheduler.add(wrappedCallback);

if (withLViewCleanup) {
// Store a cleanup function on LView, so that we cancel idle
// callback in case this LView is destroyed before a callback
// is invoked.
storeLViewOnDestroy(lView, removeIdleCallback);
storeLViewOnDestroy(lView, removeCallback);
}
}

Expand Down Expand Up @@ -605,3 +592,77 @@ export interface DeferBlockDependencyInterceptor {
export const DEFER_BLOCK_DEPENDENCY_INTERCEPTOR =
new InjectionToken<DeferBlockDependencyInterceptor>(
ngDevMode ? 'DEFER_BLOCK_DEPENDENCY_INTERCEPTOR' : '');


/**
* Shims for the `requestIdleCallback` and `cancelIdleCallback` functions for environments
* where those functions are not available (e.g. Node.js).
*/
const _requestIdleCallback =
typeof requestIdleCallback !== 'undefined' ? requestIdleCallback : setTimeout;
const _cancelIdleCallback =
typeof requestIdleCallback !== 'undefined' ? cancelIdleCallback : clearTimeout;

/**
* Helper service to schedule `requestIdleCallback`s for batches of defer blocks,
* to avoid scheduling `requestIdleCallback` for each defer block (e.g. if
* a number of defer blocks are created inside a for loop).
*/
class OnIdleScheduler {
executingCallbacks = false;
idleId: number|null = null;
current = new Set<VoidFunction>();
deferred = new Set<VoidFunction>();

add(callback: VoidFunction) {
const target = this.executingCallbacks ? this.deferred : this.current;
target.add(callback);
if (this.idleId === null) {
this.scheduleIdleCallback();
}
}

remove(callback: VoidFunction) {
this.current.delete(callback);
this.deferred.delete(callback);
}

private scheduleIdleCallback() {
this.idleId = _requestIdleCallback(() => {
_cancelIdleCallback(this.idleId!);
this.idleId = null;
this.executingCallbacks = true;

for (const callback of this.current) {
callback();
}
this.current.clear();

this.executingCallbacks = false;

if (this.deferred.size > 0) {
for (const callback of this.deferred) {
this.current.add(callback);
}
this.deferred.clear();
this.scheduleIdleCallback();
}
}) as number;
}

ngOnDestroy() {
if (this.idleId !== null) {
_cancelIdleCallback(this.idleId);
this.idleId = null;
}
this.current.clear();
this.deferred.clear();
}

/** @nocollapse */
static ɵprov = /** @pureOrBreakMyCode */ ɵɵdefineInjectable({
token: OnIdleScheduler,
providedIn: 'root',
factory: () => new OnIdleScheduler(),
});
}
32 changes: 24 additions & 8 deletions packages/core/test/acceptance/defer_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,23 @@ function onIdle(callback: () => Promise<void>): Promise<void> {
});
}

// Emulates a dynamic import promise.
// Note: `setTimeout` is used to make `fixture.whenStable()` function
// wait for promise resolution, since `whenStable()` relies on the state
// of a macrotask queue.
function dynamicImportOf(type: Type<unknown>): Promise<Type<unknown>> {
return new Promise(resolve => {
setTimeout(() => resolve(type));
});
}

// Emulates a failed dynamic import promise.
function failedDynamicImport(): Promise<void> {
return new Promise((_, reject) => {
setTimeout(() => reject());
});
}

// Set `PLATFORM_ID` to a browser platform value to trigger defer loading
// while running tests in Node.
const COMMON_PROVIDERS = [{provide: PLATFORM_ID, useValue: PLATFORM_BROWSER_ID}];
Expand Down Expand Up @@ -235,8 +252,7 @@ describe('#defer', () => {

const deferDepsInterceptor = {
intercept() {
// Simulate loading failure.
return () => [Promise.reject()];
return () => [failedDynamicImport()];
}
};

Expand Down Expand Up @@ -559,7 +575,7 @@ describe('#defer', () => {
intercept() {
return () => {
loadingFnInvokedTimes++;
return [Promise.resolve(NestedCmp)];
return [dynamicImportOf(NestedCmp)];
};
}
};
Expand Down Expand Up @@ -643,7 +659,7 @@ describe('#defer', () => {
intercept() {
return () => {
loadingFnInvokedTimes++;
return [Promise.reject()];
return [failedDynamicImport()];
};
}
};
Expand Down Expand Up @@ -723,7 +739,7 @@ describe('#defer', () => {
intercept() {
return () => {
loadingFnInvokedTimes++;
return [Promise.resolve(NestedCmp)];
return [dynamicImportOf(NestedCmp)];
};
}
};
Expand Down Expand Up @@ -791,7 +807,7 @@ describe('#defer', () => {
intercept() {
return () => {
loadingFnInvokedTimes++;
return [Promise.resolve(NestedCmp)];
return [dynamicImportOf(NestedCmp)];
};
}
};
Expand Down Expand Up @@ -875,7 +891,7 @@ describe('#defer', () => {
intercept() {
return () => {
loadingFnInvokedTimes++;
return [Promise.resolve(NestedCmp)];
return [dynamicImportOf(NestedCmp)];
};
}
};
Expand Down Expand Up @@ -959,7 +975,7 @@ describe('#defer', () => {
intercept() {
return () => {
loadingFnInvokedTimes++;
return [Promise.resolve(NestedCmp)];
return [dynamicImportOf(NestedCmp)];
};
}
};
Expand Down

0 comments on commit 1ead5b9

Please sign in to comment.