diff --git a/packages/core/src/render3/instructions/defer.ts b/packages/core/src/render3/instructions/defer.ts index 37e42876cd586..a97b6b033f7d9 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); } } } @@ -162,27 +162,33 @@ export function ɵɵdeferOnIdle() { 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); + // Note: we pass an `lView` as a second argument to cancel an `idle` + // callback in case an LView got destroyed before an `idle` callback + // is invoked. + onIdle(() => triggerDeferBlock(lView, tNode), lView); } /** * 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) { + // 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 a second argument), + // because there might be other LViews (that represent embedded views) that + // depend on resource loading. + onIdle(() => triggerResourceLoading(tDetails, tView, lView), null /* LView */); + } +} /** * Creates runtime data structures for the `on immediate` deferred trigger. @@ -253,6 +259,37 @@ export function ɵɵdeferPrefetchOnViewport(target?: unknown) {} // TODO: imple /********** Helper functions **********/ +/** + * 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. + */ +function onIdle(callback: VoidFunction, 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; + + 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); + } +} + /** * Calculates a data slot index for defer block info (either static or * instance-specific), given an index of a defer instruction. @@ -347,22 +384,23 @@ 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 && + tDetails.loadingState !== DeferDependenciesLoadingState.SCHEDULED)) { // 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 +455,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 +537,8 @@ function triggerDeferBlock(lView: LView, tNode: TNode) { switch (tDetails.loadingState) { case DeferDependenciesLoadingState.NOT_STARTED: - triggerResourceLoading( - tDetails, getPrimaryBlockTNode(lView[TVIEW], tDetails), lView[INJECTOR]!); + case DeferDependenciesLoadingState.SCHEDULED: + triggerResourceLoading(tDetails, lView[TVIEW], lView); // The `loadingState` might have changed to "loading". if ((tDetails.loadingState as DeferDependenciesLoadingState) === diff --git a/packages/core/src/render3/interfaces/defer.ts b/packages/core/src/render3/interfaces/defer.ts index 9fdb0891043c2..1548d7a19dd4c 100644 --- a/packages/core/src/render3/interfaces/defer.ts +++ b/packages/core/src/render3/interfaces/defer.ts @@ -21,6 +21,9 @@ export const enum DeferDependenciesLoadingState { /** Initial state, dependency loading is not yet triggered */ NOT_STARTED, + /** Dependency loading was scheduled (e.g. `on idle`), but has not started yet */ + SCHEDULED, + /** Dependency loading is in progress */ IN_PROGRESS, diff --git a/packages/core/test/acceptance/defer_spec.ts b/packages/core/test/acceptance/defer_spec.ts index cb983cf4fe0af..fa7b3b673b5a8 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,12 +27,29 @@ 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}]; describe('#defer', () => { - beforeEach(() => setEnabledBlockTypes(['defer'])); + beforeEach(() => setEnabledBlockTypes(['defer', 'for'])); afterEach(() => setEnabledBlockTypes([])); beforeEach(() => { @@ -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,246 @@ 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); + }); + }); + + 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: ` + {#for item of items; track item} + {#defer when deferCond; prefetch on idle} + + {:placeholder} + Placeholder \`{{ item }}\` + {/defer} + {/for} + ` + }) + class RootCmp { + deferCond = false; + 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); + + // 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 `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); + }); + }); + + 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: ` + {#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 [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); + }); + }); }); });