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`
    +

    todos

    + +
    ` + } + + 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`
    + this.toggleAll()}/> + + +
    ` + } + + 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` -
    -

    todos

    - -
    - ` - - 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() +})