[major] choo v4 (#352)

* location: change url on location:setLocation

- [ ] don't break hashing
- [ ] allow not changing the url

fixup! move fns around

fixup! add search string

* app.start: clean

* walk: add

* location: update arg calls

* fixup! location: update

* uri-wrap: fix thunking

* router: update to latest sheet-router (#239)

* router: update to latest sheet-router

* tests: fix for latest version

* tests: fix SSR

* tests: fix history

* tests: spruce up

* docs: update example

* examples: update

* deps: bump sheet-router

* 4.0.0-0

* chore(changelog): 4.0.0 (#211)

* feat(api:) arg order (#268)

* s/data, state/state, data/

* feat(api): swap arguments

* fix(href): fix routing (#271)

* feat(http): remove (#269)

* feat(router): enable hash routing (#273)

* deps: fix mount

* 4.0.0-1

* feat(mount): copy {script,link} tags

* 4.0.0-2

* fix(mount): forEach -> for

Lol can't use forEach

* fix(router): use state.location.href (#282)

* fix(mount): use deep node clone

* 4.0.0-3

* fix(deps): remove hash-match

* 4.0.0-4

* fix(mount): return node

* 4.0.0-5

* fix(router): check if a hash is a valid selector (#339)

* 4.0.0-6

* fix(router): pass params on newstate (#343)

* 4.0.0-7

* feat(docs): update for 4.0.0 (#320)

* feat(docs): update for 4.0.0

* docs: update router example in readme (#337)

* chore(changelog): update for v4 (#351)
This commit is contained in:
Yoshua Wuyts
2016-12-11 19:35:29 +01:00
committed by GitHub
parent 54b6000800
commit a7916ec3f9
30 changed files with 569 additions and 503 deletions
+29
View File
@@ -1,3 +1,32 @@
## `4.0.0` The routing patch
This patch changes the way we handle routes. It introduces query string
support (!), and changes the router to use a lisp-like syntax. It also inverts
the argument order of effects and reducers to be more intuitive. We also
managed to sneak in some performance upgrades :sparkles: - We hope you enjoy
it!
### changes
- :exclamation: slim down server side rendering API |
[issue](https://github.com/yoshuawuyts/choo/issues/191) |
[pull-request](https://github.com/yoshuawuyts/choo/pull/203)
- :exclamation: update router API to be lisp-like
- :exclamation: swap `state` and `data` argument order |
[issue](https://github.com/yoshuawuyts/choo/issues/179)
- :exclamation: remove `choo/http`. Use [xhr](https://github.com/naugtur/xhr)
instead | [pull-request](https://github.com/yoshuawuyts/choo/pull/269)
- update `router` to use memoization |
[issue](https://github.com/yoshuawuyts/sheet-router/issues/17) |
[pull-request](https://github.com/yoshuawuyts/sheet-router/pull/34)
- support inline anchor links |
[issue](https://github.com/yoshuawuyts/choo/issues/65)
- allow bypassing of link clicks in `sheet-router` |
[issue](https://github.com/yoshuawuyts/sheet-router/issues/15) |
[pull-request](https://github.com/yoshuawuyts/sheet-router/pull/27)
- update router API to handle hashes by default
- update router to provide out of the box support for Electron
- update `location` state to expose `search` parameters (query strings) |
[issue](https://github.com/yoshuawuyts/sheet-router/issues/31)
## `3.3.0` ## `3.3.0`
Yay, `plugins` now support `wrappers` which is a segway onto HMR, time travel Yay, `plugins` now support `wrappers` which is a segway onto HMR, time travel
and other cool plugins. These changes have come through in barracks `v8.3.0` and other cool plugins. These changes have come through in barracks `v8.3.0`
+141 -151
View File
@@ -125,49 +125,50 @@ production, we'd love to hear from you!_
Let's create an input box that changes the content of a textbox in real time. Let's create an input box that changes the content of a textbox in real time.
[Click here to see the app running](http://requirebin.com/?gist=229bceda0334cf30e3044d5f5c600960). [Click here to see the app running](http://requirebin.com/?gist=229bceda0334cf30e3044d5f5c600960).
```js ```js
const choo = require('choo') var html = require('choo/html')
const html = require('choo/html') var choo = require('choo')
const app = choo() var app = choo()
app.model({ app.model({
state: { title: 'Not quite set yet' }, state: { title: 'Not quite set yet' },
reducers: { reducers: {
update: (data, state) => ({ title: data }) update: function (state, data) {
return { title: data }
}
} }
}) })
const mainView = (state, prev, send) => html` function mainView (state, prev, send) {
return html`
<main> <main>
<h1>Title: ${state.title}</h1> <h1>Title: ${state.title}</h1>
<input <input type="text" oninput=${update}>
type="text"
oninput=${(e) => send('update', e.target.value)}/>
</main> </main>
` `
app.router((route) => [ function update (e) {
route('/', mainView) send('update', e.target.value)
]) }
}
const tree = app.start() app.router(['/', mainView])
var tree = app.start()
document.body.appendChild(tree) document.body.appendChild(tree)
``` ```
To run it, save it as `client.js` and run with [budo] and [es2020]. These tools To run it, save it as `client.js` and run with [bankai][bankai]. `bankai` is
are convenient but any [browserify] based tool should do: convenient but any [browserify][browserify] based tool should do:
```sh ```sh
$ budo client.js -p 8080 --open -- -t es2020 # run and reload on port 8080
``` $ bankai client.js -p 8080 --open
And to save the output to files so it can be deployed, open a new terminal and # compile to static files in `./dist/`
do: $ bankai build index.js dist/
```bash
$ mkdir -p 'dist/' # deploy to github pages using `tschaub/gh-pages`
$ curl 'localhost:8080' > 'dist/index.html' $ gh-pages -d dist/
$ curl 'localhost:8080/client.js' > 'dist/client.js'
``` ```
All using a couple of shell commands and `.js` files, no grandiose boilerplate
needed.
## Philosophy ## Philosophy
We believe programming should be fun and light, not stern and stressful. It's We believe programming should be fun and light, not stern and stressful. It's
@@ -194,47 +195,36 @@ better results and super smiley faces.
## Concepts ## Concepts
`choo` cleanly structures internal data flow, so that all pieces of logic can `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 be combined into a nice, cohesive machine. Roughly speaking there are two parts
`models` that contain several properties. `subscriptions` are functions that to `choo`: the views and the models. Models take care of state and logic, and
are called at startup and have `send()` passed in, so they act as read-only views are responsible for displaying the interface and responding to user
sources of data. `effects` react to changes, perform an `action` and can then interactions.
post the results. `reducers` take data, modify it, and update the internal
`state`.
Communication of data is done using something called `actions`. Each `action` All of `choo`'s state is contained in a single object and whenever it changes
consists of a unique `actionName` and an optional payload of `data`, which can the views receive a new version of the state which they can use to safely
be any value. render a complete new representation of the DOM. The DOM is efficiently updated
using DOM diffing/patching.
When a `reducer` modifies `state`, the `router` is called, which in turn calls The logic in choo exist in three different kinds of actions, each with their
`views`. `views` take `state` and return [DOM] nodes which are then own role: `effects`, `subscriptions` and `reducers`.
efficiently rendered on the screen.
- __Effects__ makes an asynchronous operation and calls another action when
it's done.
- __Subscriptions__ (called once when the DOM loads) listens for external input
like keyboard or WebSocket events and then calls another action.
- __Reducers__ receives the current state and returns an updated version of the
state which is then sent to the views.
In turn when the `views` are rendered, the `user` can interact with elements by
clicking on them, triggering `actions` which then flow back into the
application logic. This is the _unidirectional_ architecture of `choo`.
```txt ```txt
┌─────────────────┐ ┌─────────────────┐
│ Subscriptions ─┤ User ───┐ │ Subscriptions ─┤ User ───┐
└─ Effects ◀─────┤ ▼ └─ Effects ◀─────┤ ▼
┌─ Reducers ◀─────┴──Actions── DOM ◀┐ ┌─ Reducers ◀─────┴─────────── DOM ◀┐
│ │ │ │
└▶ Router ─────State ───▶ Views ────┘ └▶ Router ─────State ───▶ Views ────┘
``` ```
- __user:__ 🙆
- __DOM:__ the [Document Object Model][DOM] is what is currently displayed in
your browser
- __actions:__ a named event with optional properties attached. Used to call
`effects` and `reducers` that have been registered in `models`
- __model:__ optionally namespaced object containing `subscriptions`,
`effects`, `reducers` and initial `state`
- __subscriptions:__ read-only data sources that emit `actions`
- __effects:__ asynchronous functions that emit an `action` when done
- __reducers:__ synchronous functions that modify `state`
- __state:__ a single object that contains __all__ the values used in your
application
- __router:__ determines which `view` to render
- __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` `models` are objects that contain initial `state`, `subscriptions`, `effects`
@@ -249,12 +239,14 @@ Outside the model they're called by `send('todos:add')` and
`state.todos.items`. Inside the namespaced model they're called by `state.todos.items`. Inside the namespaced model they're called by
`send('todos:add')` and `state.items`. An example namespaced model: `send('todos:add')` and `state.items`. An example namespaced model:
```js ```js
const app = choo() var app = choo()
app.model({ app.model({
namespace: 'todos', namespace: 'todos',
state: { items: [] }, state: { items: [] },
reducers: { reducers: {
add: (data, state) => ({ items: state.items.concat(data.payload) }) add: function (state, data) {
return { items: state.items.concat(data.payload) }
}
} }
}) })
``` ```
@@ -272,52 +264,52 @@ bulk of your logic will be safely shielded, with only a few points touching ever
part of your application. part of your application.
### Effects ### Effects
Side effects are done through `effects` declared in `app.model()`. Unlike `effects` are similar to `reducers` except instead of modifying the state they
`reducers` they cannot modify the state by returning objects, but get a cause side `effects` by interacting servers, databases, DOM APIs, etc. Often
callback passed which is used to emit `actions` to handle results. Use effects they'll call a reducer when they're done to update the state. For instance, you
every time you don't need to modify the state object directly, but wish to may have an effect called getUsers that fetches a list of users from a server
respond to an action. API using AJAX. Assuming the AJAX request completes successfully, the effect
can pass off the list of users to a reducer called receiveUsers which simply
updates the state with that list, separating the concerns of interacting with
an API from updating the application's state.
A typical `effect` flow looks like: This is an example `effect` that is called once when the application loads and
calls the `'todos:add'` `reducer` when it receives data from the server:
1. An action is received
2. An effect is triggered
3. The effect performs an async call
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
Examples of effects include: performing [xhr] requests (server requests),
calling multiple `reducers`, persisting state to [localstorage].
```js ```js
const http = require('choo/http') var choo = require('choo')
const choo = require('choo') var http = require('xhr')
const app = choo() var app = choo()
app.model({ app.model({
namespace: 'todos', namespace: 'todos',
state: { items: [] }, state: { values: [] },
reducers: {
add: function (data, state) {
return { todos: data }
}
},
effects: { effects: {
fetch: (data, state, send, done) => { addAndSave: function (state, data, send, done) {
http('/todos', (err, res, body) => { var opts = { body: data.payload, json: true }
send('todos:receive', body, done) http.post('/todo', opts, function (err, res, body) {
if (err) return done(err)
data.payload.id = body.id
send('todos:add', data, function (err, value) {
if (err) return done(err)
done(null, value)
})
}) })
} }
}, },
reducers: { subscriptions: {
receive: (data, state) => { 'called-once-when-the-app-loads': function (send, done) {
return { items: data } send('todos:addAndSave', done)
} }
} }
}) })
``` ```
When an `effect` is done executing, it should call the `done(err, res)`
callback. This callback is 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 really powerful - without becoming
complicated.
### Subscriptions ### Subscriptions
Subscriptions are a way of receiving data from a source. For example when 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 listening for events from a server using `SSE` or `Websockets` for a
@@ -325,23 +317,29 @@ chat app, or when catching keyboard input for a videogame.
An example subscription that logs `"dog?"` every second: An example subscription that logs `"dog?"` every second:
```js ```js
const app = choo() var choo = require('choo')
var app = choo()
app.model({ app.model({
namespace: 'app', namespace: 'app',
subscriptions: [ effects: {
(send, done) => { print: function (state, data) {
setInterval(() => { console.log(data.payload)
send('app:print', { payload: 'dog?', myOtherValue: 1000 }, (err) => { }
},
subscriptions: {
callDog: function (send, done) {
setInterval(function () {
var data = { payload: 'dog?', myOtherValue: 1000 }
send('app:print', data, function (err) {
if (err) return done(err) if (err) return done(err)
}) })
}, 1000) }, 1000)
} }
],
effects: {
print: (data, state) => console.log(data.payload)
} }
}) })
``` ```
If a `subscription` runs into an error, it can call `done(err)` to signal the If a `subscription` runs into an error, it can call `done(err)` to signal the
error to the error hook. error to the error hook.
@@ -350,13 +348,13 @@ The `router` manages which `views` are rendered at any given time. It also
supports rendering a default `view` if no routes match. supports rendering a default `view` if no routes match.
```js ```js
const app = choo() var app = choo()
app.router('/404', (route) => [ app.router({ default: '/404' }, [
route('/', require('./views/empty')), [ '/', require('./views/empty') ],
route('/404', require('./views/error')), [ '/404', require('./views/error') ],
route('/:mailbox', require('./views/mailbox'), [ [ '/:mailbox', require('./views/mailbox'), [
route('/:message', require('./views/email')) [ '/:message', require('./views/email') ]
]) ]]
]) ])
``` ```
@@ -372,21 +370,29 @@ from within namespaced `models`, and usage should preferably be kept to a
minimum. Changing views all over the place tends to lead to messiness. 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 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. 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 ```js
const view = (state, prev, send) => { function view (state, prev, send) {
return html` return html`
<div> <div>
<h1>Total todos: ${state.todos.length}</h1> <h1>Total todos: ${state.todos.length}</h1>
<button onclick=${(e) => send('add', {title: 'demo'})}> <button onclick=${addTodo}>Add</button>
Add </div>
</button> `
</div>`
function addTodo (e) {
send('add', { title: 'demo' })
}
} }
``` ```
In this example, when the `Add` button is clicked, the view will dispatch an 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 `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 above](#models), the reducer will add an item to the states `todos` array. The
@@ -401,13 +407,13 @@ somewhere. This is done through something called `plugins`. Plugins are objects
that contain `hook` and `wrap` functions and are passed to `app.use()`: that contain `hook` and `wrap` functions and are passed to `app.use()`:
```js ```js
const log = require('choo-log') var log = require('choo-log')
const choo = require('choo') var choo = require('choo')
const app = choo() var app = choo()
app.use(log()) app.use(log())
const tree = app.start() var tree = app.start()
document.body.appendChild(tree) document.body.appendChild(tree)
``` ```
@@ -427,9 +433,9 @@ and `wrappers` are available, head on over to [app.use()](#appusehooks).
Using `choo` in a project? Show off which version you've used using a badge: Using `choo` in a project? Show off which version you've used using a badge:
[![built with choo v3](https://img.shields.io/badge/built%20with%20choo-v3-ffc3e4.svg?style=flat-square)](https://github.com/yoshuawuyts/choo) [![built with choo v4](https://img.shields.io/badge/built%20with%20choo-v4-ffc3e4.svg?style=flat-square)](https://github.com/yoshuawuyts/choo)
```md ```md
[![built with choo v3](https://img.shields.io/badge/built%20with%20choo-v3-ffc3e4.svg?style=flat-square)](https://github.com/yoshuawuyts/choo) [![built with choo v4](https://img.shields.io/badge/built%20with%20choo-v4-ffc3e4.svg?style=flat-square)](https://github.com/yoshuawuyts/choo)
``` ```
## API ## API
@@ -449,9 +455,9 @@ arguments:
and handlers in other models and handlers in other models
- __state:__ initial values of `state` inside the model - __state:__ initial values of `state` inside the model
- __reducers:__ synchronous operations that modify state. Triggered by - __reducers:__ synchronous operations that modify state. Triggered by
`actions`. Signature of `(data, state)`. `actions`. Signature of `(state, data)`.
- __effects:__ asynchronous operations that don't modify state directly. - __effects:__ asynchronous operations that don't modify state directly.
Triggered by `actions`, can call `actions`. Signature of `(data, state, Triggered by `actions`, can call `actions`. Signature of `(state, data,
send, done)` send, done)`
- __subscriptions:__ asynchronous read-only operations that don't modify state - __subscriptions:__ asynchronous read-only operations that don't modify state
directly. Can call `actions`. Signature of `(send, done)`. directly. Can call `actions`. Signature of `(send, done)`.
@@ -490,9 +496,9 @@ There are several `hooks` and `wrappers` that are picked up by `choo`:
- __onError(err, state, createSend):__ called when an `effect` or - __onError(err, state, createSend):__ called when an `effect` or
`subscription` emit an error. If no handler is passed, the default handler `subscription` emit an error. If no handler is passed, the default handler
will `throw` on each error. will `throw` on each error.
- __onAction(data, state, name, caller, createSend):__ called when an - __onAction(state, data, name, caller, createSend):__ called when an
`action` is fired. `action` is fired.
- __onStateChange(data, state, prev, caller, createSend):__ called after a - __onStateChange(state, data, prev, caller, createSend):__ called after a
reducer changes the `state`. reducer changes the `state`.
- __wrapSubscriptions(fn):__ wraps a `subscription` to add custom behavior - __wrapSubscriptions(fn):__ wraps a `subscription` to add custom behavior
- __wrapReducers(fn):__ wraps a `reducer` to add custom behavior - __wrapReducers(fn):__ wraps a `reducer` to add custom behavior
@@ -523,12 +529,9 @@ optional state object. When calling `.toString()` instead of `.start()`, all
calls to `send()` are disabled, and `subscriptions`, `effects` and `reducers` calls to `send()` are disabled, and `subscriptions`, `effects` and `reducers`
aren't loaded. aren't loaded.
### tree = app.start(rootId?, opts) ### tree = app.start(opts)
Start the application. Returns a tree of DOM nodes that can be mounted using Start the application. Returns a tree of DOM nodes that can be mounted using
`document.body.appendChild()`. If a valid `id` selector is passed in as the `document.body.appendChild()`. Opts can contain the following values:
first argument, the tree will diff against the selected node rather than be
returned. This is useful for [rehydration](https://github.com/yoshuawuyts/choo-handbook/blob/master/rendering-in-node.md#rehydration). Opts can contain the
following values:
- __opts.history:__ default: `true`. Enable a `subscription` to the browser - __opts.history:__ default: `true`. Enable a `subscription` to the browser
history API. e.g. updates the internal `location.href` state whenever the history API. e.g. updates the internal `location.href` state whenever the
browsers "forward" and "backward" buttons are pressed. browsers "forward" and "backward" buttons are pressed.
@@ -549,10 +552,14 @@ current `state`, `prev` is the last state, `state.params` is URI partials and
To create listeners for events, create interpolated attributes on elements. To create listeners for events, create interpolated attributes on elements.
```js ```js
const html = require('choo/html') var html = require('choo/html')
html` html`
<button onclick=${(e) => console.log(e)}>click for bananas</button> <button onclick=${log}>click for bananas</button>
` `
function log (e) {
console.log(e)
}
``` ```
Example listeners include: `onclick`, `onsubmit`, `oninput`, `onkeydown`, Example listeners include: `onclick`, `onsubmit`, `oninput`, `onkeydown`,
`onkeyup`. A full list can be found [at the yo-yo `onkeyup`. A full list can be found [at the yo-yo
@@ -660,8 +667,8 @@ Consider running some of the following:
- [unassertify](https://github.com/twada/unassertify) - remove `assert()` - [unassertify](https://github.com/twada/unassertify) - remove `assert()`
statements which reduces file size. Use as a `--global` transform statements which reduces file size. Use as a `--global` transform
- [es2020](https://github.com/yoshuawuyts/es2020) - backport `const`, - [es2020](https://github.com/yoshuawuyts/es2020) - backport `const`,
`fat-arrows` and `template strings` to older browsers. Should be run as a `arrow functions` and `template strings` to older browsers. Should be run as
`--global` transform a `--global` transform
- [yo-yoify](https://github.com/shama/yo-yoify) - replace the internal `hyperx` - [yo-yoify](https://github.com/shama/yo-yoify) - replace the internal `hyperx`
dependency with `document.createElement` calls; greatly speeds up performance dependency with `document.createElement` calls; greatly speeds up performance
too too
@@ -676,8 +683,8 @@ Consider running some of the following:
### Choo + Internet Explorer &amp; Safari ### Choo + Internet Explorer &amp; Safari
Out of the box `choo` only supports runtimes which support: Out of the box `choo` only supports runtimes which support:
* `const` * `const`
* `fat-arrow` functions (e.g. `() => {}`) * `arrow functions` (e.g. `() => {}`)
* `template-strings` * `template strings`
This does not include Safari 9 or any version of IE. If support for these This does not include Safari 9 or any version of IE. If support for these
platforms is required you will have to provide some sort of transform that platforms is required you will have to provide some sort of transform that
@@ -814,35 +821,18 @@ Become a backer, and buy us a coffee (or perhaps lunch?) every month or so.
<a href="https://opencollective.com/choo/backer/28/website" target="_blank"><img src="https://opencollective.com/choo/backer/28/avatar.svg"></a> <a href="https://opencollective.com/choo/backer/28/website" target="_blank"><img src="https://opencollective.com/choo/backer/28/avatar.svg"></a>
<a href="https://opencollective.com/choo/backer/29/website" target="_blank"><img src="https://opencollective.com/choo/backer/29/avatar.svg"></a> <a href="https://opencollective.com/choo/backer/29/website" target="_blank"><img src="https://opencollective.com/choo/backer/29/avatar.svg"></a>
## License ## License
[MIT](https://tldrlegal.com/license/mit-license) [MIT](https://tldrlegal.com/license/mit-license)
[bankai]: https://github.com/yoshuawuyts/bankai
[bel]: https://github.com/shama/bel [bel]: https://github.com/shama/bel
[big-o]: https://rob-bell.net/2009/06/a-beginners-guide-to-big-o-notation/
[bl]: https://github.com/rvagg/bl
[browserify]: https://github.com/substack/node-browserify [browserify]: https://github.com/substack/node-browserify
[budo]: https://github.com/mattdesl/budo [budo]: https://github.com/mattdesl/budo
[DOM]: https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model
[dom]: https://en.wikipedia.org/wiki/Document_Object_Model
[es2020]: https://github.com/yoshuawuyts/es2020 [es2020]: https://github.com/yoshuawuyts/es2020
[handbook]: https://github.com/yoshuawuyts/choo-handbook [handbook]: https://github.com/yoshuawuyts/choo-handbook
[html-input]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input
[hyperx]: https://github.com/substack/hyperx [hyperx]: https://github.com/substack/hyperx
[inu]: https://github.com/ahdinosaur/inu [inu]: https://github.com/ahdinosaur/inu
[isomorphic]: https://en.wikipedia.org/wiki/Isomorphism
[keyboard-support]: https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent#Browser_compatibility
[localstorage]: https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage
[module-parent]: https://nodejs.org/dist/latest-v6.x/docs/api/modules.html#modules_module_parent
[morphdom-bench]: https://github.com/patrick-steele-idem/morphdom#benchmarks [morphdom-bench]: https://github.com/patrick-steele-idem/morphdom#benchmarks
[morphdom]: https://github.com/patrick-steele-idem/morphdom [morphdom]: https://github.com/patrick-steele-idem/morphdom
[nginx]: http://nginx.org/
[qps]: https://en.wikipedia.org/wiki/Queries_per_second
[sheet-router]: https://github.com/yoshuawuyts/sheet-router [sheet-router]: https://github.com/yoshuawuyts/sheet-router
[sse-reconnect]: http://stackoverflow.com/questions/24564030/is-an-eventsource-sse-supposed-to-try-to-reconnect-indefinitely
[sse]: https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events
[varnish]: https://varnish-cache.org
[ws-reconnect]: http://stackoverflow.com/questions/13797262/how-to-reconnect-to-websocket-after-close-connection
[ws]: https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API
[xhr]: https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest
[yo-yo]: https://github.com/maxogden/yo-yo [yo-yo]: https://github.com/maxogden/yo-yo
+6 -8
View File
@@ -8,20 +8,20 @@ app.model({
counter: 0 counter: 0
}, },
reducers: { reducers: {
increment: (data, state) => ({ counter: state.counter + 1 }), increment: (state, data) => ({ counter: state.counter + 1 }),
decrement: (data, state) => ({ counter: state.counter - 1 }) decrement: (state, data) => ({ counter: state.counter - 1 })
}, },
effects: { effects: {
incrementAsync: function (data, state, send, done) { incrementAsync: function (state, data, send, done) {
setTimeout(() => send('increment', done), 1000) setTimeout(() => send('increment', done), 1000)
}, },
decrementAsync: function (data, state, send, done) { decrementAsync: function (state, data, send, done) {
setTimeout(() => send('decrement', done), 1000) setTimeout(() => send('decrement', done), 1000)
} }
} }
}) })
const mainView = (state, prev, send) => { function mainView (state, prev, send) {
const count = state.counter const count = state.counter
return html` return html`
@@ -36,9 +36,7 @@ const mainView = (state, prev, send) => {
` `
} }
app.router((route) => [ app.router([ '/', mainView ])
route('/', mainView)
])
const tree = app.start() const tree = app.start()
document.body.appendChild(tree) document.body.appendChild(tree)
+2 -2
View File
@@ -4,13 +4,13 @@
"description": "", "description": "",
"main": "client.js", "main": "client.js",
"scripts": { "scripts": {
"start": "budo client.js -p 8080" "start": "bankai start --entry=client -p 8080"
}, },
"keywords": [], "keywords": [],
"author": "Juan Soto <juan@juansoto.me>", "author": "Juan Soto <juan@juansoto.me>",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"budo": "^8.3.0", "bankai": "^3.2.0",
"plur": "^2.1.2" "plur": "^2.1.2"
} }
} }
+4 -5
View File
@@ -4,18 +4,19 @@ const mainView = require('./views/main')
const app = choo({ const app = choo({
onError: function (err, state, createSend) { onError: function (err, state, createSend) {
console.trace()
console.groupCollapsed(`Error: ${err.message}`) console.groupCollapsed(`Error: ${err.message}`)
console.error(err) console.error(err)
console.groupEnd() console.groupEnd()
const send = createSend('onError: ') const send = createSend('onError: ')
send('app:error', err) send('app:error', err)
}, },
onAction: function (data, state, name, caller, createSend) { onAction: function (state, data, name, caller, createSend) {
console.groupCollapsed(`Action: ${caller} -> ${name}`) console.groupCollapsed(`Action: ${caller} -> ${name}`)
console.log(data) console.log(data)
console.groupEnd() console.groupEnd()
}, },
onStateChange: function (data, state, prev, createSend) { onStateChange: function (state, data, prev, createSend) {
console.groupCollapsed('State') console.groupCollapsed('State')
console.log(prev) console.log(prev)
console.log(state) console.log(state)
@@ -26,9 +27,7 @@ const app = choo({
app.model(require('./models/error')) app.model(require('./models/error'))
app.model(require('./models/api')) app.model(require('./models/api'))
app.router((route) => [ app.router(['/', mainView])
route('/', mainView)
])
const tree = app.start() const tree = app.start()
document.body.appendChild(tree) document.body.appendChild(tree)
+3 -3
View File
@@ -6,13 +6,13 @@ module.exports = {
title: 'Button pushing machine 3000' title: 'Button pushing machine 3000'
}, },
reducers: { reducers: {
set: (data, state) => ({ 'title': data }) set: (state, data) => ({ 'title': data })
}, },
effects: { effects: {
good: function (data, state, send, done) { good: function (state, data, send, done) {
request('/good', send, done) request('/good', send, done)
}, },
bad: (data, state, send, done) => request('/bad', send, done) bad: (state, data, send, done) => request('/bad', send, done)
} }
} }
+2 -2
View File
@@ -15,13 +15,13 @@ module.exports = {
triggerTime: null triggerTime: null
}, },
reducers: { reducers: {
setError: function (data, state) { setError: function (state, data) {
return { return {
errors: state.errors.concat(data.message), errors: state.errors.concat(data.message),
errorTimeDone: data.errorTimeDone errorTimeDone: data.errorTimeDone
} }
}, },
'delError': function (data, state) { 'delError': function (state, data) {
state.errors.shift() state.errors.shift()
return { errors: state.errors } return { errors: state.errors }
} }
+9 -6
View File
@@ -1,19 +1,22 @@
const choo = require('../../') const log = require('choo-log')
const sf = require('sheetify') const sf = require('sheetify')
const choo = require('../../')
sf('css-wipe/dest/bundle') sf('css-wipe/dest/bundle')
const app = choo() const app = choo()
app.use(log())
app.model(require('./models/inbox')) app.model(require('./models/inbox'))
app.model(require('./models/spam')) app.model(require('./models/spam'))
app.model(require('./models/sent')) app.model(require('./models/sent'))
app.router((route) => [ app.router([
route('/', require('./views/empty')), ['/', require('./views/empty')],
route('/:mailbox', require('./views/mailbox'), [ ['/:mailbox', require('./views/mailbox'), [
route('/:message', require('./views/email')) ['/:message', require('./views/email')]
]) ]]
]) ])
const tree = app.start() const tree = app.start()
+1
View File
@@ -9,6 +9,7 @@
"author": "Yoshua Wuyts <i@yoshuawuyts.com>", "author": "Yoshua Wuyts <i@yoshuawuyts.com>",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"choo-log": "^1.4.1",
"css-wipe": "^4.2.1", "css-wipe": "^4.2.1",
"dateformat": "^1.0.12", "dateformat": "^1.0.12",
"pathname-match": "^1.1.3", "pathname-match": "^1.1.3",
+5
View File
@@ -7,6 +7,7 @@ const nav = require('../elements/nav')
module.exports = function (state, prev, send) { module.exports = function (state, prev, send) {
return html` return html`
<main class="mw5 mw7-ns center cf"> <main class="mw5 mw7-ns center cf">
<button onclick=${goHome}>Home</button>
${pathname(state, prev, send)} ${pathname(state, prev, send)}
${nav(state, prev, send)} ${nav(state, prev, send)}
<section class="fl mt4 w-80 db"> <section class="fl mt4 w-80 db">
@@ -14,4 +15,8 @@ module.exports = function (state, prev, send) {
</section> </section>
</main> </main>
` `
function goHome () {
send('location:set', { pathname: '/' })
}
} }
+5 -5
View File
@@ -1,6 +1,7 @@
const mount = require('../../mount')
const choo = require('../../') const choo = require('../../')
const mainView = require('./views/main') const mainView = require('./view')
const app = choo() const app = choo()
@@ -12,12 +13,11 @@ app.model({
} }
}) })
app.router((route) => [ app.router(['/', mainView])
route('/', mainView)
])
if (module.parent) { if (module.parent) {
module.exports = app module.exports = app
} else { } else {
app.start('#app-root') const tree = app.start()
mount('#app-root', tree)
} }
@@ -1,5 +1,5 @@
const assert = require('assert') const assert = require('assert')
const html = require('../../../html') const html = require('../../html')
module.exports = function (state, prev, send) { module.exports = function (state, prev, send) {
const serverMessage = state.message.server const serverMessage = state.message.server
+8 -10
View File
@@ -3,14 +3,12 @@ const html = require('../../html')
const app = choo() const app = choo()
app.model(createModel()) app.model(createModel())
app.router((route) => [ app.router(['/', mainView])
route('/', mainView)
])
const tree = app.start() const tree = app.start()
document.body.appendChild(tree) document.body.appendChild(tree)
function mainView (params, state, send) { function mainView (state, prev, send) {
return html` return html`
<div>${state.logger.msg}</div> <div>${state.logger.msg}</div>
` `
@@ -24,27 +22,27 @@ function createModel () {
msg: '' msg: ''
}, },
subscriptions: [ subscriptions: [
function (send) { function (send, done) {
stream.onerror = (e) => { stream.onerror = (e) => {
send('logger:error', { payload: JSON.stringify(e) }) send('logger:error', { payload: JSON.stringify(e) }, done)
} }
stream.onmessage = (e) => { stream.onmessage = (e) => {
const msg = JSON.parse(e.data).message const msg = JSON.parse(e.data).message
send('logger:print', { payload: msg }) send('logger:print', { payload: msg }, done)
} }
} }
], ],
reducers: { reducers: {
'print': (data, state) => { 'print': (state, data) => {
return ({ msg: state.msg + ' ' + data.payload }) return ({ msg: state.msg + ' ' + data.payload })
} }
}, },
effects: { effects: {
close: (data, state, send, done) => { close: (state, data, send, done) => {
stream.close() stream.close()
done() done()
}, },
error: (data, state, send, done) => { error: (state, data, send, done) => {
console.error(`error: ${data.payload}`) console.error(`error: ${data.payload}`)
done() done()
} }
+1 -3
View File
@@ -7,9 +7,7 @@ const app = choo()
app.model(stopwatch) app.model(stopwatch)
app.router((route) => [ app.router(['/', mainView])
route('/', mainView)
])
const tree = app.start() const tree = app.start()
document.body.appendChild(tree) document.body.appendChild(tree)
+6 -6
View File
@@ -8,14 +8,14 @@ module.exports = {
laps: [] laps: []
}, },
reducers: { reducers: {
start: (data, state) => ({ start: true, startTime: Date.now() - state.elapsed }), start: (state, data) => ({ start: true, startTime: Date.now() - state.elapsed }),
stop: (data, state) => ({ start: false }), stop: (state, data) => ({ start: false }),
update: (data, state) => ({ elapsed: data }), update: (state, data) => ({ elapsed: data }),
reset: (data, state) => ({ startTime: Date.now(), elapsed: 0, laps: [] }), reset: (state, data) => ({ startTime: Date.now(), elapsed: 0, laps: [] }),
add: (data, state) => ({ laps: state.laps.concat(data) }) add: (state, data) => ({ laps: state.laps.concat(data) })
}, },
effects: { effects: {
now: (data, state, send, done) => { now: (state, data, send, done) => {
if (state.start) { if (state.start) {
let elapsed = data - state.startTime let elapsed = data - state.startTime
send('update', elapsed, done) send('update', elapsed, done)
+2 -2
View File
@@ -4,13 +4,13 @@
"description": "", "description": "",
"main": "client.js", "main": "client.js",
"scripts": { "scripts": {
"start": "budo client.js -p 8080 -- -t es2020" "start": "bankai start --entry=client.js -p 8080"
}, },
"keywords": [], "keywords": [],
"author": "traducer <traducer21@gmail.com>", "author": "traducer <traducer21@gmail.com>",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"budo": "^8.3.0", "bankai": "^3.2.0",
"raf": "^3.2.0" "raf": "^3.2.0"
}, },
"devDependencies": { "devDependencies": {
+3 -6
View File
@@ -8,10 +8,10 @@ app.model({
title: 'my demo app' title: 'my demo app'
}, },
reducers: { reducers: {
update: (data, state) => ({ title: data.payload }) update: (state, data) => ({ title: data.payload })
}, },
effects: { effects: {
update: (data, state, send, done) => { update: (state, data, send, done) => {
document.title = data.payload document.title = data.payload
done() done()
} }
@@ -31,9 +31,6 @@ const mainView = (state, prev, send) => {
` `
} }
app.router((route) => [ app.router(['/', mainView])
route('/', mainView)
])
const tree = app.start() const tree = app.start()
document.body.appendChild(tree) document.body.appendChild(tree)
+4 -3
View File
@@ -4,12 +4,13 @@
"description": "", "description": "",
"main": "client.js", "main": "client.js",
"scripts": { "scripts": {
"start": "budo client.js -p 8080" "start": "bankai start --entry=client.js -p 8080 --open"
}, },
"keywords": [], "keywords": [],
"author": "Yoshua Wuyts <i@yoshuawuyts.com>", "author": "Yoshua Wuyts <i@yoshuawuyts.com>",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {},
"budo": "^8.3.0" "devDependencies": {
"bankai": "^3.2.0"
} }
} }
+4 -9
View File
@@ -10,12 +10,10 @@
app.model({ app.model({
namespace: 'counter', namespace: 'counter',
state: { state: { count: 0 },
count: 0
},
reducers: { reducers: {
increment: (data, state) => ({count: state.count + 1}), increment: (state, data) => ({count: state.count + 1}),
decrement: (data, state) => ({count: state.count - 1}) decrement: (state, data) => ({count: state.count - 1})
} }
}) })
@@ -32,10 +30,7 @@
` `
} }
app.router((route) => [ app.router(['/', mainView])
route('/', mainView)
])
const tree = app.start() const tree = app.start()
document.body.appendChild(tree) document.body.appendChild(tree)
</script> </script>
-1
View File
@@ -1 +0,0 @@
module.exports = require('xhr')
+102 -77
View File
@@ -1,10 +1,9 @@
const history = require('sheet-router/history') const createLocation = require('sheet-router/create-location')
const onHistoryChange = require('sheet-router/history')
const sheetRouter = require('sheet-router') const sheetRouter = require('sheet-router')
const document = require('global/document') const onHref = require('sheet-router/href')
const onReady = require('document-ready') const walk = require('sheet-router/walk')
const href = require('sheet-router/href') const mutate = require('xtend/mutable')
const hash = require('sheet-router/hash')
const hashMatch = require('hash-match')
const barracks = require('barracks') const barracks = require('barracks')
const nanoraf = require('nanoraf') const nanoraf = require('nanoraf')
const assert = require('assert') const assert = require('assert')
@@ -20,7 +19,7 @@ function choo (opts) {
const _store = start._store = barracks() const _store = start._store = barracks()
var _router = start._router = null var _router = start._router = null
var _defaultRoute = null var _routerOpts = null
var _rootNode = null var _rootNode = null
var _routes = null var _routes = null
var _frame = null var _frame = null
@@ -45,7 +44,7 @@ function choo (opts) {
_store.start({ subscriptions: false, reducers: false, effects: false }) _store.start({ subscriptions: false, reducers: false, effects: false })
const state = _store.state({ state: serverState }) const state = _store.state({ state: serverState })
const router = createRouter(_defaultRoute, _routes, createSend) const router = createRouter(_routerOpts, _routes, createSend)
const tree = router(route, state) const tree = router(route, state)
return tree.outerHTML || tree.toString() return tree.outerHTML || tree.toString()
@@ -58,38 +57,31 @@ function choo (opts) {
// start the application // start the application
// (str?, obj?) -> DOMNode // (str?, obj?) -> DOMNode
function start (selector, startOpts) { function start () {
if (!startOpts && typeof selector !== 'string') { _store.model(createLocationModel(opts))
startOpts = selector const createSend = _store.start(opts)
selector = null _router = start._router = createRouter(_routerOpts, _routes, createSend)
}
startOpts = startOpts || {}
_store.model(appInit(startOpts))
const createSend = _store.start(startOpts)
_router = start._router = createRouter(_defaultRoute, _routes, createSend)
const state = _store.state({state: {}}) const state = _store.state({state: {}})
if (!selector) { const tree = _router(state.location.href, state)
const tree = _router(state.location.pathname, state)
_rootNode = tree _rootNode = tree
tree.done = done
return tree return tree
} else {
onReady(function onReady () { // allow a 'mount' function to return the new node
const oldTree = document.querySelector(selector) // html -> null
assert.ok(oldTree, 'could not query selector: ' + selector) function done (newNode) {
const newTree = _router(state.location.pathname, state) _rootNode = newNode
_rootNode = yo.update(oldTree, newTree)
})
} }
} }
// update the DOM after every state mutation // update the DOM after every state mutation
// (obj, obj, obj, str, fn) -> null // (obj, obj, obj, str, fn) -> null
function render (data, state, prev, name, createSend) { function render (state, data, prev, name, createSend) {
if (!_frame) { if (!_frame) {
_frame = nanoraf(function (state, prev) { _frame = nanoraf(function (state, prev) {
const newTree = _router(state.location.pathname, state, prev) const newTree = _router(state.location.href, state, prev)
_rootNode = yo.update(_rootNode, newTree) _rootNode = yo.update(_rootNode, newTree)
}) })
} }
@@ -99,7 +91,7 @@ function choo (opts) {
// register all routes on the router // register all routes on the router
// (str?, [fn|[fn]]) -> obj // (str?, [fn|[fn]]) -> obj
function router (defaultRoute, routes) { function router (defaultRoute, routes) {
_defaultRoute = defaultRoute _routerOpts = defaultRoute
_routes = routes _routes = routes
} }
@@ -117,70 +109,103 @@ function choo (opts) {
} }
// create a new router with a custom `createRoute()` function // create a new router with a custom `createRoute()` function
// (str?, obj, fn?) -> null // (str?, obj) -> null
function createRouter (defaultRoute, routes, createSend) { function createRouter (routerOpts, routes, createSend) {
var prev = { params: {} } var prev = null
return sheetRouter(defaultRoute, routes, createRoute) if (!routes) {
routes = routerOpts
function createRoute (routeFn) { routerOpts = {}
return function (route, inline, child) {
if (typeof inline === 'function') {
inline = wrap(inline, route)
}
return routeFn(route, inline, child)
} }
routerOpts = mutate({ thunk: 'match' }, routerOpts)
const router = sheetRouter(routerOpts, routes)
walk(router, wrap)
function wrap (child, route) { return router
function wrap (route, handler) {
const send = createSend('view: ' + route, true) const send = createSend('view: ' + route, true)
return function chooWrap (params, state) { return function chooWrap (params) {
return function (state) {
const nwPrev = prev const nwPrev = prev
const nwState = prev = xtend(state, { params: params }) prev = state
// TODO(yw): find a way to wrap handlers so params shows up in state
const nwState = xtend(state)
nwState.location = xtend(nwState.location, { params: params })
if (opts.freeze !== false) Object.freeze(nwState) if (opts.freeze !== false) Object.freeze(nwState)
return child(nwState, nwPrev, send) return handler(nwState, nwPrev, send)
} }
} }
} }
} }
} }
// initial application state model // application location model
// obj -> obj // obj -> obj
function appInit (opts) { function createLocationModel (opts) {
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))
})
}, 'handleHash', subs)
} else {
if (opts.history !== false) pushLocationSub(history, 'handleHistory', subs)
if (opts.href !== false) pushLocationSub(href, 'handleHref', subs)
}
return { return {
namespace: 'location', namespace: 'location',
subscriptions: subs, state: mutate(createLocation(), { params: {} }),
reducers: reducers, subscriptions: createSubscriptions(opts),
state: state effects: { set: setLocation, touch: touchLocation },
reducers: { update: updateLocation }
} }
// create a new subscription that modifies // update the location on the state
// 'app:location' and push it to be loaded // try and jump to an anchor on the page if it exists
// (fn, obj) -> null // (obj, obj) -> obj
function pushLocationSub (cb, key, model) { function updateLocation (state, data) {
model[key] = function (send, done) { if (opts.history !== false && data.hash && data.hash !== state.hash) {
cb(function navigate (pathname) { try {
send('location:setLocation', { location: pathname }, done) const el = document.querySelector(data.hash)
if (el) el.scrollIntoView(true)
} catch (e) {
return data
}
}
return data
}
// update internal location only
// (str, obj, fn, fn) -> null
function touchLocation (state, data, send, done) {
const newLocation = createLocation(state, data)
send('location:update', newLocation, done)
}
// set a new location e.g. "/foo/bar#baz?beep=boop"
// (str, obj, fn, fn) -> null
function setLocation (state, data, send, done) {
const newLocation = createLocation(state, data)
// update url bar if it changed
if (opts.history !== false && newLocation.href !== state.href) {
window.history.pushState({}, null, newLocation.href)
}
send('location:update', newLocation, done)
}
function createSubscriptions (opts) {
const subs = {}
if (opts.history !== false) {
subs.handleHistory = function (send, done) {
onHistoryChange(function navigate (href) {
send('location:touch', href, done)
}) })
} }
} }
if (opts.href !== false) {
subs.handleHref = function (send, done) {
onHref(function navigate (location) {
send('location:set', location, done)
})
}
}
return subs
}
} }
+40
View File
@@ -0,0 +1,40 @@
// mount.js
const documentReady = require('document-ready')
const assert = require('assert')
const yo = require('yo-yo')
module.exports = mount
// (str, html) -> null
function mount (selector, newTree) {
assert.equal(typeof selector, 'string', 'choo/mount: selector should be a string')
assert.equal(typeof newTree, 'object', 'choo/mount: newTree should be an object')
const done = newTree.done
documentReady(function onReady () {
const _rootNode = document.querySelector(selector)
assert.ok(_rootNode, 'could not query selector: ' + selector)
// copy script tags from the old newTree to the new newTree so
// we can pass a <body> element straight up
if (_rootNode.nodeName === 'BODY') {
const children = _rootNode.childNodes
for (var i = 0; i < children.length; i++) {
if (children[i].nodeName === 'SCRIPT') {
newTree.appendChild(children[i].cloneNode(true))
}
}
}
const newNode = yo.update(_rootNode, newTree)
assert.equal(newNode, _rootNode, 'choo/mount: The DOM node: \n' +
newNode.outerHTML + '\n is not equal to \n' + newTree.outerHTML +
'choo cannot begin diffing.' +
' Make sure the same initial tree is rendered in the browser' +
' as on the server. Check out the choo handbook for more information')
// pass the node we mounted on back into choo
if (done) done(newNode)
})
}
+5 -6
View File
@@ -1,6 +1,6 @@
{ {
"name": "choo", "name": "choo",
"version": "3.3.0", "version": "4.0.0-7",
"description": "A 5kb framework for creating sturdy frontend applications", "description": "A 5kb framework for creating sturdy frontend applications",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
@@ -30,19 +30,17 @@
], ],
"files": [ "files": [
"index.js", "index.js",
"http.js", "mount.js",
"html.js", "html.js",
"dist/" "dist/"
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"barracks": "^8.3.1", "barracks": "^9.0.0",
"document-ready": "~1.0.2", "document-ready": "~1.0.2",
"global": "^4.3.0", "global": "^4.3.0",
"hash-match": "^1.0.2",
"nanoraf": "^2.1.1", "nanoraf": "^2.1.1",
"sheet-router": "^3.1.0", "sheet-router": "^4.0.1",
"xhr": "^2.2.0",
"xtend": "^4.0.1", "xtend": "^4.0.1",
"yo-yo": "^1.2.2" "yo-yo": "^1.2.2"
}, },
@@ -68,6 +66,7 @@
"server-router": "^3.0.0", "server-router": "^3.0.0",
"sheetify": "^5.1.0", "sheetify": "^5.1.0",
"standard": "^8.0.0", "standard": "^8.0.0",
"standard-markdown": "^2.2.0",
"tachyons": "^4.0.0-beta.19", "tachyons": "^4.0.0-beta.19",
"tape": "^4.5.1", "tape": "^4.5.1",
"tape-istanbul": "~1.0.2", "tape-istanbul": "~1.0.2",
+5 -2
View File
@@ -8,7 +8,8 @@ usage () {
} }
lint () { lint () {
standard standard > /dev/tty
standard-markdown > /dev/tty
} }
test_electron () { test_electron () {
@@ -52,7 +53,8 @@ test_browser_local () {
check_deps () { check_deps () {
dependency-check . --entry 'index.js' dependency-check . --entry 'index.js'
dependency-check . --entry 'index.js' --extra --no-dev \ dependency-check . --entry 'index.js' --extra --no-dev \
-i xhr -i document-ready \
-i global
} }
# set CLI flags # set CLI flags
@@ -70,6 +72,7 @@ case "$1" in
b|browser) shift; test_browser "$@";; b|browser) shift; test_browser "$@";;
l|browser-local) shift; test_browser_local "$@";; l|browser-local) shift; test_browser_local "$@";;
s|server) shift; test_server "$@";; s|server) shift; test_server "$@";;
lint) shift; lint "$@";;
c|cov) shift; test_cov "$@";; c|cov) shift; test_cov "$@";;
d|deps) shift; check_deps "$@";; d|deps) shift; check_deps "$@";;
esac esac
+12 -9
View File
@@ -1,7 +1,8 @@
const test = require('tape')
const append = require('append-child') const append = require('append-child')
const test = require('tape')
const choo = require('../../') const choo = require('../../')
const view = require('../../html') const html = require('../../html')
test('state is immutable', function (t) { test('state is immutable', function (t) {
t.plan(4) t.plan(4)
@@ -16,16 +17,16 @@ test('state is immutable', function (t) {
state: state, state: state,
namespace: 'test', namespace: 'test',
reducers: { reducers: {
'no-reducer-mutate': (data, state) => { 'no-reducer-mutate': (state, data) => {
return {} return {}
}, },
'mutate-on-return': (data, state) => { 'mutate-on-return': (state, data) => {
delete data.type delete data.type
return data return data
} }
}, },
effects: { effects: {
'triggers-reducers': (data, state, send, done) => { 'triggers-reducers': (state, data, send, done) => {
send('test:mutate-on-return', {beep: 'barp'}, done) send('test:mutate-on-return', {beep: 'barp'}, done)
} }
} }
@@ -46,13 +47,15 @@ test('state is immutable', function (t) {
(send) => send('test:triggers-reducers') (send) => send('test:triggers-reducers')
] ]
app.router((route) => [ app.router([
route('/', function (state, prev, send) { ['/', function (state, prev, send) {
++loop ++loop
asserts[loop] && asserts[loop](state.test) asserts[loop] && asserts[loop](state.test)
setTimeout(() => triggers[loop] && triggers[loop](send), 5) setTimeout(() => triggers[loop] && triggers[loop](send), 5)
return view`<div><span class="test">${state.foo}:${state.beep}</span></div>` return html`
}) <div><span class="test">${state.foo}:${state.beep}</span></div>
`
}]
]) ])
const tree = app.start() const tree = app.start()
+4 -8
View File
@@ -11,14 +11,12 @@ test('freeze (default)', function (t) {
} }
}) })
app.router((route) => [ app.router(['/', function (state, prev, send) {
route('/', function (state, prev, send) {
state.foo = '' state.foo = ''
t.equal(state.foo, 'bar', 'cannot modify property') t.equal(state.foo, 'bar', 'cannot modify property')
state.bar = 'baz' state.bar = 'baz'
t.equal(state.bar, undefined, 'cannot add property') t.equal(state.bar, undefined, 'cannot add property')
}) }])
])
app.start() app.start()
}) })
@@ -33,14 +31,12 @@ test('noFreeze', function (t) {
} }
}) })
app.router((route) => [ app.router(['/', function (state, prev, send) {
route('/', function (state, prev, send) {
state.foo = '' state.foo = ''
t.equal(state.foo, '', 'can modify property') t.equal(state.foo, '', 'can modify property')
state.bar = 'baz' state.bar = 'baz'
t.equal(state.bar, 'baz', 'can add property') t.equal(state.bar, 'baz', 'can add property')
}) }])
])
app.start() app.start()
}) })
+7 -9
View File
@@ -10,15 +10,15 @@ test('hooks', function (t) {
onError: function (err) { onError: function (err) {
t.equal(err.message, 'effect error', 'onError: receives err') t.equal(err.message, 'effect error', 'onError: receives err')
}, },
onAction: function (data, state, name, caller, createSend) { onAction: function (state, data, name, caller, createSend) {
if (name === 'explodes') return if (name === 'explodes') return
t.deepEqual(data, {foo: 'bar'}, 'onAction: action data') t.deepEqual(data, {foo: 'bar'}, 'onAction: action data')
t.equal(state.clicks, 0, 'onAction: current state: 0 clicks') t.equal(state.clicks, 0, 'onAction: current state: 0 clicks')
t.equal(name, 'click', 'onAction: action name') t.equal(name, 'click', 'onAction: data name')
t.equal(caller, 'view: /', 'onAction: caller name') t.equal(caller, 'view: /', 'onAction: caller name')
t.equal(typeof createSend, 'function', 'onAction: createSend fn') t.equal(typeof createSend, 'function', 'onAction: createSend fn')
}, },
onStateChange: function (data, state, prev, createSend) { onStateChange: function (state, data, prev, createSend) {
t.deepEqual(data, {foo: 'bar'}, 'onState: action data') t.deepEqual(data, {foo: 'bar'}, 'onState: action data')
t.deepEqual(state.clicks, 1, 'onState: new state: 1 clicks') t.deepEqual(state.clicks, 1, 'onState: new state: 1 clicks')
t.deepEqual(prev.clicks, 0, 'onState: prev state: 0 clicks') t.deepEqual(prev.clicks, 0, 'onState: prev state: 0 clicks')
@@ -30,26 +30,24 @@ test('hooks', function (t) {
clicks: 0 clicks: 0
}, },
reducers: { reducers: {
click: (data, state) => ({clicks: state.clicks + 1}) click: (state, data) => ({clicks: state.clicks + 1})
}, },
effects: { effects: {
explodes: (data, state, send, done) => { explodes: (state, data, send, done) => {
setTimeout(() => done(new Error('effect error')), 5) setTimeout(() => done(new Error('effect error')), 5)
} }
} }
}) })
var sent = false var sent = false
app.router((route) => [ app.router(['/', function (state, prev, send) {
route('/', function (state, prev, send) {
if (!sent) { if (!sent) {
send('click', {foo: 'bar'}) send('click', {foo: 'bar'})
send('explodes') send('explodes')
} }
sent = true sent = true
return view`<span></span>` return view`<span></span>`
}) }])
])
const tree = app.start() const tree = app.start()
t.on('end', append(tree)) t.on('end', append(tree))
+22 -13
View File
@@ -1,29 +1,38 @@
const test = require('tape') const test = require('tape')
const onReady = require('document-ready') const onReady = require('document-ready')
const append = require('append-child') const append = require('append-child')
const mount = require('../../mount')
const choo = require('../../') const choo = require('../../')
const view = require('../../html') const html = require('../../html')
test('rehydration', function (t) { test('rehydration', function (t) {
t.plan(2) t.plan(2)
const app = choo() const app = choo()
app.router((route) => [ const node = html`
route('/', function (state, prev, send) { <section id="app-root">
return view`<div id="app-root" onclick=${() => send('test')}>Hello world!</span>` <div id="app-root">Hello squirrel!</span>
}) </section>
]) `
var node = document.createElement('div') app.router(['/', function (state, prev, send) {
node.innerHTML = app.toString('/') return html`
node = node.childNodes[0] <section id="app-root">
t.on('end', append(node)) <div onclick=${() => send('test')}>Hello world!</span>
</section>
`
}])
app.start('#app-root') append(node)
const tree = app.start()
mount('#app-root', tree)
onReady(function () { onReady(function () {
t.equal(node.innerHTML, 'Hello world!', 'same content') const newNode = document.querySelector('#app-root')
t.equal(typeof node.onclick, 'function', 'attaches dom listeners') const el = newNode.children[0]
t.equal(el.innerHTML, 'Hello world!', 'same as it ever was')
t.equal(typeof el.onclick, 'function', 'attaches dom listeners')
}) })
}) })
+70 -70
View File
@@ -6,7 +6,7 @@ const view = require('../../html')
test('routing', function (t) { test('routing', function (t) {
t.test('history', function (t) { t.test('history', function (t) {
t.plan(3) t.plan(2)
const history = Event() const history = Event()
const choo = proxyquire('../..', { const choo = proxyquire('../..', {
@@ -20,23 +20,23 @@ test('routing', function (t) {
user: null user: null
}, },
reducers: { reducers: {
set: (data, state) => ({user: data.id}) set: (state, data) => ({user: data.id})
}, },
effects: { effects: {
open: function (data, state, send, done) { open: function (state, data, send, done) {
t.deepEqual(data, {id: 1}) t.deepEqual(data, {id: 1})
send('set', {id: 1}, function (err) { send('set', {id: 1}, function (err) {
if (err) return done(err) if (err) return done(err)
history.broadcast('https://foo.com/users/1') history.broadcast('/users/1')
}) })
} }
} }
}) })
app.router('/users', (route) => [ app.router({ default: '/users' }, [
route('/users', parentView, [ ['/users', parentView, [
route('/:user', childView) ['/:user', childView]
]) ]]
]) ])
const tree = app.start() const tree = app.start()
@@ -59,74 +59,76 @@ test('routing', function (t) {
} }
}) })
t.test('hash', function (t) { // t.test('hash', function (t) {
t.plan(1) // t.plan(1)
const hash = Event() // resetLocation()
const choo = proxyquire('../..', { // const hash = Event()
'sheet-router/hash': hash.listen // const choo = proxyquire('../..', {
}) // 'sheet-router/hash': hash.listen
// })
const app = choo() // const app = choo({hash: true})
app.model({ // app.model({
state: { // state: {
user: null // user: null
}, // },
reducers: { // reducers: {
set: (data, state) => ({user: data.id}) // set: (state, data) => ({user: action.id})
}, // },
effects: { // effects: {
open: function (data, state, send, done) { // open: function (state, data, send, done) {
send('set', {id: 1}, function (err) { // send('set', {id: 1}, function (err) {
if (err) return done(err) // if (err) return done(err)
hash.broadcast('#users/1') // hash.broadcast('#users/1')
}) // })
} // }
} // }
}) // })
app.router('/users', (route) => [ // app.router({ default: '/users' }, [
route('/users', parentView, [ // ['/users', parentView, [
route('/:user', childView) // ['/:user', childView]
]) // ]]
]) // ])
const tree = app.start({hash: true}) // const tree = app.start()
t.on('end', append(tree)) // t.on('end', append(tree))
tree.onclick() // tree.onclick()
function parentView (state, prev, send) { // function parentView (state, prev, send) {
return view` // return view`
<button onclick=${() => send('open', {id: 1})}> // <button onclick=${() => send('open', {id: 1})}>
Open // Open
</button> // </button>
` // `
} // }
function childView (state, prev, send) { // function childView (state, prev, send) {
t.equal(state.user, 1) // t.equal(state.user, 1)
return view`<button>${state.user}</button>` // return view`<p>${state.user}</p>`
} // }
}) // })
t.test('disabling history', function (t) { t.test('disabling history', function (t) {
t.plan(1) t.plan(1)
resetLocation()
const choo = proxyquire('../..', { const choo = proxyquire('../..', {
'sheet-router/history': () => t.fail('history listener attached') 'sheet-router/history': () => t.fail('history listener attached')
}) })
const app = choo() const app = choo({ history: false })
app.router('/', (route) => [ app.router('/', [
route('/', function () { ['/', function () {
t.pass('rendered') t.pass('rendered')
}) }]
]) ])
app.start({history: false}) app.start()
}) })
t.test('disabling href', function (t) { t.test('disabling href', function (t) {
@@ -136,15 +138,9 @@ test('routing', function (t) {
'sheet-router/href': () => t.fail('href listener attached') 'sheet-router/href': () => t.fail('href listener attached')
}) })
const app = choo() const app = choo({ href: false })
app.router(['/', () => t.pass('rendered')])
app.router('/', (route) => [ app.start()
route('/', function () {
t.pass('rendered')
})
])
app.start({href: false})
}) })
t.test('viewless nesting', function (t) { t.test('viewless nesting', function (t) {
@@ -153,12 +149,12 @@ test('routing', function (t) {
const choo = require('../..') const choo = require('../..')
const app = choo() const app = choo()
app.router('/users/123', (route) => [ app.router({ default: '/users/123' }, [
route('/users', [ ['/users', [
route('/:user', function (state) { ['/:user', function (state) {
t.deepEqual(state.params, {user: '123'}) t.deepEqual(state.params, {user: '123'})
}) }]
]) ]]
]) ])
app.start() app.start()
@@ -181,3 +177,7 @@ test('routing', function (t) {
app.start() app.start()
}) })
}) })
function resetLocation () {
window.history.pushState({}, null, '/')
}
+10 -30
View File
@@ -8,9 +8,7 @@ test('server', function (t) {
t.plan(1) t.plan(1)
const app = choo() const app = choo()
app.router((route) => [ app.router(['/', () => view`<h1>Hello Tokyo!</h1>`])
route('/', () => view`<h1>Hello Tokyo!</h1>`)
])
const html = app.toString('/') const html = app.toString('/')
const expected = '<h1>Hello Tokyo!</h1>' const expected = '<h1>Hello Tokyo!</h1>'
@@ -21,9 +19,7 @@ test('server', function (t) {
t.plan(1) t.plan(1)
const app = choo() const app = choo()
app.router((route) => [ app.router(['/', () => minDocument.createElement('div')])
route('/', () => minDocument.createElement('div'))
])
const html = app.toString('/') const html = app.toString('/')
const expected = '<div></div>' const expected = '<div></div>'
@@ -34,11 +30,9 @@ test('server', function (t) {
t.plan(1) t.plan(1)
const app = choo() const app = choo()
app.router((route) => [ app.router(['/', (state, prev, send) => {
route('/', function (state, prev, send) {
return view`<h1>meow meow ${state.message}</h1>` return view`<h1>meow meow ${state.message}</h1>`
}) }])
])
const html = app.toString('/', { message: 'nyan!' }) const html = app.toString('/', { message: 'nyan!' })
const expected = '<h1>meow meow nyan!</h1>' const expected = '<h1>meow meow nyan!</h1>'
@@ -50,11 +44,9 @@ test('server', function (t) {
const app = choo() const app = choo()
app.model({ state: { bin: 'baz', beep: 'boop' } }) app.model({ state: { bin: 'baz', beep: 'boop' } })
app.router((route) => [ app.router(['/', (state, prev, send) => {
route('/', function (state, prev, send) {
return view`<h1>${state.foo} ${state.bin} ${state.beep}</h1>` return view`<h1>${state.foo} ${state.bin} ${state.beep}</h1>`
}) }])
])
const state = { foo: 'bar!', beep: 'beep' } const state = { foo: 'bar!', beep: 'beep' }
const html = app.toString('/', state) const html = app.toString('/', state)
@@ -70,13 +62,11 @@ test('server', function (t) {
namespace: 'hello', namespace: 'hello',
state: { bin: 'baz', beep: 'boop' } state: { bin: 'baz', beep: 'boop' }
}) })
app.router((route) => [ app.router(['/', (state, prev, send) => {
route('/', function (state, prev, send) {
return view` return view`
<h1>${state.hello.foo} ${state.hello.bin} ${state.hello.beep}</h1> <h1>${state.hello.foo} ${state.hello.bin} ${state.hello.beep}</h1>
` `
}) }])
])
const state = { const state = {
hello: { hello: {
@@ -93,12 +83,7 @@ test('server', function (t) {
t.plan(1) t.plan(1)
const app = choo() const app = choo()
app.router((route) => [ app.router(['/', (state, prev, send) => send('hey!')])
route('/', function (state, prev, send) {
send('hey!')
})
])
t.throws(app.toString.bind(null), /route must be a string/) t.throws(app.toString.bind(null), /route must be a string/)
}) })
@@ -106,12 +91,7 @@ test('server', function (t) {
t.plan(1) t.plan(1)
const app = choo() const app = choo()
app.router((route) => [ app.router(['/', (state, prev, send) => send('hey!')])
route('/', function (state, prev, send) {
send('hey!')
})
])
const msg = /send\(\) cannot be called/ const msg = /send\(\) cannot be called/
t.throws(app.toString.bind(null, '/', { message: 'nyan!' }), msg) t.throws(app.toString.bind(null, '/', { message: 'nyan!' }), msg)
}) })