Skip to content

Commit

Permalink
Wrap Promise class to get unhandledrejections
Browse files Browse the repository at this point in the history
Wraps promises in a special subclass that can detect unhandled rejections and report them. This sidesteps the issue with non-cors scripts entirely.

See whatwg/html#5051 (comment)
  • Loading branch information
jridgewell committed Oct 31, 2019
1 parent 476b89d commit 8497d83
Show file tree
Hide file tree
Showing 3 changed files with 188 additions and 1 deletion.
1 change: 1 addition & 0 deletions build-system/eslint-rules/window-property-name.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const PATHS_TO_INCLUDE = [

const PATHS_TO_IGNORE = [
path.resolve(ROOT_DIR, 'src/polyfills'),
path.resolve(ROOT_DIR, 'src/wrap-promises-for-errors.js'),
path.resolve(ROOT_DIR, 'test/'),
];

Expand Down
10 changes: 9 additions & 1 deletion src/error.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {makeBodyVisibleRecovery} from './style-installer';
import {startsWith} from './string';
import {triggerAnalyticsEvent} from './analytics';
import {urls} from './config';
import {wrapPromsies} from './wrap-promises-for-errors';

/**
* @const {string}
Expand Down Expand Up @@ -281,7 +282,7 @@ export function isBlockedByConsent(errorOrMessage) {
*/
export function installErrorReporting(win) {
win.onerror = /** @type {!Function} */ (onError);
win.addEventListener('unhandledrejection', event => {
const unhandledrejection = event => {
if (
event.reason &&
(event.reason.message === CANCELLED ||
Expand All @@ -292,6 +293,13 @@ export function installErrorReporting(win) {
return;
}
reportError(event.reason || new Error('rejected promise ' + event));
};
win.addEventListener('unhandledrejection', unhandledrejection);
wrapPromsies(self, err => {
unhandledrejection({
reason: err,
preventDefault() {},
});
});
}

Expand Down
178 changes: 178 additions & 0 deletions src/wrap-promises-for-errors.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
/**
* Copyright 2019 The AMP HTML Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS-IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

/**
* @param {function()} fn
* @return {boolean}
*/
function isNative(fn) {
return Object.toString.call(fn).includes('[native code]');
}

/**
* Wraps promises in a special subclass that can detect unhandled rejections
* and report them.
*
* Normally, `unhandledrejection` event would be used to do this, but it
* doesn't work unless the script is requested as anonymous.
*
* @param {!Window} win
* @param {function(err)} reportError
*/
export function wrapPromsies(win, reportError) {
const NativePromise = win.Promise;
const originalThen = originalThen.prototype.then;
const species = typeof win['Species'] !== undefined && win['Species'].species;

// If there's no species symbol, there's nothing we can do.
if (!species) {
return;
}
// If we using the polyfilled promise, there's no need to wrap it.
if (!isNative(NativePromise)) {
return;
}

// We sometimes need to allow creating a real native promise. Eg,
// `Promise.resolve().then(() => Promise.reject(1))`. In this case, we'll go
// through the `wrappedResolve` call (not `wrappedReject`), so we have to cap
// the return promise with a catch handler. But if we tried to construct a
// new wrapped promise, we'd get an infinite loop.
let allowNative = false;

/**
* Wrapper wraps the native Promise class!
*
* Why isn't this using class syntax? Because closure doesn't properly setup
* the constructor's prototype chain (it only sets the
* `constructor.prototype`'s prototype chain).
*
* @param {function(function(T|Promise<T>), function(Error))} executer
* @return {!Promise<T>}
* @template T
*/
function Wrapper(executer) {
const p = new NativePromise((resolve, reject) => {
// The promises spec says that the `resolve` and `reject` functions may
// only be called once. After that, they do nothing.
let called = false;

/**
* We wrap the `resolve` function to make sure that "only called once" is
* not violated. Because we have to wrap the `reject`, it wouldn't work
* otherwise.
*
* @param {T} value
*/
function wrappedResolve(value) {
if (called) {
return;
}
called = true;
resolve(value);
}

/**
* We wrap the `reject` function so that we can report the error if the
* promise is the end of the chain.
*
* @param {!Error} err
*/
function wrappedReject(err) {
if (called) {
return;
}
p._rejected = true;
called = true;
reject(err);
maybeReport(err, p);
}

// Now call the user's executer with out wrapped `resolve` and `reject`!
// Note that if the executer throws a synchronous error, it's the same as
// calling `reject`.
try {
executer(wrappedResolve, wrappedReject);
} catch (e) {
wrappedReject(e);
}
});
p._chainEnd = true;
p._rejected = false;

Object.setPrototypeOf(p, Wrapper.prototype);
return p;
}

// Setup the wrapper's prototype chain. Both the constructor, and the
// constructor.prototype must properly inherit.
Wrapper.__proto__ = NativePromise;
Wrapper.prototype.__proto__ = NativePromise.prototype;

// Wrap the then method so that we can tell that this current promise is not
// the end of a promise chain, it's the returned promise that's the end.
Wrapper.prototype.then = function(f, r) {
this._chainEnd = false;
const p = originalThen.call(this, f, r);

// If the promise did not sync reject, then there's a possibility that is
// was resolved with a rejected promise. In this case, we need to cap the
// promise chain to do the reporting.
if (!this._rejected) {
allowNative = true;
originalThen.call(p, undefined, err => maybeReport(err, p));
allowNative = false;
}

return p;
};

/**
* After a delay, if this rejected promise hasn't had a promise chained off
* of it, report it.
*
* @param {!Error} err
* @param {!Promise} p
*/
function maybeReport(err, p) {
setTimeout(() => {
if (p._chainEnd) {
reportError(err);
p._chainEnd = false;
}
}, 1);
}

/**
* The species of a constructor function allows subclasses to share a base
* methods. Whatever constructor is return by the speciesWraper will be used
* to construct a new instance.
*
* Eg, having a `Wrapper` instance, then calling `w.then()` will return a new
* instance of Wrapper, instead of Promise.
*
* @return {function()}
*/
function speciesWraper() {
return allowNative ? NativePromise : Wrapper;
}
NativePromise[species] = speciesWraper;

// Finally, we need to force promises to use the think they are not the
// NativePromise. This will make them always call the species' constructor.
NativePromise.prototype.constructor = Wrapper;
win.Promise = Wrapper;
}

0 comments on commit 8497d83

Please sign in to comment.