diff --git a/packages/core/src/render3/instructions/defer.ts b/packages/core/src/render3/instructions/defer.ts index a97b6b033f7d9..c037d85d86295 100644 --- a/packages/core/src/render3/instructions/defer.ts +++ b/packages/core/src/render3/instructions/defer.ts @@ -10,6 +10,7 @@ import {InjectionToken, Injector} from '../../di'; import {findMatchingDehydratedView} from '../../hydration/views'; import {populateDehydratedViewsInContainer} from '../../linker/view_container_ref'; import {assertDefined, assertEqual, throwError} from '../../util/assert'; +import {NgZone} from '../../zone'; import {assertIndexInDeclRange, assertLContainer, assertTNodeForLView} from '../assert'; import {bindingUpdated} from '../bindings'; import {getComponentDef, getDirectiveDef, getPipeDef} from '../definition'; @@ -204,18 +205,45 @@ export function ɵɵdeferOnImmediate() {} // TODO: implement runtime logic. export function ɵɵdeferPrefetchOnImmediate() {} // TODO: implement runtime logic. /** - * Creates runtime data structures for the `on timer` deferred trigger. + * Sets up handlers that represent `on timer` deferred trigger. * @param delay Amount of time to wait before loading the content. * @codeGenApi */ -export function ɵɵdeferOnTimer(delay: number) {} // TODO: implement runtime logic. +export function ɵɵdeferOnTimer(delay: number) { + const lView = getLView(); + const tNode = getCurrentTNode()!; + + renderPlaceholder(lView, tNode); + + // Note: we pass an `lView` as the last argument to cancel a callback + // in case an LView got destroyed before the end of a timeout. + onTimer(delay, () => triggerDeferBlock(lView, tNode), getNgZone(lView), lView); +} /** - * Creates runtime data structures for the `prefetch on timer` deferred trigger. + * Sets up handlers that represent `prefetch on idle` deferred trigger. * @param delay Amount of time to wait before prefetching the content. * @codeGenApi */ -export function ɵɵdeferPrefetchOnTimer(delay: number) {} // TODO: implement runtime logic. +export function ɵɵdeferPrefetchOnTimer(delay: number) { + const lView = getLView(); + const tNode = getCurrentTNode()!; + const tView = lView[TVIEW]; + const tDetails = getTDeferBlockDetails(tView, tNode); + + if (tDetails.loadingState === DeferDependenciesLoadingState.NOT_STARTED) { + // Set loading to the scheduled state, so that we don't register it again. + tDetails.loadingState = DeferDependenciesLoadingState.SCHEDULED; + + // In case of prefetching, we intentionally avoid cancelling prefetching if + // an underlying LView get destroyed (thus passing `null` as the last argument), + // because there might be other LViews (that represent embedded views) that + // depend on resource loading. + onTimer( + delay, () => triggerResourceLoading(tDetails, tView, lView), getNgZone(lView), + null /* LView */); + } +} /** * Creates runtime data structures for the `on hover` deferred trigger. @@ -259,37 +287,76 @@ export function ɵɵdeferPrefetchOnViewport(target?: unknown) {} // TODO: imple /********** Helper functions **********/ +/** Retrieves an instance of NgZone using an injector from a given LView. */ +function getNgZone(lView: LView): NgZone { + const injector = lView[INJECTOR]!; + return injector.get(NgZone); +} + /** - * 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. + * Scheduler function that contains generic logic for `on idle` and `on timer` conditions. */ -function onIdle(callback: VoidFunction, lView: LView|null) { +function scheduler( + callback: VoidFunction, schedule: (fn: VoidFunction) => number, cancel: Function, + 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; + const cancelCallback = () => cancel(id); + id = schedule(() => { + cancelCallback(); + if (lView !== null) { + // The idle callback is invoked, we no longer need + // to retain a cleanup callback in an LView. + removeLViewOnDestroy(lView, cancelCallback); + } + callback(); + }); if (lView !== null) { // 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, cancelCallback); } } +/** + * Schedules 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. This is helpful for cases when a defer block has scheduled + * rendering, but an underlying LView got destroyed prior to the block rendering. + */ +function onIdle(callback: VoidFunction, lView: LView|null) { + const schedule = (fn: VoidFunction) => _requestIdleCallback(fn); + const cancel = _cancelIdleCallback; + scheduler(callback, schedule as (fn: VoidFunction) => number, cancel, lView); +} + +/** + * Schedule a callback to be invoked after a certain amount of time. + * + * @param delay A number of ms to wait before invoking a callback function. + * @param callback A function to be invoked after a specified amount of time. + * @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. This is helpful for cases when a defer block has scheduled + * rendering, but an underlying LView got destroyed prior to the block rendering. + */ +function onTimer(delay: number, callback: VoidFunction, ngZone: NgZone, lView: LView|null) { + const schedule = (fn: VoidFunction) => { + // Note: run the `setTimeout` outside of Angular zone to prevent extra rounds of + // change detection, but run the callback in the zone again, since the logic there + // might depend on being in a zone. + return ngZone.runOutsideAngular(() => { + return setTimeout(() => ngZone.run(() => fn()), delay); + }); + }; + const cancel = clearTimeout; + scheduler(callback, schedule as unknown as (fn: VoidFunction) => number, cancel, lView); +} + /** * Calculates a data slot index for defer block info (either static or * instance-specific), given an index of a defer instruction. diff --git a/packages/core/test/acceptance/defer_spec.ts b/packages/core/test/acceptance/defer_spec.ts index fa7b3b673b5a8..e8ae3ca68b73b 100644 --- a/packages/core/test/acceptance/defer_spec.ts +++ b/packages/core/test/acceptance/defer_spec.ts @@ -44,6 +44,18 @@ function onIdle(callback: () => Promise): Promise { }); } +/** + * Invoke a callback function after a specified amount of time (in ms). + */ +function onTimer(delay: number, callback: () => Promise): Promise { + return new Promise((resolve) => { + setTimeout(() => { + callback(); + resolve(); + }, delay); + }); +} + // 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}]; @@ -141,6 +153,87 @@ describe('#defer', () => { expect(fixture.nativeElement.outerHTML).toContain('Hi!'); }); + describe('`on timer` conditions', () => { + it('should trigger based on `on timer` condition', 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 timer(10)} + + {:placeholder} + Placeholder \`{{ item }}\` + {/defer} + {/for} + ` + }) + class RootCmp { + items = ['a', 'b', 'c']; + } + + let loadingFnInvokedTimes = 0; + const deferDepsInterceptor = { + intercept() { + return () => { + loadingFnInvokedTimes++; + return [Promise.resolve(NestedCmp)]; + }; + } + }; + + TestBed.configureTestingModule({ + providers: [ + {provide: ɵDEFER_BLOCK_DEPENDENCY_INTERCEPTOR, useValue: deferDepsInterceptor}, + ] + }); + + 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); + + await onTimer(10, async () => { + await fixture.whenStable(); // fetching dependencies of the defer block + 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); + + // Adding an extra item to the list + fixture.componentInstance.items = ['a', 'b', 'c', 'd']; + fixture.detectChanges(); + + // Make sure loading function is still 1 (i.e. wasn't invoked again). + expect(loadingFnInvokedTimes).toBe(1); + }); + }); + }); describe('directive matching', () => { it('should support directive matching in all blocks', async () => { @@ -760,102 +853,103 @@ describe('#defer', () => { expect(fixture.nativeElement.outerHTML).toContain('Rendering primary block'); }); - it('should support `prefetch on idle` condition', async () => { - @Component({ - selector: 'nested-cmp', - standalone: true, - template: 'Rendering {{ block }} block.', - }) - class NestedCmp { - @Input() block!: string; - } + describe('`on idle` conditions', () => { + it('should support `prefetch on idle` condition', 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: ` + @Component({ + standalone: true, + selector: 'root-app', + imports: [NestedCmp], + template: ` {#defer when deferCond; prefetch on idle} {:placeholder} Placeholder {/defer} ` - }) - class RootCmp { - deferCond = false; - } - - let loadingFnInvokedTimes = 0; - const deferDepsInterceptor = { - intercept() { - return () => { - loadingFnInvokedTimes++; - return [Promise.resolve(NestedCmp)]; - }; + }) + class RootCmp { + deferCond = false; } - }; - TestBed.configureTestingModule({ - providers: [ - {provide: ɵDEFER_BLOCK_DEPENDENCY_INTERCEPTOR, useValue: deferDepsInterceptor}, - ] - }); + let loadingFnInvokedTimes = 0; + const deferDepsInterceptor = { + intercept() { + return () => { + loadingFnInvokedTimes++; + return [Promise.resolve(NestedCmp)]; + }; + } + }; + + TestBed.configureTestingModule({ + providers: [ + {provide: ɵDEFER_BLOCK_DEPENDENCY_INTERCEPTOR, useValue: deferDepsInterceptor}, + ] + }); + + clearDirectiveDefs(RootCmp); + + const fixture = TestBed.createComponent(RootCmp); + fixture.detectChanges(); - clearDirectiveDefs(RootCmp); + expect(fixture.nativeElement.outerHTML).toContain('Placeholder'); - const fixture = TestBed.createComponent(RootCmp); - fixture.detectChanges(); + // Make sure loading function is not yet invoked. + expect(loadingFnInvokedTimes).toBe(0); - expect(fixture.nativeElement.outerHTML).toContain('Placeholder'); + // 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(); - // Make sure loading function is not yet invoked. - expect(loadingFnInvokedTimes).toBe(0); + // Expect that the loading resources function was invoked once. + expect(loadingFnInvokedTimes).toBe(1); - // 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(); + // Expect that placeholder content is still rendered. + expect(fixture.nativeElement.outerHTML).toContain('Placeholder'); - // Expect that the loading resources function was invoked once. - expect(loadingFnInvokedTimes).toBe(1); + // Trigger main content. + fixture.componentInstance.deferCond = true; + fixture.detectChanges(); - // Expect that placeholder content is still rendered. - expect(fixture.nativeElement.outerHTML).toContain('Placeholder'); + await fixture.whenStable(); - // Trigger main content. - fixture.componentInstance.deferCond = true; - fixture.detectChanges(); + // Verify primary block content. + const primaryBlockHTML = fixture.nativeElement.outerHTML; + expect(primaryBlockHTML) + .toContain( + 'Rendering primary block.'); - await fixture.whenStable(); - - // 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 () => { - @Component({ - selector: 'nested-cmp', - standalone: true, - template: 'Rendering {{ block }} block.', - }) - class NestedCmp { - @Input() block!: string; - } + it('should trigger prefetching based on `on idle` only once', 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: ` + @Component({ + standalone: true, + selector: 'root-app', + imports: [NestedCmp], + template: ` {#for item of items; track item} {#defer when deferCond; prefetch on idle} @@ -864,83 +958,83 @@ describe('#defer', () => { {/defer} {/for} ` - }) - class RootCmp { - deferCond = false; - items = ['a', 'b', 'c']; - } - - let loadingFnInvokedTimes = 0; - const deferDepsInterceptor = { - intercept() { - return () => { - loadingFnInvokedTimes++; - return [Promise.resolve(NestedCmp)]; - }; + }) + class RootCmp { + deferCond = false; + items = ['a', 'b', 'c']; } - }; - - TestBed.configureTestingModule({ - providers: [ - {provide: ɵDEFER_BLOCK_DEPENDENCY_INTERCEPTOR, useValue: deferDepsInterceptor}, - ] - }); - clearDirectiveDefs(RootCmp); - - const fixture = TestBed.createComponent(RootCmp); - fixture.detectChanges(); + let loadingFnInvokedTimes = 0; + const deferDepsInterceptor = { + intercept() { + return () => { + loadingFnInvokedTimes++; + return [Promise.resolve(NestedCmp)]; + }; + } + }; + + TestBed.configureTestingModule({ + providers: [ + {provide: ɵDEFER_BLOCK_DEPENDENCY_INTERCEPTOR, useValue: deferDepsInterceptor}, + ] + }); + + 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`'); + 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); + // 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(); + // 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(); - // 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 () => { - @Component({ - selector: 'nested-cmp', - standalone: true, - template: 'Rendering {{ block }} block.', - }) - class NestedCmp { - @Input() block!: string; - } + it('should trigger fetching based on `on idle` only once', 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: ` + @Component({ + standalone: true, + selector: 'root-app', + imports: [NestedCmp], + template: ` {#for item of items; track item} {#defer on idle; prefetch on idle} @@ -949,55 +1043,294 @@ describe('#defer', () => { {/defer} {/for} ` - }) - class RootCmp { - items = ['a', 'b', 'c']; - } + }) + class RootCmp { + items = ['a', 'b', 'c']; + } - let loadingFnInvokedTimes = 0; - const deferDepsInterceptor = { - intercept() { - return () => { - loadingFnInvokedTimes++; - return [Promise.resolve(NestedCmp)]; - }; + let loadingFnInvokedTimes = 0; + const deferDepsInterceptor = { + intercept() { + return () => { + loadingFnInvokedTimes++; + return [Promise.resolve(NestedCmp)]; + }; + } + }; + + TestBed.configureTestingModule({ + providers: [ + {provide: ɵDEFER_BLOCK_DEPENDENCY_INTERCEPTOR, useValue: deferDepsInterceptor}, + ] + }); + + 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); + + // 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(); + + // 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); + }); + }); + }); + + describe('`on timer` conditions', () => { + it('should support `prefetch on timer` condition', async () => { + @Component({ + selector: 'nested-cmp', + standalone: true, + template: 'Rendering {{ block }} block.', + }) + class NestedCmp { + @Input() block!: string; } - }; - TestBed.configureTestingModule({ - providers: [ - {provide: ɵDEFER_BLOCK_DEPENDENCY_INTERCEPTOR, useValue: deferDepsInterceptor}, - ] + @Component({ + standalone: true, + selector: 'root-app', + imports: [NestedCmp], + template: ` + {#defer when deferCond; prefetch on timer(10ms)} + + {:placeholder} + Placeholder + {/defer} + ` + }) + class RootCmp { + deferCond = false; + } + + let loadingFnInvokedTimes = 0; + const deferDepsInterceptor = { + intercept() { + return () => { + loadingFnInvokedTimes++; + return [Promise.resolve(NestedCmp)]; + }; + } + }; + + TestBed.configureTestingModule({ + providers: [ + {provide: ɵDEFER_BLOCK_DEPENDENCY_INTERCEPTOR, useValue: deferDepsInterceptor}, + ] + }); + + clearDirectiveDefs(RootCmp); + + const fixture = TestBed.createComponent(RootCmp); + fixture.detectChanges(); + + // Verify that placeholder content is rendered. + expect(fixture.nativeElement.outerHTML).toContain('Placeholder'); + + // Make sure loading function is not yet invoked. + expect(loadingFnInvokedTimes).toBe(0); + + await onTimer(10, async () => { + await fixture.whenStable(); + fixture.detectChanges(); + + // 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'); + + // Trigger main content. + fixture.componentInstance.deferCond = true; + fixture.detectChanges(); + + await fixture.whenStable(); + + // 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); + }); }); - clearDirectiveDefs(RootCmp); + it('should trigger prefetching based on `on timer` only once', async () => { + @Component({ + selector: 'nested-cmp', + standalone: true, + template: 'Rendering {{ block }} block.', + }) + class NestedCmp { + @Input() block!: string; + } - const fixture = TestBed.createComponent(RootCmp); - fixture.detectChanges(); + @Component({ + standalone: true, + selector: 'root-app', + imports: [NestedCmp], + template: ` + {#for item of items; track item} + {#defer when deferCond; prefetch on timer(10ms)} + + {:placeholder} + Placeholder \`{{ item }}\` + {/defer} + {/for} + ` + }) + class RootCmp { + deferCond = false; + items = ['a', 'b', 'c']; + } - expect(fixture.nativeElement.outerHTML).toContain('Placeholder `a`'); - expect(fixture.nativeElement.outerHTML).toContain('Placeholder `b`'); - expect(fixture.nativeElement.outerHTML).toContain('Placeholder `c`'); + let loadingFnInvokedTimes = 0; + const deferDepsInterceptor = { + intercept() { + return () => { + loadingFnInvokedTimes++; + return [Promise.resolve(NestedCmp)]; + }; + } + }; + + TestBed.configureTestingModule({ + providers: [ + {provide: ɵDEFER_BLOCK_DEPENDENCY_INTERCEPTOR, useValue: deferDepsInterceptor}, + ] + }); + + clearDirectiveDefs(RootCmp); + + const fixture = TestBed.createComponent(RootCmp); + fixture.detectChanges(); - // Make sure loading function is not yet invoked. - expect(loadingFnInvokedTimes).toBe(0); + 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); + + await onTimer(10, async () => { + await fixture.whenStable(); // prefetching dependencies of the defer block + fixture.detectChanges(); + + // 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`'); + + // Trigger main content. + fixture.componentInstance.deferCond = true; + fixture.detectChanges(); + + 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'); + + // Expect that the loading resources function was not invoked again (counter remains 1). + expect(loadingFnInvokedTimes).toBe(1); + }); + }); - // 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 + it('should trigger fetching based on `on timer` only once', 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 timer(20); prefetch on timer(10)} + + {:placeholder} + Placeholder \`{{ item }}\` + {/defer} + {/for} + ` + }) + class RootCmp { + items = ['a', 'b', 'c']; + } + + let loadingFnInvokedTimes = 0; + const deferDepsInterceptor = { + intercept() { + return () => { + loadingFnInvokedTimes++; + return [Promise.resolve(NestedCmp)]; + }; + } + }; + + TestBed.configureTestingModule({ + providers: [ + {provide: ɵDEFER_BLOCK_DEPENDENCY_INTERCEPTOR, useValue: deferDepsInterceptor}, + ] + }); + + clearDirectiveDefs(RootCmp); + + const fixture = TestBed.createComponent(RootCmp); fixture.detectChanges(); - // Expect that the loading resources function was invoked once. - expect(loadingFnInvokedTimes).toBe(1); + expect(fixture.nativeElement.outerHTML).toContain('Placeholder `a`'); + expect(fixture.nativeElement.outerHTML).toContain('Placeholder `b`'); + expect(fixture.nativeElement.outerHTML).toContain('Placeholder `c`'); - // 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'); + // Make sure loading function is not yet invoked. + expect(loadingFnInvokedTimes).toBe(0); - // Expect that the loading resources function was not invoked again (counter remains 1). - expect(loadingFnInvokedTimes).toBe(1); + await onTimer(20, async () => { + await fixture.whenStable(); // prefetching dependencies of the defer block + 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); + }); }); }); });