refactor(core): Ensure idle scheduler batching accounts for change detection

This updates the deadline check in requestIdleCallback to ensure change detection properly dirties the views.
This commit is contained in:
Jessica Janiuk
2026-02-24 15:57:54 -08:00
committed by Jessica Janiuk
parent 9bb4f02bef
commit e1bc342856
4 changed files with 90 additions and 5 deletions
@@ -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: {
@@ -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<VoidFunction>();
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) {
+2 -3
View File
@@ -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
@@ -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) {
<nested-cmp [block]="'primary'" />
} @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', () => {