Skip to content

Commit 0a827f9

Browse files
committed
refactor(zone.js): Add internal implementation for auto ticking fakeAsync (#62135)
Benefits of auto-ticking mock clocks have been described in other PRs, such as jasmine/jasmine#2042 and sinonjs/fake-timers#509. In short, `fakeAsync` cannot work when some tasks are required to be truly async, such as XHRs or observers like ResizeObserver. In addition, auto ticking mock clocks can be applied to tests without the tests then needing to update everything to manually flush timers. PR Close #62135
1 parent 124dcc0 commit 0a827f9

File tree

2 files changed

+215
-4
lines changed

2 files changed

+215
-4
lines changed

packages/zone.js/lib/zone-spec/fake-async-test.ts

Lines changed: 135 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ interface MacroTaskOptions {
3434
callbackArgs?: any;
3535
}
3636

37+
// Need this because mock clocks might be installed (other than fakeAsync!)
38+
const originalSetImmediate = global.setImmediate;
39+
const originalTimeout = global.setTimeout;
3740
const OriginalDate = global.Date;
3841
// Since when we compile this file to `es2015`, and if we define
3942
// this `FakeDate` as `class FakeDate`, and then set `FakeDate.prototype`
@@ -274,6 +277,19 @@ class Scheduler {
274277
}
275278
}
276279

