diff --git a/packages/core/src/render3/instructions/defer.ts b/packages/core/src/render3/instructions/defer.ts index 5db46c18893..91bbd25306c 100644 --- a/packages/core/src/render3/instructions/defer.ts +++ b/packages/core/src/render3/instructions/defer.ts @@ -158,7 +158,7 @@ export function ɵɵdeferPrefetchWhen(rawValue: unknown) { } /** - * Sets up handlers that represent `on idle` deferred trigger. + * Sets up logic to handle the `on idle` deferred trigger. * @codeGenApi */ export function ɵɵdeferOnIdle() { @@ -174,7 +174,7 @@ export function ɵɵdeferOnIdle() { } /** - * Creates runtime data structures for the `prefetch on idle` deferred trigger. + * Sets up logic to handle the `prefetch on idle` deferred trigger. * @codeGenApi */ export function ɵɵdeferPrefetchOnIdle() { @@ -202,17 +202,39 @@ export function ɵɵdeferPrefetchOnIdle() { } /** - * Creates runtime data structures for the `on immediate` deferred trigger. + * Sets up logic to handle the `on immediate` deferred trigger. * @codeGenApi */ -export function ɵɵdeferOnImmediate() {} // TODO: implement runtime logic. +export function ɵɵdeferOnImmediate() { + const lView = getLView(); + const tNode = getCurrentTNode()!; + const tView = lView[TVIEW]; + const tDetails = getTDeferBlockDetails(tView, tNode); + + // Render placeholder block only if loading template is not present + // to avoid content flickering, since it would be immediately replaced + // by the loading block. + if (tDetails.loadingTmplIndex === null) { + renderPlaceholder(lView, tNode); + } + triggerDeferBlock(lView, tNode); +} /** - * Creates runtime data structures for the `prefetch on immediate` deferred trigger. + * Sets up logic to handle the `prefetch on immediate` deferred trigger. * @codeGenApi */ -export function ɵɵdeferPrefetchOnImmediate() {} // TODO: implement runtime logic. +export function ɵɵdeferPrefetchOnImmediate() { + const lView = getLView(); + const tNode = getCurrentTNode()!; + const tView = lView[TVIEW]; + const tDetails = getTDeferBlockDetails(tView, tNode); + + if (tDetails.loadingState === DeferDependenciesLoadingState.NOT_STARTED) { + triggerResourceLoading(tDetails, lView); + } +} /** * Creates runtime data structures for the `on timer` deferred trigger. diff --git a/packages/core/test/acceptance/defer_spec.ts b/packages/core/test/acceptance/defer_spec.ts index 7c087f137e0..42bc7740ff1 100644 --- a/packages/core/test/acceptance/defer_spec.ts +++ b/packages/core/test/acceptance/defer_spec.ts @@ -44,6 +44,28 @@ function onIdle(callback: () => Promise): Promise { }); } +/** + * Emulates a dynamic import promise. + * + * Note: `setTimeout` is used to make `fixture.whenStable()` function + * wait for promise resolution, since `whenStable()` relies on the state + * of a macrotask queue. + */ +function dynamicImportOf(type: T): Promise { + return new Promise(resolve => { + setTimeout(() => resolve(type)); + }); +} + +/** + * Emulates a failed dynamic import promise. + */ +function failedDynamicImport(): Promise { + return new Promise((_, reject) => { + setTimeout(() => reject()); + }); +} + // 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}]; @@ -142,6 +164,81 @@ describe('#defer', () => { expect(fixture.nativeElement.outerHTML).toContain('Hi!'); }); + describe('`on` conditions', () => { + it('should support `on immediate` 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 on immediate} + + {:placeholder} + Placeholder + {:loading} + Loading + {/defer} + ` + }) + class RootCmp { + } + + let loadingFnInvokedTimes = 0; + const deferDepsInterceptor = { + intercept() { + return () => { + loadingFnInvokedTimes++; + return [dynamicImportOf(NestedCmp)]; + }; + } + }; + + TestBed.configureTestingModule({ + providers: [ + ...COMMON_PROVIDERS, + {provide: ɵDEFER_BLOCK_DEPENDENCY_INTERCEPTOR, useValue: deferDepsInterceptor}, + ], + deferBlockBehavior: DeferBlockBehavior.Playthrough, + }); + + clearDirectiveDefs(RootCmp); + + const fixture = TestBed.createComponent(RootCmp); + fixture.detectChanges(); + + // Expecting that no placeholder content would be rendered when + // a `{:loading}` block is present. + expect(fixture.nativeElement.outerHTML).toContain('Loading'); + + // Expecting loading function to be triggered right away. + expect(loadingFnInvokedTimes).toBe(1); + + await fixture.whenStable(); // loading dependencies of the defer block + fixture.detectChanges(); + + // Expect that the loading resources function was not invoked again. + expect(loadingFnInvokedTimes).toBe(1); + + // 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); + }); + }); + describe('directive matching', () => { it('should support directive matching in all blocks', async () => { @@ -236,8 +333,7 @@ describe('#defer', () => { const deferDepsInterceptor = { intercept() { - // Simulate loading failure. - return () => [Promise.reject()]; + return () => [failedDynamicImport()]; } }; @@ -561,7 +657,7 @@ describe('#defer', () => { intercept() { return () => { loadingFnInvokedTimes++; - return [Promise.resolve(NestedCmp)]; + return [dynamicImportOf(NestedCmp)]; }; } }; @@ -646,7 +742,7 @@ describe('#defer', () => { intercept() { return () => { loadingFnInvokedTimes++; - return [Promise.reject()]; + return [failedDynamicImport()]; }; } }; @@ -727,7 +823,7 @@ describe('#defer', () => { intercept() { return () => { loadingFnInvokedTimes++; - return [Promise.resolve(NestedCmp)]; + return [dynamicImportOf(NestedCmp)]; }; } }; @@ -796,7 +892,7 @@ describe('#defer', () => { intercept() { return () => { loadingFnInvokedTimes++; - return [Promise.resolve(NestedCmp)]; + return [dynamicImportOf(NestedCmp)]; }; } }; @@ -881,7 +977,7 @@ describe('#defer', () => { intercept() { return () => { loadingFnInvokedTimes++; - return [Promise.resolve(NestedCmp)]; + return [dynamicImportOf(NestedCmp)]; }; } }; @@ -966,7 +1062,7 @@ describe('#defer', () => { intercept() { return () => { loadingFnInvokedTimes++; - return [Promise.resolve(NestedCmp)]; + return [dynamicImportOf(NestedCmp)]; }; } }; @@ -1008,6 +1104,85 @@ describe('#defer', () => { expect(loadingFnInvokedTimes).toBe(1); }); }); + + it('should support `prefetch on immediate` 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 immediate} + + {:placeholder} + Placeholder + {/defer} + ` + }) + class RootCmp { + deferCond = false; + } + + let loadingFnInvokedTimes = 0; + const deferDepsInterceptor = { + intercept() { + return () => { + loadingFnInvokedTimes++; + return [dynamicImportOf(NestedCmp)]; + }; + } + }; + + TestBed.configureTestingModule({ + providers: [ + ...COMMON_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'); + + // Expecting loading function to be triggered right away. + expect(loadingFnInvokedTimes).toBe(1); + + 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); + }); }); // Note: these cases specifically use `on interaction`, however