diff --git a/packages/core/src/render3/instructions/defer.ts b/packages/core/src/render3/instructions/defer.ts index e7e389d844210f..d72ed57e85a0cb 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'; @@ -470,19 +471,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; } /** @@ -957,25 +963,39 @@ class DeferBlockCleanupManager { } /** - * Shims for the `requestIdleCallback` and `cancelIdleCallback` functions for environments - * where those functions are not available (e.g. Node.js). + * Use shims for the `requestIdleCallback` and `cancelIdleCallback` functions for + * environments where those functions are not available (e.g. Node.js and Safari). + * + * Note: we wrap the `requestIdleCallback` call into a function, so that it can be + * overridden/mocked in test environment and picked up by the runtime code. */ -const _requestIdleCallback = +const _requestIdleCallback = () => typeof requestIdleCallback !== 'undefined' ? requestIdleCallback : setTimeout; -const _cancelIdleCallback = +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). + * 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 = _requestIdleCallback().bind(globalThis); + cancelIdleCallback = _cancelIdleCallback().bind(globalThis); + add(callback: VoidFunction) { const target = this.executingCallbacks ? this.deferred : this.current; target.add(callback); @@ -991,8 +1011,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) { @@ -1002,6 +1023,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); @@ -1010,12 +1034,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 42bc7740ff17b3..73c99dacf9750a 100644 --- a/packages/core/test/acceptance/defer_spec.ts +++ b/packages/core/test/acceptance/defer_spec.ts @@ -34,7 +34,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(() => { @@ -51,9 +51,9 @@ function onIdle(callback: () => Promise): Promise { * wait for promise resolution, since `whenStable()` relies on the state * of a macrotask queue. */ -function dynamicImportOf(type: T): Promise { +function dynamicImportOf(type: T, timeout: number = 0): Promise { return new Promise(resolve => { - setTimeout(() => resolve(type)); + setTimeout(() => resolve(type), timeout); }); } @@ -66,6 +66,14 @@ function failedDynamicImport(): Promise { }); } +/** + * Helper function to await all pending dynamic imports + * emulated using `dynamicImportOf` function. + */ +function allPendingDynamicImports() { + return dynamicImportOf(null, 10); +} + // 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}]; @@ -625,6 +633,49 @@ describe('#defer', () => { }); describe('prefetch', () => { + /** + * 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. + */ + let idleCallbacksRequested = 0; + const onIdleCallbackQueue: IdleRequestCallback[] = []; + + let nativeRequestIdleCallback: (callback: IdleRequestCallback, options?: IdleRequestOptions) => + number; + let nativeCancelIdleCallback: (id: number) => void; + + const mockRequestIdleCallback = + (callback: IdleRequestCallback, options?: IdleRequestOptions): number => { + onIdleCallbackQueue.push(callback); + idleCallbacksRequested++; + return 0; + }; + + const mockCancelIdleCallback = (id: number) => { + idleCallbacksRequested--; + }; + + const triggerIdleCallbacks = () => { + for (const callback of onIdleCallbackQueue) { + callback(null!); + } + onIdleCallbackQueue.length = 0; // empty the queue + }; + + beforeEach(() => { + nativeRequestIdleCallback = globalThis.requestIdleCallback; + nativeCancelIdleCallback = globalThis.cancelIdleCallback; + globalThis.requestIdleCallback = mockRequestIdleCallback; + globalThis.cancelIdleCallback = mockCancelIdleCallback; + }); + + afterEach(() => { + globalThis.requestIdleCallback = nativeRequestIdleCallback; + globalThis.cancelIdleCallback = nativeCancelIdleCallback; + onIdleCallbackQueue.length = 0; // clear the queue + }); + it('should be able to prefetch resources', async () => { @Component({ selector: 'nested-cmp', @@ -914,33 +965,30 @@ describe('#defer', () => { // Make sure loading function is not yet invoked. expect(loadingFnInvokedTimes).toBe(0); - // Invoke the rest of the test when a browser is in the idle state, - // which is also a trigger condition to start defer block loading. - await onIdle(async () => { - await fixture.whenStable(); // prefetching dependencies of the defer block - fixture.detectChanges(); + triggerIdleCallbacks(); + await allPendingDynamicImports(); + fixture.detectChanges(); - // Expect that the loading resources function was invoked once. - expect(loadingFnInvokedTimes).toBe(1); + // Expect that the loading resources function was invoked once. + expect(loadingFnInvokedTimes).toBe(1); - // Expect that placeholder content is still rendered. - expect(fixture.nativeElement.outerHTML).toContain('Placeholder'); + // Expect that placeholder content is still rendered. + expect(fixture.nativeElement.outerHTML).toContain('Placeholder'); - // Trigger main content. - fixture.componentInstance.deferCond = true; - fixture.detectChanges(); + // Trigger main content. + fixture.componentInstance.deferCond = true; + fixture.detectChanges(); - await fixture.whenStable(); + await allPendingDynamicImports(); - // Verify primary block content. - const primaryBlockHTML = fixture.nativeElement.outerHTML; - expect(primaryBlockHTML) - .toContain( - 'Rendering primary block.'); + // Verify primary block content. + const primaryBlockHTML = fixture.nativeElement.outerHTML; + expect(primaryBlockHTML) + .toContain( + 'Rendering primary block.'); - // Expect that the loading resources function was not invoked again (counter remains 1). - expect(loadingFnInvokedTimes).toBe(1); - }); + // Expect that the loading resources function was not invoked again (counter remains 1). + expect(loadingFnInvokedTimes).toBe(1); }); it('should trigger prefetching based on `on idle` only once', async () => { @@ -1001,32 +1049,28 @@ describe('#defer', () => { // Make sure loading function is not yet invoked. expect(loadingFnInvokedTimes).toBe(0); - // Invoke the rest of the test when a browser is in the idle state, - // which is also a trigger condition to start defer block loading. - await onIdle(async () => { - await fixture.whenStable(); // prefetching dependencies of the defer block - fixture.detectChanges(); + triggerIdleCallbacks(); + await allPendingDynamicImports(); - // Expect that the loading resources function was invoked once. - expect(loadingFnInvokedTimes).toBe(1); + // Expect that the loading resources function was invoked once. + expect(loadingFnInvokedTimes).toBe(1); - // Expect that placeholder content is still rendered. - expect(fixture.nativeElement.outerHTML).toContain('Placeholder `a`'); + // Expect that placeholder content is still rendered. + expect(fixture.nativeElement.outerHTML).toContain('Placeholder `a`'); - // Trigger main content. - fixture.componentInstance.deferCond = true; - fixture.detectChanges(); + // Trigger main content. + fixture.componentInstance.deferCond = true; + fixture.detectChanges(); - await fixture.whenStable(); + await fixture.whenStable(); - // 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'); + // 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); - }); + // Expect that the loading resources function was not invoked again (counter remains 1). + expect(loadingFnInvokedTimes).toBe(1); }); it('should trigger fetching based on `on idle` only once', async () => { @@ -1086,23 +1130,20 @@ describe('#defer', () => { // Make sure loading function is not yet invoked. expect(loadingFnInvokedTimes).toBe(0); - // Invoke the rest of the test when a browser is in the idle state, - // which is also a trigger condition to start defer block loading. - await onIdle(async () => { - await fixture.whenStable(); // prefetching dependencies of the defer block - fixture.detectChanges(); + triggerIdleCallbacks(); + await allPendingDynamicImports(); + fixture.detectChanges(); - // Expect that the loading resources function was invoked once. - expect(loadingFnInvokedTimes).toBe(1); + // 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'); + // 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); - }); + // Expect that the loading resources function was not invoked again (counter remains 1). + expect(loadingFnInvokedTimes).toBe(1); }); it('should support `prefetch on immediate` condition', async () => { @@ -1183,6 +1224,283 @@ describe('#defer', () => { // 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)]; + }; + } + }; + + TestBed.configureTestingModule({ + providers: [ + {provide: ɵDEFER_BLOCK_DEPENDENCY_INTERCEPTOR, useValue: deferDepsInterceptor}, + ], + 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); + }); + + 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)]; + }; + } + }; + + TestBed.configureTestingModule({ + providers: [ + {provide: ɵDEFER_BLOCK_DEPENDENCY_INTERCEPTOR, useValue: deferDepsInterceptor}, + ], + 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)]; + }; + } + }; + + TestBed.configureTestingModule({ + providers: [ + {provide: ɵDEFER_BLOCK_DEPENDENCY_INTERCEPTOR, useValue: deferDepsInterceptor}, + ], + 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