Skip to content

Commit

Permalink
Introduce Commands API
Browse files Browse the repository at this point in the history
  • Loading branch information
aswitalski committed Jan 18, 2020
1 parent efca465 commit 39d6c94
Show file tree
Hide file tree
Showing 19 changed files with 444 additions and 313 deletions.
146 changes: 146 additions & 0 deletions COMMANDS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
## Commands API

Commands API is a part of the dedicated state management mechanism for Toolkit.
It allows to transition the Web Component's view model from one state to another with simple API calls.

The concept is based on reducer functions as Redux but is more focused on the API, code clarity (no boilerplate) and convenience for the client.

### All about the API

The implementation of Commands API is just a creation of a plain object declaring API methods. These methods take arbitrary domain-specific arguments and return a reducer function defining how given command call transforms the current state to the updated one.

```js
const API = {
setPersonalData(name, surname) {
return state => ({
...state,
name,
surname,
});
},
};
```

Once connected to a Web Component, the command can be issued from component methods:

```js
class FormComponent extends opr.Toolkit.WebComponent {

getCommands() {
return API;
}

onPersonalDataChange({name, surname}) {
this.commands.setPersonalData(name, surname);
}
}
```

Such call triggers the component state update and the DOM update, if necessary.

### Under the hood

Issuing the command causes the returned reducer function to be invoked on the current state of the component. The reducer also has access to the arguments the command was issued with. The result of that reducer call, the newly calculated state object, is then set on the component instance.

If the new state object differs from the previous one, the `render()` method is called on the component to calculate the new template and if that altered from the previously rendered one, both the virtual and actual DOM will be patched to reflect the changes.

If the reducer function returns the same object or it is equal to the previous one (deep comparison) no action is taken.

### Immutable data

Since the state comparison checks the deep equality of the objects, all the used data needs to be immutable. Modifying the existing state object may result in unpredictable behaviour. To avoid the incidental modifications Toolkit deeply freezes the state object in the `debug` mode. Any arbitrary modification will result in errors being thrown, to detect programmer's mistakes as early as possible.

### Execution

Commands are usually issued on either user actions or underlying data changes. In such circumstances the invocation of the command is synchronous, once it's completed, both virtual and the actual DOM are updated.

They may also be called from the component's lifecycle methods, in the middle of the state transition. In such case all invocations are queued and performed atomically once the original cycle has completed. Toolkit also detects if such cycles do not cause infinite update loops.

### Example

```js
const StackCommands = {
push(item) {
return state => ({
items: [...state.items, item],
});
},
pop() {
return state => {
const items = [...state.items];
const removed = items.shift();
return {
items,
removed,
};
};
},
};

export default class Stack extends opr.Toolkit.WebComponent {

getInitialState() {
return {
items: [],
};
}

getCommands() {
return StackCommands;
}

pushItem(item) {
const item = parseInt(256 * Math.random());
console.log('Pushing item:', item);
this.commands.push(item);
}

popItem() {
const state = this.commands.pop();
const item = state.removed;
console.log('Removed item:', item);
}
};
```

### Using multiple APIs

Web Components can use multiple Command APIs at the same time.
The `getCommands()` method may return an array containing many command objects.

```js
class FormComponent extends opr.Toolkit.WebComponent {

getCommands() {
return [FooCommands, BarCommands];
}
}
```

In such case the specified APIs are checked for any potential name conflicts.
If none are detected, the component will be able to utilize all the defined methods.

When responsibilies are divided correctly and command names are descriptive enough, conflicts should happen very rarely, if ever.

### Testing

Since all the state management logic is within the API object, it's extremally easy to debug and unit test it.

```js
describe('pushes the item to the stack', () => {

// given
const item = 10;
const state = {
items: [1, 2, 3],
};

// when
const reducer = StackCommands.push(item);
const result = reducer(state);

// then
assert(result !== state);
assert.deepEqual([1, 2, 3, 10], result);
});
```
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,9 @@ There is no need to traverse and clone complex data structures in order to amend
By design Web Components are small, single-purpose nestable apps. Their state is based on the props received from the parent.
They can fetch the additional data asynchronously and handle the data changes themselves. The ancestor Web Components are not involved when not interested in that data.

Web Components use reducer functions to make a transition between one state and another.
There are two built-in commands, `update` and `setState`, allowing to override the properties and replace the state respectively.
Web Components use commands to make a transition between one state and another.

Read more about the [Commands API](COMMANDS.md).

## Templating

