Merge pull request #117 from vector-im/bwindels/idb-promises

Prevent transaction from closing on IE11/Win7
This commit is contained in:
Bruno Windels 2020-09-28 13:37:34 +00:00 committed by GitHub
commit 3440823981
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 655 additions and 11 deletions

View file

@ -47,6 +47,7 @@
"xxhashjs": "^0.2.2"
},
"dependencies": {
"es6-promise": "https://github.com/bwindels/es6-promise.git#bwindels/expose-flush",
"aes-js": "^3.1.2",
"another-json": "^0.2.0",
"base64-arraybuffer": "^0.2.0",

View file

@ -0,0 +1,118 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<script type="text/javascript" src="promifill.js"></script>
<!-- <script src="https://cdn.jsdelivr.net/npm/promise-polyfill@8/dist/polyfill.min.js"></script> -->
<script type="text/javascript">
//window.Promise = Promifill;
function reqAsPromise(req) {
return new Promise(function (resolve, reject) {
req.onsuccess = function() {
resolve(req);
Promise.flushQueue && Promise.flushQueue();
};
req.onerror = function(e) {
reject(new Error("IDB request failed: " + e.target.error.message));
Promise.flushQueue && Promise.flushQueue();
};
});
}
function Storage(databaseName) {
this._databaseName = databaseName;
this._database = null;
}
Storage.prototype = {
open: function() {
const req = window.indexedDB.open(this._databaseName);
const self = this;
req.onupgradeneeded = function(ev) {
const db = ev.target.result;
const oldVersion = ev.oldVersion;
self._createStores(db, oldVersion);
};
return reqAsPromise(req).then(function() {
self._database = req.result;
});
},
openTxn: function(mode, storeName) {
const txn = this._database.transaction([storeName], mode);
const store = txn.objectStore(storeName);
return Promise.resolve(store);
},
_createStores: function(db) {
db.createObjectStore("foos", {keyPath: ["id"]});
}
};
function getAll(store) {
const request = store.openCursor();
const results = [];
return new Promise(function(resolve, reject) {
request.onsuccess = function(event) {
const cursor = event.target.result;
if(cursor) {
results.push(cursor.value);
cursor.continue();
} else {
resolve(results);
Promise.flushQueue && Promise.flushQueue();
}
};
request.onerror = function(e) {
reject(new Error("IDB request failed: " + e.target.error.message));
Promise.flushQueue && Promise.flushQueue();
};
});
}
function main() {
let storage = new Storage("idb-promises");
let store;
storage.open().then(function() {
return storage.openTxn("readwrite", "foos");
}).then(function(s) {
store = s;
store.clear();
store.add({id: 5, name: "foo"});
store.add({id: 6, name: "bar"});
return getAll(store);
}).then(function(all) {
console.log("all1", all);
store.add({id: 7, name: "bazzz"});
return getAll(store);
}).then(function(all) {
console.log("all2", all);
}).catch(function(err) {
console.error(err.message + ": " + err.stack);
});
}
main();
/*
we basically want something like this for IE11/Win7:
return new Promise(function (resolve, reject) {
req.onsuccess = function() {
resolve(req);
Promise?.flushQueue();
};
req.onerror = function(e) {
reject(new Error("IDB request failed: " + e.target.error.message));
Promise?.flushQueue();
};
});
we don't have this problem on platforms with a native promise implementation, so we can just have our own (forked) promise polyfill?
*/
</script>
</body>
</html>

444
prototypes/promifill.js vendored Normal file
View file

@ -0,0 +1,444 @@
"use strict";
function _createForOfIteratorHelper(o, allowArrayLike) { var it; if (typeof Symbol === "undefined" || o[Symbol.iterator] == null) { if (Array.isArray(o) || (it = _unsupportedIterableToArray(o)) || allowArrayLike && o && typeof o.length === "number") { if (it) o = it; var i = 0; var F = function F() {}; return { s: F, n: function n() { if (i >= o.length) return { done: true }; return { done: false, value: o[i++] }; }, e: function e(_e) { throw _e; }, f: F }; } throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } var normalCompletion = true, didErr = false, err; return { s: function s() { it = o[Symbol.iterator](); }, n: function n() { var step = it.next(); normalCompletion = step.done; return step; }, e: function e(_e2) { didErr = true; err = _e2; }, f: function f() { try { if (!normalCompletion && it.return != null) it.return(); } finally { if (didErr) throw err; } } }; }
function _unsupportedIterableToArray(o, minLen) { if (!o) return; if (typeof o === "string") return _arrayLikeToArray(o, minLen); var n = Object.prototype.toString.call(o).slice(8, -1); if (n === "Object" && o.constructor) n = o.constructor.name; if (n === "Map" || n === "Set") return Array.from(o); if (n === "Arguments" || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) return _arrayLikeToArray(o, minLen); }
function _arrayLikeToArray(arr, len) { if (len == null || len > arr.length) len = arr.length; for (var i = 0, arr2 = new Array(len); i < len; i++) { arr2[i] = arr[i]; } return arr2; }
function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } }
function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; }
var PENDING = void 0,
FULFILLED = true,
REJECTED = false;
var Promifill = /*#__PURE__*/function () {
_createClass(Promifill, [{
key: "state",
get: function get() {
return PENDING;
}
}, {
key: "value",
get: function get() {
return void 0;
}
}, {
key: "settled",
get: function get() {
return false;
}
}]);
function Promifill(executor) {
var _this = this;
_classCallCheck(this, Promifill);
if (typeof executor != "function") {
throw new TypeError("Promise resolver ".concat(Object.prototype.toString.call(executor), " is not a function"));
}
defineProperty(this, "chain", []);
defineProperty(this, "observers", []);
var secret = [];
var resolve = function resolve(value, bypassKey) {
if (_this.settled && bypassKey !== secret) {
return;
}
defineProperty(_this, "settled", true);
var then_ = value && value.then;
var thenable = typeof then_ == "function";
if (thenable) {
defineProperty(value, "preventThrow", true);
}
if (thenable && value.state === PENDING) {
then_.call(value, function (v) {
return resolve(v, secret);
}, function (r) {
return reject(r, secret);
});
} else {
defineProperty(_this, "value", thenable ? value.value : value);
defineProperty(_this, "state", thenable ? value.state : FULFILLED);
schedule(_this.observers.map(function (observer) {
return {
handler: _this.state === FULFILLED ? observer.onfulfill : observer.onreject,
value: _this.value
};
}));
if (_this.state === REJECTED) {
raiseUnhandledPromiseRejectionException(_this.value, _this);
}
}
};
var reject = function reject(reason, bypassKey) {
if (_this.settled && bypassKey !== secret) {
return;
}
defineProperty(_this, "settled", true);
defineProperty(_this, "value", reason);
defineProperty(_this, "state", REJECTED);
schedule(_this.observers.map(function (observer) {
return {
handler: observer.onreject,
value: _this.value
};
}));
raiseUnhandledPromiseRejectionException(_this.value, _this);
};
try {
executor(resolve, reject);
} catch (error) {
reject(error);
}
}
_createClass(Promifill, [{
key: "then",
value: function then(onfulfill, onreject) {
var _this2 = this;
var chainedPromise = new this.constructor(function (resolve, reject) {
var internalOnfulfill = function internalOnfulfill(value) {
try {
resolve(typeof onfulfill == "function" ? onfulfill(value) : value);
} catch (error) {
reject(error);
}
};
var internalOnreject = function internalOnreject(reason) {
try {
if (typeof onreject == "function") {
resolve(onreject(reason));
} else {
reject(reason);
}
} catch (error) {
reject(error);
}
};
if (_this2.state === PENDING) {
_this2.observers.push({
onfulfill: internalOnfulfill,
onreject: internalOnreject
});
} else {
schedule([{
handler: _this2.state === FULFILLED ? internalOnfulfill : internalOnreject,
value: _this2.value
}]);
}
});
this.chain.push(chainedPromise);
return chainedPromise;
}
}, {
key: "catch",
value: function _catch(onreject) {
return this.then(null, onreject);
}
}, {
key: "finally",
value: function _finally(oncomplete) {
var _this3 = this;
var chainedPromise = new this.constructor(function (resolve, reject) {
var internalOncomplete = function internalOncomplete() {
try {
oncomplete();
if (_this3.state === FULFILLED) {
resolve(_this3.value);
} else {
reject(_this3.value);
}
} catch (error) {
reject(error);
}
};
if (_this3.state === PENDING) {
_this3.observers.push({
onfulfill: internalOncomplete,
onreject: internalOncomplete
});
} else {
schedule([{
handler: internalOncomplete
}]);
}
});
this.chain.push(chainedPromise);
return chainedPromise;
}
}], [{
key: "resolve",
value: function resolve(value) {
return value && value.constructor === Promifill ? value : new Promifill(function (resolve) {
resolve(value);
});
}
}, {
key: "reject",
value: function reject(reason) {
return new Promifill(function (_, reject) {
reject(reason);
});
}
}, {
key: "all",
value: function all(iterable) {
return new Promifill(function (resolve, reject) {
validateIterable(iterable);
var iterableSize = 0;
var values = [];
if (isEmptyIterable(iterable)) {
return resolve(values);
}
var add = function add(value, index) {
values[index] = value;
if (values.filter(function () {
return true;
}).length === iterableSize) {
resolve(values);
}
};
var _iterator = _createForOfIteratorHelper(iterable),
_step;
try {
for (_iterator.s(); !(_step = _iterator.n()).done;) {
var item = _step.value;
(function (entry, index) {
Promifill.resolve(entry).then(function (value) {
return add(value, index);
}, reject);
})(item, iterableSize++);
}
} catch (err) {
_iterator.e(err);
} finally {
_iterator.f();
}
});
}
}, {
key: "race",
value: function race(iterable) {
return new Promifill(function (resolve, reject) {
validateIterable(iterable);
if (isEmptyIterable(iterable)) {
return;
}
var _iterator2 = _createForOfIteratorHelper(iterable),
_step2;
try {
for (_iterator2.s(); !(_step2 = _iterator2.n()).done;) {
var entry = _step2.value;
Promifill.resolve(entry).then(resolve, reject);
}
} catch (err) {
_iterator2.e(err);
} finally {
_iterator2.f();
}
});
}
}, {
key: "flushQueue",
value: function flushQueue() {
console.log("running promise sync by flushing queue");
schedule.flushQueue();
}
}]);
return Promifill;
}();
var defineProperty = function defineProperty(obj, propName, propValue) {
Object.defineProperty(obj, propName, {
value: propValue
});
};
var defer = function defer(handler) {
return function () {
for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) {
args[_key] = arguments[_key];
}
setTimeout.apply(void 0, [handler, 0].concat(args));
};
};
var thrower = function thrower(error) {
throw error instanceof Error ? error : new Error(error);
};
var raiseUnhandledPromiseRejectionException = defer(function (error, promise) {
if (promise.preventThrow || promise.chain.length > 0) {
return;
}
thrower(error);
});
var MutationObserverStrategy = /*#__PURE__*/function () {
function MutationObserverStrategy(handler) {
_classCallCheck(this, MutationObserverStrategy);
var observer = new MutationObserver(handler);
var node = this.node = document.createTextNode("");
observer.observe(node, {
characterData: true
});
}
_createClass(MutationObserverStrategy, [{
key: "trigger",
value: function trigger() {
this.node.data = this.node.data === 1 ? 0 : 1;
}
}]);
return MutationObserverStrategy;
}();
var NextTickStrategy = /*#__PURE__*/function () {
function NextTickStrategy(handler) {
_classCallCheck(this, NextTickStrategy);
this.scheduleNextTick = function () {
return process.nextTick(handler);
};
}
_createClass(NextTickStrategy, [{
key: "trigger",
value: function trigger() {
this.scheduleNextTick();
}
}]);
return NextTickStrategy;
}();
var BetterThanNothingStrategy = /*#__PURE__*/function () {
function BetterThanNothingStrategy(handler) {
_classCallCheck(this, BetterThanNothingStrategy);
this.scheduleAsap = function () {
return setTimeout(handler, 0);
};
}
_createClass(BetterThanNothingStrategy, [{
key: "trigger",
value: function trigger() {
this.scheduleAsap();
}
}]);
return BetterThanNothingStrategy;
}();
var getStrategy = function getStrategy() {
if (typeof window != "undefined" && typeof window.MutationObserver == "function") {
return MutationObserverStrategy;
}
if (typeof global != "undefined" && typeof process != "undefined" && typeof process.nextTick == "function") {
return NextTickStrategy;
}
return BetterThanNothingStrategy;
};
var schedule = function () {
var microtasks = [];
var run = function run() {
var handler, value;
while (microtasks.length > 0 && (_microtasks$shift = microtasks.shift(), handler = _microtasks$shift.handler, value = _microtasks$shift.value, _microtasks$shift)) {
var _microtasks$shift;
console.log("running handler with", value);
handler(value);
}
};
var Strategy = getStrategy();
var ctrl = new Strategy(run);
var scheduleFn = function scheduleFn(observers) {
if (observers.length == 0) {
return;
}
microtasks = microtasks.concat(observers);
observers.length = 0;
ctrl.trigger();
};
scheduleFn.flushQueue = function () {
run();
};
return scheduleFn;
}();
var isIterable = function isIterable(subject) {
return subject != null && typeof subject[Symbol.iterator] == "function";
};
var validateIterable = function validateIterable(subject) {
if (isIterable(subject)) {
return;
}
throw new TypeError("Cannot read property 'Symbol(Symbol.iterator)' of ".concat(Object.prototype.toString.call(subject), "."));
};
var isEmptyIterable = function isEmptyIterable(subject) {
var _iterator3 = _createForOfIteratorHelper(subject),
_step3;
try {
for (_iterator3.s(); !(_step3 = _iterator3.n()).done;) {
var _ = _step3.value;
// eslint-disable-line no-unused-vars
return false;
}
} catch (err) {
_iterator3.e(err);
} finally {
_iterator3.f();
}
return true;
};
window.Promifill = Promifill;

