Skip to content

Instantly share code, notes, and snippets.

@mhevery
Last active July 27, 2017 09:08
Show Gist options
  • Save mhevery/bb7905da8ef181436a68 to your computer and use it in GitHub Desktop.
Save mhevery/bb7905da8ef181436a68 to your computer and use it in GitHub Desktop.
TC39 Zone API Proposal

Monkey patching common browser APIs

Here is an example of how browser APIs could be patched to take advantage of Zone propagation.

setTimeout

Ideally we would not need to do this, since the browser would do this for us.

window.setTimeout = ((delegate) => {
  return function (fn, delay, var_args) {
     arguments[0] = Zone.current.wrap(fn, 'setTimeout');
     return delegate.apply(this, arguments);
  };
})(window.setTimeout);

Promise.prototype.then

Ideally we would not need to do this, since the Promise would do this for us. It could do this more efficiently by storing the Zone, and then invoking the callback using zone.run(...)

window.Promise.prototype.then = ((then) => {
  return function (onResolve, onError) {
     // Wrap callbacks without error handling.
     arguments[0] = Zone.current.wrap(onResolve, 'Promise.then.onResolve', false);
     arguments[0] = Zone.current.wrap(onError, 'Promise.then.onError', false);
     return then.apply(this, arguments);
  };
})(window.Promise.prototype.then);

addEventListener

Ideally we would not need to do this, since the browser would do this for us.

window.EventTarget.prototype.addEventListener = ((addEventListener) => {
  return function (eventName, handler, capture) {
     // Wrap callbacks without error handling.
     arguments[1] = Zone.current.wrap(handler, 'EventTarget.addEventListener');
     return addEventListener.apply(this, arguments);
  };
})(window.EventTarget.prototype.addEventListener);

Framework auto rendering

(Assuming that the browser / Promises propagate Zones)

In this example we have two applications on a page written with two frameworks, and we want to make sure that the right framework gets notified when the correct operation happens.

var frameworkAZone = Zone.current.fork({
  name: 'frameworkA', 
  onInvoke: (self, parent, zone, callback, applyThis, applyArgs) => {
    try {
      return parent.invoke(callback, applyThis, applyArgs);
    } finally {
      frameworkAMayNeedRendering();
    }
  }
})

var frameworkBZone = Zone.current.fork({
  name: 'frameworkB', 
  onInvoke: (self, parent, zone, callback, applyThis, applyArgs) => {
    try {
      return parent.invoke(callback, applyThis, applyArgs);
    } finally {
      frameworkBMayNeedRendering();
    }
  }
})

frameworkAZone.run(() => {
  bootstrapApplicationA();
});


frameworkAZone.run(() => {
  bootstrapApplicationB();
});

setInterval(() => {
  expect(Zone.current.name, '<root>');
  updateSomeThingNotPartOfApplication();  
}, 100);


function bootstrapApplicationA() {
  expect(Zone.current, frameworkAZone);
  someDiv.addEventListener('click', (event) => {
    expect(Zone.current, frameworkAZone);
    setTimeout(() => {
      expect(Zone.current, frameworkAZone);
      new Promise().then(
        expect(Zone.current, frameworkAZone);
        // Set some value
        model.someProperty = 'New Value';
        // Expect that framework A will get notified of rendering.
      )
    }, 100);
  }); 
}

function bootstrapApplicationB() {
  expect(Zone.current, frameworkBZone);
  someDiv.addEventListener('click', (event) => {
    expect(Zone.current, frameworkBZone);
    setTimeout(() => {
      expect(Zone.current, frameworkBZone);
      new Promise().then(
        expect(Zone.current, frameworkBZone);
        // Set some value
        model.someProperty = 'New Value';
        // Expect that framework B will get notified of rendering.
      )
    }, 100);
  }); 
}

Need for Zone Scheduling API

