Skip to content

Commit

Permalink
Merge branch 'dispose': major refactor, including API changes, for di…
Browse files Browse the repository at this point in the history
…sposal and dom components.
  • Loading branch information
dsagal committed Feb 5, 2018
2 parents c66881a + 1d433ea commit 9bb3a55
Show file tree
Hide file tree
Showing 10 changed files with 406 additions and 297 deletions.
7 changes: 4 additions & 3 deletions demo/celsius_grain/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,19 +35,20 @@ function TemperatureInput(temperature, scaleName) {
}

class Calculator extends dom.Component {
render() {
constructor() {
super();
this._temp = observable('');
this._scale = observable('c');

const celsius = this.autoDispose(this._makeScaleTemp('c', toCelsius));
const fahrenheit = this.autoDispose(this._makeScaleTemp('f', toFahrenheit));
const celsiusValue = this.autoDispose(computed(use => parseFloat(use(celsius))));

return dom('div',
this.setContent(dom('div',
TemperatureInput(celsius, 'Celsius'),
TemperatureInput(fahrenheit, 'Fahrenheit'),
BoilingVerdict(celsiusValue)
);
));
}

_makeScaleTemp(toScale, converter) {
Expand Down
149 changes: 88 additions & 61 deletions lib/_domComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,94 +3,125 @@
* createElem() and create().
*/

import {domDispose, onDisposeElem} from './_domDispose';
import {DomElementArg, DomElementMethod, update} from './_domImpl';
import {Disposable} from './dispose';
import {onDisposeElem} from './_domDispose';
import {DomElementMethod, update} from './_domImpl';
import {replaceContent} from './_domMethods';
import {Disposable, IDisposableOwner} from './dispose';

// Use the browser globals in a way that allows replacing them with mocks in tests.
import {G} from './browserGlobals';

// Static type of a class that inherits Component.
export interface IComponentClassType<T> {
new (...args: any[]): T;
create(owner: Element|IDisposableOwner|null, ...args: any[]): T;
}

/**
* Helper that takes ownership of a component by mounting it to a parent element.
*/
class DomOwner implements IDisposableOwner {
constructor(private _parentElem: Element) {}
public autoDispose(comp: Component): void { comp.mount(this._parentElem); }
}

/**
* A UI component should extend this base class and implement `render()`. Compared to a simple
* function returning DOM (a "functional" component), a "class" component makes it easier to
* organize code into methods.
* A UI component should extend this base class and implement a constructor that creates some DOM
* and calls this.setContent() with it. Compared to a simple function returning DOM (a
* "functional" component), a "class" component makes it easier to organize code into methods.
*
* In addition, a "class" component may be disposed to remove it from the DOM, although this is
* uncommon since a UI component is normally owned by its containing DOM.
*/
export class Component extends Disposable {
private _markerPre: Node|undefined;
private _markerPost: Node|undefined;

export abstract class Component extends Disposable {
/**
* Components must extend this class and implement a `render()` method, which is called at
* construction with constructor arguments, and should return DOM for the component.
*
* It is recommended that any constructor work is done in this method.
* Create a component using Foo.create(owner, ...args) similarly to creating any other
* Disposable object. The difference is that `owner` may be a DOM Element, and the content set
* by the constructor's setContent() call will be appended to and owned by that owner element.
*
* render() may return any type of value that's accepted by dom() as an argument, including a
* DOM element, a string, null, or an array. The returned DOM is automatically owned by the
* component, so do not wrap it in `this.autoDispose()`.
* If the owner is not an Element, works like a regular Disposable. To add such a component to
* DOM, use the mount() method.
*/
public render(...args: any[]): DomElementArg {
throw new Error("Not implemented");
// TODO add typescript overloads for strict argument checks.
public static create<T extends Component>(this: IComponentClassType<T>,
owner: Element|IDisposableOwner|null, ...args: any[]): T {
const _owner: IDisposableOwner|null = owner instanceof G.Element ? new DomOwner(owner) : owner;
return Disposable.create.call(this, _owner, ...args);
}

/**
* This is not intended to be called directly or overridden. Instead, implement render().
*/
protected create(elem: Element, ...args: any[]) {
const content: DomElementArg = this.render(...args);
private _markerPre: Node = G.document.createComment('A');
private _markerPost: Node = G.document.createComment('B');
private _contentToMount: Node|null = null;

this._markerPre = G.document.createComment('A');
this._markerPost = G.document.createComment('B');
constructor() {
super();

// If the containing DOM is disposed, it will dispose all of our DOM (included among children
// of the containing DOM). Let it also dispose this Component when it gets to _markerPost.
// Since _unmount() is unnecessary here, we skip its work by unseting _markerPre/_markerPost.
onDisposeElem(this._markerPost, () => {
this._markerPre = this._markerPost = undefined;
this._markerPre = this._markerPost = undefined!;
this.dispose();
});

// When the component is disposed, unmount the DOM we created (i.e. dispose and remove).
// Except that we skip this as unnecessary when the disposal is triggered by containing DOM.
this.autoDisposeWith(this._unmount, this);
this.onDispose(this._unmount, this);
}

// Insert the result of render() into the given parent element.
update(elem, this._markerPre, content, this._markerPost);
/**
* Inserts the content of this component into a parent DOM element.
*/
public mount(elem: Element): void {
// Insert the result of setContent() into the given parent element. Note that mount() must
// only ever be called once. It is normally called as part of .create().
if (!this._markerPost) { throw new Error('Component mount() called when already disposed'); }
if (this._markerPost.parentNode) { throw new Error('Component mount() called twice'); }
update(elem, this._markerPre, this._contentToMount, this._markerPost);
this._contentToMount = null;
}

/**
* Detaches and disposes the DOM created and attached in _mount().
* Components should call setContent() with their DOM content, typically in the constructor. If
* called outside the constructor, setContent() will replace previously set DOM. It accepts any
* DOM Node; use dom.frag() to insert multiple nodes together.
*/
private _unmount() {
// Dispose the owned content, and remove it from the DOM.
if (this._markerPre && this._markerPre.parentNode) {
let next;
const elem = this._markerPre.parentNode;
for (let n = this._markerPre.nextSibling; n && n !== this._markerPost; n = next) {
next = n.nextSibling;
domDispose(n);
elem.removeChild(n);
protected setContent(content: Node): void {
if (this._markerPost) {
if (this._markerPost.parentNode) {
// Component is already mounted. Replace previous content.
replaceContent(this._markerPre!, this._markerPost, content);
} else {
// Component is created but not yet mounted. Save the content for the mount() call.
this._contentToMount = content;
}
elem.removeChild(this._markerPre);
elem.removeChild(this._markerPost!);
}
}
}

export type ComponentClassType = typeof Component;
/**
* Detaches and disposes the DOM created and attached in mount().
*/
private _unmount(): void {
// Dispose the owned content, and remove it from the DOM. The conditional skips the work when
// the unmounting is triggered by the disposal of the containing DOM.
if (this._markerPost && this._markerPost.parentNode) {
const elem = this._markerPost.parentNode;
replaceContent(this._markerPre!, this._markerPost, null);
elem.removeChild(this._markerPre!);
elem.removeChild(this._markerPost);
}
this._markerPre = this._markerPost = undefined!;
}
}

/**
* Construct and insert a UI component into the given DOM element. The component must extend
* dom.Component(...), and must implement a `render(...)` method which should do any constructor
* work and return DOM. DOM may be any type value accepted by dom() as an argument, including a
* DOM element, string, null, or array. The returned DOM is automatically owned by the component.
* dom.Component, and should build DOM and call setContent(DOM) in the constructor. DOM may be any
* Node. Use dom.frag() to insert multiple nodes together.
*
* Logically, the parent `elem` owns the created component, and the component owns the DOM
* returned by its render() method. If the parent is disposed, so is the component and its DOM. If
* the component is somehow disposed directly, then its DOM is disposed and removed from `elem`.
* Logically, the parent `elem` owns the created component, and the component owns the DOM set by
* setContent(). If the parent is disposed, so is the component and its DOM. If the component is
* somehow disposed directly, then its DOM is disposed and removed from `elem`.
*
* Note the correct usage:
*
Expand All @@ -108,15 +139,11 @@ export type ComponentClassType = typeof Component;
* @param {Element} elem: The element to which to append the newly constructed component.
* @param {Class} ComponentClass: The component class to instantiate. It must extend
* dom.Component(...) and implement the render() method.
* @param {Objects} ...args: Arguments to the constructor which passes them to the render method.
* @param {Objects} ...args: Arguments to the Component's constructor.
*/
export function createElem(elem: Element, ComponentClass: ComponentClassType, ...args: any[]) {
// tslint:disable-next-line:no-unused-expression
new ComponentClass(elem, ...args);
}
export function create(ComponentClass: ComponentClassType, ...args: any[]): DomElementMethod {
// tslint:disable-next-line:no-unused-expression
return (elem) => { new ComponentClass(elem, ...args); };
// TODO add typescript overloads for strict argument checks.
export function create<T extends Component>(cls: IComponentClassType<T>, ...args: any[]): DomElementMethod {
return (elem) => { cls.create(elem, ...args); };
}

/**
Expand All @@ -131,10 +158,10 @@ export function create(ComponentClass: ComponentClassType, ...args: any[]): DomE
* soon as it's created, so an exception in the init function or later among dom()'s arguments
* will trigger a cleanup.
*/
export function createInit(ComponentClass: ComponentClassType, ...args: any[]): DomElementMethod {
export function createInit<T>(cls: IComponentClassType<T>, ...args: any[]): DomElementMethod {
return (elem) => {
const initFunc: (c: Component) => void = args.pop();
const c = new ComponentClass(elem, ...args);
const initFunc: (c: T) => void = args.pop();
const c = cls.create(elem, ...args);
initFunc(c);
};
}
20 changes: 13 additions & 7 deletions lib/_domMethods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,17 +207,23 @@ export function getData(elem: Node, key: string) {
return obj && obj[key];
}

// Helper for domComputed(); replace content between markerPre and markerPost with the given DOM
// content, running disposers if any on the removed content.
function _replaceContent(elem: Node, markerPre: Node, markerPost: Node, content: DomArg): void {
if (markerPre.parentNode === elem) {
/**
* Replaces the content between nodeBefore and nodeAfter, which should be two siblings within the
* same parent node. New content may be anything allowed as an argument to dom(), including null
* to insert nothing. Runs disposers, if any, on all removed content.
*/
export function replaceContent(nodeBefore: Node, nodeAfter: Node, content: DomArg): void {
const elem = nodeBefore.parentNode;
if (elem) {
let next;
for (let n = markerPre.nextSibling; n && n !== markerPost; n = next) {
for (let n = nodeBefore.nextSibling; n && n !== nodeAfter; n = next) {
next = n.nextSibling;
domDispose(n);
elem.removeChild(n);
}
elem.insertBefore(frag(content), markerPost);
if (content) {
elem.insertBefore(content instanceof G.Node ? content : frag(content), nodeAfter);
}
}
}

Expand Down Expand Up @@ -269,7 +275,7 @@ export function domComputed<T>(valueObs: BindableValue<T>, contentFunc?: (val: T
elem.appendChild(markerPre);
elem.appendChild(markerPost);
_subscribe(elem, valueObs,
(value) => _replaceContent(elem, markerPre, markerPost, _contentFunc(value)));
(value) => replaceContent(markerPre, markerPost, _contentFunc(value)));
};
}

Expand Down
Loading

0 comments on commit 9bb3a55

Please sign in to comment.