From 1ead5b9fad5913cd59d9f61e63194226128d6adc Mon Sep 17 00:00:00 2001 From: Andrew Kushnir Date: Tue, 12 Sep 2023 19:08:40 -0700 Subject: [PATCH] refactor(core): add batching for defer blocks with `on idle` conditions 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). --- .../core/src/render3/instructions/defer.ts | 139 +++++++++++++----- packages/core/test/acceptance/defer_spec.ts | 32 +++- 2 files changed, 124 insertions(+), 47 deletions(-) diff --git a/packages/core/src/render3/instructions/defer.ts b/packages/core/src/render3/instructions/defer.ts index a97b6b033f7d9e..a474040929faa8 100644 --- a/packages/core/src/render3/instructions/defer.ts +++ b/packages/core/src/render3/instructions/defer.ts @@ -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'; @@ -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. * @@ -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 */); } /** @@ -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 */); } } @@ -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); } } @@ -605,3 +592,77 @@ export interface DeferBlockDependencyInterceptor { export const DEFER_BLOCK_DEPENDENCY_INTERCEPTOR = new InjectionToken( 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(); + deferred = new Set(); + + 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(), + }); +} diff --git a/packages/core/test/acceptance/defer_spec.ts b/packages/core/test/acceptance/defer_spec.ts index fa7b3b673b5a85..e6212877b60bb0 100644 --- a/packages/core/test/acceptance/defer_spec.ts +++ b/packages/core/test/acceptance/defer_spec.ts @@ -44,6 +44,23 @@ function onIdle(callback: () => Promise): Promise { }); } +// 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): Promise> { + return new Promise(resolve => { + setTimeout(() => resolve(type)); + }); +} + +// Emulates a failed dynamic import promise. +function failedDynamicImport(): Promise { + 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}]; @@ -235,8 +252,7 @@ describe('#defer', () => { const deferDepsInterceptor = { intercept() { - // Simulate loading failure. - return () => [Promise.reject()]; + return () => [failedDynamicImport()]; } }; @@ -559,7 +575,7 @@ describe('#defer', () => { intercept() { return () => { loadingFnInvokedTimes++; - return [Promise.resolve(NestedCmp)]; + return [dynamicImportOf(NestedCmp)]; }; } }; @@ -643,7 +659,7 @@ describe('#defer', () => { intercept() { return () => { loadingFnInvokedTimes++; - return [Promise.reject()]; + return [failedDynamicImport()]; }; } }; @@ -723,7 +739,7 @@ describe('#defer', () => { intercept() { return () => { loadingFnInvokedTimes++; - return [Promise.resolve(NestedCmp)]; + return [dynamicImportOf(NestedCmp)]; }; } }; @@ -791,7 +807,7 @@ describe('#defer', () => { intercept() { return () => { loadingFnInvokedTimes++; - return [Promise.resolve(NestedCmp)]; + return [dynamicImportOf(NestedCmp)]; }; } }; @@ -875,7 +891,7 @@ describe('#defer', () => { intercept() { return () => { loadingFnInvokedTimes++; - return [Promise.resolve(NestedCmp)]; + return [dynamicImportOf(NestedCmp)]; }; } }; @@ -959,7 +975,7 @@ describe('#defer', () => { intercept() { return () => { loadingFnInvokedTimes++; - return [Promise.resolve(NestedCmp)]; + return [dynamicImportOf(NestedCmp)]; }; } };