From bf88c347caed5f18a488fc36df487a405b84092a Mon Sep 17 00:00:00 2001 From: JiaLiPassion Date: Sat, 30 Jun 2018 02:34:33 +0900 Subject: [PATCH] feat(fetch): schedule macroTask when fetch (#1075) --- file-size-limit.json | 4 +- lib/browser/browser.ts | 46 +++++--- lib/browser/rollup-main.ts | 1 + lib/common/fetch.ts | 100 +++++++++++++++++ lib/common/promise.ts | 6 +- lib/zone.ts | 4 +- test/browser-zone-setup.ts | 1 + test/common/Promise.spec.ts | 72 ------------- test/common/fetch.spec.ts | 209 ++++++++++++++++++++++++++++++++++++ test/common_tests.ts | 1 + test/test-util.ts | 16 +++ 11 files changed, 366 insertions(+), 94 deletions(-) create mode 100644 lib/common/fetch.ts create mode 100644 test/common/fetch.spec.ts diff --git a/file-size-limit.json b/file-size-limit.json index 26e5195d5..03d3f5b83 100644 --- a/file-size-limit.json +++ b/file-size-limit.json @@ -3,7 +3,7 @@ { "path": "dist/zone.min.js", "checkTarget": true, - "limit": 40144 + "limit": 41500 } ] -} +} \ No newline at end of file diff --git a/lib/browser/browser.ts b/lib/browser/browser.ts index d4873797a..221977e7a 100644 --- a/lib/browser/browser.ts +++ b/lib/browser/browser.ts @@ -199,8 +199,16 @@ Zone.__load_patch('XHR', (global: any, Zone: ZoneType) => { }); const XMLHTTPREQUEST_SOURCE = 'XMLHttpRequest.send'; - const sendNative = + const fetchTaskAborting = zoneSymbol('fetchTaskAborting'); + const fetchTaskScheduling = zoneSymbol('fetchTaskScheduling'); + const sendNative: Function|null = patchMethod(XMLHttpRequestPrototype, 'send', () => function(self: any, args: any[]) { + if ((Zone.current as any)[fetchTaskScheduling] === true) { + // a fetch is scheduling, so we are using xhr to polyfill fetch + // and because we already schedule macroTask for fetch, we should + // not schedule a macroTask for xhr again + return sendNative!.apply(self, args); + } if (self[XHR_SYNC]) { // if the XHR is sync there is no task to schedule, just execute the code. return sendNative!.apply(self, args); @@ -212,22 +220,26 @@ Zone.__load_patch('XHR', (global: any, Zone: ZoneType) => { } }); - const abortNative = patchMethod(XMLHttpRequestPrototype, 'abort', () => function(self: any) { - const task: Task = findPendingTask(self); - if (task && typeof task.type == 'string') { - // If the XHR has already completed, do nothing. - // If the XHR has already been aborted, do nothing. - // Fix #569, call abort multiple times before done will cause - // macroTask task count be negative number - if (task.cancelFn == null || (task.data && (task.data).aborted)) { - return; - } - task.zone.cancelTask(task); - } - // Otherwise, we are trying to abort an XHR which has not yet been sent, so there is no - // task - // to cancel. Do nothing. - }); + const abortNative = + patchMethod(XMLHttpRequestPrototype, 'abort', () => function(self: any, args: any[]) { + const task: Task = findPendingTask(self); + if (task && typeof task.type == 'string') { + // If the XHR has already completed, do nothing. + // If the XHR has already been aborted, do nothing. + // Fix #569, call abort multiple times before done will cause + // macroTask task count be negative number + if (task.cancelFn == null || (task.data && (task.data).aborted)) { + return; + } + task.zone.cancelTask(task); + } else if ((Zone.current as any)[fetchTaskAborting] === true) { + // the abort is called from fetch polyfill, we need to call native abort of XHR. + return abortNative!.apply(self, args); + } + // Otherwise, we are trying to abort an XHR which has not yet been sent, so there is no + // task + // to cancel. Do nothing. + }); } }); diff --git a/lib/browser/rollup-main.ts b/lib/browser/rollup-main.ts index f7dfd8759..f360d0bcd 100644 --- a/lib/browser/rollup-main.ts +++ b/lib/browser/rollup-main.ts @@ -8,5 +8,6 @@ import '../zone'; import '../common/promise'; +import '../common/fetch'; import '../common/to-string'; import './browser'; diff --git a/lib/common/fetch.ts b/lib/common/fetch.ts new file mode 100644 index 000000000..5fda7a5ab --- /dev/null +++ b/lib/common/fetch.ts @@ -0,0 +1,100 @@ +/** + * @license + * Copyright Google Inc. 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 + */ + +Zone.__load_patch('fetch', (global: any, Zone: ZoneType, api: _ZonePrivate) => { + const fetch = global['fetch']; + const ZoneAwarePromise = global.Promise; + const symbolThenPatched = api.symbol('thenPatched'); + const fetchTaskScheduling = api.symbol('fetchTaskScheduling'); + const fetchTaskAborting = api.symbol('fetchTaskAborting'); + if (typeof fetch !== 'function') { + return; + } + const OriginalAbortController = global['AbortController']; + const supportAbort = typeof OriginalAbortController === 'function'; + let abortNative: Function|null = null; + if (supportAbort) { + global['AbortController'] = function() { + const abortController = new OriginalAbortController(); + const signal = abortController.signal; + signal.abortController = abortController; + return abortController; + }; + abortNative = api.patchMethod( + OriginalAbortController.prototype, 'abort', + (delegate: Function) => (self: any, args: any) => { + if (self.task) { + return self.task.zone.cancelTask(self.task); + } + return delegate.apply(self, args); + }); + } + const placeholder = function() {}; + global['fetch'] = function() { + const args = Array.prototype.slice.call(arguments); + const options = args.length > 1 ? args[1] : null; + const signal = options && options.signal; + return new Promise((res, rej) => { + const task = Zone.current.scheduleMacroTask( + 'fetch', placeholder, args, + () => { + let fetchPromise; + let zone = Zone.current; + try { + (zone as any)[fetchTaskScheduling] = true; + fetchPromise = fetch.apply(this, args); + } catch (error) { + rej(error); + return; + } finally { + (zone as any)[fetchTaskScheduling] = false; + } + + if (!(fetchPromise instanceof ZoneAwarePromise)) { + let ctor = fetchPromise.constructor; + if (!ctor[symbolThenPatched]) { + api.patchThen(ctor); + } + } + fetchPromise.then( + (resource: any) => { + if (task.state !== 'notScheduled') { + task.invoke(); + } + res(resource); + }, + (error: any) => { + if (task.state !== 'notScheduled') { + task.invoke(); + } + rej(error); + }); + }, + () => { + if (!supportAbort) { + rej('No AbortController supported, can not cancel fetch'); + return; + } + if (signal && signal.abortController && !signal.aborted && + typeof signal.abortController.abort === 'function' && abortNative) { + try { + (Zone.current as any)[fetchTaskAborting] = true; + abortNative.call(signal.abortController); + } finally { + (Zone.current as any)[fetchTaskAborting] = false; + } + } else { + rej('cancel fetch need a AbortController.signal'); + } + }); + if (signal && signal.abortController) { + signal.abortController.task = task; + } + }); + }; +}); \ No newline at end of file diff --git a/lib/common/promise.ts b/lib/common/promise.ts index c59dffdd2..d0775f6c1 100644 --- a/lib/common/promise.ts +++ b/lib/common/promise.ts @@ -458,6 +458,8 @@ Zone.__load_patch('ZoneAwarePromise', (global: any, Zone: ZoneType, api: _ZonePr (Ctor as any)[symbolThenPatched] = true; } + api.patchThen = patchThen; + function zoneify(fn: Function) { return function() { let resultPromise = fn.apply(this, arguments); @@ -475,10 +477,10 @@ Zone.__load_patch('ZoneAwarePromise', (global: any, Zone: ZoneType, api: _ZonePr if (NativePromise) { patchThen(NativePromise); - let fetch = global['fetch']; + /*let fetch = global['fetch']; if (typeof fetch == 'function') { global['fetch'] = zoneify(fetch); - } + }*/ } // This is not part of public API, but it is useful for tests, so we expose it. diff --git a/lib/zone.ts b/lib/zone.ts index a71617a3a..7b10783de 100644 --- a/lib/zone.ts +++ b/lib/zone.ts @@ -322,6 +322,7 @@ interface _ZonePrivate { showUncaughtError: () => boolean; patchEventTarget: (global: any, apis: any[], options?: any) => boolean[]; patchOnProperties: (obj: any, properties: string[]|null) => void; + patchThen: (ctro: Function) => void; setNativePromise: (nativePromise: any) => void; patchMethod: (target: any, name: string, @@ -1326,7 +1327,8 @@ const Zone: ZoneType = (function(global: any) { patchEventTarget: () => [], patchOnProperties: noop, patchMethod: () => noop, - bindArguments: () => (null as any), + bindArguments: () => [], + patchThen: () => noop, setNativePromise: (NativePromise: any) => { // sometimes NativePromise.resolve static function // is not ready yet, (such as core-js/es6.promise) diff --git a/test/browser-zone-setup.ts b/test/browser-zone-setup.ts index 7fb70d1cf..d042450ff 100644 --- a/test/browser-zone-setup.ts +++ b/test/browser-zone-setup.ts @@ -11,6 +11,7 @@ if (typeof window !== 'undefined') { } import '../lib/common/to-string'; import '../lib/browser/browser'; +import '../lib/common/fetch'; import '../lib/browser/webapis-user-media'; import '../lib/browser/webapis-media-query'; import '../lib/testing/zone-testing'; diff --git a/test/common/Promise.spec.ts b/test/common/Promise.spec.ts index e3f3b070b..19e21cc83 100644 --- a/test/common/Promise.spec.ts +++ b/test/common/Promise.spec.ts @@ -549,76 +549,4 @@ describe( testPromiseSubClass(); }); }); - - describe('fetch', ifEnvSupports('fetch', function() { - it('should work for text response', function(done) { - testZone.run(function() { - global['fetch']('/base/test/assets/sample.json').then(function(response: any) { - const fetchZone = Zone.current; - expect(fetchZone).toBe(testZone); - - response.text().then(function(text: string) { - expect(Zone.current).toBe(fetchZone); - expect(text.trim()).toEqual('{"hello": "world"}'); - done(); - }); - }); - }); - }); - - it('should work for json response', function(done) { - testZone.run(function() { - global['fetch']('/base/test/assets/sample.json').then(function(response: any) { - const fetchZone = Zone.current; - expect(fetchZone).toBe(testZone); - - response.json().then(function(obj: any) { - expect(Zone.current).toBe(fetchZone); - expect(obj.hello).toEqual('world'); - done(); - }); - }); - }); - }); - - it('should work for blob response', function(done) { - testZone.run(function() { - global['fetch']('/base/test/assets/sample.json').then(function(response: any) { - const fetchZone = Zone.current; - expect(fetchZone).toBe(testZone); - - // Android 4.3- doesn't support response.blob() - if (response.blob) { - response.blob().then(function(blob: any) { - expect(Zone.current).toBe(fetchZone); - expect(blob instanceof Blob).toEqual(true); - done(); - }); - } else { - done(); - } - }); - }); - }); - - it('should work for arrayBuffer response', function(done) { - testZone.run(function() { - global['fetch']('/base/test/assets/sample.json').then(function(response: any) { - const fetchZone = Zone.current; - expect(fetchZone).toBe(testZone); - - // 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(); - } - }); - }); - }); - })); })); diff --git a/test/common/fetch.spec.ts b/test/common/fetch.spec.ts new file mode 100644 index 000000000..f2eb0e348 --- /dev/null +++ b/test/common/fetch.spec.ts @@ -0,0 +1,209 @@ +/** + * @license + * Copyright Google Inc. 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 {ifEnvSupports, ifEnvSupportsWithDone, isFirefox, isSafari} from '../test-util'; + +declare const global: any; + +describe( + 'fetch', 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/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/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/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/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/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(); + }); + }); + }); + + 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/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/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); + }); + })); + }); + })); diff --git a/test/common_tests.ts b/test/common_tests.ts index 2fbb7ecfd..426762d56 100644 --- a/test/common_tests.ts +++ b/test/common_tests.ts @@ -11,6 +11,7 @@ import './common/zone.spec'; import './common/task.spec'; import './common/util.spec'; import './common/Promise.spec'; +import './common/fetch.spec'; import './common/Error.spec'; import './common/setInterval.spec'; import './common/setTimeout.spec'; diff --git a/test/test-util.ts b/test/test-util.ts index 5a58cf8bd..54bf4ff9a 100644 --- a/test/test-util.ts +++ b/test/test-util.ts @@ -103,6 +103,22 @@ export function getIEVersion() { return null; } +export function isFirefox() { + const userAgent = navigator.userAgent.toLowerCase(); + if (userAgent.indexOf('firefox') != -1) { + return true; + } + return false; +} + +export function isSafari() { + const userAgent = navigator.userAgent.toLowerCase(); + if (userAgent.indexOf('safari') != -1) { + return true; + } + return false; +} + export function isEdge() { const userAgent = navigator.userAgent.toLowerCase(); return userAgent.indexOf('edge') !== -1;