├── .babelrc ├── .gitignore ├── README.md ├── lib ├── createRouteMatcher.js ├── index.js ├── ui.js └── worker.js ├── package.json └── src ├── createRouteMatcher.js ├── index.js ├── ui.js └── worker.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015"] 3 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | npm-debug.log -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # featherweight 2 | 3 | **PLEASE NOTE:** This is all highly experimental, I've not built anything major with this and there are holes in functionality and unsolved problems related to events. Also, just because I'm open sourcing doesn't mean I'll maintain it for you or do anything else with it, ever. This is a pure experiment at this point. I'm simply sharing it in the spirit of open source in case anyone finds it interesting. 4 | 5 | ![](https://img.shields.io/npm/dm/featherweight.svg)![](https://img.shields.io/npm/v/featherweight.svg)![](https://img.shields.io/npm/l/featherweight.svg)[![No Maintenance Intended](http://unmaintained.tech/badge.svg)](http://unmaintained.tech/) 6 | 7 | **The basic idea of featherweight is that you as a developer only do this:** 8 | 9 | 1. Configure [redux](https://github.com/rackt/redux) however you want it (all your application state will live in Redux) 10 | 2. Write a single, main, pure, synchronous view function that takes the state from redux (including the current URL of your app) and returns a virtual dom to represent what it should look like at that exact moment. 11 | 12 | *That's it!* 13 | 14 | **Featherweight then manages everything else**. It's a simple abstraction to manage communication between the worker thread (where Redux and your reducers will run) and the main thread:** 15 | 16 | 1. 99% of your app code runs in a web worker 17 | 2. No fancy router, the url is just another piece of state 18 | 3. You can super easily pre-render as much HTML as possible before sending to the client either on the server or to static HTML files at build time. 19 | 4. JS "takes over" on the clientside as soon as it's loaded. 20 | 5. All code necessary to do the above should be < 10kb min + gzipped 21 | 22 | 23 | ## understanding the application pattern 24 | 25 | Featherweight lets you do all the heavy application work in a web worker. This means rendering new UI, virtual dom diffing, data fetching, etc. all happens in a Web Worker. 26 | 27 | **This leaves the main thread free to focus entirely on efficient DOM updates and listening for user interractions.** 28 | 29 | It consists of two primary components: 30 | 31 | 1. The worker code: `import { worker } from 'featherweight'` 32 | 2. The main thread (ui thread) code: `import { ui } from 'featherweight'` 33 | 34 | 35 | ### Setting up the main thread 36 | 37 | Featherweight's `ui` handles most of the main thread for you. The only custom code you need here, may be to import whatever styles or 3rd party scripts. 38 | 39 | Typically, it would just look like this: 40 | 41 | ```js 42 | // import the ui module 43 | import { ui } from 'featherweight' 44 | 45 | // import your worker code (with webpack-worker-loader this Just Works™) 46 | import WorkerThread from './worker.thread' 47 | 48 | // possibly import styles if you're using some sort of css/style loader with webpack 49 | import './styles/main.styl' 50 | 51 | ui({ 52 | // pass it the worker thread 53 | worker: new WorkerThread(), 54 | 55 | // Pass in the root element where you 56 | // want the app to live. It's recommended 57 | // that this be something other than the 58 | // in case other libraries or 59 | // browser plugins need to insert elements, etc, 60 | rootElement: document.body.firstChild 61 | }) 62 | ``` 63 | 64 | ### setting up the worker thread 65 | 66 | Here's where all the work happens, but again, most of your efforts will go into writing the Redux reducers and UI code. 67 | 68 | The boilerplate inside the worker is pretty straight forward: 69 | 70 | ```js 71 | // worker.thread.js 72 | import { worker } from 'featherweight' 73 | import appView from './views/app' 74 | import configureRedux from './configureRedux' 75 | 76 | worker({ 77 | // pass it your redux main store 78 | // here we assume we're setting up 79 | // redux and it's reducers in another module 80 | // that exports a function we can use to 81 | // get the configured redux instance 82 | redux: configureRedux(), 83 | 84 | // This is the main application UI component that 85 | // you'll write. It should be a pure function returning 86 | // a new virtual DOM when passed the main application state 87 | // object we get from Redux. 88 | view: appView, 89 | 90 | // To keep things easy to trace we also want to 91 | // explicitily pass in the reference to the worker 92 | // context. This will always just be `self`. 93 | // 94 | // If you're unfamiliar with this concept, it's just 95 | // something that exists within all Web Workers. 96 | // 97 | // featherweight needs this in order to be able to listen for 98 | // and send DOM updates back to the ui thread. 99 | workerContext: self 100 | }) 101 | ``` 102 | 103 | ### Writing the UI components 104 | 105 | The assumption is that components don't have state. **All state lives in Redux**, including the current URL of the app. 106 | 107 | This means the entire UI needs to be a single pure function that takes the application state object and return a new virtual dom. 108 | 109 | It's signature is simple: 110 | 111 | ```js 112 | virtualDom = ui(state) 113 | ``` 114 | 115 | Of course, you still have to be able to break your application into small, modular components and show different things based on different urls. 116 | 117 | To address this, here's a super simple example: 118 | 119 | ``` 120 | import home from './home' 121 | import about from './about' 122 | import pageNotFound from './pageNotFound' 123 | 124 | export default (state) => { 125 | // we just grab the `.url` property of state 126 | const { url } = state 127 | let page 128 | 129 | // We then grab a `page` component 130 | // conditionally based on that url 131 | if (url === '/') { 132 | // here we can pass the state through 133 | // if we'd like 134 | page = home(state) 135 | } else if (url === '/about') { 136 | // this page is just text content 137 | // so passing in state isn't necessary 138 | page = about() 139 | } 140 | 141 | // we could also handle URLs our app isn't 142 | // aware of with a fallback page 143 | if (!page) { 144 | page = pageNotFound() 145 | } 146 | 147 | // here we simply return the JSX and include 148 | // the `{page}` content as part of our layout 149 | return ( 150 | // note that `main` here is just an HTML5 element 151 | // nothing special 152 |
153 |