View file

@ -221,7 +221,11 @@ async function buildJsLegacy(inputFile, outputName, extraFile, polyfillFile) {
{
useBuiltIns: "entry",
corejs: "3",
targets: "IE 11"
targets: "IE 11",
// we provide our own promise polyfill (es6-promise)
// with support for synchronous flushing of
// the queue for idb where needed
exclude: ["es.promise", "es.promise.all-settled", "es.promise.finally"]
}
]
]

View file

@ -96,6 +96,15 @@ async function populateLib() {
path.join(modulesDir, 'aes-js/index.js'),
path.join(libDir, "aes-js/index.js")
);
// es6-promise is already written as an es module,
// but it does need to be babelified, and current we don't babelify
// anything in node_modules in the build script, so make a bundle that
// is conveniently not placed in node_modules rather than symlinking.
await fs.mkdir(path.join(libDir, "es6-promise/"));
await commonjsToESM(
path.join(modulesDir, 'es6-promise/lib/es6-promise/promise.js'),
path.join(libDir, "es6-promise/index.js")
);
}
populateLib();

View file

@ -24,6 +24,14 @@ import "mdn-polyfills/Element.prototype.closest";
// it will also include the file supporting *all* the encodings,
// weighing a good extra 500kb :-(
import "text-encoding";
import {checkNeedsSyncPromise} from "./matrix/storage/idb/utils.js";
import Promise from "../lib/es6-promise/index.js";
if (typeof window.Promise === "undefined") {
window.Promise = Promise;
// TODO: should be awaited before opening any session in the picker
checkNeedsSyncPromise();
}
// TODO: contribute this to mdn-polyfills
if (!Element.prototype.remove) {

View file

@ -30,7 +30,7 @@ export class StorageFactory {
delete(sessionId) {
const databaseName = sessionName(sessionId);
const req = window.indexedDB.deleteDatabase(databaseName);
const req = indexedDB.deleteDatabase(databaseName);
return reqAsPromise(req);
}

View file

@ -1,5 +1,6 @@
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -16,7 +17,37 @@ limitations under the License.
import { StorageError } from "../common.js";
class WrappedDOMException extends StorageError {
let needsSyncPromise = false;
/* should be called on legacy platforms to see
if transactions close before draining the microtask queue (IE11 on Windows 7).
If this is the case, promises need to be resolved
synchronously from the idb request handler to prevent the transaction from closing prematurely.
*/
export async function checkNeedsSyncPromise() {
// important to have it turned off while doing the test,
// otherwise reqAsPromise would not fail
needsSyncPromise = false;
const NAME = "test-idb-needs-sync-promise";
const db = await openDatabase(NAME, db => {
db.createObjectStore("test", {keyPath: "key"});
}, 1);
const txn = db.transaction("test", "readonly");
try {
await reqAsPromise(txn.objectStore("test").get(1));
await reqAsPromise(txn.objectStore("test").get(2));
} catch (err) {
// err.name would be either TransactionInactiveError or InvalidStateError,
// but let's not exclude any other failure modes
needsSyncPromise = true;
}
// we could delete the store here,
// but let's not create it on every page load on legacy platforms,
// and just keep it around
return needsSyncPromise;
}
class IDBRequestError extends StorageError {
constructor(request) {
const source = request?.source;
const storeName = source?.name || "<unknown store>";
@ -38,7 +69,7 @@ export function decodeUint32(str) {
}
export function openDatabase(name, createObjectStore, version) {
const req = window.indexedDB.open(name, version);
const req = indexedDB.open(name, version);
req.onupgradeneeded = (ev) => {
const db = ev.target.result;
const txn = ev.target.transaction;
@ -50,15 +81,27 @@ export function openDatabase(name, createObjectStore, version) {
export function reqAsPromise(req) {
return new Promise((resolve, reject) => {
req.addEventListener("success", event => resolve(event.target.result));
req.addEventListener("error", event => reject(new WrappedDOMException(event.target)));
req.addEventListener("success", event => {
resolve(event.target.result);
needsSyncPromise && Promise._flush && Promise._flush();
});
req.addEventListener("error", () => {
reject(new IDBRequestError(req));
needsSyncPromise && Promise._flush && Promise._flush();
});
});
}
export function txnAsPromise(txn) {
return new Promise((resolve, reject) => {
txn.addEventListener("complete", resolve);
txn.addEventListener("abort", event => reject(new WrappedDOMException(event.target)));
txn.addEventListener("complete", () => {
resolve();
needsSyncPromise && Promise._flush && Promise._flush();
});
txn.addEventListener("abort", () => {
reject(new IDBRequestError(txn));
needsSyncPromise && Promise._flush && Promise._flush();
});
});
}
@ -66,21 +109,25 @@ export function iterateCursor(cursorRequest, processValue) {
// TODO: does cursor already have a value here??
return new Promise((resolve, reject) => {
cursorRequest.onerror = () => {
reject(new StorageError("Query failed", cursorRequest.error));
reject(new IDBRequestError(cursorRequest));
needsSyncPromise && Promise._flush && Promise._flush();
};
// collect results
cursorRequest.onsuccess = (event) => {
const cursor = event.target.result;
if (!cursor) {
resolve(false);
needsSyncPromise && Promise._flush && Promise._flush();
return; // end of results
}
const result = processValue(cursor.value, cursor.key);
// TODO: don't use object for result and assume it's jumpTo when not === true/false or undefined
const done = result?.done;
const jumpTo = result?.jumpTo;
if (done) {
resolve(true);
needsSyncPromise && Promise._flush && Promise._flush();
} else if(jumpTo) {
cursor.continue(jumpTo);
} else {

View file

@ -103,7 +103,9 @@ export class WorkerPool {
if (message.type === "success") {
request._resolve(message.payload);
} else if (message.type === "error") {
request._reject(new Error(message.stack));
const err = new Error(message.message);
err.stack = message.stack;
request._reject(err);
}
request._dispose();
}

View file

@ -17,8 +17,14 @@ limitations under the License.
// polyfills needed for IE11
// just enough to run olm, have promises and async/await
// load this first just in case anything else depends on it
import Promise from "../lib/es6-promise/index.js";
// not calling checkNeedsSyncPromise from here as we don't do any idb in the worker,
// mainly because IE doesn't handle multiple concurrent connections well
self.Promise = Promise;
import "regenerator-runtime/runtime";
import "core-js/modules/es.promise";
import "core-js/modules/es.math.imul";
import "core-js/modules/es.math.clz32";
@ -48,3 +54,4 @@ import "core-js/modules/es.typed-array.to-locale-string";
import "core-js/modules/es.typed-array.to-string";
import "core-js/modules/es.typed-array.iterator";
import "core-js/modules/es.object.to-string";

View file

@ -1205,6 +1205,10 @@ entities@^2.0.0:
resolved "https://registry.yarnpkg.com/entities/-/entities-2.0.0.tgz#68d6084cab1b079767540d80e56a39b423e4abf4"
integrity sha512-D9f7V0JSRwIxlRI2mjMqufDrRDnx8p+eEOz7aUM9SuvF8gsBzra0/6tbjl1m8eQHrZlYj6PxqE00hZ1SAIKPLw==
"es6-promise@https://github.com/bwindels/es6-promise.git#bwindels/expose-flush":
version "4.2.8"
resolved "https://github.com/bwindels/es6-promise.git#112f78f5829e627055b0ff56a52fecb63f6003b1"
escalade@^3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.0.2.tgz#6a580d70edb87880f22b4c91d0d56078df6962c4"