diff --git a/.babelrc b/.babelrc
new file mode 100644
index 0000000..892af96
--- /dev/null
+++ b/.babelrc
@@ -0,0 +1,4 @@
+{
+ "presets": [["airbnb", { "modules": false }]],
+ "plugins": ["syntax-dynamic-import"]
+}
diff --git a/.eslintrc b/.eslintrc
new file mode 100644
index 0000000..1514c54
--- /dev/null
+++ b/.eslintrc
@@ -0,0 +1,15 @@
+{
+ "extends": ["airbnb"],
+ "parser": "babel-eslint",
+ "root": true,
+ "rules": {
+ "react/prop-types": 0,
+ "jsx-a11y/label-has-for": 0,
+ "react/no-find-dom-node": 0,
+ "react/no-string-refs": 0,
+ "jsx-a11y/no-autofocus": 0,
+ "jsx-a11y/anchor-is-valid": [2, {
+ "specialLink": [ "to" ]
+ }]
+ }
+}
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b947077
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+node_modules/
+dist/
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..902af01
--- /dev/null
+++ b/README.md
@@ -0,0 +1,25 @@
+# Code Splitting + SSR with RRv4 demo
+
+This is a demo repository set up to demo code splitting by route on React Router v4 with server rendered React components.
+
+Running the demo:
+
+```
+git clone git@github.com:gdborton/rrv4-ssr-and-code-splitting.git
+cd rrv4-ssr-and-code-splitting/
+npm install
+webpack
+node server
+open http://localhost:3000
+```
+
+## Things of note:
+ - The contents of this repo were based on the [TodoMVC code](https://github.com/tastejs/todomvc/tree/master/examples/react) originally written by [Pete Hunt](https://github.com/petehunt).
+ - We're using babel-eslint to enable `import()`.
+ - We're using the Airbnb dynamic import plugins, webpack's `import()` creates references to `window` that don't work in node:
+ - [babel-plugin-dynamic-import-webpack](https://github.com/airbnb/babel-plugin-dynamic-import-webpack) for client side code.
+ - [babel-plugin-dynamic-import-node](https://github.com/airbnb/babel-plugin-dynamic-import-node) for server side code.
+ - We have two webpack configs:
+ - One for server (`libraryTarget = commonjs2` and `babel-plugin-dynamic-import-node`).
+ - Another for client (`babel-plugin-dynamic-import-webpack`).
+ - The server, starts with some static data, **and is never updated**, you'll lose your changes if you reload the page.
diff --git a/css/base.css b/css/base.css
new file mode 100644
index 0000000..da65968
--- /dev/null
+++ b/css/base.css
@@ -0,0 +1,141 @@
+hr {
+ margin: 20px 0;
+ border: 0;
+ border-top: 1px dashed #c5c5c5;
+ border-bottom: 1px dashed #f7f7f7;
+}
+
+.learn a {
+ font-weight: normal;
+ text-decoration: none;
+ color: #b83f45;
+}
+
+.learn a:hover {
+ text-decoration: underline;
+ color: #787e7e;
+}
+
+.learn h3,
+.learn h4,
+.learn h5 {
+ margin: 10px 0;
+ font-weight: 500;
+ line-height: 1.2;
+ color: #000;
+}
+
+.learn h3 {
+ font-size: 24px;
+}
+
+.learn h4 {
+ font-size: 18px;
+}
+
+.learn h5 {
+ margin-bottom: 0;
+ font-size: 14px;
+}
+
+.learn ul {
+ padding: 0;
+ margin: 0 0 30px 25px;
+}
+
+.learn li {
+ line-height: 20px;
+}
+
+.learn p {
+ font-size: 15px;
+ font-weight: 300;
+ line-height: 1.3;
+ margin-top: 0;
+ margin-bottom: 0;
+}
+
+#issue-count {
+ display: none;
+}
+
+.quote {
+ border: none;
+ margin: 20px 0 60px 0;
+}
+
+.quote p {
+ font-style: italic;
+}
+
+.quote p:before {
+ content: '“';
+ font-size: 50px;
+ opacity: .15;
+ position: absolute;
+ top: -20px;
+ left: 3px;
+}
+
+.quote p:after {
+ content: '”';
+ font-size: 50px;
+ opacity: .15;
+ position: absolute;
+ bottom: -42px;
+ right: 3px;
+}
+
+.quote footer {
+ position: absolute;
+ bottom: -40px;
+ right: 0;
+}
+
+.quote footer img {
+ border-radius: 3px;
+}
+
+.quote footer a {
+ margin-left: 5px;
+ vertical-align: middle;
+}
+
+.speech-bubble {
+ position: relative;
+ padding: 10px;
+ background: rgba(0, 0, 0, .04);
+ border-radius: 5px;
+}
+
+.speech-bubble:after {
+ content: '';
+ position: absolute;
+ top: 100%;
+ right: 30px;
+ border: 13px solid transparent;
+ border-top-color: rgba(0, 0, 0, .04);
+}
+
+.learn-bar > .learn {
+ position: absolute;
+ width: 272px;
+ top: 8px;
+ left: -300px;
+ padding: 10px;
+ border-radius: 5px;
+ background-color: rgba(255, 255, 255, .6);
+ transition-property: left;
+ transition-duration: 500ms;
+}
+
+@media (min-width: 899px) {
+ .learn-bar {
+ width: auto;
+ padding-left: 300px;
+ }
+
+ .learn-bar > .learn {
+ left: 8px;
+ }
+}
diff --git a/css/index.css b/css/index.css
new file mode 100644
index 0000000..e6e089c
--- /dev/null
+++ b/css/index.css
@@ -0,0 +1,378 @@
+html,
+body {
+ margin: 0;
+ padding: 0;
+}
+
+button {
+ margin: 0;
+ padding: 0;
+ border: 0;
+ background: none;
+ font-size: 100%;
+ vertical-align: baseline;
+ font-family: inherit;
+ font-weight: inherit;
+ color: inherit;
+ -webkit-appearance: none;
+ appearance: none;
+ -webkit-font-smoothing: antialiased;
+ -moz-font-smoothing: antialiased;
+ font-smoothing: antialiased;
+}
+
+body {
+ font: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif;
+ line-height: 1.4em;
+ background: #f5f5f5;
+ color: #4d4d4d;
+ min-width: 230px;
+ max-width: 550px;
+ margin: 0 auto;
+ -webkit-font-smoothing: antialiased;
+ -moz-font-smoothing: antialiased;
+ font-smoothing: antialiased;
+ font-weight: 300;
+}
+
+button,
+input[type="checkbox"] {
+ outline: none;
+}
+
+.hidden {
+ display: none;
+}
+
+.todoapp {
+ background: #fff;
+ margin: 130px 0 40px 0;
+ position: relative;
+ box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2),
+ 0 25px 50px 0 rgba(0, 0, 0, 0.1);
+}
+
+.todoapp input::-webkit-input-placeholder {
+ font-style: italic;
+ font-weight: 300;
+ color: #e6e6e6;
+}
+
+.todoapp input::-moz-placeholder {
+ font-style: italic;
+ font-weight: 300;
+ color: #e6e6e6;
+}
+
+.todoapp input::input-placeholder {
+ font-style: italic;
+ font-weight: 300;
+ color: #e6e6e6;
+}
+
+.todoapp h1 {
+ position: absolute;
+ top: -155px;
+ width: 100%;
+ font-size: 100px;
+ font-weight: 100;
+ text-align: center;
+ color: rgba(175, 47, 47, 0.15);
+ -webkit-text-rendering: optimizeLegibility;
+ -moz-text-rendering: optimizeLegibility;
+ text-rendering: optimizeLegibility;
+}
+
+.new-todo,
+.edit {
+ position: relative;
+ margin: 0;
+ width: 100%;
+ font-size: 24px;
+ font-family: inherit;
+ font-weight: inherit;
+ line-height: 1.4em;
+ border: 0;
+ outline: none;
+ color: inherit;
+ padding: 6px;
+ border: 1px solid #999;
+ box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2);
+ box-sizing: border-box;
+ -webkit-font-smoothing: antialiased;
+ -moz-font-smoothing: antialiased;
+ font-smoothing: antialiased;
+}
+
+.new-todo {
+ padding: 16px 16px 16px 60px;
+ border: none;
+ background: rgba(0, 0, 0, 0.003);
+ box-shadow: inset 0 -2px 1px rgba(0,0,0,0.03);
+}
+
+.main {
+ position: relative;
+ z-index: 2;
+ border-top: 1px solid #e6e6e6;
+}
+
+label[for='toggle-all'] {
+ display: none;
+}
+
+.toggle-all {
+ position: absolute;
+ top: -55px;
+ left: -12px;
+ width: 60px;
+ height: 34px;
+ text-align: center;
+ border: none; /* Mobile Safari */
+}
+
+.toggle-all:before {
+ content: '❯';
+ font-size: 22px;
+ color: #e6e6e6;
+ padding: 10px 27px 10px 27px;
+}
+
+.toggle-all:checked:before {
+ color: #737373;
+}
+
+.todo-list {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.todo-list li {
+ position: relative;
+ font-size: 24px;
+ border-bottom: 1px solid #ededed;
+}
+
+.todo-list li:last-child {
+ border-bottom: none;
+}
+
+.todo-list li.editing {
+ border-bottom: none;
+ padding: 0;
+}
+
+.todo-list li.editing .edit {
+ display: block;
+ width: 506px;
+ padding: 13px 17px 12px 17px;
+ margin: 0 0 0 43px;
+}
+
+.todo-list li.editing .view {
+ display: none;
+}
+
+.todo-list li .toggle {
+ text-align: center;
+ width: 40px;
+ /* auto, since non-WebKit browsers doesn't support input styling */
+ height: auto;
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ margin: auto 0;
+ border: none; /* Mobile Safari */
+ -webkit-appearance: none;
+ appearance: none;
+}
+
+.todo-list li .toggle:after {
+ content: url('data:image/svg+xml;utf8,');
+}
+
+.todo-list li .toggle:checked:after {
+ content: url('data:image/svg+xml;utf8,');
+}
+
+.todo-list li label {
+ white-space: pre-line;
+ word-break: break-all;
+ padding: 15px 60px 15px 15px;
+ margin-left: 45px;
+ display: block;
+ line-height: 1.2;
+ transition: color 0.4s;
+}
+
+.todo-list li.completed label {
+ color: #d9d9d9;
+ text-decoration: line-through;
+}
+
+.todo-list li .destroy {
+ display: none;
+ position: absolute;
+ top: 0;
+ right: 10px;
+ bottom: 0;
+ width: 40px;
+ height: 40px;
+ margin: auto 0;
+ font-size: 30px;
+ color: #cc9a9a;
+ margin-bottom: 11px;
+ transition: color 0.2s ease-out;
+}
+
+.todo-list li .destroy:hover {
+ color: #af5b5e;
+}
+
+.todo-list li .destroy:after {
+ content: '×';
+}
+
+.todo-list li:hover .destroy {
+ display: block;
+}
+
+.todo-list li .edit {
+ display: none;
+}
+
+.todo-list li.editing:last-child {
+ margin-bottom: -1px;
+}
+
+.footer {
+ color: #777;
+ padding: 10px 15px;
+ height: 20px;
+ text-align: center;
+ border-top: 1px solid #e6e6e6;
+}
+
+.footer:before {
+ content: '';
+ position: absolute;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ height: 50px;
+ overflow: hidden;
+ box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2),
+ 0 8px 0 -3px #f6f6f6,
+ 0 9px 1px -3px rgba(0, 0, 0, 0.2),
+ 0 16px 0 -6px #f6f6f6,
+ 0 17px 2px -6px rgba(0, 0, 0, 0.2);
+}
+
+.todo-count {
+ float: left;
+ text-align: left;
+}
+
+.todo-count strong {
+ font-weight: 300;
+}
+
+.filters {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ position: absolute;
+ right: 0;
+ left: 0;
+}
+
+.filters li {
+ display: inline;
+}
+
+.filters li a {
+ color: inherit;
+ margin: 3px;
+ padding: 3px 7px;
+ text-decoration: none;
+ border: 1px solid transparent;
+ border-radius: 3px;
+}
+
+.filters li a.selected,
+.filters li a:hover {
+ border-color: rgba(175, 47, 47, 0.1);
+}
+
+.filters li a.selected {
+ border-color: rgba(175, 47, 47, 0.2);
+}
+
+.clear-completed,
+html .clear-completed:active {
+ float: right;
+ position: relative;
+ line-height: 20px;
+ text-decoration: none;
+ cursor: pointer;
+ position: relative;
+}
+
+.clear-completed:hover {
+ text-decoration: underline;
+}
+
+.info {
+ margin: 65px auto 0;
+ color: #bfbfbf;
+ font-size: 10px;
+ text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5);
+ text-align: center;
+}
+
+.info p {
+ line-height: 1;
+}
+
+.info a {
+ color: inherit;
+ text-decoration: none;
+ font-weight: 400;
+}
+
+.info a:hover {
+ text-decoration: underline;
+}
+
+/*
+ Hack to remove background from Mobile Safari.
+ Can't use it globally since it destroys checkboxes in Firefox
+*/
+@media screen and (-webkit-min-device-pixel-ratio:0) {
+ .toggle-all,
+ .todo-list li .toggle {
+ background: none;
+ }
+
+ .todo-list li .toggle {
+ height: 40px;
+ }
+
+ .toggle-all {
+ -webkit-transform: rotate(90deg);
+ transform: rotate(90deg);
+ -webkit-appearance: none;
+ appearance: none;
+ }
+}
+
+@media (max-width: 430px) {
+ .footer {
+ height: 50px;
+ }
+
+ .filters {
+ bottom: 10px;
+ }
+}
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..ba3eb5f
--- /dev/null
+++ b/index.html
@@ -0,0 +1,19 @@
+
+
+
+
+ React • TodoMVC
+
+
+
+
+
+
+
+
+
+
+
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..508e8cf
--- /dev/null
+++ b/package.json
@@ -0,0 +1,56 @@
+{
+ "name": "rrv4-ssr-and-code-splitting",
+ "version": "0.0.0",
+ "description": "This is a demo of RRv4 with SSR and code splitting.",
+ "main": "index.js",
+ "scripts": {
+ "lint": "eslint ./src",
+ "test": "echo \"Error: no test specified\" && exit 1"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+ssh://git@github.com:gdborton/rrv4-ssr-and-code-splitting.git"
+ },
+ "keywords": [
+ "code",
+ "splitting",
+ "react",
+ "react-router",
+ "ssr",
+ "server",
+ "side",
+ "rendering"
+ ],
+ "author": "Gary Borton ",
+ "license": "MIT",
+ "bugs": {
+ "url": "https://github.com/gdborton/rrv4-ssr-and-code-splitting/issues"
+ },
+ "homepage": "https://github.com/gdborton/rrv4-ssr-and-code-splitting#readme",
+ "dependencies": {
+ "classnames": "^2.2.5",
+ "express": "^4.16.2",
+ "mime": "^2.0.3",
+ "react": "^15.6.2",
+ "react-dom": "^15.6.2",
+ "react-router": "^4.2.0",
+ "react-router-config": "^1.0.0-beta.4",
+ "react-router-dom": "^4.2.2"
+ },
+ "devDependencies": {
+ "babel-core": "^6.26.0",
+ "babel-eslint": "^7.2.3",
+ "babel-loader": "^7.1.2",
+ "babel-plugin-dynamic-import-node": "^1.1.0",
+ "babel-plugin-dynamic-import-webpack": "^1.0.1",
+ "babel-plugin-syntax-dynamic-import": "^6.18.0",
+ "babel-preset-airbnb": "^2.4.0",
+ "eslint": "^4.9.0",
+ "eslint-config-airbnb": "^16.1.0",
+ "eslint-plugin-import": "^2.8.0",
+ "eslint-plugin-jsx-a11y": "^6.0.2",
+ "eslint-plugin-react": "^7.4.0",
+ "react-dom": "^15.6.1",
+ "webpack": "^3.5.5"
+ }
+}
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..47039e8
--- /dev/null
+++ b/server.js
@@ -0,0 +1,37 @@
+const fs = require('fs');
+const express = require('express');
+const ReactDOMServer = require('react-dom/server');
+const App = require('./dist/index.server.bundle.js');
+
+const PORT = 3000;
+
+const app = express();
+const template = fs.readFileSync('./index.html', 'utf8'); // stupid simple template.
+
+const todos = [
+ { id: 'ed0bcc48-bbbe-5f06-c7c9-2ccb0456ceba', title: 'Wake Up.', completed: true },
+ { id: '42582304-3c6e-311e-7f88-7e3791caf88c', title: 'Grab a brush and put a little makeup.', completed: true },
+ { id: '036af7f9-1181-fb8f-258f-3f06034c020f', title: 'Write a blog post.', completed: false },
+ { id: '1cf63885-5f75-8deb-19dc-9b6765deae6c', title: 'Create a demo repository.', completed: false },
+ { id: '63a871b2-0b6f-4427-9c35-304bc680a4b7', title: '??????', completed: false },
+ { id: '63a871b2-0b6f-4422-9c35-304bc680a4b7', title: 'Profit.', completed: false },
+];
+
+// express.static was only working for some requests, but not others.
+app.use('/dist', express.static(`${__dirname}/dist`));
+app.use('/css', express.static(`${__dirname}/css`));
+
+app.get('*', (req, res) => {
+ const props = { todos };
+
+ App.default(req.url, props).then((reactComponent) => {
+ const result = ReactDOMServer.renderToString(reactComponent);
+ const html = template.replace('{{thing}}', result).replace('{{props}}', JSON.stringify(props));
+ res.send(html);
+ res.end();
+ });
+});
+
+app.listen(PORT, () => {
+ console.log(`listening on port: ${PORT}`);
+});
diff --git a/src/active-todos.jsx b/src/active-todos.jsx
new file mode 100644
index 0000000..bf981c1
--- /dev/null
+++ b/src/active-todos.jsx
@@ -0,0 +1,7 @@
+import React from 'react';
+import TodoList from './todoList';
+
+export default function ActiveTodos({ todos, ...props }) {
+ const filteredTodos = todos.filter(todo => !todo.completed);
+ return ;
+}
diff --git a/src/all-todos.jsx b/src/all-todos.jsx
new file mode 100644
index 0000000..c202ebd
--- /dev/null
+++ b/src/all-todos.jsx
@@ -0,0 +1,6 @@
+import React from 'react';
+import TodoList from './todoList';
+
+export default function AllTodos(props) {
+ return ;
+}
diff --git a/src/app.jsx b/src/app.jsx
new file mode 100644
index 0000000..9be8903
--- /dev/null
+++ b/src/app.jsx
@@ -0,0 +1,163 @@
+import React from 'react';
+import { renderRoutes } from 'react-router-config';
+import TodoFooter from './footer';
+import utils from './utils';
+
+const ENTER_KEY = 13;
+
+class TodoApp extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ editing: null,
+ newTodo: '',
+ todos: props.todos,
+ };
+ }
+
+ handleChange(event) {
+ this.setState({ newTodo: event.target.value });
+ }
+
+ handleNewTodoKeyDown(event) {
+ if (event.keyCode !== ENTER_KEY) {
+ return;
+ }
+
+ event.preventDefault();
+
+ const val = this.state.newTodo.trim();
+
+ if (val) {
+ this.setState({
+ todos: this.state.todos.concat({
+ id: utils.uuid(),
+ title: val,
+ completed: false,
+ }),
+ newTodo: '',
+ });
+ }
+ }
+
+ toggleAll(event) {
+ const { checked } = event.target;
+ this.setState({
+ todos: this.state.todos.map(todo => Object.assign({}, todo, { completed: checked })),
+ });
+ }
+
+ toggle(todoToToggle) {
+ this.setState({
+ todos: this.state.todos.map((todo) => {
+ if (todo === todoToToggle) {
+ return Object.assign({}, todo, {
+ completed: !todo.completed,
+ });
+ }
+ return todo;
+ }),
+ });
+ }
+
+ destroy(passedTodo) {
+ this.setState({
+ todos: this.state.todos.filter(todo => todo !== passedTodo),
+ });
+ }
+
+ edit(todo) {
+ this.setState({ editing: todo.id });
+ }
+
+ save(todoToSave, text) {
+ this.setState({
+ todos: this.state.todos.map((todo) => {
+ if (todo === todoToSave) {
+ return Object.assign({}, todo, {
+ title: text,
+ });
+ }
+ return todo;
+ }),
+ editing: null,
+ });
+ }
+
+ cancel() {
+ this.setState({ editing: null });
+ }
+
+ clearCompleted() {
+ this.setState({
+ todos: this.state.todos.filter(todo => !todo.completed),
+ });
+ }
+
+ render() {
+ let footer;
+ let main;
+ const { todos } = this.state;
+
+ const activeTodoCount = todos.reduce((accum, todo) => (todo.completed ? accum : accum + 1), 0);
+
+ const completedCount = todos.length - activeTodoCount;
+
+ if (activeTodoCount || completedCount) {
+ footer =
+ ( { this.clearCompleted(); }}
+ />);
+ }
+
+ if (todos.length) {
+ main = (
+
+
+
+ {
+ renderRoutes(this.props.route.routes, {
+ todos,
+ onToggle: (todo) => { this.toggle(todo); },
+ onDestroy: (todo) => { this.destroy(todo); },
+ onEdit: (todo) => { this.edit(todo); },
+ editing: todo => this.state.editing === todo.id,
+ onSave: (todo, text) => { this.save(todo, text); },
+ onCancel: () => this.cancel(),
+ })
+ }
+
+
+ );
+ }
+
+ return (
+
+
+ {main}
+ {footer}
+
+ );
+ }
+}
+
+export default TodoApp;
diff --git a/src/completed-todos.jsx b/src/completed-todos.jsx
new file mode 100644
index 0000000..e6b359a
--- /dev/null
+++ b/src/completed-todos.jsx
@@ -0,0 +1,7 @@
+import React from 'react';
+import TodoList from './todoList';
+
+export default function ActiveTodos({ todos, ...props }) {
+ const filteredTodos = todos.filter(todo => todo.completed);
+ return ;
+}
diff --git a/src/entry.jsx b/src/entry.jsx
new file mode 100644
index 0000000..0091140
--- /dev/null
+++ b/src/entry.jsx
@@ -0,0 +1,32 @@
+import { BrowserRouter, StaticRouter } from 'react-router-dom';
+import { render } from 'react-dom';
+import React from 'react';
+import { renderRoutes } from 'react-router-config';
+
+import routes from './routes';
+
+import { convertCustomRouteConfig, ensureReady } from './rrv4Helpers';
+
+const routeConfig = convertCustomRouteConfig(routes);
+
+if (typeof window !== 'undefined') {
+ ensureReady(routeConfig).then(() => {
+ const props = JSON.parse(document.getElementById('props').dataset.props); // eslint-disable-line
+ render(
+ (
+
+ { renderRoutes(routeConfig, props) }
+
+ ),
+ document.getElementsByClassName('todoapp')[0], // eslint-disable-line
+ );
+ });
+}
+
+export default function render2(location, props) {
+ return ensureReady(routeConfig, location).then(() => (
+
+ {renderRoutes(routeConfig, props)}
+
+ ));
+}
diff --git a/src/enums.js b/src/enums.js
new file mode 100644
index 0000000..36b9842
--- /dev/null
+++ b/src/enums.js
@@ -0,0 +1,5 @@
+export default {
+ ALL_TODOS: '/all',
+ ACTIVE_TODOS: '/active',
+ COMPLETED_TODOS: '/completed',
+};
diff --git a/src/footer.jsx b/src/footer.jsx
new file mode 100644
index 0000000..7cafae3
--- /dev/null
+++ b/src/footer.jsx
@@ -0,0 +1,51 @@
+import React from 'react';
+import classNames from 'classnames';
+import { Link } from 'react-router-dom';
+import utils from './utils';
+import enums from './enums';
+
+
+export default function Footer(props) {
+ const activeTodoWord = utils.pluralize(props.count, 'item');
+ const { nowShowing } = props;
+ return (
+
+ );
+}
diff --git a/src/routes.js b/src/routes.js
new file mode 100644
index 0000000..b05fb9a
--- /dev/null
+++ b/src/routes.js
@@ -0,0 +1,35 @@
+import App from './app';
+import { generateAsyncRouteComponent } from './rrv4Helpers';
+
+export default [
+ {
+ component: App,
+ path: parentRoute => `${parentRoute}/`,
+ routes: [
+ {
+ path: parentRoute => `${parentRoute}/all`,
+ component: generateAsyncRouteComponent({
+ loader: () => import('./all-todos'),
+ }),
+ },
+ {
+ path: parentRoute => `${parentRoute}/all`,
+ component: generateAsyncRouteComponent({
+ loader: () => import('./all-todos'),
+ }),
+ },
+ {
+ path: parentRoute => `${parentRoute}/active`,
+ component: generateAsyncRouteComponent({
+ loader: () => import('./active-todos'),
+ }),
+ },
+ {
+ path: parentRoute => `${parentRoute}/completed`,
+ component: generateAsyncRouteComponent({
+ loader: () => import('./completed-todos'),
+ }),
+ },
+ ],
+ },
+];
diff --git a/src/rrv4Helpers.jsx b/src/rrv4Helpers.jsx
new file mode 100644
index 0000000..06f776d
--- /dev/null
+++ b/src/rrv4Helpers.jsx
@@ -0,0 +1,97 @@
+import React from 'react';
+import { matchRoutes } from 'react-router-config';
+
+/**
+ * Returns a new React component, ready to be instantiated.
+ * Note the closure here protecting Component, and providing a unique
+ * instance of Component to the static implementation of `load`.
+ */
+export function generateAsyncRouteComponent({ loader, Placeholder }) {
+ let Component = null;
+ return class AsyncRouteComponent extends React.Component {
+ /**
+ * Static so that you can call load against an uninstantiated version of
+ * this component. This should only be called one time outside of the
+ * normal render path.
+ */
+ static load() {
+ return loader().then((ResolvedComponent) => {
+ Component = ResolvedComponent.default || ResolvedComponent;
+ });
+ }
+
+ constructor() {
+ super();
+ this.updateState = this.updateState.bind(this);
+ this.state = {
+ Component,
+ };
+ }
+
+ componentWillMount() {
+ AsyncRouteComponent.load().then(this.updateState);
+ }
+
+ updateState() {
+ // Only update state if we don't already have a reference to the
+ // component, this prevent unnecessary renders.
+ if (this.state.Component !== Component) {
+ this.setState({
+ Component,
+ });
+ }
+ }
+
+ render() {
+ const { Component: ComponentFromState } = this.state;
+ if (ComponentFromState) {
+ return ;
+ }
+
+ if (Placeholder) {
+ return ;
+ }
+
+ return null;
+ }
+ };
+}
+
+/**
+ * First match the routes via react-router-config's `matchRoutes` function.
+ * Then iterate over all of the matched routes, if they've got a load function
+ * call it.
+ *
+ * This helps us to make sure all the async code is loaded before rendering.
+ */
+export function ensureReady(routeConfig, providedLocation) {
+ const matches = matchRoutes(routeConfig, providedLocation || location.pathname);
+ return Promise.all(matches.map((match) => {
+ const { component } = match.route;
+ if (component && component.load) {
+ return component.load();
+ }
+ return undefined;
+ }));
+}
+
+export function convertCustomRouteConfig(customRouteConfig, parentRoute) {
+ return customRouteConfig.map((route) => {
+ if (typeof route.path === 'function') {
+ const pathResult = route.path(parentRoute || '').replace('//', '/');
+ return {
+ path: pathResult,
+ component: route.component,
+ exact: route.exact,
+ routes: route.routes ? convertCustomRouteConfig(route.routes, pathResult) : [],
+ };
+ }
+ const pathResult = `${parentRoute}${route.path}`;
+ return {
+ path: pathResult,
+ component: route.component,
+ exact: route.exact,
+ routes: route.routes ? convertCustomRouteConfig(route.routes, pathResult) : [],
+ };
+ });
+}
diff --git a/src/todoItem.jsx b/src/todoItem.jsx
new file mode 100644
index 0000000..ea88fb4
--- /dev/null
+++ b/src/todoItem.jsx
@@ -0,0 +1,109 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+import classNames from 'classnames';
+
+const ESCAPE_KEY = 27;
+const ENTER_KEY = 13;
+
+export default class TodoItem extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ editText: props.todo.title,
+ };
+ this.handleEdit = this.handleEdit.bind(this);
+ this.handleSubmit = this.handleSubmit.bind(this);
+ this.handleKeyDown = this.handleKeyDown.bind(this);
+ this.handleChange = this.handleChange.bind(this);
+ }
+
+ /**
+ * This is a completely optional performance enhancement that you can
+ * implement on any React component. If you were to delete this method
+ * the app would still work correctly (and still be very performant!), we
+ * just use it as an example of how little code it takes to get an order
+ * of magnitude performance improvement.
+ */
+ shouldComponentUpdate(nextProps, nextState) {
+ return (
+ nextProps.todo !== this.props.todo ||
+ nextProps.editing !== this.props.editing ||
+ nextState.editText !== this.state.editText
+ );
+ }
+
+ /**
+ * Safely manipulate the DOM after updating the state when invoking
+ * `this.props.onEdit()` in the `handleEdit` method above.
+ * For more info refer to notes at https://facebook.github.io/react/docs/component-api.html#setstate
+ * and https://facebook.github.io/react/docs/component-specs.html#updating-componentdidupdate
+ */
+ componentDidUpdate(prevProps) {
+ if (!prevProps.editing && this.props.editing) {
+ const node = ReactDOM.findDOMNode(this.refs.editField);
+ node.focus();
+ node.setSelectionRange(node.value.length, node.value.length);
+ }
+ }
+
+ handleSubmit() {
+ const val = this.state.editText.trim();
+ if (val) {
+ this.props.onSave(val);
+ this.setState({ editText: val });
+ } else {
+ this.props.onDestroy();
+ }
+ }
+
+ handleEdit() {
+ this.props.onEdit();
+ this.setState({ editText: this.props.todo.title });
+ }
+
+ handleKeyDown(event) {
+ if (event.which === ESCAPE_KEY) {
+ this.setState({ editText: this.props.todo.title });
+ this.props.onCancel(event);
+ } else if (event.which === ENTER_KEY) {
+ this.handleSubmit(event);
+ }
+ }
+
+ handleChange(event) {
+ if (this.props.editing) {
+ this.setState({ editText: event.target.value });
+ }
+ }
+
+ render() {
+ return (
+
+
+
+
+
+
+
+
+ );
+ }
+}
diff --git a/src/todoList.jsx b/src/todoList.jsx
new file mode 100644
index 0000000..f151c45
--- /dev/null
+++ b/src/todoList.jsx
@@ -0,0 +1,23 @@
+import React from 'react';
+import TodoItem from './todoItem';
+
+export default function TodoList(props) {
+ const todoItems = props.todos.map(todo => (
+ { props.onToggle(todo); }}
+ onDestroy={() => { props.onDestroy(todo); }}
+ onEdit={() => { props.onEdit(todo); }}
+ editing={props.editing(todo)}
+ onSave={(text) => { props.onSave(todo, text); }}
+ onCancel={() => { props.onCancel(); }}
+ />
+ ));
+
+ return (
+
+ {todoItems}
+
+ );
+}
diff --git a/src/utils.js b/src/utils.js
new file mode 100644
index 0000000..bf8aa90
--- /dev/null
+++ b/src/utils.js
@@ -0,0 +1,27 @@
+/* global localStorage */
+export default {
+ uuid() {
+ function s4() {
+ return Math.floor((1 + Math.random()) * 0x10000)
+ .toString(16)
+ .substring(1);
+ }
+ return `${s4()}${s4()}-${s4()}-${s4()}-${s4()}-${s4()}${s4()}${s4()}`;
+ },
+
+ pluralize(count, word) {
+ return count === 1 ? word : `${word}s`;
+ },
+
+ store(namespace, data) {
+ if (typeof window !== 'undefined') {
+ if (data) {
+ return localStorage.setItem(namespace, JSON.stringify(data));
+ }
+
+ const store = localStorage.getItem(namespace);
+ return (store && JSON.parse(store)) || [];
+ }
+ return [];
+ },
+};
diff --git a/webpack.config.js b/webpack.config.js
new file mode 100644
index 0000000..34e79db
--- /dev/null
+++ b/webpack.config.js
@@ -0,0 +1,52 @@
+const path = require('path');
+
+const entry = './src/entry.jsx';
+const outputPath = path.resolve('./dist');
+const publicPath = '/dist/';
+const resolve = {
+ extensions: ['.js', '.jsx'],
+};
+
+const clientConfig = {
+ entry,
+ output: {
+ path: outputPath,
+ filename: 'index.bundle.js',
+ publicPath,
+ },
+ module: {
+ loaders: [{
+ loader: 'babel-loader',
+ include: [path.resolve('./src')],
+ options: {
+ plugins: ['dynamic-import-webpack'],
+ },
+ }],
+ },
+ resolve,
+};
+
+const serverConfig = {
+ entry,
+ output: {
+ path: outputPath,
+ filename: 'index.server.bundle.js',
+ libraryTarget: 'commonjs2',
+ publicPath,
+ },
+ module: {
+ loaders: [{
+ loader: 'babel-loader',
+ include: [path.resolve('./src')],
+ options: {
+ plugins: ['dynamic-import-node'],
+ },
+ }],
+ },
+ resolve,
+};
+
+module.exports = [
+ clientConfig,
+ serverConfig,
+];