angular/packages/zone.js/test/common/fetch.spec.ts
arturovt 260d3ed0d9 fix(zone.js): patch Response methods returned by fetch (#50653)
This commit updates the implementation of the `fetch` patch and additionally
patches `Response` methods which return promises. These are `arrayBuffer`, `blob`,
`formData`, `json` and `text`. This fixes the issue when zone becomes stable too early
before all of the `fetch` tasks complete. Given the following code:
```ts
appRef.isStable.subscribe(console.log);
fetch(...).then(response => response.json()).then(console.log);
```
The `isStable` observer would log `false, true, false, true`. This was happening because
`json()` was returning a native promise (and not a `ZoneAwarePromise`). But calling `then`
on the native promise returns a `ZoneAwarePromise` which notifies Angular about the task
being scheduled and forces to re-calculate the `isStable` state.

Issue: #50327

PR Close #50653
2024-01-31 14:57:25 +00:00

255 lines
11 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.io/license
*/
import {isNode} from '../../lib/common/utils';
import {ifEnvSupports, ifEnvSupportsWithDone, isFirefox, isSafari} from '../test-util';
declare const global: any;
describe(
'fetch', isNode ? () => {
it('is untested for node as the fetch implementation is experimental', () => {});
} : ifEnvSupports('fetch', function() {
let testZone: Zone;
beforeEach(() => {
testZone = Zone.current.fork({name: 'TestZone'});
});
it('should work for text response', function(done) {
testZone.run(function() {
global['fetch']('/base/angular/packages/zone.js/test/assets/sample.json')
.then(function(response: any) {
const fetchZone = Zone.current;
expect(fetchZone.name).toBe(testZone.name);
response.text().then(function(text: string) {
expect(Zone.current.name).toBe(fetchZone.name);
expect(text.trim()).toEqual('{"hello": "world"}');
done();
});
});
});
});
it('should work for json response', function(done) {
testZone.run(function() {
global['fetch']('/base/angular/packages/zone.js/test/assets/sample.json')
.then(function(response: any) {
const fetchZone = Zone.current;
expect(fetchZone.name).toBe(testZone.name);
response.json().then(function(obj: any) {
expect(Zone.current.name).toBe(fetchZone.name);
expect(obj.hello).toEqual('world');
done();
});
});
});
});
it('should work for blob response', function(done) {
testZone.run(function() {
global['fetch']('/base/angular/packages/zone.js/test/assets/sample.json')
.then(function(response: any) {
const fetchZone = Zone.current;
expect(fetchZone.name).toBe(testZone.name);
// Android 4.3- doesn't support response.blob()
if (response.blob) {
response.blob().then(function(blob: any) {
expect(Zone.current.name).toBe(fetchZone.name);
expect(blob instanceof Blob).toEqual(true);
done();
});
} else {
done();
}
});
});
});
it('should work for arrayBuffer response', function(done) {
testZone.run(function() {
global['fetch']('/base/angular/packages/zone.js/test/assets/sample.json')
.then(function(response: any) {
const fetchZone = Zone.current;
expect(fetchZone.name).toBe(testZone.name);
// Android 4.3- doesn't support response.arrayBuffer()
if (response.arrayBuffer) {
response.arrayBuffer().then(function(blob: any) {
expect(Zone.current).toBe(fetchZone);
expect(blob instanceof ArrayBuffer).toEqual(true);
done();
});
} else {
done();
}
});
});
});
it('should throw error when send crendential',
ifEnvSupportsWithDone(isFirefox, function(done: DoneFn) {
testZone.run(function() {
global['fetch']('http://user:password@example.com')
.then(
function(response: any) {
fail('should not success');
},
(error: any) => {
expect(Zone.current.name).toEqual(testZone.name);
expect(error.constructor.name).toEqual('TypeError');
done();
});
});
}));
describe('macroTask', () => {
const logs: string[] = [];
let fetchZone: Zone;
let fetchTask: any = null;
beforeEach(() => {
logs.splice(0);
fetchZone = Zone.current.fork({
name: 'fetch',
onScheduleTask: (delegate: ZoneDelegate, curr: Zone, target: Zone, task: Task) => {
if (task.type !== 'eventTask') {
logs.push(`scheduleTask:${task.source}:${task.type}`);
}
if (task.source === 'fetch') {
fetchTask = task;
}
return delegate.scheduleTask(target, task);
},
onInvokeTask:
(delegate: ZoneDelegate, curr: Zone, target: Zone, task: Task, applyThis: any,
applyArgs: any) => {
if (task.type !== 'eventTask') {
logs.push(`invokeTask:${task.source}:${task.type}`);
}
return delegate.invokeTask(target, task, applyThis, applyArgs);
},
onCancelTask: (delegate: ZoneDelegate, curr: Zone, target: Zone, task: Task) => {
if (task.type !== 'eventTask') {
logs.push(`cancelTask:${task.source}:${task.type}`);
}
return delegate.cancelTask(target, task);
}
});
});
it('fetch should be considered as macroTask', (done: DoneFn) => {
fetchZone.run(() => {
global['fetch']('/base/angular/packages/zone.js/test/assets/sample.json')
.then(function(response: any) {
expect(Zone.current.name).toBe(fetchZone.name);
expect(logs).toEqual([
'scheduleTask:fetch:macroTask', 'scheduleTask:Promise.then:microTask',
'invokeTask:Promise.then:microTask', 'invokeTask:fetch:macroTask',
'scheduleTask:Promise.then:microTask', 'invokeTask:Promise.then:microTask'
]);
done();
});
});
});
// https://github.com/angular/angular/issues/50327
it('Response.json() should be considered as macroTask', done => {
fetchZone.run(() => {
global['fetch']('/base/angular/packages/zone.js/test/assets/sample.json')
.then((response: any) => {
const promise = response.json();
// Ensure it's a `ZoneAwarePromise`.
expect(promise).toBeInstanceOf(global.Promise);
return promise;
})
.then(() => {
expect(logs).toEqual([
'scheduleTask:fetch:macroTask', 'scheduleTask:Promise.then:microTask',
'invokeTask:Promise.then:microTask', 'invokeTask:fetch:macroTask',
'scheduleTask:Promise.then:microTask', 'invokeTask:Promise.then:microTask',
// Please refer to the issue link above. Previously, `Response` methods were not
// patched by zone.js, and their return values were considered only as
// microtasks (not macrotasks). The Angular zone stabilized prematurely,
// occurring before the resolution of the `response.json()` promise due to the
// falsy value of `zone.hasPendingMacrotasks`. We are now ensuring that
// `Response` methods are treated as macrotasks, similar to the behavior of
// `fetch`.
'scheduleTask:Response.json:macroTask', 'scheduleTask:Promise.then:microTask',
'invokeTask:Promise.then:microTask', 'invokeTask:Response.json:macroTask',
'scheduleTask:Promise.then:microTask', 'invokeTask:Promise.then:microTask',
'scheduleTask:Promise.then:microTask', 'invokeTask:Promise.then:microTask'
]);
done();
});
});
});
it('cancel fetch should invoke onCancelTask',
ifEnvSupportsWithDone('AbortController', (done: DoneFn) => {
if (isSafari()) {
// safari not work with AbortController
done();
return;
}
fetchZone.run(() => {
const AbortController = global['AbortController'];
const abort = new AbortController();
const signal = abort.signal;
global['fetch']('/base/angular/packages/zone.js/test/assets/sample.json', {signal})
.then(function(response: any) {
fail('should not get response');
})
.catch(function(error: any) {
expect(error.name).toEqual('AbortError');
expect(logs).toEqual([
'scheduleTask:fetch:macroTask', 'cancelTask:fetch:macroTask',
'scheduleTask:Promise.then:microTask', 'invokeTask:Promise.then:microTask',
'scheduleTask:Promise.then:microTask', 'invokeTask:Promise.then:microTask',
'scheduleTask:Promise.then:microTask', 'invokeTask:Promise.then:microTask'
]);
done();
});
abort.abort();
});
}));
it('cancel fetchTask should trigger abort',
ifEnvSupportsWithDone('AbortController', (done: DoneFn) => {
if (isSafari()) {
// safari not work with AbortController
done();
return;
}
fetchZone.run(() => {
const AbortController = global['AbortController'];
const abort = new AbortController();
const signal = abort.signal;
global['fetch']('/base/angular/packages/zone.js/test/assets/sample.json', {signal})
.then(function(response: any) {
fail('should not get response');
})
.catch(function(error: any) {
expect(error.name).toEqual('AbortError');
expect(logs).toEqual([
'scheduleTask:fetch:macroTask', 'cancelTask:fetch:macroTask',
'scheduleTask:Promise.then:microTask', 'invokeTask:Promise.then:microTask',
'scheduleTask:Promise.then:microTask', 'invokeTask:Promise.then:microTask',
'scheduleTask:Promise.then:microTask', 'invokeTask:Promise.then:microTask'
]);
done();
});
fetchTask.zone.cancelTask(fetchTask);
});
}));
});
}, emptyRun));
function emptyRun() {
// Jasmine will throw if there are no tests.
it('should pass', () => {});
}