angular/packages/zone.js/test/common/zone.spec.ts
arturovt eddca4280b fix(zone.js): allow draining microtasks in Promise.then (through flag)
These changes are essentially the same as those introduced in
angular#45273, but they include backward compatibility
for applications that explicitly rely on the order in which microtasks are drained.

This is critically important for our code and other third-party code, which is
beyond our control, to work properly. If a microtask is scheduled within an event
listener to be executed "later", it should indeed be executed later and not synchronously,
as this would break the expected flow of code execution.

The simple code that reproduces the behavior that exists now:

```ts
Zone.current.fork({name: 'child'}).run(() => {
  const div = document.createElement('div');
  div.style.height = '200px';
  div.style.width = '200px';
  div.style.backgroundColor = 'red';
  document.body.appendChild(div);

  function listener() {
    Promise.resolve().then(() => {
      div.style.height = '400px';
    });
  }

  div.addEventListener('fakeEvent', listener);
  div.dispatchEvent(new Event('fakeEvent'));
  console.log(div.getBoundingClientRect().height); // 400
});
```

The code above logs 400 as the height, but it should actually log 200 because the
height is updated in a microtask within the event listener.

When using Angular with microfrontend applications, especially when other apps might be
using React, zone.js can disrupt the classical order of operations. For example, when using a
`react-component/trigger`, it schedules a microtask within an event listener using
`Promise.resolve().then(...)` to determine whether the event needs to be re-dispatched.
The event is re-dispatched when the layout has changed, which is why a microtask is used.

With this change, we introduce a global configuration flag,
`__zone_symbol__enable_native_microtask_draining`, to allow consumers to enable
microtask draining within a browser microtask.

This flag is necessary to prevent any breaking changes resulting from this modification.
The previous attempt to address this issue caused a significant number of failures in g3.
Therefore, we are hiding that fix behind the configuration flag.

Closes angular#44446
Closes angular#55590
Closes angular#51328

(cherry picked from commit fc6a7eea68)
2026-04-15 10:31:33 -04:00

658 lines
22 KiB
TypeScript

