diff --git a/packages/core/src/render3/instructions/defer.ts b/packages/core/src/render3/instructions/defer.ts index 50d17da892d928..fb644a6a02cca7 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} from '../../di'; import {assertDefined, assertEqual, throwError} from '../../util/assert'; import {assertIndexInDeclRange, assertLContainer, assertTNodeForLView} from '../assert'; import {bindingUpdated} from '../bindings'; @@ -129,7 +129,7 @@ export function ɵɵdeferPrefetchWhen(rawValue: unknown) { const tDetails = getTDeferBlockDetails(tView, tNode); if (value === true && tDetails.loadingState === DeferDependenciesLoadingState.NOT_STARTED) { // If loading has not been started yet, trigger it now. - triggerResourceLoading(tDetails, getPrimaryBlockTNode(tView, tDetails), lView[INJECTOR]!); + triggerResourceLoading(tDetails, tView, lView); } } } @@ -143,28 +143,23 @@ export function ɵɵdeferOnIdle() { const tNode = getCurrentTNode()!; renderPlaceholder(lView, tNode); - - let id: number; - const removeIdleCallback = () => _cancelIdleCallback(id); - id = _requestIdleCallback(() => { - removeIdleCallback(); - // The idle callback is invoked, we no longer need - // to retain a cleanup callback in an LView. - removeLViewOnDestroy(lView, removeIdleCallback); - triggerDeferBlock(lView, tNode); - }) as number; - - // Store a cleanup function on LView, so that we cancel idle - // callback in case this LView was destroyed before a callback - // was invoked. - storeLViewOnDestroy(lView, removeIdleCallback); + onIdle(lView, () => triggerDeferBlock(lView, tNode)); } /** * Creates runtime data structures for the `prefetch on idle` deferred trigger. * @codeGenApi */ -export function ɵɵdeferPrefetchOnIdle() {} // TODO: implement runtime logic. +export function ɵɵdeferPrefetchOnIdle() { + const lView = getLView(); + const tNode = getCurrentTNode()!; + const tView = lView[TVIEW]; + const tDetails = getTDeferBlockDetails(tView, tNode); + + if (tDetails.loadingState === DeferDependenciesLoadingState.NOT_STARTED) { + onIdle(lView, () => triggerResourceLoading(tDetails, tView, lView)); + } +} /** * Creates runtime data structures for the `on immediate` deferred trigger. @@ -235,6 +230,26 @@ export function ɵɵdeferPrefetchOnViewport(target?: unknown) {} // TODO: imple /********** Helper functions **********/ +/** + * Helper function to schedule a callback to be invoked when a browser becomes idle. + */ +function onIdle(lView: LView, callback: VoidFunction) { + let id: number; + const removeIdleCallback = () => _cancelIdleCallback(id); + id = _requestIdleCallback(() => { + removeIdleCallback(); + // The idle callback is invoked, we no longer need + // to retain a cleanup callback in an LView. + removeLViewOnDestroy(lView, removeIdleCallback); + callback(); + }) as number; + + // 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); +} + /** * Calculates a data slot index for defer block info (either static or * instance-specific), given an index of a defer instruction. @@ -325,13 +340,10 @@ function renderDeferBlockState( * Trigger loading of defer block dependencies if the process hasn't started yet. * * @param tDetails Static information about this defer block. - * @param primaryBlockTNode TNode of a primary block template. - * @param injector Environment injector of the application. + * @param tView TView of a host view. + * @param lView LView of a host view. */ -function triggerResourceLoading( - tDetails: TDeferBlockDetails, primaryBlockTNode: TNode, injector: Injector) { - const tView = primaryBlockTNode.tView!; - +function triggerResourceLoading(tDetails: TDeferBlockDetails, tView: TView, lView: LView) { if (tDetails.loadingState !== DeferDependenciesLoadingState.NOT_STARTED) { // If the loading status is different from initial one, it means that // the loading of dependencies is in progress and there is nothing to do @@ -339,6 +351,9 @@ function triggerResourceLoading( return; } + const injector = lView[INJECTOR]!; + const primaryBlockTNode = getPrimaryBlockTNode(tView, tDetails); + // Switch from NOT_STARTED -> IN_PROGRESS state. tDetails.loadingState = DeferDependenciesLoadingState.IN_PROGRESS; @@ -393,13 +408,16 @@ function triggerResourceLoading( tDetails.loadingState = DeferDependenciesLoadingState.COMPLETE; // Update directive and pipe registries to add newly downloaded dependencies. + const primaryBlockTView = primaryBlockTNode.tView!; if (directiveDefs.length > 0) { - tView.directiveRegistry = tView.directiveRegistry ? - [...tView.directiveRegistry, ...directiveDefs] : + primaryBlockTView.directiveRegistry = primaryBlockTView.directiveRegistry ? + [...primaryBlockTView.directiveRegistry, ...directiveDefs] : directiveDefs; } if (pipeDefs.length > 0) { - tView.pipeRegistry = tView.pipeRegistry ? [...tView.pipeRegistry, ...pipeDefs] : pipeDefs; + primaryBlockTView.pipeRegistry = primaryBlockTView.pipeRegistry ? + [...primaryBlockTView.pipeRegistry, ...pipeDefs] : + pipeDefs; } } }); @@ -469,8 +487,7 @@ function triggerDeferBlock(lView: LView, tNode: TNode) { switch (tDetails.loadingState) { case DeferDependenciesLoadingState.NOT_STARTED: - triggerResourceLoading( - tDetails, getPrimaryBlockTNode(lView[TVIEW], tDetails), lView[INJECTOR]!); + triggerResourceLoading(tDetails, lView[TVIEW], lView); // The `loadingState` might have changed to "loading". if ((tDetails.loadingState as DeferDependenciesLoadingState) === diff --git a/packages/core/test/acceptance/defer_spec.ts b/packages/core/test/acceptance/defer_spec.ts index 473da1d052dbb6..630a74eb670078 100644 --- a/packages/core/test/acceptance/defer_spec.ts +++ b/packages/core/test/acceptance/defer_spec.ts @@ -18,7 +18,7 @@ import {TestBed} from '@angular/core/testing'; * and can not remove dependencies and their imports in the same way as AOT. * From JIT perspective, all dependencies inside a defer block remain eager. * We need to clear this association to run tests that verify loading and - * preloading behavior. + * prefetching behavior. */ function clearDirectiveDefs(type: Type): void { const cmpDef = getComponentDef(type); @@ -516,7 +516,7 @@ describe('#defer', () => { selector: 'root-app', imports: [NestedCmp], template: ` - {#defer when deferCond; prefetch when prefetchCond} + {#defer when deferCond; prefetch when prefetchCond} {:placeholder} Placeholder @@ -551,6 +551,9 @@ describe('#defer', () => { expect(fixture.nativeElement.outerHTML).toContain('Placeholder'); + // Make sure loading function is not yet invoked. + expect(loadingFnInvokedTimes).toBe(0); + // Trigger prefetching. fixture.componentInstance.prefetchCond = true; fixture.detectChanges(); @@ -580,7 +583,7 @@ describe('#defer', () => { expect(loadingFnInvokedTimes).toBe(1); }); - it('should handle a case when preloading fails', async () => { + it('should handle a case when prefetching fails', async () => { @Component({ selector: 'nested-cmp', standalone: true, @@ -595,7 +598,7 @@ describe('#defer', () => { selector: 'root-app', imports: [NestedCmp], template: ` - {#defer when deferCond; prefetch when prefetchCond} + {#defer when deferCond; prefetch when prefetchCond} {:error} Loading failed @@ -632,6 +635,9 @@ describe('#defer', () => { expect(fixture.nativeElement.outerHTML).toContain('Placeholder'); + // Make sure loading function is not yet invoked. + expect(loadingFnInvokedTimes).toBe(0); + // Trigger prefetching. fixture.componentInstance.prefetchCond = true; fixture.detectChanges(); @@ -651,7 +657,7 @@ describe('#defer', () => { await fixture.whenStable(); - // Since preloading failed, expect the `{:error}` state to be rendered. + // Since prefetching failed, expect the `{:error}` state to be rendered. expect(fixture.nativeElement.outerHTML).toContain('Loading failed'); // Expect that the loading resources function was not invoked again (counter remains 1). @@ -673,7 +679,7 @@ describe('#defer', () => { selector: 'root-app', imports: [NestedCmp], template: ` - {#defer when deferCond; prefetch when deferCond} + {#defer when deferCond; prefetch when deferCond} {:error} Loading failed @@ -709,6 +715,9 @@ describe('#defer', () => { expect(fixture.nativeElement.outerHTML).toContain('Placeholder'); + // Make sure loading function is not yet invoked. + expect(loadingFnInvokedTimes).toBe(0); + // Trigger prefetching and loading at the same time. fixture.componentInstance.deferCond = true; fixture.detectChanges(); @@ -724,5 +733,82 @@ describe('#defer', () => { // Expect the main content to be rendered. 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; + } + + @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)]; + }; + } + }; + + 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'); + + // Make sure loading function is not yet invoked. + expect(loadingFnInvokedTimes).toBe(0); + + 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'); + + // 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); + }); }); });