Skip to content

Commit

Permalink
Experimentally add patched fetch API that support auto close underlyi…
Browse files Browse the repository at this point in the history
…ng blob

Summary:
  `fetch(url, { autoCloseBlob: true })`
  If the response going to create a Blob, at the end of promise chain,
  we will try to call blob.close to destroy underlying native blob resources.

  The implementation has some limitations especially to copy blob out of promise chain.
  See this test case in fetch-test.js
  '[Limitation] will be dangling reference if using fetch promise in setTimeout'
  • Loading branch information
Kudo committed Mar 5, 2020
1 parent f1a9ca0 commit 86ac876
Show file tree
Hide file tree
Showing 2 changed files with 255 additions and 1 deletion.
148 changes: 148 additions & 0 deletions Libraries/Network/__tests__/fetch-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
* @emails oncall+react_native
* @flow
*/

'use strict';

let nativeBlobModuleRelease = jest.fn();
let nativeFileReaderReadAsText = jest.fn();

jest.unmock('event-target-shim').setMock('../../BatchedBridge/NativeModules', {
BlobModule: {
...require('../../Blob/__mocks__/BlobModule'),
release: nativeBlobModuleRelease,
},
FileReaderModule: {
...require('../../Blob/__mocks__/FileReaderModule'),
readAsText: nativeFileReaderReadAsText,
},
});

const Blob = require('../../Blob/Blob');
const FileReader = require('../../Blob/FileReader');

const {fetch} = require('../fetch');

type MockXHR = {
open: (...params: any) => void,
send: (data?: any) => void,
sendRequestHeader: Function,
getAllResponseHeaders: Function,
readyState: number,
response: any,
onload: Function,
};

describe('fetch', function() {
let xhr: MockXHR;

beforeAll(() => {
global.Blob = Blob;
global.FileReader = FileReader;
global.self = global;
});

beforeEach(() => {
nativeBlobModuleRelease.mockReset();
nativeFileReaderReadAsText.mockReturnValue(Promise.resolve(''));

global.XMLHttpRequest = jest.fn().mockImplementation(() => {
xhr = {
open: jest.fn(),
send: jest.fn(),
sendRequestHeader: jest.fn(),
getAllResponseHeaders: jest.fn(),
readyState: 4,
response: '',
onload: jest.fn(),
};
return xhr;
});
});

it('should resolve text promise', async () => {
const respText = 'ok';
nativeFileReaderReadAsText.mockReturnValue(Promise.resolve(respText));
const promise = fetch('foo');
xhr.response = new Blob([respText], {type: 'text/plain', lastModified: 0});
xhr.onload();
const resp = await promise;
const text = await resp.text();
const textWithSuffix = await Promise.resolve(text + '_suffix');
expect(text).toBe(respText);
expect(textWithSuffix).toBe(respText + '_suffix');
expect(nativeBlobModuleRelease.mock.calls.length).toBe(1);
});

it('should resolve json promise', async () => {
const respJson = {foo: 'bar'};
nativeFileReaderReadAsText.mockReturnValue(
Promise.resolve(JSON.stringify(respJson)),
);
const promise = fetch('foo');
xhr.response = new Blob([JSON.stringify(respJson)], {
type: 'application/json',
lastModified: 0,
});
xhr.onload();
const resp = await promise;
const json = await resp.json();
expect(json).toEqual(respJson);
});

it('should release internal blob', async () => {
const promise = fetch('foo');
xhr.response = new Blob();
xhr.onload();
const resp = await promise;
await resp.text();
expect(nativeBlobModuleRelease.mock.calls.length).toBe(1);
});

it('should release internal blob for rejection in promise chain', async () => {
const promise = fetch('foo');
xhr.response = new Blob();
xhr.onload();
const resp = await promise;
await resp.text();
try {
throw new Error('bar');
} catch (e) {}
expect(nativeBlobModuleRelease.mock.calls.length).toBe(1);
});

it('[Limitation] will be dangling reference if using fetch promise in setTimeout', async () => {
let blob: ?Blob = null;
setTimeout(async () => {
const promise = fetch('foo');
xhr.response = new Blob();
xhr.onload();
const resp = await promise;
blob = await resp.blob();
expect(nativeBlobModuleRelease.mock.calls.length).toBe(1);
}, 10);
jest.runAllTimers();
expect(() => {
(blob: any).close();
}).toThrow('Blob has been closed and is no longer available');
});

it('should not release internal blob if autoCloseBlob=false', async () => {
const respText = 'ok';
nativeFileReaderReadAsText.mockReturnValue(Promise.resolve(respText));
const promise = fetch('foo', {autoCloseBlob: false});
xhr.response = new Blob([respText], {type: 'text/plain', lastModified: 0});
xhr.onload();
const resp = await promise;
const text = await resp.text();
expect(nativeBlobModuleRelease.mock.calls.length).toBe(0);
expect(text).toBe(respText);
});
});
108 changes: 107 additions & 1 deletion Libraries/Network/fetch.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,120 @@
* LICENSE file in the root directory of this source tree.
*
* @format
* @flow
*/