/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {isNode} from '../../lib/common/utils';
describe('Zone Common', function () {
it('should have a name', function () {
expect(Zone.current.name).toBeDefined();
});
describe('hooks', function () {
it('should throw if onError is not defined', function () {
expect(function () {
Zone.current.run(throwError);
}).toThrow();
});
it('should fire onError if a function run by a zone throws', function () {
const errorSpy = jasmine.createSpy('error');
const myZone = Zone.current.fork({name: 'spy', onHandleError: errorSpy});
expect(errorSpy).not.toHaveBeenCalled();
expect(function () {
myZone.runGuarded(throwError);
}).not.toThrow();
expect(errorSpy).toHaveBeenCalled();
});
it('should send correct currentZone in hook method when in nested zone', function () {
const zone = Zone.current;
const zoneA = zone.fork({
name: 'A',
onInvoke: function (
parentDelegate,
currentZone,
targetZone,
callback,
applyThis,
applyArgs,
source,
) {
expect(currentZone.name).toEqual('A');
return parentDelegate.invoke(targetZone, callback, applyThis, applyArgs, source);
},
});
const zoneB = zoneA.fork({
name: 'B',
onInvoke: function (
parentDelegate,
currentZone,
targetZone,
callback,
applyThis,
applyArgs,
source,
) {
expect(currentZone.name).toEqual('B');
return parentDelegate.invoke(targetZone, callback, applyThis, applyArgs, source);
},
});
const zoneC = zoneB.fork({name: 'C'});
zoneC.run(function () {});
});
it('should send correct currentZone in hook method when in nested zone with empty implementation', function () {
const zone = Zone.current;
const zoneA = zone.fork({
name: 'A',
onInvoke: function (
parentDelegate,
currentZone,
targetZone,
callback,
applyThis,
applyArgs,
source,
) {
expect(currentZone.name).toEqual('A');
return parentDelegate.invoke(targetZone, callback, applyThis, applyArgs, source);
},
});
const zoneB = zoneA.fork({name: 'B'});
const zoneC = zoneB.fork({name: 'C'});
zoneC.run(function () {});
});
});
it('should allow zones to be run from within another zone', function () {
const zone = Zone.current;
const zoneA = zone.fork({name: 'A'});
const zoneB = zone.fork({name: 'B'});
zoneA.run(function () {
zoneB.run(function () {
expect(Zone.current).toBe(zoneB);
});
expect(Zone.current).toBe(zoneA);
});
expect(Zone.current).toBe(zone);
});
describe('wrap', function () {
it('should throw if argument is not a function', function () {
expect(function () {
(<Function>Zone.current.wrap)(11);
}).toThrowError('Expecting function got: 11');
});
});
describe('run out side of current zone', function () {
it('should be able to get root zone', function () {
Zone.current.fork({name: 'testZone'}).run(function () {
expect(Zone.root.name).toEqual('<root>');
});
});
it('should be able to get run under rootZone', function () {
Zone.current.fork({name: 'testZone'}).run(function () {
Zone.root.run(() => {
expect(Zone.current.name).toEqual('<root>');
});
});
});
it('should be able to get run outside of current zone', function () {
Zone.current.fork({name: 'testZone'}).run(function () {
Zone.root.fork({name: 'newTestZone'}).run(() => {
expect(Zone.current.name).toEqual('newTestZone');
expect(Zone.current.parent!.name).toEqual('<root>');
});
});
});
});
describe('get', function () {
it('should store properties', function () {
const testZone = Zone.current.fork({name: 'A', properties: {key: 'value'}});
expect(testZone.get('key')).toEqual('value');
expect(testZone.getZoneWith('key')).toEqual(testZone);
const childZone = testZone.fork({name: 'B', properties: {key: 'override'}});
expect(testZone.get('key')).toEqual('value');
expect(testZone.getZoneWith('key')).toEqual(testZone);
expect(childZone.get('key')).toEqual('override');
expect(childZone.getZoneWith('key')).toEqual(childZone);
});
});
describe('task', () => {
function noop() {}
let log: any[];
const zone: Zone = Zone.current.fork({
name: 'parent',
onHasTask: (
delegate: ZoneDelegate,
current: Zone,
target: Zone,
hasTaskState: HasTaskState,
): void => {
(hasTaskState as any)['zone'] = target.name;
log.push(hasTaskState);
},
onScheduleTask: (delegate: ZoneDelegate, current: Zone, target: Zone, task: Task) => {
// Do nothing to prevent tasks from being run on VM turn;
// Tests run task explicitly.
return task;
},
});
beforeEach(() => {
log = [];
});
it('task can only run in the zone of creation', () => {
const task = zone
.fork({name: 'createZone'})
.scheduleMacroTask('test', noop, undefined, noop, noop);
expect(() => {
Zone.current.fork({name: 'anotherZone'}).runTask(task);
}).toThrowError(
'A task can only be run in the zone of creation! (Creation: createZone; Execution: anotherZone)',
);
task.zone.cancelTask(task);
});
it('task can only cancel in the zone of creation', () => {
const task = zone
.fork({name: 'createZone'})
.scheduleMacroTask('test', noop, undefined, noop, noop);
expect(() => {
Zone.current.fork({name: 'anotherZone'}).cancelTask(task);
}).toThrowError(
'A task can only be cancelled in the zone of creation! (Creation: createZone; Execution: anotherZone)',
);
task.zone.cancelTask(task);
});
it('should prevent double cancellation', () => {
const task = zone.scheduleMacroTask(
'test',
() => log.push('macroTask'),
undefined,
noop,
noop,
);
zone.cancelTask(task);
try {
zone.cancelTask(task);
} catch (e) {
expect((e as Error).message).toContain(
"macroTask 'test': can not transition to 'canceling', expecting state 'scheduled' or 'running', was 'notScheduled'.",
);
}
});
it('should not decrement counters on periodic tasks', () => {
zone.run(() => {
const task = zone.scheduleMacroTask(
'test',
() => log.push('macroTask'),
{isPeriodic: true},
noop,
noop,
);
zone.runTask(task);
zone.runTask(task);
zone.cancelTask(task);
});
expect(log).toEqual([
{microTask: false, macroTask: true, eventTask: false, change: 'macroTask', zone: 'parent'},
'macroTask',
'macroTask',
{microTask: false, macroTask: false, eventTask: false, change: 'macroTask', zone: 'parent'},
]);
});
it('should notify of queue status change', () => {
zone.run(() => {
const z = Zone.current;
z.runTask(z.scheduleMicroTask('test', () => log.push('microTask')));
z.cancelTask(
z.scheduleMacroTask('test', () => log.push('macroTask'), undefined, noop, noop),
);
z.cancelTask(
z.scheduleEventTask('test', () => log.push('eventTask'), undefined, noop, noop),
);
});
expect(log).toEqual([
{microTask: true, macroTask: false, eventTask: false, change: 'microTask', zone: 'parent'},
'microTask',
{microTask: false, macroTask: false, eventTask: false, change: 'microTask', zone: 'parent'},
{microTask: false, macroTask: true, eventTask: false, change: 'macroTask', zone: 'parent'},
{microTask: false, macroTask: false, eventTask: false, change: 'macroTask', zone: 'parent'},
{microTask: false, macroTask: false, eventTask: true, change: 'eventTask', zone: 'parent'},
{microTask: false, macroTask: false, eventTask: false, change: 'eventTask', zone: 'parent'},
]);
});
it('should notify of queue status change on parent task', () => {
zone.fork({name: 'child'}).run(() => {
const z = Zone.current;
z.runTask(z.scheduleMicroTask('test', () => log.push('microTask')));
});
expect(log).toEqual([
{microTask: true, macroTask: false, eventTask: false, change: 'microTask', zone: 'child'},
{microTask: true, macroTask: false, eventTask: false, change: 'microTask', zone: 'parent'},
'microTask',
{microTask: false, macroTask: false, eventTask: false, change: 'microTask', zone: 'child'},
{microTask: false, macroTask: false, eventTask: false, change: 'microTask', zone: 'parent'},
]);
});
it('should allow rescheduling a task on a separate zone', () => {
const log: any[] = [];
const zone = Zone.current.fork({
name: 'test-root',
onHasTask: (
delegate: ZoneDelegate,
current: Zone,
target: Zone,
hasTaskState: HasTaskState,
) => {
(hasTaskState as any)['zone'] = target.name;
log.push(hasTaskState);
},
});
const left = zone.fork({name: 'left'});
const right = zone.fork({
name: 'right',
onScheduleTask: (delegate: ZoneDelegate, current: Zone, target: Zone, task: Task): Task => {
log.push({
pos: 'before',
method: 'onScheduleTask',
zone: current.name,
task: task.zone.name,
});
// Cancel the current scheduling of the task
task.cancelScheduleRequest();
// reschedule on a different zone.
task = left.scheduleTask(task);
log.push({
pos: 'after',
method: 'onScheduleTask',
zone: current.name,
task: task.zone.name,
});
return task;
},
});
const rchild = right.fork({
name: 'rchild',
onScheduleTask: (delegate: ZoneDelegate, current: Zone, target: Zone, task: Task): Task => {
log.push({
pos: 'before',
method: 'onScheduleTask',
zone: current.name,
task: task.zone.name,
});
task = delegate.scheduleTask(target, task);
log.push({
pos: 'after',
method: 'onScheduleTask',
zone: current.name,
task: task.zone.name,
});
expect((task as any)._zoneDelegates.map((zd: ZoneDelegate) => zd.zone.name)).toEqual([
'left',
'test-root',
'ProxyZone',
]);
return task;
},
});
const task = rchild.scheduleMacroTask('testTask', () => log.push('WORK'), {}, noop, noop);
expect(task.zone).toEqual(left);
log.push(task.zone.name);
task.invoke();
expect(log).toEqual([
{pos: 'before', method: 'onScheduleTask', zone: 'rchild', task: 'rchild'},
{pos: 'before', method: 'onScheduleTask', zone: 'right', task: 'rchild'},
{microTask: false, macroTask: true, eventTask: false, change: 'macroTask', zone: 'left'},
{
microTask: false,
macroTask: true,
eventTask: false,
change: 'macroTask',
zone: 'test-root',
},
{pos: 'after', method: 'onScheduleTask', zone: 'right', task: 'left'},
{pos: 'after', method: 'onScheduleTask', zone: 'rchild', task: 'left'},
'left',
'WORK',
{microTask: false, macroTask: false, eventTask: false, change: 'macroTask', zone: 'left'},
{
microTask: false,
macroTask: false,
eventTask: false,
change: 'macroTask',
zone: 'test-root',
},
]);
});
it('period task should not transit to scheduled state after being cancelled in running state', () => {
const zone = Zone.current.fork({name: 'testZone'});
const task = zone.scheduleMacroTask(
'testPeriodTask',
() => {
zone.cancelTask(task);
},
{isPeriodic: true},
() => {},
() => {},
);
task.invoke();
expect(task.state).toBe('notScheduled');
});
it('event task should not transit to scheduled state after being cancelled in running state', () => {
const zone = Zone.current.fork({name: 'testZone'});
const task = zone.scheduleEventTask(
'testEventTask',
() => {
zone.cancelTask(task);
},
undefined,
() => {},
() => {},
);
task.invoke();
expect(task.state).toBe('notScheduled');
});
describe('assert ZoneAwarePromise', () => {
it('should not throw when all is OK', () => {
Zone.assertZonePatched();
});
it('should throw error if ZoneAwarePromise has been overwritten', () => {
class WrongPromise {
static resolve(value: any) {}
then() {}
}
const ZoneAwarePromise = global.Promise;
try {
(global as any).Promise = WrongPromise;
expect(() => Zone.assertZonePatched()).toThrow();
} finally {
// restore it.
global.Promise = ZoneAwarePromise;
}
Zone.assertZonePatched();
});
});
});
describe('invoking tasks', () => {
let log: string[];
function noop() {}
beforeEach(() => {
log = [];
});
// https://github.com/angular/angular/issues/44446
// https://github.com/angular/angular/issues/55590
// https://github.com/angular/angular/issues/51328
describe('__zone_symbol__enable_native_microtask_draining', () => {
it('should not drain the microtask queue too early without task (if the flag is enabled)', (done) => {
// Regression test for https://github.com/angular/angular/issues/44446.
// Verifies that a microtask scheduled inside an event task is not drained
// synchronously mid-stack when the native draining flag is enabled.
const globalAny = global as any;
globalAny[Zone.__symbol__('enable_native_microtask_draining')] = true;
const zone = Zone.current;
const event = zone.scheduleEventTask(
'test',
() => {
log.push('eventTask');
zone.scheduleMicroTask('test', () => log.push('microTask'));
},
undefined,
noop,
noop,
);
log.push('after schedule eventTask');
expect(log).toEqual(['after schedule eventTask']);
event.invoke();
// At this point, we should not have invoked the microtask.
expect(log).toEqual(['after schedule eventTask', 'eventTask']);
globalAny[Zone.__symbol__('setTimeout')](() => {
expect(log).toEqual(['after schedule eventTask', 'eventTask', 'microTask']);
globalAny[Zone.__symbol__('enable_native_microtask_draining')] = false;
done();
});
});
it('should not drain the microtask queue too early (if the flag is enabled)', (done) => {
// We need `document` in this test.
if (isNode) {
done();
return;
}
// Regression test for https://github.com/angular/angular/issues/44446.
// Verifies that a Promise.then() callback scheduled inside a DOM event listener
// is not drained synchronously before the main stack unwinds.
const globalAny = global as any;
globalAny[Zone.__symbol__('enable_native_microtask_draining')] = true;
const zone = Zone.current;
zone.run(() => {
const listener = () => {
Promise.resolve().then(() => log.push('promise.then'));
};
document.body.addEventListener('click', listener);
document.body.click();
log.push('main stack');
globalAny[Zone.__symbol__('setTimeout')](() => {
document.body.removeEventListener('click', listener);
expect(log).toEqual(['main stack', 'promise.then']);
globalAny[Zone.__symbol__('enable_native_microtask_draining')] = false;
done();
});
});
});
it('should surface unhandled promise rejections via unhandledrejection event (if the flag is enabled)', async () => {
// We need `window` in this test.
if (isNode) {
return;
}
// Regression test for https://github.com/angular/angular/issues/55590.
// Verifies that unhandled promise rejections originating outside zone.js-patched
// code (e.g. a plain <script> tag) still surface via the native unhandledrejection
// event when the native draining flag is enabled.
let rejectionEvent: PromiseRejectionEvent | null = null;
const onError = window.onerror;
window.onerror = () => {};
const globalAny = global as any;
globalAny[Zone.__symbol__('enable_native_microtask_draining')] = true;
await jasmine.spyOnGlobalErrorsAsync(async () => {
const handler = (event: PromiseRejectionEvent) => {
window.removeEventListener('unhandledrejection', handler);
rejectionEvent = event;
};
window.addEventListener('unhandledrejection', handler);
const script = document.createElement('script');
script.innerHTML = "Promise.reject('Error happened :(')";
document.body.append(script);
// Wait until the event task is dispatched.
await new Promise((resolve) => setTimeout(resolve, 10));
window.onerror = onError;
globalAny[Zone.__symbol__('enable_native_microtask_draining')] = false;
expect(rejectionEvent).not.toBeNull();
expect(rejectionEvent!.reason).toBe('Error happened :(');
});
});
it('should not surface a rejection as unhandled when catch is attached (if the flag is enabled)', async () => {
// We need `window` in this test.
if (isNode) {
return;
}
// Regression test for https://github.com/angular/angular/issues/51328.
// Verifies that an internal `await Promise.reject()` inside an async IIFE does not
// fire `unhandledrejection` when a `.catch()` is attached to the outer promise.
let rejectionEvent: PromiseRejectionEvent | null = null;
const onError = window.onerror;
window.onerror = () => {};
const globalAny = global as any;
globalAny[Zone.__symbol__('enable_native_microtask_draining')] = true;
await jasmine.spyOnGlobalErrorsAsync(async () => {
const handler = (event: PromiseRejectionEvent) => {
window.removeEventListener('unhandledrejection', handler);
rejectionEvent = event;
};
window.addEventListener('unhandledrejection', handler);
(async function () {
await Promise.reject(2);
})().catch(() => {});
// Wait until the event task is dispatched.
await new Promise((resolve) => setTimeout(resolve, 10));
window.onerror = onError;
globalAny[Zone.__symbol__('enable_native_microtask_draining')] = false;
expect(rejectionEvent).toBeNull();
});
});
});
it('should not drain the microtask queue too early', () => {
const z = Zone.current;
const event = z.scheduleEventTask('test', () => log.push('eventTask'), undefined, noop, noop);
z.scheduleMicroTask('test', () => log.push('microTask'));
const macro = z.scheduleMacroTask(
'test',
() => {
event.invoke();
// At this point, we should not have invoked the microtask.
expect(log).toEqual(['eventTask']);
},
undefined,
noop,
noop,
);
macro.invoke();
});
it('should convert task to json without cyclic error', () => {
const z = Zone.current;
const event = z.scheduleEventTask('test', () => {}, undefined, noop, noop);
const micro = z.scheduleMicroTask('test', () => {});
const macro = z.scheduleMacroTask('test', () => {}, undefined, noop, noop);
expect(function () {
JSON.stringify(event);
}).not.toThrow();
expect(function () {
JSON.stringify(micro);
}).not.toThrow();
expect(function () {
JSON.stringify(macro);
}).not.toThrow();
});
it('should call onHandleError callback when zoneSpec onHasTask throw error', () => {
const spy = jasmine.createSpy('error');
const hasTaskZone = Zone.current.fork({
name: 'hasTask',
onHasTask: (
delegate: ZoneDelegate,
currentZone: Zone,
targetZone: Zone,
hasTasState: HasTaskState,
) => {
throw new Error('onHasTask Error');
},
onHandleError: (
delegate: ZoneDelegate,
currentZone: Zone,
targetZone: Zone,
error: Error,
) => {
spy(error.message);
return delegate.handleError(targetZone, error);
},
});
const microTask = hasTaskZone.scheduleMicroTask(
'test',
() => {},
undefined,
() => {},
);
expect(spy).toHaveBeenCalledWith('onHasTask Error');
});
});
});
function throwError() {
throw new Error();
}