Feather POC App

154 | 157 | {page} 158 |
159 | ) 160 | } 161 | ``` 162 | 163 | 164 | ### handling state 165 | 166 | For cleanliness, I suggest setting up your Redux in a separate file. 167 | 168 | That file may look something like this: 169 | 170 | ```js 171 | import { createStore } from 'redux' 172 | import * as reducers from './reducers/index' 173 | 174 | export default () => { 175 | return createStore(reducers) 176 | } 177 | 178 | ``` 179 | 180 | ### pre-rendering at build time or as part of a server response 181 | 182 | Because your app UI is a pure function, turning your view into HTML is quite simple. 183 | 184 | Simply call your apps main UI function passing in whatever state you'd like to render to generate virtual DOM. 185 | 186 | Then you can use the `vdom-to-html` module from npm to generate an HTML string. 187 | 188 | 189 | ```js 190 | import toHtml from 'vdom-to-html' 191 | import app from './views/app' 192 | 193 | // wherever you want to create the HTML string 194 | const renderedHtml = toHtml(app({url: '/about'})) 195 | 196 | ``` 197 | 198 | 199 | ### featherweight patterns 200 | 201 | 1. Under no circumstances should your complete app weigh more than 60 kb min+gzip JS. Ideally, much less. 202 | 1. **every. single. piece. of. state. lives. in. redux** 203 | 2. Leave the main thread alone 204 | 3. Never touch the DOM directly 205 | 4. The UI thread does nothing other than apply DOM updates and post events back to the worker thread 206 | 5. Never use `this` 207 | 6. Never use `function` use `() => {}` for everything 208 | 7. The UI is a pure function 209 | 8. There are no stateful components 210 | 9. Pretty much everything is a pure function 211 | 10. Name all your modules with camelCase file names 212 | 11. Use [standard](https://github.com/feross/standard) for code style 213 | 12. The build step should turn your app into a set of static HTML, CSS, and JS files. 214 | 215 | ## install 216 | 217 | ``` 218 | npm install featherweight 219 | ``` 220 | 221 | ## credits 222 | 223 | If you like this follow [@HenrikJoreteg](http://twitter.com/henrikjoreteg) on twitter. 224 | 225 | ## license 226 | 227 | [MIT](http://mit.joreteg.com/) 228 | 229 | -------------------------------------------------------------------------------- /lib/createRouteMatcher.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | // regexes borrowed from backbone 7 | var optionalParam = /\((.*?)\)/g; 8 | var namedParam = /(\(\?)?:\w+/g; 9 | var escapeRegExp = /[\-{}\[\]+?.,\\\^$|#\s]/g; 10 | 11 | // Parses a URL pattern such as `/users/:id` 12 | // and builds and returns a regex that can be used to 13 | // match said pattern. Credit for these 14 | // regexes belongs to Jeremy Ashkenas and the 15 | // other maintainers of Backbone.js 16 | // 17 | // It has been modified for extraction of 18 | // named paramaters from the URL 19 | var parsePattern = exports.parsePattern = function parsePattern(pattern) { 20 | var names = []; 21 | pattern = pattern.replace(escapeRegExp, '\\$&').replace(optionalParam, '(?:$1)?').replace(namedParam, function (match, optional) { 22 | names.push(match.slice(1)); 23 | return optional ? match : '([^/?]+)'; 24 | }); 25 | 26 | return { 27 | regExp: new RegExp('^' + pattern + '(?:\\?([\\s\\S]*))?$'), 28 | namedParams: names 29 | }; 30 | }; 31 | 32 | // our main export, pure functions, FTW! 33 | 34 | exports.default = function (routes) { 35 | var keys = Object.keys(routes); 36 | 37 | // loop through each route we're 38 | // and build the shell of our 39 | // route cache. 40 | for (var item in routes) { 41 | routes[item] = { 42 | fn: routes[item] 43 | }; 44 | } 45 | 46 | // main result is a function that can be called 47 | // with URL and current state 48 | return function (url, state) { 49 | var params = undefined; 50 | var route = undefined; 51 | 52 | // start looking for matches 53 | var matchFound = keys.some(function (key) { 54 | var parsed = undefined; 55 | 56 | // fetch the route pattern from the cache 57 | // there will always be one 58 | route = routes[key]; 59 | 60 | // if the route doesn't already have 61 | // a regex we never generated one 62 | // so we do that here lazily. 63 | // Parse the pattern to generate the 64 | // regex once, and store the result 65 | // for next time. 66 | if (!route.regExp) { 67 | parsed = parsePattern(key); 68 | route.regExp = parsed.regExp; 69 | route.namedParams = parsed.namedParams; 70 | } 71 | 72 | // run our cached regex 73 | var result = route.regExp.exec(url); 74 | 75 | // if null there was no match 76 | // returning falsy here continues 77 | // the `Array.prototype.some` loop 78 | if (!result) { 79 | return; 80 | } 81 | 82 | // remove other cruft from result 83 | result = result.slice(1, -1); 84 | 85 | // reduce our match to an object of named paramaters 86 | // we've extracted from the url 87 | params = result.reduce(function (obj, val, index) { 88 | if (val) { 89 | obj[route.namedParams[index]] = val; 90 | } 91 | return obj; 92 | }, {}); 93 | 94 | // stops the loop 95 | return true; 96 | }); 97 | 98 | // no routes matched 99 | if (!matchFound) { 100 | return null; 101 | } 102 | 103 | // return the result of user's function 104 | // passing in state and parsed params from 105 | // url. 106 | return route.fn({ state: state, params: params }); 107 | }; 108 | }; -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | exports.FEATHER_INIT = exports.worker = exports.ui = exports.createRouteMatcher = undefined; 7 | 8 | var _createRouteMatcher = require('./createRouteMatcher'); 9 | 10 | var _createRouteMatcher2 = _interopRequireDefault(_createRouteMatcher); 11 | 12 | var _ui = require('./ui'); 13 | 14 | var _ui2 = _interopRequireDefault(_ui); 15 | 16 | var _worker = require('./worker'); 17 | 18 | var _worker2 = _interopRequireDefault(_worker); 19 | 20 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 21 | 22 | var FEATHER_INIT = '@@feather/INIT'; 23 | 24 | exports.createRouteMatcher = _createRouteMatcher2.default; 25 | exports.ui = _ui2.default; 26 | exports.worker = _worker2.default; 27 | exports.FEATHER_INIT = FEATHER_INIT; -------------------------------------------------------------------------------- /lib/ui.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | 7 | var _vdomVirtualize = require('vdom-virtualize'); 8 | 9 | var _vdomVirtualize2 = _interopRequireDefault(_vdomVirtualize); 10 | 11 | var _toJson = require('vdom-as-json/toJson'); 12 | 13 | var _toJson2 = _interopRequireDefault(_toJson); 14 | 15 | var _patch = require('vdom-serialized-patch/patch'); 16 | 17 | var _patch2 = _interopRequireDefault(_patch); 18 | 19 | var _localLinks = require('local-links'); 20 | 21 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 22 | 23 | exports.default = function (_ref) { 24 | var worker = _ref.worker; 25 | var rootElement = _ref.rootElement; 26 | 27 | var firstChild = rootElement.firstChild; 28 | 29 | if (!firstChild) { 30 | firstChild = document.createElement('div'); 31 | rootElement.appendChild(firstChild); 32 | } 33 | // any time we get a message from the worker 34 | // it will be a set of "patches" to apply to 35 | // the real DOM. We do this on a requestAnimationFrame 36 | // for minimal impact 37 | worker.onmessage = function (_ref2) { 38 | var data = _ref2.data; 39 | var url = data.url; 40 | var title = data.title; 41 | var patches = data.patches; 42 | 43 | window.requestAnimationFrame(function () { 44 | (0, _patch2.default)(rootElement.firstChild, patches); 45 | }); 46 | // we only want to update the URL 47 | // if it's different than the current 48 | // URL. Otherwise we'd keep pushing 49 | // the same url to the history with 50 | // each render 51 | if (window.location.pathname !== url) { 52 | window.history.pushState(null, null, url); 53 | } 54 | 55 | // if page title 56 | if (document.title !== title) { 57 | document.title = title; 58 | } 59 | }; 60 | 61 | // we start things off by sending a virtual DOM 62 | // representation of the *real* DOM along with 63 | // the current URL to our worker 64 | worker.postMessage({ 65 | type: '@@feather/INIT', 66 | vdom: (0, _toJson2.default)((0, _vdomVirtualize2.default)(firstChild)), 67 | url: window.location.pathname 68 | }); 69 | 70 | // if the user hits the back/forward buttons 71 | // pass the new url to the worker 72 | window.addEventListener('popstate', function () { 73 | worker.postMessage({ 74 | type: 'SET_URL', 75 | url: window.location.pathname 76 | }); 77 | }); 78 | 79 | // listen for all clicks globally 80 | rootElement.addEventListener('click', function (event) { 81 | // handles internal navigation defined as 82 | // clicks on tags that have `href` that is 83 | // on the same origin. 84 | // https://www.npmjs.com/package/local-links 85 | var pathname = (0, _localLinks.getLocalPathname)(event); 86 | if (pathname) { 87 | // stop browser from following the link 88 | event.preventDefault(); 89 | // instead, post the new URL to our worker 90 | // which will trigger compute a new vDom 91 | // based on that new URL state 92 | worker.postMessage({ type: 'SET_URL', url: pathname }); 93 | return; 94 | } 95 | 96 | // this is for other "onClick" type events we want to 97 | // respond to. We check existance of an `data-click` 98 | // attribute and if it exists, post that back. 99 | var click = event.target['data-click']; 100 | if (click) { 101 | event.preventDefault(); 102 | worker.postMessage(click); 103 | } 104 | }); 105 | 106 | rootElement.addEventListener('submit', function (event) { 107 | var target = event.target; 108 | 109 | var formAction = target['data-action']; 110 | if (!formAction) { 111 | return; 112 | } 113 | 114 | event.preventDefault(); 115 | var data = {}; 116 | var l = target.length; 117 | for (var i = 0; i < l; i++) { 118 | var input = target[i]; 119 | if (input.name) { 120 | data[input.name] = input.value; 121 | } 122 | } 123 | worker.postMessage({ type: formAction, data: data }); 124 | }); 125 | }; -------------------------------------------------------------------------------- /lib/worker.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | 7 | var _diff = require('virtual-dom/diff'); 8 | 9 | var _diff2 = _interopRequireDefault(_diff); 10 | 11 | var _serialize = require('vdom-serialized-patch/serialize'); 12 | 13 | var _serialize2 = _interopRequireDefault(_serialize); 14 | 15 | var _fromJson = require('vdom-as-json/fromJson'); 16 | 17 | var _fromJson2 = _interopRequireDefault(_fromJson); 18 | 19 | var _index = require('./index'); 20 | 21 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 22 | 23 | exports.default = function (_ref) { 24 | var redux = _ref.redux; 25 | var view = _ref.view; 26 | var workerContext = _ref.workerContext; 27 | var _ref$debug = _ref.debug; 28 | var debug = _ref$debug === undefined ? false : _ref$debug; 29 | 30 | var currentVDom = undefined; 31 | 32 | var render = function render() { 33 | var state = redux.getState(); 34 | var title = undefined; 35 | var newVDom = undefined; 36 | 37 | // our entire app in one line: 38 | var result = view(state); 39 | 40 | // allow main app to optionally return 41 | // { 42 | // vdom: {{ VIRTUAL DOM }}, 43 | // title: {{ PAGE TITLE STRING }} 44 | // } 45 | if (result.hasOwnProperty('vdom')) { 46 | title = result.title; 47 | newVDom = result.vdom; 48 | } else { 49 | newVDom = result; 50 | } 51 | 52 | // do the diff 53 | var patches = (0, _diff2.default)(currentVDom, newVDom); 54 | 55 | // cache last vdom so we diff against 56 | // the new one the next time through 57 | currentVDom = newVDom; 58 | 59 | var message = { 60 | url: state.url, 61 | patches: (0, _serialize2.default)(patches) 62 | }; 63 | 64 | if (title !== undefined) { 65 | message.title = title; 66 | } 67 | 68 | // send patches and current url back to the main thread 69 | workerContext.postMessage(message); 70 | }; 71 | 72 | workerContext.onmessage = function (_ref2) { 73 | var data = _ref2.data; 74 | 75 | if (debug) console.log('action:', data); 76 | if (data.type === _index.FEATHER_INIT) { 77 | currentVDom = (0, _fromJson2.default)(data.vdom); 78 | } 79 | 80 | // let redux do its thing 81 | redux.dispatch(data); 82 | }; 83 | 84 | redux.subscribe(render); 85 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "featherweight", 3 | "description": "featherweight application pattern", 4 | "version": "3.0.0", 5 | "author": "Henrik Joreteg (https://joreteg.com)", 6 | "bugs": { 7 | "url": "https://github.com/HenrikJoreteg/featherweight/issues" 8 | }, 9 | "devDependencies": { 10 | "babel": "^6.3.26", 11 | "babel-cli": "^6.3.17", 12 | "babel-core": "^6.3.26", 13 | "babel-preset-es2015": "^6.3.13", 14 | "node-babel": "^0.1.2", 15 | "nodemon": "^1.8.1", 16 | "npm-watch": "^0.1.1", 17 | "standard": "^5.4.1", 18 | "tape": "^4.3.0" 19 | }, 20 | "files": [ 21 | "src", 22 | "lib" 23 | ], 24 | "homepage": "https://github.com/HenrikJoreteg/featherweight#readme", 25 | "jsnext:main": "src/index.js", 26 | "keywords": [ 27 | "application", 28 | "lightweight" 29 | ], 30 | "license": "MIT", 31 | "main": "lib/index.js", 32 | "repository": { 33 | "type": "git", 34 | "url": "git+https://github.com/HenrikJoreteg/featherweight.git" 35 | }, 36 | "scripts": { 37 | "build": "babel src --out-dir lib", 38 | "preversion": "standard && npm run build", 39 | "lint": "standard", 40 | "watch": "npm-watch" 41 | }, 42 | "watch": { 43 | "build": { 44 | "patterns": [ 45 | "src" 46 | ] 47 | } 48 | }, 49 | "dependencies": { 50 | "local-links": "^1.4.0", 51 | "vdom-serialized-patch": "^1.0.2", 52 | "vdom-virtualize": "^1.0.1", 53 | "virtual-dom": "^2.1.1" 54 | }, 55 | "standard": { 56 | "ignore": "lib/*" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/createRouteMatcher.js: -------------------------------------------------------------------------------- 1 | // regexes borrowed from backbone 2 | const optionalParam = /\((.*?)\)/g 3 | const namedParam = /(\(\?)?:\w+/g 4 | const escapeRegExp = /[\-{}\[\]+?.,\\\^$|#\s]/g 5 | 6 | // Parses a URL pattern such as `/users/:id` 7 | // and builds and returns a regex that can be used to 8 | // match said pattern. Credit for these 9 | // regexes belongs to Jeremy Ashkenas and the 10 | // other maintainers of Backbone.js 11 | // 12 | // It has been modified for extraction of 13 | // named paramaters from the URL 14 | export const parsePattern = (pattern) => { 15 | const names = [] 16 | pattern = pattern 17 | .replace(escapeRegExp, '\\$&') 18 | .replace(optionalParam, '(?:$1)?') 19 | .replace(namedParam, (match, optional) => { 20 | names.push(match.slice(1)) 21 | return optional ? match : '([^/?]+)' 22 | }) 23 | 24 | return { 25 | regExp: new RegExp('^' + pattern + '(?:\\?([\\s\\S]*))?$'), 26 | namedParams: names 27 | } 28 | } 29 | 30 | // our main export, pure functions, FTW! 31 | export default (routes) => { 32 | const keys = Object.keys(routes) 33 | 34 | // loop through each route we're 35 | // and build the shell of our 36 | // route cache. 37 | for (const item in routes) { 38 | routes[item] = { 39 | fn: routes[item] 40 | } 41 | } 42 | 43 | // main result is a function that can be called 44 | // with URL and current state 45 | return (url, state) => { 46 | let params 47 | let route 48 | 49 | // start looking for matches 50 | const matchFound = keys.some((key) => { 51 | let parsed 52 | 53 | // fetch the route pattern from the cache 54 | // there will always be one 55 | route = routes[key] 56 | 57 | // if the route doesn't already have 58 | // a regex we never generated one 59 | // so we do that here lazily. 60 | // Parse the pattern to generate the 61 | // regex once, and store the result 62 | // for next time. 63 | if (!route.regExp) { 64 | parsed = parsePattern(key) 65 | route.regExp = parsed.regExp 66 | route.namedParams = parsed.namedParams 67 | } 68 | 69 | // run our cached regex 70 | let result = route.regExp.exec(url) 71 | 72 | // if null there was no match 73 | // returning falsy here continues 74 | // the `Array.prototype.some` loop 75 | if (!result) { 76 | return 77 | } 78 | 79 | // remove other cruft from result 80 | result = result.slice(1, -1) 81 | 82 | // reduce our match to an object of named paramaters 83 | // we've extracted from the url 84 | params = result.reduce((obj, val, index) => { 85 | if (val) { 86 | obj[route.namedParams[index]] = val 87 | } 88 | return obj 89 | }, {}) 90 | 91 | // stops the loop 92 | return true 93 | }) 94 | 95 | // no routes matched 96 | if (!matchFound) { 97 | return null 98 | } 99 | 100 | // return the result of user's function 101 | // passing in state and parsed params from 102 | // url. 103 | return route.fn({state: state, params: params}) 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import createRouteMatcher from './createRouteMatcher' 2 | import ui from './ui' 3 | import worker from './worker' 4 | 5 | const FEATHER_INIT = '@@feather/INIT' 6 | 7 | export { 8 | createRouteMatcher, 9 | ui, 10 | worker, 11 | FEATHER_INIT 12 | } 13 | -------------------------------------------------------------------------------- /src/ui.js: -------------------------------------------------------------------------------- 1 | import virtualize from 'vdom-virtualize' 2 | import toJson from 'vdom-as-json/toJson' 3 | import applyPatch from 'vdom-serialized-patch/patch' 4 | import { getLocalPathname } from 'local-links' 5 | 6 | export default ({worker, rootElement}) => { 7 | let firstChild = rootElement.firstChild 8 | 9 | if (!firstChild) { 10 | firstChild = document.createElement('div') 11 | rootElement.appendChild(firstChild) 12 | } 13 | // any time we get a message from the worker 14 | // it will be a set of "patches" to apply to 15 | // the real DOM. We do this on a requestAnimationFrame 16 | // for minimal impact 17 | worker.onmessage = ({data}) => { 18 | const { url, title, patches } = data 19 | window.requestAnimationFrame(() => { 20 | applyPatch(rootElement.firstChild, patches) 21 | }) 22 | // we only want to update the URL 23 | // if it's different than the current 24 | // URL. Otherwise we'd keep pushing 25 | // the same url to the history with 26 | // each render 27 | if (window.location.pathname !== url) { 28 | window.history.pushState(null, null, url) 29 | } 30 | 31 | // if page title 32 | if (document.title !== title) { 33 | document.title = title 34 | } 35 | } 36 | 37 | // we start things off by sending a virtual DOM 38 | // representation of the *real* DOM along with 39 | // the current URL to our worker 40 | worker.postMessage({ 41 | type: '@@feather/INIT', 42 | vdom: toJson(virtualize(firstChild)), 43 | url: window.location.pathname 44 | }) 45 | 46 | // if the user hits the back/forward buttons 47 | // pass the new url to the worker 48 | window.addEventListener('popstate', () => { 49 | worker.postMessage({ 50 | type: 'SET_URL', 51 | url: window.location.pathname 52 | }) 53 | }) 54 | 55 | // listen for all clicks globally 56 | rootElement.addEventListener('click', (event) => { 57 | // handles internal navigation defined as 58 | // clicks on tags that have `href` that is 59 | // on the same origin. 60 | // https://www.npmjs.com/package/local-links 61 | const pathname = getLocalPathname(event) 62 | if (pathname) { 63 | // stop browser from following the link 64 | event.preventDefault() 65 | // instead, post the new URL to our worker 66 | // which will trigger compute a new vDom 67 | // based on that new URL state 68 | worker.postMessage({type: 'SET_URL', url: pathname}) 69 | return 70 | } 71 | 72 | // this is for other "onClick" type events we want to 73 | // respond to. We check existance of an `data-click` 74 | // attribute and if it exists, post that back. 75 | const click = event.target['data-click'] 76 | if (click) { 77 | event.preventDefault() 78 | worker.postMessage(click) 79 | } 80 | }) 81 | 82 | rootElement.addEventListener('submit', (event) => { 83 | const { target } = event 84 | const formAction = target['data-action'] 85 | if (!formAction) { 86 | return 87 | } 88 | 89 | event.preventDefault() 90 | const data = {} 91 | const l = target.length 92 | for (let i = 0; i < l; i++) { 93 | const input = target[i] 94 | if (input.name) { 95 | data[input.name] = input.value 96 | } 97 | } 98 | worker.postMessage({type: formAction, data}) 99 | }) 100 | } 101 | -------------------------------------------------------------------------------- /src/worker.js: -------------------------------------------------------------------------------- 1 | import diff from 'virtual-dom/diff' 2 | import serializePatch from 'vdom-serialized-patch/serialize' 3 | import fromJson from 'vdom-as-json/fromJson' 4 | import { FEATHER_INIT } from './index' 5 | 6 | export default ({redux, view, workerContext, debug = false}) => { 7 | let currentVDom 8 | 9 | const render = () => { 10 | const state = redux.getState() 11 | let title 12 | let newVDom 13 | 14 | // our entire app in one line: 15 | const result = view(state) 16 | 17 | // allow main app to optionally return 18 | // { 19 | // vdom: {{ VIRTUAL DOM }}, 20 | // title: {{ PAGE TITLE STRING }} 21 | // } 22 | if (result.hasOwnProperty('vdom')) { 23 | title = result.title 24 | newVDom = result.vdom 25 | } else { 26 | newVDom = result 27 | } 28 | 29 | // do the diff 30 | const patches = diff(currentVDom, newVDom) 31 | 32 | // cache last vdom so we diff against 33 | // the new one the next time through 34 | currentVDom = newVDom 35 | 36 | const message = { 37 | url: state.url, 38 | patches: serializePatch(patches) 39 | } 40 | 41 | if (title !== undefined) { 42 | message.title = title 43 | } 44 | 45 | // send patches and current url back to the main thread 46 | workerContext.postMessage(message) 47 | } 48 | 49 | workerContext.onmessage = ({data}) => { 50 | if (debug) console.log('action:', data) 51 | if (data.type === FEATHER_INIT) { 52 | currentVDom = fromJson(data.vdom) 53 | } 54 | 55 | // let redux do its thing 56 | redux.dispatch(data) 57 | } 58 | 59 | redux.subscribe(render) 60 | } 61 | --------------------------------------------------------------------------------