Expand Down
3 changes: 1 addition & 2 deletions build.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,6 @@ const Reducers = normalizeModule('core/reducers');
const Renderer = normalizeModule('core/renderer');
const Sandbox = normalizeModule('core/sandbox');
const Service = normalizeModule('core/service');
const State = normalizeModule('core/state');
const Template = normalizeModule('core/template');
const VirtualDOM = normalizeModule('core/virtual-dom');
const utils = normalizeModule('core/utils');
Expand All @@ -69,7 +68,7 @@ const Release = loadModule('release');
let release = merge(
Loader, Browser, Dispatcher, Nodes, Diff, Lifecycle, Patch,
Description, Plugins, Reconciler, Renderer, Sandbox,
Service, State, Reducers, Template, VirtualDOM, utils,
Service, Reducers, Template, VirtualDOM, utils,
Toolkit, Release,
).replace(/\n\n\n/g, '\n\n');

Expand Down
141 changes: 99 additions & 42 deletions src/core/dispatcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,77 +15,138 @@ limitations under the License.
*/

{

const Mode = {
QUEUE: Symbol('queue-commands'),
EXECUTE: Symbol('execute-commands'),
IGNORE: Symbol('ignore-commands'),
};

const coreAPI = {
setState(state) {
return () => state;
},
update(overrides) {
return state => ({
...state,
...overrides,
});
},
}

class Command {

constructor(name, args, method) {
this.name = name;
this.args = args;
this.method = method;
}

invoke(state) {
return this.method(...this.args)(state);
}
}

const createCommandsAPI = (...apis) => {
let commandsAPI = {};
for (const api of [coreAPI, ...apis]) {
const defined = Object.keys(commandsAPI);
const incoming = Object.keys(api);
const overriden = incoming.find(key => defined.includes(key));
if (overriden) {
throw new Error(`The "${overriden}" command is already defined!`)
}
Object.assign(commandsAPI, api);
}
return commandsAPI;
};

class Dispatcher {

static create(root) {
return new Dispatcher(root);
queueIncoming() {
this.mode = Mode.QUEUE;
}

executeIncoming() {
this.mode = Mode.EXECUTE;
}

ignoreIncoming() {
this.mode = Mode.IGNORE;
}

execute(command, root) {
const prevState = root.state;
const nextState = command.invoke(prevState);
root.state = nextState;
opr.Toolkit.Renderer.update(root, prevState, nextState, command);
}

constructor(root) {
const state = root.state;
const commands = state.reducer.commands;

this.names = Object.keys(commands);
this.mode = Mode.EXECUTE;
this.queue = [];
this.commands = {};

this.execute = command => {
const prevState = state.current;
const nextState = state.reducer(prevState, command);
opr.Toolkit.Renderer.update(root, prevState, nextState, command);
};
let createCommand;

this.queueIncoming = () => {
this.mode = Mode.QUEUE;
}
const ComponentClass = root.constructor;
if (typeof ComponentClass.getCommands === 'function') {

this.executeIncoming = () => {
this.mode = Mode.EXECUTE;
};
const customAPI = ComponentClass.getCommands();
if (!customAPI) {
throw new Error('No API returned in getCommands() method');
}
const customAPIs = Array.isArray(customAPI) ? customAPI : [customAPI];
const api = createCommandsAPI(...customAPIs);

this.ignoreIncoming = () => {
this.mode = Mode.IGNORE;
};
this.names = Object.keys(api);
createCommand = (name, args) => new Command(name, args, api[name]);

} else {

const reducers = root.getReducers ? root.getReducers() : [];
const combinedReducer = opr.Toolkit.Reducers.combine(...reducers);
const api = combinedReducer.commands;

this.names = Object.keys(api);
createCommand = (name, args) => new Command(
name, args,
() => state => combinedReducer(state, api[name](...args)));
}

this.mode = Mode.EXECUTE;
let level = 0;

for (const name of this.names) {
this[name] = async (...args) => {
if (this.mode === Mode.IGNORE) {
level = 0;
return false;
}
this.commands[name] = (...args) => {

const command = createCommand(name, args);

if (this.mode === Mode.QUEUE) {
let done;
const donePromise = new Promise(resolve => {
done = resolve;
});
this.queue.push({
name,
args,
done,
command.done = resolve;
});
this.queue.push(command);
return donePromise;
}
const command = commands[name](...args);
this.execute(command);

if (this.mode === Mode.IGNORE) {
level = 0;
return false;
}

this.execute(command, root);

if (this.queue.length) {
level = level + 1;
if (level >= 3) {
throw new Error(
'Too many cycles updating state in lifecycle methods!');
}
const calls = [...this.queue];
setTimeout(() => {
for (const {name, args, done} of calls) {
this[name](...args);
done();
for (const command of this.queue) {
this.execute(command, root);
command.done();
}
});
this.queue.length = 0;
Expand All @@ -96,10 +157,6 @@ limitations under the License.
};
}
}

destroy() {
this.execute = opr.Toolkit.noop;
}
}

module.exports = Dispatcher;
Expand Down
Loading

0 comments on commit 39d6c94

Please sign in to comment.