From 60665f2a71fddc84cc38d7475c1e829ec76f4a61 Mon Sep 17 00:00:00 2001 From: Yoshua Wuyts Date: Mon, 23 May 2016 23:39:22 +0900 Subject: [PATCH] toString: support rehydration --- README.md | 52 +++++++++++++++++++++++-- examples/server-rendering/client.js | 11 +++++- examples/server-rendering/package.json | 2 + examples/server-rendering/server.js | 23 ++++++++--- examples/server-rendering/views/main.js | 21 ++++++++-- index.js | 47 +++++++++++++++++----- test.js | 7 ---- tests/index.js | 29 +++++++++++++- 8 files changed, 159 insertions(+), 33 deletions(-) delete mode 100644 test.js diff --git a/README.md b/README.md index 73254c8..a527068 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ productive package. - [keyboard](#keyboard) - [websockets](#websockets) - [Rendering in Node](#rendering-in-node) + - [Rehydration](#rehydration) - [API](#api) - [FAQ](#faq) - [Installation](#installation) @@ -310,9 +311,10 @@ queueing data. You might want to use a package from `npm` or [write your own][ws-reconnect] if you're building something for production. ## Rendering in Node -Sometimes it's necessary to render code inside of Node; for serving first -requests, testing or other purposes. Applications that are capable of being -rendered in both Node and the browser are called _[isomorphic][isomorphic]_. +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` @@ -352,6 +354,45 @@ if (module.parent) module.exports = app else document.body.appendChild(app.start()) ``` +### Rehydration +Now that your application is succesfully 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 @@ -404,7 +445,7 @@ 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 (`~150` LOC) and mostly acts to enforce structure around +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. @@ -535,3 +576,6 @@ $ npm install choo [module-parent]: https://nodejs.org/dist/latest-v6.x/docs/api/modules.html#modules_module_parent [sse-reconnect]: http://stackoverflow.com/questions/24564030/is-an-eventsource-sse-supposed-to-try-to-reconnect-indefinitely [ws-reconnect]: http://stackoverflow.com/questions/13797262/how-to-reconnect-to-websocket-after-close-connection +[bl]: https://github.com/rvagg/bl +[varnish]: https://varnish-cache.org +[nginx]: http://nginx.org/ diff --git a/examples/server-rendering/client.js b/examples/server-rendering/client.js index 16d09b9..7e8283c 100644 --- a/examples/server-rendering/client.js +++ b/examples/server-rendering/client.js @@ -4,6 +4,14 @@ const mainView = require('./views/main') const app = choo() +app.model({ + namespace: 'message', + state: { + server: 'rehydration has kicked in, server data was tossed', + client: 'hello client!' + } +}) + app.router((route) => [ route('/', mainView) ]) @@ -11,6 +19,5 @@ app.router((route) => [ if (module.parent) { module.exports = app } else { - const tree = app.start() - document.body.appendChild(tree) + app.start('#app-root') } diff --git a/examples/server-rendering/package.json b/examples/server-rendering/package.json index 687cd91..5c6d6f5 100644 --- a/examples/server-rendering/package.json +++ b/examples/server-rendering/package.json @@ -7,7 +7,9 @@ }, "dependencies": { "bankai": "^2.0.2", + "bl": "^1.1.2", "browserify": "^13.0.1", + "hyperstream": "^1.2.2", "server-router": "^2.1.0" } } diff --git a/examples/server-rendering/server.js b/examples/server-rendering/server.js index 93e138f..2a22276 100644 --- a/examples/server-rendering/server.js +++ b/examples/server-rendering/server.js @@ -1,4 +1,7 @@ const serverRouter = require('server-router') +const hyperstream = require('hyperstream') +const browserify = require('browserify') +const bankai = require('bankai') const http = require('http') const PORT = 8080 @@ -17,18 +20,26 @@ server.listen(PORT, () => process.stdout.write(`listening on port ${PORT}\n`)) // null -> fn function createRouter () { const apiRouter = serverRouter('/404') - apiRouter.on('/404', (req, res) => res.end('not found')) - apiRouter.on('/', (req, res) => { - res.end(JSON.stringify({ routes: [ '/', '/404' ] })) - }) + + apiRouter.on('/404', (req, res) => res.end('404 not found')) + apiRouter.on('/', (req, res) => res.end('nothing to be found here')) + + const js = bankai.js(browserify, require.resolve('./client.js')) + apiRouter.on('/bundle.js', (req, res) => js(req, res).pipe(res)) + return apiRouter } // render the client to string // based on the requested url // (obj, obj) -> null +const createIndex = bankai.html({ favicon: false, css: false }) function handleHtml (req, res) { - const html = client.toString(req.url, { message: 'hello server!' }) res.setHeader('Content-Type', 'text/html; charset=utf-8') - res.end(html) + + const state = { message: { server: 'hello server!' } } + const inner = client.toString(req.url, state) + const hs = hyperstream({ 'body': { _appendHtml: inner } }) + + createIndex(req, res).pipe(hs).pipe(res) } diff --git a/examples/server-rendering/views/main.js b/examples/server-rendering/views/main.js index c0dd659..c038c87 100644 --- a/examples/server-rendering/views/main.js +++ b/examples/server-rendering/views/main.js @@ -1,10 +1,25 @@ +const assert = require('assert') const choo = require('../../../') module.exports = function (params, state, send) { - const message = state.message + const serverMessage = state.message.server + const clientMessage = state.message.client + + assert.equal(typeof serverMessage, 'string', 'server should be a string') + assert.equal(typeof clientMessage, 'string', 'server should be a string') + return choo.view` -
-

${message}

+
+

server message: ${serverMessage}

+

client message: ${clientMessage}

+

${` + The first message is passed in by the server on compile time, + the second message was set by the client. + The more static the data you pass in, the more cachable your site + beocmes (and thus performant). Try and keep the amount of properties + you pass in on the server to a minimum for most applications - it'll + make life a lot easier in the long run, hah. + `}

` } diff --git a/index.js b/index.js index 62e0718..67f4f50 100644 --- a/index.js +++ b/index.js @@ -25,14 +25,23 @@ function choo () { // render the application to a string // (str, obj) -> str - function toString (route, state) { + function toString (route, serverState) { const initialState = {} + const nsState = {} _models.forEach(function (model) { - if (model.state) apply(model.name, model.state, initialState) + const ns = model.namespace + if (ns) { + if (!nsState[ns]) nsState[ns] = {} + apply(ns, model.state, nsState) + nsState[ns] = xtend(nsState[ns], serverState[ns]) + } else { + apply(model.namespace, model.state, initialState) + } }) - const tree = _router(route, xtend(initialState, state), function () { + const state = xtend(initialState, xtend(serverState, nsState)) + const tree = _router(route, state, function () { throw new Error('send() cannot be called on the server') }) @@ -40,8 +49,9 @@ function choo () { } // start the application - // obj -> DOMNode - function start (opts) { + // (str?, obj?) -> DOMNode + function start (rootId, opts) { + if (!opts) opts = rootId opts = opts || {} const name = opts.name || 'choo' const initialState = {} @@ -75,15 +85,32 @@ function choo () { } }) - // the rootId is determined to find the application root + // If an id is provided, the application will rehydrate + // on the node. If no id is provided it will return + // a tree that's ready to be appended to the DOM. + // + // The rootId is determined to find the application root // on update. Since the DOM nodes change between updates, // we must call document.querySelector() to find the root. // Use different names when loading multiple choo applications // on the same page - const rootId = name + '-root' - const tree = _router(send.state().app.location, send.state(), send) - tree.setAttribute('id', rootId) - return tree + if (rootId) { + document.addEventListener('DOMContentLoaded', function (event) { + rootId = rootId.replace(/^#/, '') + + const oldTree = document.querySelector('#' + rootId) + assert.ok(oldTree, 'could not find node #' + rootId) + + const newTree = _router(send.state().app.location, send.state(), send) + + yo.update(oldTree, newTree) + }) + } else { + rootId = name + '-root' + const tree = _router(send.state().app.location, send.state(), send) + tree.setAttribute('id', rootId) + return tree + } // handle an action by either reducers, effects // or both - return the new state when done diff --git a/test.js b/test.js deleted file mode 100644 index 0fe45ac..0000000 --- a/test.js +++ /dev/null @@ -1,7 +0,0 @@ -const test = require('tape') -const choo = require('./') - -test('should assert input types', function (t) { - t.plan(1) - t.throws(choo) -}) diff --git a/tests/index.js b/tests/index.js index e71a307..e9bbe8f 100644 --- a/tests/index.js +++ b/tests/index.js @@ -30,7 +30,7 @@ tape('should render on the server', function (t) { t.equal(html, expected, 'strings are equal') }) - t.test('should extend existing models', function (t) { + t.test('should extend flat existing models', function (t) { t.plan(1) const app = choo() @@ -47,6 +47,33 @@ tape('should render on the server', function (t) { t.equal(html, expected, 'strings are equal') }) + t.test('should extend namespaced existing models', function (t) { + t.plan(1) + + const app = choo() + app.model({ + namespace: 'hello', + state: { bin: 'baz', beep: 'boop' } + }) + app.router((route) => [ + route('/', function (params, state) { + return choo.view` +

${state.hello.foo} ${state.hello.bin} ${state.hello.beep}

+ ` + }) + ]) + + const state = { + hello: { + foo: 'bar!', + beep: 'beep' + } + } + const html = app.toString('/', state) + const expected = '

bar! baz beep

' + t.equal(html, expected, 'strings are equal') + }) + t.test('should throw if called without route', function (t) { t.plan(1)