Permalink
tornqvist
Immutable state on SSR (toString) (#649)
842b7aa
Jun 12, 2019
Join GitHub today
GitHub is home to over 40 million developers working together to host and review code, manage projects, and build software together.
Sign up| var scrollToAnchor = require('scroll-to-anchor') | |
| var documentReady = require('document-ready') | |
| var nanotiming = require('nanotiming') | |
| var nanorouter = require('nanorouter') | |
| var nanomorph = require('nanomorph') | |
| var nanoquery = require('nanoquery') | |
| var nanohref = require('nanohref') | |
| var nanoraf = require('nanoraf') | |
| var nanobus = require('nanobus') | |
| var assert = require('assert') | |
| var Cache = require('./component/cache') | |
| module.exports = Choo | |
| var HISTORY_OBJECT = {} | |
| function Choo (opts) { | |
| var timing = nanotiming('choo.constructor') | |
| if (!(this instanceof Choo)) return new Choo(opts) | |
| opts = opts || {} | |
| assert.equal(typeof opts, 'object', 'choo: opts should be type object') | |
| var self = this | |
| // define events used by choo | |
| this._events = { | |
| DOMCONTENTLOADED: 'DOMContentLoaded', | |
| DOMTITLECHANGE: 'DOMTitleChange', | |
| REPLACESTATE: 'replaceState', | |
| PUSHSTATE: 'pushState', | |
| NAVIGATE: 'navigate', | |
| POPSTATE: 'popState', | |
| RENDER: 'render' | |
| } | |
| // properties for internal use only | |
| this._historyEnabled = opts.history === undefined ? true : opts.history | |
| this._hrefEnabled = opts.href === undefined ? true : opts.href | |
| this._hashEnabled = opts.hash === undefined ? false : opts.hash | |
| this._hasWindow = typeof window !== 'undefined' | |
| this._cache = opts.cache | |
| this._loaded = false | |
| this._stores = [ondomtitlechange] | |
| this._tree = null | |
| // state | |
| var _state = { | |
| events: this._events, | |
| components: {} | |
| } | |
| if (this._hasWindow) { | |
| this.state = window.initialState | |
| ? Object.assign({}, window.initialState, _state) | |
| : _state | |
| delete window.initialState | |
| } else { | |
| this.state = _state | |
| } | |
| // properties that are part of the API | |
| this.router = nanorouter({ curry: true }) | |
| this.emitter = nanobus('choo.emit') | |
| this.emit = this.emitter.emit.bind(this.emitter) | |
| // listen for title changes; available even when calling .toString() | |
| if (this._hasWindow) this.state.title = document.title | |
| function ondomtitlechange (state) { | |
| self.emitter.prependListener(self._events.DOMTITLECHANGE, function (title) { | |
| assert.equal(typeof title, 'string', 'events.DOMTitleChange: title should be type string') | |
| state.title = title | |
| if (self._hasWindow) document.title = title | |
| }) | |
| } | |
| timing() | |
| } | |
| Choo.prototype.route = function (route, handler) { | |
| var routeTiming = nanotiming("choo.route('" + route + "')") | |
| assert.equal(typeof route, 'string', 'choo.route: route should be type string') | |
| assert.equal(typeof handler, 'function', 'choo.handler: route should be type function') | |
| this.router.on(route, handler) | |
| routeTiming() | |
| } | |
| Choo.prototype.use = function (cb) { | |
| assert.equal(typeof cb, 'function', 'choo.use: cb should be type function') | |
| var self = this | |
| this._stores.push(function (state) { | |
| var msg = 'choo.use' | |
| msg = cb.storeName ? msg + '(' + cb.storeName + ')' : msg | |
| var endTiming = nanotiming(msg) | |
| cb(state, self.emitter, self) | |
| endTiming() | |
| }) | |
| } | |
| Choo.prototype.start = function () { | |
| assert.equal(typeof window, 'object', 'choo.start: window was not found. .start() must be called in a browser, use .toString() if running in Node') | |
| var startTiming = nanotiming('choo.start') | |
| var self = this | |
| if (this._historyEnabled) { | |
| this.emitter.prependListener(this._events.NAVIGATE, function () { | |
| self._matchRoute(self.state) | |
| if (self._loaded) { | |
| self.emitter.emit(self._events.RENDER) | |
| setTimeout(scrollToAnchor.bind(null, window.location.hash), 0) | |
| } | |
| }) | |
| this.emitter.prependListener(this._events.POPSTATE, function () { | |
| self.emitter.emit(self._events.NAVIGATE) | |
| }) | |
| this.emitter.prependListener(this._events.PUSHSTATE, function (href) { | |
| assert.equal(typeof href, 'string', 'events.pushState: href should be type string') | |
| window.history.pushState(HISTORY_OBJECT, null, href) | |
| self.emitter.emit(self._events.NAVIGATE) | |
| }) | |
| this.emitter.prependListener(this._events.REPLACESTATE, function (href) { | |
| assert.equal(typeof href, 'string', 'events.replaceState: href should be type string') | |
| window.history.replaceState(HISTORY_OBJECT, null, href) | |
| self.emitter.emit(self._events.NAVIGATE) | |
| }) | |
| window.onpopstate = function () { | |
| self.emitter.emit(self._events.POPSTATE) | |
| } | |
| if (self._hrefEnabled) { | |
| nanohref(function (location) { | |
| var href = location.href | |
| var hash = location.hash | |
| if (href === window.location.href) { | |
| if (!self._hashEnabled && hash) scrollToAnchor(hash) | |
| return | |
| } | |
| self.emitter.emit(self._events.PUSHSTATE, href) | |
| }) | |
| } | |
| } | |
| this._setCache(this.state) | |
| this._matchRoute(this.state) | |
| this._stores.forEach(function (initStore) { | |
| initStore(self.state) | |
| }) | |
| this._tree = this._prerender(this.state) | |
| assert.ok(this._tree, 'choo.start: no valid DOM node returned for location ' + this.state.href) | |
| this.emitter.prependListener(self._events.RENDER, nanoraf(function () { | |
| var renderTiming = nanotiming('choo.render') | |
| var newTree = self._prerender(self.state) | |
| assert.ok(newTree, 'choo.render: no valid DOM node returned for location ' + self.state.href) | |
| assert.equal(self._tree.nodeName, newTree.nodeName, 'choo.render: The target node <' + | |
| self._tree.nodeName.toLowerCase() + '> is not the same type as the new node <' + | |
| newTree.nodeName.toLowerCase() + '>.') | |
| var morphTiming = nanotiming('choo.morph') | |
| nanomorph(self._tree, newTree) | |
| morphTiming() | |
| renderTiming() | |
| })) | |
| documentReady(function () { | |
| self.emitter.emit(self._events.DOMCONTENTLOADED) | |
| self._loaded = true | |
| }) | |
| startTiming() | |
| return this._tree | |
| } | |
| Choo.prototype.mount = function mount (selector) { | |
| var mountTiming = nanotiming("choo.mount('" + selector + "')") | |
| if (typeof window !== 'object') { | |
| assert.ok(typeof selector === 'string', 'choo.mount: selector should be type String') | |
| this.selector = selector | |
| mountTiming() | |
| return this | |
| } | |
| assert.ok(typeof selector === 'string' || typeof selector === 'object', 'choo.mount: selector should be type String or HTMLElement') | |
| var self = this | |
| documentReady(function () { | |
| var renderTiming = nanotiming('choo.render') | |
| var newTree = self.start() | |
| if (typeof selector === 'string') { | |
| self._tree = document.querySelector(selector) | |
| } else { | |
| self._tree = selector | |
| } | |
| assert.ok(self._tree, 'choo.mount: could not query selector: ' + selector) | |
| assert.equal(self._tree.nodeName, newTree.nodeName, 'choo.mount: The target node <' + | |
| self._tree.nodeName.toLowerCase() + '> is not the same type as the new node <' + | |
| newTree.nodeName.toLowerCase() + '>.') | |
| var morphTiming = nanotiming('choo.morph') | |
| nanomorph(self._tree, newTree) | |
| morphTiming() | |
| renderTiming() | |
| }) | |
| mountTiming() | |
| } | |
| Choo.prototype.toString = function (location, state) { | |
| state = state || {} | |
| state.components = state.components || {} | |
| state.events = Object.assign({}, state.events, this._events) | |
| assert.notEqual(typeof window, 'object', 'choo.mount: window was found. .toString() must be called in Node, use .start() or .mount() if running in the browser') | |
| assert.equal(typeof location, 'string', 'choo.toString: location should be type string') | |
| assert.equal(typeof state, 'object', 'choo.toString: state should be type object') | |
| this._setCache(state) | |
| this._matchRoute(state, location) | |
| this.emitter.removeAllListeners() | |
| this._stores.forEach(function (initStore) { | |
| initStore(state) | |
| }) | |
| var html = this._prerender(state) | |
| assert.ok(html, 'choo.toString: no valid value returned for the route ' + location) | |
| assert(!Array.isArray(html), 'choo.toString: return value was an array for the route ' + location) | |
| return typeof html.outerHTML === 'string' ? html.outerHTML : html.toString() | |
| } | |
| Choo.prototype._matchRoute = function (state, locationOverride) { | |
| var location, queryString | |
| if (locationOverride) { | |
| location = locationOverride.replace(/\?.+$/, '').replace(/\/$/, '') | |
| if (!this._hashEnabled) location = location.replace(/#.+$/, '') | |
| queryString = locationOverride | |
| } else { | |
| location = window.location.pathname.replace(/\/$/, '') | |
| if (this._hashEnabled) location += window.location.hash.replace(/^#/, '/') | |
| queryString = window.location.search | |
| } | |
| var matched = this.router.match(location) | |
| this._handler = matched.cb | |
| state.href = location | |
| state.query = nanoquery(queryString) | |
| state.route = matched.route | |
| state.params = matched.params | |
| } | |
| Choo.prototype._prerender = function (state) { | |
| var routeTiming = nanotiming("choo.prerender('" + state.route + "')") | |
| var res = this._handler(state, this.emit) | |
| routeTiming() | |
| return res | |
| } | |
| Choo.prototype._setCache = function (state) { | |
| var cache = new Cache(state, this.emitter.emit.bind(this.emitter), this._cache) | |
| state.cache = renderComponent | |
| function renderComponent (Component, id) { | |
| assert.equal(typeof Component, 'function', 'choo.state.cache: Component should be type function') | |
| var args = [] | |
| for (var i = 0, len = arguments.length; i < len; i++) { | |
| args.push(arguments[i]) | |
| } | |
| return cache.render.apply(cache, args) | |
| } | |
| // When the state gets stringified, make sure `state.cache` isn't | |
| // stringified too. | |
| renderComponent.toJSON = function () { | |
| return null | |
| } | |
| } |