diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..22bd7d1 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -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. diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 0000000..a1d3d67 --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,8 @@ +### Expected behavior +Write here. + +### Actual behavior +Write here. + +### Steps to reproduce behavior +Write here. diff --git a/.gitignore b/.gitignore index 823b3d1..7582006 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ coverage/ +coverage.json tmp/ npm-debug.log* .DS_Store diff --git a/.travis.yml b/.travis.yml index c1e0e35..99ea9e2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -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: diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ebf230..60c7349 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 5355dcc..75b5fb1 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Fun functional programming
- A 7kb framework for creating sturdy frontend applications + A 5kb framework for creating sturdy frontend applications

@@ -50,7 +50,19 @@ -
+
+

+ + Handbook + + | + Packages + | + + Contributing + +

+
The little framework that could. Built with ❤︎ by @@ -60,35 +72,22 @@
-## 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) +

Table of Contents

+
+ Table of Contents +
  • Features
  • +
  • Demos
  • +
  • Example
  • +
  • Philosophy
  • +
  • Concepts
  • +
  • API
  • +
  • FAQ
  • +
  • Installation
  • +
  • See Also
  • +
    ## 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 `` field which sends the current value of the text box on every -input: -```js -const mainView = (params, state, send) => choo.view` -
    -

    ${state.title}

    - send('update', { value: e.target.value })}> -
    -` -``` - -_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`
    -

    ${state.title}

    +

    Title: ${state.title}

    send('update', { value: e.target.value })}> + oninput=${(e) => send('update', e.target.value)}>
    ` @@ -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. They’re passed the current state, and any time the state changes they’re 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`

    Total todos: ${state.todos.length}

    - +
    ` } ``` In this example, when the `Add` button is clicked, the view will dispatch an `add` action that the model’s `add` reducer will receive. [As seen above](#models), the reducer will add an item to the state’s `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` -
    -
    - - -
    -
    - - -
    - -
    - ` - - 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` -
    - -
    -` -``` - -### Links -In HTML links are represented with the `` 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` - home - first link - second link -` -``` - -### 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` -

    ${state.message}

    - `) -]) - -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` -

    ${state.message}

    - `) -]) - -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 `` 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` + +` +``` +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 diff --git a/examples/http/client.js b/examples/http/client.js index c1473a5..7ca0145 100644 --- a/examples/http/client.js +++ b/examples/http/client.js @@ -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')) diff --git a/examples/http/models/api.js b/examples/http/models/api.js index 65be0db..9fd5826 100644 --- a/examples/http/models/api.js +++ b/examples/http/models/api.js @@ -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) }) } diff --git a/examples/http/models/error.js b/examples/http/models/error.js index 632d0e5..bd1620a 100644 --- a/examples/http/models/error.js +++ b/examples/http/models/error.js @@ -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) + }) } } } diff --git a/examples/http/views/main.js b/examples/http/views/main.js index 4d0d435..989af23 100644 --- a/examples/http/views/main.js +++ b/examples/http/views/main.js @@ -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`

    ${title}

    Latest error: ${error}

    diff --git a/examples/mailbox/elements/email-list.js b/examples/mailbox/elements/email-list.js index 4a03b5e..be25b8b 100644 --- a/examples/mailbox/elements/email-list.js +++ b/examples/mailbox/elements/email-list.js @@ -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`
    Date @@ -20,7 +21,7 @@ module.exports = function (params, state, send) { } function createMessage (message, mailbox) { - return choo.view` + return html`