Current proposal does not include task scheduling. (scheduleTimer(...), scheduleMicrotask(...), and addEventListener(...)). This limits the usefulness of Zones in these cases:

  • Not possible to keep an inventory of Outstanding tasks. This is useful when writing end-to-end tests which can intelligently wait for task to complete. For example Protractor (Angular end-to-end test runner) counts the number of outstanding task in the Angular zone, and it only proceeds once the number of outstanding tasks goes zero. This allows the test to click on a button in the application and wait with the assertion that the resulting page has the correct value. Doing the check too early is a problem since typically a page transitions need to wait for XHR. Waiting too long makes the test slow. To further complicate the problem there are often multiple XHRs, and some of the XHRs can only be invoked once previous XHR returned. Once the count reaches zero, Protractor knows that it can safely assert that the result is correct. This makes for very efficient and simple (no pooling/waiting) and non-flaky test running.

  • Not possible to do application task tracking. Applications are often interested how long do user operations take. These are hard to measure, since the user initiates an operations with an action, which then results in a complex set of subsequent tasks, such as XHRs, setTimeouts() and requestAnimationFrame. From the user point of view the operation delay is from click to rendered data. But from framework point of view this can take a form of many intermediate renderings. The way this is solved is that the action is invoked in a child tasks zone, and the stopwatch stops running when the number of tasks in the Zone is zero.

  • Not possible for the framework to not over-render. Over-rending happens because the framework gets notified on each microtask (Promise chaining will cause this). Keeping track of task scheduling would solve this.

