From f5d514752daa894ee36117145e2f35c99903306b Mon Sep 17 00:00:00 2001 From: Andrew Kushnir Date: Sat, 23 Sep 2023 13:49:14 -0700 Subject: [PATCH] fixup! refactor(core): add batching for defer blocks with `on idle` conditions --- .../core/src/render3/instructions/defer.ts | 50 +- packages/core/test/acceptance/defer_spec.ts | 491 ++++++++++++++---- 2 files changed, 437 insertions(+), 104 deletions(-) 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..b752fd0cc3fd17 100644 --- a/packages/core/test/acceptance/defer_spec.ts +++ b/packages/core/test/acceptance/defer_spec.ts @@ -27,23 +27,6 @@ function clearDirectiveDefs(type: Type): void { cmpDef!.directiveDefs = null; } -/** - * Invoke a callback function when a browser in the idle state. - * For Node environment, use `setTimeout` as a shim, similar to - * how we handle it in the `deferOnIdle` code at runtime. - */ -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. - const onIdleFn = typeof requestIdleCallback !== 'undefined' ? requestIdleCallback : setTimeout; - return new Promise((resolve) => { - onIdleFn(() => { - callback(); - resolve(); - }); - }); -} - /** * Emulates a dynamic import promise. * @@ -51,9 +34,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 = 0): Promise { return new Promise(resolve => { - setTimeout(() => resolve(type)); + setTimeout(() => resolve(type), timeout); }); } @@ -66,6 +49,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}]; @@ -119,7 +110,7 @@ describe('#defer', () => { expect(fixture.nativeElement.outerHTML).toContain('Loading'); // Wait for dependencies to load. - await fixture.whenStable(); + await allPendingDynamicImports(); fixture.detectChanges(); expect(fixture.nativeElement.outerHTML).toContain('Hi!'); @@ -158,7 +149,7 @@ describe('#defer', () => { fixture.detectChanges(); // Wait for dependencies to load. - await fixture.whenStable(); + await allPendingDynamicImports(); fixture.detectChanges(); expect(fixture.nativeElement.outerHTML).toContain('Hi!'); @@ -222,7 +213,7 @@ describe('#defer', () => { // Expecting loading function to be triggered right away. expect(loadingFnInvokedTimes).toBe(1); - await fixture.whenStable(); // loading dependencies of the defer block + await allPendingDynamicImports(); fixture.detectChanges(); // Expect that the loading resources function was not invoked again. @@ -289,7 +280,7 @@ describe('#defer', () => { 'Rendering loading block.'); // Wait for dependencies to load. - await fixture.whenStable(); + await allPendingDynamicImports(); fixture.detectChanges(); expect(fixture.nativeElement.outerHTML) @@ -355,7 +346,7 @@ describe('#defer', () => { expect(fixture.nativeElement.outerHTML).toContain('Loading'); // Wait for dependencies to load. - await fixture.whenStable(); + await allPendingDynamicImports(); fixture.detectChanges(); // Verify that the error block is rendered. @@ -424,7 +415,7 @@ describe('#defer', () => { 'Rendering loading block.'); // Wait for dependencies to load. - await fixture.whenStable(); + await allPendingDynamicImports(); fixture.detectChanges(); expect(fixture.componentInstance.cmps.length).toBe(1); @@ -523,7 +514,7 @@ describe('#defer', () => { 'Rendering loading block.'); // Wait for dependencies to load. - await fixture.whenStable(); + await allPendingDynamicImports(); fixture.detectChanges(); // Verify primary block content. @@ -540,7 +531,7 @@ describe('#defer', () => { fixture.detectChanges(); // Wait for projected block dependencies to load. - await fixture.whenStable(); + await allPendingDynamicImports(); fixture.detectChanges(); // Nested defer block was triggered and the `CmpB` content got rendered. @@ -599,7 +590,7 @@ describe('#defer', () => { fixture.componentInstance.isVisible = true; fixture.detectChanges(); - await fixture.whenStable(); // loading dependencies for the defer block within MyCmp... + await allPendingDynamicImports(); fixture.detectChanges(); // Verify primary block content. @@ -616,7 +607,7 @@ describe('#defer', () => { fixture.detectChanges(); // Wait for nested block dependencies to load. - await fixture.whenStable(); + await allPendingDynamicImports(); fixture.detectChanges(); // Nested defer block was triggered and the `CmpB` content got rendered. @@ -625,6 +616,51 @@ 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); + expect(idleCallbacksRequested).toBe(0); + idleCallbacksRequested++; + return 0; + }; + + const mockCancelIdleCallback = (id: number) => { + expect(idleCallbacksRequested).toBe(1); + 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', @@ -683,7 +719,7 @@ describe('#defer', () => { fixture.componentInstance.prefetchCond = true; fixture.detectChanges(); - await fixture.whenStable(); // prefetching dependencies of the defer block + await allPendingDynamicImports(); fixture.detectChanges(); // Expect that the loading resources function was invoked once. @@ -696,7 +732,8 @@ describe('#defer', () => { fixture.componentInstance.deferCond = true; fixture.detectChanges(); - await fixture.whenStable(); + await allPendingDynamicImports(); + fixture.detectChanges(); // Verify primary block content. const primaryBlockHTML = fixture.nativeElement.outerHTML; @@ -768,7 +805,7 @@ describe('#defer', () => { fixture.componentInstance.prefetchCond = true; fixture.detectChanges(); - await fixture.whenStable(); // prefetching dependencies of the defer block + await allPendingDynamicImports(); fixture.detectChanges(); // Expect that the loading resources function was invoked once. @@ -781,7 +818,8 @@ describe('#defer', () => { fixture.componentInstance.deferCond = true; fixture.detectChanges(); - await fixture.whenStable(); + await allPendingDynamicImports(); + fixture.detectChanges(); // Since prefetching failed, expect the `{:error}` state to be rendered. expect(fixture.nativeElement.outerHTML).toContain('Loading failed'); @@ -849,7 +887,7 @@ describe('#defer', () => { fixture.componentInstance.deferCond = true; fixture.detectChanges(); - await fixture.whenStable(); // loading dependencies + await allPendingDynamicImports(); fixture.detectChanges(); // Expect that the loading resources function was invoked once, @@ -914,33 +952,31 @@ 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(); + fixture.detectChanges(); - // 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 +1037,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 `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 allPendingDynamicImports(); + fixture.detectChanges(); - // 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 +1120,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 () => { @@ -1159,7 +1190,7 @@ describe('#defer', () => { // Expecting loading function to be triggered right away. expect(loadingFnInvokedTimes).toBe(1); - await fixture.whenStable(); // prefetching dependencies of the defer block + await allPendingDynamicImports(); fixture.detectChanges(); // Expect that the loading resources function was invoked once. @@ -1172,7 +1203,8 @@ describe('#defer', () => { fixture.componentInstance.deferCond = true; fixture.detectChanges(); - await fixture.whenStable(); + await allPendingDynamicImports(); + fixture.detectChanges(); // Verify primary block content. const primaryBlockHTML = fixture.nativeElement.outerHTML; @@ -1183,6 +1215,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