Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions packages/core/src/application/application_ref.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,8 @@ export function optionsReducer<T extends Object>(dst: T, objs: T|T[]): T {
export class ApplicationRef {
/** @internal */
private _bootstrapListeners: ((compRef: ComponentRef<any>) => void)[] = [];
private _runningTick: boolean = false;
/** @internal */
_runningTick: boolean = false;
private _destroyed = false;
private _destroyListeners: Array<() => void> = [];
/** @internal */
Expand All @@ -298,7 +299,8 @@ export class ApplicationRef {
// Eventually the hostView of the fixture should just attach to ApplicationRef.
private externalTestViews: Set<InternalViewRef<unknown>> = new Set();
private beforeRender = new Subject<boolean>();
private afterTick = new Subject<void>();
/** @internal */
afterTick = new Subject<void>();

/**
* Indicates whether this instance was destroyed.
Expand Down Expand Up @@ -537,9 +539,9 @@ export class ApplicationRef {
// Attention: Don't rethrow as it could cancel subscriptions to Observables!
this.internalErrorHandler(e);
} finally {
this.afterTick.next();
this._runningTick = false;
setActiveConsumer(prevConsumer);
this.afterTick.next();
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,14 @@ export class NgZoneChangeDetectionScheduler {

this._onMicrotaskEmptySubscription = this.zone.onMicrotaskEmpty.subscribe({
next: () => {
// `onMicroTaskEmpty` can happen _during_ the zoneless scheduler change detection because
// zone.run(() => {}) will result in `checkStable` at the end of the `zone.run` closure
// and emit `onMicrotaskEmpty` synchronously if run coalsecing is false.
if (this.changeDetectionScheduler?.runningTick) {
Comment thread
atscott marked this conversation as resolved.
Outdated
return;
}
this.zone.run(() => {
if (this.changeDetectionScheduler) {
this.changeDetectionScheduler.tick(true /* shouldRefreshViews */);
} else {
this.applicationRef.tick();
}
this.applicationRef.tick();
});
}
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export const enum NotificationType {
*/
export abstract class ChangeDetectionScheduler {
abstract notify(source?: NotificationType): void;
abstract tick(shouldRefreshViews: boolean): void;
abstract runningTick: boolean;
}

/** Token used to indicate if zoneless was enabled via provideZonelessChangeDetection(). */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,20 @@ export class ChangeDetectionSchedulerImpl implements ChangeDetectionScheduler {
private pendingRenderTaskId: number|null = null;
private shouldRefreshViews = false;
private readonly ngZone = inject(NgZone);
private runningTick = false;
runningTick = false;
private cancelScheduledCallback: null|(() => void) = null;
private readonly zonelessEnabled = inject(ZONELESS_ENABLED);
private readonly disableScheduling =
inject(ZONELESS_SCHEDULER_DISABLED, {optional: true}) ?? false;
private readonly zoneIsDefined = typeof Zone !== 'undefined' && !!Zone.root.scheduleEventTask;
private readonly afterTickSubscription = this.appRef.afterTick.subscribe(() => {
// If the scheduler isn't running a tick but the application ticked, that means
// someone called ApplicationRef.tick manually. In this case, we should cancel
// any change detections that had been scheduled so we don't run an extra one.
if (!this.runningTick) {
this.cleanup();
}
});

constructor() {
// TODO(atscott): These conditions will need to change when zoneless is the default
Expand Down Expand Up @@ -70,7 +78,7 @@ export class ChangeDetectionSchedulerImpl implements ChangeDetectionScheduler {
return false;
}
// already scheduled or running
if (this.pendingRenderTaskId !== null || this.runningTick) {
if (this.pendingRenderTaskId !== null || this.runningTick || this.appRef._runningTick) {
return false;
}
// If we're inside the zone don't bother with scheduler. Zone will stabilize
Expand All @@ -91,7 +99,7 @@ export class ChangeDetectionSchedulerImpl implements ChangeDetectionScheduler {
* @param shouldRefreshViews Passed directly to `ApplicationRef._tick` and skips straight to
* render hooks when `false`.
*/
tick(shouldRefreshViews: boolean): void {
private tick(shouldRefreshViews: boolean): void {
// When ngZone.run below exits, onMicrotaskEmpty may emit if the zone is
// stable. We want to prevent double ticking so we track whether the tick is
// already running and skip it if so.
Expand All @@ -110,6 +118,7 @@ export class ChangeDetectionSchedulerImpl implements ChangeDetectionScheduler {
}

ngOnDestroy() {
this.afterTickSubscription.unsubscribe();
this.cleanup();
}

Expand Down
60 changes: 60 additions & 0 deletions packages/core/test/change_detection_scheduler_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -540,6 +540,32 @@ describe('Angular with zoneless enabled', () => {
}).not.toThrow();
await fixture.whenStable();
});

it('should not run change detection twice if manual tick called when CD was scheduled',
async () => {
let changeDetectionRuns = 0;
TestBed.runInInjectionContext(() => {
afterRender(() => {
changeDetectionRuns++;
});
});
@Component({template: '', standalone: true})
class MyComponent {
cdr = inject(ChangeDetectorRef);
}
const fixture = TestBed.createComponent(MyComponent);
await fixture.whenStable();
expect(changeDetectionRuns).toEqual(1);

// notify the scheduler
fixture.componentInstance.cdr.markForCheck();
// call tick manually
TestBed.inject(ApplicationRef).tick();
await fixture.whenStable();
// ensure we only ran render hook 1 more time rather than once for tick and once for the
// scheduled run
expect(changeDetectionRuns).toEqual(2);
});
});

describe('Angular with scheduler and ZoneJS', () => {
Expand Down Expand Up @@ -585,4 +611,38 @@ describe('Angular with scheduler and ZoneJS', () => {
await fixture.whenStable();
expect(fixture.nativeElement.innerText).toContain('new');
});

it('should not run change detection twice if notified during AppRef.tick', async () => {
TestBed.configureTestingModule({
providers: [
provideZoneChangeDetection({ignoreChangesOutsideZone: false}),
{provide: PLATFORM_ID, useValue: PLATFORM_BROWSER_ID},
]
});

let changeDetectionRuns = 0;
TestBed.runInInjectionContext(() => {
afterRender(() => {
changeDetectionRuns++;
});
});
@Component({template: '', standalone: true})
class MyComponent {
cdr = inject(ChangeDetectorRef);
ngDoCheck() {
// notify scheduler every time this component is checked
this.cdr.markForCheck();
}
}
const fixture = TestBed.createComponent(MyComponent);
await fixture.whenStable();
expect(changeDetectionRuns).toEqual(1);

// call tick manually
TestBed.inject(ApplicationRef).tick();
await fixture.whenStable();
// ensure we only ran render hook 1 more time rather than once for tick and once for the
// scheduled run
expect(changeDetectionRuns).toEqual(2);
});
});