/**
* Zone is a mechanism for propagating context across asynchronous operations.
*
* Zones can be composed into a tree by forking and specifying a [ZoneSpec] for the child
* zone. A child zone composes with the parent zone in the sense that it can add
* additional behavior to it methods (or override the default behavior).
*
* The composition of behavior is achieved with the [ZoneDelegate].
*/
class Zone {
/**
* A global variable pointing to a currently active zone.
*
* This variable gets restored to a value associated with the asynchronous operation
* when it gets executed.
*/
static current: Zone = new Zone(null, {name: '<root>'}, {});
/**
* Parent Zone.
*/
public parent: Zone;
/**
* Name of the Zone.
*
* While not strictly used by zone, it is very usefull information to have during
* debugging.
*/
public name: string;
private _delegate: ZoneDelegate;
private _properties: {[key: string]: any};
/* private */ constructor(parent: Zone, zoneSpec: ZoneSpec, properties?: {[key: string]: any}) {
this.parent = parent;
this.name = zoneSpec.name;
this._properties = properties;
this._delegate = new ZoneDelegate(this, this.parent && this.parent._delegate, zoneSpec);
}
/**
* Create a child Zone with additional behavior.
*
* @param [zoneSpec] A new set of behaviors.
* @param [properties] An optional set of properties associated with the zone.
* @returns a child zone.
*/
public fork(zoneSpec: ZoneSpec, properties?: {[k: string]: any}) {
return this._delegate.fork(this, zoneSpec, properties || {});
}
/**
* Wrap a [callback] function in a function which restores zone and optionally handles errors.
*
* @param [callback] A function to wrapped.
* @param [source] An optional location describing the usage of the callback.
* @param [handleError] An optional argument which specifies if exceptions should be caught and
* forwarded to zone's error handler. (Catch by default.)
* @returns Returns a new function which wraps the [callback] and in zone propagation and
* optional error handling.
*/
public wrap(callback: Function, source?: string, handleError: boolean = true): Function {
var boundCallback = this._delegate.beforeWrap(this, callback, source);
var zone = this;
// optimize for root zone which does not need wrapping.
if (boundCallback == callback && handleError == false && this.parent == null) return callback;
return function(...args: any[]): any {
return zone.run(boundCallback, this, args, handleError);
}
}
/**
* Execute a function in a given zone.
*
* @param [callback] Function to execute in a zone.
* @param [applyThis] Apply `this` for the [callback].
* @param [applyArgs] Apply `arguments` for the [callback].
* @param [handleError] Should exceptions be handled by the zone?
* @returns value returned by the [callback]
*/
public run(callback: Function, applyThis?: any, applyArgs?: any[],
handleError: boolean = false): any
{
var previousZone = Zone.current;
Zone.current = this;
try {
if (handleError) {
try {
return this._delegate.invoke(this, callback, applyThis, applyArgs);
} catch(e) {
if (this.handleError(e)) {
throw e;
}
}
} else {
return this._delegate.invoke(this, callback, applyThis, applyArgs);
}
} finally {
Zone.current = previousZone;
}
}
/**
* Process a caught exception.
*
* @param [error] Caught exception.
* @returns {boolean} If the [error] is unhandled and should be rethrown.
*/
public handleError(error: any): boolean {
return this._delegate.handleError(this, error);
}
/**
* Associate a value with a zone.
*
* Setting a value of the same [key] as in the parent zone will cause this zone's value to
* shadow the parent zone value.
*
* @param [key] A key to store the value under in the current zone.
* @param [value] A value to store.
* @returns The [value] passed in.
*/
public set(key: string, value: any): any {
return this._properties[key] = value;
}
/**
* Retrieve a value for given key from the current or parent zones.
*
* @param [key] Key to retrieve the value from.
* @returns The value for the [key]
*/
public get(key: string): any {
var zone: Zone = this;
while (zone) {
if (zone._properties.hasOwnProperty(key)) {
return zone._properties[key];
}
zone = zone.parent;
}
}
/**
* Delete the key in the current zone.
*
* NOTE: it is possible that deleting a value in the current zone will expose a new value from
* the parent zone.
*
* @param [key] Key to delete.
* @returns Returns true of the value existed and was deleted.
*/
public delete(key: string): boolean {
if (this._properties.hasOwnProperty(key)) {
return delete this._properties[key];
}
return false;
}
}
/**
* ZoneDelegate is used by [Zone] to compose of [ZoneSpec] behavior.
*
* When zones compose, it is not possible to simply have a child zone call a parent zone. This is
* because when behavior is being composed it needs to contain information about the original
* zone requesting the operation, current zone processing the operation, as well as where the
* operation should be delegated to. For this reason the methods in this class mirror the Zone
* methods with three additional arguments:
*
* - [self] A current zone processing the operation.
* - [parent] A place where the operation should be delegated to.
* - [zone] The original zone which received the operation request.
*
* The [ZoneDelegate] is configured using [ZoneSpec] which provides the actual behavior for each
* Zone. The [ZoneDelegate] takes care of composing the behaviors.
*/
class ZoneDelegate {
/**
* The [Zone] associated with this [ZoneDelegate].
*/
public self: Zone;
/**
* The parent [ZoneDelegate].
*/
public parent: ZoneDelegate;
private _onFork: OnForkHandler;
private _onInvoke: OnInvokeHandler;
private _onBeforeWrap: OnBeforeWrapHandler;
private _onHandleError: OnHandleErrorHandler;
constructor(zone: Zone, parent: ZoneDelegate, zoneSpec: ZoneSpec) {
this.self = zone;
this.parent = parent;
this._onFork = zoneSpec.onFork
|| parent && parent._onFork
|| function (self: Zone, parent: ZoneDelegate, zone: Zone,
zoneSpec: ZoneSpec, properties: {[key: string]: any}): Zone {
/// The default root [Zone] behavior creates a new zone.
return new Zone(zone, zoneSpec, properties);
};
this._onInvoke = zoneSpec.onInvoke
|| parent && parent._onInvoke
|| function (self: Zone, parent: ZoneDelegate, zone: Zone,
callback: Function, applyThis: any, applyArgs: any[]): any {
// The default root [Zone] behavior is to apply the function
return callback.apply(applyThis, applyArgs);
};
this._onBeforeWrap = zoneSpec.onBeforeWrap
|| parent && parent._onBeforeWrap
|| function (self: Zone, parent: ZoneDelegate, zone: Zone,
callback: Function, source: string): Function {
// The default root [Zone] behavior does not do any additional wrapping
return callback;
};
this._onHandleError = zoneSpec.onHandleError
|| parent && parent._onHandleError
|| function (self: Zone, parent: ZoneDelegate, zone: Zone,
error: any): boolean {
// The default root [Zone] behavior does not handle errors, and causes a rethrow
return false;
};
}
/**
* Allows an interception of [Zone.fork] operation.
*
* @param [zone] The [Zone] on which the operation was invoked on.
* @param [zoneSpec] A new set of behaviors.
* @param [properties] A set of properties associated with the zone.
* @returns a child zone.
*/
fork(zone: Zone, zoneSpec: ZoneSpec, properties: {[key: string]: any}): Zone {
return this._onFork(this.self, this.parent, zone, zoneSpec, properties);
}
/**
* Allows an interception of [Zone.run] operation by controlling how the callback is invoked.
*
* @param [zone] The [Zone] on which the operation was invoked on.
* @param [callback] Function to execute.
* @param [applyThis] Apply `this` for the [callback].
* @param [applyArgs] Apply `arguments` for the [callback].
* @returns value returned by the [callback]
*/
invoke(zone: Zone, delegate: Function, applyThis: any, applyArgs: any[]): any {
return this._onInvoke(this.self, this.parent, zone, delegate, applyThis, applyArgs);
}
/**
* Allows an interception of [Zone.wrap] operation.
*
* @param [zone] The [Zone] on which the operation was invoked on.
* @param [callback] A function to wrapped.
* @param [source] An optional location describing the usage of the callback.
* @returns Returns [callback] or a new function which performs additional work.
*/
beforeWrap(zone: Zone, callback: Function, source: string): Function {
return this._onBeforeWrap(this.self, this.parent, zone, callback, source);
}
/**
* Allows an interception of [Zone.handleError] operation.
*
* @param [zone] The [Zone] on which the operation was invoked on.
* @param [error] Caught exception.
* @returns Whether or not the error should be rethrown past the [Zone].
*/
handleError(zone: Zone, error: any): boolean {
return this._onHandleError(this.self, this.parent, zone, error);
}
}
/**
* [ZoneSpec] provides a configuration when [Zone]s are forked.
*/
interface ZoneSpec {
/**
* While name is not strictly needed it makes debugging [Zone]s significantly easier.
*/
name: string;
/**
* Optional way to overwrite [Zone] forking.
*/
onFork?: OnForkHandler;
/**
* Optional way to overwrite how callbacks are invoked.
*/
onInvoke?: OnInvokeHandler;
/**
* Optional way to wrap callbacks.
*/
onBeforeWrap?: OnBeforeWrapHandler;
/**
* Optional way to handle errors.
*/
onHandleError?: OnHandleErrorHandler;
}
/**
* A Function which intercepts [Zone] forking.
*
* A possible use is to prevent a [Zone] from further forks, or to ensure that all child forks have
* a particular set of properties.
*
* Typically this method calls `parent.fork(...)` and may do additional work before or after the
* call.
*
* @param [self] A current zone processing the operation.
* @param [parent] A place where the operation should be delegated to.
* @param [zone] The original zone which received the operation request.
* @param [zoneSpec] A [ZoneSpec] object containing [Zone] configuration.
* @param [properties] A set of properties to associate with a [Zone].
* @returns A child [Zone].
*/
type OnForkHandler = (self: Zone, parent: ZoneDelegate, zone: Zone,
zoneSpec: ZoneSpec, properties: {[key: string]: any}) => Zone;
/**
* A function which helps with [Zone.run] operation.
*
* By the time we reach this method the [Zone#current] has already been restored. (Optionally,
* the error handling frame is already above us.)
*
* A possible use is to measure execution time, logging, or introspect the [callback] arguments.
*
* Typically this method calls `parent.invoke(...)` and may do additional work before or after the
* call.
*
* @param [self] A current zone processing the operation.
* @param [parent] A place where the operation should be delegated to.
* @param [zone] The original zone which received the operation request.
* @param [callback] Function to execute.
* @param [applyThis] Apply `this` for the [callback].
* @param [applyArgs] Apply `arguments` for the [callback].
* @returns value returned by the [callback]
*/
type OnInvokeHandler = (self: Zone, parent: ZoneDelegate, zone: Zone,
callback: Function, applyThis: any, applyArgs: any[]) => any;
/**
* A function which helps with [Zone.wrap] operation.
*
* Use this hook to wrap the callback if additional work needs to happen before or after.
*
* A possible use is to wrap the callback to associate it with addition context information.
*
* Typically this method calls `parent.beforeCallback(...)` and may do additional work before or
* after the call.
*
* @param [self] A current zone processing the operation.
* @param [parent] A place where the operation should be delegated to.
* @param [zone] The original zone which received the operation request.
* @param [callback] A function to wrapped.
* @param [source] An optional location describing the usage of the callback.
* @returns Returns [callback] or a new function which performs additional work.
*/
type OnBeforeWrapHandler = (self: Zone, parent: ZoneDelegate, zone: Zone,
callback: Function, source: string) => Function;
/**
* A function which helps [Zone.run] operation to handle errors using [Zone.handleError].
*
* Typically this method handles the error itself or delegates to `parent.handleError(...)`.
*
* @param [self] A current zone processing the operation.
* @param [parent] A place where the operation should be delegated to.
* @param [zone] The original zone which received the operation request.
* @param [error] Caught exception.
* @returns Whether or not the error should be rethrown past the [Zone].
*/
type OnHandleErrorHandler = (self: Zone, parent: ZoneDelegate, zone: Zone,
error: any) => boolean;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment