diff --git a/packages/core/src/render3/instructions/defer.ts b/packages/core/src/render3/instructions/defer.ts index faa3db915b3a67..34b2fadb88c90b 100644 --- a/packages/core/src/render3/instructions/defer.ts +++ b/packages/core/src/render3/instructions/defer.ts @@ -7,6 +7,7 @@ */ import {InjectionToken, Injector, ɵɵdefineInjectable} from '../../di'; +import {inject} from '../../di/injector_compatibility'; import {findMatchingDehydratedView} from '../../hydration/views'; import {populateDehydratedViewsInContainer} from '../../linker/view_container_ref'; import {assertDefined, assertElement, assertEqual, throwError} from '../../util/assert'; @@ -406,19 +407,24 @@ function onIdle(callback: VoidFunction, lView: LView, withLViewCleanup: boolean) const injector = lView[INJECTOR]!; const scheduler = injector.get(OnIdleScheduler); const cleanupFn = () => scheduler.remove(callback); - const _callback = withLViewCleanup ? wrapWithLViewCleanup(callback, lView, cleanupFn) : callback; - scheduler.add(_callback); + const wrappedCallback = + withLViewCleanup ? wrapWithLViewCleanup(callback, lView, cleanupFn) : callback; + scheduler.add(wrappedCallback); return cleanupFn; } +/** + * Wraps a given callback into a logic that registers a cleanup function + * in the LView cleanup slot, to be invoked when an LView is destroyed. + */ function wrapWithLViewCleanup( callback: VoidFunction, lView: LView, cleanup: VoidFunction): VoidFunction { - const _callback = () => { + const wrappedCallback = () => { callback(); removeLViewOnDestroy(lView, cleanup); }; storeLViewOnDestroy(lView, cleanup); - return _callback; + return wrappedCallback; } /** @@ -893,25 +899,43 @@ class DeferBlockCleanupManager { } /** - * Shims for the `requestIdleCallback` and `cancelIdleCallback` functions for environments + * Use 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; +const hasIdleSupport = typeof requestIdleCallback !== 'undefined'; + +export const REQUEST_IDLE_CALLBACK = new InjectionToken(ngDevMode ? 'REQUEST_IDLE_CALLBACK' : '', { + providedIn: 'root', + factory: () => hasIdleSupport ? requestIdleCallback : setTimeout, +}); + +export const CANCEL_IDLE_CALLBACK = new InjectionToken(ngDevMode ? 'CANCEL_IDLE_CALLBACK' : '', { + providedIn: 'root', + factory: () => hasIdleSupport ? 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). + * to avoid calling `requestIdleCallback` for each defer block (e.g. if + * defer blocks are defined inside a for loop). */ class OnIdleScheduler { + // Indicates whether current callbacks are being invoked. executingCallbacks = false; + + // Currently scheduled idle callback id. idleId: number|null = null; + + // Set of callbacks to be invoked next. current = new Set(); + + // Set of callbacks collected while invoking current set of callbacks. + // Those callbacks are scheduled for the next idle period. deferred = new Set(); + requestIdleCallback = inject(REQUEST_IDLE_CALLBACK).bind(globalThis); + cancelIdleCallback = inject(CANCEL_IDLE_CALLBACK).bind(globalThis); + add(callback: VoidFunction) { const target = this.executingCallbacks ? this.deferred : this.current; target.add(callback); @@ -927,8 +951,9 @@ class OnIdleScheduler { private scheduleIdleCallback() { const callback = () => { - _cancelIdleCallback(this.idleId!); + this.cancelIdleCallback(this.idleId!); this.idleId = null; + this.executingCallbacks = true; for (const callback of this.current) { @@ -938,6 +963,9 @@ class OnIdleScheduler { this.executingCallbacks = false; + // If there are any callbacks added during an invocation + // of the current ones - make them "current" and schedule + // a new idle callback. if (this.deferred.size > 0) { for (const callback of this.deferred) { this.current.add(callback); @@ -946,12 +974,12 @@ class OnIdleScheduler { this.scheduleIdleCallback(); } }; - this.idleId = _requestIdleCallback(callback) as number; + this.idleId = this.requestIdleCallback(callback) as number; } ngOnDestroy() { if (this.idleId !== null) { - _cancelIdleCallback(this.idleId); + this.cancelIdleCallback(this.idleId); this.idleId = null; } this.current.clear(); diff --git a/packages/core/test/acceptance/defer_spec.ts b/packages/core/test/acceptance/defer_spec.ts index a06f706114b2ec..01549a8cb25725 100644 --- a/packages/core/test/acceptance/defer_spec.ts +++ b/packages/core/test/acceptance/defer_spec.ts @@ -10,6 +10,7 @@ import {ɵPLATFORM_BROWSER_ID as PLATFORM_BROWSER_ID} from '@angular/common'; import {ɵsetEnabledBlockTypes as setEnabledBlockTypes} from '@angular/compiler/src/jit_compiler_facade'; import {Component, Input, PLATFORM_ID, QueryList, Type, ViewChildren, ɵDEFER_BLOCK_DEPENDENCY_INTERCEPTOR} from '@angular/core'; import {getComponentDef} from '@angular/core/src/render3/definition'; +import {CANCEL_IDLE_CALLBACK, REQUEST_IDLE_CALLBACK} from '@angular/core/src/render3/instructions/defer'; import {DeferBlockBehavior, fakeAsync, flush, TestBed} from '@angular/core/testing'; /** @@ -34,7 +35,7 @@ function clearDirectiveDefs(type: Type): void { */ function onIdle(callback: () => Promise): Promise { // If we are in a browser environment and the `requestIdleCallback` function - // is present - use it, otherwise just invoke the callback function. + // is present - use it, otherwise use `setTimeout`. const onIdleFn = typeof requestIdleCallback !== 'undefined' ? requestIdleCallback : setTimeout; return new Promise((resolve) => { onIdleFn(() => { @@ -44,23 +45,69 @@ 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 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: T, timeout: number = 0): Promise { + return new Promise(resolve => { + setTimeout(() => resolve(type), timeout); }); } -// Emulates a failed dynamic import promise. +/** + * Emulates a failed dynamic import promise. + */ function failedDynamicImport(): Promise { return new Promise((_, reject) => { setTimeout(() => reject()); }); } +/** + * Helper function to await all pending dynamic imports + * emulated using `dynamicImportOf` function. + */ +function allPendingDynamicImports() { + return dynamicImportOf(null, 10); +} + +/** + * Sets up interceptors for when an idle callback is requested + * and when it's cancelled. This is needed to keep track of calls + * made to `requestIdleCallback` and `cancelIdleCallback` APIs. + */ +function setupRequestIdleCallbackInterceptor( + onIdleRequest?: VoidFunction, onCancel?: VoidFunction) { + const onIdleCallbackQueue: VoidFunction[] = []; + const providers = [ + { + provide: REQUEST_IDLE_CALLBACK, + useValue: (callback: VoidFunction) => { + onIdleRequest?.(); + onIdleCallbackQueue.push(callback); + } + }, + { + provide: CANCEL_IDLE_CALLBACK, + useValue: (id: number) => { + onCancel?.(); + } + } + ]; + const triggerIdleCallbacks = () => { + for (const callback of onIdleCallbackQueue) { + callback(); + } + onIdleCallbackQueue.length = 0; // empty the queue + }; + return {providers, triggerIdleCallbacks}; +} + + // 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}]; @@ -1024,6 +1071,216 @@ describe('#defer', () => { expect(loadingFnInvokedTimes).toBe(1); }); }); + + it('should not request idle callback for each block in a for loop', async () => { + @Component({ + selector: 'nested-cmp', + standalone: true, + template: 'Rendering {{ block }} block.', + }) + class NestedCmp { + @Input() block!: string; + } + + @Component({ + standalone: true, + selector: 'root-app', + imports: [NestedCmp], + template: ` + {#for item of items; track item} + {#defer on idle; prefetch on idle} + + {:placeholder} + Placeholder \`{{ item }}\` + {/defer} + {/for} + ` + }) + class RootCmp { + items = ['a', 'b', 'c']; + } + + let loadingFnInvokedTimes = 0; + const deferDepsInterceptor = { + intercept() { + return () => { + loadingFnInvokedTimes++; + return [dynamicImportOf(NestedCmp)]; + }; + } + }; + + // Configure callbacks for when idle callback is requested + // and cancelled. Verify that we never have more than one + // idle callback requested at a time. + let idleCallbacksRequested = 0; + const onRequest = () => { + expect(idleCallbacksRequested).toBe(0); + idleCallbacksRequested++; + }; + const onCancel = () => { + expect(idleCallbacksRequested).toBe(1); + idleCallbacksRequested--; + }; + const {providers, triggerIdleCallbacks} = + setupRequestIdleCallbackInterceptor(onRequest, onCancel); + + TestBed.configureTestingModule({ + providers: [ + {provide: ɵDEFER_BLOCK_DEPENDENCY_INTERCEPTOR, useValue: deferDepsInterceptor}, + ...providers, + ], + deferBlockBehavior: DeferBlockBehavior.Playthrough, + }); + + clearDirectiveDefs(RootCmp); + + const fixture = TestBed.createComponent(RootCmp); + fixture.detectChanges(); + + expect(fixture.nativeElement.outerHTML).toContain('Placeholder `a`'); + expect(fixture.nativeElement.outerHTML).toContain('Placeholder `b`'); + expect(fixture.nativeElement.outerHTML).toContain('Placeholder `c`'); + + // Make sure loading function is not yet invoked. + expect(loadingFnInvokedTimes).toBe(0); + + // Trigger all scheduled callbacks and await all mocked dynamic imports. + triggerIdleCallbacks(); + await allPendingDynamicImports(); + + fixture.detectChanges(); + + // Expect that the loading resources function was invoked once. + expect(loadingFnInvokedTimes).toBe(1); + + // Verify primary blocks content. + expect(fixture.nativeElement.outerHTML).toContain('Rendering primary for `a` block'); + expect(fixture.nativeElement.outerHTML).toContain('Rendering primary for `b` block'); + expect(fixture.nativeElement.outerHTML).toContain('Rendering primary for `c` block'); + + // Expect that the loading resources function was not invoked again (counter remains 1). + expect(loadingFnInvokedTimes).toBe(1); + }); + + it('should delay nested defer blocks with `on idle` triggers', async () => { + @Component({ + selector: 'nested-cmp', + standalone: true, + template: 'Primary block content.', + }) + class NestedCmp { + } + + @Component({ + selector: 'another-nested-cmp', + standalone: true, + template: 'Nested block component.', + }) + class AnotherNestedCmp { + } + + @Component({ + standalone: true, + selector: 'root-app', + imports: [NestedCmp, AnotherNestedCmp], + template: ` + {#defer on idle; prefetch on idle} + + + + {#defer on idle} + + {:placeholder} + Nested block placeholder + {:loading} + Nested block loading + {/defer} + + {:placeholder} + Root block placeholder + {/defer} + ` + }) + class RootCmp { + } + + let loadingFnInvokedTimes = 0; + const deferDepsInterceptor = { + intercept() { + return () => { + loadingFnInvokedTimes++; + const nextDeferredComponent = + loadingFnInvokedTimes === 1 ? NestedCmp : AnotherNestedCmp; + return [dynamicImportOf(nextDeferredComponent)]; + }; + } + }; + + // Configure callbacks for when idle callback is requested + // and cancelled. Verify that we never have more than one + // idle callback requested at a time. + let idleCallbacksRequested = 0; + const onRequest = () => { + expect(idleCallbacksRequested).toBe(0); + idleCallbacksRequested++; + }; + const onCancel = () => { + expect(idleCallbacksRequested).toBe(1); + idleCallbacksRequested--; + }; + const {providers, triggerIdleCallbacks} = + setupRequestIdleCallbackInterceptor(onRequest, onCancel); + + TestBed.configureTestingModule({ + providers: [ + {provide: ɵDEFER_BLOCK_DEPENDENCY_INTERCEPTOR, useValue: deferDepsInterceptor}, + ...providers, + ], + deferBlockBehavior: DeferBlockBehavior.Playthrough, + }); + + clearDirectiveDefs(RootCmp); + + const fixture = TestBed.createComponent(RootCmp); + fixture.detectChanges(); + + expect(fixture.nativeElement.outerHTML).toContain('Root block placeholder'); + + // Make sure loading function is not yet invoked. + expect(loadingFnInvokedTimes).toBe(0); + + // Trigger all scheduled callbacks and await all mocked dynamic imports. + triggerIdleCallbacks(); + await allPendingDynamicImports(); + + fixture.detectChanges(); + + // Expect that the loading resources function was invoked once. + expect(loadingFnInvokedTimes).toBe(1); + + // Verify primary blocks content. + expect(fixture.nativeElement.outerHTML).toContain('Primary block content'); + + // Verify that nested defer block is in a placeholder mode. + expect(fixture.nativeElement.outerHTML).toContain('Nested block placeholder'); + + // Expect that the loading resources function was not invoked again (counter remains 1). + expect(loadingFnInvokedTimes).toBe(1); + + triggerIdleCallbacks(); + await allPendingDynamicImports(); + fixture.detectChanges(); + + // Verify that nested defer block now renders the main content. + expect(fixture.nativeElement.outerHTML).toContain('Nested block component'); + + // We loaded a nested block dependency, expect counter to be 2. + expect(loadingFnInvokedTimes).toBe(2); + }); }); // Note: these cases specifically use `on interaction`, however