Skip to content

Commit 3fcd8af

Browse files
feat(component): replace markDirty with custom TickScheduler (#3488)
1 parent bb9071c commit 3fcd8af

File tree

9 files changed

+172
-63
lines changed

9 files changed

+172
-63
lines changed
Lines changed: 10 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,24 @@
1-
import * as angular from '@angular/core';
2-
import { noop } from 'rxjs';
31
import { createRenderScheduler } from '../../src/core/render-scheduler';
4-
import {
5-
manualInstanceNgZone,
6-
manualInstanceNoopNgZone,
7-
MockChangeDetectorRef,
8-
} from '../fixtures/fixtures';
2+
import { NoopTickScheduler } from '../../src/core/tick-scheduler';
3+
import { MockChangeDetectorRef } from '../fixtures/fixtures';
94

105
describe('createRenderScheduler', () => {
11-
function setup(ngZone: angular.NgZone) {
6+
function setup() {
127
const cdRef = new MockChangeDetectorRef();
13-
const renderScheduler = createRenderScheduler({ ngZone, cdRef });
14-
jest.spyOn(angular, 'ɵmarkDirty').mockImplementation(noop);
8+
const tickScheduler = new NoopTickScheduler();
9+
jest.spyOn(tickScheduler, 'schedule');
10+
const renderScheduler = createRenderScheduler({ cdRef, tickScheduler });
1511

16-
return { cdRef, renderScheduler, markDirty: angular.ɵmarkDirty };
12+
return { cdRef, renderScheduler, tickScheduler };
1713
}
1814

1915
describe('schedule', () => {
20-
it('should call markForCheck in zone-full mode', () => {
21-
const { cdRef, renderScheduler, markDirty } = setup(manualInstanceNgZone);
16+
it('should call cdRef.markForCheck and tickScheduler.schedule', () => {
17+
const { cdRef, renderScheduler, tickScheduler } = setup();
2218
renderScheduler.schedule();
2319

24-
expect(markDirty).toHaveBeenCalledTimes(0);
2520
expect(cdRef.markForCheck).toHaveBeenCalledTimes(1);
26-
});
27-
28-
it('should call markDirty in zone-less mode', () => {
29-
const { cdRef, renderScheduler, markDirty } = setup(
30-
manualInstanceNoopNgZone
31-
);
32-
renderScheduler.schedule();
33-
34-
expect(markDirty).toHaveBeenCalledWith(
35-
(cdRef as unknown as { context: object }).context
36-
);
37-
expect(cdRef.markForCheck).toHaveBeenCalledTimes(0);
21+
expect(tickScheduler.schedule).toHaveBeenCalledTimes(1);
3822
});
3923
});
4024
});
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import {
2+
fakeAsync,
3+
flushMicrotasks,
4+
TestBed,
5+
tick,
6+
} from '@angular/core/testing';
7+
import { ApplicationRef, NgZone } from '@angular/core';
8+
import {
9+
AnimationFrameTickScheduler,
10+
NoopTickScheduler,
11+
TickScheduler,
12+
} from '../../src/core/tick-scheduler';
13+
import { ngZoneMock, noopNgZoneMock } from '../fixtures/fixtures';
14+
15+
describe('TickScheduler', () => {
16+
function setup(ngZone: unknown) {
17+
TestBed.configureTestingModule({
18+
providers: [{ provide: NgZone, useValue: ngZone }],
19+
});
20+
const tickScheduler = TestBed.inject(TickScheduler);
21+
const appRef = TestBed.inject(ApplicationRef);
22+
jest.spyOn(appRef, 'tick');
23+
24+
return { tickScheduler, appRef };
25+
}
26+
27+
describe('when NgZone is provided', () => {
28+
it('should initialize NoopTickScheduler', () => {
29+
const { tickScheduler } = setup(ngZoneMock);
30+
expect(tickScheduler instanceof NoopTickScheduler).toBe(true);
31+
});
32+
});
33+
34+
describe('when NgZone is not provided', () => {
35+
// `fakeAsync` uses 16ms as `requestAnimationFrame` delay
36+
const animationFrameDelay = 16;
37+
38+
it('should initialize AnimationFrameTickScheduler', () => {
39+
const { tickScheduler } = setup(noopNgZoneMock);
40+
expect(tickScheduler instanceof AnimationFrameTickScheduler).toBe(true);
41+
});
42+
43+
it('should schedule tick using the animationFrameScheduler', fakeAsync(() => {
44+
const { tickScheduler, appRef } = setup(noopNgZoneMock);
45+
46+
tickScheduler.schedule();
47+
48+
expect(appRef.tick).toHaveBeenCalledTimes(0);
49+
tick(animationFrameDelay / 2);
50+
expect(appRef.tick).toHaveBeenCalledTimes(0);
51+
tick(animationFrameDelay / 2);
52+
expect(appRef.tick).toHaveBeenCalledTimes(1);
53+
}));
54+
55+
it('should coalesce multiple synchronous schedule calls', fakeAsync(() => {
56+
const { tickScheduler, appRef } = setup(noopNgZoneMock);
57+
58+
tickScheduler.schedule();
59+
tickScheduler.schedule();
60+
tickScheduler.schedule();
61+
62+
tick(animationFrameDelay);
63+
expect(appRef.tick).toHaveBeenCalledTimes(1);
64+
}));
65+
66+
it('should coalesce multiple schedule calls that are queued to the microtask queue', fakeAsync(() => {
67+
const { tickScheduler, appRef } = setup(noopNgZoneMock);
68+
69+
queueMicrotask(() => tickScheduler.schedule());
70+
queueMicrotask(() => tickScheduler.schedule());
71+
queueMicrotask(() => tickScheduler.schedule());
72+
73+
flushMicrotasks();
74+
expect(appRef.tick).toHaveBeenCalledTimes(0);
75+
tick(animationFrameDelay);
76+
expect(appRef.tick).toHaveBeenCalledTimes(1);
77+
}));
78+
79+
it('should schedule multiple ticks for multiple asynchronous schedule calls', fakeAsync(() => {
80+
const { tickScheduler, appRef } = setup(noopNgZoneMock);
81+
82+
setTimeout(() => tickScheduler.schedule(), 100);
83+
setTimeout(() => tickScheduler.schedule(), 200);
84+
setTimeout(() => tickScheduler.schedule(), 300);
85+
86+
tick(300 + animationFrameDelay);
87+
expect(appRef.tick).toHaveBeenCalledTimes(3);
88+
}));
89+
});
90+
});
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { isNgZone } from '../../src/core/zone-helpers';
2+
import { ngZoneMock, noopNgZoneMock } from '../fixtures/fixtures';
3+
4+
describe('isNgZone', () => {
5+
it('should return true with NgZone instance', () => {
6+
expect(isNgZone(ngZoneMock)).toBe(true);
7+
});
8+
9+
it('should return false with NoopNgZone instance', () => {
10+
expect(isNgZone(noopNgZoneMock)).toBe(false);
11+
});
12+
});

modules/component/spec/fixtures/fixtures.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,11 @@
11
import { NgZone } from '@angular/core';
22
import { MockNoopNgZone } from './mock-noop-ng-zone';
33

4-
/**
5-
* this is not exposed as NgZone should never be exposed to get miss matched with the real one
6-
*/
7-
class NoopNgZone extends MockNoopNgZone {}
8-
9-
export const manualInstanceNgZone = new NgZone({
4+
export const ngZoneMock = new NgZone({
105
enableLongStackTrace: false,
116
shouldCoalesceEventChangeDetection: false,
127
});
13-
export const manualInstanceNoopNgZone = new NoopNgZone({
8+
export const noopNgZoneMock = new MockNoopNgZone({
149
enableLongStackTrace: false,
1510
shouldCoalesceEventChangeDetection: false,
1611
});
Lines changed: 5 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,22 @@
1-
import {
2-
ChangeDetectorRef,
3-
NgZone,
4-
ɵmarkDirty as markDirty,
5-
} from '@angular/core';
1+
import { ChangeDetectorRef } from '@angular/core';
2+
import { TickScheduler } from './tick-scheduler';
63

74
export interface RenderScheduler {
85
schedule(): void;
96
}
107

118
export interface RenderSchedulerConfig {
12-
ngZone: NgZone;
139
cdRef: ChangeDetectorRef;
10+
tickScheduler: TickScheduler;
1411
}
1512

1613
export function createRenderScheduler(
1714
config: RenderSchedulerConfig
1815
): RenderScheduler {
1916
function schedule(): void {
20-
if (hasZone(config.ngZone)) {
21-
config.cdRef.markForCheck();
22-
} else {
23-
const context = getCdRefContext(config.cdRef);
24-
markDirty(context);
25-
}
17+
config.cdRef.markForCheck();
18+
config.tickScheduler.schedule();
2619
}
2720

2821
return { schedule };
2922
}
30-
31-
/**
32-
* @description
33-
* Determines if the application uses `NgZone` or `NgNoopZone` as ngZone service instance.
34-
*/
35-
function hasZone(z: NgZone): boolean {
36-
return z instanceof NgZone;
37-
}
38-
39-
function getCdRefContext(cdRef: ChangeDetectorRef): object {
40-
return (cdRef as unknown as { context: object }).context;
41-
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { ApplicationRef, inject, Injectable, NgZone } from '@angular/core';
2+
import { animationFrameScheduler } from 'rxjs';
3+
import { isNgZone } from './zone-helpers';
4+
5+
@Injectable({
6+
providedIn: 'root',
7+
useFactory: () => {
8+
const zone = inject(NgZone);
9+
return isNgZone(zone)
10+
? new NoopTickScheduler()
11+
: inject(AnimationFrameTickScheduler);
12+
},
13+
})
14+
export abstract class TickScheduler {
15+
abstract schedule(): void;
16+
}
17+
18+
@Injectable({
19+
providedIn: 'root',
20+
})
21+
export class AnimationFrameTickScheduler extends TickScheduler {
22+
private isScheduled = false;
23+
24+
constructor(private readonly appRef: ApplicationRef) {
25+
super();
26+
}
27+
28+
schedule(): void {
29+
if (!this.isScheduled) {
30+
this.isScheduled = true;
31+
animationFrameScheduler.schedule(() => {
32+
this.appRef.tick();
33+
this.isScheduled = false;
34+
});
35+
}
36+
}
37+
}
38+
39+
export class NoopTickScheduler extends TickScheduler {
40+
// eslint-disable-next-line @typescript-eslint/no-empty-function
41+
schedule(): void {}
42+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { NgZone } from '@angular/core';
2+
3+
export function isNgZone(zone: unknown): zone is NgZone {
4+
return zone instanceof NgZone;
5+
}

modules/component/src/let/let.directive.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import {
33
Directive,
44
ErrorHandler,
55
Input,
6-
NgZone,
76
OnDestroy,
87
OnInit,
98
TemplateRef,
@@ -16,6 +15,7 @@ import {
1615
} from '../core/potential-observable';
1716
import { createRenderScheduler } from '../core/render-scheduler';
1817
import { createRenderEventManager } from '../core/render-event/manager';
18+
import { TickScheduler } from '../core/tick-scheduler';
1919

2020
type LetViewContextValue<PO> = PO extends ObservableOrPromise<infer V> ? V : PO;
2121

@@ -115,8 +115,8 @@ export class LetDirective<PO> implements OnInit, OnDestroy {
115115
$suspense: true,
116116
};
117117
private readonly renderScheduler = createRenderScheduler({
118-
ngZone: this.ngZone,
119118
cdRef: this.cdRef,
119+
tickScheduler: this.tickScheduler,
120120
});
121121
private readonly renderEventManager = createRenderEventManager<
122122
LetViewContextValue<PO>
@@ -183,7 +183,7 @@ export class LetDirective<PO> implements OnInit, OnDestroy {
183183

184184
constructor(
185185
private readonly cdRef: ChangeDetectorRef,
186-
private readonly ngZone: NgZone,
186+
private readonly tickScheduler: TickScheduler,
187187
private readonly mainTemplateRef: TemplateRef<LetViewContext<PO>>,
188188
private readonly viewContainerRef: ViewContainerRef,
189189
private readonly errorHandler: ErrorHandler

modules/component/src/push/push.pipe.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import {
22
ChangeDetectorRef,
33
ErrorHandler,
4-
NgZone,
54
OnDestroy,
65
Pipe,
76
PipeTransform,
@@ -10,6 +9,7 @@ import { Unsubscribable } from 'rxjs';
109
import { ObservableOrPromise } from '../core/potential-observable';
1110
import { createRenderScheduler } from '../core/render-scheduler';
1211
import { createRenderEventManager } from '../core/render-event/manager';
12+
import { TickScheduler } from '../core/tick-scheduler';
1313

1414
type PushPipeResult<PO> = PO extends ObservableOrPromise<infer R>
1515
? R | undefined
@@ -40,8 +40,8 @@ type PushPipeResult<PO> = PO extends ObservableOrPromise<infer R>
4040
export class PushPipe implements PipeTransform, OnDestroy {
4141
private renderedValue: unknown;
4242
private readonly renderScheduler = createRenderScheduler({
43-
ngZone: this.ngZone,
4443
cdRef: this.cdRef,
44+
tickScheduler: this.tickScheduler,
4545
});
4646
private readonly renderEventManager = createRenderEventManager({
4747
suspense: (event) => this.setRenderedValue(undefined, event.synchronous),
@@ -62,7 +62,7 @@ export class PushPipe implements PipeTransform, OnDestroy {
6262

6363
constructor(
6464
private readonly cdRef: ChangeDetectorRef,
65-
private readonly ngZone: NgZone,
65+
private readonly tickScheduler: TickScheduler,
6666
private readonly errorHandler: ErrorHandler
6767
) {
6868
this.subscription = this.renderEventManager

0 commit comments

Comments
 (0)