/* globals Headers, Request, Response */

'use strict';

type PromiseOnFulfillHandler = (value: any) => any;
type PromiseOnRejectHandler = () => any;
type PromiseHandler = (
onFulfill: ?PromiseOnFulfillHandler,
onReject: ?PromiseOnRejectHandler,
) => Promise<any>;
type FetchResponse = any;

type ExtendedFetchOptions = {
autoCloseBlob?: boolean,
};

// side-effectful require() to put fetch,
// Headers, Request, Response in global scope
require('whatwg-fetch');

module.exports = {fetch, Headers, Request, Response};
const __fetch = fetch;
function patchedFetch(...params: any): Promise<FetchResponse> {
const originalFetch = __fetch;

const options: ?ExtendedFetchOptions = arguments[1];
if (
options != null &&
typeof options === 'object' &&
options.autoCloseBlob === false
) {
return originalFetch.apply(this, arguments);
}

//
// Implementation Details:
// If fetch(uri, { autoCloseBlob: true }),
// we will try to reorder users promise chain and try to do blob close at the end.
//
// If original promise chain is like:
// fetch()
// .then(handler1)
// .then(handler2)
// .then(handler3)
//
// The patched promise chain will be:
// fetch()
// .then(setupOriginalResp)
// .then(runNextHandler)
// .then(handler1)
// .then(runNextHandler)
// .then(handler2)
// .then(runNextHandler)
// .then(handler3)
// .then(runNextHandler)
// .then(finalizeBlob)
//

// store user promise chain handlers into this queue
// We will reorder promise chaing and try to add |finalizeBlob()| to the tail of queue.
const handlerQueue: Array<PromiseHandler> = [];

// the promise after fetch(...)
const promise: Promise<FetchResponse> = originalFetch.apply(this, arguments);

// original then function before patch
const originalThen = Promise.prototype.then;

// original fetch response. At |finalizeBlob()| we need lookup this and do blob close if necessary
let originalResp: ?FetchResponse = null;
function setupOriginalResp(resp: FetchResponse) {
originalResp = resp;
return resp;
}
promise.then(setupOriginalResp);

// to close blob in fetch response if necessary
function finalizeBlob() {
if (
originalResp != null &&
typeof originalResp._bodyBlob === 'object' &&
typeof originalResp._bodyBlob.close === 'function'
) {
originalResp._bodyBlob.close();
originalResp._bodyBlob = null;
}
}

// to patch Promise.then which add the handler into |handlerQueue|
function patchedThen(handler: PromiseHandler): Promise<any> {
handlerQueue.push(handler);
const newPromise = Promise.resolve(originalResp);
(newPromise: any).then = patchedThen;
return newPromise;
}
(promise: any).then = patchedThen;

// a promise handler to execute next handler in |handlerQueue|
function runNextHandler(currPromise: Promise<any>) {
const handler = handlerQueue.shift();
if (handler) {
const nextPromise: Promise<any> = originalThen.call(currPromise, handler);
originalThen.call(nextPromise, () => runNextHandler(nextPromise));
} else {
finalizeBlob();
originalThen.call(currPromise, () => originalResp);
}
}

originalThen.call(promise, () => runNextHandler(promise));
return promise;
}

global.fetch = patchedFetch;
module.exports = {fetch: patchedFetch, Headers, Request, Response};

0 comments on commit 86ac876

Please sign in to comment.