diff --git a/packages/core/src/render3/instructions/defer.ts b/packages/core/src/render3/instructions/defer.ts index 37e42876cd586f..1517ee5286da23 100644 --- a/packages/core/src/render3/instructions/defer.ts +++ b/packages/core/src/render3/instructions/defer.ts @@ -147,7 +147,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); } } } @@ -161,28 +161,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. @@ -253,6 +248,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. @@ -347,22 +362,22 @@ 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!; - - if (!shouldTriggerDeferBlock(injector)) return; +function triggerResourceLoading(tDetails: TDeferBlockDetails, tView: TView, lView: LView) { + const injector = lView[INJECTOR]!; - if (tDetails.loadingState !== DeferDependenciesLoadingState.NOT_STARTED) { + if (!shouldTriggerDeferBlock(injector) || + 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 // in this function. All details can be obtained from the `tDetails` object. return; } + const primaryBlockTNode = getPrimaryBlockTNode(tView, tDetails); + // Switch from NOT_STARTED -> IN_PROGRESS state. tDetails.loadingState = DeferDependenciesLoadingState.IN_PROGRESS; @@ -417,13 +432,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; } } }); @@ -496,8 +514,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 cb983cf4fe0afb..c93881f63c4acf 100644 --- a/packages/core/test/acceptance/defer_spec.ts +++ b/packages/core/test/acceptance/defer_spec.ts @@ -19,7 +19,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); @@ -27,6 +27,23 @@ 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(); + }); + }); +} + // 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}]; @@ -525,7 +542,7 @@ describe('#defer', () => { selector: 'root-app', imports: [NestedCmp], template: ` - {#defer when deferCond; prefetch when prefetchCond} + {#defer when deferCond; prefetch when prefetchCond} {:placeholder} Placeholder @@ -560,6 +577,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(); @@ -589,7 +609,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, @@ -604,7 +624,7 @@ describe('#defer', () => { selector: 'root-app', imports: [NestedCmp], template: ` - {#defer when deferCond; prefetch when prefetchCond} + {#defer when deferCond; prefetch when prefetchCond} {:error} Loading failed @@ -641,6 +661,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(); @@ -660,7 +683,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). @@ -682,7 +705,7 @@ describe('#defer', () => { selector: 'root-app', imports: [NestedCmp], template: ` - {#defer when deferCond; prefetch when deferCond} + {#defer when deferCond; prefetch when deferCond} {:error} Loading failed @@ -718,6 +741,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(); @@ -733,5 +759,86 @@ 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); + + // 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 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); + }); + }); }); });