Skip to content

Commit

Permalink
feat(Controller): add ability to handle binary like values
Browse files Browse the repository at this point in the history
  • Loading branch information
christianalfoni committed Dec 1, 2016
1 parent 2d565fc commit 078114c
Show file tree
Hide file tree
Showing 14 changed files with 148 additions and 83 deletions.
39 changes: 19 additions & 20 deletions packages/cerebral/src/Controller.js
Expand Up @@ -2,7 +2,7 @@ import DependencyStore from './DependencyStore'
import FunctionTree from 'function-tree'
import Module from './Module'
import Model from './Model'
import {ensurePath, isDeveloping, throwError, isSerializable, verifyStrictRender} from './utils'
import {ensurePath, isDeveloping, throwError, isSerializable, verifyStrictRender, forceSerializable, isObject} from './utils'
import VerifyInputProvider from './providers/VerifyInput'
import StateProvider from './providers/State'
import DebuggerProvider from './providers/Debugger'
Expand Down Expand Up @@ -144,32 +144,31 @@ class Controller extends EventEmitter {
getState (path) {
return this.model.get(ensurePath(path))
}
/*
Checks if payload is serializable
*/
isSerializablePayload (payload) {
if (!isSerializable(payload)) {
return false
}

return Object.keys(payload).reduce((isSerializablePayload, key) => {
if (!isSerializable(payload[key])) {
return false
}

return isSerializablePayload
}, true)
}
/*
Uses function tree to run the array and optional
payload passed in. The payload will be checkd
*/
runSignal (name, signal, payload = {}) {
if (this.devtools && this.devtools.enforceSerializable && !this.isSerializablePayload(payload)) {
throwError(`You passed an invalid payload to signal "${name}". Only serializable payloads can be passed to a signal`)
if (this.devtools && (!isObject(payload) || !isSerializable(payload))) {
console.warn(`You passed an invalid payload to signal "${name}". Only serializable payloads can be passed to a signal. The payload has been ignored. This is the object:`, payload)
payload = {}
}

if (this.devtools) {
payload = Object.keys(payload).reduce((currentPayload, key) => {
if (!isSerializable(payload, this.devtools.allowedTypes)) {
console.warn(`You passed an invalid payload to signal "${name}", on key "${key}". Only serializable values like Object, Array, String, Number and Boolean can be passed in. Also these special value types:`, this.devtools.allowedTypes)

return currentPayload
}

currentPayload[key] = forceSerializable(payload[key])

return currentPayload
}, {})
}

this.runTree(name, signal, payload || {})
this.runTree(name, signal, payload)
}
/*
Returns a function which binds the name/path of signal,
Expand Down
24 changes: 11 additions & 13 deletions packages/cerebral/src/Controller.test.js
Expand Up @@ -109,29 +109,27 @@ describe('Controller', () => {
})
assert.equal(controller.getModel(), controller.model)
})
it('should throw when passing in unserializable payload property to signal', () => {
it('should create JSON stringify friendly value of unserializable payload property to signal', () => {
const controller = new Controller({
devtools: {enforceSerializable: true, init () {}},
devtools: {init () {}},
signals: {
foo: []
foo: [({input}) => assert.equal(JSON.stringify(input), '{"date":"[Date]"}')]
}
})
assert.throws(() => {
controller.getSignal('foo')({
date: new Date()
})
controller.getSignal('foo')({
date: new Date()
})
})
it('should throw when passing in unserializable payload to signal', () => {
it('should ignore when passing in unserializable payload to signal', () => {
const controller = new Controller({
devtools: {enforceSerializable: true, init () {}},
devtools: {init () {}},
signals: {
foo: []
foo: [
({input}) => assert.deepEqual(input, {})
]
}
})
assert.throws(() => {
controller.getSignal('foo')(new Date())
})
controller.getSignal('foo')(new Date())
})
it('should throw when pointing to a non existing signal', () => {
const controller = new Controller({})
Expand Down
41 changes: 21 additions & 20 deletions packages/cerebral/src/Model.js
@@ -1,14 +1,10 @@
import {isObject, isSerializable, throwError} from './utils'
import {isObject, isSerializable, throwError, forceSerializable} from './utils'

class Model {
constructor (initialState = {}, devtools = null) {
if (devtools) {
this.preventExternalMutations = devtools.preventExternalMutations
this.enforceSerializable = Boolean(devtools.enforceSerializable)
} else {
this.preventExternalMutations = false
this.enforceSerializable = false
}
this.devtools = devtools
this.preventExternalMutations = devtools ? devtools.preventExternalMutations : false

this.state = (
this.preventExternalMutations
? this.freezeObject(initialState)
Expand Down Expand Up @@ -133,15 +129,20 @@ class Model {
/*
Checks if value is serializable, if turned on
*/
checkValue (value, path) {
if (this.enforceSerializable && !isSerializable(value)) {
throwError(`You are passing a non serializable value on ${path.join('.')}`)
verifyValue (value, path) {
if (
this.devtools &&
!isSerializable(value, this.devtools.allowedTypes)
) {
throwError(`You are passing a non serializable value into the state tree on path ${path.join('.')}`)
}

return forceSerializable(value)
}
checkValues (values, path) {
if (this.enforceSerializable) {
verifyValues (values, path) {
if (this.devtools) {
values.forEach((value) => {
this.checkValue(value, path)
this.verifyValue(value, path)
})
}
}
Expand All @@ -151,19 +152,19 @@ class Model {
}, this.state)
}
set (path, value) {
this.checkValue(value, path)
this.verifyValue(value, path)
this.updateIn(path, () => {
return value
})
}
push (path, value) {
this.checkValue(value, path)
this.verifyValue(value, path)
this.updateIn(path, (array) => {
return array.concat(value)
})
}
merge (path, ...values) {
this.checkValues(values, path)
this.verifyValues(values, path)

// Create object if no present
if (!this.get(path)) {
Expand Down Expand Up @@ -192,15 +193,15 @@ class Model {
})
}
unshift (path, value) {
this.checkValue(value, path)
this.verifyValue(value, path)
this.updateIn(path, (array) => {
array.unshift(value)

return array
})
}
splice (path, ...args) {
this.checkValues(args, path)
this.verifyValues(args, path)
this.updateIn(path, (array) => {
array.splice(...args)

Expand All @@ -218,7 +219,7 @@ class Model {
})
}
concat (path, value) {
this.checkValue(value, path)
this.verifyValue(value, path)
this.updateIn(path, (array) => {
return array.concat(value)
})
Expand Down
16 changes: 12 additions & 4 deletions packages/cerebral/src/Model.test.js
Expand Up @@ -257,22 +257,30 @@ describe('Model', () => {
})
})
})
describe('Enforce serializable', () => {
describe('Serializable', () => {
it('should throw error if value inserted is not serializable', () => {
const model = new Model({
foo: 'bar'
}, {enforceSerializable: true})
}, {})
assert.throws(() => {
model.set(['foo'], new Date())
})
})
it('should throw error if value inserted is not serializable', () => {
it('should NOT throw error if value inserted is serializable', () => {
const model = new Model({
foo: 'bar'
}, {enforceSerializable: true})
}, {})
assert.doesNotThrow(() => {
model.set(['foo'], [])
})
})
it('should NOT throw error if passing allowed type in devtools', () => {
const model = new Model({
foo: 'bar'
}, {allowedTypes: [Date]})
assert.doesNotThrow(() => {
model.set(['foo'], new Date())
})
})
})
})
8 changes: 4 additions & 4 deletions packages/cerebral/src/devtools/index.js
@@ -1,4 +1,4 @@
/* global CustomEvent WebSocket */
/* global CustomEvent WebSocket File FileList Blob */
import {debounce} from '../utils'
const PLACEHOLDER_INITIAL_MODEL = 'PLACEHOLDER_INITIAL_MODEL'
const PLACEHOLDER_DEBUGGING_DATA = '$$DEBUGGING_DATA$$'
Expand All @@ -13,22 +13,21 @@ class Devtools {
constructor (options = {
storeMutations: true,
preventExternalMutations: true,
enforceSerializable: true,
verifyStrictRender: true,
preventInputPropReplacement: false,
bigComponentsWarning: {
state: 5,
signals: 5
},
remoteDebugger: null,
multipleApps: true
multipleApps: true,
allowedTypes: []
}) {
this.VERSION = VERSION
this.debuggerComponentsMap = {}
this.debuggerComponentDetailsId = 1
this.storeMutations = typeof options.storeMutations === 'undefined' ? true : options.storeMutations
this.preventExternalMutations = typeof options.preventExternalMutations === 'undefined' ? true : options.preventExternalMutations
this.enforceSerializable = typeof options.enforceSerializable === 'undefined' ? true : options.enforceSerializable
this.verifyStrictRender = typeof options.verifyStrictRender === 'undefined' ? true : options.verifyStrictRender
this.preventInputPropReplacement = options.preventInputPropReplacement || false
this.bigComponentsWarning = options.bigComponentsWarning || {state: 5, signals: 5}
Expand All @@ -43,6 +42,7 @@ class Devtools {
this.originalRunTreeFunction = null
this.ws = null
this.isResettingDebugger = false
this.allowedTypes = [File, FileList, Blob].concat(options.allowedTypes || [])

this.sendInitial = this.sendInitial.bind(this)
this.sendComponentsMap = debounce(this.sendComponentsMap, 50)
Expand Down
48 changes: 38 additions & 10 deletions packages/cerebral/src/utils.js
Expand Up @@ -25,18 +25,30 @@ export function isObject (obj) {
return typeof obj === 'object' && obj !== null && !Array.isArray(obj)
}

export function isSerializable (value) {
export function isSerializable (value, additionalTypes = []) {
const validType = additionalTypes.reduce((currentValid, type) => {
if (currentValid || value instanceof type) {
return true
}

return currentValid
}, false)

if (
value !== undefined &&
(
isObject(value) &&
Object.prototype.toString.call(value) === '[object Object]' &&
value.constructor === Object
) ||
typeof value === 'number' ||
typeof value === 'string' ||
typeof value === 'boolean' ||
value === null ||
Array.isArray(value)
validType ||
(
isObject(value) &&
Object.prototype.toString.call(value) === '[object Object]' &&
value.constructor === Object
) ||
typeof value === 'number' ||
typeof value === 'string' ||
typeof value === 'boolean' ||
value === null ||
Array.isArray(value)
)
) {
return true
}
Expand Down Expand Up @@ -107,3 +119,19 @@ export function debounce (func, wait) {
}

export const noop = () => {}

export const forceSerializable = (value) => {
if (value && !isSerializable(value)) {
const name = value.constructor.name

try {
Object.defineProperty(value, 'toJSON', {
value () {
return `[${name}]`
}
})
} catch (e) {}
}

return value
}
17 changes: 14 additions & 3 deletions packages/docs/content/api/02_state.en.md
Expand Up @@ -3,7 +3,7 @@ title: State
---

## State
State can be defined at the top level in the controller and/or in each module. State is defined as plain JavaScript value types. Arrays, objects, strings, numbers and booleans. This means that the state is serializable. There are no classes or other abstractions around state. This makes it easier to reason about how state is translated into user interface, it can be stored on server/local storage and the debugger can now visualize all the state of the application.
State can be defined at the top level in the controller and/or in each module. State is defined as plain JavaScript value types. Objects, arrays, strings, numbers and booleans. This means that the state is serializable. There are no classes or other abstractions around state. This is an important choice in Cerebral that makes it possible to track changes to update the UI, store state on server/local storage and passing state information to the debugger.

```js
import {Controller} from 'cerebral'
Expand All @@ -19,15 +19,26 @@ const controller = Controller({
}
})
```
### Special values support
When building an application you often need to keep things like files and blobs in your state for further processing. Cerebral supports these kinds of values because they will never change, or changing them can be used with existing state API. This is the list of supported types:

- **File**
- **FilesList**
- **Blob**

If you want to force Cerebral to support other types as well, you can do that with a devtools option. This is perfectly okay, but remember all state changes has to be done though the state API.

### Get state
The only way to get state in your application is by connecting it to a component or grabbing it in an action.

```js
function someAction({state}) {
// Get all state
state.get()
const allState = state.get()
// Get by path
state.get('some.path')
const stateAtSomePath = state.get('some.path')
// Get computed state by passing in a computed
const computedState = state.compute(someComputed)
}
```

Expand Down
2 changes: 1 addition & 1 deletion packages/docs/content/in-depth/01_the-architecture.en.md
Expand Up @@ -47,7 +47,7 @@ With a single state tree we can point to parts of the state using paths (the fir
#### Optimized rendering
Cerebral does not look at the updates in your application as "value updates", but as "path updates". This allows Cerebral to make optimizations not possible in other frameworks:

1. There is no need for immutability in Cerebral because a change to a path means that any component depending on that path should render (no value comparison). In applications with large data structures immutability has a high cost.
1. There is no need for immutability in Cerebral because a change to a path means that any component depending on that path should render (no value comparison). In applications with large data structures immutability has a high cost. There is no need to hack objects and arrays to observe changes to them either. There is nothing special about the state you put into Cerebrals state tree

2. Since there is no value comparison in Cerebral it has a **strict render mode**. Traditionally if you change an item in an array, also the array itself has a change. This means that the component handling the array will render whenever an item needs to render. In large applications **strict render mode** will give you a lot more control of how your components should react to a state change.

Expand Down

0 comments on commit 078114c

Please sign in to comment.