Merge pull request #127 from yoshuawuyts/3.0

3.0
This commit is contained in:
Yoshua Wuyts
2016-07-06 01:52:19 +02:00
committed by GitHub
35 changed files with 1000 additions and 811 deletions
+10
View File
@@ -0,0 +1,10 @@
## Thanks for contributing to Choo!
:steam_locomotive::train::train::train::train::train:
### Running tests
Browser tests can be run with the right credentials via the `npm run
test:browser` command. This will be run automatically when `npm version` is
executed.
You may skip the tests by providing `SKIP_TEST=true` when running the version
command.
+8
View File
@@ -0,0 +1,8 @@
### Expected behavior
Write here.
### Actual behavior
Write here.
### Steps to reproduce behavior
Write here.
+1
View File
@@ -1,5 +1,6 @@
node_modules/
coverage/
coverage.json
tmp/
npm-debug.log*
.DS_Store
+7
View File
@@ -3,6 +3,13 @@ node_js:
- '6'
sudo: false
language: node_js
addons:
apt:
packages:
- xvfb
before_install:
- export DISPLAY=':99.0'
- Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 &
script: npm run test:cov
after_script: npm i -g codecov.io && cat ./coverage/lcov.info | codecov
env:
+82
View File
@@ -1,3 +1,85 @@
## `3.0.0`
Woooh, happy third birthday `choo` - _thanks dad_. You're all grown up now;
look at how far you've come in the last month. You've grown... tinier? But yet
you do more? I love you `choo` - _shut up dad_.
### Notable changes
#### Who's the tiniest of them all?
`choo` is now `5kb` optimized! That's `2kb` less compared to v2. _Woah, how?_
We now support [yo-yoify](https://github.com/shama/yo-yoify) which optimizes
those lil template tags to `document.createElement()` calls. So not only is it
smaller, creating elements now has no overhead. Pretty nifty eh? Mad shoutout
to [Shama](http://twitter.com/shamakry) for building this!
#### Captain Hook(s)
V3 introduces `hooks` - powerful functions that are called at certain points in
the refresh cycle. Unlike functions in `models` these functions have unfiltered
access to all properties, call stacks and more. They're super useful when
building error handling, logging or persisting for stuff like `hot reloading`.
I quite like them, and I'm def keen to see what uses people will come up with!
#### Effect Composition :train::train::train::train:
`effects` are now composable by calling a `done(err, res)` callback when
they're done executing. This means that multiple namespaced effects can be
chained together to form some higher level behavior.
Think of cases like "logout" - multiple models must be cleared, perhaps tokens
invalidated on the server, all in a certain order. This requires multiple
models to work in tandem. - And now that's possible! :sparkles:
#### Pathfinders guide
We've started work on the [choo
handbook](https://github.com/yoshuawuyts/choo-handbook) - a lil manual to help
you get started, not only with choo, but with web development in general. It's
super modest still, only containing a single `choo` tutorial, but we'll be
expanding this over the coming months. If you want to contribute some docs,
there's [a whole section of
ideas](https://github.com/yoshuawuyts/choo-handbook/issues/10) on stuff that
might be neat to write. Any lil bits are welcome! Shout out to
[Tim](https://twitter.com/timwis) for making this happen :tada:
#### The Cycle of Life
`views` have gone through a bit of a change - they're now required using
`require('choo/html')` so they can be factored out of a project into standalone
[bel](https://github.com/shama/bel) components at any time. But additionally
these components have gained super powers through the adition of `onload` and
`onunload` hooks. Components can now react to being mounted or not, which makes
them ideal to implement standalone widgets. This behavior uses [html5
MutationObserver](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver)
under the hood, so it will work anywhere with a DOM! Again, this was all
[Shama](http://twitter.com/shamakry)'s hard work.
#### Test coverage
`choo` has gained a beaut blanket of tests, courtesy of
[Todd](https://twitter.com/whale_eat_squid) and
[Ben](https://twitter.com/bendrucker/). We've got server, browser and
_pretty-much-all-browsers-known-to-mankind_ style testing which should give us
a pretty good idea if stuff breaks. Neat!
#### Core dump
Internally we've moved the core of `choo` into a separate package -
[barracks](https://github.com/yoshuawuyts/barracks). `choo` is now mere glue
code around `barracks`, `yo-yo` and `sheet-router`. This is good news for folks
who like `choo`, but don't agree with all decisions. Go forth and build your
own lil framework!
### Changelog
- move `choo.view` out to `require('choo/html')` #71 | pr #103
- streamline view API #35 | pr #111
- higher order functions #34 | pr #104
- create lifecycle hooks #1 | feature addition in dependency covered by semver
- implement state hooks #15 | pr #104
- add yo-yoify #3 | pr #110
- rename "app" namespace #82 | pr #111
- enable browser testing | pr #86
- propagating actions creates infinite loop #114 | pr #104
- state is now immutable in `reducers` and `effects`
### Thanks
Huge thanks to everyone who's collaborated on this, provided feedback or
even mentioned it anywhere. It's been a hella lot of people, but seriously,
you're the best :steam_locomotive::train::train::train::train::train:
## `2.3.1`
- [76](https://github.com/yoshuawuyts/choo/pull/76) - fix router arguments
+241 -453
View File
@@ -7,7 +7,7 @@
<strong>Fun functional programming</strong>
</div>
<div align="center">
A <code>7kb</code> framework for creating sturdy frontend applications
A <code>5kb</code> framework for creating sturdy frontend applications
</div>
<br />
@@ -50,7 +50,19 @@
</a>
</div>
<br />
<div align="center">
<h3>
<a href="https://github.com/yoshuawuyts/choo-handbook">
Handbook
</a>
<span>|</span>
Packages
<span>|</span>
<a href="https://github.com/yoshuawuyts/choo/blob/master/.github/CONTRIBUTING.md">
Contributing
</a>
</h3>
</div>
<div align="center">
<sub>The little framework that could. Built with ❤︎ by
@@ -60,35 +72,22 @@
</a>
</div>
## Table of Contents
- [Features](#features)
- [Demos](#demos)
- [Getting started](#getting-started)
- [Concepts](#concepts)
- [Models](#models)
- [Actions](#actions)
- [Effects](#effects)
- [Subscriptions](#subscriptions)
- [Router](#router)
- [Views](#views)
- [Common actions](#common-actions)
- [HTTP](#http)
- [Server sent events](#server-sent-events-sse)
- [Keyboard](#keyboard)
- [Websockets](#websockets)
- [Forms](#forms)
- [Links](#links)
- [Rendering in Node](#rendering-in-node)
- [API](#api)
- [Errors](#errors)
- [FAQ](#faq)
- [Installation](#installation)
- [Contributing](#contributing)
- [See Also](#see-also)
- [License](#license)
<h2>Table of Contents</h2>
<details>
<summary>Table of Contents</summary>
<li><a href="#features">Features</a></li>
<li><a href="#demos">Demos</a></li>
<li><a href="#example">Example</a></li>
<li><a href="#philosophy">Philosophy</a></li>
<li><a href="#concepts">Concepts</a></li>
<li><a href="#api">API</a></li>
<li><a href="#faq">FAQ</a></li>
<li><a href="#installation">Installation</a></li>
<li><a href="#see-also">See Also</a></li>
</details>
## Features
- __minimal size:__ weighing `7kb`, `choo` is a tiny little framework
- __minimal size:__ weighing `5kb`, `choo` is a tiny little framework
- __single state:__ immutable single state helps reason about changes
- __small api:__ with only 6 methods, there's not a lot to learn
- __minimal tooling:__ built for the cutting edge `browserify` compiler
@@ -111,76 +110,27 @@
_note: If you've built something cool using `choo` or are using it in
production, we'd love to hear from you!_
## Getting started
## Example
Let's create an input box that changes the content of a textbox in real time.
[Click here to see the final app](http://requirebin.com/?gist=e589473373b3100a6ace29f7bbee3186).
First we import `choo` and create a new instance:
```js
const choo = require('choo')
const app = choo()
```
Then we define a model. We set an initial value of `state` and a `reducer` that
can be called to modify it:
```js
app.model({
state: { title: 'Set the title' },
reducers: {
update: (action, state) => ({ title: action.value })
}
})
```
Then we create a new view. It has an `h1` tag which displays the current title,
and an `<input>` field which sends the current value of the text box on every
input:
```js
const mainView = (params, state, send) => choo.view`
<main>
<h1>${state.title}</h1>
<input
type="text"
oninput=${(e) => send('update', { value: e.target.value })}>
</main>
`
```
_Note_: if an `id` property is defined on the outer-most element it will be
replaced.
We then bind the view to the `/` route on our application
```js
app.router((route) => [
route('/', mainView)
])
```
And then start the app and append it to the DOM. You can now run it and [see it
in action!](http://requirebin.com/?gist=e589473373b3100a6ace29f7bbee3186)
```js
const tree = app.start()
document.body.appendChild(tree)
```
And all together now:
[Click here to see the app running](http://requirebin.com/?gist=e589473373b3100a6ace29f7bbee3186).
```js
const choo = require('choo')
const html = require('choo/html')
const app = choo()
app.model({
state: { title: 'Set the title' },
state: { title: 'Not quite set yet' },
reducers: {
update: (action, state) => ({ title: action.value })
update: (data, state) => ({ title: data })
}
})
const mainView = (params, state, send) => choo.view`
const mainView = (state, prev, send) => html`
<main>
<h1>${state.title}</h1>
<h1>Title: ${state.title}</h1>
<input
type="text"
oninput=${(e) => send('update', { value: e.target.value })}>
oninput=${(e) => send('update', e.target.value)}>
</main>
`
@@ -192,6 +142,46 @@ const tree = app.start()
document.body.appendChild(tree)
```
To run it, save it as `client.js` and run with [budo][budo] and
[es2020][es2020]. These tools are convenient but any [browserify][browserify]
based tool should do:
```sh
$ budo 'client.js' -p 8080 --open -- -t es2020
```
And to save the output to files so it can be deployed, open a new terminal and
do:
```bash
$ mkdir -p 'dist/'
$ curl 'localhost:8080' > 'dist/index.html'
$ curl 'localhost:8080/bundle.js' > 'dist/bundle.js'
```
All using a couple of shell commands and `.js` files, no grandiose boilerplate
needed.
## Philosophy
We believe programming should be fun and light, not stern and stressful. It's
cool to be cute; using serious words without explaining them doesn't make for
better results - if anything it scares people off. We don't want to be scary,
we want to be nice and fun, and then _casually_ be the best choice around.
_Real casually._
We believe frameworks should be disposable, and components recyclable. We don't
like the current state of web development where walled gardens jealously
compete with one another. We want you to be free, not shackled to a damp
dungeon wall. By making the DOM the lowest common denominator, switching from
one framework to another becomes frictionless. Components should run anywhere
that has a DOM, regardless of the framework. `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.
We don't believe that bigger is better. Big APIs, big dependencies, large file
sizes - we see them as omens of impending userland complexity. We want
everyone on a team, no matter the size, to fully understand how an application
is laid out. And once an application is built, we want it to be small,
performant and easy to reason about. All of which makes for easy to debug code,
better results and super smiley faces.
## Concepts
`choo` cleanly structures internal data flow, so that all pieces of logic can
be combined into a nice, cohesive machine. Internally all logic lives within
@@ -201,9 +191,9 @@ sources of data. `effects` react to changes, perform an `action` and can then
post the results. `reducers` take data, modify it, and update the internal
`state`.
Communication of data is done using objects called `actions`. Each `action` has
any number of properties for data, and a unique `type` that can trigger
properties on the models.
Communication of data is done using something called `actions`. Each `action`
consists of a unique `actionName` and an optional payload of `data`, which can
be any value.
When a `reducer` modifies `state`, the `router` is called, which in turn calls
`views`. `views` take `state` and return [DOM][dom] nodes which are then
@@ -236,7 +226,7 @@ application logic. This is the _unidirectional_ architecture of `choo`.
- __views:__ take `state` and returns a new `DOM tree` that is rendered in the
browser
## Models
### Models
`models` are objects that contain initial `state`, `subscriptions`, `effects`
and `reducers`. They're generally grouped around a theme (or domain, if you
like). To provide some sturdiness to your `models`, they can either be
@@ -244,17 +234,17 @@ namespaced or not. Namespacing means that only state within the model can be
accessed. Models can still trigger actions on other models, though it's
recommended to keep that to a minimum.
So say we have a `todos` namespace, an `add` reducer and a `todos` model.
So say we have a `myTodos` namespace, an `add` reducer and a `todos` model.
Outside the model they're called by `send('todos:add')` and
`state.todos.todos`. Inside the namespaced model they're called by
`send('todos:add')` and `state.todos`. An example namespaced model:
```js
const app = choo()
app.model({
namespace: 'todos',
namespace: 'myTodos',
state: { todos: [] },
reducers: {
add: (action, state) => ({ todos: state.todos.concat(action.payload) })
add: (data, state) => ({ todos: state.todos.concat(data.payload) })
}
})
```
@@ -271,7 +261,7 @@ and has access to the full application state. Try and keep the logic in these
bulk of your logic will safely shielded, with only a few points touching every
part of your application.
## Effects
### Effects
Side effects are done through `effects` declared in `app.model()`. Unlike
`reducers` they cannot modify the state by returning objects, but get a
callback passed which is used to emit `actions` to handle results. Use effects
@@ -286,7 +276,19 @@ A typical `effect` flow looks like:
4. When the async call is done, either a success or error action is emitted
5. A reducer catches the action and updates the state
## Subscriptions
Examples of effects include: performing
[xhr](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest) requests
(server requests), calling multiple `reducers`, persisting state to
[localstorage][localstorage].
When an `effect` is done executing, it should call the `done(err, res)`
callback. This callback used to communicate when an `effect` is done, handle
possible errors and send values back to the caller. You'll probably notice when
applications become more complex, that composing multiple namespaced models
using higher level effects becomes real powerful - without becoming
complicated.
### Subscriptions
Subscriptions are a way of receiving data from a source. For example when
listening for events from a server using `SSE` or `Websockets` for a
chat app, or when catching keyboard input for a videogame.
@@ -297,15 +299,19 @@ const app = choo()
app.model({
namespace: 'app',
subscriptions: [
(send) => setInterval(() => send('app:print', { payload: 'dog?' }), 1000)
(send, done) => {
setInterval(() => send('app:print', { payload: 'dog?' }), 1000)
}
],
effects: {
print: (action, state) => console.log(action.payload)
print: (data, state) => console.log(data.payload)
}
})
```
If a `subscription` runs into an error, it can call `done(err)` to signal the
error to the error hook.
## Router
### Router
The `router` manages which `views` are rendered at any given time. It also
supports rendering a default `view` if no routes match.
@@ -327,338 +333,99 @@ figure out how views relate to each other.
Under the hood `choo` uses [sheet-router][sheet-router]. Internally the
currently rendered route is kept in `state.app.location`. If you want to modify
the location programmatically the `reducer` for the location can be called
using `send('app:location', { location: href })`. This will not work from
within namespaced `models`, and usage should preferably be kept to a minimum.
Changing views all over the place tends to lead to messiness.
using `send('location:setLocation', { location: href })`. This will not work
from within namespaced `models`, and usage should preferably be kept to a
minimum. Changing views all over the place tends to lead to messiness.
## Views
### Views
Views are pure functions that return a DOM tree for the router to render. Theyre passed the current state, and any time the state changes theyre run again with the new state.
Views are also passed the `send` function, which they can use to dispatch actions that can update the state. For example, the DOM tree can have an `onclick` handler that dispatches an `add` action.
```javascript
const view = (params, state, send) => {
return choo.view`
const view = (state, prev, send) => {
return html`
<div>
<h1>Total todos: ${state.todos.length}</h1>
<button onclick=${(e) => send('add', { payload: {title: 'demo'})}>Add</button>
<button onclick=${(e) => send('add', {title: 'demo'})}>
Add
</button>
</div>`
}
```
In this example, when the `Add` button is clicked, the view will dispatch an `add` action that the models `add` reducer will receive. [As seen above](#models), the reducer will add an item to the states `todos` array. The state change will cause this view to be run again with the new state, and the resulting DOM tree will be used to [efficiently patch the DOM](#does-choo-use-a-virtual-dom).
## Common Actions
### HTTP
`choo` ships with a built-in [`http` module](https://github.com/Raynos/xhr)
that weighs only `2.4kb`:
```js
const http = require('choo/http')
const choo = require('choo')
const app = choo()
app.model({
effects: {
'app:error': (state, event) => console.error(`error: ${event.payload}`),
'app:print': (state, event) => console.log(`http: ${event.payload}`),
'http:get_json': getJson,
'http:post_json': postJson,
'http:delete': httpDelete
}
})
function getJson (state, action, send) {
http.get('/my-endpoint', { json: true }, function (err, res, body) {
if (err) return send('app:error', { payload: err.message })
if (res.statusCode !== 200 || !body) {
return send('app:error', { payload:'something went wrong' })
}
send('app:print', { payload: body })
})
}
function postJson (state, action, send) {
const body = { foo: 'bar' }
http.post('/my-endpoint', { json: body }, function (err, res, body) {
if (err) return send('app:error', { payload: err.message })
if (res.statusCode !== 200 || !body) {
return send('app:error', { payload:'something went wrong' })
}
send('app:print', { payload: body })
})
}
function httpDelete (state, action, send) {
const body = { foo: 'bar' }
http.del('/my-endpoint', { json: body }, function (err, res, body) {
if (err) return send('app:error', { payload: err.message })
if (res.statusCode !== 200) {
return send('app:error', { payload:'something went wrong' })
}
})
}
```
Note that `http` only runs in the browser to prevent accidental requests when
rendering in Node. For more details view the [`raynos/xhr`
documentation](https://github.com/Raynos/xhr).
### Server Sent Events (SSE)
[Server Sent Events (SSE)][sse] allow servers to push data to the browser.
They're the unidirectional cousin of `websockets` and compliment `HTTP`
brilliantly. To enable `SSE`, create a new `EventSource`, point it at a local
uri (generally `/sse`) and setup a `subscription`:
```js
const stream = new document.EventSource('/sse')
app.model({
subscriptions: [
function (send) {
stream.onerror = (e) => send('app:error', { payload: JSON.stringify(e) })
stream.onmessage = (e) => send('app:print', { payload: e.data })
}
],
effects: {
'sse:close': () => stream.close(),
'app:error': (state, event) => console.error(`error: ${event.payload}`),
'app:print': (state, event) => console.log(`sse: ${event.payload}`)
}
})
```
This code does not handle reconnects, server timeouts, exponential backoff and
queueing data. You might want to use a package from `npm` or [write your
own][sse-reconnect] if you're building something for production.
### Keyboard
Most browsers have [basic support for keyboard events][keyboard-support]. To
capture keyboard events, setup a `subscription`:
```js
app.model({
namespace: 'input',
subscriptions: [
function (send) {
document.addEventListener(
'keypress',
(e) => send('input:print', { payload: e.keyCode })
)
}
],
effects: {
print: (state) => console.log(`pressed key: ${state.payload}`)
}
})
```
### WebSockets
[WebSockets][ws] allow for bidirectional communication between servers and
browsers:
```js
const socket = new document.WebSocket('ws://localhost:8081')
app.model({
subscriptions: [
function (send) {
socket.onerror = (e) => send('app:error', { payload: JSON.stringify(e) })
socket.onmessage = (e) => send('app:print', { payload: e.data })
}
],
effects: {
'ws:close': () => socket.close(),
'ws:send': (state, event) => socket.send(JSON.stringify(event.payload)),
'app:error': (state, event) => console.error(`error: ${event.payload}`),
'app:print': (state, event) => console.log(`ws: ${event.payload}`)
}
})
```
This code does not handle reconnects, server timeouts, exponential backoff and
queueing data. You might want to use a package from `npm` or [write your
own][ws-reconnect] if you're building something for production.
### Forms
Forms and lists are probably the most used concepts on any page. Together with
links they comprise most of what can be done on web pages.
```js
const choo = require('choo')
const http = require('choo/http')
const app = choo()
function view (params, state, send) {
return choo.view`
<form onsubmit=${onSubmit}>
<fieldset>
<label>username</label>
<input type="text" name="username" autofocus>
</fieldset>
<fieldset>
<label>password</label>
<input type="password" name="password">
</fieldset>
<input type="submit" value="Submit">
</form>
`
function onSubmit (event) {
send('login', { data: new FormData(event.target) })
event.preventDefault()
}
}
app.model({
effects: {
login: (action, state, send) => {
http.post('/login', { body: action.data }, (err, res, body) => {
send('authorize', { payload: body })
})
}
}
})
app.router((route) => [
route('/', view)
])
app.start()
```
If you want a form element to be selected when it's loaded, add the
[`autofocus`][html-input] property.
```js
const view = choo.view`
<form>
<input type="text" autofocus>
</form>
`
```
### Links
In HTML links are represented with the `<a href="/some-location">` tag. By
default `choo` enables a `subscription` for all `a` tags on a page. When a link
is clicked, the click event is caught, and the value of `href` is passed into
the router causing a state change. If you want to disable this behavior, set
`app.start({ href: false })`.
```js
const nav = choo.view`
<a href="/">home</a>
<a href="/first-link">first link</a>
<a href="/second-link">second link</a>
`
```
### Rendering in Node
Sometimes it's necessary to render code inside of Node; for serving hyper fast
first requests, testing or other purposes. Applications that are capable of
being rendered in both Node and the browser are called
_[isomorphic][isomorphic]_.
Rendering in Node is slightly different than in the browser. First off, to
maintain performance all calls to `subscriptions`, `effects`, and `reducers`
are disabled. That means you need to know what the state of your application is
going to be _before_ you render it - no cheating!
Secondly, the `send()` method inside `router` and `view` has been disabled. If
you call it your program will crash. Disabling all these things means that your
program will render [`O(n)`][big-o], which is super neat. Off to [10.000
QPS][qps] we go!
To render in Node call the `.toString()` method instead of `.start()`. The
first argument is the path that should be rendered, the second is the state:
```js
const http = require('http')
const client = require('./client') // path to client entry point
http.createServer(function (req, res) {
const html = client.toString('/', { message: 'hello server!' })
res.setHeader('Content-Type', 'text/html; charset=utf-8')
res.end(html)
})
```
In order to make our `choo` app call `app.start()` in the browser and be
`require()`-able in Node, we check if [`module.parent`][module-parent] exists:
```js
const choo = require('choo')
const app = choo()
app.router((route) => [
route('/', (params, state, send) => choo.view`
<h1>${state.message}</h1>
`)
])
if (module.parent) module.exports = app
else document.body.appendChild(app.start())
```
#### Rehydration
Now that your application is successfully rendering in Node, the next step would
be to make it load a JavaScript bundle once has loaded the HTML. To do this we
will use a technique called _rehydration_.
_Rehydration_ is when you take the static, server-rendered version of your
application (static HTML, _dehydrated_ because it has no logic) and _rehydrate_
it by booting up the JS and attaching event handlers on the DOM to make it
dynamic again. It's like restoring flavor to cup noodles by adding hot water.
Because we're using something called `morphdom` under the hood, all we need is
point at an `id` at the root of the application. The syntax for this is
slightly different from what we've seen so far, because we're _updating_ a
dehydrated DOM nodes to make them dynamic, rather than a new DOM tree and
attaching it to the DOM.
```js
const choo = require('choo')
const app = choo()
app.router((route) => [
route('/', (params, state, send) => choo.view`
<h1 id="app-root">${state.message}</h1>
`)
])
if (module.parent) module.exports = app
else app.start('#app-root'))
```
When the JS is booted on top of the dehydrated application, it will look for
the `#app-root` id and load on top of it. You can choose any name you like for
the id, but __make sure it's the same on every possible top level DOM node__,
or else things might break. Furthermore to ensure things go smoothly, try and
keep the initial state identical on both the server and the client.
And that's it! If you want to go down the route of mad performance, consider
make all first request static and caching them using something like [bl][bl],
[nginx][nginx], [varnish][varnish] or a global CDN.
## API
### app = choo()
Create a new `choo` app
This section provides documentation on how each function in `choo` works. It's
intended to be a technical reference. If you're interested in learning choo for
the first time, consider reading through the [handbook][handbook] or
[concepts](#concepts) first :sparkles:
### app = choo(opts)
Initialize a new `choo` app. Takes an optional object of handlers. Handlers can
be:
- __onError(err, state, createSend):__ called when an `effect` or
`subscription` emit an error. If no handler is passed, the default handler
will `throw` on each error.
- __onAction(action, state, name, caller, createSend):__ called when an
`action` is fired.
- __onStateChange(action, state, prev, caller, createSend):__ called after a
reducer changes the `state`.
`createSend()` is a special function that allows the creation of a new named
`send()` function. The first argument should be a string which is the name, the
second argument is a boolean `callOnError` which can be set to `true` to call
the `onError` hook istead of a provided callback. It then returns a
`send(actionName, data?)` function.
Handlers should be used with care, as they're the most powerful interface into
the state. For application level code it's generally recommended to delegate to
actions inside models using the `send()` call, and only shape the actions
inside the handlers.
### app.model(obj)
Create a new model. Models modify data and perform IO. Takes the following
arguments:
- __namespace:__ optional namespace that prefixes the keys in `state`,
`reducers` and `effects`. Also limits `actions` called by `send()` to
in-namespace only.
- __state:__ object. Key value store of initial values
- __reducers:__ object. Syncronous functions that modify state. Each function
has a signature of `(action, state)`
- __effects:__ object. Asyncronous functions that perform IO. Each function has
a signature of `(action, state, send)` where `send` is a reference to
`app.send()`
- __namespace:__ namespace the model so that it cannot access any properties
and handlers in other models
- __state:__ initial values of `state` inside the model
- __reducers:__ synchronous operations that modify state. Triggered by
`actions`. Signature of `(data, state)`.
- __effects:__ asynchronous operations that don't modify state directly.
Triggered by `actions`, can call `actions`. Signature of `(data, state,
send, done)`
- __subscriptions:__ asynchronous read-only operations that don't modify state
directly. Can call `actions`. Signature of `(state, send, done)`.
### choo.view\`html\`
Tagged template string HTML builder. See
[`yo-yo`](https://github.com/maxogden/yo-yo) for full documentation. Views
should be passed to `app.router()`
#### send(actionName, data?)
Send a new action to the models with optional data attached. Namespaced models
can be accessed by prefixing the name with the namespace separated with a `:`,
e.g. `namespace:name`.
### app.router(params, state, send)
Creates a new router. See
#### done(err?, res?)
When an `effect` or `subscription` is done executing, or encounters an error,
it should call the final `done(err, res)` callback. If an `effect` was called
by another `effect` it will call the callback of the caller. When an error
propegates all the way to the top, the `onError` handler will be called,
registered in `choo(handlers)`. If no callback is registered, errors will
`throw`.
### app.router(defaultRoute?, (route) => [routes])
Creates a new router. Takes a function that exposes a single `route` function,
and that expects a tree of `routes` to be returned. See
[`sheet-router`](https://github.com/yoshuawuyts/sheet-router) for full
documentation. Registered views have a signature of `(params, state, send)`,
where `params` is URI partials.
documentation. Registered views have a signature of `(state, prev, send)`,
where `state` is the current `state`, `prev` is the last state, `state.params`
is URI partials and `send()` can be called to trigger actions. If
`defaultRoute` is passed in, that will be called if no paths match. If no
`defaultRoute` is specified it will throw instead.
### html = app.toString(route, state)
### html = app.toString(route, state?)
Render the application to a string of HTML. Useful for rendering on the server.
First argument is a path that's passed to the router. Second argument is the
state object. When calling `.toString()` instead of `.start()`, all calls to
`send()` are disabled, and `subscriptions`, `effects` and `reducers` aren't
loaded. See [rendering in Node](#rendering-in-node) for an in-depth guide.
First argument is a path that's passed to the router. Second argument is an
optional state object. When calling `.toString()` instead of `.start()`, all
calls to `send()` are disabled, and `subscriptions`, `effects` and `reducers`
aren't loaded.
### tree = app.start(rootId?, opts)
Start the application. Returns a tree of DOM nodes that can be mounted using
@@ -667,8 +434,8 @@ first argument, the tree will diff against the selected node rather than be
returned. This is useful for [rehydration](#rehydration). Opts can contain the
following values:
- __opts.history:__ default: `true`. Enable a `subscription` to the browser
history API. e.g. updates the internal `state.location` state whenever the
browser "forward" and "backward" buttons are pressed.
history API. e.g. updates the internal `location.href` state whenever the
browsers "forward" and "backward" buttons are pressed.
- __opts.href:__ default: `true`. Handle all relative `<a
href="<location>"></a>` clicks and update internal `state.location`
accordingly.
@@ -677,40 +444,40 @@ following values:
changes (eg `localhost/#posts/123`). Enabling this option automatically
disables `opts.history` and `opts.href`.
## Errors
### Could not find DOM node (#id) to update
This means that a re-render of the DOM was triggered before the first render
was done. This is usually the case when `send()` is called inside a
`subscription` before the DOM is done rendering. Instead try listening for a
`'DOMContentLoaded'` event:
```js
document.addEventListener('DOMContentLoaded', (e) => send('init'))
```
### view = require('choo/html')\`html\`
Tagged template string HTML builder. Built on top of [yo-yo][yo-yo], [bel][bel]
and [hyperx][hyperx]. To register a view on the `router` it should be wrapped
in a function with the signature of `(state, prev, send)` where `state` is the
current `state`, `prev` is the last state, `state.params` is URI partials and
`send()` can be called to trigger actions.
### send() cannot be called on the server
This means a `send()` event was triggered in Node. In Node, `reducers`,
`effects` and `subscriptions` are disabled for performance reasons, so if
`send()` was called to trigger an action it wouldn't work. Try finding where in
the DOM tree `send()` is called, and disable it when called from within Node.
To create listeners for events, create interpolated attributes on elements.
```js
const html = require('choo/html')
html`
<button onclick=${(e) => console.log(e)}>click for bananas</button>
`
```
Example listeners include: `onclick`, `onsubmit`, `oninput`, `onkeydown`,
`onkeyup`. A full list can be found [at the yo-yo
repo](https://github.com/maxogden/yo-yo/blob/master/update-events.js). When
creating listeners always remember to call `e.preventDefault()` on the event so
it doesn't bubble up and do stuff like refreshing the full page or the like.
To trigger lifecycle events on any part of a view, set the `onload=${(el) =>
{}}` and `onunload=${() => {el}}` attributes. These parameters are useful when
creating self-contained widgets that take care of their own state and lifecycle
(e.g. a maps widget) or to trigger animations. Most elements shouldn't have a
need for these hooks though.
## FAQ
### Why did you build this?
`choo` is nothing but a formalization of how I've been building my applications
for the past year. I originally used `virtual-dom` with `virtual-app` and
`wayfarer` where now it's `yo-yo` with `send-action` and `sheet-router`. The
main benefit of using `choo` over these technologies separately is that it
becomes easier for teams to pick up and gather around. The code base for `choo`
itself is super petite (`~200` LOC) and mostly acts to enforce structure around
some excellent npm packages. This is my take on modular frameworks; I hope
you'll find it pleasant.
### Why is it called choo?
Because I thought it sounded cute. All these programs talk about being
"performant", "rigid", "robust" - I like programming to be light, fun and
_"performant"_, _"rigid"_, _"robust"_ - I like programming to be light, fun and
non-scary. `choo` embraces that.
Also imagine telling some business people you chose to rewrite something
critical to the company using the `choo` framework.
critical to the company using `choo`.
:steam_locomotive::train::train::train:
### Why is it a framework, and not a library?
@@ -718,7 +485,13 @@ I love small libraries that do one thing well, but when working in a team,
having an undocumented combination of packages often isn't great. `choo()` is a
small set of packages that work well together, wrapped in an an architectural
pattern. This means you get all the benefits of small packages, but get to be
productive right from the start.
productive right from the start without needing to plough through layers of
boilerplate.
### Is it called choo, choo.js or...?
It's called "choo", though we're fine if you call it "choo-choo" or
"chugga-chugga-choo-choo" too. The only time "choo.js" is tolerated is if /
when you shimmy like you're a locomotive.
### How does choo compare to X?
Ah, so this is where I get to rant. `choo` (_chugga-chugga-chugga-choo-choo!_)
@@ -763,9 +536,16 @@ give you my opinions directly. Ready? Here goes:
provides framework lock in, and additionally doesn't have a clean enough
architecture. I appreciate what it does, but don't think it's the answer.
### Why can't send() be called on the server?
In Node, `reducers`, `effects` and `subscriptions` are disabled for performance
reasons, so if `send()` was called to trigger an action it wouldn't work. Try
finding where in the DOM tree `send()` is called, and disable it when called
from within Node.
### Which packages was choo built on?
- __views:__ [`yo-yo`](https://github.com/maxogden/yo-yo)
- __models:__ [`send-action`](https://github.com/sethvincent/send-action),
- __views:__ [`yo-yo`](https://github.com/maxogden/yo-yo),
[`bel`](https://github.com/shama/bel)
- __models:__ [`barracks`](https://github.com/yoshuawuyts/barracks),
[`xtend`](https://github.com/raynos/xtend)
- __routes:__ [`sheet-router`](https://github.com/yoshuawuyts/sheet-router)
- __http:__ [`xhr`](https://github.com/Raynos/xhr)
@@ -777,13 +557,6 @@ with DOM nodes][morphdom-bench], and it has the added benefit of working with
_any_ library that produces valid DOM nodes. So to put a long answer short:
we're using something even better.
### What packages do you recommend to pair with choo?
- [tachyons](https://github.com/tachyons-css/tachyons) - functional CSS for
humans
- [sheetify](https://github.com/stackcss/sheetify) - modular CSS bundler for
browserify
- [pull-stream](https://github.com/pull-stream/pull-stream) - minimal streams
### How can I optimize choo?
`choo` really shines when coupled with `browserify` transforms. They can do
things like reduce file size, prune dependencies and clean up boilerplate code.
@@ -791,7 +564,11 @@ Consider running some of the following:
- [unassertify](https://github.com/twada/unassertify) - remove `assert()`
statements which reduces file size. Use as a `--global` transform
- [es2020](https://github.com/yoshuawuyts/es2020) - backport `const`,
`fat-arrows` and `template strings` to older browsers
`fat-arrows` and `template strings` to older browsers. Should be run as a
`--global` transform
- [yo-yoify](https://github.com/shama/yo-yoify) - replace the internal `hyperx`
dependency with `document.createElement` calls; greatly speeds up performance
too
- [uglifyify](https://github.com/hughsk/uglifyify) - minify your code using
UglifyJS2. Use as a `--global` transform
- [bulkify](https://www.npmjs.com/package/bulkify) - transform inline
@@ -816,6 +593,7 @@ Generally for production builds you'll want to run:
```sh
$ NODE_ENV=production browserify \
-t envify \
-g yo-yoify \
-g unassertify \
-g es2020 \
-g uglifyify \
@@ -841,20 +619,22 @@ Sure.
$ npm install choo
```
## Contributing
Browser tests can be run with the right credentials via the `npm run test:browser`
command. This will be run automatically when `npm version` is executed.
You may skip the tests by providing `SKIP_TEST=true` when running the version
command.
## See Also
- [choo-handbook](https://github.com/yoshuawuyts/choo-handbook) - the little
`choo` guide
- [budo](https://github.com/mattdesl/budo) - quick prototyping tool for
`browserify`
- [stack.gl](http://stack.gl/) - open software ecosystem for WebGL
- [yo-yo](https://github.com/maxogden/yo-yo) - tiny library for modular UI
- [bel](https://github.com/shama/bel) - composable DOM elements using template
strings
- [tachyons](https://github.com/tachyons-css/tachyons) - functional CSS for
humans
- [sheetify](https://github.com/stackcss/sheetify) - modular CSS bundler for
`browserify`
- [pull-stream](https://github.com/pull-stream/pull-stream) - minimal streams
- [es2020](https://github.com/yoshuawuyts/es2020) - because in hindsight we
don't need most of ES6
## License
[MIT](https://tldrlegal.com/license/mit-license)
@@ -878,3 +658,11 @@ command.
[sheet-router]: https://github.com/yoshuawuyts/sheet-router
[html-input]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input
[inu]: https://github.com/ahdinosaur/inu
[yo-yo]: https://github.com/maxogden/yo-yo
[bel]: https://github.com/shama/bel
[hyperx]: https://github.com/substack/hyperx
[budo]: https://github.com/mattdesl/budo
[es2020]: https://github.com/yoshuawuyts/es2020
[browserify]: https://github.com/substack/browserify
[localstorage]: https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage
[handbook]: https://github.com/yoshuawuyts/choo-handbook
+20 -1
View File
@@ -2,7 +2,26 @@ const choo = require('../../')
const mainView = require('./views/main')
const app = choo()
const app = choo({
onError: function (err, state, createSend) {
console.groupCollapsed(`Error: ${err.message}`)
console.error(err)
console.groupEnd()
const send = createSend('onError: ')
send('app:error', err)
},
onAction: function (data, state, name, caller, createSend) {
console.groupCollapsed(`Action: ${caller} -> ${name}`)
console.log(data)
console.groupEnd()
},
onStateChange: function (data, state, prev, createSend) {
console.groupCollapsed('State')
console.log(prev)
console.log(state)
console.groupEnd()
}
})
app.model(require('./models/error'))
app.model(require('./models/api'))
+10 -10
View File
@@ -6,26 +6,26 @@ module.exports = {
title: 'Button pushing machine 3000'
},
reducers: {
set: (action, state) => ({ 'title': action.payload })
set: (data, state) => ({ 'title': data })
},
effects: {
good: (action, state, send) => request('/good', send),
bad: (action, state, send) => request('/bad', send)
good: function (data, state, send, done) {
request('/good', send, done)
},
bad: (data, state, send, done) => request('/bad', send, done)
}
}
function request (uri, send, state) {
function request (uri, send, done) {
http(uri, { json: true }, function (err, res, body) {
if (err) return send('app:error', { payload: 'HTTP error' })
if (err) return done(new Error('HTTP error'))
if (res.statusCode !== 200) {
const message = (body && body.message)
? body.message
: 'unknown server error'
return send('app:error', { payload: message })
return done(new Error(message))
}
if (!body) {
return send('app:error', { payload: 'fatal: no body received' })
}
send('api:set', { payload: body.message || body.title })
if (!body) return done(new Error('fatal: no body received'))
send('api:set', body.message || body.title, done)
})
}
+28 -19
View File
@@ -10,34 +10,43 @@ const ERROR_TIMEOUT = 1000
module.exports = {
namespace: 'app',
state: {
error: [],
errorTimeDone: null,
errors: [],
errorTimeDone: 0,
triggerTime: null
},
reducers: {
error: function (action, state) {
const now = Date.now()
const timeDone = state.errorTimeDone
const newTimestamp = (timeDone && timeDone >= now)
? timeDone + ERROR_TIMEOUT
: now + ERROR_TIMEOUT
setError: function (data, state) {
return {
error: state.error.concat(action.payload),
errorTimeDone: newTimestamp
errors: state.errors.concat(data.message),
errorTimeDone: data.errorTimeDone
}
},
'error:delete': function (action, state) {
state.error.shift()
return { error: state.error }
'delError': function (data, state) {
state.errors.shift()
return { errors: state.errors }
}
},
effects: {
error: function (action, state, send) {
const timeout = state.errorTimeDone - Date.now()
setTimeout(function () {
send('app:error:delete')
}, timeout)
error: function (err, state, send, done) {
const timeDone = state.errorTimeDone
const now = Date.now()
const timeStamp = (timeDone && timeDone >= now)
? timeDone + ERROR_TIMEOUT
: now + ERROR_TIMEOUT
const timeout = timeStamp - now
const errAction = {
message: err.message,
errorTimeDone: timeStamp
}
send('app:setError', errAction, function (err) {
if (err) return done(err)
setTimeout(function () {
send('app:delError', done)
}, timeout)
})
}
}
}
+4 -4
View File
@@ -1,9 +1,9 @@
const choo = require('../../../')
const html = require('../../../html')
module.exports = function (params, state, send) {
const error = state.app.error[0]
module.exports = function (state, prev, send) {
const error = state.app.errors[0]
const title = state.api.title
return choo.view`
return html`
<section>
<h1>${title}</h1>
<h2>Latest error: ${error}</h2>
+5 -4
View File
@@ -1,10 +1,11 @@
const dateformat = require('dateformat')
const choo = require('../../../')
const html = require('../../../html')
module.exports = function (params, state, send) {
module.exports = function (state, prev, send) {
const params = state.params
const mailbox = params.mailbox
const messages = state[mailbox].messages
return choo.view`
return html`
<div>
<div class="db cf w-100">
<div class="fl mb3 w-25 mt0 b">Date</th>
@@ -20,7 +21,7 @@ module.exports = function (params, state, send) {
}
function createMessage (message, mailbox) {
return choo.view`
return html`
<div class="db cf w-100">
<a href="${'/' + mailbox + '/' + message.id}">
<div class="fl mb3 w-25 f6 link">
+5 -4
View File
@@ -1,6 +1,7 @@
const choo = require('../../../')
const html = require('../../../html')
module.exports = function (params, state, send) {
module.exports = function (state, prev, send) {
const params = state.params
const mailbox = params.mailbox
const message = params.message
@@ -8,7 +9,7 @@ module.exports = function (params, state, send) {
return String(msg.id) === message
})[0]
return choo.view`
return html`
<div>
${email ? createEmail(email) : 'error: no email found'}
</div
@@ -16,7 +17,7 @@ module.exports = function (params, state, send) {
}
function createEmail (message) {
return choo.view`
return html`
<div class="mail">
<dl>
<dt>From</dt>
+3 -3
View File
@@ -1,7 +1,7 @@
const choo = require('../../../')
const html = require('../../../html')
module.exports = function (params, state, send) {
return choo.view`
module.exports = function (state, prev, send) {
return html`
<section>
<p>Select a mailbox</p>
</section>
+8 -7
View File
@@ -1,8 +1,9 @@
const dateformat = require('dateformat')
const choo = require('../../../')
const html = require('../../../html')
module.exports = function () {
return function (params, state, send) {
return function (state, prev, send) {
const params = state.params
const mailbox = params.mailbox
const message = params.message
const messages = state[mailbox].messages
@@ -12,7 +13,7 @@ module.exports = function () {
return String(msg.id) === message
})[0]
return choo.view`
return html`
<section class="fl mt4 w-80 db">
<div>
${createHeader()}
@@ -26,7 +27,7 @@ module.exports = function () {
</section>
`
} else {
return choo.view`
return html`
<section class="fl mt4 w-80 db">
${createHeader()}
${messages.map(function (msg) {
@@ -39,7 +40,7 @@ module.exports = function () {
}
function createHeader () {
return choo.view`
return html`
<div class="db cf w-100">
<div class="fl mb3 w-25 mt0 b">Date</th>
<div class="fl mb3 w-25 mt0 b">Subject</th>
@@ -50,7 +51,7 @@ function createHeader () {
}
function createMessage (message, mailbox) {
return choo.view`
return html`
<div class="db cf w-100">
<a href="${'/' + mailbox + '/' + message.id}">
<div class="fl mb3 w-25 f6 link">
@@ -65,7 +66,7 @@ function createMessage (message, mailbox) {
}
function createEmail (message) {
return choo.view`
return html`
<div class="mail">
<dl>
<dt>From</dt>
+4 -4
View File
@@ -1,9 +1,9 @@
const choo = require('../../../')
const html = require('../../../html')
const mailboxes = [ 'inbox', 'spam', 'sent' ]
module.exports = function (params, state, send) {
return choo.view`
module.exports = function (state, prev, send) {
return html`
<aside class="fl mt4 w-20 db">
<ul>
<li>
@@ -19,7 +19,7 @@ module.exports = function (params, state, send) {
}
function createLi (mailbox, messages) {
return choo.view`
return html`
<li class="mt4 f6">
<a href="/${mailbox}">
${mailbox.charAt(0).toUpperCase() + mailbox.slice(1)}
+4 -4
View File
@@ -1,9 +1,9 @@
const pathname = require('pathname-match')
const choo = require('../../../')
const html = require('../../../html')
module.exports = function (params, state, send) {
const location = state.app.location
return choo.view`
module.exports = function (state, prev, send) {
const location = state.location.pathname
return html`
<span class="fl mt4 w-100 f4 b">
URL: ${pathname(location) || '/'}
</span>
+7 -5
View File
@@ -6,16 +6,18 @@
"scripts": {
"start": "NODE_ENV=development node server.js"
},
"browserify": {
"transform": [
"sheetify/transform"
]
},
"author": "Yoshua Wuyts <i@yoshuawuyts.com>",
"license": "ISC",
"dependencies": {
"css-wipe": "^4.2.1",
"dateformat": "^1.0.12",
"tachyons": "^4.0.0-beta.33"
},
"devDependencies": {
"bankai": "^2.0.5",
"browserify": "^13.0.1",
"insert-css": "^0.2.0",
"server-router": "^2.1.0",
"sheetify": "^5.0.3"
}
}
+7 -7
View File
@@ -1,18 +1,18 @@
const choo = require('../../../')
const html = require('../../../html')
const emailList = require('../elements/email-list')
const pathname = require('../elements/pathname')
const email = require('../elements/email')
const nav = require('../elements/nav')
module.exports = function (params, state, send) {
return choo.view`
module.exports = function (state, prev, send) {
return html`
<main class="mw5 mw7-ns center cf">
${pathname(params, state, send)}
${nav(params, state, send)}
${pathname(state, prev, send)}
${nav(state, prev, send)}
<section class="fl mt4 w-80 db">
${emailList(params, state, send)}
${email(params, state, send)}
${emailList(state, prev, send)}
${email(state, prev, send)}
</section>
</main>
`
+6 -6
View File
@@ -1,15 +1,15 @@
const choo = require('../../../')
const html = require('../../../html')
const empty = require('../elements/empty-mailbox')
const pathname = require('../elements/pathname')
const nav = require('../elements/nav')
module.exports = function (params, state, send) {
return choo.view`
module.exports = function (state, prev, send) {
return html`
<main class="mw5 mw7-ns center cf">
${pathname(params, state, send)}
${nav(params, state, send)}
${empty(params, state, send)}
${pathname(state, prev, send)}
${nav(state, prev, send)}
${empty(state, prev, send)}
</main>
`
}
+6 -6
View File
@@ -1,16 +1,16 @@
const choo = require('../../../')
const html = require('../../../html')
const emailList = require('../elements/email-list')
const pathname = require('../elements/pathname')
const nav = require('../elements/nav')
module.exports = function (params, state, send) {
return choo.view`
module.exports = function (state, prev, send) {
return html`
<main class="mw5 mw7-ns center cf">
${pathname(params, state, send)}
${nav(params, state, send)}
${pathname(state, prev, send)}
${nav(state, prev, send)}
<section class="fl mt4 w-80 db">
${emailList(params, state, send)}
${emailList(state, prev, send)}
</section>
</main>
`
+3 -3
View File
@@ -1,14 +1,14 @@
const assert = require('assert')
const choo = require('../../../')
const html = require('../../../html')
module.exports = function (params, state, send) {
module.exports = function (state, prev, send) {
const serverMessage = state.message.server
const clientMessage = state.message.client
assert.equal(typeof serverMessage, 'string', 'server should be a string')
assert.equal(typeof clientMessage, 'string', 'client should be a string')
return choo.view`
return html`
<section id="app-root">
<h1>server message: ${serverMessage}</h1>
<h1>client message: ${clientMessage}</h1>
+5 -4
View File
@@ -1,4 +1,5 @@
const choo = require('../../')
const html = require('../../html')
const app = choo()
app.model(createModel())
@@ -10,7 +11,7 @@ const tree = app.start()
document.body.appendChild(tree)
function mainView (params, state, send) {
return choo.view`
return html`
<div>${state.logger.msg}</div>
`
}
@@ -34,13 +35,13 @@ function createModel () {
}
],
reducers: {
'print': (action, state) => {
return ({ msg: state.msg + ' ' + action.payload })
'print': (data, state) => {
return ({ msg: state.msg + ' ' + data.payload })
}
},
effects: {
close: () => stream.close(),
error: (action, state) => console.error(`error: ${action.payload}`)
error: (data, state) => console.error(`error: ${data.payload}`)
}
}
}
+2 -2
View File
@@ -1,9 +1,9 @@
const choo = require('../../../')
const html = require('../../../html')
module.exports = function (params, state, send) {
const error = state.app.error[0]
const title = state.api.title
return choo.view`
return html`
<section>
<h1>${title}</h1>
<h2>Latest error: ${error}</h2>
+5 -4
View File
@@ -1,4 +1,5 @@
const choo = require('../../')
const html = require('../../html')
const app = choo()
app.model({
@@ -7,15 +8,15 @@ app.model({
title: 'my demo app'
},
reducers: {
update: (action, state) => ({ title: action.payload })
update: (data, state) => ({ title: data.payload })
},
effects: {
update: (action, state, send) => (document.title = action.payload)
update: (data, state, send) => (document.title = data.payload)
}
})
const mainView = (params, state, send) => {
return choo.view`
const mainView = (state, prev, send) => {
return html`
<main class="app">
<h1>${state.input.title}</h1>
<label>Set the title</label>
+15
View File
@@ -0,0 +1,15 @@
{
"name": "title",
"version": "1.0.0",
"description": "",
"main": "client.js",
"scripts": {
"start": "budo client.js -p 8080"
},
"keywords": [],
"author": "Yoshua Wuyts <i@yoshuawuyts.com>",
"license": "ISC",
"dependencies": {
"budo": "^8.3.0"
}
}
+1
View File
@@ -0,0 +1 @@
module.exports = require('yo-yo')
+103 -186
View File
@@ -1,23 +1,27 @@
const history = require('sheet-router/history')
const sheetRouter = require('sheet-router')
const document = require('global/document')
const onReady = require('document-ready')
const href = require('sheet-router/href')
const hash = require('sheet-router/hash')
const hashMatch = require('hash-match')
const sendAction = require('send-action')
const mutate = require('xtend/mutable')
const barracks = require('barracks')
const assert = require('assert')
const xtend = require('xtend')
const yo = require('yo-yo')
choo.view = yo
module.exports = choo
// framework for creating sturdy web applications
// null -> fn
function choo () {
const _models = []
var _router = null
function choo (opts) {
opts = opts || {}
const _store = start._store = barracks(xtend(opts, { onStateChange: render }))
var _router = start._router = null
var _defaultRoute = null
var _rootNode = null
var _routes = null
start.toString = toString
start.router = router
@@ -29,227 +33,140 @@ function choo () {
// render the application to a string
// (str, obj) -> str
function toString (route, serverState) {
const initialState = {}
const nsState = {}
serverState = serverState || {}
assert.equal(typeof route, 'string', 'choo.app.toString: route must be a string')
assert.equal(typeof serverState, 'object', 'choo.app.toString: serverState must be an object')
_store.start({ subscriptions: false, reducers: false, effects: false })
_models.forEach(function (model) {
const ns = model.namespace
if (ns) {
if (!nsState[ns]) nsState[ns] = {}
apply(ns, model.state, nsState)
nsState[ns] = xtend(nsState[ns], serverState[ns])
} else {
apply(model.namespace, model.state, initialState)
const state = _store.state({ state: serverState })
const router = createRouter(_defaultRoute, _routes, createSend)
const tree = router(route, state)
return tree.outerHTML || tree.toString()
function createSend () {
return function send () {
assert.fail('choo: send() cannot be called from Node')
}
})
const state = xtend(initialState, xtend(serverState, nsState))
const tree = _router(route, state, function () {
throw new Error('send() cannot be called on the server')
})
return tree.toString()
}
}
// start the application
// (str?, obj?) -> DOMNode
function start (rootId, opts) {
if (!opts && typeof rootId !== 'string') {
opts = rootId
rootId = null
function start (selector, startOpts) {
if (!startOpts && typeof selector !== 'string') {
startOpts = selector
selector = null
}
opts = opts || {}
const name = opts.name || 'choo'
const initialState = {}
const reducers = {}
const effects = {}
startOpts = startOpts || {}
_models.push(appInit(opts))
_models.forEach(function (model) {
if (model.state) apply(model.namespace, model.state, initialState)
if (model.reducers) apply(model.namespace, model.reducers, reducers)
if (model.effects) apply(model.namespace, model.effects, effects)
})
_store.model(appInit(startOpts))
const createSend = _store.start(startOpts)
_router = start._router = createRouter(_defaultRoute, _routes, createSend)
const state = _store.state({state: {}})
// send() is used to trigger actions inside
// views, effects and subscriptions
const send = sendAction({
onaction: handleAction,
onchange: onchange,
state: initialState
})
// subscriptions are loaded after sendAction() is called
// because they both need access to send() and can't
// react to actions (read-only) - also wait on DOM to
// be loaded
document.addEventListener('DOMContentLoaded', function () {
_models.forEach(function (model) {
if (model.subscriptions) {
assert.ok(Array.isArray(model.subscriptions), 'subs must be an arr')
model.subscriptions.forEach(function (sub) {
sub(send)
})
}
})
})
// If an id is provided, the application will rehydrate
// on the node. If no id is provided it will return
// a tree that's ready to be appended to the DOM.
//
// The rootId is determined to find the application root
// on update. Since the DOM nodes change between updates,
// we must call document.querySelector() to find the root.
// Use different names when loading multiple choo applications
// on the same page
if (rootId) {
document.addEventListener('DOMContentLoaded', function (event) {
rootId = rootId.replace(/^#/, '')
const oldTree = document.querySelector('#' + rootId)
assert.ok(oldTree, 'could not find node #' + rootId)
const newTree = _router(send.state().app.location, send.state(), send)
yo.update(oldTree, newTree)
})
} else {
rootId = name + '-root'
const tree = _router(send.state().app.location, send.state(), send)
tree.setAttribute('id', rootId)
if (!selector) {
const tree = _router(state.location.pathname, state)
_rootNode = tree
return tree
} else {
onReady(function onReady () {
const oldTree = document.querySelector(selector)
assert.ok(oldTree, 'could not query selector: ' + selector)
const newTree = _router(state.location.pathname, state)
_rootNode = yo.update(oldTree, newTree)
})
}
}
// update the DOM after every state mutation
// (obj, obj, obj, str, fn) -> null
function render (data, state, prev, name, createSend) {
if (opts.onStateChange) {
opts.onStateChange(data, state, prev, name, createSend)
}
// handle an action by either reducers, effects
// or both - return the new state when done
// (obj, obj, fn) -> obj
function handleAction (action, state, send) {
var reducersCalled = false
var effectsCalled = false
const newState = xtend(state)
// validate if a namespace exists. Namespaces
// are delimited by the first ':'. Perhaps
// we'll allow recursive namespaces in the
// future - who knows
if (/:/.test(action.type)) {
const arr = action.type.split(':')
var ns = arr.shift()
action.type = arr.join(':')
}
const _reducers = ns ? reducers[ns] : reducers
if (_reducers && _reducers[action.type]) {
if (ns) {
const reducedState = _reducers[action.type](action, state[ns])
if (!newState[ns]) newState[ns] = {}
mutate(newState[ns], xtend(state[ns], reducedState))
} else {
mutate(newState, reducers[action.type](action, state))
}
reducersCalled = true
}
const _effects = ns ? effects[ns] : effects
if (_effects && _effects[action.type]) {
if (ns) _effects[action.type](action, state[ns], send)
else _effects[action.type](action, state, send)
effectsCalled = true
}
if (!reducersCalled && !effectsCalled) {
throw new Error('Could not find action ' + action.type)
}
// allows (newState === oldState) checks
return (reducersCalled) ? newState : state
}
// update the DOM after every state mutation
// (obj, obj) -> null
function onchange (action, newState, oldState) {
if (newState === oldState) return
const oldTree = document.querySelector('#' + rootId)
assert.ok(oldTree, "Could not find DOM node '#" + rootId + "' to update")
const newTree = _router(newState.app.location, newState, send, oldState)
newTree.setAttribute('id', rootId)
yo.update(oldTree, newTree)
}
const newTree = _router(state.location.pathname, state, prev)
_rootNode = yo.update(_rootNode, newTree)
}
// register all routes on the router
// (str?, [fn|[fn]]) -> obj
function router (defaultRoute, cb) {
_router = sheetRouter(defaultRoute, cb)
return _router
function router (defaultRoute, routes) {
_defaultRoute = defaultRoute
_routes = routes
}
// create a new model
// (str?, obj) -> null
function model (model) {
_models.push(model)
_store.model(model)
}
// create a new router with a custom `createRoute()` function
// (str?, obj, fn?) -> null
function createRouter (defaultRoute, routes, createSend) {
var prev = {}
return sheetRouter(defaultRoute, routes, createRoute)
function createRoute (routeFn) {
return function (route, inline, child) {
if (typeof inline === 'function') {
inline = wrap(inline, route)
}
return routeFn(route, inline, child)
}
function wrap (child, route) {
const send = createSend(route, true)
return function chooWrap (params, state) {
const nwPrev = prev
const nwState = prev = xtend(state, { params: params })
if (opts.freeze !== false) Object.freeze(nwState)
return child(nwState, nwPrev, send)
}
}
}
}
}
// initial application state model
// obj -> obj
function appInit (opts) {
const initialLocation = (opts.hash === true)
? hashMatch(document.location.hash)
: document.location.href
const model = {
namespace: 'app',
state: { location: initialLocation },
subscriptions: [],
reducers: {
// handle href links
location: function setLocation (action, state) {
return {
location: action.location.replace(/#.*/, '')
}
}
const loc = document.location
const state = { pathname: (opts.hash) ? hashMatch(loc.hash) : loc.href }
const reducers = {
setLocation: function setLocation (data, state) {
return { pathname: data.location.replace(/#.*/, '') }
}
}
// if hash routing explicitly enabled, subscribe to it
const subs = {}
if (opts.hash === true) {
pushLocationSub(function (navigate) {
hash(function (fragment) {
navigate(hashMatch(fragment))
})
})
// otherwise, subscribe to HTML5 history API
}, 'handleHash', subs)
} else {
if (opts.history !== false) pushLocationSub(history)
// enable catching <a href=""></a> links
if (opts.href !== false) pushLocationSub(href)
if (opts.history !== false) pushLocationSub(history, 'handleHistory', subs)
if (opts.href !== false) pushLocationSub(href, 'handleHref', subs)
}
return model
return {
namespace: 'location',
subscriptions: subs,
reducers: reducers,
state: state
}
// create a new subscription that modifies
// 'app:location' and push it to be loaded
// fn -> null
function pushLocationSub (cb) {
model.subscriptions.push(function (send) {
cb(function (href) {
send('app:location', { location: href })
// (fn, obj) -> null
function pushLocationSub (cb, key, model) {
model[key] = function (send, done) {
cb(function navigate (pathname) {
send('location:setLocation', { location: pathname }, done)
})
})
}
}
}
// compose an object conditionally
// optionally contains a namespace
// which is used to nest properties.
// (str, obj, obj) -> null
function apply (ns, source, target) {
Object.keys(source).forEach(function (key) {
if (ns) {
if (!target[ns]) target[ns] = {}
target[ns][key] = source[key]
} else target[key] = source[key]
})
}
+15 -5
View File
@@ -5,13 +5,13 @@
"main": "index.js",
"scripts": {
"deps": "dependency-check . && dependency-check . --extra --no-dev -i xhr",
"test:electron": "browserify tests/**/*.js -t es2020 -p proxyquire-universal | tape-run",
"test:cov": "browserify tests/**/*.js -t es2020 -p proxyquire-universal -p tape-istanbul/plugin | tape-run | tape-istanbul && istanbul report",
"test:server": "standard && npm run deps && NODE_ENV=test node tests/server/*",
"test:server:cov": "standard && npm run deps && NODE_ENV=test istanbul cover tests/server/*",
"test:browser": "standard && npm run deps && NODE_ENV=test zuul tests/browser/*",
"test:browser:local": "standard && npm run deps && NODE_ENV=test zuul --local 8080 -- tests/browser/*",
"test:cov": "npm run test:server:cov",
"preversion": "if [ ! -z $SKIP_TEST ]; then npm run test:browser; fi",
"test": "npm run test:server"
"test": "npm run test:electron"
},
"repository": "yoshuawuyts/choo",
"keywords": [
@@ -24,29 +24,39 @@
],
"license": "MIT",
"dependencies": {
"barracks": "^8.0.0",
"document-ready": "~1.0.2",
"global": "^4.3.0",
"hash-match": "^1.0.2",
"send-action": "^2.0.2",
"sheet-router": "^3.1.0",
"xhr": "^2.2.0",
"xtend": "^4.0.1",
"yo-yo": "^1.2.0"
"yo-yo": "^1.2.2"
},
"devDependencies": {
"append-child": "~1.0.0",
"bankai": "^2.0.2",
"browserify": "^13.0.1",
"browserify-istanbul": "^2.0.0",
"bundle-collapser": "^1.2.1",
"dependency-check": "^2.5.1",
"es2020": "^1.0.1",
"geval": "~2.1.1",
"insert-css": "^0.2.0",
"istanbul": "^0.4.4",
"karma-sauce-launcher": "^1.0.0",
"min-document": "~2.18.0",
"proxyquire": "~1.7.10",
"proxyquire-universal": "~1.0.8",
"proxyquireify": "~3.2.0",
"server-router": "^2.1.0",
"sheetify": "^5.0.0",
"standard": "^7.1.0",
"tachyons": "^4.0.0-beta.19",
"tape": "^4.5.1",
"tape-istanbul": "~1.0.2",
"tape-run": "~2.1.4",
"yo-yoify": "^3.1.0",
"zuul": "toddself/zuul"
}
}
+3
View File
@@ -13,6 +13,7 @@ USAGE
gzip_size () {
browserify index.js \
-g unassertify \
-g yo-yoify \
-g es2020 \
-g uglifyify \
-p bundle-collapser/plugin \
@@ -24,6 +25,7 @@ gzip_size () {
min_size () {
browserify index.js \
-g unassertify \
-g yo-yoify \
-g es2020 \
-g uglifyify \
-p bundle-collapser/plugin \
@@ -35,6 +37,7 @@ min_size () {
run_discify () {
browserify index.js --full-paths \
-g unassertify \
-g yo-yoify \
-g es2020 \
-g uglifyify \
| uglifyjs \
+52 -52
View File
@@ -1,60 +1,60 @@
const tape = require('tape')
const test = require('tape')
const append = require('append-child')
const choo = require('../../')
const view = require('../../html')
tape('should render on the client', function (t) {
t.test('state should not be mutable', function (t) {
t.plan(4)
test('state is immutable', function (t) {
t.plan(4)
const app = choo()
const state = {
foo: 'baz',
beep: 'boop'
}
const app = choo()
const state = {
foo: 'baz',
beep: 'boop'
}
app.model({
state: state,
namespace: 'test',
reducers: {
'no-reducer-mutate': (action, state) => {
return {}
},
'mutate-on-return': (action, state) => {
delete action.type
return action
}
app.model({
state: state,
namespace: 'test',
reducers: {
'no-reducer-mutate': (action, state) => {
return {}
},
effects: {
'triggers-reducers': (action, state, send) => {
send('test:mutate-on-return', {beep: 'barp'})
}
'mutate-on-return': (action, state) => {
delete action.type
return action
}
})
let loop = -1
const asserts = [
(state) => t.deepEqual(state, {foo: 'baz', beep: 'boop'}, 'intial state'),
(state) => t.deepEqual(state, {foo: 'baz', beep: 'boop'}, 'no change in state'),
(state) => t.deepEqual(state, {foo: 'oof', beep: 'boop'}, 'change in state from reducer'),
(state) => t.deepEqual(state, {foo: 'oof', beep: 'barp'}, 'change in state from effect')
]
const triggers = [
(send) => send('test:no-reducer-mutate'),
(send) => send('test:mutate-on-return', {foo: 'oof'}),
(send) => send('test:triggers-reducers')
]
app.router((route) => [
route('/', function (params, state, send) {
++loop
asserts[loop] && asserts[loop](state.test)
setTimeout(() => triggers[loop] && triggers[loop](send), 5)
return choo.view`<div><span class="test">${state.foo}:${state.beep}</span></div>`
})
])
const tree = app.start()
document.body.appendChild(tree)
},
effects: {
'triggers-reducers': (action, state, send, done) => {
send('test:mutate-on-return', {beep: 'barp'}, done)
}
}
})
let loop = -1
const asserts = [
(state) => t.deepEqual(state, {foo: 'baz', beep: 'boop'}, 'intial state'),
(state) => t.deepEqual(state, {foo: 'baz', beep: 'boop'}, 'no change in state'),
(state) => t.deepEqual(state, {foo: 'oof', beep: 'boop'}, 'change in state from reducer'),
(state) => t.deepEqual(state, {foo: 'oof', beep: 'barp'}, 'change in state from effect')
]
const triggers = [
(send) => send('test:no-reducer-mutate'),
(send) => send('test:mutate-on-return', {foo: 'oof'}),
(send) => send('test:triggers-reducers')
]
app.router((route) => [
route('/', function (state, prev, send) {
++loop
asserts[loop] && asserts[loop](state.test)
setTimeout(() => triggers[loop] && triggers[loop](send), 5)
return view`<div><span class="test">${state.foo}:${state.beep}</span></div>`
})
])
const tree = app.start()
t.on('end', append(tree))
})
+46
View File
@@ -0,0 +1,46 @@
const test = require('tape')
const choo = require('../../')
test('freeze (default)', function (t) {
t.plan(2)
const app = choo()
app.model({
state: {
foo: 'bar'
}
})
app.router((route) => [
route('/', function (state, prev, send) {
state.foo = ''
t.equal(state.foo, 'bar', 'cannot modify property')
state.bar = 'baz'
t.equal(state.bar, undefined, 'cannot add property')
})
])
app.start()
})
test('noFreeze', function (t) {
t.plan(2)
const app = choo({freeze: false})
app.model({
state: {
foo: 'bar'
}
})
app.router((route) => [
route('/', function (state, prev, send) {
state.foo = ''
t.equal(state.foo, '', 'can modify property')
state.bar = 'baz'
t.equal(state.bar, 'baz', 'can add property')
})
])
app.start()
})
+56
View File
@@ -0,0 +1,56 @@
const test = require('tape')
const append = require('append-child')
const choo = require('../../')
const view = require('../../html')
test('hooks', function (t) {
t.plan(9)
const app = choo({
onError: function (err) {
t.equal(err.message, 'effect error', 'onError: receives err')
},
onAction: function (action, state, name, caller, createSend) {
if (name === 'explodes') return
t.deepEqual(action, {foo: 'bar'}, 'onAction: action data')
t.equal(state.clicks, 0, 'onAction: current state: 0 clicks')
t.equal(name, 'click', 'onAction: action name')
t.equal(caller, '/', 'onAction: caller name')
t.equal(typeof createSend, 'function', 'onAction: createSend fn')
},
onStateChange: function (action, state, prev, createSend) {
t.deepEqual(action, {foo: 'bar'}, 'onState: action data')
t.deepEqual(state.clicks, 1, 'onState: new state: 1 clicks')
t.deepEqual(prev.clicks, 0, 'onState: prev state: 0 clicks')
}
})
app.model({
state: {
clicks: 0
},
reducers: {
click: (action, state) => ({clicks: state.clicks + 1})
},
effects: {
explodes: (action, state, send, done) => {
setTimeout(() => done(new Error('effect error')), 5)
}
}
})
var sent = false
app.router((route) => [
route('/', function (state, prev, send) {
if (!sent) {
send('click', {foo: 'bar'})
send('explodes')
}
sent = true
return view`<span></span>`
})
])
const tree = app.start()
t.on('end', append(tree))
})
+29
View File
@@ -0,0 +1,29 @@
const test = require('tape')
const onReady = require('document-ready')
const append = require('append-child')
const choo = require('../../')
const view = require('../../html')
test('rehydration', function (t) {
t.plan(2)
const app = choo()
app.router((route) => [
route('/', function (state, prev, send) {
return view`<div id="app-root" onclick=${() => send('test')}>Hello world!</span>`
})
])
var node = document.createElement('div')
node.innerHTML = app.toString('/')
node = node.childNodes[0]
t.on('end', append(node))
app.start('#app-root')
onReady(function () {
t.equal(node.innerHTML, 'Hello world!', 'same content')
t.equal(typeof node.onclick, 'function', 'attaches dom listeners')
})
})
+166
View File
@@ -0,0 +1,166 @@
const test = require('tape')
const Event = require('geval/event')
const proxyquire = require('proxyquire')
const append = require('append-child')
const view = require('../../html')
test('routing', function (t) {
t.test('history', function (t) {
t.plan(3)
const history = Event()
const choo = proxyquire('../..', {
'sheet-router/history': history.listen
})
const app = choo()
app.model({
state: {
user: null
},
reducers: {
set: (action, state) => ({user: action.id})
},
effects: {
open: function (action, state, send, done) {
t.deepEqual(action, {id: 1})
send('set', {id: 1}, function (err) {
if (err) return done(err)
history.broadcast('https://foo.com/users/1')
})
}
}
})
app.router('/users', (route) => [
route('/users', parentView, [
route('/:user', childView)
])
])
const tree = app.start()
t.on('end', append(tree))
t.equal(tree.innerHTML.trim(), 'Open')
tree.onclick()
function parentView (state, prev, send) {
return view`
<button onclick=${() => send('open', {id: 1})}>
Open
</button>
`
}
function childView (state, prev, send) {
t.equal(state.user, 1)
return view`<button>${state.user}</button>`
}
})
t.test('hash', function (t) {
t.plan(1)
const hash = Event()
const choo = proxyquire('../..', {
'sheet-router/hash': hash.listen
})
const app = choo()
app.model({
state: {
user: null
},
reducers: {
set: (action, state) => ({user: action.id})
},
effects: {
open: function (action, state, send, done) {
send('set', {id: 1}, function (err) {
if (err) return done(err)
hash.broadcast('#users/1')
})
}
}
})
app.router('/users', (route) => [
route('/users', parentView, [
route('/:user', childView)
])
])
const tree = app.start({hash: true})
t.on('end', append(tree))
tree.onclick()
function parentView (state, prev, send) {
return view`
<button onclick=${() => send('open', {id: 1})}>
Open
</button>
`
}
function childView (state, prev, send) {
t.equal(state.user, 1)
return view`<button>${state.user}</button>`
}
})
t.test('disabling history', function (t) {
t.plan(1)
const choo = proxyquire('../..', {
'sheet-router/history': () => t.fail('history listener attached')
})
const app = choo()
app.router('/', (route) => [
route('/', function () {
t.pass('rendered')
})
])
app.start({history: false})
})
t.test('disabling href', function (t) {
t.plan(1)
const choo = proxyquire('../..', {
'sheet-router/href': () => t.fail('href listener attached')
})
const app = choo()
app.router('/', (route) => [
route('/', function () {
t.pass('rendered')
})
])
app.start({href: false})
})
t.test('viewless nesting', function (t) {
t.plan(1)
const choo = require('../..')
const app = choo()
app.router('/users/123', (route) => [
route('/users', [
route('/:user', function (state) {
t.deepEqual(state.params, {user: '123'})
})
])
])
app.start()
})
})
+33 -18
View File
@@ -1,13 +1,15 @@
const tape = require('tape')
const test = require('tape')
const minDocument = require('min-document')
const choo = require('../../')
const view = require('../../html')
tape('should render on the server', function (t) {
t.test('should render a static response', function (t) {
test('server', function (t) {
t.test('renders a static html response', function (t) {
t.plan(1)
const app = choo()
app.router((route) => [
route('/', () => choo.view`<h1>Hello Tokyo!</h1>`)
route('/', () => view`<h1>Hello Tokyo!</h1>`)
])
const html = app.toString('/')
@@ -15,13 +17,26 @@ tape('should render on the server', function (t) {
t.equal(html, expected, 'strings are equal')
})
t.test('should accept a state object', function (t) {
t.test('renders without a real DOM', function (t) {
t.plan(1)
const app = choo()
app.router((route) => [
route('/', function (params, state) {
return choo.view`<h1>meow meow ${state.message}</h1>`
route('/', () => minDocument.createElement('div'))
])
const html = app.toString('/')
const expected = '<div></div>'
t.equal(html, expected, 'strings are equal')
})
t.test('receives a state object', function (t) {
t.plan(1)
const app = choo()
app.router((route) => [
route('/', function (state, prev, send) {
return view`<h1>meow meow ${state.message}</h1>`
})
])
@@ -30,14 +45,14 @@ tape('should render on the server', function (t) {
t.equal(html, expected, 'strings are equal')
})
t.test('should extend flat existing models', function (t) {
t.test('extends flat existing models', function (t) {
t.plan(1)
const app = choo()
app.model({ state: { bin: 'baz', beep: 'boop' } })
app.router((route) => [
route('/', function (params, state) {
return choo.view`<h1>${state.foo} ${state.bin} ${state.beep}</h1>`
route('/', function (state, prev, send) {
return view`<h1>${state.foo} ${state.bin} ${state.beep}</h1>`
})
])
@@ -47,7 +62,7 @@ tape('should render on the server', function (t) {
t.equal(html, expected, 'strings are equal')
})
t.test('should extend namespaced existing models', function (t) {
t.test('extends namespaced existing models', function (t) {
t.plan(1)
const app = choo()
@@ -56,8 +71,8 @@ tape('should render on the server', function (t) {
state: { bin: 'baz', beep: 'boop' }
})
app.router((route) => [
route('/', function (params, state) {
return choo.view`
route('/', function (state, prev, send) {
return view`
<h1>${state.hello.foo} ${state.hello.bin} ${state.hello.beep}</h1>
`
})
@@ -74,12 +89,12 @@ tape('should render on the server', function (t) {
t.equal(html, expected, 'strings are equal')
})
t.test('should throw if called without route', function (t) {
t.test('throws if called without route', function (t) {
t.plan(1)
const app = choo()
app.router((route) => [
route('/', function (params, state, send) {
route('/', function (state, prev, send) {
send('hey!')
})
])
@@ -87,17 +102,17 @@ tape('should render on the server', function (t) {
t.throws(app.toString.bind(null), /route must be a string/)
})
t.test('should throw if calling send()', function (t) {
t.test('throws when calling send()', function (t) {
t.plan(1)
const app = choo()
app.router((route) => [
route('/', function (params, state, send) {
route('/', function (state, prev, send) {
send('hey!')
})
])
const msg = /send\(\) cannot be called on the server/
const msg = /send\(\) cannot be called/
t.throws(app.toString.bind(null, '/', { message: 'nyan!' }), msg)
})
})