diff --git a/component/cache.js b/component/cache.js
new file mode 100644
index 00000000..101acc0d
--- /dev/null
+++ b/component/cache.js
@@ -0,0 +1,41 @@
+var assert = require('assert')
+var LRU = require('nanolru')
+
+module.exports = ChooComponentCache
+
+function ChooComponentCache (state, emit, lru) {
+ assert.ok(this instanceof ChooComponentCache, 'ChooComponentCache should be created with `new`')
+
+ assert.equal(typeof state, 'object', 'ChooComponentCache: state should be type object')
+ assert.equal(typeof emit, 'function', 'ChooComponentCache: emit should be type function')
+
+ if (typeof lru === 'number') this.cache = new LRU(lru)
+ else this.cache = lru || new LRU(100)
+ this.state = state
+ this.emit = emit
+}
+
+// Get & create component instances.
+ChooComponentCache.prototype.render = function (Component, id) {
+ assert.equal(typeof Component, 'function', 'ChooComponentCache.render: Component should be type function')
+ assert.ok(typeof id === 'string' || typeof id === 'number', 'ChooComponentCache.render: id should be type string or type number')
+
+ var el = this.cache.get(id)
+ if (!el) {
+ var args = []
+ for (var i = 2, len = arguments.length; i < len; i++) {
+ args.push(arguments[i])
+ }
+ args.unshift(Component, id, this.state, this.emit)
+ el = newCall.apply(newCall, args)
+ this.cache.set(id, el)
+ }
+
+ return el
+}
+
+// Because you can't call `new` and `.apply()` at the same time. This is a mad
+// hack, but hey it works so we gonna go for it. Whoop.
+function newCall (Cls) {
+ return new (Cls.bind.apply(Cls, arguments)) // eslint-disable-line
+}
diff --git a/component/index.js b/component/index.js
new file mode 100644
index 00000000..b639a1bf
--- /dev/null
+++ b/component/index.js
@@ -0,0 +1 @@
+module.exports = require('nanocomponent')
diff --git a/example/components/footer/clear-button.js b/example/components/footer/clear-button.js
new file mode 100644
index 00000000..ba632404
--- /dev/null
+++ b/example/components/footer/clear-button.js
@@ -0,0 +1,15 @@
+var html = require('bel')
+
+module.exports = deleteCompleted
+
+function deleteCompleted (emit) {
+ return html`
+
+ `
+
+ function deleteAllCompleted () {
+ emit('todos:deleteCompleted')
+ }
+}
diff --git a/example/components/footer/filter-button.js b/example/components/footer/filter-button.js
new file mode 100644
index 00000000..f37003b8
--- /dev/null
+++ b/example/components/footer/filter-button.js
@@ -0,0 +1,18 @@
+var html = require('bel')
+
+module.exports = filterButton
+
+function filterButton (name, filter, currentFilter, emit) {
+ var filterClass = filter === currentFilter
+ ? 'selected'
+ : ''
+
+ var uri = '#' + name.toLowerCase()
+ if (uri === '#all') uri = '/'
+
+ return html`
+
+ ${name}
+
+ `
+}
diff --git a/example/components/footer/index.js b/example/components/footer/index.js
new file mode 100644
index 00000000..3645a803
--- /dev/null
+++ b/example/components/footer/index.js
@@ -0,0 +1,50 @@
+var Component = require('../../../component')
+var html = require('bel')
+
+var clearButton = require('./clear-button')
+var filterButton = require('./filter-button')
+
+module.exports = class Footer extends Component {
+ constructor (name, state, emit) {
+ super(name)
+ this.state = state
+ this.emit = emit
+
+ this.local = this.state.components.footer = {}
+ this.setState()
+ }
+
+ setState () {
+ this.local.rawTodos = this.state.todos.clock
+ this.local.rawHref = this.state.href
+
+ this.local.filter = this.state.href.replace(/^\//, '') || ''
+ this.local.activeCount = this.state.todos.active.length
+ this.local.hasDone = this.state.todos.done.length || null
+ }
+
+ update () {
+ if (this.local.rawTodos !== this.state.todos.clock ||
+ this.local.rawHref !== this.state.href) {
+ this.setState()
+ return true
+ } else {
+ return false
+ }
+ }
+
+ createElement () {
+ return html``
+ }
+}
diff --git a/example/components/header.js b/example/components/header.js
new file mode 100644
index 00000000..ff682d4b
--- /dev/null
+++ b/example/components/header.js
@@ -0,0 +1,32 @@
+var Component = require('../../component')
+var html = require('bel')
+
+module.exports = class Header extends Component {
+ constructor (name, state, emit) {
+ super(name)
+ this.state = state
+ this.emit = emit
+ }
+
+ update () {
+ return false
+ }
+
+ createElement () {
+ return html``
+ }
+
+ createTodo (e) {
+ var value = e.target.value
+ if (e.keyCode === 13) {
+ e.target.value = ''
+ this.emit('todos:create', value)
+ }
+ }
+}
diff --git a/example/components/info.js b/example/components/info.js
new file mode 100644
index 00000000..f1b917cc
--- /dev/null
+++ b/example/components/info.js
@@ -0,0 +1,16 @@
+var Component = require('../../component')
+var html = require('bel')
+
+module.exports = class Info extends Component {
+ update () {
+ return false
+ }
+
+ createElement () {
+ return html``
+ }
+}
diff --git a/example/components/todos/index.js b/example/components/todos/index.js
new file mode 100644
index 00000000..7dbfd3a2
--- /dev/null
+++ b/example/components/todos/index.js
@@ -0,0 +1,65 @@
+var Component = require('../../../component')
+var html = require('bel')
+
+var Todo = require('./todo')
+
+module.exports = class Header extends Component {
+ constructor (name, state, emit) {
+ super(name)
+ this.state = state
+ this.emit = emit
+ this.local = this.state.components[name] = {}
+ this.setState()
+ }
+
+ setState () {
+ this.local.rawTodos = this.state.todos.clock
+ this.local.rawHref = this.state.href
+
+ this.local.allDone = this.state.todos.done.length === this.state.todos.all.length
+ this.local.filter = this.state.href.replace(/^\//, '') || ''
+ this.local.todos = this.local.filter === 'completed'
+ ? this.state.todos.done
+ : this.local.filter === 'active'
+ ? this.state.todos.active
+ : this.state.todos.all
+ }
+
+ update () {
+ if (this.local.rawTodos !== this.state.todos.clock ||
+ this.local.rawHref !== this.state.href) {
+ this.setState()
+ return true
+ } else {
+ return false
+ }
+ }
+
+ createElement () {
+ return html``
+ }
+
+ createTodo (e) {
+ var value = e.target.value
+ if (e.keyCode === 13) {
+ e.target.value = ''
+ this.emit('todos:create', value)
+ }
+ }
+
+ toggleAll () {
+ this.emit('todos:toggleAll')
+ }
+}
diff --git a/example/components/todos/todo.js b/example/components/todos/todo.js
new file mode 100644
index 00000000..5a3606d2
--- /dev/null
+++ b/example/components/todos/todo.js
@@ -0,0 +1,64 @@
+var html = require('bel')
+
+module.exports = Todo
+
+function Todo (todo, emit) {
+ var clx = classList({ completed: todo.done, editing: todo.editing })
+ return html`
+
+
+
+
+
+
+
+
+ `
+
+ function toggle (e) {
+ emit('todos:toggle', todo.id)
+ }
+
+ function edit (e) {
+ emit('todos:edit', todo.id)
+ }
+
+ function destroy (e) {
+ emit('todos:delete', todo.id)
+ }
+
+ function update (e) {
+ emit('todos:update', {
+ id: todo.id,
+ editing: false,
+ name: e.target.value
+ })
+ }
+
+ function handleEditKeydown (e) {
+ if (e.keyCode === 13) update(e) // Enter
+ else if (e.code === 27) emit('todos:unedit') // Escape
+ }
+
+ function classList (classes) {
+ var str = ''
+ var keys = Object.keys(classes)
+ for (var i = 0, len = keys.length; i < len; i++) {
+ var key = keys[i]
+ var val = classes[key]
+ if (val) str += (key + ' ')
+ }
+ return str
+ }
+}
diff --git a/example/index.js b/example/index.js
index 5c767b15..117d05e3 100644
--- a/example/index.js
+++ b/example/index.js
@@ -8,11 +8,11 @@ var app = choo()
if (process.env.NODE_ENV !== 'production') {
app.use(require('choo-devtools')())
}
-app.use(require('./store'))
+app.use(require('./stores/todos'))
-app.route('/', require('./view'))
-app.route('#active', require('./view'))
-app.route('#completed', require('./view'))
-app.route('*', require('./view'))
+app.route('/', require('./views/main'))
+app.route('#active', require('./views/main'))
+app.route('#completed', require('./views/main'))
+app.route('*', require('./views/main'))
module.exports = app.mount('body')
diff --git a/example/store.js b/example/stores/todos.js
similarity index 83%
rename from example/store.js
rename to example/stores/todos.js
index 63f206d6..d6e2efa3 100644
--- a/example/store.js
+++ b/example/stores/todos.js
@@ -1,30 +1,18 @@
module.exports = todoStore
function todoStore (state, emitter) {
- if (!state.todos) {
- state.todos = {}
-
- state.todos.active = []
- state.todos.done = []
- state.todos.all = []
-
- state.todos.idCounter = 0
+ state.todos = {
+ clock: 0,
+ idCounter: 0,
+ active: [],
+ done: [],
+ all: []
}
- // Always reset when application boots
- state.todos.input = ''
-
- // Register emitters after DOM is loaded to speed up DOM loading
emitter.on('DOMContentLoaded', function () {
- // CRUD
emitter.on('todos:create', create)
emitter.on('todos:update', update)
emitter.on('todos:delete', del)
-
- // Special
- emitter.on('todos:input', oninput)
-
- // Shorthand
emitter.on('todos:edit', edit)
emitter.on('todos:unedit', unedit)
emitter.on('todos:toggle', toggle)
@@ -32,10 +20,6 @@ function todoStore (state, emitter) {
emitter.on('todos:deleteCompleted', deleteCompleted)
})
- function oninput (text) {
- state.todos.input = text
- }
-
function create (name) {
var item = {
id: state.todos.idCounter,
@@ -47,21 +31,21 @@ function todoStore (state, emitter) {
state.todos.idCounter += 1
state.todos.active.push(item)
state.todos.all.push(item)
- emitter.emit('render')
+ render()
}
function edit (id) {
state.todos.all.forEach(function (todo) {
if (todo.id === id) todo.editing = true
})
- emitter.emit('render')
+ render()
}
function unedit (id) {
state.todos.all.forEach(function (todo) {
if (todo.id === id) todo.editing = false
})
- emitter.emit('render')
+ render()
}
function update (newTodo) {
@@ -78,7 +62,7 @@ function todoStore (state, emitter) {
}
Object.assign(todo, newTodo)
- emitter.emit('render')
+ render()
}
function del (id) {
@@ -111,7 +95,7 @@ function todoStore (state, emitter) {
})
active.splice(activeIndex, 1)
}
- emitter.emit('render')
+ render()
}
function deleteCompleted (data) {
@@ -121,7 +105,7 @@ function todoStore (state, emitter) {
state.todos.all.splice(index, 1)
})
state.todos.done = []
- emitter.emit('render')
+ render()
}
function toggle (id) {
@@ -135,7 +119,7 @@ function todoStore (state, emitter) {
var index = arr.indexOf(todo)
arr.splice(index, 1)
target.push(todo)
- emitter.emit('render')
+ render()
}
function toggleAll (data) {
@@ -155,6 +139,11 @@ function todoStore (state, emitter) {
state.todos.active = []
}
+ render()
+ }
+
+ function render () {
+ state.todos.clock += 1
emitter.emit('render')
}
}
diff --git a/example/test.js b/example/test.js
index 116e220f..e7cef737 100644
--- a/example/test.js
+++ b/example/test.js
@@ -2,7 +2,7 @@ var EventEmitter = require('events').EventEmitter
var spok = require('spok')
var tape = require('tape')
-var todoStore = require('./store')
+var todoStore = require('./stores/todos')
tape('should initialize empty state', function (t) {
var emitter = new EventEmitter()
@@ -19,25 +19,6 @@ tape('should initialize empty state', function (t) {
t.end()
})
-tape('restore previous state', function (t) {
- var emitter = new EventEmitter()
- var state = {
- todos: {
- idCounter: 100,
- active: [],
- done: [],
- all: []
- }
- }
- todoStore(state, emitter)
- spok(t, state, {
- todos: {
- idCounter: 100
- }
- })
- t.end()
-})
-
tape('todos:create', function (t) {
var emitter = new EventEmitter()
var state = {}
diff --git a/example/view.js b/example/view.js
deleted file mode 100644
index 9f120b55..00000000
--- a/example/view.js
+++ /dev/null
@@ -1,190 +0,0 @@
-var html = require('bel') // cannot require choo/html because it's a nested repo
-
-module.exports = mainView
-
-function mainView (state, emit) {
- emit('log:debug', 'Rendering main view')
- return html`
-
-
- ${Header(state, emit)}
- ${TodoList(state, emit)}
- ${Footer(state, emit)}
-
-
-
- `
-}
-
-function Header (state, emit) {
- return html`
-
- `
-
- function createTodo (e) {
- var value = e.target.value
- if (!value) return
- if (e.keyCode === 13) {
- emit('todos:input', '')
- emit('todos:create', value)
- } else {
- emit('todos:input', value)
- }
- }
-}
-
-function Footer (state, emit) {
- var filter = state.href.replace(/^\//, '') || ''
- var activeCount = state.todos.active.length
- var hasDone = state.todos.done.length
-
- return html`
-
- `
-
- function filterButton (name, filter, currentFilter, emit) {
- var filterClass = filter === currentFilter
- ? 'selected'
- : ''
-
- var uri = '#' + name.toLowerCase()
- if (uri === '#all') uri = '/'
- return html`
-
-
- ${name}
-
-
- `
- }
-
- function deleteCompleted (emit) {
- return html`
-
- `
-
- function deleteAllCompleted () {
- emit('todos:deleteCompleted')
- }
- }
-}
-
-function TodoItem (todo, emit) {
- var clx = classList({ completed: todo.done, editing: todo.editing })
- return html`
-
-
-
-
-
-
-
-
- `
-
- function toggle (e) {
- emit('todos:toggle', todo.id)
- }
-
- function edit (e) {
- emit('todos:edit', todo.id)
- }
-
- function destroy (e) {
- emit('todos:delete', todo.id)
- }
-
- function update (e) {
- emit('todos:update', {
- id: todo.id,
- editing: false,
- name: e.target.value
- })
- }
-
- function handleEditKeydown (e) {
- if (e.keyCode === 13) update(e) // Enter
- else if (e.code === 27) emit('todos:unedit') // Escape
- }
-
- function classList (classes) {
- var str = ''
- var keys = Object.keys(classes)
- for (var i = 0, len = keys.length; i < len; i++) {
- var key = keys[i]
- var val = classes[key]
- if (val) str += (key + ' ')
- }
- return str
- }
-}
-
-function TodoList (state, emit) {
- var filter = state.href.replace(/^\//, '') || ''
- var items = filter === 'completed'
- ? state.todos.done
- : filter === 'active'
- ? state.todos.active
- : state.todos.all
-
- var allDone = state.todos.done.length === state.todos.all.length
-
- var nodes = items.map(function (todo) {
- return TodoItem(todo, emit)
- })
-
- return html`
-
- `
-
- function toggleAll () {
- emit('todos:toggleAll')
- }
-}
diff --git a/example/views/main.js b/example/views/main.js
new file mode 100644
index 00000000..67d74547
--- /dev/null
+++ b/example/views/main.js
@@ -0,0 +1,21 @@
+var html = require('bel') // cannot require choo/html because it's a nested repo
+
+var Header = require('../components/header')
+var Footer = require('../components/footer')
+var Todos = require('../components/todos')
+var Info = require('../components/info')
+
+module.exports = mainView
+
+function mainView (state, emit) {
+ return html`
+
+
+ ${state.cache(Header, 'header').render()}
+ ${state.cache(Todos, 'todos').render()}
+ ${state.cache(Footer, 'footer').render()}
+
+ ${state.cache(Info, 'info').render()}
+
+ `
+}
diff --git a/index.js b/index.js
index 7b1931fa..9d60891b 100644
--- a/index.js
+++ b/index.js
@@ -11,6 +11,8 @@ var nanobus = require('nanobus')
var assert = require('assert')
var xtend = require('xtend')
+var Cache = require('./component/cache')
+
module.exports = Choo
var HISTORY_OBJECT = {}
@@ -39,25 +41,30 @@ function Choo (opts) {
this._hrefEnabled = opts.href === undefined ? true : opts.href
this._hasWindow = typeof window !== 'undefined'
this._createLocation = nanolocation
+ this._cache = opts.cache
this._loaded = false
this._stores = []
this._tree = null
- // properties that are part of the API
- this.router = nanorouter()
- this.emitter = nanobus('choo.emit')
- this.emit = this.emitter.emit.bind(this.emitter)
-
- var events = { events: this._events }
+ // state
+ var _state = {
+ events: this._events,
+ components: {}
+ }
if (this._hasWindow) {
this.state = window.initialState
- ? xtend(window.initialState, events)
- : events
+ ? xtend(window.initialState, _state)
+ : _state
delete window.initialState
} else {
- this.state = events
+ this.state = _state
}
+ // properties that are part of the API
+ this.router = nanorouter({ curry: true })
+ this.emitter = nanobus('choo.emit')
+ this.emit = this.emitter.emit.bind(this.emitter)
+
// listen for title changes; available even when calling .toString()
if (this._hasWindow) this.state.title = document.title
this.emitter.prependListener(this._events.DOMTITLECHANGE, function (title) {
@@ -76,11 +83,11 @@ Choo.prototype.route = function (route, handler) {
Choo.prototype.use = function (cb) {
assert.equal(typeof cb, 'function', 'choo.use: cb should be type function')
var self = this
- this._stores.push(function () {
+ this._stores.push(function (state) {
var msg = 'choo.use'
msg = cb.storeName ? msg + '(' + cb.storeName + ')' : msg
var endTiming = nanotiming(msg)
- cb(self.state, self.emitter, self)
+ cb(state, self.emitter, self)
endTiming()
})
}
@@ -128,8 +135,9 @@ Choo.prototype.start = function () {
}
}
+ this._setCache(this.state)
this._stores.forEach(function (initStore) {
- initStore()
+ initStore(self.state)
})
this._matchRoute()
@@ -200,9 +208,10 @@ Choo.prototype.toString = function (location, state) {
assert.equal(typeof location, 'string', 'choo.toString: location should be type string')
assert.equal(typeof this.state, 'object', 'choo.toString: state should be type object')
- // TODO: pass custom state down to each store.
+ var self = this
+ this._setCache(this.state)
this._stores.forEach(function (initStore) {
- initStore()
+ initStore(self.state)
})
this._matchRoute(location)
@@ -235,3 +244,23 @@ Choo.prototype._prerender = function (state) {
routeTiming()
return res
}
+
+Choo.prototype._setCache = function (state) {
+ var cache = new Cache(state, this.emitter.emit.bind(this.emitter), this._cache)
+ state.cache = renderComponent
+
+ function renderComponent (Component, id) {
+ assert.equal(typeof Component, 'function', 'choo.state.cache: Component should be type function')
+ var args = []
+ for (var i = 0, len = arguments.length; i < len; i++) {
+ args.push(arguments[i])
+ }
+ return cache.render.apply(cache, args)
+ }
+
+ // When the state gets stringified, make sure `state.cache` isn't
+ // stringified too.
+ renderComponent.toJSON = function () {
+ return null
+ }
+}
diff --git a/package.json b/package.json
index dd5da726..5011908e 100644
--- a/package.json
+++ b/package.json
@@ -9,12 +9,14 @@
"html/index.js",
"html/raw.js",
"html/index.d.ts",
+ "component/cache.js",
+ "component/index.js",
"dist",
"example"
],
"scripts": {
"build": "mkdir -p dist/ && browserify index -p bundle-collapser/plugin > dist/bundle.js && browserify index -p tinyify > dist/bundle.min.js && cat dist/bundle.min.js | gzip --best --stdout | wc -c | pretty-bytes",
- "deps": "dependency-check --entry ./html/index.js . && dependency-check . --extra --no-dev --entry ./html/index.js",
+ "deps": "dependency-check --entry ./html/index.js . && dependency-check . --extra --no-dev --entry ./html/index.js --entry ./component/index.js",
"inspect": "browserify --full-paths index -g unassertify -g uglifyify | discify --open",
"prepublishOnly": "npm run build",
"start": "bankai start example",
@@ -34,8 +36,10 @@
"bel": "^5.1.3",
"document-ready": "^2.0.1",
"nanobus": "^4.2.0",
+ "nanocomponent": "^6.5.0",
"nanohref": "^3.0.0",
"nanolocation": "^1.0.0",
+ "nanolru": "^1.0.0",
"nanomorph": "^5.1.2",
"nanoquery": "^1.1.0",
"nanoraf": "^3.0.0",
diff --git a/test.js b/test.js
index cb748542..a8880d4a 100644
--- a/test.js
+++ b/test.js
@@ -29,3 +29,19 @@ tape('should render on the server with hyperscript', function (t) {
t.equal(res.toString().trim(), exp, 'result was OK')
t.end()
})
+
+tape('should expose a public API', function (t) {
+ var app = choo()
+
+ t.equal(typeof app.route, 'function', 'app.route prototype method exists')
+ t.equal(typeof app.toString, 'function', 'app.toString prototype method exists')
+ t.equal(typeof app.start, 'function', 'app.start prototype method exists')
+ t.equal(typeof app.mount, 'function', 'app.mount prototype method exists')
+ t.equal(typeof app.emitter, 'object', 'app.emitter prototype method exists')
+
+ t.equal(typeof app.emit, 'function', 'app.emit instance method exists')
+ t.equal(typeof app.router, 'object', 'app.router instance object exists')
+ t.equal(typeof app.state, 'object', 'app.state instance object exists')
+
+ t.end()
+})