gristlabs_grist-core/app/client/lib/dispose.js
Paul Fitzpatrick 1654a2681f (core) move client code to core
Summary:
This moves all client code to core, and makes minimal fix-ups to
get grist and grist-core to compile correctly.  The client works
in core, but I'm leaving clean-up around the build and bundles to
follow-up.

Test Plan: existing tests pass; server-dev bundle looks sane

Reviewers: dsagal

Reviewed By: dsagal

Differential Revision: https://phab.getgrist.com/D2627
2020-10-02 13:24:21 -04:00

370 lines
13 KiB
JavaScript

/**
* dispose.js provides tools to components that needs to dispose of resources, such as
* destroy DOM, and unsubscribe from events. The motivation with examples is presented here:
*
* https://phab.getgrist.com/w/disposal/
*/
var ko = require('knockout');
var util = require('util');
var _ = require("underscore");
// Use the browser globals in a way that allows replacing them with mocks in tests.
var G = require('./browserGlobals').get('DocumentFragment', 'Node');
/**
* Disposable is a base class for components that need cleanup (e.g. maintain DOM, listen to
* events, subscribe to anything). It provides a .dispose() method that should be called to
* destroy the component, and .autoDispose() method that the component should use to take
* responsibility for other pieces that require cleanup.
*
* To define a disposable prototype:
* function Foo() { ... }
* dispose.makeDisposable(Foo);
*
* To define a disposable ES6 class:
* class Foo extends dispose.Disposable { create() {...} }
*
* NB: Foo should not have its construction logic in a constructor but in a `create` method
* instead. If Foo defines a constructor (for taking advantage of type checking) the constructor
* should only call super `super(arg1, arg2 ...)`. Any way calling `new Foo(...args)` safely
* construct the component.
*
* In Foo's constructor or methods, take ownership of other objects:
* this.bar = this.autoDispose(Bar.create(...));
* The argument will get disposed when `this` is disposed. If it's a DOM node, it will get removed
* using ko.removeNode(). If it has a `dispose` method, it will be called.
*
* For more customized disposal:
* this.baz = this.autoDisposeWith('destroy', new Baz());
* this.elem = this.autoDisposeWith(ko.cleanNode, document.createElement(...));
* When `this` is disposed, will call this.baz.destroy(), and ko.cleanNode(this.elem).
*
* To call another method on disposal (e.g. to add custom disposal logic):
* this.autoDisposeCallback(this.myUnsubscribeAllMethod);
* The method will be called with `this` as context, and no arguments.
*
* To create Foo:
* var foo = Foo.create(args...);
* `Foo.create` ensures that if the constructor throws an exception, any calls to .autoDispose()
* that happened before that are honored.
*
* To dispose of Foo:
* foo.dispose();
* Owned objects will be disposed in reverse order from which `autoDispose` were called. Note that
* `foo` is no longer usable afterwards, and all its properties are wiped.
* If Foo has a `stopListening` method (e.g. inherits from Backbone.Events), `dispose` will call
* it automatically, as if it were added with `this.autoDisposeCallback(this.stopListening)`.
*
* To release an owned object:
* this.disposeRelease(this.bar);
*
* To dispose of an owned object early:
* this.disposeDiscard(this.bar);
*
* To determine if a reference refers to object that has already been disposed:
* foo.isDisposed()
*/
class Disposable {
/**
* A safe constructor which calls dispose() in case the creation throws an exception.
*/
constructor(...args) {
safelyConstruct(this.create, this, args);
}
/**
* Static method to allow rewriting old classes into ES6 without modifying their
* instantiation to use `new Foo()` (i.e. you can continue to use `Foo.create()`).
*/
static create(...args) {
return new this(...args);
}
}
Object.assign(Disposable.prototype, {
/**
* Take ownership of `obj`, and dispose it when `this.dispose` is called.
* @param {Object} obj: Object to take ownership of. It can be a DOM node or an object with a
* `dispose` method.
* @returns {Object} obj
*/
autoDispose: function(obj) {
return this.autoDisposeWith(defaultDisposer, obj);
},
/**
* As for autoDispose, but we receive a promise of an object. We wait for it to
* resolve and then take ownership of it. We return a promise that resolves to
* the object, or to null if the owner is disposed in the meantime.
*/
autoDisposePromise: function(objPromise) {
return objPromise.then(obj => {
if (this.isDisposed()) {
defaultDisposer(obj);
return null;
}
this.autoDispose(obj);
return obj;
});
},
/**
* Take ownership of `obj`, and dispose it when `this.dispose` is called by calling the
* specified function.
* @param {Function|String} disposer: If a function, disposer(obj) will be called to dispose the
* object, with `this` as the context. If a string, then obj[disposer]() will be called. E.g.
* this.autoDisposeWith('destroy', a); // will call a.destroy()
* this.autoDisposeWith(ko.cleanNode, b); // will call ko.cleanNode(b)
* @param {Object} obj: Object to take ownership of, on which `disposer` will be called.
* @returns {Object} obj
*/
autoDisposeWith: function(disposer, obj) {
var list = this._disposalList || (this._disposalList = []);
list.push({ obj: obj,
disposer: typeof disposer === 'string' ? methodDisposer(disposer) : disposer });
return obj;
},
/**
* Adds the given callback to be called when `this.dispose` is called.
* @param {Function} callback: Called on disposal with `this` as the context and no arguments.
* @returns nothing
*/
autoDisposeCallback: function(callback) {
this.autoDisposeWith(callFuncHelper, callback);
},
/**
* Remove `obj` from the list of owned objects; it will not be disposed on `this.dispose`.
* @param {Object} obj: Object to release.
* @returns {Object} obj
*/
disposeRelease: function(obj) {
removeObjectToDispose(this._disposalList, obj);
return obj;
},
/**
* Dispose of an owned object `obj` now, and remove it from the list of owned objects.
* @param {Object} obj: Object to release.
* @returns nothing
*/
disposeDiscard: function(obj) {
var entry = removeObjectToDispose(this._disposalList, obj);
if (entry) {
entry.disposer.call(this, obj);
}
},
/**
* Returns whether this object has already been disposed.
*/
isDisposed: function() {
return this._disposalList === WIPED_VALUE;
},
/**
* Clean up `this` by disposing of all owned objects, and calling `stopListening()` if defined.
*/
dispose: function() {
if (this.isDisposed()) {
return;
}
var disposalList = this._disposalList;
this._disposalList = WIPED_VALUE; // This makes isDisposed() true.
if (disposalList) {
// Go backwards through the disposal list, and dispose of everything.
for (var i = disposalList.length - 1; i >= 0; i--) {
var entry = disposalList[i];
disposeHelper(this, entry.disposer, entry.obj);
}
}
// Call stopListening if it exists. This is a convenience when using Backbone.Events. It's
// equivalent to calling this.autoDisposeCallback(this.stopListening) in constructor.
if (typeof this.stopListening === 'function') {
// Wrap in disposeHelper so that errors get caught.
disposeHelper(this, callFuncHelper, this.stopListening);
}
// Finish by wiping out the object, since nothing should use it after dispose().
// See https://phab.getgrist.com/w/disposal/ for more motivation.
wipeOutObject(this);
}
});
exports.Disposable = Disposable;
/**
* The recommended way to make an object disposable. It simply adds the methods of `Disposable` to
* its prototype, and also adds a `Class.create()` function, for a safer way to construct objects
* (see `safeCreate` for explanation). For instance,
* function Foo(args...) {...}
* dispose.makeDisposable(Foo);
* Now you can create Foo objects with:
* var foo = Foo.create(args...);
* And dispose of them with:
* foo.dispose();
*/
function makeDisposable(Constructor) {
Object.assign(Constructor.prototype, Disposable.prototype);
Constructor.create = safeConstructor;
}
exports.makeDisposable = makeDisposable;
/**
* Helper to create and construct an object safely: `safeCreate(Foo, ...)` is similar to `new
* Foo(...)`. The difference is that in case of an exception in the constructor, the dispose()
* method will be called on the partially constructed object.
* If you call makeDisposable(Foo), then Foo.create(...) is equivalent and more convenient.
* @returns {Object} the newly constructed object.
*/
function safeCreate(Constructor, varArgs) {
return safeConstructor.apply(Constructor, Array.prototype.slice.call(arguments, 1));
}
exports.safeCreate = safeCreate;
/**
* Helper used by makeDisposable() for the `create` property of a disposable class. E.g. when
* assigned to Foo.create, the call `Foo.create(args)` becomes similar to `new Foo(args)`, but
* calls dispose() in case the constructor throws an exception.
*/
var safeConstructor = function(varArgs) {
var Constructor = this;
var obj = Object.create(Constructor.prototype);
return safelyConstruct(Constructor, obj, arguments);
};
var safelyConstruct = function(Constructor, obj, args) {
try {
Constructor.apply(obj, args);
return obj;
} catch (e) {
// Be a bit more helpful and concise in reporting errors: print error as an object (that
// includes its stacktrace in FF and Chrome), and avoid printing it multiple times as it
// bubbles up through the stack of safeConstructor calls.
if (!e.printed) {
let name = obj.constructor.name || Constructor.name;
console.error("Error constructing %s:", name, e);
// assigning printed to a string throws: TypeError: Cannot create property 'printed' on [...]
if (_.isObject(e)) {
e.printed = true;
}
}
obj.dispose();
throw e;
}
};
// It doesn't matter what the value is, but some values cause more helpful errors than others.
// E.g. if x = "disposed", then x.foo() throws "undefined is not a function", while when x = null,
// x.foo() throws "Cannot read property 'foo' of null", which seems more helpful.
var WIPED_VALUE = null;
/**
* Wipe out the given object by setting each property to a dummy value. This is helpful for
* objects that are disposed and should be ready to be garbage-collected. The goals are:
* - If anything still refers to the object and uses it, we'll get an early error, rather than
* silently keep going, potentially doing useless work (or worse) and wasting resources.
* - If anything still refers to the object but doesn't use it, the fields of the object can
* still be garbage-collected.
* - If there are circular references between the object and its properties, they get broken,
* making the job easier for the garbage collector.
*/
function wipeOutObject(obj) {
for (var k in obj) {
if (obj.hasOwnProperty(k)) {
obj[k] = WIPED_VALUE;
}
}
}
/**
* Internal helper used by disposeDiscard() and disposeRelease(). It finds, removes, and returns
* an entry from the given disposalList.
*/
function removeObjectToDispose(disposalList, obj) {
if (disposalList) {
for (var i = 0; i < disposalList.length; i++) {
if (disposalList[i].obj === obj) {
var entry = disposalList[i];
disposalList.splice(i, 1);
return entry;
}
}
}
return null;
}
/**
* Internal helper to allow adding cleanup callbacks to the disposalList. It acts as the
* "disposer" for callback, by simply calling them with the same context that it is called with.
*/
var callFuncHelper = function(callback) {
callback.call(this);
};
/**
* Internal helper to dispose objects that need a differently-named method to be called on them.
* It's used by `autoDisposeWith` when the disposer is a string method name.
*/
function methodDisposer(methodName) {
return function(obj) {
obj[methodName]();
};
}
/**
* Internal helper to call a disposer on an object. It swallows errors (but reports them) to make
* sure that when we dispose of an object, an error in disposing of one owned part doesn't stop
* the disposal of the other parts.
*/
function disposeHelper(owner, disposer, obj) {
try {
disposer.call(owner, obj);
} catch (e) {
console.error("While disposing %s, error disposing %s: %s",
describe(owner), describe(obj), e);
}
}
/**
* Helper for reporting errors during disposal. Try to report the type of the object.
*/
function describe(obj) {
return (obj && obj.constructor && obj.constructor.name ? obj.constructor.name :
util.inspect(obj, {depth: 1}));
}
/**
* Internal helper that implements the default disposal for an object. It just supports removing
* DOM nodes with ko.removeNode, and calling dispose() on any part that has a `dispose` method.
*/
function defaultDisposer(obj) {
if (obj instanceof G.Node) {
// This does both knockout- and jquery-related cleaning, and removes the node from the DOM.
ko.removeNode(obj);
} else if (typeof obj.dispose === 'function') {
obj.dispose();
} else {
throw new Error("Object has no 'dispose' method");
}
}
/**
* Removes all children of the given node, and all knockout bindings. You can use it as
* this.autoDisposeWith(dispose.emptyNode, node);
*/
function emptyNode(node) {
ko.virtualElements.emptyNode(node);
ko.cleanNode(node);
}
exports.emptyNode = emptyNode;