diff --git a/packages/core/src/change_detection/scheduling/zoneless_scheduling_impl.ts b/packages/core/src/change_detection/scheduling/zoneless_scheduling_impl.ts index 2c44a2cd2b6..c45faf00623 100644 --- a/packages/core/src/change_detection/scheduling/zoneless_scheduling_impl.ts +++ b/packages/core/src/change_detection/scheduling/zoneless_scheduling_impl.ts @@ -149,12 +149,12 @@ export class ChangeDetectionSchedulerImpl implements ChangeDetectionScheduler, O } switch (source) { - case NotificationSource.MarkAncestorsForTraversal: { + case NotificationSource.MarkAncestorsForTraversal: + case NotificationSource.DeferBlockStateUpdate: { this.appRef.dirtyFlags |= ApplicationRefDirtyFlags.ViewTreeTraversal; break; } case NotificationSource.DebugApplyChanges: - case NotificationSource.DeferBlockStateUpdate: case NotificationSource.MarkForCheck: case NotificationSource.Listener: case NotificationSource.SetInput: { diff --git a/packages/core/src/defer/idle_scheduler.ts b/packages/core/src/defer/idle_scheduler.ts index 1912ad83fcb..ba410af3b23 100644 --- a/packages/core/src/defer/idle_scheduler.ts +++ b/packages/core/src/defer/idle_scheduler.ts @@ -10,6 +10,7 @@ import type {OnDestroy} from '../core'; import {Injector, inject, ɵɵdefineInjectable} from '../di'; import {NgZone} from '../zone'; import {IDLE_SERVICE} from './idle_service'; +import {ApplicationRef} from '../application/application_ref'; /** * Helper function to schedule a callback to be invoked when a browser becomes idle. @@ -35,6 +36,7 @@ export class IdleScheduler implements OnDestroy { // Queue of callbacks to be invoked next. queue = new Set(); + applicationRef = inject(ApplicationRef); ngZone = inject(NgZone); private readonly idleService = inject(IDLE_SERVICE); @@ -64,6 +66,10 @@ export class IdleScheduler implements OnDestroy { for (const callbackFn of this.queue) { callbackFn(); + // _tick here is an optimized change detection check and is safe to call here. + // We also account for the time it takes to run change detection + // for the newly-created view as a part of the same idle callback. + this.applicationRef._tick(); this.queue.delete(callbackFn); if (deadline && deadline.timeRemaining() === 0 && !deadline.didTimeout) { diff --git a/packages/core/src/defer/rendering.ts b/packages/core/src/defer/rendering.ts index 86164ff3e36..9fe262dada7 100644 --- a/packages/core/src/defer/rendering.ts +++ b/packages/core/src/defer/rendering.ts @@ -15,13 +15,12 @@ import { } from '../hydration/interfaces'; import {assertLContainer, assertTNodeForLView} from '../render3/assert'; import {ChainedInjector} from '../render3/chained_injector'; -import {markViewDirty} from '../render3/instructions/mark_view_dirty'; import {handleUncaughtError} from '../render3/instructions/shared'; import {DEHYDRATED_VIEWS, LContainer} from '../render3/interfaces/container'; import {TContainerNode, TNode} from '../render3/interfaces/node'; import {isDestroyed} from '../render3/interfaces/type_checks'; import {HEADER_OFFSET, INJECTOR, LView, PARENT, TVIEW, TView} from '../render3/interfaces/view'; -import {getConstant, getTNode} from '../render3/util/view_utils'; +import {markViewForRefresh, getConstant, getTNode} from '../render3/util/view_utils'; import {createAndRenderEmbeddedLView, shouldAddViewToDom} from '../render3/view_manipulation'; import {assertDefined} from '../util/assert'; @@ -289,7 +288,7 @@ function applyDeferBlockState( viewIndex, shouldAddViewToDom(activeBlockTNode, dehydratedView), ); - markViewDirty(embeddedLView, NotificationSource.DeferBlockStateUpdate); + markViewForRefresh(embeddedLView); if (dehydratedViewIx > -1) { // Erase dehydrated view info in a given LContainer, so that the view is not diff --git a/packages/core/test/acceptance/defer_spec.ts b/packages/core/test/acceptance/defer_spec.ts index 1558e02e56e..41d4b120786 100644 --- a/packages/core/test/acceptance/defer_spec.ts +++ b/packages/core/test/acceptance/defer_spec.ts @@ -701,6 +701,86 @@ describe('@defer', () => { // Expect that the loading resources function was not invoked again (counter remains 1). expect(loadingFnInvokedTimes).toBe(1); }); + + it('should trigger change detection when `on idle` is fired without explicit fixture.detectChanges()', async () => { + @Component({ + selector: 'nested-cmp', + template: 'Rendering {{ block }} block.', + }) + class NestedCmp { + @Input() block!: string; + } + + @Component({ + selector: 'root-app', + imports: [NestedCmp], + template: ` + @defer (on idle) { + + } @placeholder { + Placeholder + } @loading { + Loading + } + `, + }) + class RootCmp {} + + let loadingFnInvokedTimes = 0; + const deferDepsInterceptor = { + intercept() { + return () => { + loadingFnInvokedTimes++; + return [dynamicImportOf(NestedCmp)]; + }; + }, + }; + + const idleCallbacks: IdleRequestCallback[] = []; + const mockRequestIdleCallback = ( + callback: IdleRequestCallback, + options?: IdleRequestOptions, + ): number => { + idleCallbacks.push(callback); + return 1; + }; + + const nativeRequestIdleCallback = globalThis.requestIdleCallback; + const nativeCancelIdleCallback = globalThis.cancelIdleCallback; + globalThis.requestIdleCallback = mockRequestIdleCallback; + globalThis.cancelIdleCallback = (id: number) => {}; + + try { + TestBed.configureTestingModule({ + providers: [ + ...COMMON_PROVIDERS, + {provide: ɵDEFER_BLOCK_DEPENDENCY_INTERCEPTOR, useValue: deferDepsInterceptor}, + ], + }); + + clearDirectiveDefs(RootCmp); + + const fixture = TestBed.createComponent(RootCmp); + fixture.detectChanges(); + + expect(fixture.nativeElement.outerHTML).toContain('Placeholder'); + expect(loadingFnInvokedTimes).toBe(0); + + // Trigger the idle callback + expect(idleCallbacks.length).toBe(1); + idleCallbacks[0]({timeRemaining: () => 50, didTimeout: false} as IdleDeadline); + + // Ensure that loading function was called + expect(loadingFnInvokedTimes).toBe(1); + + // The tick generated from the defer block state change inside the idle scheduler + // should have updated the view automatically, showing the loading block. + expect(fixture.nativeElement.outerHTML).toContain('Loading'); + } finally { + globalThis.requestIdleCallback = nativeRequestIdleCallback; + globalThis.cancelIdleCallback = nativeCancelIdleCallback; + } + }); }); describe('directive matching', () => {