-## Table of Content
+## Table of Contents
- [Features](#features)
- [Example](#example)
- [Philosophy](#philosophy)
@@ -93,6 +93,7 @@
- [State](#state)
- [Routing](#routing)
- [Server Rendering](#server-rendering)
+- [Components](#components)
- [Optimizations](#optimizations)
- [FAQ](#faq)
- [API](#api)
@@ -101,7 +102,7 @@
- [Support](#support)
## Features
-- __minimal size:__ weighing `4kb`, `choo` is a tiny little framework
+- __minimal size:__ weighing `4kb`, Choo is a tiny little framework
- __event based:__ our performant event system makes writing apps easy
- __small api:__ with only 6 methods there's not much to learn
- __minimal tooling:__ built for the cutting edge `browserify` compiler
@@ -111,11 +112,11 @@
## Example
```js
var html = require('choo/html')
-var log = require('choo-log')
+var devtools = require('choo-devtools')
var choo = require('choo')
var app = choo()
-app.use(log())
+app.use(devtools())
app.use(countStore)
app.route('/', mainView)
app.mount('body')
@@ -153,7 +154,7 @@ _Real casually._
We believe frameworks should be disposable, and components recyclable. We don't
want a web where walled gardens jealously compete with one another. By making
the DOM the lowest common denominator, switching from one framework to another
-becomes frictionless. `choo` is modest in its design; we don't believe it will
+becomes frictionless. Choo is modest in its design; we don't believe it will
be top of the class forever, so we've made it as easy to toss out as it is to
pick up.
@@ -169,7 +170,7 @@ At the core of Choo is an event emitter, which is used for both application
logic but also to interface with the framework itself. The package we use for
this is [nanobus](https://github.com/choojs/nanobus).
-You can access the emitter through `app.use(state, emitter)`, `app.route(route,
+You can access the emitter through `app.use(state, emitter, app)`, `app.route(route,
view(state, emit))` or `app.emitter`. Routes only have access to the
`emitter.emit` method to encourage people to separate business logic from
render logic.
@@ -208,10 +209,13 @@ and `'render'`. Similar to
[history.replaceState](http://devdocs.io/dom/history#history-replacestate).
### `'popState'`|`state.events.POPSTATE`
-This event should be emitted to navigate to a previous route. The new route
-will be a previous entry in the browser's history stack, and will emit
-`'navigate'` and `'render'`. Similar to
-[history.popState](http://devdocs.io/dom_events/popstate).
+This event is emitted when the user hits the 'back' button in their browser.
+The new route will be a previous entry in the browser's history stack, and
+immediately afterward the`'navigate'` and `'render'`events will be emitted.
+Similar to [history.popState](http://devdocs.io/dom_events/popstate). (Note
+that `emit('popState')` will _not_ cause a popState action - use
+`history.go(-1)` for that - this is different from the behaviour of `pushState`
+and `replaceState`!)
### `'DOMTitleChange'`|`state.events.DOMTITLECHANGE`
This event should be emitted whenever the `document.title` needs to be updated.
@@ -225,6 +229,10 @@ Choo comes with a shared state object. This object can be mutated freely, and
is passed into the view functions whenever `'render'` is emitted. The state
object comes with a few properties set.
+When initializing the application, `window.initialState` is used to provision
+the initial state. This is especially useful when combined with server
+rendering. See [server rendering](#server-rendering) for more details.
+
### `state.events`
A mapping of Choo's built in events. It's recommended to extend this object
with your application's events. By defining your event names once and setting
@@ -241,7 +249,7 @@ An object containing the current queryString. `/foo?bin=baz` becomes `{ bin:
'baz' }`.
### `state.href`
-An object containing the current href. `/foo?bin=baz` becomes `foo`.
+An object containing the current href. `/foo?bin=baz` becomes `/foo`.
### `state.route`
The current name of the route used in the router (e.g. `/foo/:bar`).
@@ -249,12 +257,19 @@ The current name of the route used in the router (e.g. `/foo/:bar`).
### `state.title`
The current page title. Can be set using the `DOMTitleChange` event.
+### `state.components`
+An object _recommended_ to use for local component state.
+
+### `state.cache(Component, id, [...args])`
+Generic class cache. Will lookup Component instance by id and create one if not
+found. Useful for working with stateful [components](#components).
+
## Routing
Choo is an application level framework. This means that it takes care of
everything related to routing and pathnames for you.
### Params
-Params can be registered by prepending the routename with `:routename`, e.g.
+Params can be registered by prepending the route name with `:routename`, e.g.
`/foo/:bar/:baz`. The value of the param will be saved on `state.params` (e.g.
`state.params.bar`). Wildcard routes can be registered with `*`, e.g. `/foo/*`.
The value of the wildcard will be saved under `state.params.wildcard`.
@@ -269,10 +284,12 @@ Querystrings (e.g. `?foo=bar`) are ignored when matching routes. An object
containing the key-value mappings exists as `state.query`.
### Hash routing
-Using hashes to delimit routes is supported out of the box (e.g. `/foo#bar`).
-When a hash is found we also check if there's an available anchor on the same
-page, and will scroll the screen to the position. Using both hashes in URLs and
-anchor links on the page is generally not recommended.
+By default hashes are treated as part of the url when routing. Using hashes to
+delimit routes (e.g. `/foo#bar`) can be disabled by setting the `hash`
+[option](#app--chooopts) to `false`. Regardless, when a hash is found we also
+check if there's an available anchor on the same page, and will scroll the
+screen to the position. Using both hashes in URLs and anchor links on the page
+is generally not recommended.
### Following links
By default all clicks on `` tags are handled by the router through the
@@ -291,12 +308,12 @@ constructor. The event is not handled under the following conditions:
pages](https://mathiasbynens.github.io/rel-noopener/).
### Navigating programmatically
-To can navigate routes you can emit `'pushState'`, `'popState'` or
+To navigate routes you can emit `'pushState'`, `'popState'` or
`'replaceState'`. See [#events](#events) for more details about these events.
## Server Rendering
-Choo was built with Node in mind. To render on the server call `.toString()` on
-your application.
+Choo was built with Node in mind. To render on the server call
+`.toString(route, [state])` on your `choo` instance.
```js
var html = require('choo/html')
@@ -314,6 +331,112 @@ console.log(string)
// => '
+ `
+ })
+ app.mount(container)
+})
+
+tape('should render with hyperscript', function (t) {
+ t.plan(1)
+ var app = choo()
+ var container = init('/', 'p')
+ app.route('/', function (state, emit) {
+ window.requestAnimationFrame(function () {
+ var exp = '
Hello filthy planet
'
+ t.equal(container.outerHTML, exp, 'result was OK')
+ })
+ return h('p', h('strong', 'Hello filthy planet'))
+ })
+ app.mount(container)
+})
+
+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()
+})
+
+tape('should enable history and hash by defaut', function (t) {
+ var app = choo()
+ t.true(app._historyEnabled, 'history enabled')
+ t.true(app._hrefEnabled, 'href enabled')
+ t.end()
+})
+
+tape('router should pass state and emit to view', function (t) {
+ t.plan(2)
+ var app = choo()
+ var container = init()
+ app.route('/', function (state, emit) {
+ t.equal(typeof state, 'object', 'state is an object')
+ t.equal(typeof emit, 'function', 'emit is a function')
+ return html``
+ })
+ app.mount(container)
+})
+
+tape('router should support a default route', function (t) {
+ t.plan(1)
+ var app = choo()
+ var container = init('/random')
+ app.route('*', function (state, emit) {
+ t.pass()
+ return html``
+ })
+ app.mount(container)
+})
+
+tape('router should treat hashes as slashes by default', function (t) {
+ t.plan(1)
+ var app = choo()
+ var container = init('/account#security')
+ app.route('/account/security', function (state, emit) {
+ t.pass()
+ return html``
+ })
+ app.mount(container)
+})
+
+tape('router should ignore hashes if hash is disabled', function (t) {
+ t.plan(1)
+ var app = choo({ hash: false })
+ var container = init('/account#security')
+ app.route('/account', function (state, emit) {
+ t.pass()
+ return html``
+ })
+ app.mount(container)
+})
+
+tape('cache should default to 100 instances', function (t) {
+ t.plan(1)
+ var app = choo()
+ var container = init()
+ app.route('/', function (state, emit) {
+ for (var i = 0; i <= 100; i++) state.cache(Component, i)
+ state.cache(Component, 0)
+ return html``
+
+ function Component (id) {
+ if (id < i) t.pass('oldest instance was pruned when exceeding 100')
+ }
+ })
+ app.mount(container)
+})
+
+tape('cache option should override number of max instances', function (t) {
+ t.plan(1)
+ var app = choo({ cache: 1 })
+ var container = init()
+ app.route('/', function (state, emit) {
+ var instances = 0
+ state.cache(Component, instances)
+ state.cache(Component, instances)
+ state.cache(Component, 0)
+ return html``
+
+ function Component (id) {
+ if (id < instances) t.pass('oldest instance was pruned when exceeding 1')
+ instances++
+ }
+ })
+ app.mount(container)
+})
+
+tape('cache option should override default LRU cache', function (t) {
+ t.plan(2)
+ var cache = {
+ get (Component, id) {
+ t.pass('called get')
+ },
+ set (Component, id) {
+ t.pass('called set')
+ }
+ }
+ var app = choo({ cache: cache })
+ var container = init()
+ app.route('/', function (state, emit) {
+ state.cache(Component, 'foo')
+ return html``
+ })
+ app.mount(container)
+
+ function Component () {}
+})
+
+// built-in state
+
+tape('state should include events', function (t) {
+ t.plan(2)
+ var app = choo()
+ var container = init()
+ app.route('/', function (state, emit) {
+ t.ok(state.hasOwnProperty('events'), 'state has event property')
+ t.ok(Object.keys(state.events).length > 0, 'events object has keys')
+ return html``
+ })
+ app.mount(container)
+})
+
+tape('state should include location on render', function (t) {
+ t.plan(6)
+ var app = choo()
+ var container = init('/foo/bar/file.txt?bin=baz')
+ app.route('/:first/:second/*', function (state, emit) {
+ var params = { first: 'foo', second: 'bar', wildcard: 'file.txt' }
+ t.equal(state.href, '/foo/bar/file.txt', 'state has href')
+ t.equal(state.route, ':first/:second/*', 'state has route')
+ t.ok(state.hasOwnProperty('params'), 'state has params')
+ t.deepEqual(state.params, params, 'params match')
+ t.ok(state.hasOwnProperty('query'), 'state has query')
+ t.deepEqual(state.query, { bin: 'baz' }, 'query match')
+ return html``
+ })
+ app.mount(container)
+})
+
+tape('state should include title', function (t) {
+ t.plan(3)
+ document.title = 'foo'
+ var app = choo()
+ var container = init()
+ t.equal(app.state.title, 'foo', 'title is match')
+ app.use(function (state, emitter) {
+ emitter.on(state.events.DOMTITLECHANGE, function (title) {
+ t.equal(state.title, 'bar', 'title is changed in state')
+ t.equal(document.title, 'bar', 'title is changed in document')
+ })
+ })
+ app.route('/', function (state, emit) {
+ emit(state.events.DOMTITLECHANGE, 'bar')
+ return html``
+ })
+ app.mount(container)
+})
+
+tape('state should include cache', function (t) {
+ t.plan(6)
+ var app = choo()
+ var container = init()
+ app.route('/', function (state, emit) {
+ t.equal(typeof state.cache, 'function', 'state has cache method')
+ var cached = state.cache(Component, 'foo', 'arg')
+ t.equal(cached, state.cache(Component, 'foo'), 'consecutive calls return same instance')
+ return html``
+ })
+ app.mount(container)
+
+ function Component (id, state, emit, arg) {
+ t.equal(id, 'foo', 'id was prefixed to constructor args')
+ t.equal(typeof state, 'object', 'state was prefixed to constructor args')
+ t.equal(typeof emit, 'function', 'emit was prefixed to constructor args')
+ t.equal(arg, 'arg', 'constructor args were forwarded')
+ }
+})
+
+// create application container and set location
+// (str?, str?) -> Element
+function init (location, type) {
+ location = location ? location.split('#') : ['/', '']
+ window.history.replaceState({}, document.title, location[0])
+ window.location.hash = location[1] || ''
+ var container = document.createElement(type || 'div')
+ document.body.appendChild(container)
+ return container
+}
diff --git a/test/node.js b/test/node.js
new file mode 100644
index 00000000..eefe4b58
--- /dev/null
+++ b/test/node.js
@@ -0,0 +1,206 @@
+var tape = require('tape')
+var h = require('hyperscript')
+
+var html = require('../html')
+var raw = require('../html/raw')
+var choo = require('..')
+
+tape('should render on the server with nanohtml', function (t) {
+ var app = choo()
+ app.route('/', function (state, emit) {
+ var strong = 'Hello filthy planet'
+ return html`
+
${raw(strong)}
+ `
+ })
+ var res = app.toString('/')
+ var exp = '
Hello filthy planet
'
+ t.equal(res.toString().trim(), exp, 'result was OK')
+ t.end()
+})
+
+tape('should render on the server with hyperscript', function (t) {
+ var app = choo()
+ app.route('/', function (state, emit) {
+ return h('p', h('strong', 'Hello filthy planet'))
+ })
+ var res = app.toString('/')
+ var exp = '
Hello filthy planet
'
+ 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()
+})
+
+tape('should enable history and hash by defaut', function (t) {
+ var app = choo()
+ t.true(app._historyEnabled, 'history enabled')
+ t.true(app._hrefEnabled, 'href enabled')
+ t.end()
+})
+
+tape('router should pass state and emit to view', function (t) {
+ t.plan(2)
+ var app = choo()
+ app.route('/', function (state, emit) {
+ t.equal(typeof state, 'object', 'state is an object')
+ t.equal(typeof emit, 'function', 'emit is a function')
+ return html``
+ })
+ app.toString('/')
+ t.end()
+})
+
+tape('router should support a default route', function (t) {
+ t.plan(1)
+ var app = choo()
+ app.route('*', function (state, emit) {
+ t.pass()
+ return html``
+ })
+ app.toString('/random')
+ t.end()
+})
+
+tape('router should treat hashes as slashes by default', function (t) {
+ t.plan(1)
+ var app = choo()
+ app.route('/account/security', function (state, emit) {
+ t.pass()
+ return html``
+ })
+ app.toString('/account#security')
+ t.end()
+})
+
+tape('router should ignore hashes if hash is disabled', function (t) {
+ t.plan(1)
+ var app = choo({ hash: false })
+ app.route('/account', function (state, emit) {
+ t.pass()
+ return html``
+ })
+ app.toString('/account#security')
+ t.end()
+})
+
+tape('cache should default to 100 instances', function (t) {
+ t.plan(1)
+ var app = choo()
+ app.route('/', function (state, emit) {
+ for (var i = 0; i <= 100; i++) state.cache(Component, i)
+ state.cache(Component, 0)
+ return html``
+
+ function Component (id) {
+ if (id < i) t.pass('oldest instance was pruned when exceeding 100')
+ }
+ })
+ app.toString('/')
+ t.end()
+})
+
+tape('cache option should override number of max instances', function (t) {
+ t.plan(1)
+ var app = choo({ cache: 1 })
+ app.route('/', function (state, emit) {
+ var instances = 0
+ state.cache(Component, instances)
+ state.cache(Component, instances)
+ state.cache(Component, 0)
+ return html``
+
+ function Component (id) {
+ if (id < instances) t.pass('oldest instance was pruned when exceeding 1')
+ instances++
+ }
+ })
+ app.toString('/')
+ t.end()
+})
+
+tape('cache option should override default LRU cache', function (t) {
+ t.plan(2)
+ var cache = {
+ get (Component, id) {
+ t.pass('called get')
+ },
+ set (Component, id) {
+ t.pass('called set')
+ }
+ }
+ var app = choo({ cache: cache })
+ app.route('/', function (state, emit) {
+ state.cache(Component, 'foo')
+ return html``
+ })
+ app.toString('/')
+ t.end()
+
+ function Component () {}
+})
+
+// built-in state
+
+tape('state should include events', function (t) {
+ t.plan(2)
+ var app = choo()
+ app.route('/', function (state, emit) {
+ t.ok(state.hasOwnProperty('events'), 'state has event property')
+ t.ok(Object.keys(state.events).length > 0, 'events object has keys')
+ return html``
+ })
+ app.toString('/')
+ t.end()
+})
+
+tape('state should include location on render', function (t) {
+ t.plan(6)
+ var app = choo()
+ app.route('/:first/:second/*', function (state, emit) {
+ var params = { first: 'foo', second: 'bar', wildcard: 'file.txt' }
+ t.equal(state.href, '/foo/bar/file.txt', 'state has href')
+ t.equal(state.route, ':first/:second/*', 'state has route')
+ t.ok(state.hasOwnProperty('params'), 'state has params')
+ t.deepEqual(state.params, params, 'params match')
+ t.ok(state.hasOwnProperty('query'), 'state has query')
+ t.deepEqual(state.query, { bin: 'baz' }, 'query match')
+ return html``
+ })
+ app.toString('/foo/bar/file.txt?bin=baz')
+ t.end()
+})
+
+tape('state should include cache', function (t) {
+ t.plan(6)
+ var app = choo()
+ app.route('/', function (state, emit) {
+ t.equal(typeof state.cache, 'function', 'state has cache method')
+ var cached = state.cache(Component, 'foo', 'arg')
+ t.equal(cached, state.cache(Component, 'foo'), 'consecutive calls return same instance')
+ return html``
+ })
+ app.toString('/')
+ t.end()
+
+ function Component (id, state, emit, arg) {
+ t.equal(id, 'foo', 'id was prefixed to constructor args')
+ t.equal(typeof state, 'object', 'state was prefixed to constructor args')
+ t.equal(typeof emit, 'function', 'emit was prefixed to constructor args')
+ t.equal(arg, 'arg', 'constructor args were forwarded')
+ }
+})