Skip to content
This repository has been archived by the owner on Jul 30, 2018. It is now read-only.

Add onAttach and onDetach lifecycle hooks #771

Merged
merged 8 commits into from
Nov 15, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 31 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ widget-core is a library to create powerful, composable user interface widgets.
- [Advanced Concepts](#advanced-concepts)
- [Advanced Properties](#advanced-properties)
- [Registry](#registry)
- [Render Lifecycle Hooks](#render-lifecycle-hooks)
- [Decorator Lifecycle Hooks](#decorator-lifecycle-hooks)
- [Method Lifecycle Hooks](#method-lifecycle-hooks)
- [Containers](#containers--injectors)
- [Decorators](#decorators)
- [DOM Wrapper](#domwrapper)
Expand Down Expand Up @@ -708,7 +709,7 @@ class MyWidget extends WidgetBase {
}
```

### Lifecycle Hooks
### Decorator Lifecycle Hooks

Occasionally, in a mixin or a widget class, it my be required to provide logic that needs to be executed before properties are diffed using `beforeProperties` or either side of a widget's `render` call using `beforeRender` & `afterRender`.

Expand Down Expand Up @@ -772,6 +773,34 @@ class MyBaseClass extends WidgetBase<WidgetProperties> {
}
```

### Method Lifecycle Hooks

These lifecycle hooks are used by overriding methods in a widget class. Currently `onAttach` and `onDetach` are supported and provide callbacks for when a widget has been first attached and removed (destroyed) from the virtual dom.

#### onAttach

`onAttach` is called once when a widget is first rendered and attached to the DOM.

```ts
class MyClass extends WidgetBase {
onAttach() {
// do things when attached to the DOM
}
}
```

#### onDetach

`onDetach` is called when a widget is removed from the widget tree and therefore the DOM. `onDetach` is called recursively down the tree to ensure that even if a widget at the top of the tree is removed all the child widgets `onDetach` callbacks are fired.

```ts
class MyClass extends WidgetBase {
onDetach() {
// do things when removed from the DOM
}
}
```

### Containers & Injectors

There is built in support for side-loading/injecting values into sections of the widget tree and mapping them to a widget's properties. This is achieved by registering a `@dojo/widget-core/Injector` instance against a `registry` that is available to your application (i.e. set on the projector instance, `projector.setProperties({ registry })`).
Expand Down
14 changes: 14 additions & 0 deletions src/WidgetBase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,12 @@ export class WidgetBase<P = WidgetProperties, C extends DNode = DNode> implement
onElementUpdated: (element: HTMLElement, key: string) => {
this.onElementUpdated(element, key);
},
onAttach: (): void => {
this.onAttach();
},
onDetach: (): void => {
this.onDetach();
},
nodeHandler: this._nodeHandler,
registry: () => {
return this.registry;
Expand Down Expand Up @@ -169,6 +175,14 @@ export class WidgetBase<P = WidgetProperties, C extends DNode = DNode> implement
// Do nothing by default.
}

protected onAttach(): void {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any thought to this just being an optional method on WidgetBaseInterface?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also, you will essentially always do a noop on most widgets on attachment, and I don't know the performance impact, but invoking potentially hundreds of the same function seems 😕 where as testing for presence and only calling if present feels like it would be more efficient...

Copy link
Member Author

@agubler agubler Nov 14, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We do the same for the onElementCreated / onElementUpdated, these guys get called on requestIdleCallback so certainly should reduce/negate the performance impact - but I can look at the optional method.

Copy link
Member Author

@agubler agubler Nov 14, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Problem is I don't want to put it on the interface (it's protected), so I don't think we can type it as such.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, optional abstract isn't allowed and protected cannot be enforced on an interface... You ruin all my best ideas... 😝

// Do nothing by default.
}

protected onDetach(): void {
// Do nothing by default.
}

public get properties(): Readonly<P> & Readonly<WidgetProperties> {
return this._properties;
}
Expand Down
46 changes: 44 additions & 2 deletions src/vdom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ export type InternalDNode = InternalHNode | InternalWNode;
export interface WidgetData {
onElementCreated: Function;
onElementUpdated: Function;
onDetach: () => void;
onAttach: () => void;
parentInvalidate?: Function;
dirty: boolean;
registry: () => RegistryHandler;
Expand Down Expand Up @@ -467,6 +469,23 @@ function nodeAdded(dnode: InternalDNode, transitions: TransitionStrategy) {
}
}

function callOnDetach(dNodes: InternalDNode | InternalDNode[], parentInstance: DefaultWidgetBaseInterface): void {
dNodes = Array.isArray(dNodes) ? dNodes : [ dNodes ];
for (let i = 0; i < dNodes.length; i++) {
const dNode = dNodes[i];
if (isWNode(dNode)) {
callOnDetach(dNode.rendered, dNode.instance);
const instanceData = widgetInstanceMap.get(dNode.instance)!;
instanceData.onDetach();
}
else {
if (dNode.children) {
callOnDetach(dNode.children as InternalDNode[], parentInstance);
}
}
}
}

function nodeToRemove(dnode: InternalDNode, transitions: TransitionStrategy, projectionOptions: ProjectionOptions) {
if (isWNode(dnode)) {
const rendered = dnode.rendered || emptyArray;
Expand Down Expand Up @@ -569,7 +588,11 @@ function updateChildren(
const findOldIndex = findIndexOfChild(oldChildren, newChild, oldIndex + 1);
if (findOldIndex >= 0) {
for (i = oldIndex; i < findOldIndex; i++) {
nodeToRemove(oldChildren[i], transitions, projectionOptions);
const oldChild = oldChildren[i];
projectionOptions.afterRenderCallbacks.push(() => {
callOnDetach(oldChild, parentInstance);
});
nodeToRemove(oldChild, transitions, projectionOptions);
checkDistinguishable(oldChildren, i, domNode, 'removed');
}
textUpdated = updateDom(oldChildren[findOldIndex], newChild, projectionOptions, domNode, parentInstance) || textUpdated;
Expand Down Expand Up @@ -599,7 +622,11 @@ function updateChildren(
if (oldChildrenLength > oldIndex) {
// Remove child fragments
for (i = oldIndex; i < oldChildrenLength; i++) {
nodeToRemove(oldChildren[i], transitions, projectionOptions);
const oldChild = oldChildren[i];
projectionOptions.afterRenderCallbacks.push(() => {
callOnDetach(oldChild, parentInstance);
});
nodeToRemove(oldChild, transitions, projectionOptions);
checkDistinguishable(oldChildren, i, domNode, 'removed');
}
}
Expand Down Expand Up @@ -697,6 +724,9 @@ function createDom(
addChildren(parentNode, filteredRendered, projectionOptions, instance, insertBefore, childNodes);
}
instanceData.nodeHandler.addRoot();
projectionOptions.afterRenderCallbacks.push(() => {
instanceData.onAttach();
});
}
else {
if (projectionOptions.merge && projectionOptions.mergeElement !== undefined) {
Expand Down Expand Up @@ -906,6 +936,9 @@ export const dom = {
addChildren(rootNode, decoratedNode, finalProjectorOptions, instance, undefined);
const instanceData = widgetInstanceMap.get(instance)!;
instanceData.nodeHandler.addRoot();
finalProjectorOptions.afterRenderCallbacks.push(() => {
instanceData.onAttach();
});
runDeferredRenderCallbacks(finalProjectorOptions);
runAfterRenderCallbacks(finalProjectorOptions);
return createProjection(decoratedNode, instance, finalProjectorOptions);
Expand All @@ -917,6 +950,9 @@ export const dom = {
addChildren(parentNode, decoratedNode, finalProjectorOptions, instance, undefined);
const instanceData = widgetInstanceMap.get(instance)!;
instanceData.nodeHandler.addRoot();
finalProjectorOptions.afterRenderCallbacks.push(() => {
instanceData.onAttach();
});
runDeferredRenderCallbacks(finalProjectorOptions);
runAfterRenderCallbacks(finalProjectorOptions);
return createProjection(decoratedNode, instance, finalProjectorOptions);
Expand All @@ -934,6 +970,9 @@ export const dom = {
createDom(decoratedNode, finalProjectorOptions.rootNode, undefined, finalProjectorOptions, instance);
const instanceData = widgetInstanceMap.get(instance)!;
instanceData.nodeHandler.addRoot();
finalProjectorOptions.afterRenderCallbacks.push(() => {
instanceData.onAttach();
});
runDeferredRenderCallbacks(finalProjectorOptions);
runAfterRenderCallbacks(finalProjectorOptions);
return createProjection(decoratedNode, instance, finalProjectorOptions);
Expand All @@ -948,6 +987,9 @@ export const dom = {
createDom(decoratedNode, element.parentNode!, element, finalProjectorOptions, instance);
const instanceData = widgetInstanceMap.get(instance)!;
instanceData.nodeHandler.addRoot();
finalProjectorOptions.afterRenderCallbacks.push(() => {
instanceData.onAttach();
});
runDeferredRenderCallbacks(finalProjectorOptions);
runAfterRenderCallbacks(finalProjectorOptions);
element.parentNode!.removeChild(element);
Expand Down
145 changes: 144 additions & 1 deletion tests/unit/vdom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ const projectorStub: any = {
addRoot: stub()
},
onElementCreated: stub(),
onElementUpdated: stub()
onElementUpdated: stub(),
onAttach: stub(),
onDetach: stub()
};

widgetInstanceMap.set(projectorStub, projectorStub);
Expand Down Expand Up @@ -57,6 +59,10 @@ describe('vdom', () => {
beforeEach(() => {
projectorStub.nodeHandler.add.reset();
projectorStub.nodeHandler.addRoot.reset();
projectorStub.onElementCreated.reset();
projectorStub.onElementUpdated.reset();
projectorStub.onAttach.reset();
projectorStub.onDetach.reset();
consoleStub = stub(console, 'warn');
resolvers.stub();
});
Expand Down Expand Up @@ -777,6 +783,143 @@ describe('vdom', () => {
assert.strictEqual(barCreatedCount, 3);
});

it('calls onAttach when widget is rendered', () => {
let onAttachCallCount = 0;
class Foo extends WidgetBase {
onAttach() {
onAttachCallCount++;
}
}
const widget = new Foo();
const projection = dom.create(widget.__render__(), widget);
resolvers.resolve();
assert.strictEqual(onAttachCallCount, 1);
widget.invalidate();
projection.update(widget.__render__());
resolvers.resolve();
assert.strictEqual(onAttachCallCount, 1);
});

it('calls onDetach when widget is removed', () => {
let fooAttachCount = 0;
let fooDetachCount = 0;
let barAttachCount = 0;
let barDetachCount = 0;
let bazAttachCount = 0;
let bazDetachCount = 0;
let quxAttachCount = 0;
let quxDetachCount = 0;

class Qux extends WidgetBase {
onAttach() {
quxAttachCount++;
}

onDetach() {
quxDetachCount++;
}
}

class Foo extends WidgetBase {
onAttach() {
fooAttachCount++;
}

onDetach() {
fooDetachCount++;
}

render() {
return [
w(Qux, {}),
v('div', [
w(Qux, {})
])
];
}
}

class Bar extends WidgetBase {
onAttach() {
barAttachCount++;
}

onDetach() {
barDetachCount++;
}
}

class FooBar extends WidgetBase {

}

class Baz extends WidgetBase {
private _foo = false;

onAttach() {
bazAttachCount++;
}

onDetach() {
bazDetachCount++;
}

render() {
this._foo = !this._foo;
return v('div', [
w(FooBar, {}),
this._foo ? w(Foo, {}) : null,
w(FooBar, {}),
this._foo ? w(Foo, {}) : w(Bar, {})
]);
}
}
const widget = new Baz();
const projection = dom.create(widget.__render__(), widget);
resolvers.resolve();
assert.strictEqual(bazAttachCount, 1);
assert.strictEqual(bazDetachCount, 0);
assert.strictEqual(fooAttachCount, 2);
assert.strictEqual(fooDetachCount, 0);
assert.strictEqual(barAttachCount, 0);
assert.strictEqual(barDetachCount, 0);
assert.strictEqual(quxAttachCount, 4);
assert.strictEqual(quxDetachCount, 0);
widget.invalidate();
projection.update(widget.__render__());
resolvers.resolve();
assert.strictEqual(bazAttachCount, 1);
assert.strictEqual(bazDetachCount, 0);
assert.strictEqual(fooAttachCount, 2);
assert.strictEqual(fooDetachCount, 2);
assert.strictEqual(barAttachCount, 1);
assert.strictEqual(barDetachCount, 0);
assert.strictEqual(quxAttachCount, 4);
assert.strictEqual(quxDetachCount, 4);
widget.invalidate();
projection.update(widget.__render__());
resolvers.resolve();
assert.strictEqual(bazAttachCount, 1);
assert.strictEqual(bazDetachCount, 0);
assert.strictEqual(fooAttachCount, 4);
assert.strictEqual(fooDetachCount, 2);
assert.strictEqual(barAttachCount, 1);
assert.strictEqual(barDetachCount, 1);
assert.strictEqual(quxAttachCount, 8);
assert.strictEqual(quxDetachCount, 4);
widget.invalidate();
projection.update(widget.__render__());
resolvers.resolve();
assert.strictEqual(bazAttachCount, 1);
assert.strictEqual(bazDetachCount, 0);
assert.strictEqual(fooAttachCount, 4);
assert.strictEqual(fooDetachCount, 4);
assert.strictEqual(barAttachCount, 2);
assert.strictEqual(barDetachCount, 1);
assert.strictEqual(quxAttachCount, 8);
assert.strictEqual(quxDetachCount, 8);
});

it('remove elements for embedded WNodes', () => {
class Foo extends WidgetBase {
render() {
Expand Down