280+
executeNextTask(doTick?: (elapsed: number) => void): void {
281+
const current = this._schedulerQueue.shift();
282+
if (current === undefined) {
283+
return;
284+
}
285+
doTick?.(current.endTime - this._currentTickTime);
286+
this._currentTickTime = current.endTime;
287+
current.func.apply(
288+
global,
289+
current.isRequestAnimationFrame ? [this._currentTickTime] : current.args,
290+
);
291+
}
292+
277293
flushOnlyPendingTimers(doTick?: (elapsed: number) => void): number {
278294
if (this._schedulerQueue.length === 0) {
279295
return 0;
@@ -547,6 +563,99 @@ class FakeAsyncTestZoneSpec implements ZoneSpec {
547563
FakeAsyncTestZoneSpec.resetDate();
548564
}
549565

566+
private tickMode: {counter: number; mode: 'manual' | 'automatic'} = {
567+
counter: 0,
568+
mode: 'manual',
569+
};
570+
571+
/** @experimental */
572+
setTickMode(mode: 'manual' | 'automatic', doTick?: (elapsed: number) => void) {
573+
if (mode === this.tickMode.mode) {
574+
return;
575+
}
576+
this.tickMode.counter++;
577+
this.tickMode.mode = mode;
578+
if (mode === 'automatic') {
579+
this.advanceUntilModeChanges(doTick);
580+
}
581+
}
582+
583+
private advanceUntilModeChanges(doTick?: (elapsed: number) => void): void {
584+
FakeAsyncTestZoneSpec.assertInZone();
585+
const specZone = Zone.current;
586+
const {counter} = this.tickMode;
587+
588+
Zone.root.run(async () => {
589+
// autoTick with fakeAsync is a bit awkward because microtasks are
590+
// controlled by the scheduler as well. This means that we have to
591+
// manually flush microtasks before allowing real macrotasks to execute.
592+
// Waiting for a macrotask would otherwise allow the browser to execute
593+
// other macrotasks before the currently scheduled microtasks are flushed.
594+
await safeAsync(async () => {
595+
await void 0;
596+
specZone.run(() => {
597+
this.flushMicrotasks();
598+
});
599+
});
600+
601+
if (this.tickMode.counter !== counter) {
602+
return;
603+
}
604+
605+
while (true) {
606+
await safeAsync(() => this.newMacrotask(specZone));
607+
608+
if (this.tickMode.counter !== counter) {
609+
return;
610+
}
611+
612+
await safeAsync(() =>
613+
specZone.run(() => {
614+
this._scheduler.executeNextTask(doTick);
615+
}),
616+
);
617+
}
618+
});
619+
}
620+
621+
// Waits until a new macro task.
622+
//
623+
// Used with autoTick(), which is meant to act when the test is waiting, we
624+
// need to insert ourselves in the macro task queue.
625+
//
626+
// @return {!Promise<undefined>}
627+
private async newMacrotask(specZone: Zone) {
628+
if (originalSetImmediate) {
629+
// setImmediate is much faster than setTimeout in node
630+
await new Promise((resolve) => {
631+
originalSetImmediate(resolve);
632+
});
633+
} else {
634+
// MessageChannel ensures that setTimeout is not throttled to 4ms.
635+
// https://developer.mozilla.org/en-US/docs/Web/API/setTimeout#reasons_for_delays_longer_than_specified
636+
// https://stackblitz.com/edit/stackblitz-starters-qtlpcc
637+
// Note: This trick does not work in Safari, which will still throttle the
638+
// setTimeout
639+
const channel = new MessageChannel();
640+
await new Promise((resolve) => {
641+
channel.port1.onmessage = resolve;
642+
channel.port2.postMessage(undefined);
643+
});
644+
channel.port1.close();
645+
channel.port2.close();
646+
// setTimeout ensures that we interleave with other setTimeouts.
647+
await new Promise((resolve) => {
648+
originalTimeout(resolve);
649+
});
650+
}
651+
652+
// flush any microtasks that were scheduled from the tasks that ran during
653+
// the timeout.
654+
specZone.run(() => {
655+
this.flushMicrotasks();
656+
});
657+
}
658+
550659
tickToNext(
551660
steps: number = 1,
552661
doTick?: (elapsed: number) => void,
@@ -676,10 +785,16 @@ class FakeAsyncTestZoneSpec implements ZoneSpec {
676785
);
677786
break;
678787
case 'XMLHttpRequest.send':
679-
throw new Error(
680-
'Cannot make XHRs from within a fake async test. Request URL: ' +
681-
(task.data as any)['url'],
682-
);
788+
if (this.tickMode.mode === 'manual') {
789+
throw new Error(
790+
'Cannot make XHRs from within a fake async test. Request URL: ' +
791+
(task.data as any)['url'],
792+
);
793+
}
794+
// When using automatic ticking, we allow the XHR to be handled in a truly async form
795+
// by the parent/delegate Zone because auto ticking FakeAsync is not strictly synchronous.
796+
task = delegate.scheduleTask(target, task);
797+
break;
683798
case 'requestAnimationFrame':
684799
case 'webkitRequestAnimationFrame':
685800
case 'mozRequestAnimationFrame':
@@ -1034,3 +1149,19 @@ export function patchFakeAsyncTest(Zone: ZoneType): void {
10341149

10351150
Scheduler.nextId = Scheduler.getNextId();
10361151
}
1152+
1153+
async function safeAsync(fn: () => Promise<void>): Promise<void> {
1154+
try {
1155+
return await fn();
1156+
} catch (e) {
1157+
hostReportError(e);
1158+
}
1159+
}
1160+
1161+
function hostReportError(e: unknown) {
1162+
Zone.root.run(() => {
1163+
originalTimeout(() => {
1164+
throw e;
1165+
});
1166+
});
1167+
}

packages/zone.js/test/zone-spec/fake-async-test.spec.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1152,6 +1152,86 @@ describe('FakeAsyncTestZoneSpec', () => {
11521152
emptyRun,
11531153
),
11541154
);
1155+
1156+
describe('setTickMode', () => {
1157+
it('should execute timers automatically when mode is set to automatic', async () => {
1158+
let ran = false;
1159+
await new Promise<void>((resolve) => {
1160+
fakeAsyncTestZone.run(() => {
1161+
setTimeout(() => {
1162+
ran = true;
1163+
resolve();
1164+
}, 10);
1165+
testZoneSpec.setTickMode('automatic');
1166+
});
1167+
});
1168+
1169+
expect(ran).toBe(true);
1170+
fakeAsyncTestZone.run(() => {
1171+
testZoneSpec.setTickMode('manual');
1172+
});
1173+
});
1174+
1175+
it('should execute multiple timers automatically', async () => {
1176+
const log: string[] = [];
1177+
const promise = new Promise<void>((resolve) => {
1178+
fakeAsyncTestZone.run(() => {
1179+
setTimeout(() => {
1180+
log.push('timer A');
1181+
}, 10);
1182+
setTimeout(() => {
1183+
log.push('timer B');
1184+
resolve();
1185+
}, 20);
1186+
testZoneSpec.setTickMode('automatic');
1187+
});
1188+
});
1189+
1190+
await promise;
1191+
expect(log).toEqual(['timer A', 'timer B']);
1192+
fakeAsyncTestZone.run(() => {
1193+
testZoneSpec.setTickMode('manual');
1194+
});
1195+
});
1196+
1197+
it('should allow mutation observers to execute between timers', async () => {
1198+
if (isNode) {
1199+
return;
1200+
}
1201+
const log: string[] = [];
1202+
const el = document.createElement('div');
1203+
let observer: MutationObserver;
1204+
1205+
await new Promise<void>((resolve) => {
1206+
fakeAsyncTestZone.run(() => {
1207+
document.body.appendChild(el);
1208+
observer = new MutationObserver(() => {
1209+
log.push('mutation');
1210+
});
1211+
observer.observe(el, {attributes: true});
1212+
1213+
setTimeout(() => {
1214+
log.push('timer A');
1215+
el.style.width = '100px'; // trigger mutation observer
1216+
}, 10);
1217+
setTimeout(() => {
1218+
debugger;
1219+
log.push('timer B');
1220+
resolve();
1221+
}, 10);
1222+
1223+
testZoneSpec.setTickMode('automatic');
1224+
});
1225+
});
1226+
1227+
expect(log).toEqual(['timer A', 'mutation', 'timer B']);
1228+
fakeAsyncTestZone.run(() => {
1229+
testZoneSpec.setTickMode('manual');
1230+
observer.disconnect();
1231+
});
1232+
document.body.removeChild(el);
1233+
});
1234+
});
11551235
});
11561236

11571237
class Log<T> {

0 commit comments

Comments
 (0)